@webpieces/code-rules 0.4.649 → 0.4.651
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/package.json +2 -2
- package/src/wp-ci-nx-runner.d.ts +38 -0
- package/src/wp-ci-nx-runner.js +85 -0
- package/src/wp-ci-nx-runner.js.map +1 -0
- package/src/wp-ci-survivors.d.ts +94 -0
- package/src/wp-ci-survivors.js +215 -0
- package/src/wp-ci-survivors.js.map +1 -0
- package/src/wp-ci.d.ts +4 -0
- package/src/wp-ci.js +12 -13
- package/src/wp-ci.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/code-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.651",
|
|
4
4
|
"description": "Standalone code validation rules extracted from architecture-validators, no Nx dependency required",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"directory": "packages/tooling/code-rules"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@webpieces/rules-config": "0.4.
|
|
19
|
+
"@webpieces/rules-config": "0.4.651",
|
|
20
20
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
21
21
|
"inversify": "7.10.4",
|
|
22
22
|
"reflect-metadata": "0.2.2"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one `nx` step for wp-ci and refuses to leave survivors behind.
|
|
3
|
+
*
|
|
4
|
+
* The step is spawned DETACHED, which on POSIX makes the child a process-group leader (pgid == pid).
|
|
5
|
+
* That group id is the handle everything else needs: it is what `ps` is filtered on to enumerate
|
|
6
|
+
* workers that outlived nx, and it is what gets SIGKILLed so the step's stdout can reach EOF.
|
|
7
|
+
* Without the group there is no way to tell an orphaned nx worker from an unrelated process, because
|
|
8
|
+
* orphans are re-parented away from the tree that spawned them.
|
|
9
|
+
*/
|
|
10
|
+
import { GracePeriod, ProcessGroupKiller, SurvivorReporter, SurvivorWatchdog } from './wp-ci-survivors';
|
|
11
|
+
export declare class NxStepRunner {
|
|
12
|
+
/**
|
|
13
|
+
* Distinct from 1 so a survivor abort is greppable in CI history and cannot be confused with an
|
|
14
|
+
* ordinary red build.
|
|
15
|
+
*/
|
|
16
|
+
static readonly SURVIVOR_TIMEOUT_EXIT_CODE = 75;
|
|
17
|
+
private readonly root;
|
|
18
|
+
private readonly gracePeriod;
|
|
19
|
+
private readonly watchdog;
|
|
20
|
+
private readonly reporter;
|
|
21
|
+
private readonly killer;
|
|
22
|
+
/**
|
|
23
|
+
* ONE budget for the whole wp-ci process, fixed at construction (i.e. at wp-ci start) rather
|
|
24
|
+
* than per step. A per-step grace period would multiply by the number of steps and could still
|
|
25
|
+
* overrun the outer CI timeout the number was chosen against.
|
|
26
|
+
*/
|
|
27
|
+
private readonly deadlineEpochMillis;
|
|
28
|
+
constructor(root: string, gracePeriod: GracePeriod, watchdog: SurvivorWatchdog, reporter: SurvivorReporter, killer: ProcessGroupKiller);
|
|
29
|
+
run(args: string[], stepLabel: string): Promise<number>;
|
|
30
|
+
/**
|
|
31
|
+
* The whole point of the fix: a step is not finished when its child exits, it is finished when
|
|
32
|
+
* nothing it spawned is left holding the step's stdout. Split out from `run` so it is unit
|
|
33
|
+
* testable without spawning nx.
|
|
34
|
+
*/
|
|
35
|
+
settleStep(processGroupId: number, stepLabel: string, exitCode: number): Promise<number>;
|
|
36
|
+
private awaitExit;
|
|
37
|
+
private nxBin;
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Runs one `nx` step for wp-ci and refuses to leave survivors behind.
|
|
4
|
+
*
|
|
5
|
+
* The step is spawned DETACHED, which on POSIX makes the child a process-group leader (pgid == pid).
|
|
6
|
+
* That group id is the handle everything else needs: it is what `ps` is filtered on to enumerate
|
|
7
|
+
* workers that outlived nx, and it is what gets SIGKILLed so the step's stdout can reach EOF.
|
|
8
|
+
* Without the group there is no way to tell an orphaned nx worker from an unrelated process, because
|
|
9
|
+
* orphans are re-parented away from the tree that spawned them.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.NxStepRunner = void 0;
|
|
13
|
+
const tslib_1 = require("tslib");
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
16
|
+
const path = tslib_1.__importStar(require("path"));
|
|
17
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
18
|
+
class NxStepRunner {
|
|
19
|
+
/**
|
|
20
|
+
* Distinct from 1 so a survivor abort is greppable in CI history and cannot be confused with an
|
|
21
|
+
* ordinary red build.
|
|
22
|
+
*/
|
|
23
|
+
static SURVIVOR_TIMEOUT_EXIT_CODE = 75;
|
|
24
|
+
root;
|
|
25
|
+
gracePeriod;
|
|
26
|
+
watchdog;
|
|
27
|
+
reporter;
|
|
28
|
+
killer;
|
|
29
|
+
/**
|
|
30
|
+
* ONE budget for the whole wp-ci process, fixed at construction (i.e. at wp-ci start) rather
|
|
31
|
+
* than per step. A per-step grace period would multiply by the number of steps and could still
|
|
32
|
+
* overrun the outer CI timeout the number was chosen against.
|
|
33
|
+
*/
|
|
34
|
+
deadlineEpochMillis;
|
|
35
|
+
constructor(root, gracePeriod, watchdog, reporter, killer) {
|
|
36
|
+
this.root = root;
|
|
37
|
+
this.gracePeriod = gracePeriod;
|
|
38
|
+
this.watchdog = watchdog;
|
|
39
|
+
this.reporter = reporter;
|
|
40
|
+
this.killer = killer;
|
|
41
|
+
this.deadlineEpochMillis = Date.now() + gracePeriod.millis;
|
|
42
|
+
}
|
|
43
|
+
async run(args, stepLabel) {
|
|
44
|
+
const child = (0, child_process_1.spawn)(this.nxBin(), args, { stdio: 'inherit', cwd: this.root, detached: true });
|
|
45
|
+
const exitCode = await this.awaitExit(child);
|
|
46
|
+
const processGroupId = child.pid;
|
|
47
|
+
if (processGroupId === undefined)
|
|
48
|
+
return exitCode;
|
|
49
|
+
return this.settleStep(processGroupId, stepLabel, exitCode);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The whole point of the fix: a step is not finished when its child exits, it is finished when
|
|
53
|
+
* nothing it spawned is left holding the step's stdout. Split out from `run` so it is unit
|
|
54
|
+
* testable without spawning nx.
|
|
55
|
+
*/
|
|
56
|
+
async settleStep(processGroupId, stepLabel, exitCode) {
|
|
57
|
+
const scan = await this.watchdog.waitForGroupToDrain(processGroupId, this.deadlineEpochMillis);
|
|
58
|
+
if (scan.scanError !== null) {
|
|
59
|
+
this.reporter.reportScanUnavailable(stepLabel, scan.scanError);
|
|
60
|
+
return exitCode;
|
|
61
|
+
}
|
|
62
|
+
if (scan.survivors.length === 0)
|
|
63
|
+
return exitCode;
|
|
64
|
+
this.reporter.reportSurvivors(stepLabel, this.gracePeriod, scan.survivors);
|
|
65
|
+
this.killer.kill(processGroupId);
|
|
66
|
+
return NxStepRunner.SURVIVOR_TIMEOUT_EXIT_CODE;
|
|
67
|
+
}
|
|
68
|
+
awaitExit(child) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
child.on('error', (err) => {
|
|
71
|
+
console.error(`[wp-ci] could not run nx: ${(0, rules_config_1.toError)(err).message}`);
|
|
72
|
+
resolve(1);
|
|
73
|
+
});
|
|
74
|
+
child.on('close', (code) => {
|
|
75
|
+
resolve(code === null ? 1 : code);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
nxBin() {
|
|
80
|
+
const local = path.join(this.root, 'node_modules', '.bin', 'nx');
|
|
81
|
+
return fs.existsSync(local) ? local : 'nx';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.NxStepRunner = NxStepRunner;
|
|
85
|
+
//# sourceMappingURL=wp-ci-nx-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wp-ci-nx-runner.js","sourceRoot":"","sources":["../../../../../packages/tooling/code-rules/src/wp-ci-nx-runner.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Runs one `nx` step for wp-ci and refuses to leave survivors behind.\n *\n * The step is spawned DETACHED, which on POSIX makes the child a process-group leader (pgid == pid).\n * That group id is the handle everything else needs: it is what `ps` is filtered on to enumerate\n * workers that outlived nx, and it is what gets SIGKILLed so the step's stdout can reach EOF.\n * Without the group there is no way to tell an orphaned nx worker from an unrelated process, because\n * orphans are re-parented away from the tree that spawned them.\n */\n\nimport { ChildProcess, spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from '@webpieces/rules-config';\nimport { GracePeriod, ProcessGroupKiller, SurvivorReporter, SurvivorWatchdog } from './wp-ci-survivors';\n\nexport class NxStepRunner {\n /**\n * Distinct from 1 so a survivor abort is greppable in CI history and cannot be confused with an\n * ordinary red build.\n */\n static readonly SURVIVOR_TIMEOUT_EXIT_CODE = 75;\n\n private readonly root: string;\n private readonly gracePeriod: GracePeriod;\n private readonly watchdog: SurvivorWatchdog;\n private readonly reporter: SurvivorReporter;\n private readonly killer: ProcessGroupKiller;\n /**\n * ONE budget for the whole wp-ci process, fixed at construction (i.e. at wp-ci start) rather\n * than per step. A per-step grace period would multiply by the number of steps and could still\n * overrun the outer CI timeout the number was chosen against.\n */\n private readonly deadlineEpochMillis: number;\n\n constructor(\n root: string,\n gracePeriod: GracePeriod,\n watchdog: SurvivorWatchdog,\n reporter: SurvivorReporter,\n killer: ProcessGroupKiller,\n ) {\n this.root = root;\n this.gracePeriod = gracePeriod;\n this.watchdog = watchdog;\n this.reporter = reporter;\n this.killer = killer;\n this.deadlineEpochMillis = Date.now() + gracePeriod.millis;\n }\n\n async run(args: string[], stepLabel: string): Promise<number> {\n const child = spawn(this.nxBin(), args, { stdio: 'inherit', cwd: this.root, detached: true });\n const exitCode = await this.awaitExit(child);\n\n const processGroupId = child.pid;\n if (processGroupId === undefined) return exitCode;\n return this.settleStep(processGroupId, stepLabel, exitCode);\n }\n\n /**\n * The whole point of the fix: a step is not finished when its child exits, it is finished when\n * nothing it spawned is left holding the step's stdout. Split out from `run` so it is unit\n * testable without spawning nx.\n */\n async settleStep(processGroupId: number, stepLabel: string, exitCode: number): Promise<number> {\n const scan = await this.watchdog.waitForGroupToDrain(processGroupId, this.deadlineEpochMillis);\n if (scan.scanError !== null) {\n this.reporter.reportScanUnavailable(stepLabel, scan.scanError);\n return exitCode;\n }\n if (scan.survivors.length === 0) return exitCode;\n\n this.reporter.reportSurvivors(stepLabel, this.gracePeriod, scan.survivors);\n this.killer.kill(processGroupId);\n return NxStepRunner.SURVIVOR_TIMEOUT_EXIT_CODE;\n }\n\n private awaitExit(child: ChildProcess): Promise<number> {\n return new Promise<number>((resolve: (code: number) => void) => {\n child.on('error', (err: Error) => {\n console.error(`[wp-ci] could not run nx: ${toError(err).message}`);\n resolve(1);\n });\n child.on('close', (code: number | null) => {\n resolve(code === null ? 1 : code);\n });\n });\n }\n\n private nxBin(): string {\n const local = path.join(this.root, 'node_modules', '.bin', 'nx');\n return fs.existsSync(local) ? local : 'nx';\n }\n}\n"]}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wp-ci survivor watchdog.
|
|
3
|
+
*
|
|
4
|
+
* WHY this exists (see backlog/bug-wp-ci-hangs-forever-after-success-...md):
|
|
5
|
+
* a CI step that runs `wp-ci` can hang FOREVER *after* every task has succeeded. GitHub Actions
|
|
6
|
+
* ends a step when the step's stdout reaches EOF, not when the shell exits. Because the nx child
|
|
7
|
+
* was handed the step's real stdout fd, any nx worker that outlives nx keeps that fd open, so the
|
|
8
|
+
* step never ends — no output, no error, the whole job timeout burned with a zero exit code
|
|
9
|
+
* already in hand. Three hours and eleven CI runs went into diagnosing that once.
|
|
10
|
+
*
|
|
11
|
+
* The cure is to make the failure SAY SO. Every nx step is spawned into its own process group, and
|
|
12
|
+
* once the step has finished we wait for that group to drain. If anything is still alive when the
|
|
13
|
+
* grace period expires we print every surviving pid with its full command line, kill the group so
|
|
14
|
+
* the step's stdout can finally reach EOF, and exit non-zero. One log line replaces the whole
|
|
15
|
+
* investigation.
|
|
16
|
+
*/
|
|
17
|
+
/** One process that is still alive after the step that spawned it has already finished. */
|
|
18
|
+
export declare class SurvivingProcess {
|
|
19
|
+
readonly pid: number;
|
|
20
|
+
readonly commandLine: string;
|
|
21
|
+
constructor(pid: number, commandLine: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Outcome of one `ps` sweep. `scanError` is non-null when `ps` itself could not be consulted
|
|
25
|
+
* (not every container ships it) — that must never mask the real build result, so a failed scan
|
|
26
|
+
* degrades to "assume drained" rather than throwing.
|
|
27
|
+
*/
|
|
28
|
+
export declare class SurvivorScan {
|
|
29
|
+
readonly survivors: SurvivingProcess[];
|
|
30
|
+
readonly scanError: string | null;
|
|
31
|
+
constructor(survivors: SurvivingProcess[], scanError: string | null);
|
|
32
|
+
}
|
|
33
|
+
/** How long wp-ci may wait for survivors, and where that number came from (printed in the banner). */
|
|
34
|
+
export declare class GracePeriod {
|
|
35
|
+
readonly millis: number;
|
|
36
|
+
readonly source: string;
|
|
37
|
+
constructor(millis: number, source: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Enumerates the processes still running in a given process group, with full argv.
|
|
41
|
+
* `ps -A -o pid=,pgid=,args=` is the one spelling that behaves the same on macOS (BSD ps) and Linux
|
|
42
|
+
* (procps), which is why the fields are requested with trailing `=` (header suppression) instead of
|
|
43
|
+
* `-eo`.
|
|
44
|
+
*/
|
|
45
|
+
export declare class ProcessGroupScanner {
|
|
46
|
+
private static readonly PS_MAX_BUFFER_BYTES;
|
|
47
|
+
private static readonly LINE_PATTERN;
|
|
48
|
+
scan(processGroupId: number): SurvivorScan;
|
|
49
|
+
/** Exposed so the parsing can be unit-tested against real macOS/Linux `ps` output. */
|
|
50
|
+
parse(psOutput: string, processGroupId: number): SurvivingProcess[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolves the grace period. The default is 25 minutes: the client repo whose CI this incident came
|
|
54
|
+
* from uses a 30-minute step timeout, so firing at 25 leaves headroom for the diagnostic to be
|
|
55
|
+
* printed and flushed before the outer timeout kills everything (a diagnostic that races the job
|
|
56
|
+
* timeout is no diagnostic at all).
|
|
57
|
+
*
|
|
58
|
+
* Overridable by ENV VAR rather than by a webpieces.config.json key on purpose: the config
|
|
59
|
+
* validator that runs against this repo is one release behind the source, so a brand-new config key
|
|
60
|
+
* is rejected as unknown and deadlocks the session. An env var ships in the same PR as its reader.
|
|
61
|
+
*/
|
|
62
|
+
export declare class GracePeriodResolver {
|
|
63
|
+
static readonly DEFAULT_MINUTES = 25;
|
|
64
|
+
static readonly ENV_VAR = "WP_CI_SURVIVOR_GRACE_MINUTES";
|
|
65
|
+
resolve(env: NodeJS.ProcessEnv): GracePeriod;
|
|
66
|
+
}
|
|
67
|
+
/** Polls the process group until it drains, the deadline passes, or `ps` turns out to be unusable. */
|
|
68
|
+
export declare class SurvivorWatchdog {
|
|
69
|
+
private readonly scanner;
|
|
70
|
+
private readonly pollIntervalMillis;
|
|
71
|
+
constructor(scanner: ProcessGroupScanner, pollIntervalMillis: number);
|
|
72
|
+
waitForGroupToDrain(processGroupId: number, deadlineEpochMillis: number): Promise<SurvivorScan>;
|
|
73
|
+
private sleep;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prints the banner with `fs.writeSync(2, …)` rather than `console.error`. The entire failure mode
|
|
77
|
+
* being diagnosed is entangled stdio, and node's console is asynchronous on a pipe — a buffered
|
|
78
|
+
* message can be lost when the process exits or is killed moments later. A synchronous write to fd 2
|
|
79
|
+
* is on the wire before the next line of code runs.
|
|
80
|
+
*/
|
|
81
|
+
export declare class SurvivorReporter {
|
|
82
|
+
private static readonly RULE;
|
|
83
|
+
private static readonly STDERR_FD;
|
|
84
|
+
/** The fd to write to; only tests ever pass anything other than stderr. */
|
|
85
|
+
private readonly fd;
|
|
86
|
+
constructor(fd?: number);
|
|
87
|
+
reportSurvivors(stepLabel: string, gracePeriod: GracePeriod, survivors: SurvivingProcess[]): void;
|
|
88
|
+
reportScanUnavailable(stepLabel: string, scanError: string): void;
|
|
89
|
+
private writeStderr;
|
|
90
|
+
}
|
|
91
|
+
/** SIGKILLs a whole process group, so the leaked stdout fd is finally released and the step can end. */
|
|
92
|
+
export declare class ProcessGroupKiller {
|
|
93
|
+
kill(processGroupId: number): void;
|
|
94
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* wp-ci survivor watchdog.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists (see backlog/bug-wp-ci-hangs-forever-after-success-...md):
|
|
6
|
+
* a CI step that runs `wp-ci` can hang FOREVER *after* every task has succeeded. GitHub Actions
|
|
7
|
+
* ends a step when the step's stdout reaches EOF, not when the shell exits. Because the nx child
|
|
8
|
+
* was handed the step's real stdout fd, any nx worker that outlives nx keeps that fd open, so the
|
|
9
|
+
* step never ends — no output, no error, the whole job timeout burned with a zero exit code
|
|
10
|
+
* already in hand. Three hours and eleven CI runs went into diagnosing that once.
|
|
11
|
+
*
|
|
12
|
+
* The cure is to make the failure SAY SO. Every nx step is spawned into its own process group, and
|
|
13
|
+
* once the step has finished we wait for that group to drain. If anything is still alive when the
|
|
14
|
+
* grace period expires we print every surviving pid with its full command line, kill the group so
|
|
15
|
+
* the step's stdout can finally reach EOF, and exit non-zero. One log line replaces the whole
|
|
16
|
+
* investigation.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.ProcessGroupKiller = exports.SurvivorReporter = exports.SurvivorWatchdog = exports.GracePeriodResolver = exports.ProcessGroupScanner = exports.GracePeriod = exports.SurvivorScan = exports.SurvivingProcess = void 0;
|
|
20
|
+
const tslib_1 = require("tslib");
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
23
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
24
|
+
/** One process that is still alive after the step that spawned it has already finished. */
|
|
25
|
+
class SurvivingProcess {
|
|
26
|
+
pid;
|
|
27
|
+
commandLine;
|
|
28
|
+
constructor(pid, commandLine) {
|
|
29
|
+
this.pid = pid;
|
|
30
|
+
this.commandLine = commandLine;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.SurvivingProcess = SurvivingProcess;
|
|
34
|
+
/**
|
|
35
|
+
* Outcome of one `ps` sweep. `scanError` is non-null when `ps` itself could not be consulted
|
|
36
|
+
* (not every container ships it) — that must never mask the real build result, so a failed scan
|
|
37
|
+
* degrades to "assume drained" rather than throwing.
|
|
38
|
+
*/
|
|
39
|
+
class SurvivorScan {
|
|
40
|
+
survivors;
|
|
41
|
+
scanError;
|
|
42
|
+
constructor(survivors, scanError) {
|
|
43
|
+
this.survivors = survivors;
|
|
44
|
+
this.scanError = scanError;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.SurvivorScan = SurvivorScan;
|
|
48
|
+
/** How long wp-ci may wait for survivors, and where that number came from (printed in the banner). */
|
|
49
|
+
class GracePeriod {
|
|
50
|
+
millis;
|
|
51
|
+
source;
|
|
52
|
+
constructor(millis, source) {
|
|
53
|
+
this.millis = millis;
|
|
54
|
+
this.source = source;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.GracePeriod = GracePeriod;
|
|
58
|
+
/**
|
|
59
|
+
* Enumerates the processes still running in a given process group, with full argv.
|
|
60
|
+
* `ps -A -o pid=,pgid=,args=` is the one spelling that behaves the same on macOS (BSD ps) and Linux
|
|
61
|
+
* (procps), which is why the fields are requested with trailing `=` (header suppression) instead of
|
|
62
|
+
* `-eo`.
|
|
63
|
+
*/
|
|
64
|
+
class ProcessGroupScanner {
|
|
65
|
+
static PS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
66
|
+
static LINE_PATTERN = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/;
|
|
67
|
+
scan(processGroupId) {
|
|
68
|
+
const result = (0, child_process_1.spawnSync)('ps', ['-A', '-o', 'pid=,pgid=,args='], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
maxBuffer: ProcessGroupScanner.PS_MAX_BUFFER_BYTES,
|
|
71
|
+
});
|
|
72
|
+
if (result.error) {
|
|
73
|
+
return new SurvivorScan([], `ps could not be run: ${(0, rules_config_1.toError)(result.error).message}`);
|
|
74
|
+
}
|
|
75
|
+
if (result.status !== 0) {
|
|
76
|
+
const stderr = (result.stderr ?? '').trim();
|
|
77
|
+
return new SurvivorScan([], `ps exited with status ${result.status}: ${stderr}`);
|
|
78
|
+
}
|
|
79
|
+
return new SurvivorScan(this.parse(result.stdout ?? '', processGroupId), null);
|
|
80
|
+
}
|
|
81
|
+
/** Exposed so the parsing can be unit-tested against real macOS/Linux `ps` output. */
|
|
82
|
+
parse(psOutput, processGroupId) {
|
|
83
|
+
const found = [];
|
|
84
|
+
const lines = psOutput.split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const match = ProcessGroupScanner.LINE_PATTERN.exec(line);
|
|
87
|
+
if (match === null)
|
|
88
|
+
continue;
|
|
89
|
+
const pid = Number(match[1]);
|
|
90
|
+
const pgid = Number(match[2]);
|
|
91
|
+
if (pgid !== processGroupId)
|
|
92
|
+
continue;
|
|
93
|
+
if (pid === process.pid)
|
|
94
|
+
continue;
|
|
95
|
+
found.push(new SurvivingProcess(pid, (match[3] ?? '').trim()));
|
|
96
|
+
}
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
exports.ProcessGroupScanner = ProcessGroupScanner;
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the grace period. The default is 25 minutes: the client repo whose CI this incident came
|
|
103
|
+
* from uses a 30-minute step timeout, so firing at 25 leaves headroom for the diagnostic to be
|
|
104
|
+
* printed and flushed before the outer timeout kills everything (a diagnostic that races the job
|
|
105
|
+
* timeout is no diagnostic at all).
|
|
106
|
+
*
|
|
107
|
+
* Overridable by ENV VAR rather than by a webpieces.config.json key on purpose: the config
|
|
108
|
+
* validator that runs against this repo is one release behind the source, so a brand-new config key
|
|
109
|
+
* is rejected as unknown and deadlocks the session. An env var ships in the same PR as its reader.
|
|
110
|
+
*/
|
|
111
|
+
class GracePeriodResolver {
|
|
112
|
+
static DEFAULT_MINUTES = 25;
|
|
113
|
+
static ENV_VAR = 'WP_CI_SURVIVOR_GRACE_MINUTES';
|
|
114
|
+
resolve(env) {
|
|
115
|
+
const defaultMillis = GracePeriodResolver.DEFAULT_MINUTES * 60 * 1000;
|
|
116
|
+
const raw = env[GracePeriodResolver.ENV_VAR];
|
|
117
|
+
if (raw === undefined || raw.trim() === '') {
|
|
118
|
+
return new GracePeriod(defaultMillis, `default ${GracePeriodResolver.DEFAULT_MINUTES} minutes`);
|
|
119
|
+
}
|
|
120
|
+
const minutes = Number(raw.trim());
|
|
121
|
+
if (!Number.isFinite(minutes) || minutes < 0) {
|
|
122
|
+
return new GracePeriod(defaultMillis, `default ${GracePeriodResolver.DEFAULT_MINUTES} minutes (ignored unparseable ${GracePeriodResolver.ENV_VAR}=${raw})`);
|
|
123
|
+
}
|
|
124
|
+
return new GracePeriod(minutes * 60 * 1000, `${GracePeriodResolver.ENV_VAR}=${raw.trim()}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
exports.GracePeriodResolver = GracePeriodResolver;
|
|
128
|
+
/** Polls the process group until it drains, the deadline passes, or `ps` turns out to be unusable. */
|
|
129
|
+
class SurvivorWatchdog {
|
|
130
|
+
scanner;
|
|
131
|
+
pollIntervalMillis;
|
|
132
|
+
constructor(scanner, pollIntervalMillis) {
|
|
133
|
+
this.scanner = scanner;
|
|
134
|
+
this.pollIntervalMillis = pollIntervalMillis;
|
|
135
|
+
}
|
|
136
|
+
async waitForGroupToDrain(processGroupId, deadlineEpochMillis) {
|
|
137
|
+
let scan = this.scanner.scan(processGroupId);
|
|
138
|
+
while (scan.scanError === null && scan.survivors.length > 0 && Date.now() < deadlineEpochMillis) {
|
|
139
|
+
await this.sleep(this.pollIntervalMillis);
|
|
140
|
+
scan = this.scanner.scan(processGroupId);
|
|
141
|
+
}
|
|
142
|
+
return scan;
|
|
143
|
+
}
|
|
144
|
+
sleep(millis) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
setTimeout(resolve, millis);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.SurvivorWatchdog = SurvivorWatchdog;
|
|
151
|
+
/**
|
|
152
|
+
* Prints the banner with `fs.writeSync(2, …)` rather than `console.error`. The entire failure mode
|
|
153
|
+
* being diagnosed is entangled stdio, and node's console is asynchronous on a pipe — a buffered
|
|
154
|
+
* message can be lost when the process exits or is killed moments later. A synchronous write to fd 2
|
|
155
|
+
* is on the wire before the next line of code runs.
|
|
156
|
+
*/
|
|
157
|
+
class SurvivorReporter {
|
|
158
|
+
static RULE = '='.repeat(78);
|
|
159
|
+
static STDERR_FD = 2;
|
|
160
|
+
/** The fd to write to; only tests ever pass anything other than stderr. */
|
|
161
|
+
fd;
|
|
162
|
+
constructor(fd = SurvivorReporter.STDERR_FD) {
|
|
163
|
+
this.fd = fd;
|
|
164
|
+
}
|
|
165
|
+
reportSurvivors(stepLabel, gracePeriod, survivors) {
|
|
166
|
+
const lines = [];
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(SurvivorReporter.RULE);
|
|
169
|
+
lines.push('❌ wp-ci ABORTED — processes SURVIVED a step that already finished');
|
|
170
|
+
lines.push(SurvivorReporter.RULE);
|
|
171
|
+
lines.push(`Step: ${stepLabel}`);
|
|
172
|
+
lines.push(`Grace period: ${gracePeriod.source} (${gracePeriod.millis} ms) — expired.`);
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push('These processes still hold this step\'s stdout open, so CI would hang forever');
|
|
175
|
+
lines.push('with all work already done. They are listed with full command lines:');
|
|
176
|
+
lines.push('');
|
|
177
|
+
for (const survivor of survivors) {
|
|
178
|
+
lines.push(` pid ${survivor.pid} ${survivor.commandLine}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push(`Killing process group and failing. Raise/lower the wait with ${GracePeriodResolver.ENV_VAR}=<minutes>.`);
|
|
182
|
+
lines.push(SurvivorReporter.RULE);
|
|
183
|
+
lines.push('');
|
|
184
|
+
this.writeStderr(lines.join('\n'));
|
|
185
|
+
}
|
|
186
|
+
reportScanUnavailable(stepLabel, scanError) {
|
|
187
|
+
this.writeStderr(`\n⚠️ wp-ci could not check for surviving processes after ${stepLabel}: ${scanError}\n`);
|
|
188
|
+
}
|
|
189
|
+
writeStderr(text) {
|
|
190
|
+
// webpieces-disable no-unmanaged-exceptions -- a diagnostic must never replace the real failure
|
|
191
|
+
try {
|
|
192
|
+
fs.writeSync(this.fd, `${text}\n`);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const error = (0, rules_config_1.toError)(err);
|
|
196
|
+
console.error(`[wp-ci] could not write survivor diagnostic: ${error.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
exports.SurvivorReporter = SurvivorReporter;
|
|
201
|
+
/** SIGKILLs a whole process group, so the leaked stdout fd is finally released and the step can end. */
|
|
202
|
+
class ProcessGroupKiller {
|
|
203
|
+
kill(processGroupId) {
|
|
204
|
+
// webpieces-disable no-unmanaged-exceptions -- the group may drain between scan and kill; that is a success, not an error
|
|
205
|
+
try {
|
|
206
|
+
process.kill(-processGroupId, 'SIGKILL');
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const error = (0, rules_config_1.toError)(err);
|
|
210
|
+
console.error(`[wp-ci] could not kill process group ${processGroupId}: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
exports.ProcessGroupKiller = ProcessGroupKiller;
|
|
215
|
+
//# sourceMappingURL=wp-ci-survivors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wp-ci-survivors.js","sourceRoot":"","sources":["../../../../../packages/tooling/code-rules/src/wp-ci-survivors.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * wp-ci survivor watchdog.\n *\n * WHY this exists (see backlog/bug-wp-ci-hangs-forever-after-success-...md):\n * a CI step that runs `wp-ci` can hang FOREVER *after* every task has succeeded. GitHub Actions\n * ends a step when the step's stdout reaches EOF, not when the shell exits. Because the nx child\n * was handed the step's real stdout fd, any nx worker that outlives nx keeps that fd open, so the\n * step never ends — no output, no error, the whole job timeout burned with a zero exit code\n * already in hand. Three hours and eleven CI runs went into diagnosing that once.\n *\n * The cure is to make the failure SAY SO. Every nx step is spawned into its own process group, and\n * once the step has finished we wait for that group to drain. If anything is still alive when the\n * grace period expires we print every surviving pid with its full command line, kill the group so\n * the step's stdout can finally reach EOF, and exit non-zero. One log line replaces the whole\n * investigation.\n */\n\nimport { spawnSync } from 'child_process';\nimport * as fs from 'fs';\n\nimport { toError } from '@webpieces/rules-config';\n\n/** One process that is still alive after the step that spawned it has already finished. */\nexport class SurvivingProcess {\n readonly pid: number;\n readonly commandLine: string;\n\n constructor(pid: number, commandLine: string) {\n this.pid = pid;\n this.commandLine = commandLine;\n }\n}\n\n/**\n * Outcome of one `ps` sweep. `scanError` is non-null when `ps` itself could not be consulted\n * (not every container ships it) — that must never mask the real build result, so a failed scan\n * degrades to \"assume drained\" rather than throwing.\n */\nexport class SurvivorScan {\n readonly survivors: SurvivingProcess[];\n readonly scanError: string | null;\n\n constructor(survivors: SurvivingProcess[], scanError: string | null) {\n this.survivors = survivors;\n this.scanError = scanError;\n }\n}\n\n/** How long wp-ci may wait for survivors, and where that number came from (printed in the banner). */\nexport class GracePeriod {\n readonly millis: number;\n readonly source: string;\n\n constructor(millis: number, source: string) {\n this.millis = millis;\n this.source = source;\n }\n}\n\n/**\n * Enumerates the processes still running in a given process group, with full argv.\n * `ps -A -o pid=,pgid=,args=` is the one spelling that behaves the same on macOS (BSD ps) and Linux\n * (procps), which is why the fields are requested with trailing `=` (header suppression) instead of\n * `-eo`.\n */\nexport class ProcessGroupScanner {\n private static readonly PS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;\n private static readonly LINE_PATTERN = /^\\s*(\\d+)\\s+(\\d+)\\s+(\\S.*)$/;\n\n scan(processGroupId: number): SurvivorScan {\n const result = spawnSync('ps', ['-A', '-o', 'pid=,pgid=,args='], {\n encoding: 'utf8',\n maxBuffer: ProcessGroupScanner.PS_MAX_BUFFER_BYTES,\n });\n if (result.error) {\n return new SurvivorScan([], `ps could not be run: ${toError(result.error).message}`);\n }\n if (result.status !== 0) {\n const stderr = (result.stderr ?? '').trim();\n return new SurvivorScan([], `ps exited with status ${result.status}: ${stderr}`);\n }\n return new SurvivorScan(this.parse(result.stdout ?? '', processGroupId), null);\n }\n\n /** Exposed so the parsing can be unit-tested against real macOS/Linux `ps` output. */\n parse(psOutput: string, processGroupId: number): SurvivingProcess[] {\n const found: SurvivingProcess[] = [];\n const lines = psOutput.split('\\n');\n for (const line of lines) {\n const match = ProcessGroupScanner.LINE_PATTERN.exec(line);\n if (match === null) continue;\n const pid = Number(match[1]);\n const pgid = Number(match[2]);\n if (pgid !== processGroupId) continue;\n if (pid === process.pid) continue;\n found.push(new SurvivingProcess(pid, (match[3] ?? '').trim()));\n }\n return found;\n }\n}\n\n/**\n * Resolves the grace period. The default is 25 minutes: the client repo whose CI this incident came\n * from uses a 30-minute step timeout, so firing at 25 leaves headroom for the diagnostic to be\n * printed and flushed before the outer timeout kills everything (a diagnostic that races the job\n * timeout is no diagnostic at all).\n *\n * Overridable by ENV VAR rather than by a webpieces.config.json key on purpose: the config\n * validator that runs against this repo is one release behind the source, so a brand-new config key\n * is rejected as unknown and deadlocks the session. An env var ships in the same PR as its reader.\n */\nexport class GracePeriodResolver {\n static readonly DEFAULT_MINUTES = 25;\n static readonly ENV_VAR = 'WP_CI_SURVIVOR_GRACE_MINUTES';\n\n resolve(env: NodeJS.ProcessEnv): GracePeriod {\n const defaultMillis = GracePeriodResolver.DEFAULT_MINUTES * 60 * 1000;\n const raw = env[GracePeriodResolver.ENV_VAR];\n if (raw === undefined || raw.trim() === '') {\n return new GracePeriod(defaultMillis, `default ${GracePeriodResolver.DEFAULT_MINUTES} minutes`);\n }\n const minutes = Number(raw.trim());\n if (!Number.isFinite(minutes) || minutes < 0) {\n return new GracePeriod(\n defaultMillis,\n `default ${GracePeriodResolver.DEFAULT_MINUTES} minutes (ignored unparseable ${GracePeriodResolver.ENV_VAR}=${raw})`,\n );\n }\n return new GracePeriod(minutes * 60 * 1000, `${GracePeriodResolver.ENV_VAR}=${raw.trim()}`);\n }\n}\n\n/** Polls the process group until it drains, the deadline passes, or `ps` turns out to be unusable. */\nexport class SurvivorWatchdog {\n private readonly scanner: ProcessGroupScanner;\n private readonly pollIntervalMillis: number;\n\n constructor(scanner: ProcessGroupScanner, pollIntervalMillis: number) {\n this.scanner = scanner;\n this.pollIntervalMillis = pollIntervalMillis;\n }\n\n async waitForGroupToDrain(processGroupId: number, deadlineEpochMillis: number): Promise<SurvivorScan> {\n let scan = this.scanner.scan(processGroupId);\n while (scan.scanError === null && scan.survivors.length > 0 && Date.now() < deadlineEpochMillis) {\n await this.sleep(this.pollIntervalMillis);\n scan = this.scanner.scan(processGroupId);\n }\n return scan;\n }\n\n private sleep(millis: number): Promise<void> {\n return new Promise<void>((resolve: () => void) => {\n setTimeout(resolve, millis);\n });\n }\n}\n\n/**\n * Prints the banner with `fs.writeSync(2, …)` rather than `console.error`. The entire failure mode\n * being diagnosed is entangled stdio, and node's console is asynchronous on a pipe — a buffered\n * message can be lost when the process exits or is killed moments later. A synchronous write to fd 2\n * is on the wire before the next line of code runs.\n */\nexport class SurvivorReporter {\n private static readonly RULE = '='.repeat(78);\n private static readonly STDERR_FD = 2;\n\n /** The fd to write to; only tests ever pass anything other than stderr. */\n private readonly fd: number;\n\n constructor(fd: number = SurvivorReporter.STDERR_FD) {\n this.fd = fd;\n }\n\n reportSurvivors(stepLabel: string, gracePeriod: GracePeriod, survivors: SurvivingProcess[]): void {\n const lines: string[] = [];\n lines.push('');\n lines.push(SurvivorReporter.RULE);\n lines.push('❌ wp-ci ABORTED — processes SURVIVED a step that already finished');\n lines.push(SurvivorReporter.RULE);\n lines.push(`Step: ${stepLabel}`);\n lines.push(`Grace period: ${gracePeriod.source} (${gracePeriod.millis} ms) — expired.`);\n lines.push('');\n lines.push('These processes still hold this step\\'s stdout open, so CI would hang forever');\n lines.push('with all work already done. They are listed with full command lines:');\n lines.push('');\n for (const survivor of survivors) {\n lines.push(` pid ${survivor.pid} ${survivor.commandLine}`);\n }\n lines.push('');\n lines.push(`Killing process group and failing. Raise/lower the wait with ${GracePeriodResolver.ENV_VAR}=<minutes>.`);\n lines.push(SurvivorReporter.RULE);\n lines.push('');\n this.writeStderr(lines.join('\\n'));\n }\n\n reportScanUnavailable(stepLabel: string, scanError: string): void {\n this.writeStderr(`\\n⚠️ wp-ci could not check for surviving processes after ${stepLabel}: ${scanError}\\n`);\n }\n\n private writeStderr(text: string): void {\n // webpieces-disable no-unmanaged-exceptions -- a diagnostic must never replace the real failure\n try {\n fs.writeSync(this.fd, `${text}\\n`);\n } catch (err: unknown) {\n const error = toError(err);\n console.error(`[wp-ci] could not write survivor diagnostic: ${error.message}`);\n }\n }\n}\n\n/** SIGKILLs a whole process group, so the leaked stdout fd is finally released and the step can end. */\nexport class ProcessGroupKiller {\n kill(processGroupId: number): void {\n // webpieces-disable no-unmanaged-exceptions -- the group may drain between scan and kill; that is a success, not an error\n try {\n process.kill(-processGroupId, 'SIGKILL');\n } catch (err: unknown) {\n const error = toError(err);\n console.error(`[wp-ci] could not kill process group ${processGroupId}: ${error.message}`);\n }\n }\n}\n"]}
|
package/src/wp-ci.d.ts
CHANGED
|
@@ -13,5 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Repos reference this as a bin (`"webpieces:ci": "wp-ci"`) so the logic is versioned in
|
|
15
15
|
* the npm package instead of copy-pasted into each repo's package.json (which drifts).
|
|
16
|
+
*
|
|
17
|
+
* Every nx step goes through NxStepRunner, which spawns it into its own process group and refuses
|
|
18
|
+
* to return until that group has drained — a step whose workers outlive it holds the CI step's
|
|
19
|
+
* stdout open and hangs the job forever with all work already green. See wp-ci-survivors.ts.
|
|
16
20
|
*/
|
|
17
21
|
import 'reflect-metadata';
|
package/src/wp-ci.js
CHANGED
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Repos reference this as a bin (`"webpieces:ci": "wp-ci"`) so the logic is versioned in
|
|
16
16
|
* the npm package instead of copy-pasted into each repo's package.json (which drifts).
|
|
17
|
+
*
|
|
18
|
+
* Every nx step goes through NxStepRunner, which spawns it into its own process group and refuses
|
|
19
|
+
* to return until that group has drained — a step whose workers outlive it holds the CI step's
|
|
20
|
+
* stdout open and hangs the job forever with all work already green. See wp-ci-survivors.ts.
|
|
17
21
|
*/
|
|
18
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
23
|
const tslib_1 = require("tslib");
|
|
20
|
-
const child_process_1 = require("child_process");
|
|
21
24
|
const fs = tslib_1.__importStar(require("fs"));
|
|
22
25
|
const path = tslib_1.__importStar(require("path"));
|
|
23
26
|
require("reflect-metadata");
|
|
@@ -26,6 +29,10 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
26
29
|
const code_rules_app_1 = require("./code-rules-app");
|
|
27
30
|
const code_rules_context_1 = require("./code-rules-context");
|
|
28
31
|
const code_rules_config_table_1 = require("./code-rules-config-table");
|
|
32
|
+
const wp_ci_nx_runner_1 = require("./wp-ci-nx-runner");
|
|
33
|
+
const wp_ci_survivors_1 = require("./wp-ci-survivors");
|
|
34
|
+
/** How often the watchdog re-runs `ps` while waiting for a finished step's process group to drain. */
|
|
35
|
+
const SURVIVOR_POLL_INTERVAL_MILLIS = 1000;
|
|
29
36
|
const NX_PLUGIN_NAME = '@webpieces/nx-webpieces-rules';
|
|
30
37
|
function findUp(filename, startDir) {
|
|
31
38
|
let dir = startDir;
|
|
@@ -57,16 +64,6 @@ function isPluginRegistered(nxJsonPath) {
|
|
|
57
64
|
throw new rules_config_1.InformAiError(`nx.json has invalid JSON — fix the file, then retry.\nParse error: ${error.message}\nFile: ${nxJsonPath}`);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
|
-
function nxBin(root) {
|
|
61
|
-
const local = path.join(root, 'node_modules', '.bin', 'nx');
|
|
62
|
-
return fs.existsSync(local) ? local : 'nx';
|
|
63
|
-
}
|
|
64
|
-
function runNx(root, args) {
|
|
65
|
-
const result = (0, child_process_1.spawnSync)(nxBin(root), args, { stdio: 'inherit', cwd: root });
|
|
66
|
-
if (typeof result.status === 'number')
|
|
67
|
-
return result.status;
|
|
68
|
-
return 1;
|
|
69
|
-
}
|
|
70
67
|
async function runStandalone(cwd) {
|
|
71
68
|
const workspaceRoot = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
72
69
|
const loaded = (0, rules_config_1.loadAndValidate)(workspaceRoot);
|
|
@@ -110,15 +107,17 @@ async function main() {
|
|
|
110
107
|
reportPluginMissing();
|
|
111
108
|
process.exit(1);
|
|
112
109
|
}
|
|
110
|
+
const gracePeriod = new wp_ci_survivors_1.GracePeriodResolver().resolve(process.env);
|
|
111
|
+
const runner = new wp_ci_nx_runner_1.NxStepRunner(root, gracePeriod, new wp_ci_survivors_1.SurvivorWatchdog(new wp_ci_survivors_1.ProcessGroupScanner(), SURVIVOR_POLL_INTERVAL_MILLIS), new wp_ci_survivors_1.SurvivorReporter(), new wp_ci_survivors_1.ProcessGroupKiller());
|
|
113
112
|
// Run the architecture + code validators first (this also runs the wiring guard,
|
|
114
113
|
// which fails loudly if nx.json no longer wires validators into the build).
|
|
115
114
|
if (fs.existsSync(path.join(root, 'architecture'))) {
|
|
116
|
-
const validateCode =
|
|
115
|
+
const validateCode = await runner.run(['run', 'architecture:validate-complete'], 'architecture:validate-complete');
|
|
117
116
|
if (validateCode !== 0)
|
|
118
117
|
process.exit(validateCode);
|
|
119
118
|
}
|
|
120
119
|
// Then the Gradle-style ci composite (lint + build + test) across affected projects.
|
|
121
|
-
const ciCode =
|
|
120
|
+
const ciCode = await runner.run(['affected', '--target=ci', ...passthrough], 'nx affected --target=ci');
|
|
122
121
|
process.exit(ciCode);
|
|
123
122
|
}
|
|
124
123
|
catch (err) {
|
package/src/wp-ci.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wp-ci.js","sourceRoot":"","sources":["../../../../../packages/tooling/code-rules/src/wp-ci.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * wp-ci — the universal webpieces CI entrypoint.\n *\n * Works in BOTH an Nx monorepo and a plain (non-Nx) repo, because the detection of\n * \"are we even in Nx?\" cannot live inside an Nx executor (by the time an executor runs,\n * Nx is already running). Dispatch:\n *\n * - no nx.json (non-Nx repo) -> run the standalone code validators, succeed.\n * - nx.json present, plugin NOT in it -> fail with the exact install command.\n * - nx.json present, plugin registered -> run validators (incl. the wiring guard),\n * then `nx affected --target=ci`.\n *\n * Repos reference this as a bin (`\"webpieces:ci\": \"wp-ci\"`) so the logic is versioned in\n * the npm package instead of copy-pasted into each repo's package.json (which drifts).\n */\n\nimport { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { loadAndValidate, InformAiError, RuleFailError, toError, RepoRootFinder, BaseRuleConfig } from '@webpieces/rules-config';\nimport { CodeRulesApp } from './code-rules-app';\nimport { WorkspaceRoot, MatchRulesHolder } from './code-rules-context';\nimport { CONFIG_BINDINGS } from './code-rules-config-table';\n\nconst NX_PLUGIN_NAME = '@webpieces/nx-webpieces-rules';\n\ninterface NxPluginObject {\n plugin?: string;\n}\n\ntype NxPluginEntry = string | NxPluginObject;\n\ninterface RawNxJson {\n plugins?: NxPluginEntry[];\n}\n\nfunction findUp(filename: string, startDir: string): string | null {\n let dir = startDir;\n while (true) {\n const candidate = path.join(dir, filename);\n if (fs.existsSync(candidate)) return candidate;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\nfunction pluginEntryMatches(entry: NxPluginEntry): boolean {\n if (typeof entry === 'string') return entry === NX_PLUGIN_NAME;\n return entry.plugin === NX_PLUGIN_NAME;\n}\n\nfunction isPluginRegistered(nxJsonPath: string): boolean {\n const raw = fs.readFileSync(nxJsonPath, 'utf8');\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n const parsed = JSON.parse(raw) as RawNxJson;\n const plugins = parsed.plugins ?? [];\n return plugins.some((entry: NxPluginEntry) => pluginEntryMatches(entry));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`nx.json has invalid JSON — fix the file, then retry.\\nParse error: ${error.message}\\nFile: ${nxJsonPath}`);\n }\n}\n\nfunction nxBin(root: string): string {\n const local = path.join(root, 'node_modules', '.bin', 'nx');\n return fs.existsSync(local) ? local : 'nx';\n}\n\nfunction runNx(root: string, args: string[]): number {\n const result = spawnSync(nxBin(root), args, { stdio: 'inherit', cwd: root });\n if (typeof result.status === 'number') return result.status;\n return 1;\n}\n\nasync function runStandalone(cwd: string): Promise<number> {\n const workspaceRoot = new RepoRootFinder().resolveRepoRoot(cwd);\n const loaded = loadAndValidate(workspaceRoot);\n if (loaded.configPath === null) {\n console.log('ℹ️ Not an Nx repo and no webpieces.config.json found — nothing to validate.');\n return 0;\n }\n console.log('ℹ️ Not an Nx repo — running standalone webpieces code validators.\\n');\n\n // Composition root: bind runtime values, then resolve the app so inversify builds the whole DAG.\n // autobind self-binds every @injectable(Singleton) tooling class (replaces the buildProviderModule registry scan)\n const container = new Container({ autobind: true });\n container.bind(WorkspaceRoot).toConstantValue(new WorkspaceRoot(workspaceRoot));\n container.bind(MatchRulesHolder).toConstantValue(new MatchRulesHolder(loaded.matchRules));\n for (const binding of CONFIG_BINDINGS) {\n const ConfigClass = binding[0];\n const configured = loaded.rulesConfig[binding[1]] as BaseRuleConfig | undefined;\n container.bind(ConfigClass).toConstantValue(configured ?? new ConfigClass());\n }\n\n const app = container.get(CodeRulesApp);\n const result = await app.run();\n return result.success ? 0 : 1;\n}\n\nfunction reportPluginMissing(): void {\n console.error('\\n❌ This is an Nx monorepo but the webpieces Nx plugin is not installed.\\n');\n console.error(' Install it so the validators run during CI:\\n');\n console.error(` nx add ${NX_PLUGIN_NAME}\\n`);\n console.error(' (or add it manually to the \"plugins\" array in nx.json).\\n');\n}\n\n// webpieces-disable no-unmanaged-exceptions -- global entry point for wp-ci CLI\nasync function main(): Promise<void> {\n try {\n const cwd = process.cwd();\n const passthrough = process.argv.slice(2);\n\n const nxJsonPath = findUp('nx.json', cwd);\n if (!nxJsonPath) {\n const code = await runStandalone(cwd);\n process.exit(code);\n }\n\n const root = path.dirname(nxJsonPath);\n if (!isPluginRegistered(nxJsonPath)) {\n reportPluginMissing();\n process.exit(1);\n }\n\n // Run the architecture + code validators first (this also runs the wiring guard,\n // which fails loudly if nx.json no longer wires validators into the build).\n if (fs.existsSync(path.join(root, 'architecture'))) {\n const validateCode = runNx(root, ['run', 'architecture:validate-complete']);\n if (validateCode !== 0) process.exit(validateCode);\n }\n\n // Then the Gradle-style ci composite (lint + build + test) across affected projects.\n const ciCode = runNx(root, ['affected', '--target=ci', ...passthrough]);\n process.exit(ciCode);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n console.error(error.humanMessage);\n } else if (err instanceof InformAiError) {\n console.error(error.message);\n } else {\n console.error(`[wp-ci] unexpected error: ${error.message}`);\n }\n process.exit(1);\n }\n}\n\nvoid main();\n"]}
|
|
1
|
+
{"version":3,"file":"wp-ci.js","sourceRoot":"","sources":["../../../../../packages/tooling/code-rules/src/wp-ci.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * wp-ci — the universal webpieces CI entrypoint.\n *\n * Works in BOTH an Nx monorepo and a plain (non-Nx) repo, because the detection of\n * \"are we even in Nx?\" cannot live inside an Nx executor (by the time an executor runs,\n * Nx is already running). Dispatch:\n *\n * - no nx.json (non-Nx repo) -> run the standalone code validators, succeed.\n * - nx.json present, plugin NOT in it -> fail with the exact install command.\n * - nx.json present, plugin registered -> run validators (incl. the wiring guard),\n * then `nx affected --target=ci`.\n *\n * Repos reference this as a bin (`\"webpieces:ci\": \"wp-ci\"`) so the logic is versioned in\n * the npm package instead of copy-pasted into each repo's package.json (which drifts).\n *\n * Every nx step goes through NxStepRunner, which spawns it into its own process group and refuses\n * to return until that group has drained — a step whose workers outlive it holds the CI step's\n * stdout open and hangs the job forever with all work already green. See wp-ci-survivors.ts.\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport 'reflect-metadata';\nimport { Container } from 'inversify';\nimport { loadAndValidate, InformAiError, RuleFailError, toError, RepoRootFinder, BaseRuleConfig } from '@webpieces/rules-config';\nimport { CodeRulesApp } from './code-rules-app';\nimport { WorkspaceRoot, MatchRulesHolder } from './code-rules-context';\nimport { CONFIG_BINDINGS } from './code-rules-config-table';\nimport { NxStepRunner } from './wp-ci-nx-runner';\nimport {\n GracePeriodResolver,\n ProcessGroupKiller,\n ProcessGroupScanner,\n SurvivorReporter,\n SurvivorWatchdog,\n} from './wp-ci-survivors';\n\n/** How often the watchdog re-runs `ps` while waiting for a finished step's process group to drain. */\nconst SURVIVOR_POLL_INTERVAL_MILLIS = 1000;\n\nconst NX_PLUGIN_NAME = '@webpieces/nx-webpieces-rules';\n\ninterface NxPluginObject {\n plugin?: string;\n}\n\ntype NxPluginEntry = string | NxPluginObject;\n\ninterface RawNxJson {\n plugins?: NxPluginEntry[];\n}\n\nfunction findUp(filename: string, startDir: string): string | null {\n let dir = startDir;\n while (true) {\n const candidate = path.join(dir, filename);\n if (fs.existsSync(candidate)) return candidate;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\nfunction pluginEntryMatches(entry: NxPluginEntry): boolean {\n if (typeof entry === 'string') return entry === NX_PLUGIN_NAME;\n return entry.plugin === NX_PLUGIN_NAME;\n}\n\nfunction isPluginRegistered(nxJsonPath: string): boolean {\n const raw = fs.readFileSync(nxJsonPath, 'utf8');\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n const parsed = JSON.parse(raw) as RawNxJson;\n const plugins = parsed.plugins ?? [];\n return plugins.some((entry: NxPluginEntry) => pluginEntryMatches(entry));\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`nx.json has invalid JSON — fix the file, then retry.\\nParse error: ${error.message}\\nFile: ${nxJsonPath}`);\n }\n}\n\nasync function runStandalone(cwd: string): Promise<number> {\n const workspaceRoot = new RepoRootFinder().resolveRepoRoot(cwd);\n const loaded = loadAndValidate(workspaceRoot);\n if (loaded.configPath === null) {\n console.log('ℹ️ Not an Nx repo and no webpieces.config.json found — nothing to validate.');\n return 0;\n }\n console.log('ℹ️ Not an Nx repo — running standalone webpieces code validators.\\n');\n\n // Composition root: bind runtime values, then resolve the app so inversify builds the whole DAG.\n // autobind self-binds every @injectable(Singleton) tooling class (replaces the buildProviderModule registry scan)\n const container = new Container({ autobind: true });\n container.bind(WorkspaceRoot).toConstantValue(new WorkspaceRoot(workspaceRoot));\n container.bind(MatchRulesHolder).toConstantValue(new MatchRulesHolder(loaded.matchRules));\n for (const binding of CONFIG_BINDINGS) {\n const ConfigClass = binding[0];\n const configured = loaded.rulesConfig[binding[1]] as BaseRuleConfig | undefined;\n container.bind(ConfigClass).toConstantValue(configured ?? new ConfigClass());\n }\n\n const app = container.get(CodeRulesApp);\n const result = await app.run();\n return result.success ? 0 : 1;\n}\n\nfunction reportPluginMissing(): void {\n console.error('\\n❌ This is an Nx monorepo but the webpieces Nx plugin is not installed.\\n');\n console.error(' Install it so the validators run during CI:\\n');\n console.error(` nx add ${NX_PLUGIN_NAME}\\n`);\n console.error(' (or add it manually to the \"plugins\" array in nx.json).\\n');\n}\n\n// webpieces-disable no-unmanaged-exceptions -- global entry point for wp-ci CLI\nasync function main(): Promise<void> {\n try {\n const cwd = process.cwd();\n const passthrough = process.argv.slice(2);\n\n const nxJsonPath = findUp('nx.json', cwd);\n if (!nxJsonPath) {\n const code = await runStandalone(cwd);\n process.exit(code);\n }\n\n const root = path.dirname(nxJsonPath);\n if (!isPluginRegistered(nxJsonPath)) {\n reportPluginMissing();\n process.exit(1);\n }\n\n const gracePeriod = new GracePeriodResolver().resolve(process.env);\n const runner = new NxStepRunner(\n root,\n gracePeriod,\n new SurvivorWatchdog(new ProcessGroupScanner(), SURVIVOR_POLL_INTERVAL_MILLIS),\n new SurvivorReporter(),\n new ProcessGroupKiller(),\n );\n\n // Run the architecture + code validators first (this also runs the wiring guard,\n // which fails loudly if nx.json no longer wires validators into the build).\n if (fs.existsSync(path.join(root, 'architecture'))) {\n const validateCode = await runner.run(['run', 'architecture:validate-complete'], 'architecture:validate-complete');\n if (validateCode !== 0) process.exit(validateCode);\n }\n\n // Then the Gradle-style ci composite (lint + build + test) across affected projects.\n const ciCode = await runner.run(['affected', '--target=ci', ...passthrough], 'nx affected --target=ci');\n process.exit(ciCode);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n console.error(error.humanMessage);\n } else if (err instanceof InformAiError) {\n console.error(error.message);\n } else {\n console.error(`[wp-ci] unexpected error: ${error.message}`);\n }\n process.exit(1);\n }\n}\n\nvoid main();\n"]}
|