@hominis/fireforge 0.44.4 → 0.45.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 +40 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/doctor-source-pin.d.ts +29 -0
- package/dist/src/commands/doctor-source-pin.js +81 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/export-flow.js +3 -18
- package/dist/src/commands/patch/move-files-into.js +3 -6
- package/dist/src/commands/patch/split-plan.js +3 -6
- package/dist/src/commands/re-export-adjacent.d.ts +8 -0
- package/dist/src/commands/re-export-adjacent.js +26 -1
- package/dist/src/commands/re-export-drift.d.ts +8 -0
- package/dist/src/commands/re-export-drift.js +61 -0
- package/dist/src/commands/re-export-files.js +3 -6
- package/dist/src/commands/re-export-options.js +19 -5
- package/dist/src/commands/re-export-register.js +5 -1
- package/dist/src/commands/re-export-scan.js +3 -6
- package/dist/src/commands/re-export.js +16 -16
- package/dist/src/commands/test-build-phase.js +2 -1
- package/dist/src/commands/test-diagnose.js +14 -5
- package/dist/src/commands/test-register.js +1 -0
- package/dist/src/commands/test-run.js +4 -3
- package/dist/src/commands/test-stale-gate.js +5 -2
- package/dist/src/commands/test.js +9 -2
- package/dist/src/core/build-audit.js +44 -2
- package/dist/src/core/build-jar-escalation.d.ts +64 -0
- package/dist/src/core/build-jar-escalation.js +127 -0
- package/dist/src/core/build-prepare.d.ts +12 -1
- package/dist/src/core/build-prepare.js +36 -3
- package/dist/src/core/config-validate-file-size.d.ts +12 -0
- package/dist/src/core/config-validate-file-size.js +65 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/harness-orphans.d.ts +73 -0
- package/dist/src/core/harness-orphans.js +186 -0
- package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
- package/dist/src/core/mach-known-noise-filter.js +24 -1
- package/dist/src/core/mach.d.ts +7 -0
- package/dist/src/core/mach.js +18 -2
- package/dist/src/core/patch-apply.js +49 -20
- package/dist/src/core/patch-lint-checkjs.js +6 -1
- package/dist/src/core/patch-lint-file-size.d.ts +52 -0
- package/dist/src/core/patch-lint-file-size.js +53 -0
- package/dist/src/core/patch-lint-prettier.d.ts +36 -0
- package/dist/src/core/patch-lint-prettier.js +126 -0
- package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
- package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
- package/dist/src/core/patch-lint.js +23 -24
- package/dist/src/core/patch-transform.d.ts +21 -0
- package/dist/src/core/patch-transform.js +38 -0
- package/dist/src/core/smoke-patterns.js +12 -0
- package/dist/src/core/test-harness-crash.js +61 -48
- package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
- package/dist/src/core/test-harness-verdict-notes.js +48 -0
- package/dist/src/core/test-stale-check.d.ts +23 -6
- package/dist/src/core/test-stale-check.js +75 -2
- package/dist/src/core/test-stall-triage.js +37 -19
- package/dist/src/errors/build.d.ts +28 -0
- package/dist/src/errors/build.js +49 -0
- package/dist/src/types/commands/options.d.ts +19 -0
- package/dist/src/types/commands/patches.d.ts +17 -6
- package/dist/src/types/config.d.ts +41 -0
- package/dist/src/utils/fs.d.ts +4 -2
- package/dist/src/utils/fs.js +3 -1
- package/docs/configuration.md +47 -0
- package/docs/exit-codes.md +4 -2
- package/docs/testing.md +62 -10
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Validation for `patchLint.fileSizeThresholds`.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `config-validate.ts`, which is at its per-file line budget.
|
|
6
|
+
*/
|
|
7
|
+
import { ConfigError } from '../errors/config.js';
|
|
8
|
+
import { isObject } from '../utils/validation.js';
|
|
9
|
+
import { DEFAULT_FILE_SIZE_THRESHOLDS } from './patch-lint-file-size.js';
|
|
10
|
+
/**
|
|
11
|
+
* Parses one `patchLint.fileSizeThresholds.<tier>` triple. Every field is
|
|
12
|
+
* optional and merges over the built-in default; values must be positive
|
|
13
|
+
* integers and stay ordered `notice <= warning <= error`, because an
|
|
14
|
+
* out-of-order triple would silently disable a band instead of failing.
|
|
15
|
+
*/
|
|
16
|
+
function parseFileSizeTier(raw, field) {
|
|
17
|
+
if (!isObject(raw)) {
|
|
18
|
+
throw new ConfigError(`Config field "${field}" must be a plain object`);
|
|
19
|
+
}
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const key of ['notice', 'warning', 'error']) {
|
|
22
|
+
const value = raw[key];
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
26
|
+
throw new ConfigError(`Config field "${field}.${key}" must be a positive integer`);
|
|
27
|
+
}
|
|
28
|
+
out[key] = value;
|
|
29
|
+
}
|
|
30
|
+
for (const key of Object.keys(raw)) {
|
|
31
|
+
if (!['notice', 'warning', 'error'].includes(key)) {
|
|
32
|
+
throw new ConfigError(`Config field "${field}" has unknown key "${key}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parses the `patchLint.fileSizeThresholds` block. Ordering is validated
|
|
39
|
+
* against the MERGED triple (overrides on top of the defaults), so setting
|
|
40
|
+
* only `warning` cannot land below the default `notice`.
|
|
41
|
+
*/
|
|
42
|
+
export function parsePatchLintFileSizeThresholds(raw) {
|
|
43
|
+
if (!isObject(raw)) {
|
|
44
|
+
throw new ConfigError('Config field "patchLint.fileSizeThresholds" must be a plain object');
|
|
45
|
+
}
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const tier of ['general', 'test']) {
|
|
48
|
+
const value = raw[tier];
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
const parsed = parseFileSizeTier(value, `patchLint.fileSizeThresholds.${tier}`);
|
|
52
|
+
const merged = { ...DEFAULT_FILE_SIZE_THRESHOLDS[tier], ...parsed };
|
|
53
|
+
if (merged.notice > merged.warning || merged.warning > merged.error) {
|
|
54
|
+
throw new ConfigError(`Config field "patchLint.fileSizeThresholds.${tier}" must satisfy notice <= warning <= error ` +
|
|
55
|
+
`(resolved: ${String(merged.notice)}/${String(merged.warning)}/${String(merged.error)})`);
|
|
56
|
+
}
|
|
57
|
+
out[tier] = parsed;
|
|
58
|
+
}
|
|
59
|
+
for (const key of Object.keys(raw)) {
|
|
60
|
+
if (key !== 'general' && key !== 'test') {
|
|
61
|
+
throw new ConfigError(`Config field "patchLint.fileSizeThresholds" has unknown key "${key}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -8,6 +8,7 @@ import { parseObject } from '../utils/parse.js';
|
|
|
8
8
|
import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
|
|
9
9
|
import { describeProductVersionIncompatibility, FIREFOX_PRODUCTS, isObject, isValidAppId, isValidFirefoxCandidate, isValidFirefoxProduct, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, } from '../utils/validation.js';
|
|
10
10
|
import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
|
|
11
|
+
import { parsePatchLintFileSizeThresholds } from './config-validate-file-size.js';
|
|
11
12
|
import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
|
|
12
13
|
import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
|
|
13
14
|
/**
|
|
@@ -323,6 +324,7 @@ function optionalPatchLintBoolean(rec, key) {
|
|
|
323
324
|
}
|
|
324
325
|
/** Severity-gate fields, each parsed and assigned identically. */
|
|
325
326
|
const PATCH_LINT_SEVERITY_GATE_KEYS = [
|
|
327
|
+
'prettier',
|
|
326
328
|
'jsdocClassMethods',
|
|
327
329
|
'testAssertionFloor',
|
|
328
330
|
'chromeScriptJsDoc',
|
|
@@ -359,6 +361,10 @@ function parsePatchLintBlock(rec) {
|
|
|
359
361
|
}
|
|
360
362
|
out.rawColorAllowlist = rawColorAllowlist;
|
|
361
363
|
}
|
|
364
|
+
const fileSizeThresholds = rec.raw('fileSizeThresholds');
|
|
365
|
+
if (fileSizeThresholds !== undefined) {
|
|
366
|
+
out.fileSizeThresholds = parsePatchLintFileSizeThresholds(fileSizeThresholds);
|
|
367
|
+
}
|
|
362
368
|
for (const key of PATCH_LINT_SEVERITY_GATE_KEYS) {
|
|
363
369
|
const gate = parseSeverityGate(rec.raw(key), `patchLint.${key}`);
|
|
364
370
|
if (gate !== undefined)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight census of ORPHANED test-harness helper processes.
|
|
3
|
+
*
|
|
4
|
+
* Stopping a long mochitest run mid-flight does not necessarily take its
|
|
5
|
+
* helpers with it. A downstream incident left `xpcshell` (the harness
|
|
6
|
+
* httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2`
|
|
7
|
+
* alive for an hour. Every subsequent run crawled — a three-second suite
|
|
8
|
+
* took six minutes of wall clock and audio-start waits timed out — and
|
|
9
|
+
* nothing in FireForge's output connected the slowness to the survivors.
|
|
10
|
+
* The hour was spent mis-attributing it to the change under test.
|
|
11
|
+
*
|
|
12
|
+
* Visibility alone would have collapsed that hour, which is why the census
|
|
13
|
+
* runs by default and the kill does not. Two rules keep it honest:
|
|
14
|
+
*
|
|
15
|
+
* - The census runs at PREFLIGHT, before this run spawns anything, so
|
|
16
|
+
* every matching process is by construction a survivor of an earlier
|
|
17
|
+
* run. No age heuristic is needed, and none is used.
|
|
18
|
+
* - A match must be anchored to a Firefox OBJDIR. `xpcshell` and
|
|
19
|
+
* `server.js` are far too generic on their own; the same rule
|
|
20
|
+
* `mochitest-server-port.ts` applies to the httpd holder applies here,
|
|
21
|
+
* for the same reason — FireForge has no business reporting, let alone
|
|
22
|
+
* terminating, a process it cannot attribute to the harness.
|
|
23
|
+
*
|
|
24
|
+
* `--reap-orphans` opts into termination. Without it the census is
|
|
25
|
+
* report-only, exactly like the `Orphaned harness workers` doctor check
|
|
26
|
+
* (which covers a DIFFERENT shape: reparented Python multiprocessing
|
|
27
|
+
* workers, matched on PPID 1 and accumulated CPU time).
|
|
28
|
+
*/
|
|
29
|
+
/** One surviving harness helper process. */
|
|
30
|
+
export interface OrphanedHarnessProcess {
|
|
31
|
+
pid: number;
|
|
32
|
+
ppid: number;
|
|
33
|
+
/** Raw `ps` ELAPSED column. */
|
|
34
|
+
elapsed: string;
|
|
35
|
+
/** Parsed elapsed seconds since the process started. */
|
|
36
|
+
elapsedSeconds: number;
|
|
37
|
+
command: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
|
|
41
|
+
* helpers. Pure — fixture-testable without spawning anything.
|
|
42
|
+
*
|
|
43
|
+
* @param psOutput - Raw `ps` output
|
|
44
|
+
* @param objDir - Absolute objdir of this project, when known; widens the
|
|
45
|
+
* provenance test beyond the generic `obj-…` path segment
|
|
46
|
+
* @param selfPid - This process's pid, excluded so FireForge cannot report
|
|
47
|
+
* itself
|
|
48
|
+
*/
|
|
49
|
+
export declare function findOrphanedHarnessProcesses(psOutput: string, objDir?: string, selfPid?: number): OrphanedHarnessProcess[];
|
|
50
|
+
/**
|
|
51
|
+
* Renders the census. Names every process with its elapsed time, because
|
|
52
|
+
* "a leftover harness process exists" and "a leftover harness process has
|
|
53
|
+
* been burning a core for 58 minutes" are different findings.
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatOrphanReport(orphans: readonly OrphanedHarnessProcess[]): string;
|
|
56
|
+
/**
|
|
57
|
+
* Preflight census of surviving harness helpers, run before a test
|
|
58
|
+
* dispatch.
|
|
59
|
+
*
|
|
60
|
+
* Best-effort by construction: a host without a usable `ps` (Windows, a
|
|
61
|
+
* locked-down container) logs at verbose and runs exactly as before. It
|
|
62
|
+
* never refuses a run — unlike the server-port preflight, a survivor here
|
|
63
|
+
* degrades performance rather than making the run dispatch against the
|
|
64
|
+
* wrong server, so the correct response is to say so loudly, not to stop.
|
|
65
|
+
*
|
|
66
|
+
* @param objDir - Absolute objdir of this project, when known
|
|
67
|
+
* @param options - `reap` terminates each recognized survivor (SIGTERM,
|
|
68
|
+
* then SIGKILL for anything that stays)
|
|
69
|
+
* @returns The census, empty when nothing was found or nothing could be probed
|
|
70
|
+
*/
|
|
71
|
+
export declare function reportOrphanedHarnessProcesses(objDir: string | undefined, options?: {
|
|
72
|
+
reap?: boolean;
|
|
73
|
+
}): Promise<OrphanedHarnessProcess[]>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Preflight census of ORPHANED test-harness helper processes.
|
|
4
|
+
*
|
|
5
|
+
* Stopping a long mochitest run mid-flight does not necessarily take its
|
|
6
|
+
* helpers with it. A downstream incident left `xpcshell` (the harness
|
|
7
|
+
* httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2`
|
|
8
|
+
* alive for an hour. Every subsequent run crawled — a three-second suite
|
|
9
|
+
* took six minutes of wall clock and audio-start waits timed out — and
|
|
10
|
+
* nothing in FireForge's output connected the slowness to the survivors.
|
|
11
|
+
* The hour was spent mis-attributing it to the change under test.
|
|
12
|
+
*
|
|
13
|
+
* Visibility alone would have collapsed that hour, which is why the census
|
|
14
|
+
* runs by default and the kill does not. Two rules keep it honest:
|
|
15
|
+
*
|
|
16
|
+
* - The census runs at PREFLIGHT, before this run spawns anything, so
|
|
17
|
+
* every matching process is by construction a survivor of an earlier
|
|
18
|
+
* run. No age heuristic is needed, and none is used.
|
|
19
|
+
* - A match must be anchored to a Firefox OBJDIR. `xpcshell` and
|
|
20
|
+
* `server.js` are far too generic on their own; the same rule
|
|
21
|
+
* `mochitest-server-port.ts` applies to the httpd holder applies here,
|
|
22
|
+
* for the same reason — FireForge has no business reporting, let alone
|
|
23
|
+
* terminating, a process it cannot attribute to the harness.
|
|
24
|
+
*
|
|
25
|
+
* `--reap-orphans` opts into termination. Without it the census is
|
|
26
|
+
* report-only, exactly like the `Orphaned harness workers` doctor check
|
|
27
|
+
* (which covers a DIFFERENT shape: reparented Python multiprocessing
|
|
28
|
+
* workers, matched on PPID 1 and accumulated CPU time).
|
|
29
|
+
*/
|
|
30
|
+
import { toError } from '../utils/errors.js';
|
|
31
|
+
import { info, verbose, warn } from '../utils/logger.js';
|
|
32
|
+
import { exec } from '../utils/process.js';
|
|
33
|
+
import { parsePsDuration } from '../utils/ps-duration.js';
|
|
34
|
+
/**
|
|
35
|
+
* Harness helper executables/scripts. Every one of these is started by the
|
|
36
|
+
* mochitest/xpcshell harness and is expected to die with it; none of them
|
|
37
|
+
* is a thing a developer runs by hand.
|
|
38
|
+
*/
|
|
39
|
+
const HARNESS_HELPER_PATTERN = /\b(?:xpcshell|pywebsocket\w*|websocket_server\.py|ssltunnel|moz-http2|http2_server|httpd\.js|server\.js|runtests\.py|runxpcshelltests\.py)\b/;
|
|
40
|
+
/** Objdir provenance: an explicit objdir path, a `obj-…` path segment, or `_tests/`. */
|
|
41
|
+
function isObjdirAnchored(command, objDir) {
|
|
42
|
+
if (objDir !== undefined && objDir.length > 0 && command.includes(objDir))
|
|
43
|
+
return true;
|
|
44
|
+
if (/[/\\]obj-[^/\\\s]*[/\\]/.test(command))
|
|
45
|
+
return true;
|
|
46
|
+
return /[/\\]_tests[/\\]/.test(command);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Scans `ps -axo pid=,ppid=,etime=,command=` output for surviving harness
|
|
50
|
+
* helpers. Pure — fixture-testable without spawning anything.
|
|
51
|
+
*
|
|
52
|
+
* @param psOutput - Raw `ps` output
|
|
53
|
+
* @param objDir - Absolute objdir of this project, when known; widens the
|
|
54
|
+
* provenance test beyond the generic `obj-…` path segment
|
|
55
|
+
* @param selfPid - This process's pid, excluded so FireForge cannot report
|
|
56
|
+
* itself
|
|
57
|
+
*/
|
|
58
|
+
export function findOrphanedHarnessProcesses(psOutput, objDir, selfPid = process.pid) {
|
|
59
|
+
const found = [];
|
|
60
|
+
for (const line of psOutput.split('\n')) {
|
|
61
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
62
|
+
if (!match)
|
|
63
|
+
continue;
|
|
64
|
+
const pid = Number(match[1]);
|
|
65
|
+
const ppid = Number(match[2]);
|
|
66
|
+
const elapsed = match[3] ?? '';
|
|
67
|
+
const command = (match[4] ?? '').trim();
|
|
68
|
+
if (pid === selfPid || ppid === selfPid)
|
|
69
|
+
continue;
|
|
70
|
+
if (!HARNESS_HELPER_PATTERN.test(command))
|
|
71
|
+
continue;
|
|
72
|
+
if (!isObjdirAnchored(command, objDir))
|
|
73
|
+
continue;
|
|
74
|
+
const elapsedSeconds = parsePsDuration(elapsed);
|
|
75
|
+
found.push({
|
|
76
|
+
pid,
|
|
77
|
+
ppid,
|
|
78
|
+
elapsed,
|
|
79
|
+
elapsedSeconds: Number.isNaN(elapsedSeconds) ? 0 : elapsedSeconds,
|
|
80
|
+
command,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
/** Grace period between SIGTERM and SIGKILL when reaping. */
|
|
86
|
+
const REAP_GRACE_MS = 500;
|
|
87
|
+
/** Longest command excerpt carried into the report line. */
|
|
88
|
+
const COMMAND_EXCERPT_LIMIT = 160;
|
|
89
|
+
/**
|
|
90
|
+
* Renders the census. Names every process with its elapsed time, because
|
|
91
|
+
* "a leftover harness process exists" and "a leftover harness process has
|
|
92
|
+
* been burning a core for 58 minutes" are different findings.
|
|
93
|
+
*/
|
|
94
|
+
export function formatOrphanReport(orphans) {
|
|
95
|
+
const rows = orphans
|
|
96
|
+
.map((p) => ` PID ${String(p.pid)} (up ${p.elapsed}): ${p.command.slice(0, COMMAND_EXCERPT_LIMIT)}`)
|
|
97
|
+
.join('\n');
|
|
98
|
+
const pids = orphans.map((p) => String(p.pid)).join(' ');
|
|
99
|
+
return (`${String(orphans.length)} harness helper process(es) from an EARLIER run are still alive ` +
|
|
100
|
+
`(this preflight runs before the current run spawns anything, so none of these belong to ` +
|
|
101
|
+
`it):\n${rows}\n` +
|
|
102
|
+
`Survivors like these slow every later run without appearing anywhere in its output — a ` +
|
|
103
|
+
`three-second suite taking minutes of wall clock is the usual symptom. Terminate them with ` +
|
|
104
|
+
`"kill ${pids}", or re-run with --reap-orphans to have FireForge do it.`);
|
|
105
|
+
}
|
|
106
|
+
/** Thin exec wrapper for the process listing (tests mock `exec` instead). */
|
|
107
|
+
async function listSystemProcesses() {
|
|
108
|
+
const result = await exec('ps', ['-axo', 'pid=,ppid=,etime=,command='], { timeout: 10000 });
|
|
109
|
+
if (result.exitCode !== 0) {
|
|
110
|
+
throw new Error(`ps exited ${String(result.exitCode)}`);
|
|
111
|
+
}
|
|
112
|
+
return result.stdout;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Preflight census of surviving harness helpers, run before a test
|
|
116
|
+
* dispatch.
|
|
117
|
+
*
|
|
118
|
+
* Best-effort by construction: a host without a usable `ps` (Windows, a
|
|
119
|
+
* locked-down container) logs at verbose and runs exactly as before. It
|
|
120
|
+
* never refuses a run — unlike the server-port preflight, a survivor here
|
|
121
|
+
* degrades performance rather than making the run dispatch against the
|
|
122
|
+
* wrong server, so the correct response is to say so loudly, not to stop.
|
|
123
|
+
*
|
|
124
|
+
* @param objDir - Absolute objdir of this project, when known
|
|
125
|
+
* @param options - `reap` terminates each recognized survivor (SIGTERM,
|
|
126
|
+
* then SIGKILL for anything that stays)
|
|
127
|
+
* @returns The census, empty when nothing was found or nothing could be probed
|
|
128
|
+
*/
|
|
129
|
+
export async function reportOrphanedHarnessProcesses(objDir, options = {}) {
|
|
130
|
+
if (process.platform === 'win32')
|
|
131
|
+
return [];
|
|
132
|
+
let psOutput;
|
|
133
|
+
try {
|
|
134
|
+
psOutput = await listSystemProcesses();
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
verbose(`Orphan preflight: could not scan processes (${toError(error).message}); skipping.`);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const orphans = findOrphanedHarnessProcesses(psOutput, objDir);
|
|
141
|
+
if (orphans.length === 0) {
|
|
142
|
+
verbose('Orphan preflight: no surviving harness helper processes.');
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
warn(formatOrphanReport(orphans));
|
|
146
|
+
if (options.reap === true) {
|
|
147
|
+
await reapOrphanedHarnessProcesses(orphans);
|
|
148
|
+
}
|
|
149
|
+
return orphans;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Terminates each census entry: SIGTERM first, then SIGKILL for anything
|
|
153
|
+
* still alive. Failures are reported, never thrown — a process that exited
|
|
154
|
+
* between the census and the signal is the common case, not an error.
|
|
155
|
+
*/
|
|
156
|
+
async function reapOrphanedHarnessProcesses(orphans) {
|
|
157
|
+
for (const orphan of orphans) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(orphan.pid, 'SIGTERM');
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
verbose(`--reap-orphans: SIGTERM to ${String(orphan.pid)} failed (${toError(error).message}).`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Grace period before escalating: the httpd shape in the field
|
|
166
|
+
// incident ignored SIGTERM while spinning, but an ordinary helper exits
|
|
167
|
+
// promptly and must not be SIGKILLed for being slow by a millisecond.
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, REAP_GRACE_MS));
|
|
169
|
+
let alive = true;
|
|
170
|
+
try {
|
|
171
|
+
process.kill(orphan.pid, 0);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
alive = false;
|
|
175
|
+
}
|
|
176
|
+
if (alive) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(orphan.pid, 'SIGKILL');
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
verbose(`--reap-orphans: SIGKILL to ${String(orphan.pid)} failed (${toError(error).message}).`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
info(`--reap-orphans: terminated PID ${String(orphan.pid)}.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -25,6 +25,20 @@
|
|
|
25
25
|
* echoed verbatim, always.
|
|
26
26
|
* - The hold buffer is bounded; on overflow the block is flushed verbatim
|
|
27
27
|
* and the filter returns to pass-through, so output is never lost.
|
|
28
|
+
*
|
|
29
|
+
* The COLLAPSE is test-only, deliberately, and not an oversight — the two
|
|
30
|
+
* reasons are worth stating because from outside it reads like one:
|
|
31
|
+
* 1. Recognition requires a previously-seen SUITE_END shutdown marker, and
|
|
32
|
+
* `mach build` never prints one. Wiring the option into the build
|
|
33
|
+
* dispatch would therefore be a silent no-op.
|
|
34
|
+
* 2. Relaxing that gate for builds would be wrong on its own terms. The
|
|
35
|
+
* marker is what separates teardown noise from a traceback raised while
|
|
36
|
+
* work was still happening; a build has no equivalent boundary, so
|
|
37
|
+
* FireForge cannot tell the known-cosmetic case from a real build-time
|
|
38
|
+
* failure and must not withhold the block.
|
|
39
|
+
* The build path instead RECOGNIZES the signature and says so beside the
|
|
40
|
+
* verbatim traceback — see {@link KNOWN_TEARDOWN_NOISE_BUILD_NOTE} and
|
|
41
|
+
* `runProtectedMachBuild`.
|
|
28
42
|
*/
|
|
29
43
|
/**
|
|
30
44
|
* True when CAPTURED output carries the documented mozsystemmonitor
|
|
@@ -76,6 +90,13 @@ export interface TeardownNoiseContext {
|
|
|
76
90
|
}
|
|
77
91
|
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
78
92
|
export declare function createTeardownNoiseContext(): TeardownNoiseContext;
|
|
93
|
+
/**
|
|
94
|
+
* Build-phase note for the SAME recognized signature. The build path does
|
|
95
|
+
* not COLLAPSE the traceback (see the module doc's "test-only" note) — it
|
|
96
|
+
* prints verbatim and this line is added beside it, so one signature reads
|
|
97
|
+
* the same in both phases without a build losing output it may need.
|
|
98
|
+
*/
|
|
99
|
+
export declare const KNOWN_TEARDOWN_NOISE_BUILD_NOTE: string;
|
|
79
100
|
/** One line replaces the whole recognized traceback block in the echo. */
|
|
80
101
|
export declare const KNOWN_TEARDOWN_NOISE_ANNOTATION: string;
|
|
81
102
|
/** Chunk-safe, line-buffered echo filter. See module doc. */
|
|
@@ -26,6 +26,20 @@
|
|
|
26
26
|
* echoed verbatim, always.
|
|
27
27
|
* - The hold buffer is bounded; on overflow the block is flushed verbatim
|
|
28
28
|
* and the filter returns to pass-through, so output is never lost.
|
|
29
|
+
*
|
|
30
|
+
* The COLLAPSE is test-only, deliberately, and not an oversight — the two
|
|
31
|
+
* reasons are worth stating because from outside it reads like one:
|
|
32
|
+
* 1. Recognition requires a previously-seen SUITE_END shutdown marker, and
|
|
33
|
+
* `mach build` never prints one. Wiring the option into the build
|
|
34
|
+
* dispatch would therefore be a silent no-op.
|
|
35
|
+
* 2. Relaxing that gate for builds would be wrong on its own terms. The
|
|
36
|
+
* marker is what separates teardown noise from a traceback raised while
|
|
37
|
+
* work was still happening; a build has no equivalent boundary, so
|
|
38
|
+
* FireForge cannot tell the known-cosmetic case from a real build-time
|
|
39
|
+
* failure and must not withhold the block.
|
|
40
|
+
* The build path instead RECOGNIZES the signature and says so beside the
|
|
41
|
+
* verbatim traceback — see {@link KNOWN_TEARDOWN_NOISE_BUILD_NOTE} and
|
|
42
|
+
* `runProtectedMachBuild`.
|
|
29
43
|
*/
|
|
30
44
|
const TRACEBACK_HEADER_PATTERN = /^Traceback \(most recent call last\)/;
|
|
31
45
|
const CHAINED_EXCEPTION_CONNECTOR_PATTERN = /^(?:During handling of the above exception, another exception occurred:|The above exception was the direct cause of the following exception:)\s*$/;
|
|
@@ -93,9 +107,18 @@ export function isKnownTeardownNoiseLine(line) {
|
|
|
93
107
|
export function createTeardownNoiseContext() {
|
|
94
108
|
return { shutdownSeen: false };
|
|
95
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Build-phase note for the SAME recognized signature. The build path does
|
|
112
|
+
* not COLLAPSE the traceback (see the module doc's "test-only" note) — it
|
|
113
|
+
* prints verbatim and this line is added beside it, so one signature reads
|
|
114
|
+
* the same in both phases without a build losing output it may need.
|
|
115
|
+
*/
|
|
116
|
+
export const KNOWN_TEARDOWN_NOISE_BUILD_NOTE = '[FireForge] The mozsystemmonitor traceback above is the known upstream ' +
|
|
117
|
+
'SystemResourceMonitor AttributeError — cosmetic, and not a build failure. It is left ' +
|
|
118
|
+
'verbatim here (unlike a test run, which collapses it); see docs/testing.md.';
|
|
96
119
|
/** One line replaces the whole recognized traceback block in the echo. */
|
|
97
120
|
export const KNOWN_TEARDOWN_NOISE_ANNOTATION = '[FireForge] Known upstream mozsystemmonitor teardown noise (SystemResourceMonitor ' +
|
|
98
|
-
'AttributeError at harness shutdown) — not a test failure. See
|
|
121
|
+
'AttributeError at harness shutdown) — not a test failure. See docs/testing.md.\n';
|
|
99
122
|
/** Bounds on the held traceback block before flushing verbatim. */
|
|
100
123
|
const HOLD_LINE_LIMIT = 100;
|
|
101
124
|
const HOLD_BYTE_LIMIT = 16 * 1024;
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -23,6 +23,13 @@ export interface MachOptions {
|
|
|
23
23
|
* stdout/stderr stay raw — the harness classifier depends on the raw
|
|
24
24
|
* traceback. Unrecognized tracebacks always echo verbatim. Opted into by
|
|
25
25
|
* the test dispatchers only.
|
|
26
|
+
*
|
|
27
|
+
* Consumed by {@link runMachCapture} ALONE. `runMachInheritCapture` accepts
|
|
28
|
+
* it in the type and ignores it — the inherit path has no per-chunk hook,
|
|
29
|
+
* only a mirror stream that feeds the terminal and the run log the same
|
|
30
|
+
* string, so filtering there would strip the traceback from the raw log
|
|
31
|
+
* too. The build path uses the recognition NOTE instead; see
|
|
32
|
+
* `mach-known-noise-filter.ts`.
|
|
26
33
|
*/
|
|
27
34
|
annotateKnownTeardownNoise?: boolean;
|
|
28
35
|
/**
|
package/dist/src/core/mach.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { MachNotFoundError } from '../errors/build.js';
|
|
4
4
|
import { pathExists } from '../utils/fs.js';
|
|
5
|
-
import { warn } from '../utils/logger.js';
|
|
5
|
+
import { info, warn } from '../utils/logger.js';
|
|
6
6
|
import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
|
|
7
7
|
import { createSiblingLockPath, withFileLock } from './file-lock.js';
|
|
8
8
|
import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
|
|
9
9
|
import { explainMachError } from './mach-error-hints.js';
|
|
10
|
-
import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, } from './mach-known-noise-filter.js';
|
|
10
|
+
import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, hasKnownTeardownNoise, KNOWN_TEARDOWN_NOISE_BUILD_NOTE, } from './mach-known-noise-filter.js';
|
|
11
11
|
import { getPython } from './mach-python.js';
|
|
12
12
|
import { installMachResourceGuard } from './mach-resource-shim.js';
|
|
13
13
|
import { teeToRunLog, writeToActiveRunLog } from './run-log.js';
|
|
@@ -193,6 +193,20 @@ function surfaceMachErrorHints(result) {
|
|
|
193
193
|
* the same crash family the same number of times.
|
|
194
194
|
*/
|
|
195
195
|
const DEFAULT_BUILD_CRASH_RETRIES = 2;
|
|
196
|
+
/**
|
|
197
|
+
* Names the known mozsystemmonitor teardown traceback when a BUILD carried
|
|
198
|
+
* it. The test path collapses this signature in the echo; a build cannot
|
|
199
|
+
* (see `mach-known-noise-filter.ts`), so it reached operators as an
|
|
200
|
+
* unexplained traceback in a build log — a recognized, documented upstream
|
|
201
|
+
* defect wearing the appearance of a new one. The output is left verbatim
|
|
202
|
+
* and this one line is added beside it, so the same signature reads the same
|
|
203
|
+
* in both phases.
|
|
204
|
+
*/
|
|
205
|
+
function noteKnownTeardownNoiseInBuild(result) {
|
|
206
|
+
if (!hasKnownTeardownNoise(`${result.stdout}\n${result.stderr}`))
|
|
207
|
+
return;
|
|
208
|
+
info(KNOWN_TEARDOWN_NOISE_BUILD_NOTE);
|
|
209
|
+
}
|
|
196
210
|
/**
|
|
197
211
|
* The single protected path every FireForge mach build dispatch routes
|
|
198
212
|
* through — `build`, `build --ui`, and the pre-test `--build` step all use
|
|
@@ -222,6 +236,7 @@ export async function runProtectedMachBuild(kind, engineDir, options = {}) {
|
|
|
222
236
|
const { env } = await installMachResourceGuard(engineDir);
|
|
223
237
|
const result = await runMachInheritCapture(args, engineDir, { env });
|
|
224
238
|
if (result.exitCode === 0) {
|
|
239
|
+
noteKnownTeardownNoiseInBuild(result);
|
|
225
240
|
return { ...result, attempts: attempt };
|
|
226
241
|
}
|
|
227
242
|
const signature = detectHarnessCrashSignature(`${result.stdout}\n${result.stderr}`);
|
|
@@ -233,6 +248,7 @@ export async function runProtectedMachBuild(kind, engineDir, options = {}) {
|
|
|
233
248
|
continue;
|
|
234
249
|
}
|
|
235
250
|
surfaceMachErrorHints(result);
|
|
251
|
+
noteKnownTeardownNoiseInBuild(result);
|
|
236
252
|
return { ...result, attempts: attempt, ...(signature ? { crashSignature: signature } : {}) };
|
|
237
253
|
}
|
|
238
254
|
}
|
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
* Patch orchestration — coordinates patch discovery, application, and validation.
|
|
4
4
|
* Pure parsing, content transformation, and lock management are in separate modules.
|
|
5
5
|
*/
|
|
6
|
-
import { lstat, readlink, realpath } from 'node:fs/promises';
|
|
6
|
+
import { lstat, readFile, readlink, realpath, rm } from 'node:fs/promises';
|
|
7
7
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
8
8
|
import { PatchError } from '../errors/patch.js';
|
|
9
9
|
import { toError } from '../utils/errors.js';
|
|
10
|
-
import { pathExists, readText, writeText } from '../utils/fs.js';
|
|
10
|
+
import { pathExists, readText, writeFileAtomic, writeText } from '../utils/fs.js';
|
|
11
11
|
import { verbose } from '../utils/logger.js';
|
|
12
12
|
import { isContainedRelativePath } from '../utils/paths.js';
|
|
13
13
|
import { applyPatchIdempotent, reversePatch } from './git.js';
|
|
14
14
|
import { getFileContentFromHead } from './git-file-ops.js';
|
|
15
15
|
import { discoverPatches } from './patch-files.js';
|
|
16
16
|
import { loadPatchesManifest } from './patch-manifest.js';
|
|
17
|
-
import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch } from './patch-parse.js';
|
|
18
|
-
import { applyPatchTextToContent,
|
|
17
|
+
import { extractAffectedFiles, extractConflictingFiles, isNewFileInPatch, parseDiffSections, } from './patch-parse.js';
|
|
18
|
+
import { applyPatchTextToContent, extractNewFileContentFromDiff } from './patch-transform.js';
|
|
19
19
|
// Re-export from split modules so existing import sites continue working
|
|
20
20
|
export { PatchError } from '../errors/patch.js';
|
|
21
21
|
export { countPatches, discoverPatches, getAllTargetFilesFromPatch, getTargetFileFromPatch, isNewFilePatch, } from './patch-files.js';
|
|
@@ -53,28 +53,57 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
|
|
|
53
53
|
const applyError = toError(error);
|
|
54
54
|
// Check if this is a resolvable "new file" conflict
|
|
55
55
|
let resolvedNewFiles = false;
|
|
56
|
-
// Save original
|
|
56
|
+
// Save original BYTES of files we might overwrite, so we can restore on
|
|
57
|
+
// failure. Raw buffers rather than decoded text: the same snapshot has to
|
|
58
|
+
// restore a binary target byte-for-byte, and a Buffer round-trips text
|
|
59
|
+
// losslessly where a utf-8 decode of a binary file does not.
|
|
57
60
|
const savedContents = new Map();
|
|
61
|
+
// New-file sections keyed by target, so the binary discrimination below
|
|
62
|
+
// reads the ALREADY-parsed section instead of re-scanning the body per
|
|
63
|
+
// file.
|
|
64
|
+
const newFileSections = new Map(parseDiffSections(patchContent)
|
|
65
|
+
.filter((section) => section.isNewFile)
|
|
66
|
+
.map((section) => [section.targetPath, section]));
|
|
58
67
|
try {
|
|
59
68
|
for (const file of affectedFiles) {
|
|
60
|
-
if (isNewFileInPatch(patchContent, file))
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
if (!isNewFileInPatch(patchContent, file))
|
|
70
|
+
continue;
|
|
71
|
+
const targetPath = join(engineDir, file);
|
|
72
|
+
if (!(await pathExists(targetPath)))
|
|
73
|
+
continue;
|
|
74
|
+
savedContents.set(file, await readFile(targetPath));
|
|
75
|
+
const section = newFileSections.get(file);
|
|
76
|
+
if (section?.isBinary === true) {
|
|
77
|
+
// A binary new file cannot be resolved by writing extracted text —
|
|
78
|
+
// the payload is base85, whose alphabet includes '+'. It does not
|
|
79
|
+
// need to be: `git apply` decodes a `GIT binary patch` itself. So
|
|
80
|
+
// the resolution is to REMOVE the blocking file and let the retry
|
|
81
|
+
// below create it from the payload, which is exactly what the
|
|
82
|
+
// text branch achieves by overwriting.
|
|
83
|
+
if (!section.hasBinaryDelta) {
|
|
84
|
+
// `Binary files … differ` records THAT the bytes changed and
|
|
85
|
+
// carries none of them, so nothing can recreate the file. Fail
|
|
86
|
+
// honestly rather than deleting a file we cannot put back.
|
|
87
|
+
throw new PatchError(`Cannot resolve the new-file conflict for ${file}: the patch's binary section ` +
|
|
88
|
+
'carries no replayable payload (`Binary files … differ` rather than `GIT binary ' +
|
|
89
|
+
'patch`), so the file cannot be recreated. Re-export the patch with a binary-aware ' +
|
|
90
|
+
'diff.', file);
|
|
67
91
|
}
|
|
92
|
+
await rm(targetPath, { force: true });
|
|
68
93
|
}
|
|
94
|
+
else {
|
|
95
|
+
await writeText(targetPath, extractNewFileContentFromDiff(patchContent, file));
|
|
96
|
+
}
|
|
97
|
+
resolvedNewFiles = true;
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
catch (extractError) {
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
101
|
+
// Resolution threw for a LATER target after earlier targets were
|
|
102
|
+
// already overwritten or removed — restore them before reporting
|
|
103
|
+
// failure, instead of letting the exception skip the restore loop
|
|
104
|
+
// entirely and crash the whole import.
|
|
76
105
|
for (const [file, originalContent] of savedContents) {
|
|
77
|
-
await
|
|
106
|
+
await writeFileAtomic(join(engineDir, file), originalContent);
|
|
78
107
|
}
|
|
79
108
|
return { patch, success: false, error: toError(extractError).message };
|
|
80
109
|
}
|
|
@@ -91,9 +120,9 @@ async function applySinglePatch(patch, engineDir, protectedFiles) {
|
|
|
91
120
|
}
|
|
92
121
|
catch (retryError) {
|
|
93
122
|
verbose(`Auto-resolved new-file retry failed for ${patch.filename}: ${toError(retryError).message}`);
|
|
94
|
-
// Restore original file
|
|
123
|
+
// Restore original file bytes before falling through to --reject
|
|
95
124
|
for (const [file, originalContent] of savedContents) {
|
|
96
|
-
await
|
|
125
|
+
await writeFileAtomic(join(engineDir, file), originalContent);
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
128
|
}
|
|
@@ -138,7 +167,7 @@ async function rollbackPatches(results, engineDir) {
|
|
|
138
167
|
if (result.autoResolvedOriginals) {
|
|
139
168
|
for (const [file, originalContent] of result.autoResolvedOriginals) {
|
|
140
169
|
try {
|
|
141
|
-
await
|
|
170
|
+
await writeFileAtomic(join(engineDir, file), originalContent);
|
|
142
171
|
verbose(`Restored pre-existing content of ${file} after rollback`);
|
|
143
172
|
}
|
|
144
173
|
catch (restoreError) {
|
|
@@ -30,6 +30,7 @@ import { toError } from '../utils/errors.js';
|
|
|
30
30
|
import { pathExists } from '../utils/fs.js';
|
|
31
31
|
import { verbose } from '../utils/logger.js';
|
|
32
32
|
import { normalizePathSlashes } from '../utils/paths.js';
|
|
33
|
+
import { collectUnmanagedCompanions, retargetUnmanagedCompanionHints, } from './patch-lint-unmanaged-companion.js';
|
|
33
34
|
import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, TEST_HARNESS_SHIM, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
|
|
34
35
|
// ---------------------------------------------------------------------------
|
|
35
36
|
// Public API
|
|
@@ -464,6 +465,7 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
|
|
|
464
465
|
const mode = modeFromPatchLintConfig(patchLint);
|
|
465
466
|
const seenGlobal = new Set();
|
|
466
467
|
const files = [...testFiles].sort((a, b) => a.localeCompare(b));
|
|
468
|
+
const companions = await collectUnmanagedCompanions(repoDir, files, testFiles);
|
|
467
469
|
for (const file of files) {
|
|
468
470
|
// Under a rootScope only the scoped files get their own program, but
|
|
469
471
|
// head.js helper discovery still spans the full owned set so a scoped
|
|
@@ -483,7 +485,10 @@ export async function runCheckJsTestFilesGrouped(repoDir, testFiles, patchLint,
|
|
|
483
485
|
const result = await runCheckJsGrouped(repoDir, roots, patchLint.checkJsTestShim, projectRoot, mode, TEST_HARNESS_SHIM);
|
|
484
486
|
const own = result.byFile.get(file);
|
|
485
487
|
if (own && own.length > 0) {
|
|
486
|
-
merged.byFile.set(file, [
|
|
488
|
+
merged.byFile.set(file, [
|
|
489
|
+
...(merged.byFile.get(file) ?? []),
|
|
490
|
+
...retargetUnmanagedCompanionHints(own, companions),
|
|
491
|
+
]);
|
|
487
492
|
}
|
|
488
493
|
for (const globalIssue of result.global) {
|
|
489
494
|
if (seenGlobal.has(globalIssue.message))
|