@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +6 -6
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export.js +12 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +25 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +40 -23
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +58 -0
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +51 -7
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +6 -1
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +69 -1
- package/dist/src/core/test-stale-check.js +134 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +84 -7
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- 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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
76
|
+
"knip": "6.26.0",
|
|
77
77
|
"lint-staged": "^17.0.4",
|
|
78
|
-
"prettier": "3.9.
|
|
78
|
+
"prettier": "3.9.5",
|
|
79
79
|
"tsx": "^4.7.0",
|
|
80
80
|
"typescript": "~6.0.0",
|
|
81
81
|
"typescript-eslint": "^8.0.0",
|