@hominis/fireforge 0.36.0 → 0.37.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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/src/commands/build.js +1 -1
  3. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  4. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  5. package/dist/src/commands/doctor.js +2 -0
  6. package/dist/src/commands/export.js +7 -0
  7. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  8. package/dist/src/commands/patch/staged-dependency.js +113 -51
  9. package/dist/src/commands/re-export-files.js +4 -0
  10. package/dist/src/commands/re-export.js +8 -0
  11. package/dist/src/commands/test-diagnose.js +20 -1
  12. package/dist/src/commands/test.js +75 -23
  13. package/dist/src/core/build-baseline-types.d.ts +24 -0
  14. package/dist/src/core/build-baseline.d.ts +5 -2
  15. package/dist/src/core/build-baseline.js +5 -1
  16. package/dist/src/core/furnace-config-custom.js +10 -0
  17. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  18. package/dist/src/core/furnace-stale-export.js +123 -0
  19. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  20. package/dist/src/core/furnace-validate-structure.js +3 -2
  21. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  22. package/dist/src/core/mach-known-noise-filter.js +193 -0
  23. package/dist/src/core/mach.d.ts +8 -0
  24. package/dist/src/core/mach.js +36 -3
  25. package/dist/src/core/patch-lint-cross.js +80 -2
  26. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  27. package/dist/src/core/patch-manifest-io.js +27 -16
  28. package/dist/src/core/patch-manifest-validate.js +24 -0
  29. package/dist/src/core/test-harness-crash.d.ts +16 -0
  30. package/dist/src/core/test-harness-crash.js +55 -0
  31. package/dist/src/core/test-stale-check.d.ts +29 -1
  32. package/dist/src/core/test-stale-check.js +59 -0
  33. package/dist/src/types/commands/index.d.ts +1 -1
  34. package/dist/src/types/commands/options.d.ts +27 -4
  35. package/dist/src/types/commands/patches.d.ts +29 -0
  36. package/dist/src/types/furnace.d.ts +13 -0
  37. package/dist/src/utils/process-group.d.ts +33 -0
  38. package/dist/src/utils/process-group.js +161 -0
  39. package/dist/src/utils/process.d.ts +15 -0
  40. package/dist/src/utils/process.js +88 -44
  41. package/package.json +2 -2
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -73,7 +73,7 @@
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.25.0",
77
77
  "lint-staged": "^17.0.4",
78
78
  "prettier": "3.9.4",
79
79
  "tsx": "^4.7.0",