@hominis/fireforge 0.14.0 → 0.15.2

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 (50) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +41 -3
  3. package/dist/src/commands/build.js +12 -1
  4. package/dist/src/commands/furnace/create-templates.d.ts +47 -0
  5. package/dist/src/commands/furnace/create-templates.js +135 -0
  6. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  7. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  8. package/dist/src/commands/furnace/create.js +81 -109
  9. package/dist/src/commands/furnace/deploy.js +3 -3
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/setup.d.ts +1 -1
  12. package/dist/src/commands/setup.js +3 -2
  13. package/dist/src/commands/test.js +20 -0
  14. package/dist/src/core/build-prepare.js +6 -1
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +2 -0
  17. package/dist/src/core/config-validate.js +32 -0
  18. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  19. package/dist/src/core/furnace-apply-ftl.js +102 -0
  20. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  21. package/dist/src/core/furnace-apply-helpers.js +16 -12
  22. package/dist/src/core/furnace-apply.js +7 -4
  23. package/dist/src/core/furnace-config-tokens.d.ts +17 -0
  24. package/dist/src/core/furnace-config-tokens.js +43 -0
  25. package/dist/src/core/furnace-config.d.ts +6 -0
  26. package/dist/src/core/furnace-config.js +16 -3
  27. package/dist/src/core/furnace-constants.d.ts +20 -0
  28. package/dist/src/core/furnace-constants.js +32 -0
  29. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  30. package/dist/src/core/furnace-registration-ast.js +58 -25
  31. package/dist/src/core/furnace-registration.d.ts +27 -0
  32. package/dist/src/core/furnace-registration.js +96 -0
  33. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  34. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  35. package/dist/src/core/furnace-validate-helpers.d.ts +39 -1
  36. package/dist/src/core/furnace-validate-helpers.js +182 -18
  37. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  38. package/dist/src/core/furnace-validate-registration.js +34 -9
  39. package/dist/src/core/furnace-validate.js +2 -2
  40. package/dist/src/core/marionette-preflight.d.ts +46 -0
  41. package/dist/src/core/marionette-preflight.js +260 -0
  42. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  43. package/dist/src/core/patch-lint-cross.js +1 -1
  44. package/dist/src/core/patch-lint.js +29 -9
  45. package/dist/src/types/commands/options.d.ts +16 -0
  46. package/dist/src/types/config.d.ts +7 -0
  47. package/dist/src/types/furnace.d.ts +19 -0
  48. package/dist/src/utils/process.d.ts +15 -2
  49. package/dist/src/utils/process.js +73 -0
  50. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Marionette handshake preflight for `fireforge test --doctor`.
