wsl-chrome-bridge 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,206 +1,150 @@
1
1
  import { appendFileSync, createReadStream, createWriteStream, fstatSync, lstatSync, mkdirSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { once } from "node:events";
3
3
  import { createServer } from "node:http";
4
- import { dirname } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { WebSocket, WebSocketServer } from "ws";
7
8
  import { buildWindowsChromeArgs, parseBrowserPathFromWsUrl, planBridgeLaunch } from "./bridge-options.js";
8
9
  import { resolveChromeCommand } from "./chrome-command.js";
9
10
  import { createPowerShellContext, destroyPowerShellContext, runPowerShellFile, writePowerShellScript } from "./powershell.js";
11
+ import { getBridgePowerShellScripts } from "./bridge-powershell-scripts.js";
10
12
  const POLL_INTERVAL_MS = 400;
11
13
  const CHROME_READY_TIMEOUT_MS = 30_000;
12
- const LAUNCH_CHROME_PS = `
13
- param(
14
- [string]$ChromePath,
15
- [Parameter(ValueFromRemainingArguments = $true)]
16
- [string[]]$ChromeArgs
17
- )
18
- $ErrorActionPreference = 'Stop'
19
- $ChromeArgs = $ChromeArgs | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_) }
20
- $userDataArg = $ChromeArgs | Where-Object { $_ -like "--user-data-dir=*" } | Select-Object -First 1
21
- if ($null -ne $userDataArg) {
22
- $userDataDir = $userDataArg.Substring(16)
23
- $escaped = [Regex]::Escape($userDataDir)
24
- $existing = Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object {
25
- $null -ne $_.CommandLine -and $_.CommandLine -match ("--user-data-dir(=|\\s+)" + $escaped)
26
- }
27
- foreach ($proc in $existing) {
28
- Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
29
- }
14
+ const LARGE_CDP_STRING_BYTES = 16 * 1024;
15
+ const MAX_TRACKED_REQUEST_METHODS = 100;
16
+ const INTERNAL_CDP_REQUEST_ID_START = 900_000_000;
17
+ const DISCONNECT_EVENT_BUFFER_LIMIT = 10;
18
+ const DISCONNECT_EVENT_TIME_WINDOW_MS = 2_000;
19
+ const IMPORTANT_CDP_METHODS = new Set([
20
+ // Target/session lifecycle (most common failure points for attach/detach/disconnect)
21
+ "Target.setDiscoverTargets",
22
+ "Target.getTargets",
23
+ "Target.getTargetInfo",
24
+ "Target.getDevToolsTarget",
25
+ "Target.setAutoAttach",
26
+ "Target.autoAttachRelated",
27
+ "Target.attachToTarget",
28
+ "Target.attachedToTarget",
29
+ "Target.detachFromTarget",
30
+ "Target.detachedFromTarget",
31
+ "Target.targetCreated",
32
+ "Target.targetInfoChanged",
33
+ "Target.targetDestroyed",
34
+ "Target.activateTarget",
35
+ "Target.createTarget",
36
+ "Target.closeTarget",
37
+ // Browser/page open-close lifecycle
38
+ "Browser.getVersion",
39
+ "Browser.close",
40
+ "Page.navigate",
41
+ "Page.frameStartedLoading",
42
+ // Disconnection/error signals
43
+ "Inspector.detached",
44
+ "Runtime.exceptionThrown",
45
+ "Network.loadingFailed"
46
+ ]);
47
+ const IMPORTANT_EXCLUDED_METHODS = new Set([
48
+ // Common benign error during early-frame phases; noisy but usually not a disconnect root cause.
49
+ "Storage.getStorageKey"
50
+ ]);
51
+ function sleep(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
53
  }
31
- $proc = Start-Process -FilePath $ChromePath -ArgumentList $ChromeArgs -PassThru
32
- Write-Output $proc.Id
33
- `;
34
- const GET_VERSION_PS = `
35
- param([int]$Port)
36
- $ErrorActionPreference = 'Stop'
37
- $uri = "http://127.0.0.1:$Port/json/version"
38
- try {
39
- $json = Invoke-RestMethod -UseBasicParsing -Uri $uri -TimeoutSec 2
40
- if ($null -eq $json) { exit 1 }
41
- $raw = $json | ConvertTo-Json -Compress -Depth 12
42
- Write-Output $raw
43
- exit 0
44
- } catch {
45
- exit 1
54
+ const WEAK_DISCONNECT_SIGNALS = new Set([
55
+ "Inspector.detached",
56
+ "Target.detachedFromTarget"
57
+ ]);
58
+ const STRONG_DISCONNECT_SIGNALS = new Set([
59
+ "ConnectionClosedPrematurely",
60
+ "READER_CLOSE"
61
+ ]);
62
+ function isWeakDisconnectSignal(signal) {
63
+ return signal === "Inspector.detached" || signal === "Target.detachedFromTarget";
46
64
  }
47
- `;
48
- const RESOLVE_PORT_PS = `
49
- param(
50
- [int]$RequestedPort,
51
- [string]$Mode
52
- )
53
- $ErrorActionPreference = 'Stop'
54
-
55
- function Test-PortAvailable {
56
- param([int]$Port)
57
- try {
58
- $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
59
- $listener.Start()
60
- $listener.Stop()
61
- return $true
62
- } catch {
63
- return $false
64
- }
65
+ function isStrongDisconnectSignal(signal) {
66
+ return signal === "ConnectionClosedPrematurely" || signal === "READER_CLOSE";
65
67
  }
66
-
67
- function Get-RandomFreePort {
68
- $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
69
- $listener.Start()
70
- $allocated = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
71
- $listener.Stop()
72
- return $allocated
68
+ function cdpKindLabel(kind) {
69
+ switch (kind) {
70
+ case "request":
71
+ return "Request";
72
+ case "response":
73
+ return "Response";
74
+ case "event":
75
+ return "Event";
76
+ case "invalid-json":
77
+ return "InvalidJson";
78
+ case "unknown":
79
+ return "Unknown";
80
+ }
73
81
  }
74
-
75
- if ($Mode -eq "fixed") {
76
- if (-not (Test-PortAvailable -Port $RequestedPort)) {
77
- Write-Error "Port $RequestedPort is already in use on Windows."
78
- exit 2
79
- }
80
- Write-Output $RequestedPort
81
- exit 0
82
+ function asObject(value) {
83
+ if (value && typeof value === "object" && !Array.isArray(value)) {
84
+ return value;
85
+ }
86
+ return null;
82
87
  }
83
-
84
- if ($Mode -eq "random") {
85
- if ($RequestedPort -gt 0 -and (Test-PortAvailable -Port $RequestedPort)) {
86
- Write-Output $RequestedPort
87
- exit 0
88
- }
89
-
90
- for ($i = 0; $i -lt 50; $i++) {
91
- $candidate = Get-RandomFreePort
92
- if (Test-PortAvailable -Port $candidate) {
93
- Write-Output $candidate
94
- exit 0
88
+ function pickFirstString(...values) {
89
+ for (const value of values) {
90
+ if (typeof value === "string" && value.length > 0) {
91
+ return value;
92
+ }
95
93
  }
96
- }
97
-
98
- Write-Error "Failed to allocate an available Windows debug port."
99
- exit 3
94
+ return null;
100
95
  }
101
-
102
- Write-Error "Unsupported mode: $Mode"
103
- exit 4
104
- `;
105
- const STOP_PROCESS_PS = `
106
- param([int]$Pid)
107
- $ErrorActionPreference = 'SilentlyContinue'
108
- Stop-Process -Id $Pid -Force
109
- `;
110
- const RELAY_CSHARP = String.raw `
111
- using System;
112
- using System.IO;
113
- using System.Net.WebSockets;
114
- using System.Text;
115
- using System.Threading;
116
- using System.Threading.Tasks;
117
-
118
- public class CDPRelay
119
- {
120
- public static void Run(string wsUrl)
121
- {
122
- var ws = new ClientWebSocket();
123
- ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
124
- var cts = new CancellationTokenSource();
125
- var token = cts.Token;
126
-
127
- try
128
- {
129
- ws.ConnectAsync(new Uri(wsUrl), token).GetAwaiter().GetResult();
130
- Console.Error.WriteLine("CONNECTED");
131
- Console.Error.Flush();
132
-
133
- var reader = Task.Run(() =>
134
- {
135
- var buf = new byte[4 * 1024 * 1024];
136
- try
137
- {
138
- while (ws.State == WebSocketState.Open && !token.IsCancellationRequested)
139
- {
140
- var sb = new StringBuilder();
141
- WebSocketReceiveResult recv;
142
- do
143
- {
144
- var seg = new ArraySegment<byte>(buf);
145
- recv = ws.ReceiveAsync(seg, token).GetAwaiter().GetResult();
146
- if (recv.MessageType == WebSocketMessageType.Close) return;
147
- sb.Append(Encoding.UTF8.GetString(buf, 0, recv.Count));
148
- } while (!recv.EndOfMessage);
149
-
150
- Console.Out.WriteLine(sb.ToString());
151
- Console.Out.Flush();
152
- }
153
- }
154
- catch (OperationCanceledException) { }
155
- catch (Exception ex)
156
- {
157
- Console.Error.WriteLine("READER_ERROR:" + ex.Message);
158
- Console.Error.Flush();
159
- }
160
- }, token);
161
-
162
- string line;
163
- while ((line = Console.In.ReadLine()) != null)
164
- {
165
- if (ws.State != WebSocketState.Open) break;
166
- var bytes = Encoding.UTF8.GetBytes(line);
167
- var seg = new ArraySegment<byte>(bytes);
168
- ws.SendAsync(seg, WebSocketMessageType.Text, true, token).GetAwaiter().GetResult();
169
- }
170
-
171
- cts.Cancel();
172
- reader.Wait(TimeSpan.FromSeconds(2));
96
+ function parseRelayMessage(payload) {
97
+ try {
98
+ const parsed = JSON.parse(payload);
99
+ const id = typeof parsed.id === "number" || typeof parsed.id === "string" ? parsed.id : null;
100
+ const method = typeof parsed.method === "string" ? parsed.method : null;
101
+ const hasResult = Object.prototype.hasOwnProperty.call(parsed, "result");
102
+ const hasError = Object.prototype.hasOwnProperty.call(parsed, "error");
103
+ const paramsObj = asObject(parsed.params);
104
+ const resultObj = asObject(parsed.result);
105
+ const paramsTargetInfoObj = asObject(paramsObj?.targetInfo);
106
+ const resultTargetInfoObj = asObject(resultObj?.targetInfo);
107
+ const targetId = pickFirstString(parsed.targetId, paramsObj?.targetId, paramsTargetInfoObj?.targetId, resultObj?.targetId, resultTargetInfoObj?.targetId);
108
+ const browserContextId = pickFirstString(parsed.browserContextId, paramsObj?.browserContextId, paramsTargetInfoObj?.browserContextId, resultObj?.browserContextId, resultTargetInfoObj?.browserContextId);
109
+ const sessionId = pickFirstString(parsed.sessionId, paramsObj?.sessionId, resultObj?.sessionId);
110
+ if (method && id !== null) {
111
+ return { kind: "request", id, method, hasError: false, targetId, browserContextId, sessionId };
173
112
  }
174
- catch (Exception ex)
175
- {
176
- Console.Error.WriteLine("FATAL:" + ex.Message);
177
- Console.Error.Flush();
113
+ if (id !== null && (hasResult || hasError || !method)) {
114
+ return { kind: "response", id, method, hasError, targetId, browserContextId, sessionId };
178
115
  }
179
- finally
180
- {
181
- if (ws.State == WebSocketState.Open)
182
- {
183
- try
184
- {
185
- ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None).GetAwaiter().GetResult();
186
- }
187
- catch { }
188
- }
189
- ws.Dispose();
116
+ if (method) {
117
+ return { kind: "event", id: null, method, hasError: false, targetId, browserContextId, sessionId };
190
118
  }
119
+ return { kind: "unknown", id, method, hasError, targetId, browserContextId, sessionId };
120
+ }
121
+ catch {
122
+ return {
123
+ kind: "invalid-json",
124
+ id: null,
125
+ method: null,
126
+ hasError: false,
127
+ targetId: null,
128
+ browserContextId: null,
129
+ sessionId: null
130
+ };
191
131
  }
192
132
  }
193
- `;
194
- const RELAY_PS = `
195
- param([string]$WsUrl)
196
- $ErrorActionPreference = 'Stop'
197
- Add-Type -TypeDefinition @'
198
- ${RELAY_CSHARP}
199
- '@
200
- [CDPRelay]::Run($WsUrl)
201
- `;
202
- function sleep(ms) {
203
- return new Promise((resolve) => setTimeout(resolve, ms));
133
+ function toSafeRawTimestamp(iso) {
134
+ return iso.replaceAll(":", "-").replaceAll(".", "-");
135
+ }
136
+ function normalizeDebugLevel(raw) {
137
+ const value = raw?.trim().toLowerCase();
138
+ if (!value || value === "important" || value === "important-only") {
139
+ return "important";
140
+ }
141
+ if (value === "all" || value === "full") {
142
+ return "all";
143
+ }
144
+ return "important";
145
+ }
146
+ function requestKey(id, sessionId) {
147
+ return `${sessionId ?? "root"}::${String(id)}`;
204
148
  }
205
149
  function detectUpstreamHint(chromeArgs) {
206
150
  const hasPipeMode = chromeArgs.includes("--remote-debugging-pipe");
@@ -220,12 +164,22 @@ function collectBridgeEnvSnapshot(env) {
220
164
  return {
221
165
  WSL_CHROME_BRIDGE_DEBUG: env.WSL_CHROME_BRIDGE_DEBUG ?? null,
222
166
  WSL_CHROME_BRIDGE_DEBUG_FILE: env.WSL_CHROME_BRIDGE_DEBUG_FILE ?? null,
167
+ WSL_CHROME_BRIDGE_DEBUG_LEVEL: env.WSL_CHROME_BRIDGE_DEBUG_LEVEL ?? null,
168
+ WSL_CHROME_BRIDGE_DEBUG_RAW_DIR: env.WSL_CHROME_BRIDGE_DEBUG_RAW_DIR ?? null,
223
169
  WSL_CHROME_BRIDGE_REMOTE_DEBUG_PORT: env.WSL_CHROME_BRIDGE_REMOTE_DEBUG_PORT ?? null,
224
170
  WSL_CHROME_BRIDGE_EXECUTABLE_PATH: env.WSL_CHROME_BRIDGE_EXECUTABLE_PATH ?? null,
225
171
  WSL_CHROME_BRIDGE_USER_DATA_DIR: env.WSL_CHROME_BRIDGE_USER_DATA_DIR ?? null,
226
172
  DISPLAY: env.DISPLAY ?? null
227
173
  };
228
174
  }
175
+ function hasRequestedHeadlessFlag(args) {
176
+ for (const arg of args) {
177
+ if (arg === "--headless" || arg.startsWith("--headless=")) {
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
229
183
  function cleanupEmptyLocalUserDataDirArtifact(userDataDir, writeDebug) {
230
184
  if (!userDataDir) {
231
185
  return;
@@ -270,6 +224,104 @@ function hasPipeFds() {
270
224
  return false;
271
225
  }
272
226
  }
227
+ function parseExistingChromeMatch(raw) {
228
+ try {
229
+ const value = JSON.parse(raw.trim());
230
+ if (value &&
231
+ Object.prototype.hasOwnProperty.call(value, "found") &&
232
+ value.found === false) {
233
+ return null;
234
+ }
235
+ const port = Number.parseInt(String(value.port), 10);
236
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
237
+ return null;
238
+ }
239
+ const pidRaw = value.pid;
240
+ const pid = typeof pidRaw === "number" && Number.isFinite(pidRaw)
241
+ ? Math.trunc(pidRaw)
242
+ : Number.parseInt(String(pidRaw), 10);
243
+ const headless = value.headless === true;
244
+ return {
245
+ port,
246
+ pid: Number.isFinite(pid) ? pid : null,
247
+ headless
248
+ };
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ function parseRelayField(line, field) {
255
+ const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
256
+ const pattern = new RegExp(`(?:^|;)${escapedField}=([^;]*)`);
257
+ const match = line.match(pattern);
258
+ if (!match) {
259
+ return null;
260
+ }
261
+ return match[1] ?? null;
262
+ }
263
+ function detectStrongDisconnectSignalFromRelayLog(line) {
264
+ if (line.startsWith("READER_CLOSE:")) {
265
+ return "READER_CLOSE";
266
+ }
267
+ if (!line.startsWith("READER_ERROR:")) {
268
+ return null;
269
+ }
270
+ const payload = line.slice("READER_ERROR:".length);
271
+ const webSocketErrorCode = parseRelayField(payload, "webSocketErrorCode");
272
+ if (webSocketErrorCode === "ConnectionClosedPrematurely") {
273
+ return "ConnectionClosedPrematurely";
274
+ }
275
+ return null;
276
+ }
277
+ function detectWeakDisconnectSignalFromCdp(rendered) {
278
+ if (rendered.parsed.kind !== "event" || !rendered.canonicalMethod) {
279
+ return null;
280
+ }
281
+ if (rendered.canonicalMethod === "Inspector.detached") {
282
+ return "Inspector.detached";
283
+ }
284
+ if (rendered.canonicalMethod === "Target.detachedFromTarget") {
285
+ return "Target.detachedFromTarget";
286
+ }
287
+ return null;
288
+ }
289
+ function assessDisconnectSignals(signals, recentWeakEvents, disconnectAtMs, windowMs) {
290
+ let weakSignalSeen = false;
291
+ let strongSignalSeen = false;
292
+ for (const signal of signals) {
293
+ if (isWeakDisconnectSignal(signal) && WEAK_DISCONNECT_SIGNALS.has(signal)) {
294
+ weakSignalSeen = true;
295
+ }
296
+ if (isStrongDisconnectSignal(signal) && STRONG_DISCONNECT_SIGNALS.has(signal)) {
297
+ strongSignalSeen = true;
298
+ }
299
+ }
300
+ // We only treat weak disconnect events as actionable when they happen
301
+ // immediately before websocket interruption. This avoids stale-detach false positives.
302
+ const nearbyWeakEvents = [];
303
+ for (const event of recentWeakEvents) {
304
+ const deltaMs = disconnectAtMs - event.observedAtMs;
305
+ if (deltaMs < 0 || deltaMs > windowMs) {
306
+ continue;
307
+ }
308
+ nearbyWeakEvents.push({
309
+ signal: event.signal,
310
+ deltaMs,
311
+ rawJson: event.rawJson
312
+ });
313
+ }
314
+ const weakSignalWithinWindow = nearbyWeakEvents.length > 0;
315
+ return {
316
+ weakSignalSeen,
317
+ strongSignalSeen,
318
+ weakSignalWithinWindow,
319
+ chromeDisconnectedLikely: weakSignalWithinWindow && strongSignalSeen,
320
+ summary: Array.from(signals).join(",") || "none",
321
+ nearbyWeakEventCount: nearbyWeakEvents.length,
322
+ nearbyWeakEvents
323
+ };
324
+ }
273
325
  async function startLocalDebugProxy(options) {
274
326
  const clients = new Set();
275
327
  const wsServer = new WebSocketServer({ noServer: true });
@@ -354,7 +406,14 @@ export function createBridgeRunner() {
354
406
  const env = process.env;
355
407
  const debugFileFromEnv = env.WSL_CHROME_BRIDGE_DEBUG_FILE?.trim() || null;
356
408
  let activeDebugFile = debugFileFromEnv;
409
+ let activeRawDebugDir = env.WSL_CHROME_BRIDGE_DEBUG_RAW_DIR?.trim() || null;
410
+ const debugLevel = normalizeDebugLevel(env.WSL_CHROME_BRIDGE_DEBUG_LEVEL);
357
411
  const debug = env.WSL_CHROME_BRIDGE_DEBUG === "1" || Boolean(activeDebugFile);
412
+ const requestMethodById = new Map();
413
+ let internalCdpRequestId = INTERNAL_CDP_REQUEST_ID_START;
414
+ const pendingInternalCdpRequests = new Map();
415
+ let lastRawTimestamp = "";
416
+ let sameTimestampSequence = 0;
358
417
  const writeDebug = (message) => {
359
418
  if (!debug) {
360
419
  return;
@@ -371,6 +430,145 @@ export function createBridgeRunner() {
371
430
  }
372
431
  }
373
432
  };
433
+ const writeRawPayload = (payload) => {
434
+ if (!activeRawDebugDir) {
435
+ return null;
436
+ }
437
+ try {
438
+ mkdirSync(activeRawDebugDir, { recursive: true });
439
+ const now = new Date();
440
+ const safeTimestamp = toSafeRawTimestamp(now.toISOString());
441
+ if (safeTimestamp === lastRawTimestamp) {
442
+ sameTimestampSequence += 1;
443
+ }
444
+ else {
445
+ lastRawTimestamp = safeTimestamp;
446
+ sameTimestampSequence = 0;
447
+ }
448
+ const suffix = sameTimestampSequence === 0 ? "" : `-${String(sameTimestampSequence).padStart(4, "0")}`;
449
+ const fileName = `raw-${safeTimestamp}${suffix}.log`;
450
+ const path = join(activeRawDebugDir, fileName);
451
+ writeFileSync(path, payload, "utf8");
452
+ return path;
453
+ }
454
+ catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ writeDebug(`rawPayloadWriteFailed dir=${activeRawDebugDir} error=${message}`);
457
+ return null;
458
+ }
459
+ };
460
+ const writeDebugBlock = (header, jsonPayload) => {
461
+ if (!debug) {
462
+ return;
463
+ }
464
+ const timestamp = new Date().toISOString();
465
+ const body = typeof jsonPayload === "string" ? jsonPayload : JSON.stringify(jsonPayload);
466
+ const block = `[${timestamp}] ${header}\n${body}\n\n`;
467
+ process.stderr.write(`[wsl-chrome-bridge][debug] ${header}\n${body}\n`);
468
+ if (activeDebugFile) {
469
+ try {
470
+ mkdirSync(dirname(activeDebugFile), { recursive: true });
471
+ appendFileSync(activeDebugFile, block, "utf8");
472
+ }
473
+ catch {
474
+ // keep running even if debug file write fails
475
+ }
476
+ }
477
+ };
478
+ const replaceLargeStringFields = (value, path) => {
479
+ if (typeof value === "string") {
480
+ if (Buffer.byteLength(value, "utf8") < LARGE_CDP_STRING_BYTES) {
481
+ return value;
482
+ }
483
+ const rawPath = writeRawPayload(value);
484
+ if (!rawPath) {
485
+ return value;
486
+ }
487
+ return {
488
+ __rawDataPath: rawPath,
489
+ __rawDataBytes: Buffer.byteLength(value, "utf8"),
490
+ __replacedField: path
491
+ };
492
+ }
493
+ if (Array.isArray(value)) {
494
+ return value.map((item, index) => replaceLargeStringFields(item, `${path}[${index}]`));
495
+ }
496
+ if (value && typeof value === "object") {
497
+ const record = value;
498
+ const replaced = {};
499
+ for (const [key, child] of Object.entries(record)) {
500
+ replaced[key] = replaceLargeStringFields(child, `${path}.${key}`);
501
+ }
502
+ return replaced;
503
+ }
504
+ return value;
505
+ };
506
+ const hopLabel = (hop) => {
507
+ switch (hop) {
508
+ case "upstream=>relay":
509
+ return "upstream -> relay";
510
+ case "relay=>chrome":
511
+ return "relay -> chrome";
512
+ case "chrome=>relay":
513
+ return "chrome -> relay";
514
+ case "relay=>upstream":
515
+ return "relay -> upstream";
516
+ }
517
+ };
518
+ const renderCdpMessage = (payload) => {
519
+ const parsed = parseRelayMessage(payload);
520
+ let canonicalMethod = parsed.method;
521
+ if (parsed.kind === "request" && parsed.id !== null && parsed.method) {
522
+ if (requestMethodById.size >= MAX_TRACKED_REQUEST_METHODS) {
523
+ const oldest = requestMethodById.keys().next().value;
524
+ if (oldest) {
525
+ requestMethodById.delete(oldest);
526
+ }
527
+ }
528
+ requestMethodById.set(requestKey(parsed.id, parsed.sessionId), parsed.method);
529
+ }
530
+ if (!canonicalMethod && parsed.kind === "response" && parsed.id !== null) {
531
+ const key = requestKey(parsed.id, parsed.sessionId);
532
+ canonicalMethod = requestMethodById.get(key) ?? null;
533
+ requestMethodById.delete(key);
534
+ }
535
+ const title = canonicalMethod ??
536
+ (parsed.kind === "response" && parsed.id !== null
537
+ ? `id=${parsed.id}`
538
+ : parsed.kind === "invalid-json"
539
+ ? "InvalidJSON"
540
+ : "UnknownMessage");
541
+ try {
542
+ const json = JSON.parse(payload);
543
+ const replaced = replaceLargeStringFields(json, "$");
544
+ if (replaced && typeof replaced === "object" && !Array.isArray(replaced)) {
545
+ const asRecord = { ...replaced };
546
+ delete asRecord.method;
547
+ return { title, payload: asRecord, parsed, canonicalMethod };
548
+ }
549
+ return { title, payload: replaced, parsed, canonicalMethod };
550
+ }
551
+ catch {
552
+ return { title, payload, parsed, canonicalMethod };
553
+ }
554
+ };
555
+ const isImportantRenderedMessage = (rendered) => {
556
+ const method = rendered.canonicalMethod;
557
+ if (method && IMPORTANT_EXCLUDED_METHODS.has(method)) {
558
+ return false;
559
+ }
560
+ if (rendered.parsed.hasError) {
561
+ return true;
562
+ }
563
+ return method !== null && IMPORTANT_CDP_METHODS.has(method);
564
+ };
565
+ const writeCdpHopLog = (hop, rendered) => {
566
+ if (debugLevel === "important" && !isImportantRenderedMessage(rendered)) {
567
+ return;
568
+ }
569
+ const kind = cdpKindLabel(rendered.parsed.kind);
570
+ writeDebugBlock(`CDP(${hopLabel(hop)}) ${kind} ${rendered.title}`, rendered.payload);
571
+ };
374
572
  if (activeDebugFile) {
375
573
  try {
376
574
  mkdirSync(dirname(activeDebugFile), { recursive: true });
@@ -404,9 +602,28 @@ export function createBridgeRunner() {
404
602
  process.stderr.write(`[wsl-chrome-bridge] failed to create debug file: ${activeDebugFile}\n`);
405
603
  }
406
604
  }
605
+ if (!activeRawDebugDir && plan.bridgeDebugRawDir) {
606
+ activeRawDebugDir = plan.bridgeDebugRawDir;
607
+ }
608
+ if (activeRawDebugDir) {
609
+ try {
610
+ mkdirSync(activeRawDebugDir, { recursive: true });
611
+ writeDebug(`rawDebugDir enabled path=${activeRawDebugDir}`);
612
+ }
613
+ catch {
614
+ process.stderr.write(`[wsl-chrome-bridge] failed to create raw debug dir: ${activeRawDebugDir}\n`);
615
+ activeRawDebugDir = null;
616
+ }
617
+ }
618
+ else {
619
+ writeDebug("rawDebugDir disabled");
620
+ }
621
+ writeDebug(`debugLevel=${debugLevel}`);
407
622
  writeDebug(`argv=${JSON.stringify(chromeArgs)}`);
623
+ const requestedHeadlessMode = hasRequestedHeadlessFlag(plan.passthroughArgs);
408
624
  writeDebug(`launchPlan=${JSON.stringify({
409
625
  bridgeDebugFile: plan.bridgeDebugFile,
626
+ bridgeDebugRawDir: plan.bridgeDebugRawDir,
410
627
  bridgeChromeExecutablePath: plan.bridgeChromeExecutablePath,
411
628
  usePipeTransport: plan.usePipeTransport,
412
629
  userDataDir: plan.userDataDir,
@@ -416,6 +633,7 @@ export function createBridgeRunner() {
416
633
  localProxyPort: plan.localProxyPort,
417
634
  windowsDebugPort: plan.windowsDebugPort,
418
635
  windowsDebugPortSource: plan.windowsDebugPortSource,
636
+ requestedHeadlessMode,
419
637
  passthroughArgs: plan.passthroughArgs
420
638
  })}`);
421
639
  cleanupEmptyLocalUserDataDirArtifact(plan.userDataDir, writeDebug);
@@ -428,127 +646,189 @@ export function createBridgeRunner() {
428
646
  };
429
647
  const powerShell = createPowerShellContext(env);
430
648
  writeDebug(`powershellPath=${powerShell.powershellPath}`);
431
- const launchScript = writePowerShellScript(powerShell, "launch-chrome.ps1", LAUNCH_CHROME_PS);
432
- const getVersionScript = writePowerShellScript(powerShell, "get-version.ps1", GET_VERSION_PS);
433
- const resolvePortScript = writePowerShellScript(powerShell, "resolve-port.ps1", RESOLVE_PORT_PS);
434
- const stopProcessScript = writePowerShellScript(powerShell, "stop-process.ps1", STOP_PROCESS_PS);
435
- const relayScript = writePowerShellScript(powerShell, "relay.ps1", RELAY_PS);
436
- writeDebug(`scripts={launch:${launchScript.windowsPath},version:${getVersionScript.windowsPath},resolvePort:${resolvePortScript.windowsPath},stop:${stopProcessScript.windowsPath},relay:${relayScript.windowsPath}}`);
437
- const resolvePortMode = plan.windowsDebugPortSource === "auto-random" ? "random" : "fixed";
438
- const resolvePortResult = await runPowerShellFile(powerShell, resolvePortScript.windowsPath, [String(plan.windowsDebugPort), resolvePortMode], { timeoutMs: 8_000 });
439
- if (resolvePortResult.code !== 0) {
440
- writeDebug(`resolvePortFailed mode=${resolvePortMode} requested=${plan.windowsDebugPort} code=${resolvePortResult.code} stdout=${resolvePortResult.stdout.trim()} stderr=${resolvePortResult.stderr.trim()}`);
441
- process.stderr.write("[wsl-chrome-bridge] failed to resolve an available Windows debug port: " +
442
- `${resolvePortResult.stderr || resolvePortResult.stdout}\n`);
443
- destroyPowerShellContext(powerShell);
444
- cleanupUserDataDirOnExit("resolvePortFailed");
445
- return 1;
446
- }
447
- const resolvedWindowsDebugPort = Number.parseInt(resolvePortResult.stdout.trim(), 10);
448
- if (!Number.isFinite(resolvedWindowsDebugPort)) {
449
- writeDebug(`resolvePortParseFailed raw=${resolvePortResult.stdout.trim()} stderr=${resolvePortResult.stderr.trim()}`);
450
- process.stderr.write("[wsl-chrome-bridge] failed to parse resolved Windows debug port from PowerShell output.\n");
451
- destroyPowerShellContext(powerShell);
452
- cleanupUserDataDirOnExit("resolvePortParseFailed");
453
- return 1;
454
- }
455
- writeDebug(`resolvedWindowsDebugPort=${resolvedWindowsDebugPort} source=${plan.windowsDebugPortSource} mode=${resolvePortMode}`);
456
- const planWithResolvedPort = {
457
- ...plan,
458
- windowsDebugPort: resolvedWindowsDebugPort
459
- };
460
- const chromePath = resolveChromeCommand({
461
- env,
462
- bridgeChromeExecutablePath: plan.bridgeChromeExecutablePath
463
- });
464
- const windowsArgs = buildWindowsChromeArgs(planWithResolvedPort, env);
465
- writeDebug(`chromePath=${chromePath}`);
466
- writeDebug(`windowsArgs=${JSON.stringify(windowsArgs)}`);
467
- const launchResult = await runPowerShellFile(powerShell, launchScript.windowsPath, [chromePath, ...windowsArgs], { timeoutMs: 15_000 });
468
- if (launchResult.code !== 0) {
469
- writeDebug(`launchFailed code=${launchResult.code} stdout=${launchResult.stdout} stderr=${launchResult.stderr}`);
470
- process.stderr.write(`[wsl-chrome-bridge] failed to launch Windows Chrome: ${launchResult.stderr || launchResult.stdout}\n`);
649
+ const scripts = getBridgePowerShellScripts();
650
+ const launchScript = writePowerShellScript(powerShell, "launch-chrome.ps1", scripts.launchChrome);
651
+ const findExistingChromeScript = writePowerShellScript(powerShell, "find-existing-chrome.ps1", scripts.findExistingChrome);
652
+ const getVersionScript = writePowerShellScript(powerShell, "get-version.ps1", scripts.getVersion);
653
+ const resolvePortScript = writePowerShellScript(powerShell, "resolve-port.ps1", scripts.resolvePort);
654
+ const stopChromeScript = writePowerShellScript(powerShell, "stop-chrome-by-profile-port.ps1", scripts.stopChromeByProfilePort);
655
+ const relayScript = writePowerShellScript(powerShell, "relay.ps1", scripts.relay);
656
+ writeDebug(`scripts={launch:${launchScript.windowsPath},findExisting:${findExistingChromeScript.windowsPath},version:${getVersionScript.windowsPath},resolvePort:${resolvePortScript.windowsPath},stopChrome:${stopChromeScript.windowsPath},relay:${relayScript.windowsPath}}`);
657
+ const usePipeTransport = plan.usePipeTransport && hasPipeFds();
658
+ const useLocalProxyTransport = plan.localProxyPort !== null;
659
+ writeDebug(`transportMode requestedPipe=${plan.usePipeTransport} pipe=${usePipeTransport} localProxy=${useLocalProxyTransport} localProxyPort=${plan.localProxyPort ?? "none"}`);
660
+ if (plan.usePipeTransport && !usePipeTransport) {
661
+ process.stderr.write("[wsl-chrome-bridge] --remote-debugging-pipe was requested but OS pipe fds (3/4) are missing.\n");
471
662
  destroyPowerShellContext(powerShell);
472
- cleanupUserDataDirOnExit("launchFailed");
663
+ cleanupUserDataDirOnExit("pipeFdsMissing");
473
664
  return 1;
474
665
  }
475
- writeDebug(`launchResult code=${launchResult.code} stdout=${launchResult.stdout.trim()} stderr=${launchResult.stderr.trim()}`);
476
- const chromePid = Number.parseInt(launchResult.stdout.trim(), 10);
477
- if (!Number.isFinite(chromePid)) {
478
- writeDebug(`invalidChromePid output=${launchResult.stdout}`);
479
- process.stderr.write(`[wsl-chrome-bridge] failed to parse Chrome PID from PowerShell output: ${launchResult.stdout}\n`);
666
+ if (!usePipeTransport && !useLocalProxyTransport) {
667
+ process.stderr.write("[wsl-chrome-bridge] missing transport channel. " +
668
+ "Provide --remote-debugging-port (Playwright mode) or launch with OS pipes fd3/fd4.\n");
480
669
  destroyPowerShellContext(powerShell);
481
- cleanupUserDataDirOnExit("invalidChromePid");
670
+ cleanupUserDataDirOnExit("missingTransportChannel");
482
671
  return 1;
483
672
  }
484
- let remoteVersion = null;
485
- const startAt = Date.now();
486
- while (Date.now() - startAt < CHROME_READY_TIMEOUT_MS) {
487
- const result = await runPowerShellFile(powerShell, getVersionScript.windowsPath, [String(planWithResolvedPort.windowsDebugPort)], { timeoutMs: 4_000 });
488
- if (result.code === 0 && result.stdout.trim()) {
489
- try {
490
- remoteVersion = JSON.parse(result.stdout.trim());
491
- writeDebug(`remoteVersion=${JSON.stringify(remoteVersion)}`);
492
- break;
673
+ const waitForRemoteVersion = async (windowsDebugPort, phase) => {
674
+ const startAt = Date.now();
675
+ while (Date.now() - startAt < CHROME_READY_TIMEOUT_MS) {
676
+ const result = await runPowerShellFile(powerShell, getVersionScript.windowsPath, [String(windowsDebugPort)], { timeoutMs: 4_000 });
677
+ if (result.code === 0 && result.stdout.trim()) {
678
+ try {
679
+ const version = JSON.parse(result.stdout.trim());
680
+ if (typeof version.webSocketDebuggerUrl === "string") {
681
+ writeDebug(`remoteVersion phase=${phase} port=${windowsDebugPort} payload=${JSON.stringify(version)}`);
682
+ return version;
683
+ }
684
+ writeDebug(`remoteVersionMissingWs phase=${phase} port=${windowsDebugPort} payload=${result.stdout.trim()}`);
685
+ }
686
+ catch {
687
+ writeDebug(`remoteVersionParseFailed phase=${phase} port=${windowsDebugPort} raw=${result.stdout.trim()}`);
688
+ }
493
689
  }
494
- catch {
495
- writeDebug(`remoteVersionParseFailed raw=${result.stdout.trim()}`);
496
- // ignore malformed JSON and retry
690
+ else if (debug) {
691
+ writeDebug(`waitVersion retry phase=${phase} port=${windowsDebugPort} code=${result.code} stdout=${result.stdout.trim()} stderr=${result.stderr.trim()}`);
497
692
  }
693
+ await sleep(POLL_INTERVAL_MS);
498
694
  }
499
- else if (debug) {
500
- writeDebug(`waitVersion retry code=${result.code} stdout=${result.stdout.trim()} stderr=${result.stderr.trim()}`);
695
+ return null;
696
+ };
697
+ const tryReuseExistingChromeSession = async (phase) => {
698
+ if (!plan.windowsUserDataDir) {
699
+ writeDebug(`existingChrome skip phase=${phase} reason=no-windows-user-data-dir`);
700
+ return null;
501
701
  }
502
- await sleep(POLL_INTERVAL_MS);
503
- }
504
- if (!remoteVersion || typeof remoteVersion.webSocketDebuggerUrl !== "string") {
505
- writeDebug("chromeDebugWsNotReady");
506
- process.stderr.write("[wsl-chrome-bridge] Chrome debug websocket was not ready in time.\n");
507
- await runPowerShellFile(powerShell, stopProcessScript.windowsPath, [String(chromePid)], {
508
- timeoutMs: 3_000
702
+ const existingResult = await runPowerShellFile(powerShell, findExistingChromeScript.windowsPath, [plan.windowsUserDataDir], { timeoutMs: 8_000 });
703
+ if (existingResult.code !== 0) {
704
+ writeDebug(`existingChrome query failed phase=${phase} code=${existingResult.code} stdout=${existingResult.stdout.trim()} stderr=${existingResult.stderr.trim()}`);
705
+ return null;
706
+ }
707
+ const existingMatch = parseExistingChromeMatch(existingResult.stdout);
708
+ if (!existingMatch) {
709
+ writeDebug(`existingChrome miss phase=${phase}`);
710
+ return null;
711
+ }
712
+ writeDebug(`existingChrome hit phase=${phase} port=${existingMatch.port} pid=${existingMatch.pid ?? "unknown"} headless=${existingMatch.headless}`);
713
+ const version = await waitForRemoteVersion(existingMatch.port, phase);
714
+ if (!version || typeof version.webSocketDebuggerUrl !== "string") {
715
+ writeDebug(`existingChrome stale phase=${phase} port=${existingMatch.port} pid=${existingMatch.pid ?? "unknown"}`);
716
+ return null;
717
+ }
718
+ if (existingMatch.headless !== requestedHeadlessMode) {
719
+ const existingMode = existingMatch.headless ? "headless" : "headed";
720
+ const requestedMode = requestedHeadlessMode ? "headless" : "headed";
721
+ throw new Error(`headless mode mismatch for shared --user-data-dir "${plan.windowsUserDataDir}". Existing Chrome mode is ${existingMode}, requested mode is ${requestedMode}. Use a different --user-data-dir per mode, or close the existing Chrome instance first.`);
722
+ }
723
+ return {
724
+ windowsDebugPort: existingMatch.port,
725
+ remoteVersion: version,
726
+ remoteBrowserWsUrl: version.webSocketDebuggerUrl,
727
+ ownership: "attached",
728
+ chromePid: null,
729
+ launchedExecutablePath: null
730
+ };
731
+ };
732
+ const launchChromeSession = async (phase) => {
733
+ const resolvePortMode = plan.windowsDebugPortSource === "auto-random" ? "random" : "fixed";
734
+ const resolvePortResult = await runPowerShellFile(powerShell, resolvePortScript.windowsPath, [String(plan.windowsDebugPort), resolvePortMode], { timeoutMs: 8_000 });
735
+ if (resolvePortResult.code !== 0) {
736
+ throw new Error(`failed to resolve an available Windows debug port: ${resolvePortResult.stderr || resolvePortResult.stdout}`);
737
+ }
738
+ const resolvedWindowsDebugPort = Number.parseInt(resolvePortResult.stdout.trim(), 10);
739
+ if (!Number.isFinite(resolvedWindowsDebugPort)) {
740
+ throw new Error("failed to parse resolved Windows debug port from PowerShell output.");
741
+ }
742
+ writeDebug(`resolvedWindowsDebugPort phase=${phase} port=${resolvedWindowsDebugPort} source=${plan.windowsDebugPortSource} mode=${resolvePortMode}`);
743
+ const planWithResolvedPort = {
744
+ ...plan,
745
+ windowsDebugPort: resolvedWindowsDebugPort
746
+ };
747
+ const chromePath = resolveChromeCommand({
748
+ env,
749
+ bridgeChromeExecutablePath: plan.bridgeChromeExecutablePath
509
750
  });
510
- destroyPowerShellContext(powerShell);
511
- cleanupUserDataDirOnExit("chromeDebugWsNotReady");
512
- return 1;
751
+ const windowsArgs = buildWindowsChromeArgs(planWithResolvedPort, env);
752
+ writeDebug(`chromePath phase=${phase} path=${chromePath}`);
753
+ writeDebug(`windowsArgs phase=${phase} args=${JSON.stringify(windowsArgs)}`);
754
+ const launchResult = await runPowerShellFile(powerShell, launchScript.windowsPath, [chromePath, ...windowsArgs], { timeoutMs: 15_000 });
755
+ if (launchResult.code !== 0) {
756
+ throw new Error(`failed to launch Windows Chrome: ${launchResult.stderr || launchResult.stdout}`);
757
+ }
758
+ writeDebug(`launchResult phase=${phase} code=${launchResult.code} stdout=${launchResult.stdout.trim()} stderr=${launchResult.stderr.trim()}`);
759
+ const launchedPid = Number.parseInt(launchResult.stdout.trim(), 10);
760
+ if (Number.isFinite(launchedPid)) {
761
+ writeDebug(`launchResult phase=${phase} pid=${launchedPid}`);
762
+ }
763
+ else {
764
+ writeDebug(`launchResult phase=${phase} pid=unknown raw=${launchResult.stdout.trim()}`);
765
+ }
766
+ const version = await waitForRemoteVersion(resolvedWindowsDebugPort, phase);
767
+ if (!version || typeof version.webSocketDebuggerUrl !== "string") {
768
+ throw new Error("Chrome debug websocket was not ready in time.");
769
+ }
770
+ return {
771
+ windowsDebugPort: resolvedWindowsDebugPort,
772
+ remoteVersion: version,
773
+ remoteBrowserWsUrl: version.webSocketDebuggerUrl,
774
+ ownership: "launched",
775
+ chromePid: Number.isFinite(launchedPid) ? launchedPid : null,
776
+ launchedExecutablePath: chromePath
777
+ };
778
+ };
779
+ const establishChromeSession = async (phase) => {
780
+ const existing = await tryReuseExistingChromeSession(phase);
781
+ if (existing) {
782
+ return existing;
783
+ }
784
+ return await launchChromeSession(phase);
785
+ };
786
+ let startupSession;
787
+ try {
788
+ startupSession = await establishChromeSession("startup");
513
789
  }
514
- const remoteBrowserWsUrl = remoteVersion.webSocketDebuggerUrl;
515
- writeDebug(`remoteBrowserWsUrl=${remoteBrowserWsUrl}`);
516
- const relayChild = spawn(powerShell.powershellPath, [
517
- "-NoLogo",
518
- "-NoProfile",
519
- "-NonInteractive",
520
- "-ExecutionPolicy",
521
- "Bypass",
522
- "-File",
523
- relayScript.windowsPath,
524
- remoteBrowserWsUrl
525
- ], {
526
- env,
527
- stdio: ["pipe", "pipe", "pipe"]
528
- });
529
- relayChild.stdout.setEncoding("utf8");
530
- relayChild.stderr.setEncoding("utf8");
531
- const usePipeTransport = plan.usePipeTransport && hasPipeFds();
532
- const useLocalProxyTransport = plan.localProxyPort !== null;
533
- writeDebug(`transportMode requestedPipe=${plan.usePipeTransport} pipe=${usePipeTransport} localProxy=${useLocalProxyTransport} localProxyPort=${plan.localProxyPort ?? "none"}`);
534
- if (plan.usePipeTransport && !usePipeTransport) {
535
- process.stderr.write("[wsl-chrome-bridge] --remote-debugging-pipe was requested but OS pipe fds (3/4) are missing.\n");
536
- await runPowerShellFile(powerShell, stopProcessScript.windowsPath, [String(chromePid)], {
537
- timeoutMs: 3_000
538
- });
790
+ catch (error) {
791
+ const message = error instanceof Error ? error.message : String(error);
792
+ writeDebug(`startupSession failed message=${message}`);
793
+ process.stderr.write(`[wsl-chrome-bridge] ${message}\n`);
539
794
  destroyPowerShellContext(powerShell);
540
- cleanupUserDataDirOnExit("pipeFdsMissing");
795
+ cleanupUserDataDirOnExit("startupSessionFailed");
541
796
  return 1;
542
797
  }
543
- if (!usePipeTransport && !useLocalProxyTransport) {
544
- process.stderr.write("[wsl-chrome-bridge] missing transport channel. " +
545
- "Provide --remote-debugging-port (Playwright mode) or launch with OS pipes fd3/fd4.\n");
546
- await runPowerShellFile(powerShell, stopProcessScript.windowsPath, [String(chromePid)], {
547
- timeoutMs: 3_000
798
+ let activeRemoteBrowserWsUrl = startupSession.remoteBrowserWsUrl;
799
+ let activeWindowsDebugPort = startupSession.windowsDebugPort;
800
+ let activeOwnership = startupSession.ownership;
801
+ let activeChromePid = startupSession.chromePid;
802
+ let activeLaunchedExecutablePath = startupSession.launchedExecutablePath;
803
+ const startupRemoteVersion = startupSession.remoteVersion;
804
+ writeDebug(`startupSession established ownership=${activeOwnership} port=${activeWindowsDebugPort} chromePid=${activeChromePid ?? "unknown"} ws=${activeRemoteBrowserWsUrl}`);
805
+ const startBridgeWatchdog = (chromePid) => {
806
+ const watchdogScriptPath = fileURLToPath(new URL("./bridge-watchdog.js", import.meta.url));
807
+ const watchdogArgs = [
808
+ watchdogScriptPath,
809
+ "--bridge-pid",
810
+ String(process.pid),
811
+ "--chrome-pid",
812
+ String(chromePid),
813
+ "--powershell-path",
814
+ powerShell.powershellPath
815
+ ];
816
+ if (activeLaunchedExecutablePath) {
817
+ watchdogArgs.push("--expected-executable-path", activeLaunchedExecutablePath);
818
+ }
819
+ if (activeDebugFile) {
820
+ watchdogArgs.push("--debug-file", activeDebugFile);
821
+ }
822
+ const watchdog = spawn(process.execPath, watchdogArgs, {
823
+ env,
824
+ detached: true,
825
+ stdio: "ignore"
548
826
  });
549
- destroyPowerShellContext(powerShell);
550
- cleanupUserDataDirOnExit("missingTransportChannel");
551
- return 1;
827
+ watchdog.unref();
828
+ writeDebug(`watchdog started pid=${watchdog.pid ?? "unknown"} bridgePid=${process.pid} chromePid=${chromePid}`);
829
+ };
830
+ if (usePipeTransport && requestedHeadlessMode && activeOwnership === "launched" && activeChromePid) {
831
+ startBridgeWatchdog(activeChromePid);
552
832
  }
553
833
  let pipeIn = null;
554
834
  let pipeOut = null;
@@ -556,14 +836,18 @@ export function createBridgeRunner() {
556
836
  pipeIn = createReadStream("", { fd: 3, autoClose: false });
557
837
  pipeOut = createWriteStream("", { fd: 4, autoClose: false });
558
838
  }
559
- const remoteBrowserPath = parseBrowserPathFromWsUrl(remoteBrowserWsUrl);
839
+ const remoteBrowserPath = parseBrowserPathFromWsUrl(activeRemoteBrowserWsUrl);
560
840
  const localBrowserWsUrl = plan.localProxyPort === null
561
841
  ? null
562
842
  : `ws://127.0.0.1:${plan.localProxyPort}${remoteBrowserPath}`;
563
843
  let localProxy = null;
844
+ const earlyWsMessages = [];
845
+ let handleLocalProxyMessage = (message) => {
846
+ earlyWsMessages.push(message);
847
+ };
564
848
  if (plan.localProxyPort !== null && localBrowserWsUrl) {
565
849
  const localVersionPayload = {
566
- ...remoteVersion,
850
+ ...startupRemoteVersion,
567
851
  webSocketDebuggerUrl: localBrowserWsUrl
568
852
  };
569
853
  try {
@@ -574,12 +858,7 @@ export function createBridgeRunner() {
574
858
  localVersionPayload,
575
859
  writeDebug,
576
860
  onClientMessage: (message) => {
577
- if (relayConnected) {
578
- relayChild.stdin.write(`${message}\n`);
579
- }
580
- else {
581
- queuedForRelay.push(message);
582
- }
861
+ handleLocalProxyMessage(message);
583
862
  }
584
863
  });
585
864
  writeDebug(`localProxy listening ws=${localBrowserWsUrl}`);
@@ -592,25 +871,27 @@ export function createBridgeRunner() {
592
871
  const message = error instanceof Error ? error.message : String(error);
593
872
  writeDebug(`localProxy start failed: ${message}`);
594
873
  process.stderr.write(`[wsl-chrome-bridge] failed to start local websocket proxy on 127.0.0.1:${plan.localProxyPort}: ${message}\n`);
595
- try {
596
- relayChild.kill("SIGTERM");
597
- }
598
- catch {
599
- // ignore
600
- }
601
- await runPowerShellFile(powerShell, stopProcessScript.windowsPath, [String(chromePid)], {
602
- timeoutMs: 3_000
603
- });
874
+ pipeIn?.destroy();
875
+ pipeOut?.destroy();
604
876
  destroyPowerShellContext(powerShell);
605
877
  cleanupUserDataDirOnExit("localProxyStartFailed");
606
878
  return 1;
607
879
  }
608
880
  }
881
+ let relayState = "connecting";
609
882
  let relayConnected = false;
610
883
  let relayStdoutBuffer = "";
611
884
  let relayStderrBuffer = "";
885
+ let relayGeneration = 0;
886
+ let relayChild = null;
612
887
  let pendingFromPipe = Buffer.alloc(0);
613
888
  const queuedForRelay = [];
889
+ let relayBootstrapPending = false;
890
+ let recoveryPromise = null;
891
+ const disconnectSignals = new Set();
892
+ const recentWeakDisconnectEvents = [];
893
+ let strongDisconnectAtMs = null;
894
+ let latestDisconnectAssessment = null;
614
895
  let closed = false;
615
896
  const cleanup = async (code) => {
616
897
  if (closed) {
@@ -624,23 +905,27 @@ export function createBridgeRunner() {
624
905
  await localProxy.close();
625
906
  writeDebug("cleanup localProxy closed");
626
907
  }
627
- try {
628
- relayChild.stdin.end();
629
- }
630
- catch {
631
- // ignore
632
- }
633
- try {
634
- relayChild.kill("SIGTERM");
908
+ const child = relayChild;
909
+ relayChild = null;
910
+ if (child) {
911
+ try {
912
+ child.stdin?.end();
913
+ }
914
+ catch {
915
+ // ignore
916
+ }
917
+ try {
918
+ child.kill("SIGTERM");
919
+ }
920
+ catch {
921
+ // ignore
922
+ }
923
+ await Promise.race([once(child, "exit"), sleep(1_000)]);
635
924
  }
636
- catch {
637
- // ignore
925
+ if (usePipeTransport && requestedHeadlessMode && plan.windowsUserDataDir) {
926
+ const stopResult = await runPowerShellFile(powerShell, stopChromeScript.windowsPath, [plan.windowsUserDataDir, String(activeWindowsDebugPort)], { timeoutMs: 8_000 });
927
+ writeDebug(`cleanup stopHeadlessChrome code=${stopResult.code} port=${activeWindowsDebugPort} ownership=${activeOwnership} stdout=${stopResult.stdout.trim()} stderr=${stopResult.stderr.trim()}`);
638
928
  }
639
- await Promise.race([once(relayChild, "exit"), sleep(1_000)]);
640
- await runPowerShellFile(powerShell, stopProcessScript.windowsPath, [String(chromePid)], {
641
- timeoutMs: 3_000
642
- });
643
- writeDebug("cleanup stopProcess done");
644
929
  destroyPowerShellContext(powerShell);
645
930
  writeDebug("cleanup powershell context destroyed");
646
931
  cleanupUserDataDirOnExit("cleanup");
@@ -651,6 +936,7 @@ export function createBridgeRunner() {
651
936
  const signalHandlers = new Map();
652
937
  return await new Promise((resolvePromise) => {
653
938
  let resolving = false;
939
+ let shuttingDown = false;
654
940
  const clear = () => {
655
941
  for (const [signal, handler] of signalHandlers) {
656
942
  process.off(signal, handler);
@@ -661,66 +947,422 @@ export function createBridgeRunner() {
661
947
  return;
662
948
  }
663
949
  resolving = true;
950
+ shuttingDown = true;
664
951
  clear();
665
952
  void cleanup(code).then(resolvePromise);
666
953
  };
667
- for (const signal of signals) {
668
- const handler = () => finalize(0);
669
- signalHandlers.set(signal, handler);
670
- process.on(signal, handler);
671
- }
672
- relayChild.stderr.on("data", (chunk) => {
673
- relayStderrBuffer += chunk;
674
- while (true) {
675
- const newlineIndex = relayStderrBuffer.indexOf("\n");
676
- if (newlineIndex === -1) {
954
+ const noteDisconnectSignal = (signal, source) => {
955
+ if (disconnectSignals.has(signal)) {
956
+ return;
957
+ }
958
+ disconnectSignals.add(signal);
959
+ writeDebug(`disconnectSignal observed signal=${signal} source=${source}`);
960
+ };
961
+ const rememberWeakDisconnectEvent = (signal, rawJson, observedAtMs) => {
962
+ recentWeakDisconnectEvents.push({ signal, rawJson, observedAtMs });
963
+ if (recentWeakDisconnectEvents.length > DISCONNECT_EVENT_BUFFER_LIMIT) {
964
+ recentWeakDisconnectEvents.splice(0, recentWeakDisconnectEvents.length - DISCONNECT_EVENT_BUFFER_LIMIT);
965
+ }
966
+ writeDebug(`disconnectEvent cached signal=${signal} cacheSize=${recentWeakDisconnectEvents.length}`);
967
+ };
968
+ const writeDisconnectAssessment = (trigger, disconnectAtMs) => {
969
+ const effectiveDisconnectAtMs = strongDisconnectAtMs ?? disconnectAtMs ?? Date.now();
970
+ const assessment = assessDisconnectSignals(disconnectSignals, recentWeakDisconnectEvents, effectiveDisconnectAtMs, DISCONNECT_EVENT_TIME_WINDOW_MS);
971
+ writeDebug(`disconnectAssessment trigger=${trigger} weakSignalSeen=${assessment.weakSignalSeen} strongSignalSeen=${assessment.strongSignalSeen} weakSignalWithinWindow=${assessment.weakSignalWithinWindow} windowMs=${DISCONNECT_EVENT_TIME_WINDOW_MS} disconnectAtMs=${effectiveDisconnectAtMs} chromeDisconnectedLikely=${assessment.chromeDisconnectedLikely} nearbyWeakEventCount=${assessment.nearbyWeakEventCount} disconnectSignals=${assessment.summary}`);
972
+ if (assessment.nearbyWeakEvents.length > 0) {
973
+ writeDebugBlock(`disconnectAssessment nearbyWeakEvents trigger=${trigger}`, assessment.nearbyWeakEvents);
974
+ }
975
+ return assessment;
976
+ };
977
+ const markRelayState = (nextState, reason, disconnectAssessment = null) => {
978
+ if (nextState !== "degraded") {
979
+ latestDisconnectAssessment = null;
980
+ }
981
+ else if (disconnectAssessment) {
982
+ latestDisconnectAssessment = disconnectAssessment;
983
+ }
984
+ if (relayState === nextState) {
985
+ writeDebug(`relayState unchanged state=${nextState} reason=${reason}`);
986
+ return;
987
+ }
988
+ relayState = nextState;
989
+ const assessmentSuffix = disconnectAssessment
990
+ ? ` disconnectSignals=${disconnectAssessment.summary} weakSignalWithinWindow=${disconnectAssessment.weakSignalWithinWindow} nearbyWeakEventCount=${disconnectAssessment.nearbyWeakEventCount} chromeDisconnectedLikely=${disconnectAssessment.chromeDisconnectedLikely}`
991
+ : "";
992
+ writeDebug(`relayState changed state=${relayState} reason=${reason} port=${activeWindowsDebugPort} ownership=${activeOwnership}${assessmentSuffix}`);
993
+ };
994
+ const nextInternalCdpRequestId = () => {
995
+ internalCdpRequestId += 1;
996
+ return internalCdpRequestId;
997
+ };
998
+ const sendRecoveryAutoAttachBootstrap = (child, generation) => {
999
+ if (!child.stdin) {
1000
+ return false;
1001
+ }
1002
+ const payload = {
1003
+ id: nextInternalCdpRequestId(),
1004
+ method: "Target.setAutoAttach",
1005
+ params: {
1006
+ autoAttach: true,
1007
+ waitForDebuggerOnStart: true,
1008
+ flatten: true
1009
+ }
1010
+ };
1011
+ const message = JSON.stringify(payload);
1012
+ const rendered = renderCdpMessage(message);
1013
+ pendingInternalCdpRequests.set(requestKey(payload.id, null), {
1014
+ generation,
1015
+ method: payload.method,
1016
+ purpose: "recovery-auto-attach"
1017
+ });
1018
+ writeDebugBlock("CDP(bridge-internal -> chrome) Request Target.setAutoAttach", rendered.payload);
1019
+ child.stdin.write(`${message}\n`);
1020
+ return true;
1021
+ };
1022
+ const tryHandleInternalCdpResponse = (generation, rendered, child) => {
1023
+ if (rendered.parsed.kind !== "response" || rendered.parsed.id === null) {
1024
+ return false;
1025
+ }
1026
+ const key = requestKey(rendered.parsed.id, rendered.parsed.sessionId);
1027
+ const pending = pendingInternalCdpRequests.get(key);
1028
+ if (!pending) {
1029
+ return false;
1030
+ }
1031
+ pendingInternalCdpRequests.delete(key);
1032
+ writeDebugBlock(`CDP(chrome -> bridge-internal) Response ${pending.method}`, rendered.payload);
1033
+ if (pending.generation !== generation) {
1034
+ return true;
1035
+ }
1036
+ if (pending.purpose === "recovery-auto-attach") {
1037
+ relayBootstrapPending = false;
1038
+ if (rendered.parsed.hasError) {
1039
+ relayConnected = false;
1040
+ const assessment = writeDisconnectAssessment("recoveryBootstrapAutoAttachError");
1041
+ markRelayState("degraded", "recoveryBootstrapAutoAttachError", assessment);
1042
+ try {
1043
+ child.kill("SIGTERM");
1044
+ }
1045
+ catch {
1046
+ // ignore
1047
+ }
1048
+ maybeRecoverRelayForWs();
1049
+ return true;
1050
+ }
1051
+ writeDebug("recoveryBootstrap autoAttach acknowledged");
1052
+ flushQueuedForRelay();
1053
+ }
1054
+ return true;
1055
+ };
1056
+ const flushQueuedForRelay = () => {
1057
+ if (!relayChild || !relayConnected || !relayChild.stdin || relayBootstrapPending) {
1058
+ return;
1059
+ }
1060
+ const relayStdin = relayChild.stdin;
1061
+ while (queuedForRelay.length > 0) {
1062
+ const queued = queuedForRelay.shift();
1063
+ if (!queued) {
677
1064
  break;
678
1065
  }
679
- const rawLine = relayStderrBuffer.slice(0, newlineIndex);
680
- relayStderrBuffer = relayStderrBuffer.slice(newlineIndex + 1);
681
- const line = rawLine.replace(/\r$/, "");
682
- if (!line) {
683
- continue;
1066
+ relayStdin.write(`${queued.message}\n`);
1067
+ writeCdpHopLog("relay=>chrome", queued.rendered);
1068
+ }
1069
+ };
1070
+ const forwardToUpstream = (message, rendered) => {
1071
+ if (pipeOut) {
1072
+ pipeOut.write(message);
1073
+ pipeOut.write("\0");
1074
+ writeCdpHopLog("relay=>upstream", rendered);
1075
+ }
1076
+ if (localProxy) {
1077
+ localProxy.broadcast(message);
1078
+ writeCdpHopLog("relay=>upstream", rendered);
1079
+ }
1080
+ };
1081
+ const sendToRelayOrQueue = (message, rendered) => {
1082
+ if (relayState === "connected" &&
1083
+ relayChild &&
1084
+ relayConnected &&
1085
+ relayChild.stdin &&
1086
+ !relayBootstrapPending) {
1087
+ relayChild.stdin.write(`${message}\n`);
1088
+ writeCdpHopLog("relay=>chrome", rendered);
1089
+ return;
1090
+ }
1091
+ queuedForRelay.push({ message, rendered });
1092
+ };
1093
+ const startRelay = (remoteBrowserWsUrl, reason) => {
1094
+ if (shuttingDown) {
1095
+ writeDebug(`startRelay skipped reason=shuttingDown trigger=${reason}`);
1096
+ return;
1097
+ }
1098
+ disconnectSignals.clear();
1099
+ recentWeakDisconnectEvents.length = 0;
1100
+ strongDisconnectAtMs = null;
1101
+ relayBootstrapPending = false;
1102
+ pendingInternalCdpRequests.clear();
1103
+ relayGeneration += 1;
1104
+ const generation = relayGeneration;
1105
+ markRelayState("connecting", reason);
1106
+ relayConnected = false;
1107
+ relayStdoutBuffer = "";
1108
+ relayStderrBuffer = "";
1109
+ const previousRelayChild = relayChild;
1110
+ const child = spawn(powerShell.powershellPath, [
1111
+ "-NoLogo",
1112
+ "-NoProfile",
1113
+ "-NonInteractive",
1114
+ "-ExecutionPolicy",
1115
+ "Bypass",
1116
+ "-File",
1117
+ relayScript.windowsPath,
1118
+ remoteBrowserWsUrl
1119
+ ], {
1120
+ env,
1121
+ stdio: ["pipe", "pipe", "pipe"]
1122
+ });
1123
+ child.stdout.setEncoding("utf8");
1124
+ child.stderr.setEncoding("utf8");
1125
+ relayChild = child;
1126
+ if (previousRelayChild) {
1127
+ try {
1128
+ previousRelayChild.stdin?.end();
1129
+ }
1130
+ catch {
1131
+ // ignore
1132
+ }
1133
+ try {
1134
+ previousRelayChild.kill("SIGTERM");
1135
+ }
1136
+ catch {
1137
+ // ignore
684
1138
  }
685
- writeDebug(`relayStderr ${line}`);
686
- if (line.includes("CONNECTED")) {
687
- relayConnected = true;
688
- for (const queued of queuedForRelay) {
689
- relayChild.stdin.write(`${queued}\n`);
1139
+ }
1140
+ child.stderr.on("data", (chunk) => {
1141
+ if (generation !== relayGeneration) {
1142
+ return;
1143
+ }
1144
+ relayStderrBuffer += chunk;
1145
+ while (true) {
1146
+ const newlineIndex = relayStderrBuffer.indexOf("\n");
1147
+ if (newlineIndex === -1) {
1148
+ break;
1149
+ }
1150
+ const rawLine = relayStderrBuffer.slice(0, newlineIndex);
1151
+ relayStderrBuffer = relayStderrBuffer.slice(newlineIndex + 1);
1152
+ const line = rawLine.replace(/\r$/, "");
1153
+ if (!line) {
1154
+ continue;
1155
+ }
1156
+ writeDebug(`relayStderr ${line}`);
1157
+ const strongDisconnectSignal = detectStrongDisconnectSignalFromRelayLog(line);
1158
+ if (strongDisconnectSignal) {
1159
+ noteDisconnectSignal(strongDisconnectSignal, "relayStderr");
1160
+ const observedAtMs = Date.now();
1161
+ strongDisconnectAtMs = observedAtMs;
1162
+ relayConnected = false;
1163
+ const assessment = writeDisconnectAssessment(`relayStrongDisconnect:${strongDisconnectSignal}`, observedAtMs);
1164
+ if (usePipeTransport) {
1165
+ finalize(1);
1166
+ }
1167
+ else {
1168
+ relayBootstrapPending = false;
1169
+ markRelayState("degraded", `relayStrongDisconnect:${strongDisconnectSignal}`, assessment);
1170
+ try {
1171
+ child.kill("SIGTERM");
1172
+ }
1173
+ catch {
1174
+ // ignore
1175
+ }
1176
+ }
1177
+ return;
1178
+ }
1179
+ if (line.includes("CONNECTED")) {
1180
+ relayConnected = true;
1181
+ markRelayState("connected", "relayConnected");
1182
+ if (!usePipeTransport && reason === "recovery") {
1183
+ relayBootstrapPending = true;
1184
+ const sent = sendRecoveryAutoAttachBootstrap(child, generation);
1185
+ if (!sent) {
1186
+ relayBootstrapPending = false;
1187
+ relayConnected = false;
1188
+ const assessment = writeDisconnectAssessment("recoveryBootstrapSendFailed");
1189
+ markRelayState("degraded", "recoveryBootstrapSendFailed", assessment);
1190
+ try {
1191
+ child.kill("SIGTERM");
1192
+ }
1193
+ catch {
1194
+ // ignore
1195
+ }
1196
+ maybeRecoverRelayForWs();
1197
+ return;
1198
+ }
1199
+ continue;
1200
+ }
1201
+ flushQueuedForRelay();
1202
+ continue;
1203
+ }
1204
+ if (line.startsWith("FATAL:")) {
1205
+ const assessment = writeDisconnectAssessment(`relayFatal:${line}`);
1206
+ if (usePipeTransport) {
1207
+ finalize(1);
1208
+ }
1209
+ else {
1210
+ relayConnected = false;
1211
+ relayBootstrapPending = false;
1212
+ markRelayState("degraded", `relayFatal:${line}`, assessment);
1213
+ }
1214
+ return;
690
1215
  }
691
- queuedForRelay.length = 0;
692
- continue;
693
1216
  }
694
- if (line.startsWith("FATAL:")) {
695
- finalize(1);
1217
+ });
1218
+ child.stdout.on("data", (chunk) => {
1219
+ if (generation !== relayGeneration) {
696
1220
  return;
697
1221
  }
698
- }
699
- });
700
- relayChild.stdout.on("data", (chunk) => {
701
- relayStdoutBuffer += chunk;
702
- while (true) {
703
- const newlineIndex = relayStdoutBuffer.indexOf("\n");
704
- if (newlineIndex === -1) {
705
- break;
1222
+ relayStdoutBuffer += chunk;
1223
+ while (true) {
1224
+ const newlineIndex = relayStdoutBuffer.indexOf("\n");
1225
+ if (newlineIndex === -1) {
1226
+ break;
1227
+ }
1228
+ const rawLine = relayStdoutBuffer.slice(0, newlineIndex);
1229
+ relayStdoutBuffer = relayStdoutBuffer.slice(newlineIndex + 1);
1230
+ const line = rawLine.replace(/\r$/, "");
1231
+ if (!line) {
1232
+ continue;
1233
+ }
1234
+ const rendered = renderCdpMessage(line);
1235
+ if (tryHandleInternalCdpResponse(generation, rendered, child)) {
1236
+ continue;
1237
+ }
1238
+ const weakDisconnectSignal = detectWeakDisconnectSignalFromCdp(rendered);
1239
+ if (weakDisconnectSignal) {
1240
+ noteDisconnectSignal(weakDisconnectSignal, "chromeEvent");
1241
+ rememberWeakDisconnectEvent(weakDisconnectSignal, line, Date.now());
1242
+ }
1243
+ writeCdpHopLog("chrome=>relay", rendered);
1244
+ forwardToUpstream(line, rendered);
1245
+ }
1246
+ });
1247
+ child.once("error", () => {
1248
+ if (generation !== relayGeneration) {
1249
+ return;
706
1250
  }
707
- const rawLine = relayStdoutBuffer.slice(0, newlineIndex);
708
- relayStdoutBuffer = relayStdoutBuffer.slice(newlineIndex + 1);
709
- const line = rawLine.replace(/\r$/, "");
710
- if (!line) {
711
- continue;
1251
+ relayConnected = false;
1252
+ relayBootstrapPending = false;
1253
+ const assessment = writeDisconnectAssessment("relayError");
1254
+ if (usePipeTransport) {
1255
+ finalize(1);
712
1256
  }
713
- if (pipeOut) {
714
- pipeOut.write(line);
715
- pipeOut.write("\0");
1257
+ else {
1258
+ markRelayState("degraded", "relayError", assessment);
1259
+ }
1260
+ });
1261
+ child.once("exit", (code) => {
1262
+ if (generation !== relayGeneration) {
1263
+ return;
716
1264
  }
717
- if (localProxy) {
718
- localProxy.broadcast(line);
1265
+ relayConnected = false;
1266
+ relayBootstrapPending = false;
1267
+ relayChild = null;
1268
+ const assessment = writeDisconnectAssessment(`relayExit:${code ?? "null"}`);
1269
+ if (usePipeTransport) {
1270
+ finalize(code === 0 ? 0 : 1);
1271
+ return;
719
1272
  }
1273
+ markRelayState("degraded", `relayExit:${code ?? "null"}`, assessment);
1274
+ });
1275
+ };
1276
+ const maybeRecoverRelayForWs = () => {
1277
+ if (usePipeTransport || relayState !== "degraded") {
1278
+ return;
720
1279
  }
721
- });
722
- relayChild.once("error", () => finalize(1));
723
- relayChild.once("exit", (code) => finalize(code === 0 ? 0 : 1));
1280
+ if (shuttingDown || closed || resolving) {
1281
+ writeDebug("recovery skipped reason=shuttingDown");
1282
+ return;
1283
+ }
1284
+ if (recoveryPromise) {
1285
+ return;
1286
+ }
1287
+ recoveryPromise = (async () => {
1288
+ writeDebug("recovery start trigger=upstreamRequest");
1289
+ const recoverySession = await establishChromeSession("recovery");
1290
+ activeRemoteBrowserWsUrl = recoverySession.remoteBrowserWsUrl;
1291
+ activeWindowsDebugPort = recoverySession.windowsDebugPort;
1292
+ activeOwnership = recoverySession.ownership;
1293
+ activeChromePid = recoverySession.chromePid;
1294
+ activeLaunchedExecutablePath = recoverySession.launchedExecutablePath;
1295
+ writeDebug(`recovery success ownership=${activeOwnership} port=${activeWindowsDebugPort} chromePid=${activeChromePid ?? "unknown"} ws=${activeRemoteBrowserWsUrl}`);
1296
+ startRelay(activeRemoteBrowserWsUrl, "recovery");
1297
+ })()
1298
+ .catch((error) => {
1299
+ const message = error instanceof Error ? error.message : String(error);
1300
+ writeDebug(`recovery failed message=${message}`);
1301
+ process.stderr.write(`[wsl-chrome-bridge] recovery failed: ${message}\n`);
1302
+ finalize(1);
1303
+ })
1304
+ .finally(() => {
1305
+ recoveryPromise = null;
1306
+ });
1307
+ };
1308
+ const processUpstreamMessage = (message) => {
1309
+ if (shuttingDown || closed || resolving) {
1310
+ writeDebug("upstream message ignored reason=shuttingDown");
1311
+ return;
1312
+ }
1313
+ const rendered = renderCdpMessage(message);
1314
+ writeCdpHopLog("upstream=>relay", rendered);
1315
+ const shouldShortCircuitKnownClosedBrowserClose = !usePipeTransport &&
1316
+ relayState !== "connected" &&
1317
+ rendered.parsed.kind === "request" &&
1318
+ rendered.canonicalMethod === "Browser.close" &&
1319
+ rendered.parsed.id !== null &&
1320
+ Boolean(latestDisconnectAssessment?.chromeDisconnectedLikely);
1321
+ if (shouldShortCircuitKnownClosedBrowserClose) {
1322
+ const syntheticResponse = {
1323
+ id: rendered.parsed.id,
1324
+ result: {}
1325
+ };
1326
+ if (rendered.parsed.sessionId) {
1327
+ syntheticResponse.sessionId = rendered.parsed.sessionId;
1328
+ }
1329
+ const syntheticMessage = JSON.stringify(syntheticResponse);
1330
+ const syntheticRendered = renderCdpMessage(syntheticMessage);
1331
+ writeDebug(`shortCircuit Browser.close reason=chromeAlreadyClosed relayState=${relayState} id=${String(rendered.parsed.id)} session=${rendered.parsed.sessionId ?? "root"}`);
1332
+ forwardToUpstream(syntheticMessage, syntheticRendered);
1333
+ return;
1334
+ }
1335
+ if (!usePipeTransport && relayState === "degraded") {
1336
+ maybeRecoverRelayForWs();
1337
+ sendToRelayOrQueue(message, rendered);
1338
+ return;
1339
+ }
1340
+ if (!usePipeTransport && relayState === "connecting") {
1341
+ sendToRelayOrQueue(message, rendered);
1342
+ return;
1343
+ }
1344
+ if (!usePipeTransport && (!relayConnected || relayState !== "connected")) {
1345
+ const assessment = writeDisconnectAssessment("upstreamWhileRelayUnavailable");
1346
+ markRelayState("degraded", "upstreamWhileRelayUnavailable", assessment);
1347
+ maybeRecoverRelayForWs();
1348
+ sendToRelayOrQueue(message, rendered);
1349
+ return;
1350
+ }
1351
+ sendToRelayOrQueue(message, rendered);
1352
+ };
1353
+ handleLocalProxyMessage = (message) => {
1354
+ processUpstreamMessage(message);
1355
+ };
1356
+ for (const earlyMessage of earlyWsMessages) {
1357
+ processUpstreamMessage(earlyMessage);
1358
+ }
1359
+ earlyWsMessages.length = 0;
1360
+ for (const signal of signals) {
1361
+ const handler = () => finalize(0);
1362
+ signalHandlers.set(signal, handler);
1363
+ process.on(signal, handler);
1364
+ }
1365
+ startRelay(activeRemoteBrowserWsUrl, "initial");
724
1366
  if (pipeIn) {
725
1367
  pipeIn.on("data", (chunk) => {
726
1368
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
@@ -735,12 +1377,7 @@ export function createBridgeRunner() {
735
1377
  if (!message) {
736
1378
  continue;
737
1379
  }
738
- if (relayConnected) {
739
- relayChild.stdin.write(`${message}\n`);
740
- }
741
- else {
742
- queuedForRelay.push(message);
743
- }
1380
+ processUpstreamMessage(message);
744
1381
  }
745
1382
  });
746
1383
  pipeIn.once("end", () => finalize(0));