@hyperdrive.bot/fleet-server 0.3.155 → 0.3.158

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 (31) hide show
  1. package/dist/server/server/bootstrap.js +8 -0
  2. package/dist/server/server/ingestion/apply.js +12 -0
  3. package/dist/server/server/ingestion/dry-run.d.ts +3 -1
  4. package/dist/server/server/ingestion/dry-run.js +8 -0
  5. package/dist/server/server/ingestion/subscriptions/nudge-route.d.ts +42 -0
  6. package/dist/server/server/ingestion/subscriptions/nudge-route.js +67 -0
  7. package/dist/server/server/ingestion/subscriptions/poller.d.ts +11 -0
  8. package/dist/server/server/ingestion/subscriptions/poller.js +23 -3
  9. package/dist/server/server/session/chat/chat-ingestion-session.js +5 -1
  10. package/dist/server/utils/git-command-cache.d.ts +67 -0
  11. package/dist/server/utils/git-command-cache.js +436 -0
  12. package/dist/server/utils/run-git-command.js +14 -4
  13. package/dist/server/utils/spawn-broker-child.mjs +156 -0
  14. package/dist/server/utils/spawn-broker.d.ts +120 -0
  15. package/dist/server/utils/spawn-broker.js +299 -0
  16. package/dist/server/utils/spawn.d.ts +21 -0
  17. package/dist/server/utils/spawn.js +144 -10
  18. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js → index-461552a3716a48e99f87634c93c7b220.js} +6 -6
  19. package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.br +0 -0
  20. package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.gz +0 -0
  21. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.br → index-461552a3716a48e99f87634c93c7b220.js.map.br} +0 -0
  22. package/dist/server/web-ui/_expo/static/js/web/{index-81ee92214133211579a50ef5c84491cf.js.map.gz → index-461552a3716a48e99f87634c93c7b220.js.map.gz} +0 -0
  23. package/dist/server/web-ui/index.html +1 -1
  24. package/dist/server/web-ui/index.html.br +0 -0
  25. package/dist/server/web-ui/index.html.gz +0 -0
  26. package/dist/src/utils/spawn-broker-child.mjs +156 -0
  27. package/dist/src/utils/spawn-broker.js +299 -0
  28. package/dist/src/utils/spawn.js +144 -10
  29. package/package.json +8 -8
  30. package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.br +0 -0
  31. package/dist/server/web-ui/_expo/static/js/web/index-81ee92214133211579a50ef5c84491cf.js.gz +0 -0