4
+ *
5
+ * Answers a single question before tests run: "does marionette come up?" A
6
+ * silent 360-second mach-test hang is indistinguishable from "tests failed
7
+ * to discover"; this helper surfaces the failure in under a minute with a
8
+ * clear PASS/FAIL line and the tail of the browser's stderr.
9
+ *
10
+ * The probe is a cascade of layered checks (engine → mach → python →
11
+ * profile → spawn → handshake). Each layer has a tight per-attempt budget
12
+ * so a broken earlier layer fails fast with a specific diagnosis rather
13
+ * than blocking on the final socket poll for the full overall budget.
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import { mkdtemp, rm } from 'node:fs/promises';
17
+ import net from 'node:net';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { pathExists } from '../utils/fs.js';
21
+ import { info, warn } from '../utils/logger.js';
22
+ import { ensureMach } from './mach.js';
23
+ import { getPython } from './mach-python.js';
24
+ /** Marionette's default TCP port when the browser is launched with `--marionette`. */
25
+ const MARIONETTE_PORT = 2828;
26
+ /** Overall budget for the preflight (browser boot + socket handshake). */
27
+ const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
28
+ /** Per-attempt socket connect timeout. Polling continues until the overall budget expires. */
29
+ const SOCKET_ATTEMPT_TIMEOUT_MS = 2_000;
30
+ /**
31
+ * Grace window after spawn() returns before we accept the child as
32
+ * "spawned OK". A browser binary that exits immediately (missing dylib,
33
+ * wrong CPU arch, corrupt profile) must be caught here — not 30 seconds
34
+ * later at the socket layer.
35
+ */
36
+ const SPAWN_SETTLE_MS = 750;
37
+ /** Tail of stderr preserved for FAIL diagnostics. */
38
+ const STDERR_TAIL_LIMIT = 8 * 1024;
39
+ /**
40
+ * Layer names, ordered by the probe sequence. Surfaced in `detail` so the
41
+ * operator sees which layer failed without having to guess.
42
+ */
43
+ const LAYER_NAMES = [
44
+ 'engine-present',
45
+ 'mach-available',
46
+ 'python-available',
47
+ 'profile-creatable',
48
+ 'browser-spawns',
49
+ 'marionette-handshake',
50
+ ];
51
+ function layerTag(name) {
52
+ const index = LAYER_NAMES.indexOf(name) + 1;
53
+ return `[layer ${index}/${LAYER_NAMES.length}: ${name}]`;
54
+ }
55
+ /**
56
+ * Runs the marionette preflight. Returns PASS on first byte read from the
57
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
58
+ * identifying which layer of the cascade broke. Always tears down the
59
+ * spawned browser before returning.
60
+ */
61
+ export async function runMarionettePreflight(engineDir, options = {}) {
62
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
63
+ const spawnSettleMs = options.spawnSettleMs ?? SPAWN_SETTLE_MS;
64
+ const port = options.port ?? MARIONETTE_PORT;
65
+ const spawnerFn = options.spawner ?? spawn;
66
+ const connectFn = options.connect ?? net.createConnection;
67
+ const startedAt = Date.now();
68
+ const elapsed = () => Date.now() - startedAt;
69
+ // Layer 1: engine directory exists.
70
+ if (!(await pathExists(engineDir))) {
71
+ return fail('engine-present', 'Engine directory not found — run "fireforge download" first.', elapsed());
72
+ }
73
+ // Layer 2: mach binary resolves in the engine.
74
+ try {
75
+ await ensureMach(engineDir);
76
+ }
77
+ catch (error) {
78
+ return fail('mach-available', `mach not available in engine: ${error.message}`, elapsed());
79
+ }
80
+ // Layer 3: Python that mach requires is discoverable.
81
+ let python;
82
+ try {
83
+ python = await getPython(engineDir);
84
+ }
85
+ catch (error) {
86
+ return fail('python-available', `Python interpreter required by mach is not available: ${error.message}`, elapsed());
87
+ }
88
+ // Layer 4: throwaway browser profile directory is creatable.
89
+ let profileDir;
90
+ try {
91
+ profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
92
+ }
93
+ catch (error) {
94
+ return fail('profile-creatable', `Could not create a throwaway browser profile in ${tmpdir()}: ${error.message}`, elapsed());
95
+ }
96
+ let child;
97
+ let stderrTail = '';
98
+ try {
99
+ // Layer 5: browser spawns and does not crash within the settle window.
100
+ try {
101
+ child = spawnerFn(python, [
102
+ join(engineDir, 'mach'),
103
+ 'run',
104
+ '--marionette',
105
+ '--headless',
106
+ '--no-remote',
107
+ '-profile',
108
+ profileDir,
109
+ ], {
110
+ cwd: engineDir,
111
+ env: { ...process.env, MOZ_HEADLESS: '1' },
112
+ stdio: ['ignore', 'ignore', 'pipe'],
113
+ });
114
+ }
115
+ catch (error) {
116
+ return fail('browser-spawns', `Could not spawn mach run: ${error.message}`, elapsed());
117
+ }
118
+ const spawnedChild = child;
119
+ child.stderr?.on('data', (data) => {
120
+ const chunk = data.toString();
121
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT);
122
+ });
123
+ // Short settle window — catches "binary exits immediately" failures
124
+ // (missing dylib, wrong CPU arch, corrupt profile) before the socket
125
+ // poll swallows the full overall budget waiting for bytes that will
126
+ // never come.
127
+ const settleDeadline = Math.min(spawnSettleMs, Math.max(0, timeoutMs - elapsed()));
128
+ if (settleDeadline > 0) {
129
+ await delay(settleDeadline);
130
+ }
131
+ if (hasChildExited(spawnedChild)) {
132
+ return fail('browser-spawns', `Browser process exited during spawn (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
133
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
134
+ }
135
+ // Layer 6: marionette handshake within the remaining budget.
136
+ const socketResult = await waitForMarionetteSocket(port, connectFn, () => {
137
+ return elapsed() < timeoutMs && !hasChildExited(spawnedChild);
138
+ });
139
+ if (socketResult.ok) {
140
+ return {
141
+ ok: true,
142
+ durationMs: elapsed(),
143
+ detail: `Marionette handshake received on 127.0.0.1:${port} in ${Date.now() - startedAt}ms.`,
144
+ };
145
+ }
146
+ // Child may have exited before the socket was ever ready — surface that
147
+ // distinctly from "socket never answered" so the operator has a lead.
148
+ if (hasChildExited(spawnedChild)) {
149
+ return fail('marionette-handshake', `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
150
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
151
+ }
152
+ return fail('marionette-handshake', `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
153
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
154
+ }
155
+ finally {
156
+ if (child && !hasChildExited(child)) {
157
+ try {
158
+ child.kill('SIGTERM');
159
+ }
160
+ catch {
161
+ // Already exited — nothing to do.
162
+ }
163
+ // Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
164
+ // so we don't leave a ghost mach process around after a failed probe.
165
+ await delay(500);
166
+ if (!hasChildExited(child)) {
167
+ try {
168
+ child.kill('SIGKILL');
169
+ }
170
+ catch {
171
+ // Already gone.
172
+ }
173
+ }
174
+ }
175
+ try {
176
+ await rm(profileDir, { recursive: true, force: true });
177
+ }
178
+ catch (error) {
179
+ warn(`Could not clean up marionette preflight profile: ${error.message}`);
180
+ }
181
+ }
182
+ }
183
+ function fail(layer, message, durationMs) {
184
+ return {
185
+ ok: false,
186
+ durationMs,
187
+ detail: `${layerTag(layer)} ${message}`,
188
+ };
189
+ }
190
+ /** Returns true when the child process has exited (normal or signaled). */
191
+ function hasChildExited(child) {
192
+ return child.exitCode !== null || child.signalCode !== null;
193
+ }
194
+ async function waitForMarionetteSocket(port, connectFn, keepTrying) {
195
+ while (keepTrying()) {
196
+ const result = await attemptMarionetteConnect(port, connectFn);
197
+ if (result.ok) {
198
+ return { ok: true };
199
+ }
200
+ await delay(400);
201
+ }
202
+ return { ok: false };
203
+ }
204
+ function attemptMarionetteConnect(port, connectFn) {
205
+ return new Promise((resolve) => {
206
+ const socket = connectFn({ host: '127.0.0.1', port });
207
+ let settled = false;
208
+ const finish = (ok) => {
209
+ if (settled)
210
+ return;
211
+ settled = true;
212
+ try {
213
+ socket.destroy();
214
+ }
215
+ catch {
216
+ // Ignore — already closed.
217
+ }
218
+ resolve({ ok });
219
+ };
220
+ const attemptTimer = setTimeout(() => {
221
+ finish(false);
222
+ }, SOCKET_ATTEMPT_TIMEOUT_MS);
223
+ attemptTimer.unref();
224
+ socket.once('connect', () => {
225
+ // Connect alone is insufficient — the marionette server performs a
226
+ // handshake send as soon as the socket opens, so wait for at least one
227
+ // byte to confirm the server is actually speaking marionette.
228
+ const readTimer = setTimeout(() => {
229
+ finish(false);
230
+ }, SOCKET_ATTEMPT_TIMEOUT_MS);
231
+ readTimer.unref();
232
+ socket.once('data', () => {
233
+ clearTimeout(readTimer);
234
+ finish(true);
235
+ });
236
+ });
237
+ socket.once('error', () => {
238
+ finish(false);
239
+ });
240
+ socket.once('close', () => {
241
+ finish(false);
242
+ });
243
+ });
244
+ }
245
+ function delay(ms) {
246
+ return new Promise((resolve) => {
247
+ const timer = setTimeout(resolve, ms);
248
+ timer.unref();
249
+ });
250
+ }
251
+ /** Renders a PASS/FAIL banner to the CLI using the shared logger helpers. */
252
+ export function reportMarionettePreflight(result) {
253
+ if (result.ok) {
254
+ info(`Marionette preflight: PASS (${result.durationMs}ms) — ${result.detail}`);
255
+ }
256
+ else {
257
+ warn(`Marionette preflight: FAIL (${result.durationMs}ms) — ${result.detail}`);
258
+ }
259
+ }
260
+ //# sourceMappingURL=marionette-preflight.js.map
@@ -91,7 +91,7 @@ export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Ma
91
91
  /**
92
92
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
93
93
  * +++ b/path`) by more than one patch. This is the failure mode that
94
- * motivated the rule — Hominis landed three patches each trying to create
94
+ * motivated the rule — a fork landed three patches each trying to create
95
95
  * the same file, and the error surfaced only when import rolled back
96
96
  * mid-apply.
97
97
  *
@@ -114,7 +114,7 @@ export function collectNewFileCreatorsByPath(ctx) {
114
114
  /**
115
115
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
116
116
  * +++ b/path`) by more than one patch. This is the failure mode that
117
- * motivated the rule — Hominis landed three patches each trying to create
117
+ * motivated the rule — a fork landed three patches each trying to create
118
118
  * the same file, and the error surfaced only when import rolled back
119
119
  * mid-apply.
120
120
  *
@@ -110,12 +110,14 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
110
110
  // Load furnace config gracefully — skip token-prefix check if unavailable
111
111
  let tokenPrefix;
112
112
  let tokenAllowlist;
113
+ let runtimeVariables;
113
114
  try {
114
115
  const root = join(repoDir, '..');
115
116
  const furnaceConfig = await loadFurnaceConfig(root);
116
117
  if (furnaceConfig.tokenPrefix) {
117
118
  tokenPrefix = furnaceConfig.tokenPrefix;
118
119
  tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
120
+ runtimeVariables = new Set(furnaceConfig.runtimeVariables ?? []);
119
121
  }
120
122
  }
121
123
  catch (error) {
@@ -154,20 +156,38 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
154
156
  });
155
157
  }
156
158
  }
157
- // Check for non-tokenized custom properties
159
+ // Check for non-tokenized custom properties. A variable that is both
160
+ // declared and consumed inside the same file is auto-exempted as a
161
+ // runtime state channel (see furnace.json → runtimeVariables).
158
162
  if (tokenPrefix) {
163
+ const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
164
+ const localDeclarations = new Set();
165
+ let declMatch;
166
+ while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
167
+ const name = declMatch[1];
168
+ if (name)
169
+ localDeclarations.add(name);
170
+ }
159
171
  const varPattern = /var\(\s*(--[\w-]+)/g;
160
172
  let match;
161
173
  while ((match = varPattern.exec(cssContent)) !== null) {
162
174
  const prop = match[1];
163
- if (prop && !prop.startsWith(tokenPrefix) && !tokenAllowlist?.has(prop)) {
164
- issues.push({
165
- file,
166
- check: 'token-prefix-violation',
167
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token or add to tokenAllowlist.`,
168
- severity: 'error',
169
- });
170
- }
175
+ if (!prop)
176
+ continue;
177
+ if (prop.startsWith(tokenPrefix))
178
+ continue;
179
+ if (tokenAllowlist?.has(prop))
180
+ continue;
181
+ if (runtimeVariables?.has(prop))
182
+ continue;
183
+ if (localDeclarations.has(prop))
184
+ continue;
185
+ issues.push({
186
+ file,
187
+ check: 'token-prefix-violation',
188
+ message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
189
+ severity: 'error',
190
+ });
171
191
  }
172
192
  }
173
193
  }
@@ -178,6 +178,12 @@ export interface TestOptions {
178
178
  headless?: boolean;
179
179
  /** Run incremental UI build before testing */
180
180
  build?: boolean;
181
+ /**
182
+ * Run a marionette preflight before tests. Reports PASS/FAIL in under a
183
+ * minute. When test paths are supplied, a FAIL aborts before mach test is
184
+ * spawned. When no paths are supplied, runs the preflight only and exits.
185
+ */
186
+ doctor?: boolean;
181
187
  }
182
188
  /**
183
189
  * Options for the furnace apply command.
@@ -265,6 +271,16 @@ export interface FurnaceCreateOptions {
265
271
  register?: boolean;
266
272
  /** Scaffold Mochitest directory and register in moz.build */
267
273
  withTests?: boolean;
274
+ /**
275
+ * Scaffold an xpcshell test harness (headless, no tabbrowser) instead of
276
+ * browser-chrome. Required for forks without a `tabbrowser` (storage-only
277
+ * code, observer-driven modules). Implies `withTests` when set. Writes an
278
+ * `xpcshell.toml` + `test_<name>.js` under
279
+ * `engine/browser/base/content/test/<binary-name>-xpcshell/` and leaves
280
+ * moz.build registration to the operator (add the directory to
281
+ * `XPCSHELL_TESTS_MANIFESTS`).
282
+ */
283
+ xpcshell?: boolean;
268
284
  /** Stock component tag names composed internally by this component */
269
285
  compose?: string[];
270
286
  }
@@ -44,6 +44,13 @@ export interface FireForgeConfig {
44
44
  wire?: WireConfig;
45
45
  /** Patch lint configuration */
46
46
  patchLint?: PatchLintConfig;
47
+ /**
48
+ * Project marker prefix appended to lines FireForge writes into
49
+ * upstream Firefox source files (e.g. the `customElements.js` tag list).
50
+ * `"MYBROWSER"` emits a trailing ` // MYBROWSER:` on each inserted line.
51
+ * Keeps modifications discoverable and re-applies idempotent.
52
+ */
53
+ markerComment?: string;
47
54
  }
48
55
  /**
49
56
  * Wire command configuration.
@@ -63,6 +63,25 @@ export interface FurnaceConfig {
63
63
  tokenPrefix?: string;
64
64
  /** Custom properties allowed even though they don't match tokenPrefix (e.g. ["--background-color-box"]) */
65
65
  tokenAllowlist?: string[];
66
+ /**
67
+ * Custom properties used as runtime state channels — written and read by the
68
+ * component itself (e.g. per-frame camera/tile positions) rather than
69
+ * consumed as design tokens. Listed names are exempt from the
70
+ * `token-prefix-violation` check even when they do not match `tokenPrefix`
71
+ * and are not in `tokenAllowlist`. Use this for cross-component runtime
72
+ * variables (e.g. set in JS, read in CSS of a child). Component-local
73
+ * variables that are both declared and consumed inside the same component's
74
+ * own CSS file are auto-exempted and do not need an entry here.
75
+ */
76
+ runtimeVariables?: string[];
77
+ /**
78
+ * Chrome documents scanned by the `missing-token-link` validator to confirm
79
+ * the tokens CSS file is `<link>`ed. Forks with multiple chrome host
80
+ * documents (e.g. `mybrowser.xhtml` beside the stock `browser.xhtml`) should
81
+ * list every document that may own the link. When omitted, defaults to
82
+ * `['browser/base/content/browser.xhtml']` — the upstream Firefox path.
83
+ */
84
+ tokenHostDocuments?: string[];
66
85
  /**
67
86
  * Override the default Fluent (.ftl) base path within the engine.
68
87
  * Defaults to `toolkit/locales/en-US/toolkit/global` when not set.
@@ -51,12 +51,23 @@ export interface StreamOptions extends ExecOptions {
51
51
  export declare function execStream(command: string, args: string[], options?: StreamOptions): Promise<number>;
52
52
  /**
53
53
  * Executes a command and inherits stdio (shows output directly).
54
+ *
55
+ * Graceful shutdown: when the FireForge process receives SIGINT/SIGTERM, the
56
+ * signal is forwarded to the child as SIGTERM and a short grace timer (default
57
+ * 1500ms) runs before escalating to SIGKILL. A second matching signal during
58
+ * the grace period triggers an immediate SIGKILL — matching the usual
59
+ * "hit Ctrl-C again to force-quit" UX. Without this, Firefox's AsyncShutdown
60
+ * / profileBeforeChange blockers (which flush in-memory state to disk) can be
61
+ * racing the OS child-exit path, losing the last few seconds of edits.
62
+ *
54
63
  * @param command - Command to execute
55
64
  * @param args - Command arguments
56
65
  * @param options - Execution options
57
66
  * @returns Exit code of the process
58
67
  */
59
- export declare function execInherit(command: string, args: string[], options?: ExecOptions): Promise<number>;
68
+ export declare function execInherit(command: string, args: string[], options?: ExecOptions & {
69
+ shutdownGraceMs?: number;
70
+ }): Promise<number>;
60
71
  /**
61
72
  * Executes a command while inheriting stdin, streaming stdout/stderr live,
62
73
  * and capturing the emitted output for diagnostics.
@@ -65,7 +76,9 @@ export declare function execInherit(command: string, args: string[], options?: E
65
76
  * @param options - Execution options
66
77
  * @returns Execution result with stdout, stderr, and exit code
67
78
  */
68
- export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
79
+ export declare function execInheritCapture(command: string, args: string[], options?: ExecOptions & {
80
+ shutdownGraceMs?: number;
81
+ }): Promise<ExecResult>;
69
82
  /**
70
83
  * Finds an executable in the system PATH.
71
84
  * @param name - Name of the executable
@@ -108,6 +108,15 @@ export async function execStream(command, args, options = {}) {
108
108
  }
109
109
  /**
110
110
  * Executes a command and inherits stdio (shows output directly).
111
+ *
112
+ * Graceful shutdown: when the FireForge process receives SIGINT/SIGTERM, the
113
+ * signal is forwarded to the child as SIGTERM and a short grace timer (default
114
+ * 1500ms) runs before escalating to SIGKILL. A second matching signal during
115
+ * the grace period triggers an immediate SIGKILL — matching the usual
116
+ * "hit Ctrl-C again to force-quit" UX. Without this, Firefox's AsyncShutdown
117
+ * / profileBeforeChange blockers (which flush in-memory state to disk) can be
118
+ * racing the OS child-exit path, losing the last few seconds of edits.
119
+ *
111
120
  * @param command - Command to execute
112
121
  * @param args - Command arguments
113
122
  * @param options - Execution options
@@ -121,14 +130,74 @@ export async function execInherit(command, args, options = {}) {
121
130
  stdio: 'inherit',
122
131
  signal: buildSignalFromTimeout(options.timeout),
123
132
  });
133
+ const graceMs = options.shutdownGraceMs ?? 1500;
134
+ const { dispose } = installGracefulShutdownForwarder(child, graceMs);
124
135
  child.on('error', (error) => {
136
+ dispose();
125
137
  reject(error);
126
138
  });
127
139
  child.on('close', (code, signal) => {
140
+ dispose();
128
141
  resolve(exitCodeFromClose(code, signal));
129
142
  });
130
143
  });
131
144
  }
145
+ /**
146
+ * Wires parent-process SIGINT/SIGTERM to a child: first signal → child.kill
147
+ * (SIGTERM) + grace timer; second matching signal → immediate SIGKILL; grace
148
+ * timer expiry → SIGKILL. Returns a `dispose()` that clears the listeners and
149
+ * any outstanding timer. Callers must invoke `dispose()` from both the child's
150
+ * `close` and `error` handlers so the process does not accumulate signal
151
+ * listeners across repeated spawns.
152
+ */
153
+ function installGracefulShutdownForwarder(child, graceMs) {
154
+ let graceTimer;
155
+ const forwarded = new Set();
156
+ const escalate = () => {
157
+ if (child.exitCode !== null || child.signalCode !== null)
158
+ return;
159
+ try {
160
+ child.kill('SIGKILL');
161
+ }
162
+ catch {
163
+ // Child is already gone — nothing to do.
164
+ }
165
+ };
166
+ const handleSignal = (signal) => {
167
+ if (forwarded.has(signal)) {
168
+ // Second receipt of the same signal while still running: escalate now.
169
+ escalate();
170
+ return;
171
+ }
172
+ forwarded.add(signal);
173
+ try {
174
+ child.kill('SIGTERM');
175
+ }
176
+ catch {
177
+ // If the child can't accept SIGTERM (already dead), nothing to do.
178
+ return;
179
+ }
180
+ graceTimer = setTimeout(escalate, graceMs);
181
+ graceTimer.unref();
182
+ };
183
+ const onSigint = () => {
184
+ handleSignal('SIGINT');
185
+ };
186
+ const onSigterm = () => {
187
+ handleSignal('SIGTERM');
188
+ };
189
+ process.on('SIGINT', onSigint);
190
+ process.on('SIGTERM', onSigterm);
191
+ const dispose = () => {
192
+ process.off('SIGINT', onSigint);
193
+ process.off('SIGTERM', onSigterm);
194
+ if (graceTimer) {
195
+ clearTimeout(graceTimer);
196
+ graceTimer = undefined;
197
+ }
198
+ };
199
+ return { dispose };
200
+ }
132
201
  /**
133
202
  * Executes a command while inheriting stdin, streaming stdout/stderr live,
134
203
  * and capturing the emitted output for diagnostics.
@@ -149,10 +218,14 @@ export async function execInheritCapture(command, args, options = {}) {
149
218
  const err = createStreamCollector(process.stderr);
150
219
  child.stdout.on('data', out.onData);
151
220
  child.stderr.on('data', err.onData);
221
+ const graceMs = options.shutdownGraceMs ?? 1500;
222
+ const { dispose } = installGracefulShutdownForwarder(child, graceMs);
152
223
  child.on('error', (error) => {
224
+ dispose();
153
225
  reject(error);
154
226
  });
155
227
  child.on('close', (code, signal) => {
228
+ dispose();
156
229
  resolve({
157
230
  stdout: out.getText(),
158
231
  stderr: err.getText(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.14.0",
3
+ "version": "0.15.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",