pi-browser-harness 0.11.0 → 0.13.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
@@ -1,6 +1,7 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
- import { createInterface, type Interface } from "node:readline";
2
+ import { readLines } from "./lines";
3
3
  import { unlinkSync } from "node:fs";
4
+ import { destroyQuietly } from "./socket";
4
5
  import {
5
6
  type WireMessage,
6
7
  type WireControl,
@@ -13,7 +14,7 @@ import {
13
14
  export type ClientSocket = {
14
15
  id: string;
15
16
  readonly socket: Socket;
16
- readonly rl: Interface;
17
+ readonly stopReading: () => void;
17
18
  registered: boolean;
18
19
  };
19
20
 
@@ -21,51 +22,86 @@ export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
21
22
 
22
23
  export type ConnectionHandler = (client: ClientSocket) => void;
23
24
 
25
+ export type Disposer = () => void;
26
+
27
+ export type IpcServerOptions = {
28
+ readonly socketPath?: string;
29
+ readonly maxClients?: number;
30
+ };
31
+
24
32
  export type IpcServer = {
25
- start(): Promise<void>;
26
- stop(): Promise<void>;
27
- onMessage(handler: MessageHandler): void;
28
- onConnect(handler: ConnectionHandler): void;
29
- onDisconnect(handler: ConnectionHandler): void;
30
- send(clientId: string, msg: WireMessage): void;
31
- broadcast(msg: WireMessage): void;
32
- clientCount(): number;
33
+ readonly start: () => Promise<void>;
34
+ readonly stop: () => Promise<void>;
35
+ readonly onMessage: (handler: MessageHandler) => Disposer;
36
+ readonly onConnect: (handler: ConnectionHandler) => Disposer;
37
+ // Runs once per registered client when it goes away, whether its socket closed or it sent
38
+ // "deregister". Every registered handler runs; registering is additive, not a replacement.
39
+ readonly onDisconnect: (handler: ConnectionHandler) => Disposer;
40
+ readonly send: (clientId: string, msg: WireMessage) => void;
41
+ readonly broadcast: (msg: WireMessage) => void;
42
+ readonly clientCount: () => number;
43
+ // Every open socket, registered or not; the connection cap counts these.
44
+ readonly socketCount: () => number;
33
45
  };
34
46
 
35
- export const createIpcServer = (): IpcServer => {
47
+ const listen = <H>(handlers: Set<H>) => (handler: H): Disposer => {
48
+ handlers.add(handler);
49
+ return () => { handlers.delete(handler); };
50
+ };
51
+
52
+
53
+ export const createIpcServer = (options: IpcServerOptions = {}): IpcServer => {
54
+ const socketPath = options.socketPath ?? DAEMON_SOCKET_PATH;
55
+ const maxClients = options.maxClients ?? DAEMON_MAX_CLIENTS;
36
56
  let server: Server | null = null;
37
- const sockets = new Map<string, ClientSocket>();
57
+ // Sockets that have not registered yet, by their temporary id.
58
+ const anonymous = new Map<string, ClientSocket>();
38
59
  const clients = new Map<string, ClientSocket>();
39
- let messageHandler: MessageHandler | null = null;
40
- let connectHandler: ConnectionHandler | null = null;
41
- let disconnectHandler: ConnectionHandler | null = null;
60
+ const messageHandlers = new Set<MessageHandler>();
61
+ const connectHandlers = new Set<ConnectionHandler>();
62
+ const disconnectHandlers = new Set<ConnectionHandler>();
42
63
  let nextTempId = 1;
43
64
 
65
+ const unlinkSocketFile = (): void => {
66
+ if (process.platform === "win32") return;
67
+ try {
68
+ unlinkSync(socketPath);
69
+ } catch {
70
+ // Nothing to unlink, or no permission: listen() reports the real problem if there is one.
71
+ }
72
+ };
73
+
74
+ // A registered client leaves exactly once: either its socket closes or it deregisters first.
75
+ const forget = (client: ClientSocket): void => {
76
+ if (!client.registered) return;
77
+ client.registered = false;
78
+ clients.delete(client.id);
79
+ for (const h of disconnectHandlers) h(client);
80
+ };
81
+
44
82
  const start = (): Promise<void> =>
45
83
  new Promise((resolve, reject) => {
46
- try {
47
- unlinkSync(DAEMON_SOCKET_PATH);
48
- } catch {}
84
+ unlinkSocketFile();
49
85
 
50
86
  server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
51
- if (clients.size >= DAEMON_MAX_CLIENTS) {
87
+ if (anonymous.size + clients.size >= maxClients) {
52
88
  socket.destroy();
53
89
  return;
54
90
  }
55
91
 
56
92
  const tempId = `anon-${nextTempId++}`;
57
- const rl = createInterface({ input: socket, crlfDelay: Infinity });
58
- let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
93
+ const stopReading = readLines(socket, (line) => { onLine(line); });
94
+ const client: ClientSocket = { id: tempId, socket, stopReading, registered: false };
59
95
 
60
- sockets.set(tempId, currentClient);
96
+ anonymous.set(tempId, client);
61
97
 
62
- rl.on("line", (line: string) => {
98
+ function onLine(line: string): void {
63
99
  const msg = deserialize(line);
64
100
  if (!msg) return;
65
101
 
66
- if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
102
+ if (msg.type === "control" && msg.action === "register") {
103
+ if (client.registered) return;
67
104
  const clientId = msg.clientId;
68
- if (!clientId) return;
69
105
 
70
106
  if (clients.has(clientId)) {
71
107
  const reply: WireControl = {
@@ -73,41 +109,43 @@ export const createIpcServer = (): IpcServer => {
73
109
  action: "shutdown",
74
110
  reason: `clientId ${clientId} is already connected`,
75
111
  };
76
- socket.write(serialize(reply) + "\n");
77
- socket.destroy();
112
+ // end() flushes the reply before closing; destroy() would race it.
113
+ socket.end(serialize(reply) + "\n");
78
114
  return;
79
115
  }
80
116
 
81
117
  // Mutate in place so the already-captured closure sees the promoted client.
82
- sockets.delete(tempId);
83
- currentClient.id = clientId;
84
- currentClient.registered = true;
85
- clients.set(clientId, currentClient);
118
+ anonymous.delete(tempId);
119
+ client.id = clientId;
120
+ client.registered = true;
121
+ clients.set(clientId, client);
86
122
 
87
123
  const ack: WireControl = { type: "control", action: "registered", clientId };
88
124
  socket.write(serialize(ack) + "\n");
89
125
 
90
- connectHandler?.(currentClient);
126
+ for (const h of connectHandlers) h(client);
91
127
  return;
92
128
  }
93
129
 
94
- if (!currentClient.registered) return;
130
+ if (!client.registered) return;
95
131
 
96
- messageHandler?.(msg, currentClient);
97
- });
132
+ if (msg.type === "control" && msg.action === "deregister") {
133
+ if (msg.clientId !== client.id) return;
134
+ forget(client);
135
+ socket.end();
136
+ return;
137
+ }
138
+
139
+ for (const h of messageHandlers) h(msg, client);
140
+ }
98
141
 
99
- socket.on("error", () => {});
142
+ // A client socket error is always followed by "close", which does the cleanup.
143
+ socket.on("error", () => undefined);
100
144
 
101
145
  socket.on("close", () => {
102
- const found = clients.get(currentClient.id) ?? sockets.get(tempId);
103
- if (found) {
104
- clients.delete(found.id);
105
- sockets.delete(tempId);
106
- if (found.registered) {
107
- disconnectHandler?.(found);
108
- }
109
- }
110
- rl.close();
146
+ anonymous.delete(tempId);
147
+ forget(client);
148
+ stopReading();
111
149
  });
112
150
  });
113
151
 
@@ -115,25 +153,27 @@ export const createIpcServer = (): IpcServer => {
115
153
  reject(err);
116
154
  });
117
155
 
118
- server.listen(DAEMON_SOCKET_PATH, () => {
156
+ server.listen(socketPath, () => {
119
157
  resolve();
120
158
  });
121
159
  });
122
160
 
123
161
  const stop = (): Promise<void> =>
124
162
  new Promise((resolve) => {
163
+ // Stopping is not a client leaving: no disconnect handler runs for sockets torn down here.
125
164
  for (const [, client] of clients) {
126
- try { client.socket.destroy(); } catch {}
165
+ client.registered = false;
166
+ destroyQuietly(client.socket);
127
167
  }
128
- for (const [, client] of sockets) {
129
- try { client.socket.destroy(); } catch {}
168
+ for (const [, client] of anonymous) {
169
+ destroyQuietly(client.socket);
130
170
  }
131
171
  clients.clear();
132
- sockets.clear();
172
+ anonymous.clear();
133
173
 
134
174
  if (server) {
135
175
  server.close(() => {
136
- try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
176
+ unlinkSocketFile();
137
177
  resolve();
138
178
  });
139
179
  } else {
@@ -146,25 +186,22 @@ export const createIpcServer = (): IpcServer => {
146
186
  if (!client) return;
147
187
  try {
148
188
  client.socket.write(serialize(msg) + "\n");
149
- } catch {}
189
+ } catch {
190
+ // The client went away mid-write; its "close" handler forgets it.
191
+ }
150
192
  };
151
193
 
152
194
  return {
153
195
  start,
154
196
  stop,
155
- onMessage(handler) {
156
- messageHandler = handler;
157
- },
158
- onConnect(handler) {
159
- connectHandler = handler;
160
- },
161
- onDisconnect(handler) {
162
- disconnectHandler = handler;
163
- },
197
+ onMessage: listen(messageHandlers),
198
+ onConnect: listen(connectHandlers),
199
+ onDisconnect: listen(disconnectHandlers),
164
200
  send,
165
- broadcast(msg) {
201
+ broadcast: (msg) => {
166
202
  for (const [clientId] of clients) send(clientId, msg);
167
203
  },
168
- clientCount: (): number => clients.size,
204
+ clientCount: () => clients.size,
205
+ socketCount: () => anonymous.size + clients.size,
169
206
  };
170
207
  };
@@ -0,0 +1,10 @@
1
+ import type { Socket } from "node:net";
2
+
3
+ // destroy() on a socket whose handle is already gone throws; the socket is gone either way.
4
+ export const destroyQuietly = (socket: Socket): void => {
5
+ try {
6
+ socket.destroy();
7
+ } catch {
8
+ // Already torn down.
9
+ }
10
+ };
@@ -1,19 +1,25 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { accessSync } from "node:fs";
2
3
  import { access, unlink } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { dirname, join } from "node:path";
6
7
  import { createConnection } from "node:net";
7
- import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
8
+ import { readLines } from "./lines";
9
+ import { DAEMON_SOCKET_PATH, deserialize, serialize, type WireControl } from "./protocol";
10
+ import { destroyQuietly } from "./socket";
8
11
 
9
12
  const moduleDir = dirname(fileURLToPath(import.meta.url));
10
13
 
14
+ const requireFromHere = createRequire(import.meta.url);
15
+
11
16
  const isWindows = process.platform === "win32";
12
17
 
13
18
  // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
14
19
  const cleanupStaleSocket = (): void => {
15
20
  if (isWindows) return;
16
- unlink(DAEMON_SOCKET_PATH).catch(() => {});
21
+ // ENOENT is the normal case; anything else surfaces when listen() fails.
22
+ unlink(DAEMON_SOCKET_PATH).catch(() => undefined);
17
23
  };
18
24
 
19
25
  export const daemonSocketMayExist = async (
@@ -42,7 +48,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
42
48
 
43
49
  const sock = createConnection(DAEMON_SOCKET_PATH);
44
50
  const timer = setTimeout(() => {
45
- sock.destroy();
51
+ destroyQuietly(sock);
46
52
  cleanupStaleSocket();
47
53
  done(false);
48
54
  }, timeoutMs);
@@ -52,20 +58,20 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
52
58
  sock.write(serialize(regMsg) + "\n");
53
59
  });
54
60
 
55
- sock.on("data", (chunk: Buffer) => {
56
- const text = chunk.toString();
57
- if (text.includes('"registered"')) {
58
- clearTimeout(timer);
59
- const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
60
- try { sock.write(serialize(deregMsg) + "\n"); } catch {}
61
- sock.destroy();
62
- done(true);
63
- }
61
+ // Line-framed like every other reader of this protocol: a chunk may hold a partial line.
62
+ readLines(sock, (line) => {
63
+ const msg = deserialize(line);
64
+ if (msg?.type !== "control" || msg.action !== "registered") return;
65
+ clearTimeout(timer);
66
+ const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
67
+ // end() lets the deregister reach the daemon; destroy() would drop it unflushed.
68
+ try { sock.end(serialize(deregMsg) + "\n"); } catch { destroyQuietly(sock); }
69
+ done(true);
64
70
  });
65
71
 
66
72
  sock.on("error", () => {
67
73
  clearTimeout(timer);
68
- sock.destroy();
74
+ destroyQuietly(sock);
69
75
  cleanupStaleSocket();
70
76
  done(false);
71
77
  });
@@ -78,28 +84,79 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
78
84
  });
79
85
  };
80
86
 
81
- export const spawnDaemon = (): ChildProcess | null => {
82
- const daemonScript = join(moduleDir, "index.ts");
87
+ // Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
88
+ // hoisted layout pi uses can be exercised against a fixture instead of this checkout.
89
+ export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
90
+ for (let dir = startDir; ; dir = dirname(dir)) {
91
+ const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
92
+ try {
93
+ accessSync(candidate);
94
+ return candidate;
95
+ } catch {
96
+ // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup
97
+ // without relying on createRequire().resolve(), which is broken for external modules
98
+ // loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
99
+ }
100
+ // dirname() of the filesystem root is the root itself.
101
+ if (dirname(dir) === dir) return null;
102
+ }
103
+ };
104
+
105
+ const resolveTsxCli = (): string | null => {
106
+ try {
107
+ const resolved = requireFromHere.resolve("tsx/cli");
108
+ // A compiled runtime may theoretically return a virtual path that a separately spawned
109
+ // Node process cannot read. Only accept a resolver result that exists on the host FS.
110
+ accessSync(resolved);
111
+ return resolved;
112
+ } catch {
113
+ return resolveTsxCliFromFilesystem();
114
+ }
115
+ };
83
116
 
84
- // Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL.
85
- const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
86
- const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
87
- const hasLocalTsx = existsSync(tsxBin);
117
+ export const daemonNodeExecutable = (
118
+ env: NodeJS.ProcessEnv = process.env,
119
+ versions: Readonly<Record<string, string | undefined>> = process.versions,
120
+ platform: NodeJS.Platform = process.platform,
121
+ execPath: string = process.execPath,
122
+ ): string => {
123
+ const override = env["PI_BROWSER_NODE"];
124
+ if (override) return override;
88
125
 
89
- const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
90
- const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
126
+ // Under normal Node keep the exact executable that launched pi/the extension. Under Bun,
127
+ // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime.
128
+ if ("bun" in versions) return platform === "win32" ? "node.exe" : "node";
91
129
 
92
- // With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens.
93
- const quote = (s: string): string => (isWindows ? `"${s}"` : s);
130
+ return execPath;
131
+ };
94
132
 
95
- const child = spawn(quote(cmd), args.map(quote), {
133
+ export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
134
+ const daemonScript = join(moduleDir, "main.ts");
135
+ const tsxCli = resolveTsxCli();
136
+ if (!tsxCli) return null;
137
+
138
+ return {
139
+ command: daemonNodeExecutable(),
140
+ args: [tsxCli, daemonScript],
141
+ };
142
+ };
143
+
144
+ export const spawnDaemon = (): ChildProcess | null => {
145
+ const spec = daemonSpawnCommand();
146
+ if (!spec) return null;
147
+
148
+ const child = spawn(spec.command, [...spec.args], {
96
149
  detached: true,
97
150
  stdio: "ignore",
98
151
  env: { ...process.env },
99
- shell: isWindows,
100
- windowsVerbatimArguments: isWindows,
152
+ windowsHide: true,
101
153
  });
102
154
 
155
+ // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
156
+ // asynchronously via `error`; without a listener that is an uncaught exception in pi.
157
+ // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
158
+ child.on("error", () => undefined);
159
+
103
160
  child.unref();
104
161
  return child;
105
162
  };
@@ -1,5 +1,6 @@
1
1
  import { createConnection, type Socket } from "node:net";
2
- import { createInterface } from "node:readline";
2
+ import { readLines } from "./lines";
3
+ import { destroyQuietly } from "./socket";
3
4
  import { type Result, err, ok } from "../util/result";
4
5
  import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
5
6
  import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types";
@@ -13,72 +14,85 @@ import {
13
14
  serialize,
14
15
  } from "./protocol";
15
16
 
16
- export const createDaemonTransport = (clientId: string): CdpTransport => {
17
+ type ConnectResult = Result<void, CdpError>;
18
+
19
+ export const createDaemonTransport = (clientId: string, socketPath: string = DAEMON_SOCKET_PATH): CdpTransport => {
17
20
  let socket: Socket | null = null;
18
- let rl: ReturnType<typeof createInterface> | null = null;
21
+ let stopReading: (() => void) | null = null;
19
22
  let queue = makeEventQueue();
20
23
  const closeListeners = new Set<() => void>();
21
24
  const pending = new Map<number, Pending>();
22
25
  let registered = false;
23
26
  let nextRequestId = 1;
27
+ // The handshake in progress, so that concurrent connect() callers share one socket instead
28
+ // of each opening their own and the later one orphaning the earlier.
29
+ let inFlight: Promise<ConnectResult> | null = null;
30
+
31
+ // Tears down `sock` only if it is still the transport's socket: a socket from a superseded
32
+ // attempt is just destroyed, without touching the state that belongs to the current one.
33
+ const cleanup = (sock: Socket, reason: string): void => {
34
+ if (socket !== sock) {
35
+ destroyQuietly(sock);
36
+ return;
37
+ }
24
38
 
25
- const cleanup = (reason: string): void => {
26
39
  rejectAllPending(pending, reason);
27
40
  queue.end();
28
41
  queue = makeEventQueue();
29
42
  registered = false;
30
43
 
31
- if (rl) { rl.close(); rl = null; }
32
- if (socket) { try { socket.destroy(); } catch {} socket = null; }
44
+ if (stopReading) { stopReading(); stopReading = null; }
45
+ socket = null;
46
+ destroyQuietly(sock);
33
47
 
34
48
  for (const cb of closeListeners) cb();
35
49
  };
36
50
 
37
- const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
38
- const timeoutMs = opts?.timeoutMs ?? 10_000;
39
-
40
- if (socket && !socket.destroyed && registered) {
41
- return Promise.resolve(ok(undefined));
42
- }
43
-
44
- return new Promise((resolve) => {
45
- let settled = false;
46
- const settle = (r: Result<void, CdpError>): void => {
47
- if (settled) return;
48
- settled = true;
49
- resolve(r);
50
- };
51
-
51
+ const handshake = (timeoutMs: number): Promise<ConnectResult> =>
52
+ new Promise((resolve) => {
52
53
  let sock: Socket;
53
54
  try {
54
- sock = createConnection(DAEMON_SOCKET_PATH);
55
+ sock = createConnection(socketPath);
55
56
  } catch (e) {
56
- settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
58
  return;
58
59
  }
59
60
  socket = sock;
60
-
61
+ // The client is kept connected across sessions on purpose (index.ts session_shutdown), so this
62
+ // socket must not be what keeps the process alive: in `pi -p` it was the last handle standing
63
+ // and pi never exited (#39). In-flight requests hold their own ref'd timers, so nothing a
64
+ // caller is awaiting can be dropped by this.
65
+ sock.unref();
66
+
67
+ // The timer is the one ref'd handle for the whole handshake: with the socket unref'd, nothing
68
+ // else stops the process exiting between TCP connect and the daemon's "registered" reply.
69
+ // It is cleared only in settle(), so a daemon that accepts but never registers times out.
70
+ let settled = false;
61
71
  const connectTimer = setTimeout(() => {
62
- cleanup("Connection timeout");
72
+ cleanup(sock, "Connection timeout");
63
73
  settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
64
74
  }, timeoutMs);
65
-
66
- sock.on("connect", () => {
75
+ const settle = (r: ConnectResult): void => {
76
+ if (settled) return;
77
+ settled = true;
67
78
  clearTimeout(connectTimer);
68
- rl = createInterface({ input: sock, crlfDelay: Infinity });
79
+ resolve(r);
80
+ };
69
81
 
70
- rl.on("line", (line: string) => {
82
+ sock.on("connect", () => {
83
+ stopReading = readLines(sock, (line) => {
84
+ if (socket !== sock) return;
71
85
  const msg = deserialize(line);
72
86
  if (!msg) return;
73
87
 
74
- if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
75
- registered = true;
76
- settle(ok(undefined));
77
- return;
78
- }
79
-
80
- if (msg.type === "control" && msg.action === "shutdown") {
81
- cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
88
+ if (msg.type === "control") {
89
+ if (msg.action === "registered" && msg.clientId === clientId) {
90
+ registered = true;
91
+ settle(ok(undefined));
92
+ } else if (msg.action === "shutdown") {
93
+ cleanup(sock, `Daemon shutting down: ${msg.reason ?? "unknown"}`);
94
+ settle(err(cdpError("transport_closed", `Daemon refused the connection: ${msg.reason ?? "unknown"}`)));
95
+ }
82
96
  return;
83
97
  }
84
98
 
@@ -104,38 +118,60 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
104
118
  params: msg.params,
105
119
  ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
106
120
  });
107
- return;
108
121
  }
109
122
  });
110
123
 
111
- sock.on("error", () => {});
112
-
113
- sock.on("close", () => {
114
- clearTimeout(connectTimer);
115
- cleanup("Daemon socket closed");
116
- settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
117
- });
118
-
119
124
  const regMsg: WireControl = { type: "control", action: "register", clientId };
120
125
  sock.write(serialize(regMsg) + "\n");
121
126
  });
122
127
 
128
+ // Fires before "close" on any failure, so the message it carries wins over the generic one.
129
+ // Tearing down here too means state() reads "closed" as soon as the caller sees the error.
123
130
  sock.on("error", (e: NodeJS.ErrnoException) => {
124
- clearTimeout(connectTimer);
125
131
  const msg = e.code === "ENOENT"
126
132
  ? "Daemon not running — socket not found"
127
133
  : e.message;
134
+ cleanup(sock, msg);
128
135
  settle(err(cdpError("transport_closed", msg)));
129
136
  });
137
+
138
+ sock.on("close", () => {
139
+ cleanup(sock, "Daemon socket closed");
140
+ settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
141
+ });
130
142
  });
143
+
144
+ // `_url` is part of CdpTransport but meaningless here: the daemon discovers and owns the
145
+ // Chrome endpoint itself, and this transport always dials the daemon's own socket path.
146
+ const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<ConnectResult> => {
147
+ const timeoutMs = opts?.timeoutMs ?? 10_000;
148
+
149
+ if (socket && !socket.destroyed && registered) {
150
+ return Promise.resolve(ok(undefined));
151
+ }
152
+ if (inFlight) return inFlight;
153
+
154
+ const attempt = handshake(timeoutMs).then((r) => {
155
+ if (inFlight === attempt) inFlight = null;
156
+ return r;
157
+ });
158
+ inFlight = attempt;
159
+ return attempt;
131
160
  };
132
161
 
133
- const close = async (): Promise<void> => {
134
- if (registered && socket && !socket.destroyed) {
162
+ const close = (): Promise<void> => {
163
+ const sock = socket;
164
+ if (!sock) return Promise.resolve();
165
+ if (registered && !sock.destroyed) {
135
166
  const dereg: WireControl = { type: "control", action: "deregister", clientId };
136
- try { socket.write(serialize(dereg) + "\n"); } catch {}
167
+ try {
168
+ sock.write(serialize(dereg) + "\n");
169
+ } catch {
170
+ // The daemon is already gone; cleanup below drops the socket regardless.
171
+ }
137
172
  }
138
- cleanup("close() called");
173
+ cleanup(sock, "close() called");
174
+ return Promise.resolve();
139
175
  };
140
176
 
141
177
  const request = (
@@ -157,7 +193,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
157
193
  id,
158
194
  method,
159
195
  params,
160
- ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
196
+ ...(sessionId !== null ? { sessionId } : {}),
161
197
  };
162
198
 
163
199
  return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));