@codyswann/lisa 4.1.0 → 4.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/configs/vitest/scratch-global-setup.d.ts +27 -4
- package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
- package/dist/configs/vitest/scratch-global-setup.js +38 -9
- package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +5 -0
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-orphan-test-processes.mjs +342 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic gate against orphaned test-fixture process trees (issue #2884).
|
|
4
|
+
*
|
|
5
|
+
* Lisa's own suite shells out to real scripts from temp fixtures. When the
|
|
6
|
+
* runner is killed rather than allowed to finish — which this repository's
|
|
7
|
+
* pre-push gate does under load, exit 143 / 137 — the fixture's *grandchildren*
|
|
8
|
+
* survive: `jq`, `node -e`, and `git`, reparented to PID 1 at ~0% CPU, each
|
|
9
|
+
* still naming a `$TMPDIR/lisa-*` path.
|
|
10
|
+
*
|
|
11
|
+
* This has been cleared by hand twice, months apart, and never gated:
|
|
12
|
+
*
|
|
13
|
+
* - 2026-08-13: 142 stale trees (recorded in commit 1d815ac5's own body,
|
|
14
|
+
* which raised the suite timeout 10s -> 60s only after clearing them)
|
|
15
|
+
* - 2026-08-21: 227 stale trees, elapsed up to 13 hours
|
|
16
|
+
*
|
|
17
|
+
* They are not merely untidy. They live in the shared `$TMPDIR`, so they are
|
|
18
|
+
* one of the things *growing* the directory whose saturation makes a single
|
|
19
|
+
* `mkdtemp` cost 23,349 ms (#2883). The leak feeds the flakiness that hides it.
|
|
20
|
+
*
|
|
21
|
+
* Detection is deliberately narrow, because a gate that fires on processes it
|
|
22
|
+
* should not read is its own outage:
|
|
23
|
+
*
|
|
24
|
+
* - only processes whose command line names one of the Lisa test temp
|
|
25
|
+
* prefixes below, so a developer's unrelated `jq` is never matched;
|
|
26
|
+
* - only PPID 1, i.e. genuinely orphaned. A fixture process with a live
|
|
27
|
+
* parent is a *running test*, not a leak, and is never reported;
|
|
28
|
+
* - only past a minimum age, so a run in flight during the check — its
|
|
29
|
+
* worker mid-restart — cannot produce a false positive.
|
|
30
|
+
*
|
|
31
|
+
* Determinism: Node built-ins only, no network, no Math.random. `Date` is used
|
|
32
|
+
* solely to age stale temp directories, and the threshold is injectable so the
|
|
33
|
+
* unit test is reproducible.
|
|
34
|
+
*
|
|
35
|
+
* CLI:
|
|
36
|
+
* node scripts/check-orphan-test-processes.mjs [--json] [--reap]
|
|
37
|
+
* [--min-age-seconds <n>]
|
|
38
|
+
*
|
|
39
|
+
* Exit codes (mirroring the sibling check-* scripts):
|
|
40
|
+
* 0 — no orphaned fixture process trees.
|
|
41
|
+
* 1 — >=1 orphaned fixture process tree (or, with --reap, some survived).
|
|
42
|
+
* 2 — operational/usage error: unknown flag, a flag missing its value, or
|
|
43
|
+
* `ps` being unavailable.
|
|
44
|
+
*
|
|
45
|
+
* @module scripts/check-orphan-test-processes
|
|
46
|
+
*/
|
|
47
|
+
import { execFileSync } from "node:child_process";
|
|
48
|
+
import process from "node:process";
|
|
49
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Temp-directory prefixes used by Lisa's own test fixtures. A process is only
|
|
53
|
+
* ever a candidate if its command line names one of these.
|
|
54
|
+
*/
|
|
55
|
+
export const FIXTURE_PREFIXES = ["lisa-self-postinstall-", "lisa-test-"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimum orphan age before it is reported, in seconds. Anything younger may
|
|
59
|
+
* belong to a run that is still starting up.
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_MIN_AGE_SECONDS = 120;
|
|
62
|
+
|
|
63
|
+
/** Init's pid. An orphan is a process reparented here. */
|
|
64
|
+
const INIT_PID = 1;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Maximum reap passes. Killing an orphan reparents its own children to PID 1,
|
|
68
|
+
* so a single pass exposes the next generation rather than finishing the job.
|
|
69
|
+
* Fixture trees are only a few levels deep; this bounds a pathological case.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_REAP_PASSES = 8;
|
|
72
|
+
|
|
73
|
+
/** Longest command excerpt one report line carries. */
|
|
74
|
+
const MAX_COMMAND = 100;
|
|
75
|
+
|
|
76
|
+
/** How much of that budget the head keeps; the rest goes to the tail. */
|
|
77
|
+
const EXCERPT_HEAD = 40;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A bounded command excerpt that always keeps the END of the command line.
|
|
81
|
+
*
|
|
82
|
+
* A plain head-of-string cut looked fine and was wrong, and this gate's own
|
|
83
|
+
* bite test is what caught it. A fixture path is
|
|
84
|
+
* `$TMPDIR/lisa-scratch/run-<pid>-<epoch-ms>-<hash>/lisa-self-postinstall-<rand>/…`
|
|
85
|
+
* once the per-process scratch redirection nests it, and the first 100
|
|
86
|
+
* characters of that are consumed by the run root — so the report named no
|
|
87
|
+
* fixture at all, which is the one thing a reader needs to act. Measured: the
|
|
88
|
+
* planted-orphan case reported `1 orphaned … process tree(s)` and a path
|
|
89
|
+
* ending at the run-root hash, with the `lisa-self-postinstall-` segment cut
|
|
90
|
+
* off.
|
|
91
|
+
*
|
|
92
|
+
* The identifying part of a path is its tail, so the tail is what survives.
|
|
93
|
+
* @param {string} command - Full command line from ps.
|
|
94
|
+
* @returns {string} The command, or head + ellipsis + tail within the budget.
|
|
95
|
+
*/
|
|
96
|
+
export function excerptCommand(command) {
|
|
97
|
+
if (command.length <= MAX_COMMAND) return command;
|
|
98
|
+
const tail = command.slice(-(MAX_COMMAND - EXCERPT_HEAD - 1));
|
|
99
|
+
return `${command.slice(0, EXCERPT_HEAD)}…${tail}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse an `etime` field (`[[dd-]hh:]mm:ss`) into seconds.
|
|
104
|
+
* @param {string} etime - Elapsed-time field from ps.
|
|
105
|
+
* @returns {number} Elapsed seconds, or 0 when unparseable.
|
|
106
|
+
*/
|
|
107
|
+
export function parseEtimeSeconds(etime) {
|
|
108
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
|
|
109
|
+
if (!match) return 0;
|
|
110
|
+
const [, days, hours, minutes, seconds] = match;
|
|
111
|
+
return (
|
|
112
|
+
Number(days ?? 0) * 86400 +
|
|
113
|
+
Number(hours ?? 0) * 3600 +
|
|
114
|
+
Number(minutes) * 60 +
|
|
115
|
+
Number(seconds)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse `ps -eo pid,ppid,etime,command` output into records.
|
|
121
|
+
* @param {string} psOutput - Raw ps output including its header line.
|
|
122
|
+
* @returns {{pid:number,ppid:number,etimeSeconds:number,command:string}[]} Rows.
|
|
123
|
+
*/
|
|
124
|
+
export function parsePsOutput(psOutput) {
|
|
125
|
+
return (
|
|
126
|
+
psOutput
|
|
127
|
+
.split("\n")
|
|
128
|
+
.slice(1)
|
|
129
|
+
// Split on whitespace rather than matching: a `(\d+)\s+(\d+)\s+(\S+)\s+(.*)`
|
|
130
|
+
// pattern over an arbitrarily long command line backtracks super-linearly.
|
|
131
|
+
.map(line => line.trim().split(/\s+/))
|
|
132
|
+
.filter(
|
|
133
|
+
fields =>
|
|
134
|
+
fields.length >= 4 &&
|
|
135
|
+
/^\d+$/.test(fields[0]) &&
|
|
136
|
+
/^\d+$/.test(fields[1])
|
|
137
|
+
)
|
|
138
|
+
.map(fields => ({
|
|
139
|
+
command: fields.slice(3).join(" "),
|
|
140
|
+
etimeSeconds: parseEtimeSeconds(fields[2]),
|
|
141
|
+
pid: Number(fields[0]),
|
|
142
|
+
ppid: Number(fields[1]),
|
|
143
|
+
}))
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Select the orphaned fixture processes from a set of ps records.
|
|
149
|
+
* @param {{pid:number,ppid:number,etimeSeconds:number,command:string}[]} rows -
|
|
150
|
+
* Parsed ps records.
|
|
151
|
+
* @param {number} minAgeSeconds - Minimum age before a row is reported.
|
|
152
|
+
* @returns {{pid:number,ppid:number,etimeSeconds:number,command:string}[]}
|
|
153
|
+
* Orphans, oldest first.
|
|
154
|
+
*/
|
|
155
|
+
export function selectOrphans(rows, minAgeSeconds) {
|
|
156
|
+
return rows
|
|
157
|
+
.filter(row => row.ppid === INIT_PID)
|
|
158
|
+
.filter(row => row.etimeSeconds >= minAgeSeconds)
|
|
159
|
+
.filter(row =>
|
|
160
|
+
FIXTURE_PREFIXES.some(prefix => row.command.includes(prefix))
|
|
161
|
+
)
|
|
162
|
+
.sort((a, b) => b.etimeSeconds - a.etimeSeconds);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Read the process table.
|
|
167
|
+
* @returns {string} Raw `ps` output.
|
|
168
|
+
*/
|
|
169
|
+
function readProcessTable() {
|
|
170
|
+
// eslint-disable-next-line sonarjs/no-os-command-from-path -- fixed executable and argv
|
|
171
|
+
return execFileSync("ps", ["-eo", "pid,ppid,etime,command"], {
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format the human-readable report.
|
|
179
|
+
* @param {{pid:number,etimeSeconds:number,command:string}[]} orphans - Orphans.
|
|
180
|
+
* @returns {string} Report text.
|
|
181
|
+
*/
|
|
182
|
+
export function humanReport(orphans) {
|
|
183
|
+
if (orphans.length === 0) {
|
|
184
|
+
return "✅ No orphaned Lisa test-fixture process trees.";
|
|
185
|
+
}
|
|
186
|
+
const lines = orphans.map(
|
|
187
|
+
orphan =>
|
|
188
|
+
` pid ${String(orphan.pid)} ${String(
|
|
189
|
+
Math.round(orphan.etimeSeconds / 60)
|
|
190
|
+
)}m ${excerptCommand(orphan.command)}`
|
|
191
|
+
);
|
|
192
|
+
return [
|
|
193
|
+
`❌ ${String(orphans.length)} orphaned Lisa test-fixture process tree(s) (PPID 1):`,
|
|
194
|
+
...lines,
|
|
195
|
+
"",
|
|
196
|
+
"These are fixture grandchildren that outlived a killed test runner.",
|
|
197
|
+
"They occupy the shared $TMPDIR and feed its saturation (#2883).",
|
|
198
|
+
"Reap them with: node scripts/check-orphan-test-processes.mjs --reap",
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Kill the listed orphans.
|
|
204
|
+
* @param {{pid:number}[]} orphans - Orphans to kill.
|
|
205
|
+
* @returns {number} Count actually signalled.
|
|
206
|
+
*/
|
|
207
|
+
export function reapOrphans(orphans) {
|
|
208
|
+
return orphans.filter(orphan => {
|
|
209
|
+
try {
|
|
210
|
+
process.kill(orphan.pid, "SIGKILL");
|
|
211
|
+
return true;
|
|
212
|
+
} catch {
|
|
213
|
+
// ESRCH: already gone between listing and killing.
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}).length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reap orphans repeatedly until none surface or the pass budget is spent.
|
|
221
|
+
*
|
|
222
|
+
* One pass is not enough: killing an orphan reparents its children to PID 1,
|
|
223
|
+
* which makes them orphans in turn.
|
|
224
|
+
* @param {number} minAgeSeconds - Minimum age before a process is reaped.
|
|
225
|
+
* @returns {number} Total processes signalled across all passes.
|
|
226
|
+
*/
|
|
227
|
+
export function reapUntilSettled(minAgeSeconds) {
|
|
228
|
+
return Array.from({ length: MAX_REAP_PASSES }).reduce(total => {
|
|
229
|
+
const orphans = selectOrphans(
|
|
230
|
+
parsePsOutput(readProcessTable()),
|
|
231
|
+
minAgeSeconds
|
|
232
|
+
);
|
|
233
|
+
if (orphans.length === 0) return total;
|
|
234
|
+
return total + reapOrphans(orphans);
|
|
235
|
+
}, 0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse CLI arguments.
|
|
240
|
+
* @param {string[]} argv - Arguments after the script name.
|
|
241
|
+
* @returns {{json:boolean,reap:boolean,minAgeSeconds:number}} Parsed options.
|
|
242
|
+
*/
|
|
243
|
+
export function parseArgs(argv) {
|
|
244
|
+
const KNOWN = new Set(["--json", "--reap", "--min-age-seconds"]);
|
|
245
|
+
const ageIndex = argv.indexOf("--min-age-seconds");
|
|
246
|
+
if (ageIndex !== -1 && argv[ageIndex + 1] === undefined) {
|
|
247
|
+
throw new Error("--min-age-seconds requires a value");
|
|
248
|
+
}
|
|
249
|
+
// Guard the -1 case: without the option, `ageIndex + 1` is 0 and would skip
|
|
250
|
+
// the first argument as though it were that option's value.
|
|
251
|
+
const valueIndex = ageIndex === -1 ? -1 : ageIndex + 1;
|
|
252
|
+
const unknown = argv.find(
|
|
253
|
+
(arg, index) => index !== valueIndex && !KNOWN.has(arg)
|
|
254
|
+
);
|
|
255
|
+
if (unknown !== undefined) {
|
|
256
|
+
throw new Error(`unknown flag: ${unknown}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
json: argv.includes("--json"),
|
|
260
|
+
minAgeSeconds:
|
|
261
|
+
ageIndex === -1 ? DEFAULT_MIN_AGE_SECONDS : Number(argv[ageIndex + 1]),
|
|
262
|
+
reap: argv.includes("--reap"),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse arguments without throwing.
|
|
268
|
+
* @param {string[]} argv - Arguments after the script name.
|
|
269
|
+
* @returns {{ok:true,opts:object}|{ok:false,message:string}} Parse outcome.
|
|
270
|
+
*/
|
|
271
|
+
function tryParseArgs(argv) {
|
|
272
|
+
try {
|
|
273
|
+
return { ok: true, opts: parseArgs(argv) };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return { message: String(error.message), ok: false };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Read the process table without throwing.
|
|
281
|
+
* @returns {{ok:true,table:string}|{ok:false,message:string}} Read outcome.
|
|
282
|
+
*/
|
|
283
|
+
function tryReadProcessTable() {
|
|
284
|
+
try {
|
|
285
|
+
return { ok: true, table: readProcessTable() };
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
message: `failed to read the process table: ${String(error.message)}`,
|
|
289
|
+
ok: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Entry point.
|
|
296
|
+
* @param {string[]} argv - Arguments after the script name.
|
|
297
|
+
* @param {NodeJS.WritableStream} out - Output stream.
|
|
298
|
+
* @returns {number} Process exit code.
|
|
299
|
+
*/
|
|
300
|
+
export function main(argv, out = process.stdout) {
|
|
301
|
+
const parsed = tryParseArgs(argv);
|
|
302
|
+
if (!parsed.ok) {
|
|
303
|
+
out.write(`${parsed.message}\n`);
|
|
304
|
+
return 2;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const read = tryReadProcessTable();
|
|
308
|
+
if (!read.ok) {
|
|
309
|
+
out.write(`${read.message}\n`);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const orphans = selectOrphans(
|
|
314
|
+
parsePsOutput(read.table),
|
|
315
|
+
parsed.opts.minAgeSeconds
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (parsed.opts.reap) {
|
|
319
|
+
const total = reapUntilSettled(parsed.opts.minAgeSeconds);
|
|
320
|
+
const remaining = selectOrphans(
|
|
321
|
+
parsePsOutput(readProcessTable()),
|
|
322
|
+
parsed.opts.minAgeSeconds
|
|
323
|
+
).length;
|
|
324
|
+
out.write(`reaped ${String(total)}; ${String(remaining)} still present\n`);
|
|
325
|
+
return remaining === 0 ? 0 : 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
out.write(
|
|
329
|
+
`${
|
|
330
|
+
parsed.opts.json
|
|
331
|
+
? JSON.stringify({ orphans }, null, 2)
|
|
332
|
+
: humanReport(orphans)
|
|
333
|
+
}\n`
|
|
334
|
+
);
|
|
335
|
+
return orphans.length === 0 ? 0 : 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (invokedAsScript(import.meta.url)) {
|
|
339
|
+
// exitCode (not process.exit): when stdout is a pipe, writes are async and
|
|
340
|
+
// process.exit() truncates the report mid-flush.
|
|
341
|
+
process.exitCode = main(process.argv.slice(2));
|
|
342
|
+
}
|