@hominis/fireforge 0.36.0 → 0.38.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +6 -6
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  6. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  7. package/dist/src/commands/doctor.js +2 -0
  8. package/dist/src/commands/download.js +5 -2
  9. package/dist/src/commands/export-flow.d.ts +5 -1
  10. package/dist/src/commands/export-flow.js +10 -7
  11. package/dist/src/commands/export-placement-gate.js +3 -5
  12. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  13. package/dist/src/commands/export-placement-policy.js +32 -29
  14. package/dist/src/commands/export.js +12 -5
  15. package/dist/src/commands/furnace/index.js +23 -18
  16. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  17. package/dist/src/commands/patch/move-files-create.js +116 -0
  18. package/dist/src/commands/patch/move-files.d.ts +3 -1
  19. package/dist/src/commands/patch/move-files.js +49 -4
  20. package/dist/src/commands/patch/split.d.ts +11 -1
  21. package/dist/src/commands/patch/split.js +6 -5
  22. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  23. package/dist/src/commands/patch/staged-dependency.js +113 -51
  24. package/dist/src/commands/re-export-files.js +4 -0
  25. package/dist/src/commands/re-export-scan.d.ts +17 -0
  26. package/dist/src/commands/re-export-scan.js +51 -0
  27. package/dist/src/commands/re-export.js +25 -6
  28. package/dist/src/commands/source.js +28 -3
  29. package/dist/src/commands/test-diagnose.js +20 -1
  30. package/dist/src/commands/test-register.js +6 -5
  31. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  32. package/dist/src/commands/test-stale-gate.js +81 -0
  33. package/dist/src/commands/test.js +40 -23
  34. package/dist/src/core/build-audit.d.ts +12 -0
  35. package/dist/src/core/build-audit.js +14 -0
  36. package/dist/src/core/build-baseline-types.d.ts +58 -0
  37. package/dist/src/core/build-baseline.d.ts +10 -2
  38. package/dist/src/core/build-baseline.js +51 -7
  39. package/dist/src/core/config-paths.d.ts +1 -1
  40. package/dist/src/core/config-paths.js +1 -0
  41. package/dist/src/core/config-validate.js +6 -1
  42. package/dist/src/core/engine-session-lock.d.ts +10 -1
  43. package/dist/src/core/engine-session-lock.js +28 -2
  44. package/dist/src/core/file-lock.d.ts +33 -0
  45. package/dist/src/core/file-lock.js +32 -6
  46. package/dist/src/core/firefox-archive.d.ts +2 -1
  47. package/dist/src/core/firefox-archive.js +32 -8
  48. package/dist/src/core/firefox.d.ts +8 -3
  49. package/dist/src/core/firefox.js +11 -6
  50. package/dist/src/core/furnace-config-custom.js +10 -0
  51. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  52. package/dist/src/core/furnace-stale-export.js +123 -0
  53. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  54. package/dist/src/core/furnace-validate-helpers.js +28 -1
  55. package/dist/src/core/furnace-validate-structure.js +3 -2
  56. package/dist/src/core/license-headers.d.ts +12 -0
  57. package/dist/src/core/license-headers.js +76 -1
  58. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  59. package/dist/src/core/mach-known-noise-filter.js +193 -0
  60. package/dist/src/core/mach.d.ts +8 -0
  61. package/dist/src/core/mach.js +36 -3
  62. package/dist/src/core/patch-lint-cross.js +80 -2
  63. package/dist/src/core/patch-lint.d.ts +3 -0
  64. package/dist/src/core/patch-lint.js +25 -7
  65. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  66. package/dist/src/core/patch-manifest-io.js +27 -16
  67. package/dist/src/core/patch-manifest-validate.js +24 -0
  68. package/dist/src/core/status-classify.d.ts +11 -2
  69. package/dist/src/core/status-classify.js +49 -25
  70. package/dist/src/core/test-harness-crash.d.ts +16 -0
  71. package/dist/src/core/test-harness-crash.js +55 -0
  72. package/dist/src/core/test-stale-check.d.ts +69 -1
  73. package/dist/src/core/test-stale-check.js +134 -1
  74. package/dist/src/types/commands/index.d.ts +1 -1
  75. package/dist/src/types/commands/options.d.ts +84 -7
  76. package/dist/src/types/commands/patches.d.ts +29 -0
  77. package/dist/src/types/config.d.ts +7 -0
  78. package/dist/src/types/furnace.d.ts +13 -0
  79. package/dist/src/utils/options.d.ts +20 -0
  80. package/dist/src/utils/options.js +39 -0
  81. package/dist/src/utils/process-group.d.ts +33 -0
  82. package/dist/src/utils/process-group.js +161 -0
  83. package/dist/src/utils/process.d.ts +15 -0
  84. package/dist/src/utils/process.js +88 -44
  85. package/dist/src/utils/validation.d.ts +5 -0
  86. package/dist/src/utils/validation.js +7 -0
  87. package/package.json +3 -3
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Process-group kill and post-run sweep helpers for the exec layer
3
+ * (0.37.0 item 9a). Split out of `process.ts` to keep that file within the
4
+ * per-file line budget; deliberately spawn-based (not `exec`-based) so the
5
+ * two modules do not import each other cyclically.
6
+ */
7
+ import { type ChildProcess } from 'node:child_process';
8
+ /**
9
+ * Sends `signal` to the child's whole tree: the process GROUP on POSIX
10
+ * (negative-PID kill — reaches mach → python → firefox → content-process
11
+ * chains), or a `taskkill /T /F` tree kill plus a direct `child.kill`
12
+ * fallback on Windows. No-ops once the direct child has exited (group
13
+ * survivors after exit are the post-run sweep's job, not this function's).
14
+ */
15
+ export declare function killProcessTree(child: ChildProcess, signal: NodeJS.Signals, usesProcessGroup: boolean): void;
16
+ /** One process still alive in a swept group. */
17
+ export interface ProcessGroupSurvivor {
18
+ /** PID, or -1 when pgrep was unavailable and only a liveness probe ran. */
19
+ pid: number;
20
+ /** Command line (pgrep -lf output), best-effort. */
21
+ command: string;
22
+ }
23
+ /**
24
+ * Post-run reaper for a process group FireForge itself spawned (`pgid` is
25
+ * the PID of a child started with `detached: true`): lists survivors,
26
+ * SIGTERMs the group, waits a short grace, escalates to SIGKILL, and warns
27
+ * about anything that still refuses to die. POSIX only (no-op on win32).
28
+ * The only kill target is `-pgid` — never anything outside the group.
29
+ * A healthy run costs exactly one `pgrep`.
30
+ */
31
+ export declare function sweepProcessGroup(pgid: number, graceMs?: number): Promise<{
32
+ survivors: ProcessGroupSurvivor[];
33
+ }>;
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Process-group kill and post-run sweep helpers for the exec layer
4
+ * (0.37.0 item 9a). Split out of `process.ts` to keep that file within the
5
+ * per-file line budget; deliberately spawn-based (not `exec`-based) so the
6
+ * two modules do not import each other cyclically.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { verbose, warn } from './logger.js';
10
+ /**
11
+ * Sends `signal` to the child's whole tree: the process GROUP on POSIX
12
+ * (negative-PID kill — reaches mach → python → firefox → content-process
13
+ * chains), or a `taskkill /T /F` tree kill plus a direct `child.kill`
14
+ * fallback on Windows. No-ops once the direct child has exited (group
15
+ * survivors after exit are the post-run sweep's job, not this function's).
16
+ */
17
+ export function killProcessTree(child, signal, usesProcessGroup) {
18
+ if (child.exitCode !== null || child.signalCode !== null)
19
+ return;
20
+ const targetPid = child.pid;
21
+ if (targetPid === undefined)
22
+ return;
23
+ try {
24
+ if (usesProcessGroup) {
25
+ // Negative PID routes to the process group, killing the Python
26
+ // wrapper, the firefox it forked, and every content process
27
+ // that inherited the group.
28
+ process.kill(-targetPid, signal);
29
+ }
30
+ else {
31
+ // No process group on Windows — taskkill /T walks the descendant
32
+ // tree instead. Always forced (/F): there is no SIGTERM analogue,
33
+ // so the grace window only exists on POSIX.
34
+ spawn('taskkill', ['/pid', String(targetPid), '/T', '/F'], {
35
+ stdio: 'ignore',
36
+ }).on('error', () => {
37
+ // taskkill unavailable — nothing more we can do beyond the
38
+ // direct-child kill below.
39
+ });
40
+ child.kill(signal);
41
+ }
42
+ }
43
+ catch {
44
+ // Already gone. Nothing to do.
45
+ }
46
+ }
47
+ const SWEEP_GRACE_MS = 2000;
48
+ const MULTIPROCESSING_WORKER_PATTERN = /multiprocessing\.(?:spawn|forkserver)|resource_tracker/;
49
+ function sweepDelay(ms) {
50
+ // Deliberately ref'd (no unref()): this promise is AWAITED between the
51
+ // group SIGTERM and the post-grace re-list/SIGKILL escalation, from a
52
+ // child 'close' handler after the signal forwarder is disposed — with an
53
+ // unref'd timer nothing kept the event loop alive, so Node could exit
54
+ // mid-grace and skip the escalation entirely. Healthy runs never reach
55
+ // this function (sweepProcessGroup early-returns on zero survivors), so
56
+ // the ref never holds a clean exit open.
57
+ return new Promise((resolve) => {
58
+ setTimeout(resolve, ms);
59
+ });
60
+ }
61
+ /**
62
+ * Minimal spawn-based pgrep runner. exitCode -1 means pgrep itself was
63
+ * unavailable or errored (distinct from exit 1 = "no matches").
64
+ */
65
+ async function runPgrep(args) {
66
+ return new Promise((resolve) => {
67
+ const child = spawn('pgrep', args, { stdio: ['ignore', 'pipe', 'ignore'] });
68
+ let stdout = '';
69
+ child.stdout.on('data', (data) => {
70
+ stdout += data.toString('utf8');
71
+ });
72
+ child.on('error', () => {
73
+ resolve({ exitCode: -1, stdout: '' });
74
+ });
75
+ child.on('close', (code) => {
76
+ resolve({ exitCode: code ?? -1, stdout });
77
+ });
78
+ });
79
+ }
80
+ /** Lists processes still in `pgid` via `pgrep -g -lf`, with a kill(0) fallback. */
81
+ async function listGroupSurvivors(pgid) {
82
+ const result = await runPgrep(['-g', String(pgid), '-lf']);
83
+ if (result.exitCode === 0) {
84
+ return result.stdout
85
+ .split('\n')
86
+ .map((line) => line.trim())
87
+ .filter((line) => line.length > 0)
88
+ .map((line) => {
89
+ const match = /^(\d+)\s+(.*)$/.exec(line);
90
+ return match
91
+ ? { pid: Number(match[1]), command: match[2] ?? '' }
92
+ : { pid: -1, command: line };
93
+ });
94
+ }
95
+ if (result.exitCode === 1)
96
+ return []; // no matches
97
+ // pgrep unavailable/broken: fall back to a group liveness probe.
98
+ try {
99
+ process.kill(-pgid, 0);
100
+ return [{ pid: -1, command: 'unknown (pgrep unavailable; group still has live members)' }];
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ function describeSurvivors(list) {
107
+ return list
108
+ .map((s) => {
109
+ const tag = MULTIPROCESSING_WORKER_PATTERN.test(s.command)
110
+ ? ' [multiprocessing worker — the known busy-spin orphan shape]'
111
+ : '';
112
+ return `PID ${String(s.pid)}: ${s.command}${tag}`;
113
+ })
114
+ .join('; ');
115
+ }
116
+ /**
117
+ * Post-run reaper for a process group FireForge itself spawned (`pgid` is
118
+ * the PID of a child started with `detached: true`): lists survivors,
119
+ * SIGTERMs the group, waits a short grace, escalates to SIGKILL, and warns
120
+ * about anything that still refuses to die. POSIX only (no-op on win32).
121
+ * The only kill target is `-pgid` — never anything outside the group.
122
+ * A healthy run costs exactly one `pgrep`.
123
+ */
124
+ export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS) {
125
+ if (process.platform === 'win32')
126
+ return { survivors: [] };
127
+ const survivors = await listGroupSurvivors(pgid);
128
+ if (survivors.length === 0)
129
+ return { survivors };
130
+ warn(`Harness process group ${String(pgid)} left ${String(survivors.length)} surviving ` +
131
+ `process(es) after exit — reaping the group. ${describeSurvivors(survivors)}`);
132
+ try {
133
+ process.kill(-pgid, 'SIGTERM');
134
+ }
135
+ catch {
136
+ return { survivors };
137
+ }
138
+ await sweepDelay(graceMs);
139
+ let remaining = await listGroupSurvivors(pgid);
140
+ if (remaining.length > 0) {
141
+ try {
142
+ process.kill(-pgid, 'SIGKILL');
143
+ }
144
+ catch {
145
+ return { survivors };
146
+ }
147
+ await sweepDelay(Math.min(200, graceMs));
148
+ remaining = await listGroupSurvivors(pgid);
149
+ if (remaining.length > 0) {
150
+ warn(`Process group ${String(pgid)} still has survivors after SIGKILL: ${describeSurvivors(remaining)}. ` +
151
+ 'Inspect manually (ps -axo pid,ppid,time,command) and kill by PID.');
152
+ }
153
+ else {
154
+ verbose(`Process group ${String(pgid)} reaped after SIGKILL escalation.`);
155
+ }
156
+ }
157
+ else {
158
+ verbose(`Process group ${String(pgid)} reaped cleanly with SIGTERM.`);
159
+ }
160
+ return { survivors };
161
+ }
@@ -29,6 +29,21 @@ export interface ExecOptions {
29
29
  env?: Record<string, string>;
30
30
  /** Timeout in milliseconds */
31
31
  timeout?: number;
32
+ /**
33
+ * POSIX: spawn the child as a process-group leader and route every kill
34
+ * (parent-signal forwarding, abort, escalation) to the whole GROUP, then
35
+ * sweep the group for survivors after close — so a harness that dies at
36
+ * startup cannot strand spinning `multiprocessing` workers (field
37
+ * incident: an orphaned Python spawn worker reparented to launchd and
38
+ * busy-spun at 100% CPU for ~26 days). Win32: tree-kill via
39
+ * `taskkill /T /F` on abort/signals only, no post-run sweep
40
+ * (best-effort). Default false — non-mach consumers are unaffected.
41
+ *
42
+ * NOTE for callers: a detached group leader does NOT receive terminal
43
+ * Ctrl+C; the exec layer installs its own group-aware signal forwarder
44
+ * whenever this option is set.
45
+ */
46
+ processGroup?: boolean;
32
47
  }
33
48
  /**
34
49
  * Executes a command and returns its output.
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { constants as osConstants } from 'node:os';
4
4
  import { StringDecoder } from 'node:string_decoder';
5
5
  import { ExecTimeoutError } from '../errors/base.js';
6
+ import { killProcessTree, sweepProcessGroup } from './process-group.js';
6
7
  // 50 MB cap per stream to prevent OOM — large toolchain builds (e.g. Firefox, Chromium)
7
8
  // can easily blow past this, so we truncate rather than let the buffer grow unbounded.
8
9
  // Callers for whom truncation is unacceptable (e.g. the archive-safety preflight
@@ -115,12 +116,15 @@ export async function exec(command, args, options = {}) {
115
116
  */
116
117
  export async function execStream(command, args, options = {}) {
117
118
  return new Promise((resolve, reject) => {
119
+ const usesProcessGroup = options.processGroup === true && process.platform !== 'win32';
118
120
  const child = spawn(command, args, {
119
121
  cwd: options.cwd,
120
122
  env: { ...process.env, ...options.env },
121
123
  stdio: ['ignore', 'pipe', 'pipe'],
122
124
  signal: buildSignalFromTimeout(options.timeout),
125
+ detached: usesProcessGroup,
123
126
  });
127
+ const groupPid = usesProcessGroup ? child.pid : undefined;
124
128
  const stdoutDecoder = new StringDecoder('utf8');
125
129
  const stderrDecoder = new StringDecoder('utf8');
126
130
  child.stdout.on('data', (data) => {
@@ -133,11 +137,35 @@ export async function execStream(command, args, options = {}) {
133
137
  if (chunk.length > 0)
134
138
  options.onStderr?.(chunk);
135
139
  });
140
+ // A detached group leader no longer receives the terminal's Ctrl+C, so
141
+ // the forwarder is mandatory (not just graceful-UX) when processGroup
142
+ // is set. Group-aware: kills route to the whole tree.
143
+ const forwarder = options.processGroup === true
144
+ ? installGracefulShutdownForwarder(child, 1500, (signal) => {
145
+ killProcessTree(child, signal, usesProcessGroup);
146
+ })
147
+ : undefined;
136
148
  child.on('error', (error) => {
149
+ // Abort/startup failure: make sure a partially-started tree does not
150
+ // outlive the dispatch (a mach dying at startup used to strand
151
+ // multiprocessing workers).
152
+ if (options.processGroup === true) {
153
+ killProcessTree(child, 'SIGKILL', usesProcessGroup);
154
+ }
155
+ forwarder?.dispose();
137
156
  reject(toExecRejection(error, command, args, options.timeout));
138
157
  });
139
158
  child.on('close', (code, signal) => {
140
- resolve(exitCodeFromClose(code, signal));
159
+ forwarder?.dispose();
160
+ const finish = () => {
161
+ resolve(exitCodeFromClose(code, signal));
162
+ };
163
+ if (groupPid !== undefined) {
164
+ sweepProcessGroup(groupPid).then(finish, finish);
165
+ }
166
+ else {
167
+ finish();
168
+ }
141
169
  });
142
170
  });
143
171
  }
@@ -199,24 +227,42 @@ export async function waitForActiveChildShutdown(timeoutMs) {
199
227
  */
200
228
  export async function execInherit(command, args, options = {}) {
201
229
  return new Promise((resolve, reject) => {
230
+ const usesProcessGroup = options.processGroup === true && process.platform !== 'win32';
202
231
  const child = spawn(command, args, {
203
232
  cwd: options.cwd,
204
233
  env: { ...process.env, ...options.env },
205
234
  stdio: 'inherit',
206
235
  signal: buildSignalFromTimeout(options.timeout),
236
+ detached: usesProcessGroup,
207
237
  });
238
+ const groupPid = usesProcessGroup ? child.pid : undefined;
208
239
  const graceMs = options.shutdownGraceMs ?? 1500;
209
- const { dispose } = installGracefulShutdownForwarder(child, graceMs);
240
+ const { dispose } = installGracefulShutdownForwarder(child, graceMs, options.processGroup === true
241
+ ? (signal) => {
242
+ killProcessTree(child, signal, usesProcessGroup);
243
+ }
244
+ : undefined);
210
245
  const closure = trackChildClosure();
211
246
  child.on('error', (error) => {
247
+ if (options.processGroup === true) {
248
+ killProcessTree(child, 'SIGKILL', usesProcessGroup);
249
+ }
212
250
  dispose();
213
251
  closure.settle();
214
252
  reject(toExecRejection(error, command, args, options.timeout));
215
253
  });
216
254
  child.on('close', (code, signal) => {
217
255
  dispose();
218
- closure.settle();
219
- resolve(exitCodeFromClose(code, signal));
256
+ const finish = () => {
257
+ closure.settle();
258
+ resolve(exitCodeFromClose(code, signal));
259
+ };
260
+ if (groupPid !== undefined) {
261
+ sweepProcessGroup(groupPid).then(finish, finish);
262
+ }
263
+ else {
264
+ finish();
265
+ }
220
266
  });
221
267
  });
222
268
  }
@@ -228,14 +274,21 @@ export async function execInherit(command, args, options = {}) {
228
274
  * `close` and `error` handlers so the process does not accumulate signal
229
275
  * listeners across repeated spawns.
230
276
  */
231
- function installGracefulShutdownForwarder(child, graceMs) {
277
+ function installGracefulShutdownForwarder(child, graceMs, killTarget) {
232
278
  let graceTimer;
233
279
  const forwarded = new Set();
280
+ const sendKill = (signal) => {
281
+ if (killTarget) {
282
+ killTarget(signal);
283
+ return;
284
+ }
285
+ child.kill(signal);
286
+ };
234
287
  const escalate = () => {
235
288
  if (child.exitCode !== null || child.signalCode !== null)
236
289
  return;
237
290
  try {
238
- child.kill('SIGKILL');
291
+ sendKill('SIGKILL');
239
292
  }
240
293
  catch {
241
294
  // Child is already gone — nothing to do.
@@ -249,7 +302,7 @@ function installGracefulShutdownForwarder(child, graceMs) {
249
302
  }
250
303
  forwarded.add(signal);
251
304
  try {
252
- child.kill('SIGTERM');
305
+ sendKill('SIGTERM');
253
306
  }
254
307
  catch {
255
308
  // If the child can't accept SIGTERM (already dead), nothing to do.
@@ -286,34 +339,52 @@ function installGracefulShutdownForwarder(child, graceMs) {
286
339
  */
287
340
  export async function execInheritCapture(command, args, options = {}) {
288
341
  return new Promise((resolve, reject) => {
342
+ const usesProcessGroup = options.processGroup === true && process.platform !== 'win32';
289
343
  const child = spawn(command, args, {
290
344
  cwd: options.cwd,
291
345
  env: { ...process.env, ...options.env },
292
346
  stdio: ['inherit', 'pipe', 'pipe'],
293
347
  signal: buildSignalFromTimeout(options.timeout),
348
+ detached: usesProcessGroup,
294
349
  });
350
+ const groupPid = usesProcessGroup ? child.pid : undefined;
295
351
  const out = createStreamCollector(process.stdout);
296
352
  const err = createStreamCollector(process.stderr);
297
353
  child.stdout.on('data', out.onData);
298
354
  child.stderr.on('data', err.onData);
299
355
  const graceMs = options.shutdownGraceMs ?? 1500;
300
- const { dispose } = installGracefulShutdownForwarder(child, graceMs);
356
+ const { dispose } = installGracefulShutdownForwarder(child, graceMs, options.processGroup === true
357
+ ? (signal) => {
358
+ killProcessTree(child, signal, usesProcessGroup);
359
+ }
360
+ : undefined);
301
361
  const closure = trackChildClosure();
302
362
  child.on('error', (error) => {
363
+ if (options.processGroup === true) {
364
+ killProcessTree(child, 'SIGKILL', usesProcessGroup);
365
+ }
303
366
  dispose();
304
367
  closure.settle();
305
368
  reject(toExecRejection(error, command, args, options.timeout));
306
369
  });
307
370
  child.on('close', (code, signal) => {
308
371
  dispose();
309
- closure.settle();
310
- resolve({
311
- stdout: out.getText(),
312
- stderr: err.getText(),
313
- exitCode: exitCodeFromClose(code, signal),
314
- stdoutTruncated: out.wasTruncated(),
315
- stderrTruncated: err.wasTruncated(),
316
- });
372
+ const finish = () => {
373
+ closure.settle();
374
+ resolve({
375
+ stdout: out.getText(),
376
+ stderr: err.getText(),
377
+ exitCode: exitCodeFromClose(code, signal),
378
+ stdoutTruncated: out.wasTruncated(),
379
+ stderrTruncated: err.wasTruncated(),
380
+ });
381
+ };
382
+ if (groupPid !== undefined) {
383
+ sweepProcessGroup(groupPid).then(finish, finish);
384
+ }
385
+ else {
386
+ finish();
387
+ }
317
388
  });
318
389
  });
319
390
  }
@@ -371,34 +442,7 @@ export async function execSmokeRun(command, args, options) {
371
442
  let graceTimer;
372
443
  let signalGraceTimer;
373
444
  const signalChildGroup = (signal) => {
374
- if (child.exitCode !== null || child.signalCode !== null)
375
- return;
376
- const targetPid = child.pid;
377
- if (targetPid === undefined)
378
- return;
379
- try {
380
- if (usesProcessGroup) {
381
- // Negative PID routes to the process group, killing the Python
382
- // wrapper, the firefox it forked, and every content process
383
- // that inherited the group.
384
- process.kill(-targetPid, signal);
385
- }
386
- else {
387
- // No process group on Windows — taskkill /T walks the descendant
388
- // tree instead. Always forced (/F): there is no SIGTERM analogue,
389
- // so the grace window only exists on POSIX.
390
- spawn('taskkill', ['/pid', String(targetPid), '/T', '/F'], {
391
- stdio: 'ignore',
392
- }).on('error', () => {
393
- // taskkill unavailable — nothing more we can do beyond the
394
- // direct-child kill below.
395
- });
396
- child.kill(signal);
397
- }
398
- }
399
- catch {
400
- // Already gone. Nothing to do.
401
- }
445
+ killProcessTree(child, signal, usesProcessGroup);
402
446
  };
403
447
  const deadlineTimer = setTimeout(() => {
404
448
  timedOut = true;
@@ -57,6 +57,11 @@ export declare function isArray(value: unknown): value is unknown[];
57
57
  * Accepts formats like "140.9.0", "140.9.1", "140.9.0esr", "147.0b1"
58
58
  */
59
59
  export declare function isValidFirefoxVersion(version: string): boolean;
60
+ /**
61
+ * Validates a release-candidate build directory name.
62
+ * Accepts formats like "build1", "build2", "build12" (no leading zero).
63
+ */
64
+ export declare function isValidFirefoxCandidate(candidate: string): boolean;
60
65
  /**
61
66
  * Validates a Firefox product string.
62
67
  * Accepts: firefox, firefox-esr, firefox-beta, firefox-devedition
@@ -87,6 +87,13 @@ export function isValidFirefoxVersion(version) {
87
87
  // Beta: 147.0b1, 147.0b2
88
88
  return /^[1-9]\d{0,2}\.\d+(?:b[1-9]\d*|\.\d+(?:esr)?|esr)?$/.test(version);
89
89
  }
90
+ /**
91
+ * Validates a release-candidate build directory name.
92
+ * Accepts formats like "build1", "build2", "build12" (no leading zero).
93
+ */
94
+ export function isValidFirefoxCandidate(candidate) {
95
+ return /^build[1-9]\d{0,3}$/.test(candidate);
96
+ }
90
97
  /**
91
98
  * Validates a Firefox product string.
92
99
  * Accepts: firefox, firefox-esr, firefox-beta, firefox-devedition
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -73,9 +73,9 @@
73
73
  "eslint-plugin-simple-import-sort": "^13.0.0",
74
74
  "fast-check": "^4.6.0",
75
75
  "husky": "^9.1.7",
76
- "knip": "6.23.0",
76
+ "knip": "6.26.0",
77
77
  "lint-staged": "^17.0.4",
78
- "prettier": "3.9.4",
78
+ "prettier": "3.9.5",
79
79
  "tsx": "^4.7.0",
80
80
  "typescript": "~6.0.0",
81
81
  "typescript-eslint": "^8.0.0",