@hominis/fireforge 0.42.0 → 0.43.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 +75 -34
- package/dist/build-info.json +4 -4
- package/dist/src/cli.js +21 -2
- package/dist/src/commands/build.js +7 -1
- package/dist/src/commands/config.js +16 -27
- package/dist/src/commands/discard.js +1 -1
- package/dist/src/commands/export-flow.js +13 -3
- package/dist/src/commands/export-placement-conflicts.js +1 -1
- package/dist/src/commands/export-shared.d.ts +1 -1
- package/dist/src/commands/export-shared.js +4 -5
- package/dist/src/commands/furnace/chrome-doc-remove.js +3 -0
- package/dist/src/commands/furnace/chrome-doc.js +3 -0
- package/dist/src/commands/furnace/create.js +3 -0
- package/dist/src/commands/furnace/override.js +3 -0
- package/dist/src/commands/furnace/preview.js +3 -0
- package/dist/src/commands/furnace/refresh.js +3 -0
- package/dist/src/commands/furnace/remove.js +3 -0
- package/dist/src/commands/furnace/rename.js +3 -0
- package/dist/src/commands/furnace/scan.js +3 -0
- package/dist/src/commands/import.js +1 -1
- package/dist/src/commands/lint-per-patch.js +16 -8
- package/dist/src/commands/lint-per-run-checkjs.d.ts +4 -4
- package/dist/src/commands/lint-per-run-checkjs.js +4 -4
- package/dist/src/commands/lint-report.js +1 -1
- package/dist/src/commands/lint.js +2 -2
- package/dist/src/commands/patch/lint-ignore.js +1 -1
- package/dist/src/commands/patch/move-files-create.js +5 -3
- package/dist/src/commands/patch/move-files-into.d.ts +1 -1
- package/dist/src/commands/patch/move-files-into.js +5 -3
- package/dist/src/commands/patch/move-files.js +1 -1
- package/dist/src/commands/patch/rename.js +1 -1
- package/dist/src/commands/patch/reorder.d.ts +1 -2
- package/dist/src/commands/patch/reorder.js +1 -2
- package/dist/src/commands/patch/split.js +5 -3
- package/dist/src/commands/patch/staged-dependency-validate.js +1 -1
- package/dist/src/commands/patch/staged-dependency.js +4 -4
- package/dist/src/commands/re-export-adjacent.js +1 -1
- package/dist/src/commands/re-export-bulk-scan.d.ts +1 -1
- package/dist/src/commands/re-export-bulk-scan.js +5 -4
- package/dist/src/commands/re-export-drift.d.ts +4 -4
- package/dist/src/commands/re-export-drift.js +47 -8
- package/dist/src/commands/re-export-lint.d.ts +7 -5
- package/dist/src/commands/re-export-lint.js +9 -8
- package/dist/src/commands/re-export-single.d.ts +1 -1
- package/dist/src/commands/re-export-single.js +5 -4
- package/dist/src/commands/re-export.js +26 -14
- package/dist/src/commands/reset.js +1 -1
- package/dist/src/commands/run.js +1 -1
- package/dist/src/commands/source.js +6 -6
- package/dist/src/commands/status-check.d.ts +1 -1
- package/dist/src/commands/status-check.js +1 -1
- package/dist/src/commands/status-json.d.ts +2 -2
- package/dist/src/commands/status-json.js +1 -1
- package/dist/src/commands/status-output.d.ts +1 -1
- package/dist/src/commands/status-output.js +1 -1
- package/dist/src/commands/status.js +21 -7
- package/dist/src/commands/test-build-phase.d.ts +2 -2
- package/dist/src/commands/test-build-phase.js +9 -6
- package/dist/src/commands/test-diagnose.js +8 -1
- package/dist/src/commands/test-modes.d.ts +2 -2
- package/dist/src/commands/test-modes.js +3 -3
- package/dist/src/commands/test-register.js +7 -2
- package/dist/src/commands/test-run.d.ts +6 -0
- package/dist/src/commands/test-run.js +32 -3
- package/dist/src/commands/test-stale-gate.js +7 -2
- package/dist/src/commands/test.js +4 -4
- package/dist/src/commands/tree.js +2 -2
- package/dist/src/commands/typecheck.d.ts +2 -1
- package/dist/src/commands/typecheck.js +59 -3
- package/dist/src/commands/verify.js +26 -1
- package/dist/src/core/ast-utils.js +14 -2
- package/dist/src/core/browser-wire.js +9 -1
- package/dist/src/core/build-baseline-types.d.ts +1 -1
- package/dist/src/core/build-baseline.d.ts +1 -1
- package/dist/src/core/build-baseline.js +1 -1
- package/dist/src/core/build-overwrite-guard.d.ts +49 -0
- package/dist/src/core/build-overwrite-guard.js +112 -0
- package/dist/src/core/build-prepare.d.ts +13 -5
- package/dist/src/core/build-prepare.js +42 -9
- package/dist/src/core/config-mutate.d.ts +3 -2
- package/dist/src/core/config-mutate.js +5 -2
- package/dist/src/core/config.d.ts +3 -2
- package/dist/src/core/config.js +2 -0
- package/dist/src/core/coverage-extend.d.ts +1 -1
- package/dist/src/core/coverage-extend.js +1 -1
- package/dist/src/core/destructive.d.ts +23 -0
- package/dist/src/core/destructive.js +45 -4
- package/dist/src/core/diff-hunks.js +31 -17
- package/dist/src/core/discard-baseline.d.ts +1 -1
- package/dist/src/core/discard-baseline.js +1 -1
- package/dist/src/core/display-state.d.ts +52 -0
- package/dist/src/core/display-state.js +89 -0
- package/dist/src/core/engine-session-lock.d.ts +20 -0
- package/dist/src/core/engine-session-lock.js +84 -14
- package/dist/src/core/export-moved-code-shape.d.ts +60 -0
- package/dist/src/core/export-moved-code-shape.js +129 -0
- package/dist/src/core/file-lock.d.ts +44 -0
- package/dist/src/core/file-lock.js +157 -42
- package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
- package/dist/src/core/furnace-apply-helpers.js +1 -1
- package/dist/src/core/furnace-apply-output.js +1 -1
- package/dist/src/core/furnace-apply-overwrite-warn.d.ts +2 -2
- package/dist/src/core/furnace-apply-overwrite-warn.js +2 -2
- package/dist/src/core/furnace-apply-undeploy-actions.d.ts +1 -1
- package/dist/src/core/furnace-apply-undeploy-actions.js +1 -1
- package/dist/src/core/furnace-apply.js +13 -1
- package/dist/src/core/furnace-config-custom.d.ts +2 -1
- package/dist/src/core/furnace-config-order.d.ts +2 -1
- package/dist/src/core/furnace-config-order.js +9 -4
- package/dist/src/core/furnace-config-validate.d.ts +4 -3
- package/dist/src/core/furnace-config-validate.js +3 -3
- package/dist/src/core/furnace-config.js +5 -0
- package/dist/src/core/furnace-operation.d.ts +12 -0
- package/dist/src/core/furnace-operation.js +125 -1
- package/dist/src/core/furnace-rollback.d.ts +17 -0
- package/dist/src/core/furnace-rollback.js +35 -1
- package/dist/src/core/git-base.js +7 -2
- package/dist/src/core/git-diff.js +11 -2
- package/dist/src/core/git-readonly-index.d.ts +59 -0
- package/dist/src/core/git-readonly-index.js +137 -0
- package/dist/src/core/license-headers.d.ts +1 -1
- package/dist/src/core/license-headers.js +1 -1
- package/dist/src/core/lint-cache.d.ts +15 -5
- package/dist/src/core/lint-cache.js +35 -8
- package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
- package/dist/src/core/mach-known-noise-filter.js +23 -0
- package/dist/src/core/mach-mozconfig.js +12 -2
- package/dist/src/core/module-resolution-preflight.d.ts +51 -0
- package/dist/src/core/module-resolution-preflight.js +155 -0
- package/dist/src/core/patch-export.js +2 -1
- package/dist/src/core/patch-identifier-suggest.js +29 -3
- package/dist/src/core/patch-lint-checkjs.d.ts +5 -5
- package/dist/src/core/patch-lint-checkjs.js +6 -6
- package/dist/src/core/patch-lint-creators.d.ts +1 -1
- package/dist/src/core/patch-lint-creators.js +1 -1
- package/dist/src/core/patch-lint-cross.d.ts +1 -1
- package/dist/src/core/patch-lint-cross.js +4 -4
- package/dist/src/core/patch-lint-css.js +1 -1
- package/dist/src/core/patch-lint-diff.d.ts +1 -1
- package/dist/src/core/patch-lint-diff.js +1 -1
- package/dist/src/core/patch-lint-jsdoc.js +4 -2
- package/dist/src/core/patch-lint-module-registration.d.ts +13 -0
- package/dist/src/core/patch-lint-module-registration.js +13 -4
- package/dist/src/core/patch-lint-mozbuild.d.ts +1 -1
- package/dist/src/core/patch-lint-mozbuild.js +1 -1
- package/dist/src/core/patch-lint-ownership.d.ts +2 -2
- package/dist/src/core/patch-lint-ownership.js +2 -2
- package/dist/src/core/patch-lint.d.ts +2 -2
- package/dist/src/core/patch-lint.js +3 -3
- package/dist/src/core/patch-lock.d.ts +21 -0
- package/dist/src/core/patch-lock.js +29 -2
- package/dist/src/core/patch-manifest-io.d.ts +2 -1
- package/dist/src/core/patch-manifest-io.js +49 -0
- package/dist/src/core/patch-parse.d.ts +18 -0
- package/dist/src/core/patch-parse.js +24 -1
- package/dist/src/core/patch-policy.d.ts +2 -2
- package/dist/src/core/patch-policy.js +2 -2
- package/dist/src/core/smoke-patterns.d.ts +1 -1
- package/dist/src/core/status-binary.d.ts +1 -1
- package/dist/src/core/status-binary.js +1 -1
- package/dist/src/core/status-classify.d.ts +1 -1
- package/dist/src/core/status-classify.js +2 -2
- package/dist/src/core/test-harness-crash.d.ts +69 -3
- package/dist/src/core/test-harness-crash.js +174 -9
- package/dist/src/core/test-stale-check.d.ts +1 -1
- package/dist/src/core/test-stale-check.js +1 -1
- package/dist/src/core/token-category.js +1 -1
- package/dist/src/core/token-manager.d.ts +1 -1
- package/dist/src/core/token-manager.js +5 -5
- package/dist/src/core/tree-cow.js +1 -1
- package/dist/src/core/tree-guard.js +2 -2
- package/dist/src/core/tree-store.js +1 -1
- package/dist/src/core/typecheck-shim.d.ts +2 -2
- package/dist/src/core/typecheck-shim.js +2 -2
- package/dist/src/core/typecheck.js +10 -0
- package/dist/src/core/xpcshell-profile-dir.js +1 -1
- package/dist/src/errors/base.d.ts +22 -1
- package/dist/src/errors/base.js +32 -1
- package/dist/src/errors/codes.d.ts +10 -0
- package/dist/src/errors/codes.js +10 -0
- package/dist/src/types/commands/options.d.ts +27 -11
- package/dist/src/types/commands/patches.d.ts +1 -1
- package/dist/src/types/config.d.ts +2 -2
- package/dist/src/types/furnace.d.ts +1 -1
- package/dist/src/types/json.d.ts +22 -0
- package/dist/src/types/json.js +11 -0
- package/dist/src/utils/assert.d.ts +46 -0
- package/dist/src/utils/assert.js +83 -0
- package/dist/src/utils/build-info.d.ts +1 -1
- package/dist/src/utils/build-info.js +1 -1
- package/dist/src/utils/logger.d.ts +20 -0
- package/dist/src/utils/logger.js +26 -0
- package/dist/src/utils/options.d.ts +12 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/stdio-drain.d.ts +1 -1
- package/dist/src/utils/stdio-drain.js +1 -1
- package/dist/src/utils/validation.d.ts +12 -1
- package/dist/src/utils/validation.js +13 -1
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { LockContentionError } from '../errors/base.js';
|
|
6
6
|
import { getNodeErrorCode, isProcessAlive, toError } from '../utils/errors.js';
|
|
@@ -64,17 +64,107 @@ async function readLockOwner(lockPath) {
|
|
|
64
64
|
}
|
|
65
65
|
return { present: false };
|
|
66
66
|
}
|
|
67
|
+
/** Sibling directory in which waiters advertise themselves. */
|
|
68
|
+
function lockWaitersDir(lockPath) {
|
|
69
|
+
return `${lockPath}.waiters`;
|
|
70
|
+
}
|
|
71
|
+
/** Parsed waiter-file name: `<startedAtMs>-<pid>-<uuid>`. */
|
|
72
|
+
function parseWaiterFileName(name) {
|
|
73
|
+
const match = /^(\d+)-(\d+)-/.exec(name);
|
|
74
|
+
const startedAt = match?.[1];
|
|
75
|
+
const pid = match?.[2];
|
|
76
|
+
if (startedAt === undefined || pid === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
return { startedAt: Number.parseInt(startedAt, 10), pid: Number.parseInt(pid, 10) };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reads the live waiter queue around `lockPath`, dropping entries whose
|
|
82
|
+
* process is gone (a killed waiter must not inflate the depth forever).
|
|
83
|
+
*
|
|
84
|
+
* Advisory and fail-open: an unreadable registry reports an empty queue
|
|
85
|
+
* rather than failing whatever asked.
|
|
86
|
+
*
|
|
87
|
+
* @param lockPath - The lock directory whose queue to inspect
|
|
88
|
+
* @param selfStartedAt - This process's wait start, to compute `ahead`
|
|
89
|
+
*/
|
|
90
|
+
export async function readLockQueue(lockPath, selfStartedAt) {
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(lockWaitersDir(lockPath));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { depth: 0, ahead: 0 };
|
|
97
|
+
}
|
|
98
|
+
const live = names
|
|
99
|
+
.map(parseWaiterFileName)
|
|
100
|
+
.filter((entry) => entry !== undefined)
|
|
101
|
+
.filter((entry) => isProcessAlive(entry.pid));
|
|
102
|
+
const ahead = selfStartedAt === undefined
|
|
103
|
+
? 0
|
|
104
|
+
: live.filter((entry) => entry.startedAt < selfStartedAt).length;
|
|
105
|
+
return { depth: live.length, ahead };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Inspects a lock without acquiring it. Read-only and fail-open: a missing
|
|
109
|
+
* lock is reported as free, never as an error.
|
|
110
|
+
*/
|
|
111
|
+
export async function readLockStatus(lockPath) {
|
|
112
|
+
const queue = await readLockQueue(lockPath);
|
|
113
|
+
let heldForMs;
|
|
114
|
+
try {
|
|
115
|
+
// Clamped: filesystem mtime resolution can round a just-created lock
|
|
116
|
+
// fractionally ahead of `Date.now()`, and a negative age is nonsense
|
|
117
|
+
// in a status report.
|
|
118
|
+
heldForMs = Math.max(0, Date.now() - (await stat(lockPath)).mtimeMs);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { held: false, queueDepth: queue.depth };
|
|
122
|
+
}
|
|
123
|
+
const owner = await readLockOwner(lockPath);
|
|
124
|
+
const holder = owner.present
|
|
125
|
+
? { pid: owner.pid, alive: isProcessAlive(owner.pid), metadata: owner.metadata }
|
|
126
|
+
: undefined;
|
|
127
|
+
return { held: true, holder, heldForMs, queueDepth: queue.depth };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Registers this process in the lock's waiter queue. Returns a
|
|
131
|
+
* deregistration function that is always safe to call (including when
|
|
132
|
+
* registration itself failed).
|
|
133
|
+
*/
|
|
134
|
+
async function registerWaiter(lockPath, startedAt) {
|
|
135
|
+
const dir = lockWaitersDir(lockPath);
|
|
136
|
+
const file = join(dir, `${String(startedAt)}-${String(process.pid)}-${randomUUID()}`);
|
|
137
|
+
try {
|
|
138
|
+
await ensureDir(dir);
|
|
139
|
+
await writeFile(file, `${String(process.pid)}\n`, 'utf-8');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
// Advisory only — never fail a lock acquisition over the queue display.
|
|
143
|
+
verbose(`Could not register lock waiter for ${lockPath}: ${toError(error).message}`);
|
|
144
|
+
return async () => {
|
|
145
|
+
/* nothing to remove */
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return async () => {
|
|
149
|
+
await rm(file, { force: true });
|
|
150
|
+
};
|
|
151
|
+
}
|
|
67
152
|
/**
|
|
68
153
|
* Reads the contended lock's owner and reports wait progress to the caller's
|
|
69
154
|
* `onWaitProgress` callback. Failure to read the owner degrades to
|
|
70
155
|
* `holder: undefined` — progress reporting must never break the wait loop.
|
|
71
156
|
*/
|
|
72
|
-
async function reportWaitProgress(lockPath, waitedMs, timeoutMs, onWaitProgress) {
|
|
157
|
+
async function reportWaitProgress(lockPath, waitedMs, timeoutMs, startedAt, onWaitProgress) {
|
|
73
158
|
const owner = await readLockOwner(lockPath);
|
|
74
159
|
const holder = owner.present
|
|
75
160
|
? { pid: owner.pid, alive: isProcessAlive(owner.pid), metadata: owner.metadata }
|
|
76
161
|
: undefined;
|
|
77
|
-
onWaitProgress({
|
|
162
|
+
onWaitProgress({
|
|
163
|
+
waitedMs,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
holder,
|
|
166
|
+
queue: await readLockQueue(lockPath, startedAt),
|
|
167
|
+
});
|
|
78
168
|
}
|
|
79
169
|
/**
|
|
80
170
|
* Reaps a lock directory believed to be stale, safely against concurrent
|
|
@@ -209,6 +299,32 @@ async function releaseLock(lockPath, ownerToken, ownerFileWritten) {
|
|
|
209
299
|
}
|
|
210
300
|
await rm(lockPath, { recursive: true, force: true });
|
|
211
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Builds the contention refusal thrown when the wait budget expires.
|
|
304
|
+
*
|
|
305
|
+
* Typed as {@link LockContentionError} (a `FireForgeError`) so the CLI
|
|
306
|
+
* boundary prints the refusal as one clean line instead of an "Unexpected
|
|
307
|
+
* error" stack. Callers supplying `onTimeoutMessage` still get
|
|
308
|
+
* the holder identification appended — the reason-first copy stays theirs,
|
|
309
|
+
* the "who holds it" is ours.
|
|
310
|
+
*/
|
|
311
|
+
async function buildLockTimeoutError(lockPath, onTimeoutMessage, cause) {
|
|
312
|
+
const owner = await readLockOwner(lockPath);
|
|
313
|
+
const liveHolder = owner.present && isProcessAlive(owner.pid) ? owner : undefined;
|
|
314
|
+
const details = liveHolder !== undefined && liveHolder.metadata.length > 0
|
|
315
|
+
? ` (${liveHolder.metadata.join(', ')})`
|
|
316
|
+
: '';
|
|
317
|
+
if (onTimeoutMessage !== undefined) {
|
|
318
|
+
const identifiedHolder = liveHolder !== undefined
|
|
319
|
+
? ` The lock is held by PID ${String(liveHolder.pid)}${details}.`
|
|
320
|
+
: '';
|
|
321
|
+
return new LockContentionError(`${onTimeoutMessage}${identifiedHolder}`, cause);
|
|
322
|
+
}
|
|
323
|
+
const holderHint = liveHolder !== undefined
|
|
324
|
+
? ` It is held by running process ${String(liveHolder.pid)}${details} — wait for it to finish, or stop it and retry.`
|
|
325
|
+
: ' If no other FireForge process is running, remove the lock directory and retry.';
|
|
326
|
+
return new LockContentionError(`Timed out waiting for file lock ${lockPath}.${holderHint}`, cause);
|
|
327
|
+
}
|
|
212
328
|
/** Runs an async operation while holding a directory lock, with stale-lock recovery. */
|
|
213
329
|
export async function withFileLock(lockPath, operation, options = {}) {
|
|
214
330
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
@@ -220,51 +336,50 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
220
336
|
let lastStaleProbeAt;
|
|
221
337
|
let currentPollMs = pollMs;
|
|
222
338
|
let lastProgressAt = startedAt;
|
|
339
|
+
/** Set on first contention so an uncontended acquire costs nothing. */
|
|
340
|
+
let deregisterWaiter;
|
|
223
341
|
await ensureDir(dirname(lockPath));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
catch (error) {
|
|
230
|
-
if (getNodeErrorCode(error) !== 'EEXIST') {
|
|
231
|
-
throw error;
|
|
342
|
+
try {
|
|
343
|
+
for (;;) {
|
|
344
|
+
try {
|
|
345
|
+
await mkdir(lockPath);
|
|
346
|
+
break;
|
|
232
347
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (getNodeErrorCode(error) !== 'EEXIST') {
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
// Advertise this waiter so the queue is visible to the wait output
|
|
353
|
+
// and to `fireforge status --lock`. Registered lazily:
|
|
354
|
+
// an uncontended acquisition never touches the registry.
|
|
355
|
+
deregisterWaiter ??= await registerWaiter(lockPath, startedAt);
|
|
356
|
+
if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
|
|
357
|
+
lastStaleProbeAt = Date.now();
|
|
358
|
+
if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (Date.now() >= deadline) {
|
|
363
|
+
throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error);
|
|
364
|
+
}
|
|
365
|
+
if (options.onWaitProgress !== undefined &&
|
|
366
|
+
options.waitProgressMs !== undefined &&
|
|
367
|
+
Date.now() - lastProgressAt >= options.waitProgressMs) {
|
|
368
|
+
lastProgressAt = Date.now();
|
|
369
|
+
await reportWaitProgress(lockPath, Date.now() - startedAt, timeoutMs, startedAt, options.onWaitProgress);
|
|
370
|
+
}
|
|
371
|
+
await sleep(currentPollMs);
|
|
372
|
+
if (options.pollMaxMs !== undefined) {
|
|
373
|
+
currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
|
|
237
374
|
}
|
|
238
|
-
}
|
|
239
|
-
if (Date.now() >= deadline) {
|
|
240
|
-
const owner = await readLockOwner(lockPath);
|
|
241
|
-
const holderHint = owner.present && isProcessAlive(owner.pid)
|
|
242
|
-
? ` It is held by running process ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''} — wait for it to finish, or stop it and retry.`
|
|
243
|
-
: ' If no other FireForge process is running, remove the lock directory and retry.';
|
|
244
|
-
// Typed as LockContentionError (a FireForgeError) so the CLI
|
|
245
|
-
// boundary prints the refusal as one clean line instead of an
|
|
246
|
-
// "Unexpected error" stack (FORGE H5). Callers supplying
|
|
247
|
-
// onTimeoutMessage still get the holder identification appended —
|
|
248
|
-
// the reason-first copy stays theirs, the "who holds it" is ours.
|
|
249
|
-
const identifiedHolder = options.onTimeoutMessage !== undefined && owner.present && isProcessAlive(owner.pid)
|
|
250
|
-
? ` The lock is held by PID ${String(owner.pid)}${owner.metadata.length > 0 ? ` (${owner.metadata.join(', ')})` : ''}.`
|
|
251
|
-
: '';
|
|
252
|
-
throw new LockContentionError(options.onTimeoutMessage !== undefined
|
|
253
|
-
? `${options.onTimeoutMessage}${identifiedHolder}`
|
|
254
|
-
: `Timed out waiting for file lock ${lockPath}.${holderHint}`, error);
|
|
255
|
-
}
|
|
256
|
-
if (options.onWaitProgress !== undefined &&
|
|
257
|
-
options.waitProgressMs !== undefined &&
|
|
258
|
-
Date.now() - lastProgressAt >= options.waitProgressMs) {
|
|
259
|
-
lastProgressAt = Date.now();
|
|
260
|
-
await reportWaitProgress(lockPath, Date.now() - startedAt, timeoutMs, options.onWaitProgress);
|
|
261
|
-
}
|
|
262
|
-
await sleep(currentPollMs);
|
|
263
|
-
if (options.pollMaxMs !== undefined) {
|
|
264
|
-
currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
|
|
265
375
|
}
|
|
266
376
|
}
|
|
267
377
|
}
|
|
378
|
+
finally {
|
|
379
|
+
// Leaving the queue happens the moment we stop waiting — whether we
|
|
380
|
+
// acquired the lock or timed out.
|
|
381
|
+
await deregisterWaiter?.();
|
|
382
|
+
}
|
|
268
383
|
// Stamp ownership (PID + per-acquisition token) into the lock directory so
|
|
269
384
|
// stale-lock recovery can check liveness and release can verify the lock
|
|
270
385
|
// is still ours before removing it.
|
|
@@ -8,7 +8,7 @@ interface DirectoryEntry {
|
|
|
8
8
|
/**
|
|
9
9
|
* True for a plain-file directory entry — symlinks and directories are
|
|
10
10
|
* never copy candidates. Exported for the patch-owned overwrite probe
|
|
11
|
-
|
|
11
|
+
*, which walks the same override copy-candidate set as apply.
|
|
12
12
|
*/
|
|
13
13
|
export declare function isRegularFile(entry: DirectoryEntry): boolean;
|
|
14
14
|
/**
|
|
@@ -18,7 +18,7 @@ import { fileExistsInHead, restoreTrackedPath } from './git-file-ops.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* True for a plain-file directory entry — symlinks and directories are
|
|
20
20
|
* never copy candidates. Exported for the patch-owned overwrite probe
|
|
21
|
-
|
|
21
|
+
*, which walks the same override copy-candidate set as apply.
|
|
22
22
|
*/
|
|
23
23
|
export function isRegularFile(entry) {
|
|
24
24
|
if (!entry.isFile())
|
|
@@ -51,7 +51,7 @@ export function logApplyResult(result, isDryRun) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
// Patch-owned overwrite warnings
|
|
54
|
+
// Patch-owned overwrite warnings print on every non-dry-run
|
|
55
55
|
// outcome — including the rolled-back branch, where the overwrite
|
|
56
56
|
// happened before the rollback restored it and the operator still needs
|
|
57
57
|
// to know the deployed copy was momentarily replaced.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Patch-owned overwrite warnings for `furnace apply
|
|
2
|
+
* Patch-owned overwrite warnings for `furnace apply`.
|
|
3
3
|
*
|
|
4
4
|
* For Furnace-managed components, apply OVERWRITES the deployed engine
|
|
5
5
|
* copies with the `components/` sources. A fix made directly in `engine/`
|
|
@@ -26,7 +26,7 @@ export interface PatchOwnedOverwriteWarning {
|
|
|
26
26
|
owners: string[];
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* Loads the file → owning-patch map for the overwrite warning
|
|
29
|
+
* Loads the file → owning-patch map for the overwrite warning.
|
|
30
30
|
* A missing or unreadable patches manifest degrades to an empty map — the
|
|
31
31
|
* warning is advisory and must never block an apply.
|
|
32
32
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
/**
|
|
3
|
-
* Patch-owned overwrite warnings for `furnace apply
|
|
3
|
+
* Patch-owned overwrite warnings for `furnace apply`.
|
|
4
4
|
*
|
|
5
5
|
* For Furnace-managed components, apply OVERWRITES the deployed engine
|
|
6
6
|
* copies with the `components/` sources. A fix made directly in `engine/`
|
|
@@ -29,7 +29,7 @@ import { checkRegistrationConsistency } from './furnace-validate-registration.js
|
|
|
29
29
|
import { loadPatchesManifest } from './patch-manifest.js';
|
|
30
30
|
import { buildPatchClaims } from './status-classify.js';
|
|
31
31
|
/**
|
|
32
|
-
* Loads the file → owning-patch map for the overwrite warning
|
|
32
|
+
* Loads the file → owning-patch map for the overwrite warning.
|
|
33
33
|
* A missing or unreadable patches manifest degrades to an empty map — the
|
|
34
34
|
* warning is advisory and must never block an apply.
|
|
35
35
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dry-run action builders for files deleted from a component workspace
|
|
3
3
|
* since the last apply. Split out of `furnace-apply.ts` (at the per-file
|
|
4
|
-
* line budget after the
|
|
4
|
+
* line budget after the overwrite-warning wiring); the real
|
|
5
5
|
* undeploy paths stay in `furnace-apply-helpers.ts`.
|
|
6
6
|
*/
|
|
7
7
|
import type { CustomComponentConfig, DryRunAction, OverrideComponentConfig } from '../types/furnace.js';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Dry-run action builders for files deleted from a component workspace
|
|
4
4
|
* since the last apply. Split out of `furnace-apply.ts` (at the per-file
|
|
5
|
-
* line budget after the
|
|
5
|
+
* line budget after the overwrite-warning wiring); the real
|
|
6
6
|
* undeploy paths stay in `furnace-apply-helpers.ts`.
|
|
7
7
|
*/
|
|
8
8
|
import { join } from 'node:path';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { FurnaceError } from '../errors/furnace.js';
|
|
4
|
+
import { assert } from '../utils/assert.js';
|
|
4
5
|
import { toError } from '../utils/errors.js';
|
|
5
6
|
import { pathExists } from '../utils/fs.js';
|
|
6
7
|
import { info } from '../utils/logger.js';
|
|
@@ -320,6 +321,13 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
320
321
|
if (rollbackJournal && operationContext) {
|
|
321
322
|
operationContext.registerJournal(rollbackJournal);
|
|
322
323
|
}
|
|
324
|
+
// Everything below this line can write to the engine. A real apply that
|
|
325
|
+
// reached it without a journal would have no way back — neither the
|
|
326
|
+
// lifecycle wrapper's throw path nor the signal handler can restore what
|
|
327
|
+
// was never captured. `operationContext` is legitimately absent for the
|
|
328
|
+
// few callers that drive apply outside the wrapper, so the invariant is on
|
|
329
|
+
// the journal itself.
|
|
330
|
+
assert(dryRun || rollbackJournal !== undefined, 'rollback journal created before a real apply');
|
|
323
331
|
const result = {
|
|
324
332
|
applied: [],
|
|
325
333
|
skipped: [],
|
|
@@ -336,7 +344,7 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
336
344
|
throw new FurnaceError(`Component "${componentName}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, componentName);
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
|
-
// Patch-claim map for the overwrite warning
|
|
347
|
+
// Patch-claim map for the overwrite warning: loaded once per
|
|
340
348
|
// apply. A missing/unreadable manifest degrades to an empty map — the
|
|
341
349
|
// warning is advisory and must never block the apply.
|
|
342
350
|
const patchClaims = await loadPatchClaimsForApply(root);
|
|
@@ -377,6 +385,10 @@ export async function applyAllComponents(root, dryRun = false, options) {
|
|
|
377
385
|
if (!dryRun) {
|
|
378
386
|
if (result.errors.length > 0 || hasStepErrors) {
|
|
379
387
|
if (rollbackJournal) {
|
|
388
|
+
// This branch owns the rollback for the collected-errors case, so the
|
|
389
|
+
// lifecycle wrapper must not restore the same journal again if the
|
|
390
|
+
// restore below throws on its way out.
|
|
391
|
+
operationContext?.markRolledBack();
|
|
380
392
|
try {
|
|
381
393
|
await restoreRollbackJournalOrThrow(rollbackJournal, 'Furnace apply failed');
|
|
382
394
|
result.rolledBack = true;
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* each add their own validation branch.
|
|
7
7
|
*/
|
|
8
8
|
import type { CustomComponentConfig } from '../types/furnace.js';
|
|
9
|
+
import type { JsonObject } from '../types/json.js';
|
|
9
10
|
/**
|
|
10
11
|
* Validates a custom component config object.
|
|
11
12
|
* @param data - Raw data to validate
|
|
12
13
|
* @param name - Component name for error messages
|
|
13
14
|
*/
|
|
14
|
-
export declare function parseCustomConfig(data:
|
|
15
|
+
export declare function parseCustomConfig(data: JsonObject, name: string): CustomComponentConfig;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { FurnaceConfig } from '../types/furnace.js';
|
|
2
|
+
import type { JsonObject } from '../types/json.js';
|
|
2
3
|
/**
|
|
3
4
|
* Orders furnace.json output using the existing file as the primary key
|
|
4
5
|
* sequence, preserving unknown extension keys and appending newly supported
|
|
5
6
|
* fields only when needed.
|
|
6
7
|
*/
|
|
7
|
-
export declare function orderFurnaceConfigForWrite(existing:
|
|
8
|
+
export declare function orderFurnaceConfigForWrite(existing: JsonObject | undefined, config: FurnaceConfig): JsonObject;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isJsonObject } from '../utils/validation.js';
|
|
2
2
|
const FURNACE_CONFIG_TOP_LEVEL_KEYS = new Set([
|
|
3
3
|
'version',
|
|
4
4
|
'componentPrefix',
|
|
@@ -30,9 +30,9 @@ function orderObjectLikeExisting(existing, next) {
|
|
|
30
30
|
return ordered;
|
|
31
31
|
}
|
|
32
32
|
function orderComponentMapLikeExisting(existing, next) {
|
|
33
|
-
if (!
|
|
33
|
+
if (!isJsonObject(next))
|
|
34
34
|
return next;
|
|
35
|
-
if (!
|
|
35
|
+
if (!isJsonObject(existing))
|
|
36
36
|
return next;
|
|
37
37
|
const ordered = {};
|
|
38
38
|
for (const key of Object.keys(existing)) {
|
|
@@ -40,7 +40,7 @@ function orderComponentMapLikeExisting(existing, next) {
|
|
|
40
40
|
const existingValue = existing[key];
|
|
41
41
|
const nextValue = next[key];
|
|
42
42
|
ordered[key] =
|
|
43
|
-
|
|
43
|
+
isJsonObject(existingValue) && isJsonObject(nextValue)
|
|
44
44
|
? orderObjectLikeExisting(existingValue, nextValue)
|
|
45
45
|
: nextValue;
|
|
46
46
|
}
|
|
@@ -58,6 +58,11 @@ function orderComponentMapLikeExisting(existing, next) {
|
|
|
58
58
|
* fields only when needed.
|
|
59
59
|
*/
|
|
60
60
|
export function orderFurnaceConfigForWrite(existing, config) {
|
|
61
|
+
// FurnaceConfig is plain parsed-JSON data, but as an interface it carries
|
|
62
|
+
// no index signature, so the compiler cannot verify the JsonObject
|
|
63
|
+
// contract structurally — hence the double assertion. This is the one
|
|
64
|
+
// place a typed furnace config re-enters the raw-document world.
|
|
65
|
+
// eslint-disable-next-line no-restricted-syntax -- see above
|
|
61
66
|
const next = config;
|
|
62
67
|
if (!existing)
|
|
63
68
|
return next;
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* list, the named-component maps, and the optional-field tail.
|
|
6
6
|
*/
|
|
7
7
|
import type { FurnaceConfig, OverrideComponentConfig } from '../types/furnace.js';
|
|
8
|
+
import type { JsonObject, JsonValue } from '../types/json.js';
|
|
8
9
|
/**
|
|
9
10
|
* Validates an override component config object.
|
|
10
11
|
* @param data - Raw data to validate
|
|
11
12
|
* @param name - Component name for error messages
|
|
12
13
|
*/
|
|
13
|
-
export declare function parseOverrideConfig(data:
|
|
14
|
+
export declare function parseOverrideConfig(data: JsonObject, name: string): OverrideComponentConfig;
|
|
14
15
|
/**
|
|
15
16
|
* Parses and validates the `stock` component list: lowercase identifiers,
|
|
16
17
|
* no duplicates.
|
|
@@ -21,11 +22,11 @@ export declare function parseStockList(raw: unknown): string[];
|
|
|
21
22
|
* map must be an object, every key a lowercase identifier, every value an
|
|
22
23
|
* object handed to the kind-specific parser.
|
|
23
24
|
*/
|
|
24
|
-
export declare function parseNamedComponentMap<T>(raw:
|
|
25
|
+
export declare function parseNamedComponentMap<T>(raw: JsonValue | undefined, kind: 'override' | 'custom', key: 'overrides' | 'custom', parse: (value: JsonObject, name: string) => T): Record<string, T>;
|
|
25
26
|
/**
|
|
26
27
|
* Copies the validated optional fields (token settings, platform
|
|
27
28
|
* prefixes, ftl/jsconfig/scan paths) from the migrated raw config onto
|
|
28
29
|
* the typed config, re-validating the path-shaped ones against
|
|
29
30
|
* traversal.
|
|
30
31
|
*/
|
|
31
|
-
export declare function applyOptionalFurnaceFields(migrated:
|
|
32
|
+
export declare function applyOptionalFurnaceFields(migrated: JsonObject, config: FurnaceConfig): void;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* list, the named-component maps, and the optional-field tail.
|
|
7
7
|
*/
|
|
8
8
|
import { FurnaceError } from '../errors/furnace.js';
|
|
9
|
-
import {
|
|
9
|
+
import { isJsonObject, isString } from '../utils/validation.js';
|
|
10
10
|
import { parseStringArray } from './furnace-config-array-utils.js';
|
|
11
11
|
/**
|
|
12
12
|
* Validates an override component config object.
|
|
@@ -62,7 +62,7 @@ export function parseStockList(raw) {
|
|
|
62
62
|
* object handed to the kind-specific parser.
|
|
63
63
|
*/
|
|
64
64
|
export function parseNamedComponentMap(raw, kind, key, parse) {
|
|
65
|
-
if (!
|
|
65
|
+
if (!isJsonObject(raw)) {
|
|
66
66
|
throw new FurnaceError(`Furnace config: "${key}" must be an object`);
|
|
67
67
|
}
|
|
68
68
|
const out = {};
|
|
@@ -70,7 +70,7 @@ export function parseNamedComponentMap(raw, kind, key, parse) {
|
|
|
70
70
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
71
71
|
throw new FurnaceError(`Furnace config: ${kind} name "${name}" must match /^[a-z][a-z0-9-]*$/ (lowercase, no path separators)`);
|
|
72
72
|
}
|
|
73
|
-
if (!
|
|
73
|
+
if (!isJsonObject(value)) {
|
|
74
74
|
throw new FurnaceError(`Furnace config: ${kind} "${name}" must be an object`);
|
|
75
75
|
}
|
|
76
76
|
out[name] = parse(value, name);
|
|
@@ -93,6 +93,9 @@ export function validateFurnaceConfig(data) {
|
|
|
93
93
|
if (!isObject(data)) {
|
|
94
94
|
throw new FurnaceError('Furnace config must be an object');
|
|
95
95
|
}
|
|
96
|
+
// Furnace config documents arrive from JSON.parse (loadFurnaceConfig /
|
|
97
|
+
// writeFurnaceConfig round-trips), which can only produce JSON values,
|
|
98
|
+
// so the object check above is the only invariant left to establish.
|
|
96
99
|
// Run migration before validation so older configs are transparently upgraded.
|
|
97
100
|
const migrated = migrateFurnaceConfig(data);
|
|
98
101
|
if (migrated['version'] !== CURRENT_CONFIG_VERSION) {
|
|
@@ -356,6 +359,8 @@ export async function writeFurnaceConfig(root, config) {
|
|
|
356
359
|
if (await pathExists(paths.furnaceConfig)) {
|
|
357
360
|
try {
|
|
358
361
|
const raw = await readJson(paths.furnaceConfig);
|
|
362
|
+
// JSON.parse output is JSON data by construction, so the object
|
|
363
|
+
// check is the only invariant left for the JsonObject contract.
|
|
359
364
|
if (isObject(raw))
|
|
360
365
|
existing = raw;
|
|
361
366
|
}
|
|
@@ -29,6 +29,18 @@ export interface FurnaceOperationContext {
|
|
|
29
29
|
* collected, not re-thrown.
|
|
30
30
|
*/
|
|
31
31
|
registerCleanup(cleanup: () => Promise<void>): void;
|
|
32
|
+
/**
|
|
33
|
+
* Declares that the body has already restored its own journal, so the
|
|
34
|
+
* wrapper's throw-path rollback must not restore it a second time.
|
|
35
|
+
*
|
|
36
|
+
* Bodies that catch-and-restore on their own (the `furnace/*` command
|
|
37
|
+
* bodies, `furnace-apply`) call this from their catch block after the
|
|
38
|
+
* restore, then re-throw. Without it the wrapper would restore the same
|
|
39
|
+
* journal again on the way out — harmless for the file writes, which are
|
|
40
|
+
* rename-based and converge, but it would race a second `pendingRepair`
|
|
41
|
+
* write against the state-file lock.
|
|
42
|
+
*/
|
|
43
|
+
markRolledBack(): void;
|
|
32
44
|
}
|
|
33
45
|
/** Options for `runFurnaceMutation`. */
|
|
34
46
|
export interface RunFurnaceMutationOptions {
|