@@ -0,0 +1,436 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ const MAX_ENTRIES = 5000;
5
+ const DIRS_CACHE_TTL_MS = 5000;
6
+ const ENV_KEYS_THAT_BYPASS = [
7
+ "GIT_DIR",
8
+ "GIT_WORK_TREE",
9
+ "GIT_INDEX_FILE",
10
+ "GIT_COMMON_DIR",
11
+ "GIT_CONFIG",
12
+ "GIT_CONFIG_GLOBAL",
13
+ "GIT_CONFIG_SYSTEM",
14
+ "GIT_CONFIG_COUNT",
15
+ "GIT_CONFIG_PARAMETERS",
16
+ "GIT_CONFIG_NOSYSTEM",
17
+ "GIT_NAMESPACE",
18
+ ];
19
+ function cacheTtlMs() {
20
+ const raw = process.env.PASEO_GIT_CACHE_TTL_MS;
21
+ const parsed = raw === undefined ? NaN : Number(raw);
22
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5000;
23
+ }
24
+ function ttlFor(commandClass) {
25
+ if (commandClass === "location")
26
+ return Number.POSITIVE_INFINITY;
27
+ if (commandClass === "worktree")
28
+ return 0;
29
+ return cacheTtlMs();
30
+ }
31
+ export function isGitCacheEnabled() {
32
+ const flag = process.env.PASEO_GIT_CACHE;
33
+ return flag !== "0" && flag !== "false";
34
+ }
35
+ /**
36
+ * Subcommands that never change refs, config or HEAD. Running one of them does not
37
+ * invalidate the repo's cached answers; running anything else does.
38
+ */
39
+ const READ_ONLY_SUBCOMMANDS = new Set([
40
+ "status",
41
+ "log",
42
+ "show",
43
+ "diff",
44
+ "ls-files",
45
+ "ls-tree",
46
+ "ls-remote",
47
+ "cat-file",
48
+ "rev-list",
49
+ "rev-parse",
50
+ "for-each-ref",
51
+ "show-ref",
52
+ "merge-base",
53
+ "merge-tree",
54
+ "blame",
55
+ "grep",
56
+ "describe",
57
+ "name-rev",
58
+ "check-ignore",
59
+ "check-ref-format",
60
+ "var",
61
+ "version",
62
+ "--version",
63
+ ]);
64
+ const BRANCH_WRITE_FLAGS = new Set([
65
+ "-d",
66
+ "-D",
67
+ "--delete",
68
+ "-m",
69
+ "-M",
70
+ "--move",
71
+ "-c",
72
+ "-C",
73
+ "--copy",
74
+ "-u",
75
+ "--set-upstream-to",
76
+ "--unset-upstream",
77
+ "-f",
78
+ "--force",
79
+ "--edit-description",
80
+ ]);
81
+ const BRANCH_LIST_FLAGS = new Set([
82
+ "--list",
83
+ "-l",
84
+ "--format",
85
+ "--show-current",
86
+ "-a",
87
+ "--all",
88
+ "-r",
89
+ "--remotes",
90
+ "-v",
91
+ "-vv",
92
+ "--contains",
93
+ "--merged",
94
+ "--no-merged",
95
+ "--points-at",
96
+ ]);
97
+ const REMOTE_READ_FORMS = new Set(["-v", "get-url", "show"]);
98
+ const REV_PARSE_LOCATION_FLAGS = new Set([
99
+ "--git-common-dir",
100
+ "--absolute-git-dir",
101
+ "--git-dir",
102
+ "--show-toplevel",
103
+ "--show-cdup",
104
+ "--show-prefix",
105
+ "--is-inside-work-tree",
106
+ "--is-bare-repository",
107
+ ]);
108
+ const REV_PARSE_REF_FLAGS = new Set([
109
+ "--abbrev-ref",
110
+ "--verify",
111
+ "--quiet",
112
+ "-q",
113
+ "--symbolic-full-name",
114
+ "--short",
115
+ ]);
116
+ const CONFIG_READ_FLAGS = new Set([
117
+ "--get",
118
+ "--get-all",
119
+ "--get-regexp",
120
+ "--bool",
121
+ "--int",
122
+ "--null",
123
+ "-z",
124
+ ]);
125
+ const isFlag = (arg) => arg.startsWith("-");
126
+ function isBranchListing(rest) {
127
+ // Listing forms only: `branch`, `branch --format=...`, `branch --show-current`, `branch -a`.
128
+ const names = rest.map((arg) => arg.split("=")[0] ?? arg);
129
+ if (names.some((name) => BRANCH_WRITE_FLAGS.has(name)))
130
+ return false;
131
+ return names.some((name) => BRANCH_LIST_FLAGS.has(name)) || rest.every(isFlag);
132
+ }
133
+ export function isReadOnlyCommand(args) {
134
+ const [sub, next] = args;
135
+ if (!sub)
136
+ return false;
137
+ if (READ_ONLY_SUBCOMMANDS.has(sub))
138
+ return true;
139
+ if (sub === "worktree")
140
+ return next === "list";
141
+ if (sub === "branch")
142
+ return isBranchListing(args.slice(1));
143
+ if (sub === "remote")
144
+ return next === undefined || REMOTE_READ_FORMS.has(next);
145
+ return false;
146
+ }
147
+ function classifyRevParse(rest) {
148
+ if (rest.length > 0 && rest.every((arg) => REV_PARSE_LOCATION_FLAGS.has(arg))) {
149
+ return "location";
150
+ }
151
+ const hasPositional = rest.some((arg) => !isFlag(arg));
152
+ const flagsOk = rest
153
+ .filter(isFlag)
154
+ .every((arg) => REV_PARSE_REF_FLAGS.has(arg) || /^--short=\d+$/.test(arg));
155
+ return hasPositional && flagsOk ? "refs" : null;
156
+ }
157
+ function classifyConfig(rest) {
158
+ // Only pure reads.
159
+ const flags = rest.filter(isFlag);
160
+ const isRead = flags.some((flag) => flag === "--get" || flag === "--get-all" || flag === "--get-regexp");
161
+ return isRead && flags.every((flag) => CONFIG_READ_FLAGS.has(flag)) ? "config" : null;
162
+ }
163
+ function classifySymbolicRef(rest) {
164
+ // Reads only: with a second positional, or -d, it would write.
165
+ const positionals = rest.filter((arg) => !isFlag(arg));
166
+ const deletes = rest.includes("--delete") || rest.includes("-d");
167
+ return positionals.length <= 1 && !deletes ? "refs" : null;
168
+ }
169
+ function classifyRevList(rest) {
170
+ // Ahead/behind counts: `rev-list --left-right --count A...B`.
171
+ const flags = rest.filter(isFlag);
172
+ const countsOnly = flags.every((flag) => flag === "--left-right" || flag === "--count");
173
+ return flags.length > 0 && countsOnly ? "refs" : null;
174
+ }
175
+ const CLASSIFIERS = {
176
+ "rev-parse": classifyRevParse,
177
+ config: classifyConfig,
178
+ "symbolic-ref": classifySymbolicRef,
179
+ "show-ref": (rest) => (rest.includes("--verify") || rest.includes("--exists") ? "refs" : null),
180
+ "merge-base": (rest) => rest.every((arg) => !isFlag(arg) || arg === "--is-ancestor") ? "refs" : null,
181
+ // Listing forms read refs (and upstream config for -vv).
182
+ branch: (rest) => (isBranchListing(rest) ? "refs" : null),
183
+ "rev-list": classifyRevList,
184
+ // Working tree readers: coalesced while in flight, never served after settling.
185
+ "ls-files": (rest) => (rest.includes("--others") || rest.includes("-o") ? "worktree" : null),
186
+ diff: (rest) => (rest.includes("--shortstat") ? "worktree" : null),
187
+ status: () => "worktree",
188
+ };
189
+ /** Classify a command, or return null when it must not be cached. */
190
+ export function classifyGitCommand(args) {
191
+ const [sub, ...rest] = args;
192
+ // Upstream/push/reflog selectors resolve through config and remote-tracking refs
193
+ // we do not fingerprint precisely.
194
+ if (rest.some((arg) => arg.includes("@{")))
195
+ return null;
196
+ const classify = sub ? CLASSIFIERS[sub] : undefined;
197
+ return classify ? classify(rest) : null;
198
+ }
199
+ function statKey(path) {
200
+ try {
201
+ const stat = statSync(path, { bigint: true });
202
+ return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
203
+ }
204
+ catch {
205
+ return "-";
206
+ }
207
+ }
208
+ function readTrimmed(path) {
209
+ try {
210
+ return readFileSync(path, "utf8").trim();
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ export class GitCommandCache {
217
+ constructor() {
218
+ this.entries = new Map();
219
+ this.dirsByCwd = new Map();
220
+ this.generations = new Map();
221
+ this.stats = { hits: 0, misses: 0, coalesced: 0, invalidations: 0 };
222
+ }
223
+ clear() {
224
+ this.entries.clear();
225
+ this.dirsByCwd.clear();
226
+ this.generations.clear();
227
+ }
228
+ /**
229
+ * Walk up from cwd to the `.git` entry and resolve worktree gitfiles and
230
+ * `commondir`. Pure filesystem work, no git spawn; cached per cwd briefly and
231
+ * re-validated by the `.git` entry still existing.
232
+ */
233
+ resolveRepoDirs(cwd) {
234
+ const now = Date.now();
235
+ const cached = this.dirsByCwd.get(cwd);
236
+ if (cached && now - cached.checkedAt < DIRS_CACHE_TTL_MS) {
237
+ return cached.dirs;
238
+ }
239
+ const dirs = findRepoDirs(cwd);
240
+ this.dirsByCwd.set(cwd, { dirs, checkedAt: now });
241
+ return dirs;
242
+ }
243
+ run(args, cwd, env, extraKey, execute) {
244
+ if (!isGitCacheEnabled() || bypassesForEnv(env)) {
245
+ return execute();
246
+ }
247
+ const dirs = this.resolveRepoDirs(cwd);
248
+ if (!dirs) {
249
+ return execute();
250
+ }
251
+ const commandClass = classifyGitCommand(args);
252
+ if (!commandClass) {
253
+ if (isReadOnlyCommand(args)) {
254
+ return execute();
255
+ }
256
+ // Anything we do not know to be read-only may change the repo.
257
+ this.invalidate(dirs.commonDir);
258
+ return execute().finally(() => this.invalidate(dirs.commonDir));
259
+ }
260
+ const key = JSON.stringify([cwd, args, extraKey, env ?? null]);
261
+ const fingerprint = computeFingerprint(commandClass, args, dirs);
262
+ const generation = this.generations.get(dirs.commonDir) ?? 0;
263
+ const now = Date.now();
264
+ const existing = this.entries.get(key);
265
+ if (existing &&
266
+ existing.fingerprint === fingerprint &&
267
+ existing.generation === generation &&
268
+ existing.expiresAt > now) {
269
+ if (existing.expiresAt === Number.POSITIVE_INFINITY) {
270
+ this.stats.coalesced += 1;
271
+ }
272
+ else {
273
+ this.stats.hits += 1;
274
+ }
275
+ // Refresh LRU position.
276
+ this.entries.delete(key);
277
+ this.entries.set(key, existing);
278
+ return existing.promise;
279
+ }
280
+ this.stats.misses += 1;
281
+ const ttl = ttlFor(commandClass);
282
+ const promise = execute();
283
+ const entry = {
284
+ fingerprint,
285
+ generation,
286
+ // Single-flight while pending; after settling, keep for the TTL.
287
+ expiresAt: Number.POSITIVE_INFINITY,
288
+ promise,
289
+ };
290
+ this.entries.set(key, entry);
291
+ this.evictIfNeeded();
292
+ promise.then(() => {
293
+ entry.expiresAt = Date.now() + ttl;
294
+ return undefined;
295
+ }, (error) => {
296
+ // Cache definite answers (a nonzero git exit), never transient failures.
297
+ if (typeof error?.gitExitCode === "number") {
298
+ entry.expiresAt = Date.now() + ttl;
299
+ }
300
+ else if (this.entries.get(key) === entry) {
301
+ this.entries.delete(key);
302
+ }
303
+ });
304
+ return promise;
305
+ }
306
+ invalidate(commonDir) {
307
+ this.stats.invalidations += 1;
308
+ this.generations.set(commonDir, (this.generations.get(commonDir) ?? 0) + 1);
309
+ }
310
+ evictIfNeeded() {
311
+ while (this.entries.size > MAX_ENTRIES) {
312
+ const oldest = this.entries.keys().next().value;
313
+ if (oldest === undefined)
314
+ break;
315
+ this.entries.delete(oldest);
316
+ }
317
+ }
318
+ }
319
+ function bypassesForEnv(env) {
320
+ for (const key of ENV_KEYS_THAT_BYPASS) {
321
+ if (process.env[key] !== undefined || env?.[key] !== undefined) {
322
+ return true;
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+ export function findRepoDirs(cwd) {
328
+ let current = resolve(cwd);
329
+ for (;;) {
330
+ const dotGit = join(current, ".git");
331
+ let isDir = false;
332
+ let exists = false;
333
+ try {
334
+ const stat = statSync(dotGit);
335
+ exists = true;
336
+ isDir = stat.isDirectory();
337
+ }
338
+ catch {
339
+ exists = false;
340
+ }
341
+ if (exists) {
342
+ let gitDir = dotGit;
343
+ if (!isDir) {
344
+ const content = readTrimmed(dotGit);
345
+ const match = content?.match(/^gitdir:\s*(.+)$/m);
346
+ if (!match)
347
+ return null;
348
+ gitDir = isAbsolute(match[1]) ? match[1] : resolve(current, match[1]);
349
+ }
350
+ const commonRel = readTrimmed(join(gitDir, "commondir"));
351
+ let commonDir = gitDir;
352
+ if (commonRel) {
353
+ commonDir = isAbsolute(commonRel) ? commonRel : resolve(gitDir, commonRel);
354
+ }
355
+ if (usesReftable(commonDir))
356
+ return null;
357
+ return { dotGit, gitDir, commonDir };
358
+ }
359
+ const parent = dirname(current);
360
+ if (parent === current)
361
+ return null;
362
+ current = parent;
363
+ }
364
+ }
365
+ function usesReftable(commonDir) {
366
+ // Reftable keeps refs in reftable/*.ref; none of the files we stat change on a
367
+ // commit there, so such repos are simply not cached.
368
+ try {
369
+ statSync(join(commonDir, "reftable"));
370
+ return true;
371
+ }
372
+ catch {
373
+ // no reftable dir
374
+ }
375
+ const config = readTrimmed(join(commonDir, "config")) ?? "";
376
+ return /refstorage\s*=\s*reftable/i.test(config);
377
+ }
378
+ function globalConfigPaths() {
379
+ const home = process.env.HOME ?? homedir();
380
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
381
+ return [join(home, ".gitconfig"), join(xdg, "git", "config"), "/etc/gitconfig"];
382
+ }
383
+ function refFilesFor(args, dirs) {
384
+ const files = [];
385
+ const headContent = readTrimmed(join(dirs.gitDir, "HEAD"));
386
+ const headRef = headContent?.startsWith("ref:") ? headContent.slice(4).trim() : null;
387
+ if (headRef)
388
+ files.push(join(dirs.commonDir, headRef));
389
+ const names = args
390
+ .slice(1)
391
+ .filter((arg) => !arg.startsWith("-"))
392
+ .flatMap((arg) => arg.split(/\.{2,3}/));
393
+ for (const raw of names) {
394
+ const name = raw.replace(/[\^~].*$/, "");
395
+ if (!name || name === "HEAD")
396
+ continue;
397
+ if (name.startsWith("refs/")) {
398
+ files.push(join(dirs.commonDir, name));
399
+ }
400
+ else {
401
+ files.push(join(dirs.commonDir, "refs", "heads", name), join(dirs.commonDir, "refs", "remotes", name), join(dirs.commonDir, "refs", "tags", name));
402
+ }
403
+ }
404
+ return files;
405
+ }
406
+ function computeFingerprint(commandClass, args, dirs) {
407
+ const paths = [dirs.dotGit];
408
+ const configPaths = [
409
+ join(dirs.commonDir, "config"),
410
+ join(dirs.gitDir, "config.worktree"),
411
+ ...globalConfigPaths(),
412
+ ];
413
+ switch (commandClass) {
414
+ case "location":
415
+ break;
416
+ case "config":
417
+ paths.push(...configPaths);
418
+ break;
419
+ case "worktree":
420
+ // Never served after settling; the fingerprint only scopes coalescing.
421
+ paths.push(join(dirs.gitDir, "HEAD"), join(dirs.gitDir, "index"));
422
+ break;
423
+ case "refs":
424
+ paths.push(join(dirs.gitDir, "HEAD"), join(dirs.commonDir, "packed-refs"), join(dirs.commonDir, "FETCH_HEAD"), join(dirs.commonDir, "refs", "heads"), join(dirs.commonDir, "refs", "remotes"), join(dirs.commonDir, "refs", "tags"), ...refFilesFor(args, dirs),
425
+ // Upstream tracking (branch -vv) lives in config.
426
+ ...configPaths);
427
+ break;
428
+ }
429
+ return paths.map(statKey).join("|");
430
+ }
431
+ let sharedCache = null;
432
+ export function getSharedGitCommandCache() {
433
+ sharedCache ?? (sharedCache = new GitCommandCache());
434
+ return sharedCache;
435
+ }
436
+ //# sourceMappingURL=git-command-cache.js.map
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pLimit from "p-limit";
3
- import { spawnProcess } from "./spawn.js";
3
+ import { getSharedGitCommandCache, isReadOnlyCommand } from "./git-command-cache.js";
4
+ import { spawnNonInteractive } from "./spawn.js";
4
5
  const DEFAULT_TIMEOUT_MS = 30000;
5
6
  const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
6
7
  const DEFAULT_STDERR_LIMIT = 2048;
@@ -61,6 +62,11 @@ function getEnvOverlayKeys(envOverlay) {
61
62
  return Object.keys(envOverlay ?? {}).sort();
62
63
  }
63
64
  export function runGitCommand(args, options) {
65
+ // Read-only commands are answered from a per-repo cache validated by .git stat
66
+ // fingerprints, and identical concurrent commands share one spawn.
67
+ return getSharedGitCommandCache().run(args, options.cwd, mergeEnvOverlays(options.env, options.envOverlay), JSON.stringify([options.acceptExitCodes ?? [0], options.maxOutputBytes ?? null]), () => runGitCommandUncached(args, options));
68
+ }
69
+ function runGitCommandUncached(args, options) {
64
70
  return gitLimit(() => new Promise((resolve, reject) => {
65
71
  const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
66
72
  const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
@@ -87,11 +93,14 @@ export function runGitCommand(args, options) {
87
93
  }
88
94
  // `core.quotepath=false` makes git emit raw UTF-8 paths instead of
89
95
  // octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`).
90
- const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], {
96
+ const child = spawnNonInteractive("git", ["-c", "core.quotepath=false", ...args], {
91
97
  cwd: options.cwd,
92
98
  envOverlay,
93
99
  shell: false,
94
- stdio: ["ignore", "pipe", "pipe"],
100
+ // One byte over the cap so the truncation below still triggers.
101
+ maxStdoutBytes: maxOutputBytes + 1,
102
+ maxStderrBytes: DEFAULT_STDERR_LIMIT,
103
+ retryOnDirect: isReadOnlyCommand(args),
95
104
  });
96
105
  let settled = false;
97
106
  let metricFinished = false;
@@ -210,7 +219,8 @@ export function runGitCommand(args, options) {
210
219
  });
211
220
  const stderrPreview = result.stderr.trim() || "(no stderr)";
212
221
  const truncationNote = result.truncated ? " (stdout truncated)" : "";
213
- settle(() => reject(new Error(`Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`)));
222
+ const error = Object.assign(new Error(`Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`), { gitExitCode: exitCode });
223
+ settle(() => reject(error));
214
224
  return;
215
225
  }
216
226
  finishMetricOnce({
@@ -0,0 +1,156 @@
1
+ // Spawn broker helper process. The daemon forks this small Node process once and
2
+ // asks it to run non-interactive subprocesses (git, gh, which, ...) over IPC, so
3
+ // the daemon itself never forks its multi-GB image: on WSL2 every direct spawn()
4
+ // from the daemon blocked its event loop for about 100ms.
5
+ //
6
+ // Protocol (JSON over the IPC channel):
7
+ // parent -> child { type: "spawn", id, command, args, cwd, env, shell }
8
+ // { type: "kill", id, signal }
9
+ // child -> parent { type: "spawned", id, pid }
10
+ // { type: "data", id, fd: 1 | 2, data: base64 }
11
+ // { type: "error", id, error: { message, code, errno, syscall, path } }
12
+ // { type: "close", id, exitCode, signal }
13
+ //
14
+ // Timeouts, output caps and exit-code policy stay in the parent, which kills the
15
+ // child through the "kill" message exactly like it would kill a direct ChildProcess.
16
+ import { spawn } from "node:child_process";
17
+
18
+ const children = new Map();
19
+
20
+ function send(message) {
21
+ // Returns false when the channel is backed up (or gone; the disconnect handler
22
+ // cleans up in that case).
23
+ return sendRaw(message);
24
+ }
25
+
26
+ function serializeError(error) {
27
+ return {
28
+ message: error?.message ?? String(error),
29
+ code: error?.code,
30
+ errno: error?.errno,
31
+ syscall: error?.syscall,
32
+ path: error?.path,
33
+ };
34
+ }
35
+
36
+ const CHUNK_BYTES = 64 * 1024;
37
+
38
+ // Forward a stream in bounded chunks, stop forwarding once the caller's cap is
39
+ // reached (the caller decides whether to kill), and pause the source while the
40
+ // IPC channel is backed up so output never piles up in memory on either side.
41
+ function forward(stream, id, fd, maxBytes) {
42
+ const cap = Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : Infinity;
43
+ let forwarded = 0;
44
+ stream.on("data", (chunk) => {
45
+ if (forwarded >= cap) return;
46
+ const slice = chunk.length > cap - forwarded ? chunk.subarray(0, cap - forwarded) : chunk;
47
+ forwarded += slice.length;
48
+ for (let offset = 0; offset < slice.length; offset += CHUNK_BYTES) {
49
+ const part = slice.subarray(offset, offset + CHUNK_BYTES);
50
+ const ok = send({ type: "data", id, fd, data: part.toString("base64") });
51
+ if (!ok) {
52
+ stream.pause();
53
+ waitForDrain(() => stream.resume());
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ const drainWaiters = [];
60
+ function waitForDrain(callback) {
61
+ drainWaiters.push(callback);
62
+ if (drainWaiters.length > 1) return;
63
+ // An empty message's send callback fires once everything queued before it
64
+ // has been written to the channel.
65
+ sendRaw({ type: "noop" }, () => {
66
+ const waiters = drainWaiters.splice(0);
67
+ for (const waiter of waiters) waiter();
68
+ });
69
+ }
70
+
71
+ function sendRaw(message, callback) {
72
+ if (!process.connected) return false;
73
+ try {
74
+ return process.send(message, undefined, undefined, callback);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function handleSpawn(message) {
81
+ const { id } = message;
82
+ let child;
83
+ try {
84
+ child = spawn(message.command, message.args, {
85
+ cwd: message.cwd,
86
+ env: message.env,
87
+ shell: message.shell ?? false,
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ windowsHide: true,
90
+ });
91
+ } catch (error) {
92
+ send({ type: "error", id, error: serializeError(error) });
93
+ return;
94
+ }
95
+ children.set(id, child);
96
+ if (child.pid !== undefined) {
97
+ send({ type: "spawned", id, pid: child.pid });
98
+ }
99
+ forward(child.stdout, id, 1, message.maxStdoutBytes);
100
+ forward(child.stderr, id, 2, message.maxStderrBytes);
101
+ child.on("error", (error) => {
102
+ children.delete(id);
103
+ send({ type: "error", id, error: serializeError(error) });
104
+ });
105
+ child.on("close", (exitCode, signal) => {
106
+ children.delete(id);
107
+ send({ type: "close", id, exitCode, signal });
108
+ });
109
+ }
110
+
111
+ function killAll() {
112
+ for (const child of children.values()) {
113
+ try {
114
+ child.kill("SIGKILL");
115
+ } catch {
116
+ // already gone
117
+ }
118
+ }
119
+ children.clear();
120
+ }
121
+
122
+ process.on("message", (message) => {
123
+ if (!message || typeof message !== "object") return;
124
+ if (message.type === "spawn") {
125
+ handleSpawn(message);
126
+ return;
127
+ }
128
+ if (message.type === "kill") {
129
+ const child = children.get(message.id);
130
+ if (child) {
131
+ try {
132
+ child.kill(message.signal ?? "SIGTERM");
133
+ } catch {
134
+ // already gone
135
+ }
136
+ }
137
+ return;
138
+ }
139
+ if (message.type === "crash-for-test" && process.env.PASEO_SPAWN_BROKER_TEST_HOOKS === "1") {
140
+ process.exit(1);
141
+ }
142
+ });
143
+
144
+ // The daemon owns us: when its IPC channel closes (clean shutdown or crash) we take
145
+ // our children down and exit, so nothing is orphaned.
146
+ process.on("disconnect", () => {
147
+ killAll();
148
+ process.exit(0);
149
+ });
150
+
151
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
152
+ process.on(signal, () => {
153
+ killAll();
154
+ process.exit(0);
155
+ });
156
+ }