@hominis/fireforge 0.46.2 → 0.47.1
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 +39 -0
- package/dist/bin/fireforge.js +6 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/config.js +1 -2
- package/dist/src/commands/furnace/deploy-fragment-includers.d.ts +59 -0
- package/dist/src/commands/furnace/deploy-fragment-includers.js +120 -0
- package/dist/src/commands/furnace/deploy.js +36 -3
- package/dist/src/commands/furnace/validation-output.d.ts +5 -0
- package/dist/src/commands/furnace/validation-output.js +35 -9
- package/dist/src/commands/patch/delete-gap-notice.d.ts +24 -0
- package/dist/src/commands/patch/delete-gap-notice.js +46 -0
- package/dist/src/commands/patch/delete.js +11 -0
- package/dist/src/commands/re-export-lint.d.ts +26 -1
- package/dist/src/commands/re-export-lint.js +60 -20
- package/dist/src/commands/re-export-scan.d.ts +8 -0
- package/dist/src/commands/re-export-scan.js +12 -2
- package/dist/src/commands/re-export-single.js +24 -5
- package/dist/src/commands/run.js +42 -17
- package/dist/src/commands/test-harness-env.d.ts +24 -0
- package/dist/src/commands/test-harness-env.js +61 -0
- package/dist/src/commands/test-harness-teardown.d.ts +62 -0
- package/dist/src/commands/test-harness-teardown.js +128 -0
- package/dist/src/commands/test-register.js +8 -3
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +36 -7
- package/dist/src/commands/test-verdict.d.ts +21 -4
- package/dist/src/commands/test-verdict.js +37 -4
- package/dist/src/commands/test.js +27 -26
- package/dist/src/core/build-jar-escalation.d.ts +1 -4
- package/dist/src/core/build-jar-escalation.js +1 -4
- package/dist/src/core/build-prepare.js +72 -55
- 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-test-toolchains.js +7 -0
- package/dist/src/core/file-lock.js +16 -9
- package/dist/src/core/firefox-cache.js +1 -2
- package/dist/src/core/furnace-apply-output.d.ts +10 -0
- package/dist/src/core/furnace-apply-output.js +19 -4
- package/dist/src/core/furnace-css-fragments.d.ts +7 -0
- package/dist/src/core/furnace-css-fragments.js +12 -3
- package/dist/src/core/furnace-fragment-includers.d.ts +56 -0
- package/dist/src/core/furnace-fragment-includers.js +122 -0
- package/dist/src/core/git-diff-new-file.js +1 -2
- package/dist/src/core/git-file-ops.js +1 -2
- package/dist/src/core/git.js +1 -2
- package/dist/src/core/harness-helper-pids.d.ts +98 -0
- package/dist/src/core/harness-helper-pids.js +240 -0
- package/dist/src/core/harness-orphans.d.ts +53 -8
- package/dist/src/core/harness-orphans.js +125 -44
- package/dist/src/core/mach-build-artifacts.js +1 -2
- package/dist/src/core/mach.d.ts +16 -0
- package/dist/src/core/mach.js +6 -1
- package/dist/src/core/marionette-preflight.js +9 -3
- package/dist/src/core/mochitest-server-port.d.ts +43 -4
- package/dist/src/core/mochitest-server-port.js +64 -11
- package/dist/src/core/parent-exit-watchdog.d.ts +62 -0
- package/dist/src/core/parent-exit-watchdog.js +69 -0
- package/dist/src/core/patch-lint-imports.js +4 -4
- package/dist/src/core/smoke-patterns.d.ts +24 -0
- package/dist/src/core/smoke-patterns.js +50 -5
- package/dist/src/types/commands/options.d.ts +17 -1
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/utils/process-group.d.ts +5 -1
- package/dist/src/utils/process-group.js +7 -3
- package/dist/src/utils/process.d.ts +18 -0
- package/dist/src/utils/process.js +14 -2
- package/docs/configuration.md +15 -1
- package/docs/exit-codes.md +19 -19
- package/docs/lifecycle-invariants.md +17 -1
- package/docs/machine-output.md +44 -19
- package/docs/testing.md +80 -4
- package/package.json +5 -5
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* cost startup time but make results reproducible. The combined invocation
|
|
11
11
|
* stays available via `--no-shard`.
|
|
12
12
|
*/
|
|
13
|
+
import { join } from 'node:path';
|
|
13
14
|
import { probeDisplaySleepState } from '../core/display-state.js';
|
|
14
15
|
import { runMachTestSuite } from '../core/mach.js';
|
|
16
|
+
import { parentExited } from '../core/parent-exit-watchdog.js';
|
|
15
17
|
import { getActiveRunLogPath } from '../core/run-log.js';
|
|
16
18
|
import { changedPrefNoiseVerdictNote } from '../core/test-changed-prefs.js';
|
|
17
19
|
import { buildHarnessCrashMessage, classifyHarnessRun, headedDisplayAsleepVerdictNote, headedNoOutputTimeoutHint, } from '../core/test-harness-crash.js';
|
|
@@ -21,6 +23,7 @@ import { TestFailureError } from '../errors/build.js';
|
|
|
21
23
|
import { info, note, warn } from '../utils/logger.js';
|
|
22
24
|
import { getPlatform } from '../utils/platform.js';
|
|
23
25
|
import { maybeInjectAppdirArg } from './test-appdir.js';
|
|
26
|
+
import { createHarnessTeardown } from './test-harness-teardown.js';
|
|
24
27
|
import { emitHarnessVerdict } from './test-verdict.js';
|
|
25
28
|
/** Default bounded retry budget for recognized harness crashes. */
|
|
26
29
|
export const DEFAULT_HARNESS_RETRIES = 2;
|
|
@@ -61,13 +64,14 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
|
|
|
61
64
|
let verdict;
|
|
62
65
|
for (;;) {
|
|
63
66
|
attempts += 1;
|
|
64
|
-
result = await
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
result = await runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env);
|
|
68
|
+
// The parent that was reading this run is gone and the watchdog has
|
|
69
|
+
// already killed the harness. Classifying the corpse would call it a
|
|
70
|
+
// harness crash and retry it under nobody's supervision.
|
|
71
|
+
if (parentExited()) {
|
|
72
|
+
throw new TestFailureError('The parent process exited while the harness was running; the harness tree was ' +
|
|
73
|
+
'terminated and the run ended without a result.', `mach ${kind}`, undefined, getActiveRunLogPath());
|
|
74
|
+
}
|
|
71
75
|
result = await retryAfterXpcshellSymlinkRepair({
|
|
72
76
|
engineDir: ctx.engineDir,
|
|
73
77
|
objDir: ctx.objDir,
|
|
@@ -107,6 +111,31 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
|
|
|
107
111
|
}
|
|
108
112
|
return { result, verdict, attempts, appdirInjectionAttempted, displayState };
|
|
109
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* One mach dispatch with its kill path attached: helper-pid tracking and
|
|
116
|
+
* post-close reaping, the process-group announcement, and the parent-exit
|
|
117
|
+
* watchdog for exactly as long as mach runs.
|
|
118
|
+
*/
|
|
119
|
+
async function runTrackedMachTestSuite(ctx, kind, paths, extraArgs, env) {
|
|
120
|
+
const teardown = createHarnessTeardown({
|
|
121
|
+
objDir: ctx.objDir === undefined ? undefined : join(ctx.engineDir, ctx.objDir),
|
|
122
|
+
pgidFile: ctx.pgidFile,
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
await teardown.prepare();
|
|
126
|
+
return await runMachTestSuite(kind, {
|
|
127
|
+
engineDir: ctx.engineDir,
|
|
128
|
+
testPaths: paths,
|
|
129
|
+
args: extraArgs,
|
|
130
|
+
env,
|
|
131
|
+
fullOutput: ctx.fullOutput,
|
|
132
|
+
teardown: teardown.hooks,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
teardown.dispose();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
110
139
|
/**
|
|
111
140
|
* Probes the display's power state for a headed no-output stall, and only for
|
|
112
141
|
* that shape. Every other outcome (a passing run, a real test failure, a
|
|
@@ -12,6 +12,16 @@ import type { HarnessRunVerdict } from '../core/test-harness-crash.js';
|
|
|
12
12
|
* of the others.
|
|
13
13
|
*/
|
|
14
14
|
export type FireforgeVerdictReason = 'crash' | 'no-tests' | 'test-failures' | 'preflight' | 'inconclusive' | 'lock-timeout' | 'killed';
|
|
15
|
+
/** Additive keys a run can stamp on its verdict line. */
|
|
16
|
+
export type VerdictRunAttributeKey = 'shuffle' | 'orphans-reaped';
|
|
17
|
+
/** Records an additive attribute for this run's verdict line. */
|
|
18
|
+
export declare function setVerdictRunAttribute(key: VerdictRunAttributeKey, value: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Adds to a counting attribute. The key appears only once the total is
|
|
21
|
+
* positive, so a run that reaped nothing prints the line it always did.
|
|
22
|
+
* Sums across the preflight census, every retry attempt and every shard.
|
|
23
|
+
*/
|
|
24
|
+
export declare function addVerdictRunCount(key: 'orphans-reaped', count: number): void;
|
|
15
25
|
/**
|
|
16
26
|
* Re-arms the verdict sink for a new run. Called at `testCommand` entry so
|
|
17
27
|
* repeated programmatic invocations (and tests) each get their own
|
|
@@ -34,7 +44,7 @@ export declare function emitPassVerdict(): void;
|
|
|
34
44
|
* Emits a FAIL verdict carrying an emission-layer reason code, and
|
|
35
45
|
* optionally the refusal class that produced it.
|
|
36
46
|
*
|
|
37
|
-
* `note=` is additive, like the `log=`, `signal=` and `
|
|
47
|
+
* `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
|
|
38
48
|
* on this line: `reason=` is unchanged and still comes from the closed set
|
|
39
49
|
* in `docs/machine-output.md`, so a consumer that tokenises `key=value`
|
|
40
50
|
* pairs is unaffected. It exists because `reason=preflight` covers every
|
|
@@ -47,7 +57,14 @@ export declare function emitPassVerdict(): void;
|
|
|
47
57
|
*/
|
|
48
58
|
export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: string): void;
|
|
49
59
|
/**
|
|
50
|
-
*
|
|
60
|
+
* What ended a killed run: a signal FireForge received, or `parent-exit`,
|
|
61
|
+
* the parent-death watchdog's finding that the process above FireForge
|
|
62
|
+
* vanished (typically SIGKILLed by a supervisor) while a harness ran.
|
|
63
|
+
*/
|
|
64
|
+
export type KilledVerdictCause = NodeJS.Signals | 'parent-exit';
|
|
65
|
+
/**
|
|
66
|
+
* Emits the terminal verdict for a run killed by a signal, or ended by
|
|
67
|
+
* FireForge itself because its parent died.
|
|
51
68
|
*
|
|
52
69
|
* Without it a killed run wrote no terminal line, so a log tail could not
|
|
53
70
|
* distinguish "killed" from "still running" from "never started". Recovery
|
|
@@ -57,7 +74,7 @@ export declare function emitFailVerdict(reason: FireforgeVerdictReason, note?: s
|
|
|
57
74
|
* line asserts a test run happened, and an interrupted `status` did not
|
|
58
75
|
* have one.
|
|
59
76
|
*
|
|
60
|
-
* @param signal - The signal that terminated the run
|
|
77
|
+
* @param signal - The signal that terminated the run, or `parent-exit`
|
|
61
78
|
* @returns True when a line was written
|
|
62
79
|
*/
|
|
63
|
-
export declare function emitKilledVerdict(signal:
|
|
80
|
+
export declare function emitKilledVerdict(signal: KilledVerdictCause): boolean;
|
|
@@ -3,6 +3,36 @@ import { getActiveRunLogPath } from '../core/run-log.js';
|
|
|
3
3
|
import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
|
|
4
4
|
import { setStdoutSealed } from '../utils/logger.js';
|
|
5
5
|
let emitted = false;
|
|
6
|
+
/**
|
|
7
|
+
* Additive `key=value` attributes of the run in flight, appended to
|
|
8
|
+
* whichever verdict line ends it. `shuffle` is the seed a `--shuffle` run
|
|
9
|
+
* exported to the harness, so a red found by shuffling carries its own
|
|
10
|
+
* reproduction on the one line an unattended reader keeps. `orphans-reaped`
|
|
11
|
+
* is how many harness processes FireForge had to terminate itself, at
|
|
12
|
+
* preflight (survivors of an earlier run) or at teardown (helpers this run
|
|
13
|
+
* launched that outlived mach): a green after a reap is not the same
|
|
14
|
+
* finding as a green on a quiet machine. Cleared by
|
|
15
|
+
* {@link resetVerdictEmission}, so a later run in the same process cannot
|
|
16
|
+
* inherit them.
|
|
17
|
+
*/
|
|
18
|
+
const runAttributes = new Map();
|
|
19
|
+
/** Records an additive attribute for this run's verdict line. */
|
|
20
|
+
export function setVerdictRunAttribute(key, value) {
|
|
21
|
+
runAttributes.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Adds to a counting attribute. The key appears only once the total is
|
|
25
|
+
* positive, so a run that reaped nothing prints the line it always did.
|
|
26
|
+
* Sums across the preflight census, every retry attempt and every shard.
|
|
27
|
+
*/
|
|
28
|
+
export function addVerdictRunCount(key, count) {
|
|
29
|
+
// Written as a negated `> 0` so a NaN (a stubbed census, a bad parse) is
|
|
30
|
+
// dropped rather than stamped on the line as `orphans-reaped=NaN`.
|
|
31
|
+
if (!(count > 0))
|
|
32
|
+
return;
|
|
33
|
+
const current = Number(runAttributes.get(key) ?? '0');
|
|
34
|
+
runAttributes.set(key, String(current + count));
|
|
35
|
+
}
|
|
6
36
|
/**
|
|
7
37
|
* True once a test run has begun in this process.
|
|
8
38
|
*
|
|
@@ -20,6 +50,7 @@ let armed = false;
|
|
|
20
50
|
export function resetVerdictEmission() {
|
|
21
51
|
emitted = false;
|
|
22
52
|
armed = true;
|
|
53
|
+
runAttributes.clear();
|
|
23
54
|
setStdoutSealed(false);
|
|
24
55
|
}
|
|
25
56
|
/** True once this run has written its `FIREFORGE-VERDICT:` line. */
|
|
@@ -45,9 +76,10 @@ function writeVerdictLine(line) {
|
|
|
45
76
|
// break that contract, and one before it is the first thing a `tail` cuts.
|
|
46
77
|
// Appended as an additive `key=value`, so consumers that tokenize the line
|
|
47
78
|
// are unaffected and a truncated tail still says where the full output is.
|
|
79
|
+
const attributes = [...runAttributes].map(([key, value]) => ` ${key}=${value}`).join('');
|
|
48
80
|
const logPath = getActiveRunLogPath();
|
|
49
81
|
const suffix = logPath === undefined ? '' : ` log=${logPath}`;
|
|
50
|
-
process.stdout.write(`${line}${suffix}\n`);
|
|
82
|
+
process.stdout.write(`${line}${attributes}${suffix}\n`);
|
|
51
83
|
setStdoutSealed(true);
|
|
52
84
|
}
|
|
53
85
|
/**
|
|
@@ -65,7 +97,7 @@ export function emitPassVerdict() {
|
|
|
65
97
|
* Emits a FAIL verdict carrying an emission-layer reason code, and
|
|
66
98
|
* optionally the refusal class that produced it.
|
|
67
99
|
*
|
|
68
|
-
* `note=` is additive, like the `log=`, `signal=` and `
|
|
100
|
+
* `note=` is additive, like the `log=`, `signal=`, `shards=` and `shuffle=` keys already
|
|
69
101
|
* on this line: `reason=` is unchanged and still comes from the closed set
|
|
70
102
|
* in `docs/machine-output.md`, so a consumer that tokenises `key=value`
|
|
71
103
|
* pairs is unaffected. It exists because `reason=preflight` covers every
|
|
@@ -81,7 +113,8 @@ export function emitFailVerdict(reason, note) {
|
|
|
81
113
|
writeVerdictLine(`FIREFORGE-VERDICT: FAIL reason=${reason}${suffix}`);
|
|
82
114
|
}
|
|
83
115
|
/**
|
|
84
|
-
* Emits the terminal verdict for a run killed by a signal
|
|
116
|
+
* Emits the terminal verdict for a run killed by a signal, or ended by
|
|
117
|
+
* FireForge itself because its parent died.
|
|
85
118
|
*
|
|
86
119
|
* Without it a killed run wrote no terminal line, so a log tail could not
|
|
87
120
|
* distinguish "killed" from "still running" from "never started". Recovery
|
|
@@ -91,7 +124,7 @@ export function emitFailVerdict(reason, note) {
|
|
|
91
124
|
* line asserts a test run happened, and an interrupted `status` did not
|
|
92
125
|
* have one.
|
|
93
126
|
*
|
|
94
|
-
* @param signal - The signal that terminated the run
|
|
127
|
+
* @param signal - The signal that terminated the run, or `parent-exit`
|
|
95
128
|
* @returns True when a line was written
|
|
96
129
|
*/
|
|
97
130
|
export function emitKilledVerdict(signal) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
4
|
import { assertEngineExists } from '../core/engine-precondition.js';
|
|
5
5
|
import { assertEngineGenerationUnchanged, snapshotEngineGeneration, } from '../core/engine-session-lock.js';
|
|
@@ -21,9 +21,11 @@ import { info, intro, notice, outro, verbose } from '../utils/logger.js';
|
|
|
21
21
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
22
22
|
import { runTestBuildPhase } from './test-build-phase.js';
|
|
23
23
|
import { diagnoseShardOutcome, finalizeSingleRunOutcome } from './test-diagnose.js';
|
|
24
|
+
import { buildPerfSampleEnv, mergeHarnessEnv, resolveShuffleSeed } from './test-harness-env.js';
|
|
25
|
+
import { removePgidFile, setActivePgidFile } from './test-harness-teardown.js';
|
|
24
26
|
import { assertPathlessTestMode, assertTestModeCombinations, canaryTimeoutSeconds, classifyBeforeDispatch, reportCanaryOutcome, resolveCanaryPath, } from './test-modes.js';
|
|
25
27
|
import { DEFAULT_HARNESS_RETRIES, finalizeShardedOutcome, runShardedTests, runTestsWithRetries, } from './test-run.js';
|
|
26
|
-
import { emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
|
|
28
|
+
import { addVerdictRunCount, emitFailVerdict, emitPassVerdict, resetVerdictEmission, verdictEmitted, } from './test-verdict.js';
|
|
27
29
|
async function assertTestPathsExist(engineDir, testPaths) {
|
|
28
30
|
const missingPaths = [];
|
|
29
31
|
for (const testPath of testPaths) {
|
|
@@ -226,7 +228,7 @@ async function ensureTestMarionettePortAvailable(port, binaryName, options, skip
|
|
|
226
228
|
}
|
|
227
229
|
await assertMarionettePortAvailable(port, { binaryName });
|
|
228
230
|
}
|
|
229
|
-
async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly,
|
|
231
|
+
async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellOnly, projectConfig, options, objDir) {
|
|
230
232
|
// A timed-out mochitest can leave the built app alive after its Marionette
|
|
231
233
|
// listener has disappeared. The port probe cannot see that case, but the
|
|
232
234
|
// survivor can still steal focus and wedge every later headed run.
|
|
@@ -241,20 +243,25 @@ async function ensureTestBrowserEnvironment(engineDir, launchablePath, xpcshellO
|
|
|
241
243
|
// does not use the httpd, so an xpcshell-only run is never blocked by it.
|
|
242
244
|
if (!xpcshellOnly) {
|
|
243
245
|
await ensureMochitestServerPortAvailable(undefined, {
|
|
246
|
+
engineDir,
|
|
244
247
|
killStaleServer: options.killStaleMarionette === true,
|
|
245
248
|
});
|
|
246
249
|
}
|
|
247
250
|
// Helpers that outlived an earlier run (httpd, pywebsocket, ssltunnel,
|
|
248
251
|
// moz-http2) slow every later run without appearing in its output. Both
|
|
249
252
|
// harnesses are affected, so this census is not gated on xpcshellOnly.
|
|
250
|
-
|
|
251
|
-
|
|
253
|
+
// Reaping is opt-in per invocation (--reap-orphans) or per repo
|
|
254
|
+
// (test.reapOrphans: "reap"); a reap is stamped on the verdict line so a
|
|
255
|
+
// green after one is not mistaken for a green on a quiet machine.
|
|
256
|
+
const census = await reportOrphanedHarnessProcesses(objDir, {
|
|
257
|
+
reap: options.reapOrphans === true || projectConfig.test?.reapOrphans === 'reap',
|
|
252
258
|
});
|
|
259
|
+
addVerdictRunCount('orphans-reaped', census.reaped);
|
|
253
260
|
const forwardedPort = options.machArg
|
|
254
261
|
? extractForwardedMarionettePort(options.machArg)
|
|
255
262
|
: undefined;
|
|
256
263
|
const effectivePort = options.marionettePort ?? forwardedPort;
|
|
257
|
-
await ensureTestMarionettePortAvailable(effectivePort, binaryName, options, {
|
|
264
|
+
await ensureTestMarionettePortAvailable(effectivePort, projectConfig.binaryName, options, {
|
|
258
265
|
xpcshellOnly,
|
|
259
266
|
doctor: options.doctor === true,
|
|
260
267
|
});
|
|
@@ -289,6 +296,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
289
296
|
// exactly the runs whose only output a `tail` throws away. Closed in
|
|
290
297
|
// `finally`, after the verdict line has already read the path.
|
|
291
298
|
setActiveRunLog(await openRunLog(projectRoot, 'test'));
|
|
299
|
+
setActivePgidFile(options.pgidFile);
|
|
292
300
|
try {
|
|
293
301
|
await runTestCommandBody(projectRoot, testPaths, options);
|
|
294
302
|
}
|
|
@@ -300,6 +308,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
300
308
|
throw error;
|
|
301
309
|
}
|
|
302
310
|
finally {
|
|
311
|
+
// A run that ends under FireForge's control owns no harness group any
|
|
312
|
+
// more; the file only outlives FireForge when FireForge was killed.
|
|
313
|
+
await removePgidFile(options.pgidFile);
|
|
303
314
|
await closeActiveRunLog();
|
|
304
315
|
}
|
|
305
316
|
}
|
|
@@ -403,7 +414,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
403
414
|
// documented `--mach-arg --marionette-port=NNNN` route still hits the
|
|
404
415
|
// wrapper preflight refusing on 2828 before the forwarded arg reaches
|
|
405
416
|
// mach.
|
|
406
|
-
const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig
|
|
417
|
+
const { forwardedPort, effectivePort } = await ensureTestBrowserEnvironment(paths.engine, launchablePath, xpcshellOnly, projectConfig, options, buildCheck.objDir);
|
|
407
418
|
if (options.doctor) {
|
|
408
419
|
const doctorOutcome = await runDoctorPreflight({
|
|
409
420
|
engineDir: paths.engine,
|
|
@@ -418,6 +429,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
418
429
|
}
|
|
419
430
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
420
431
|
const suite = resolveTestSuite(classification, options.genericMachTest === true);
|
|
432
|
+
const shuffleSeed = resolveShuffleSeed(options.shuffle, suite);
|
|
421
433
|
const forwardedMachArgs = options.machArg && options.machArg.length > 0
|
|
422
434
|
? filterRedundantXpcshellFlavorArgs(options.machArg, classification)
|
|
423
435
|
: [];
|
|
@@ -425,20 +437,21 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
425
437
|
if (options.headless) {
|
|
426
438
|
extraArgs.push('--headless');
|
|
427
439
|
}
|
|
440
|
+
if (shuffleSeed !== undefined) {
|
|
441
|
+
extraArgs.push('--shuffle');
|
|
442
|
+
}
|
|
428
443
|
if (options.auto === true) {
|
|
429
444
|
extraArgs.push('--auto');
|
|
430
445
|
}
|
|
431
|
-
if (canaryPath !== undefined)
|
|
446
|
+
if (canaryPath !== undefined)
|
|
432
447
|
extraArgs.push(`--timeout=${canaryTimeoutSeconds(projectConfig)}`);
|
|
433
|
-
}
|
|
434
448
|
// --mach-arg is a verbatim passthrough for upstream mach/xpcshell/mochitest
|
|
435
449
|
// flags FireForge does not model directly (see the xpcshell appdir hint
|
|
436
450
|
// above for why). Appended after --headless so mach sees
|
|
437
451
|
// the FireForge-managed flags first and the escape-valve ones last, which
|
|
438
452
|
// keeps the override precedence predictable.
|
|
439
|
-
if (forwardedMachArgs.length > 0)
|
|
453
|
+
if (forwardedMachArgs.length > 0)
|
|
440
454
|
extraArgs.push(...forwardedMachArgs);
|
|
441
|
-
}
|
|
442
455
|
appendMarionetteForwardingArgs(extraArgs, options, forwardedPort, xpcshellOnly);
|
|
443
456
|
// Directory arguments mean exactly that directory: mozbuild's test
|
|
444
457
|
// resolver matches paths by string prefix, so a bare directory arg
|
|
@@ -465,7 +478,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
465
478
|
// the manifest for each file individually. See src/core/xpcshell-appdir.ts
|
|
466
479
|
// for the full motivation.
|
|
467
480
|
logTestSelection(scopes);
|
|
468
|
-
const
|
|
481
|
+
const harnessEnv = mergeHarnessEnv(buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples), shuffleSeed === undefined ? undefined : { FIREFORGE_SHUFFLE_SEED: String(shuffleSeed) });
|
|
469
482
|
const runCtx = {
|
|
470
483
|
engineDir: paths.engine,
|
|
471
484
|
objDir: buildCheck.objDir,
|
|
@@ -475,7 +488,8 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
475
488
|
harnessRetries,
|
|
476
489
|
headless: options.headless === true,
|
|
477
490
|
...(options.fullOutput === true ? { fullOutput: true } : {}),
|
|
478
|
-
...(
|
|
491
|
+
...(harnessEnv ? { env: harnessEnv } : {}),
|
|
492
|
+
pgidFile: options.pgidFile,
|
|
479
493
|
};
|
|
480
494
|
const postRebuildContext = options.build
|
|
481
495
|
? createPostRebuildFailureContext('fireforge test --build', normalizedPaths)
|
|
@@ -527,16 +541,3 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
|
|
|
527
541
|
}
|
|
528
542
|
finalizeSingleRunOutcome(outcome, normalizedPaths, projectConfig.binaryName, postRebuildContext, options.headless === true);
|
|
529
543
|
}
|
|
530
|
-
/**
|
|
531
|
-
* Builds the perf-sample env contract for the harness run:
|
|
532
|
-
* `--perf-samples <path>` exports `<BINARYNAME>_PERF_SAMPLE_JSON` naming the
|
|
533
|
-
* artifact file a budget checker consumes after the run.
|
|
534
|
-
*/
|
|
535
|
-
function buildPerfSampleEnv(projectRoot, binaryName, perfSamples) {
|
|
536
|
-
if (!perfSamples)
|
|
537
|
-
return undefined;
|
|
538
|
-
const envName = `${binaryName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PERF_SAMPLE_JSON`;
|
|
539
|
-
const artifactPath = resolve(projectRoot, perfSamples);
|
|
540
|
-
info(`Perf sample contract: ${envName}=${artifactPath}`);
|
|
541
|
-
return { [envName]: artifactPath };
|
|
542
|
-
}
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
* parse a manifest escalates: this narrows a conservative rule, so its
|
|
29
29
|
* error direction stays the slow-but-correct one.
|
|
30
30
|
*/
|
|
31
|
-
import type { BuildBaseline } from './build-baseline-types.js';
|
|
32
31
|
/** Why one changed `jar.mn` still forces a full build. */
|
|
33
32
|
export interface JarEscalationCause {
|
|
34
33
|
/** Engine-relative manifest path. */
|
|
@@ -51,11 +50,9 @@ export interface JarEscalationDecision {
|
|
|
51
50
|
* @param engineDir - Absolute engine directory
|
|
52
51
|
* @param changedPaths - Engine-relative paths changed since the last
|
|
53
52
|
* successful build (non-`jar.mn` entries are ignored)
|
|
54
|
-
* @param baseline - Last successful build's baseline (reserved: the
|
|
55
|
-
* fingerprint map already gated which paths reach here)
|
|
56
53
|
* @returns The escalation decision with per-manifest causes
|
|
57
54
|
*/
|
|
58
|
-
export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[]
|
|
55
|
+
export declare function evaluateJarManifestEscalation(engineDir: string, changedPaths: readonly string[]): Promise<JarEscalationDecision>;
|
|
59
56
|
/**
|
|
60
57
|
* Renders the operator-facing escalation notice. Names the manifest and the
|
|
61
58
|
* reason: "a jar.mn changed" is the sentence that made the previous rule
|
|
@@ -68,12 +68,9 @@ async function isNewJarManifest(engineDir, path) {
|
|
|
68
68
|
* @param engineDir - Absolute engine directory
|
|
69
69
|
* @param changedPaths - Engine-relative paths changed since the last
|
|
70
70
|
* successful build (non-`jar.mn` entries are ignored)
|
|
71
|
-
* @param baseline - Last successful build's baseline (reserved: the
|
|
72
|
-
* fingerprint map already gated which paths reach here)
|
|
73
71
|
* @returns The escalation decision with per-manifest causes
|
|
74
72
|
*/
|
|
75
|
-
export async function evaluateJarManifestEscalation(engineDir, changedPaths
|
|
76
|
-
void baseline;
|
|
73
|
+
export async function evaluateJarManifestEscalation(engineDir, changedPaths) {
|
|
77
74
|
const causes = [];
|
|
78
75
|
const cleared = [];
|
|
79
76
|
for (const path of changedPaths.filter(isJarManifestPath)) {
|
|
@@ -16,6 +16,7 @@ import { evaluateJarManifestEscalation, formatJarEscalationNotice, } from './bui
|
|
|
16
16
|
import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
|
|
17
17
|
import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
|
|
18
18
|
import { applyAllComponents } from './furnace-apply.js';
|
|
19
|
+
import { logApplyWarnings } from './furnace-apply-output.js';
|
|
19
20
|
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
|
|
20
21
|
import { runFurnaceMutation } from './furnace-operation.js';
|
|
21
22
|
import { countEntriesWithBlockingStepErrors } from './furnace-step-errors.js';
|
|
@@ -114,15 +115,14 @@ function buildConfigureFailureError(captured) {
|
|
|
114
115
|
*
|
|
115
116
|
* @param engineDir - Absolute engine directory
|
|
116
117
|
* @param changed - Build inputs changed since the last successful build
|
|
117
|
-
* @param baseline - Last successful build's baseline
|
|
118
118
|
*/
|
|
119
|
-
async function decideJarEscalation(engineDir, changed
|
|
119
|
+
async function decideJarEscalation(engineDir, changed) {
|
|
120
120
|
// Packaging manifests whose graph/destination directories may need a full
|
|
121
121
|
// build. Path-shape only: `evaluateJarManifestEscalation` decides whether a
|
|
122
122
|
// given changed manifest actually forces one (a new manifest, or one that
|
|
123
123
|
// redirects its install base directory), so an entry added to an existing
|
|
124
124
|
// `dist/bin` manifest no longer costs a ~10-minute build.
|
|
125
|
-
const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(isJarManifestPath)
|
|
125
|
+
const decision = await evaluateJarManifestEscalation(engineDir, changed.filter(isJarManifestPath));
|
|
126
126
|
if (decision.cleared.length > 0) {
|
|
127
127
|
verbose(`jar escalation: ${decision.cleared.length} changed jar.mn add entries to an existing dist/bin manifest; not escalating for ${decision.cleared.join(', ')}.`);
|
|
128
128
|
}
|
|
@@ -164,6 +164,73 @@ async function reportUnexportedDriftBeforeOverwrite(projectRoot, config, options
|
|
|
164
164
|
}
|
|
165
165
|
notice(message);
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Applies Furnace components before the build when `furnace.json` exists.
|
|
169
|
+
* Prints every warning the apply computed, then the loud sync banner.
|
|
170
|
+
*
|
|
171
|
+
* @param projectRoot - Project root
|
|
172
|
+
* @returns Number of components written to engine/
|
|
173
|
+
*/
|
|
174
|
+
async function applyFurnaceBeforeBuild(projectRoot) {
|
|
175
|
+
if (!(await furnaceConfigExists(projectRoot)))
|
|
176
|
+
return 0;
|
|
177
|
+
const furnaceConfig = await loadFurnaceConfig(projectRoot);
|
|
178
|
+
const hasComponents = Object.keys(furnaceConfig.overrides).length > 0 || Object.keys(furnaceConfig.custom).length > 0;
|
|
179
|
+
if (!hasComponents)
|
|
180
|
+
return 0;
|
|
181
|
+
const furnaceSpinner = spinner('Applying Furnace components...');
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
furnaceSpinner.error('Failed to apply Furnace components');
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const furnaceApplied = result.applied.length;
|
|
191
|
+
// Count entries that were "applied" but recorded step-level errors
|
|
192
|
+
// mid-apply (e.g. a post-step failure after file writes succeeded).
|
|
193
|
+
// These are distinct from `result.errors`, which captures
|
|
194
|
+
// components that failed before reaching the applied list at all.
|
|
195
|
+
// The sum of the two is the total count of failed components.
|
|
196
|
+
const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
|
|
197
|
+
const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
|
|
198
|
+
if (totalApplyFailures > 0) {
|
|
199
|
+
furnaceSpinner.error('Failed to apply Furnace components');
|
|
200
|
+
for (const err of result.errors) {
|
|
201
|
+
warn(`Furnace: ${err.name} — ${err.error}`);
|
|
202
|
+
}
|
|
203
|
+
for (const applied of result.applied) {
|
|
204
|
+
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
205
|
+
for (const stepErr of applied.stepErrors) {
|
|
206
|
+
warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
|
|
211
|
+
}
|
|
212
|
+
// The apply computed these (patch-owned engine files whose content
|
|
213
|
+
// differed from components/ and were replaced). `furnace apply`
|
|
214
|
+
// prints them; this path used to drop them, so a negative control
|
|
215
|
+
// that edited a deployed Furnace file ran against the shipped code
|
|
216
|
+
// with a green result and only a checksum could tell.
|
|
217
|
+
const overwriteCount = logApplyWarnings(result.warnings);
|
|
218
|
+
if (overwriteCount > 0) {
|
|
219
|
+
notice(`Furnace: ${overwriteCount} Furnace-managed engine file${overwriteCount === 1 ? '' : 's'} differed from components/ and ${overwriteCount === 1 ? 'was' : 'were'} REPLACED before this build. An engine-side edit to a Furnace-managed file (for example a negative control) did not run; edit the component source under components/ instead, or restore the edit after the build.`);
|
|
220
|
+
}
|
|
221
|
+
if (furnaceApplied > 0) {
|
|
222
|
+
const appliedNames = result.applied.map((entry) => entry.name).join(', ');
|
|
223
|
+
furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
|
|
224
|
+
// Loud banner: the build operator needs to see that engine/ was
|
|
225
|
+
// updated before this build, otherwise a silent re-apply is
|
|
226
|
+
// indistinguishable from a build that shipped stale components.
|
|
227
|
+
notice(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
furnaceSpinner.stop('Components up to date');
|
|
231
|
+
}
|
|
232
|
+
return furnaceApplied;
|
|
233
|
+
}
|
|
167
234
|
/**
|
|
168
235
|
* Runs the shared pre-flight steps for build and package commands:
|
|
169
236
|
* 1. Warns (or refuses) about unexported engine drift the writes below
|
|
@@ -202,7 +269,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
202
269
|
if (options.previousBaseline) {
|
|
203
270
|
const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
|
|
204
271
|
const invalidating = changed.filter(isBackendInvalidatingFile);
|
|
205
|
-
const jarEscalation = await decideJarEscalation(paths.engine, changed
|
|
272
|
+
const jarEscalation = await decideJarEscalation(paths.engine, changed);
|
|
206
273
|
fullBuildRequired = jarEscalation !== undefined;
|
|
207
274
|
fullBuildReason = jarEscalation;
|
|
208
275
|
if (invalidating.length > 0) {
|
|
@@ -263,57 +330,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
263
330
|
throw error;
|
|
264
331
|
}
|
|
265
332
|
}
|
|
266
|
-
|
|
267
|
-
let furnaceApplied = 0;
|
|
268
|
-
if (await furnaceConfigExists(projectRoot)) {
|
|
269
|
-
const furnaceConfig = await loadFurnaceConfig(projectRoot);
|
|
270
|
-
const hasComponents = Object.keys(furnaceConfig.overrides).length > 0 ||
|
|
271
|
-
Object.keys(furnaceConfig.custom).length > 0;
|
|
272
|
-
if (hasComponents) {
|
|
273
|
-
const furnaceSpinner = spinner('Applying Furnace components...');
|
|
274
|
-
let result;
|
|
275
|
-
try {
|
|
276
|
-
result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
furnaceSpinner.error('Failed to apply Furnace components');
|
|
280
|
-
throw error;
|
|
281
|
-
}
|
|
282
|
-
furnaceApplied = result.applied.length;
|
|
283
|
-
// Count entries that were "applied" but recorded step-level errors
|
|
284
|
-
// mid-apply (e.g. a post-step failure after file writes succeeded).
|
|
285
|
-
// These are distinct from `result.errors`, which captures
|
|
286
|
-
// components that failed before reaching the applied list at all.
|
|
287
|
-
// The sum of the two is the total count of failed components.
|
|
288
|
-
const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
|
|
289
|
-
const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
|
|
290
|
-
if (totalApplyFailures > 0) {
|
|
291
|
-
furnaceSpinner.error('Failed to apply Furnace components');
|
|
292
|
-
for (const err of result.errors) {
|
|
293
|
-
warn(`Furnace: ${err.name} — ${err.error}`);
|
|
294
|
-
}
|
|
295
|
-
for (const applied of result.applied) {
|
|
296
|
-
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
297
|
-
for (const stepErr of applied.stepErrors) {
|
|
298
|
-
warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
|
|
303
|
-
}
|
|
304
|
-
if (furnaceApplied > 0) {
|
|
305
|
-
const appliedNames = result.applied.map((entry) => entry.name).join(', ');
|
|
306
|
-
furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
|
|
307
|
-
// Loud banner: the build operator needs to see that engine/ was
|
|
308
|
-
// updated before this build, otherwise a silent re-apply is
|
|
309
|
-
// indistinguishable from a build that shipped stale components.
|
|
310
|
-
notice(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
|
|
311
|
-
}
|
|
312
|
-
else {
|
|
313
|
-
furnaceSpinner.stop('Components up to date');
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
333
|
+
const furnaceApplied = await applyFurnaceBeforeBuild(projectRoot);
|
|
317
334
|
// Generate mozconfig
|
|
318
335
|
const mozconfigSpinner = spinner('Generating mozconfig...');
|
|
319
336
|
try {
|
|
@@ -7,7 +7,7 @@ export declare const FIREFORGE_DIR = ".fireforge";
|
|
|
7
7
|
/** Supported top-level fireforge.json keys backed by the current schema. */
|
|
8
8
|
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "buildAudit", "test", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
|
|
9
9
|
/** Supported config paths that can be read or set without --force. */
|
|
10
|
-
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
|
|
10
|
+
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "firefox.allowUnverifiedDownload", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "test.reapOrphans", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.checkJsTestFiles", "patchLint.checkJsTestShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
|
|
11
11
|
/**
|
|
12
12
|
* Gets all project paths based on a root directory.
|
|
13
13
|
* @param root - Root directory of the project
|
|
@@ -45,6 +45,13 @@ export function parseTestBlock(rec, config) {
|
|
|
45
45
|
}
|
|
46
46
|
out.canaryTimeoutSeconds = canaryTimeout;
|
|
47
47
|
}
|
|
48
|
+
const reapOrphans = testRec.raw('reapOrphans');
|
|
49
|
+
if (reapOrphans !== undefined) {
|
|
50
|
+
if (reapOrphans !== 'report' && reapOrphans !== 'reap') {
|
|
51
|
+
throw new ConfigError('Config field "test.reapOrphans" must be "report" or "reap"');
|
|
52
|
+
}
|
|
53
|
+
out.reapOrphans = reapOrphans;
|
|
54
|
+
}
|
|
48
55
|
config.test = out;
|
|
49
56
|
}
|
|
50
57
|
/** Parses optional project-specific external toolchain doctor probes. */
|
|
@@ -433,15 +433,13 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
433
433
|
continue;
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
|
|
444
|
-
}
|
|
436
|
+
// The probe runs before the deadline check, so a due progress line is
|
|
437
|
+
// written before the refusal that follows it, never lost to it. On a
|
|
438
|
+
// slow host (a cold Windows CI runner) the first iteration alone, with
|
|
439
|
+
// its waiter registration and stale probe, can outlast a short budget;
|
|
440
|
+
// checked the other way round, the wait timed out having reported
|
|
441
|
+
// nothing, and an advance that probe would have seen could not extend
|
|
442
|
+
// the deadline it was about to miss.
|
|
445
443
|
if (probeWanted && Date.now() - lastProbeAt >= probeIntervalMs) {
|
|
446
444
|
lastProbeAt = Date.now();
|
|
447
445
|
// Report the budget that is currently in force, not the one
|
|
@@ -473,6 +471,15 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
473
471
|
}
|
|
474
472
|
}
|
|
475
473
|
}
|
|
474
|
+
if (Date.now() >= deadline) {
|
|
475
|
+
// The queue is read here, not carried over from the last progress
|
|
476
|
+
// probe: the position the wait reached is the one number that says
|
|
477
|
+
// whether a larger budget would have helped, and it must not depend
|
|
478
|
+
// on a probe having happened to land inside the budget. We are still
|
|
479
|
+
// registered at this point. `deregisterWaiter` runs in the `finally`
|
|
480
|
+
// below, which is only entered once this refusal has been built.
|
|
481
|
+
throw await buildLockTimeoutError(lockPath, options.onTimeoutMessage, error, await readLockQueue(lockPath, startedAt));
|
|
482
|
+
}
|
|
476
483
|
await sleep(currentPollMs);
|
|
477
484
|
if (options.pollMaxMs !== undefined) {
|
|
478
485
|
currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
|