browser-commander 0.31.2 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +26 -0
- package/package.json +1 -1
- package/src/browser/browser-handoff.js +304 -0
- package/src/browser/cdp-browser-endpoint.js +55 -0
- package/src/browser/launcher.js +8 -0
- package/src/browser/persistent-session.js +5 -56
- package/src/browser/profile-directory.js +8 -1
- package/src/browser/real-browser.js +56 -5
- package/src/browser/session-persistence.js +9 -5
- package/src/browser/webdriver.js +8 -1
- package/src/exports.js +6 -0
- package/src/utilities/subprocess.js +199 -45
- package/tests/e2e/browser-handoff.e2e.test.js +94 -0
- package/tests/helpers/fake-cdp-endpoint.js +64 -0
- package/tests/helpers/temp-directory.js +25 -0
- package/tests/unit/browser/browser-handoff.test.js +406 -0
- package/tests/unit/browser/launch-building-blocks.test.js +9 -2
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/scripts/preflight-credentials.test.js +156 -10
- package/tests/unit/scripts/trusted-publishing.test.js +29 -7
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +6 -13
|
@@ -90,6 +90,7 @@ function environment(overrides) {
|
|
|
90
90
|
NPM_REGISTRY: `${stub.origin}/npm`,
|
|
91
91
|
CRATES_API: `${stub.origin}/crates`,
|
|
92
92
|
NPM_PACKAGE: '@link-foundation/probe',
|
|
93
|
+
CRATES_PACKAGE: 'probe-crate',
|
|
93
94
|
CARGO_REGISTRY_TOKEN: '',
|
|
94
95
|
CARGO_TOKEN: '',
|
|
95
96
|
GITHUB_STEP_SUMMARY: '',
|
|
@@ -133,6 +134,13 @@ const EXCHANGE =
|
|
|
133
134
|
'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
|
|
134
135
|
const CRATES_MINT = 'POST /crates/api/v1/trusted_publishing/tokens';
|
|
135
136
|
const CRATES_REVOKE = 'DELETE /crates/api/v1/trusted_publishing/tokens';
|
|
137
|
+
const CRATES_PUBLISH = 'PUT /crates/api/v1/crates/new';
|
|
138
|
+
// What crates.io answers to an authorised publish that omits the archive.
|
|
139
|
+
const TOKEN_ACCEPTED = [
|
|
140
|
+
400,
|
|
141
|
+
{ errors: [{ detail: 'invalid tarball length' }] },
|
|
142
|
+
];
|
|
143
|
+
const TOKEN_REJECTED = [403, { errors: [{ detail: 'authentication failed' }] }];
|
|
136
144
|
const INVALID_PUBLISHER = [
|
|
137
145
|
422,
|
|
138
146
|
{
|
|
@@ -157,6 +165,33 @@ function probeCrates(answer, overrides = {}) {
|
|
|
157
165
|
return preflight({ PREFLIGHT_REGISTRIES: 'crates', ...overrides });
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Refuse the crates.io OIDC exchange and answer the token publish probe with
|
|
170
|
+
* `answer`, so the outcome rests on the token fallback.
|
|
171
|
+
*/
|
|
172
|
+
function probeFallback(answer, overrides = {}) {
|
|
173
|
+
stub.routes[CRATES_PUBLISH] = answer;
|
|
174
|
+
return probeCrates([403, {}], { CARGO_TOKEN: CARGO_SECRET, ...overrides });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Run the crates.io probe in a job without `id-token: write`. */
|
|
178
|
+
function preflightWithoutOidc(overrides = {}) {
|
|
179
|
+
return preflight({
|
|
180
|
+
PREFLIGHT_REGISTRIES: 'crates',
|
|
181
|
+
ACTIONS_ID_TOKEN_REQUEST_URL: '',
|
|
182
|
+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
|
|
183
|
+
...overrides,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The metadata of a token publish probe: a u32 length, then JSON. */
|
|
188
|
+
function probeMetadata(body) {
|
|
189
|
+
const bytes = Buffer.from(body, 'latin1');
|
|
190
|
+
const length = bytes.readUInt32LE(0);
|
|
191
|
+
assert.equal(bytes.length, 4 + length, 'the probe must carry no archive');
|
|
192
|
+
return JSON.parse(bytes.subarray(4).toString('utf8'));
|
|
193
|
+
}
|
|
194
|
+
|
|
160
195
|
describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
161
196
|
before(async () => {
|
|
162
197
|
stub.server = createServer(respond);
|
|
@@ -349,11 +384,11 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
|
349
384
|
assertNoSecrets(output);
|
|
350
385
|
});
|
|
351
386
|
|
|
352
|
-
it('fails a release when no trusted publisher matches
|
|
353
|
-
const { status, output } = await probeCrates(
|
|
354
|
-
|
|
355
|
-
{
|
|
356
|
-
);
|
|
387
|
+
it('fails a release when no trusted publisher matches and no token is set', async () => {
|
|
388
|
+
const { status, output } = await probeCrates([
|
|
389
|
+
403,
|
|
390
|
+
{ errors: [{ detail: 'no matching publisher' }] },
|
|
391
|
+
]);
|
|
357
392
|
|
|
358
393
|
assert.equal(status, 1, output);
|
|
359
394
|
assert.match(
|
|
@@ -362,18 +397,80 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
|
362
397
|
);
|
|
363
398
|
assert.match(output, /rust\.yml/);
|
|
364
399
|
assert.match(output, /docs\/PUBLISHING\.md/);
|
|
400
|
+
assert.match(output, /neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN/);
|
|
365
401
|
assert.equal(stub.requests.length, 2);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('passes a release on the token fallback when no trusted publisher matches', async () => {
|
|
405
|
+
const { status, output } = await probeFallback(TOKEN_ACCEPTED);
|
|
406
|
+
|
|
407
|
+
assert.equal(status, 0, output);
|
|
408
|
+
assert.match(output, /1 verified, 0 failed, 0 unknown/);
|
|
409
|
+
assert.match(
|
|
410
|
+
output,
|
|
411
|
+
/PASS: crates.io accepted the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate/
|
|
412
|
+
);
|
|
413
|
+
assert.match(
|
|
414
|
+
output,
|
|
415
|
+
/::warning::release-preflight: crates.io refused the OIDC token exchange \(403\).*falls back to the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN secret/
|
|
416
|
+
);
|
|
417
|
+
assert.doesNotMatch(output, /::error::/);
|
|
418
|
+
const publish = stub.requests.at(-1);
|
|
419
|
+
assert.equal(publish.route, CRATES_PUBLISH);
|
|
420
|
+
assert.equal(publish.authorization, CARGO_SECRET);
|
|
421
|
+
assert.match(publish.userAgent, /^release-preflight /);
|
|
422
|
+
assert.deepEqual(probeMetadata(publish.body), {
|
|
423
|
+
name: 'probe-crate',
|
|
424
|
+
vers: '0.0.0-preflight',
|
|
425
|
+
});
|
|
426
|
+
assert.equal(stub.requests.length, 3);
|
|
366
427
|
assertNoSecrets(output);
|
|
367
428
|
});
|
|
368
429
|
|
|
369
|
-
it('
|
|
370
|
-
const { status, output } = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
430
|
+
it('prefers CARGO_REGISTRY_TOKEN over CARGO_TOKEN for the fallback', async () => {
|
|
431
|
+
const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
|
|
432
|
+
CARGO_REGISTRY_TOKEN: CARGO_SECRET,
|
|
433
|
+
CARGO_TOKEN: 'cio-other-token-must-not-be-used',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
assert.equal(status, 0, output);
|
|
437
|
+
assert.equal(stub.requests.at(-1).authorization, CARGO_SECRET);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('fails a release when trusted publishing and the token fallback are both refused', async () => {
|
|
441
|
+
const { status, output } = await probeFallback(TOKEN_REJECTED);
|
|
442
|
+
|
|
443
|
+
assert.equal(status, 1, output);
|
|
444
|
+
assert.match(output, /0 verified, 1 failed, 0 unknown/);
|
|
445
|
+
assert.match(
|
|
446
|
+
output,
|
|
447
|
+
/::error::release-preflight: crates.io refused the OIDC token exchange \(403\).*; crates.io rejected the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate \(403\)/
|
|
448
|
+
);
|
|
449
|
+
assert.equal(stub.requests.length, 3);
|
|
450
|
+
assertNoSecrets(output);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('falls back to the token when the job has no OIDC permission', async () => {
|
|
454
|
+
stub.routes[CRATES_PUBLISH] = TOKEN_ACCEPTED;
|
|
455
|
+
const { status, output } = await preflightWithoutOidc({
|
|
374
456
|
CARGO_REGISTRY_TOKEN: CARGO_SECRET,
|
|
375
457
|
});
|
|
376
458
|
|
|
459
|
+
assert.equal(status, 0, output);
|
|
460
|
+
assert.match(
|
|
461
|
+
output,
|
|
462
|
+
/::warning::release-preflight: crates.io: no OIDC token available: .*id-token: write/
|
|
463
|
+
);
|
|
464
|
+
assert.deepEqual(
|
|
465
|
+
stub.requests.map(({ route }) => route),
|
|
466
|
+
[CRATES_PUBLISH]
|
|
467
|
+
);
|
|
468
|
+
assertNoSecrets(output);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('fails when the job has no OIDC permission and no token is set', async () => {
|
|
472
|
+
const { status, output } = await preflightWithoutOidc();
|
|
473
|
+
|
|
377
474
|
assert.equal(status, 1, output);
|
|
378
475
|
assert.match(
|
|
379
476
|
output,
|
|
@@ -382,6 +479,54 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
|
382
479
|
assert.equal(stub.requests.length, 0);
|
|
383
480
|
});
|
|
384
481
|
|
|
482
|
+
it('treats an unproven token fallback as unknown, never as a pass', async () => {
|
|
483
|
+
for (const answer of [
|
|
484
|
+
[400, { errors: [{ detail: 'invalid upload request' }] }],
|
|
485
|
+
[429, {}],
|
|
486
|
+
[500, { token: CARGO_SECRET }],
|
|
487
|
+
]) {
|
|
488
|
+
const { status, output } = await probeFallback(answer);
|
|
489
|
+
assert.equal(status, 1, output);
|
|
490
|
+
assert.match(output, /0 verified, 0 failed, 1 unknown/);
|
|
491
|
+
assert.match(output, /verified nothing/);
|
|
492
|
+
assertNoSecrets(output);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reads the crate name from Cargo.toml when CRATES_PACKAGE is unset', async () => {
|
|
497
|
+
writeFileSync(
|
|
498
|
+
path.join(workDir, 'Cargo.toml'),
|
|
499
|
+
[
|
|
500
|
+
'[package]',
|
|
501
|
+
'name = "from-manifest"',
|
|
502
|
+
'version = "1.2.3"',
|
|
503
|
+
'',
|
|
504
|
+
'[dependencies.serde]',
|
|
505
|
+
'name = "not-this-one"',
|
|
506
|
+
'',
|
|
507
|
+
].join('\n')
|
|
508
|
+
);
|
|
509
|
+
const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
|
|
510
|
+
CRATES_PACKAGE: '',
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
assert.equal(status, 0, output);
|
|
514
|
+
assert.equal(
|
|
515
|
+
probeMetadata(stub.requests.at(-1).body).name,
|
|
516
|
+
'from-manifest'
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('cannot probe the token without a crate name', async () => {
|
|
521
|
+
const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
|
|
522
|
+
CRATES_PACKAGE: '',
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
assert.equal(status, 1, output);
|
|
526
|
+
assert.match(output, /cannot tell which crate to probe/);
|
|
527
|
+
assert.equal(stub.requests.length, 2);
|
|
528
|
+
});
|
|
529
|
+
|
|
385
530
|
it('keeps missing publishers advisory on pull requests', async () => {
|
|
386
531
|
const { status, output } = await probeCrates([403, {}], {
|
|
387
532
|
PREFLIGHT_MODE: 'report',
|
|
@@ -429,6 +574,7 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
|
429
574
|
stub.routes[MINT] = INVALID_PUBLISHER;
|
|
430
575
|
stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
|
|
431
576
|
stub.routes[CRATES_MINT] = [401, { errors: [{ detail: 'bad token' }] }];
|
|
577
|
+
stub.routes[CRATES_PUBLISH] = TOKEN_REJECTED;
|
|
432
578
|
const summary = path.join(workDir, 'summary.md');
|
|
433
579
|
writeFileSync(summary, '');
|
|
434
580
|
|
|
@@ -7,8 +7,13 @@ import { readRepoText } from '../../helpers/repo.js';
|
|
|
7
7
|
describe('trusted publishing configuration', () => {
|
|
8
8
|
const rust = parse(readRepoText('.github/workflows/rust.yml'));
|
|
9
9
|
|
|
10
|
+
// Organization policy: trusted publishing (OIDC) first, the token secret
|
|
11
|
+
// only when OIDC is unavailable.
|
|
12
|
+
const FALLBACK =
|
|
13
|
+
'${{ steps.crates_auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}';
|
|
14
|
+
|
|
10
15
|
for (const jobId of ['auto-release', 'manual-release']) {
|
|
11
|
-
it(`${jobId}
|
|
16
|
+
it(`${jobId} publishes with a fresh OIDC token, falling back to the token secret`, () => {
|
|
12
17
|
const job = rust.jobs[jobId];
|
|
13
18
|
assert.equal(job.permissions['id-token'], 'write');
|
|
14
19
|
const auth = job.steps.find((step) => step.id === 'crates_auth');
|
|
@@ -20,9 +25,11 @@ describe('trusted publishing configuration', () => {
|
|
|
20
25
|
);
|
|
21
26
|
assert.equal(auth.if, publish.if);
|
|
22
27
|
assert.equal(
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
auth['continue-on-error'],
|
|
29
|
+
true,
|
|
30
|
+
'a trusted publishing failure must leave the token fallback reachable'
|
|
25
31
|
);
|
|
32
|
+
assert.equal(publish.env.CARGO_REGISTRY_TOKEN, FALLBACK);
|
|
26
33
|
assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
|
|
27
34
|
assert.ok(
|
|
28
35
|
job.steps.indexOf(auth) >
|
|
@@ -31,13 +38,28 @@ describe('trusted publishing configuration', () => {
|
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
it('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
it('gives the release preflight the same token fallback', () => {
|
|
42
|
+
const probe = rust.jobs['release-preflight'].steps.find(
|
|
43
|
+
(step) => step.name === 'Probe crates.io trusted publishing'
|
|
44
|
+
);
|
|
45
|
+
assert.equal(
|
|
46
|
+
probe.env.CARGO_REGISTRY_TOKEN,
|
|
47
|
+
'${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}'
|
|
38
48
|
);
|
|
39
49
|
});
|
|
40
50
|
|
|
51
|
+
it('reads the Cargo secrets only as the trusted publishing fallback', () => {
|
|
52
|
+
const uses = readRepoText('.github/workflows/rust.yml')
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter((line) => /secrets\.CARGO_/.test(line))
|
|
55
|
+
.map((line) => line.trim());
|
|
56
|
+
assert.deepEqual(uses, [
|
|
57
|
+
'CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}',
|
|
58
|
+
`CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
|
|
59
|
+
`CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
41
63
|
it('keeps the registered PyPI identity identical across preflight and releases', () => {
|
|
42
64
|
const python = parse(readRepoText('.github/workflows/python.yml'));
|
|
43
65
|
for (const jobId of [
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import assert from 'node:assert';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import {
|
|
7
|
+
interruptionMode,
|
|
8
|
+
startProcess,
|
|
9
|
+
} from '../../../src/utilities/subprocess.js';
|
|
10
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
11
|
+
import { waitForFileText } from '../../helpers/temp-directory.js';
|
|
12
|
+
|
|
13
|
+
// feature-parity: process.interruption@native-typed
|
|
14
|
+
|
|
15
|
+
const node = process.execPath;
|
|
16
|
+
const delayedShutdown = repoPath(
|
|
17
|
+
'experiments',
|
|
18
|
+
'issue-172',
|
|
19
|
+
'delayed-shutdown.mjs'
|
|
20
|
+
);
|
|
21
|
+
const unixOnly = {
|
|
22
|
+
timeout: 15000,
|
|
23
|
+
skip:
|
|
24
|
+
process.platform === 'win32' &&
|
|
25
|
+
'Command Stream only creates separate process groups on Unix',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isAlive(pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ESRCH') {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function forceKill(pid) {
|
|
41
|
+
if (pid && isAlive(pid)) {
|
|
42
|
+
process.kill(pid, 'SIGKILL');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function tempDirectory(t, prefix) {
|
|
47
|
+
const directory = await fs.mkdtemp(path.join(tmpdir(), prefix));
|
|
48
|
+
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
|
49
|
+
return directory;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run an embedding supervisor that interrupts itself, keeps its child from
|
|
54
|
+
* its own SIGINT handler, and exits. Returns whether the child survived.
|
|
55
|
+
*/
|
|
56
|
+
async function childSurvivesSupervisorInterrupt(t, mode) {
|
|
57
|
+
const directory = await tempDirectory(t, 'bc-interrupt-174-');
|
|
58
|
+
const pidFile = path.join(directory, 'pid');
|
|
59
|
+
const supervisor = startProcess(node, [
|
|
60
|
+
repoPath('experiments', 'issue-174', 'sigint-supervisor.mjs'),
|
|
61
|
+
mode,
|
|
62
|
+
pidFile,
|
|
63
|
+
]);
|
|
64
|
+
assert.equal(await supervisor.exited, 130, supervisor.stderrTail);
|
|
65
|
+
const pid = Number(await fs.readFile(pidFile, 'utf8'));
|
|
66
|
+
t.after(() => forceKill(pid));
|
|
67
|
+
assert.ok(pid > 0);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
69
|
+
return isAlive(pid);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('interruption ownership (issue #174)', unixOnly, () => {
|
|
73
|
+
it('forwards SIGINT to the child by default, before the embedder can keep it', async (t) => {
|
|
74
|
+
assert.equal(await childSurvivesSupervisorInterrupt(t, 'managed'), false);
|
|
75
|
+
assert.equal(
|
|
76
|
+
await childSurvivesSupervisorInterrupt(t, 'detach-in-handler'),
|
|
77
|
+
false
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves SIGINT to the embedder's handler with interruption: 'caller'", async (t) => {
|
|
82
|
+
assert.equal(await childSurvivesSupervisorInterrupt(t, 'caller'), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps streaming output and reporting the exit code for a caller-owned child', async () => {
|
|
86
|
+
const child = startProcess(
|
|
87
|
+
node,
|
|
88
|
+
[
|
|
89
|
+
'-e',
|
|
90
|
+
'setTimeout(()=>{process.stderr.write("hello\\n");process.exit(4)},100)',
|
|
91
|
+
],
|
|
92
|
+
{ interruption: 'caller' }
|
|
93
|
+
);
|
|
94
|
+
assert.equal(child.interruption, 'caller');
|
|
95
|
+
let stderr = '';
|
|
96
|
+
child.stderr.on('data', (chunk) => {
|
|
97
|
+
stderr += chunk;
|
|
98
|
+
});
|
|
99
|
+
assert.equal(await child.exited, 4);
|
|
100
|
+
assert.equal(stderr, 'hello\n');
|
|
101
|
+
assert.equal(child.kill(), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('kill() still awaits the actual exit of a caller-owned child', async (t) => {
|
|
105
|
+
const directory = await tempDirectory(t, 'bc-caller-kill-174-');
|
|
106
|
+
const marker = path.join(directory, 'flushed');
|
|
107
|
+
const child = startProcess(node, [delayedShutdown, marker], {
|
|
108
|
+
interruption: 'caller',
|
|
109
|
+
killGrace: 1000,
|
|
110
|
+
});
|
|
111
|
+
t.after(() => forceKill(child.pid));
|
|
112
|
+
await new Promise((resolve) => child.stdout.on('data', resolve));
|
|
113
|
+
assert.equal(child.kill(), true);
|
|
114
|
+
assert.equal(await child.exited, 0);
|
|
115
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
|
|
116
|
+
assert.equal(isAlive(child.pid), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('escalates to SIGKILL when a caller-owned child ignores SIGTERM', async (t) => {
|
|
120
|
+
const child = startProcess(
|
|
121
|
+
node,
|
|
122
|
+
[
|
|
123
|
+
'-e',
|
|
124
|
+
'process.on("SIGTERM",()=>{});process.stdout.write("ready");setInterval(()=>{},1000)',
|
|
125
|
+
],
|
|
126
|
+
{ interruption: 'caller', killGrace: 200 }
|
|
127
|
+
);
|
|
128
|
+
t.after(() => forceKill(child.pid));
|
|
129
|
+
await new Promise((resolve) => child.stdout.on('data', resolve));
|
|
130
|
+
child.kill();
|
|
131
|
+
assert.equal(await child.exited, 128 + 9);
|
|
132
|
+
assert.equal(child.signalCode, 'SIGKILL');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('detach() lets the supervisor end while the exact child keeps running', async (t) => {
|
|
136
|
+
const directory = await tempDirectory(t, 'bc-detach-174-');
|
|
137
|
+
const marker = path.join(directory, 'survived');
|
|
138
|
+
const launcher = startProcess(node, [
|
|
139
|
+
repoPath('experiments', 'issue-174', 'detach-launcher.mjs'),
|
|
140
|
+
marker,
|
|
141
|
+
]);
|
|
142
|
+
let output = '';
|
|
143
|
+
launcher.stdout.on('data', (chunk) => {
|
|
144
|
+
output += chunk;
|
|
145
|
+
});
|
|
146
|
+
assert.equal(await launcher.exited, 0, launcher.stderrTail);
|
|
147
|
+
const { pid, detached } = JSON.parse(output);
|
|
148
|
+
t.after(() => forceKill(pid));
|
|
149
|
+
assert.equal(detached, true);
|
|
150
|
+
assert.equal(isAlive(pid), true);
|
|
151
|
+
assert.equal(
|
|
152
|
+
await waitForFileText(marker, 'survived'),
|
|
153
|
+
true,
|
|
154
|
+
'detached child did not outlive its supervisor'
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('detach() is idempotent and a detached child can still be stopped', async () => {
|
|
159
|
+
const child = startProcess(node, ['-e', 'setInterval(()=>{},1000)'], {
|
|
160
|
+
killGrace: 200,
|
|
161
|
+
});
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
163
|
+
assert.equal(child.detach(), true);
|
|
164
|
+
assert.equal(child.detach(), true);
|
|
165
|
+
assert.equal(child.detached, true);
|
|
166
|
+
assert.equal(child.kill(), true);
|
|
167
|
+
assert.equal(await child.exited, 128 + 15);
|
|
168
|
+
assert.equal(child.detach(), false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('interruptionMode', () => {
|
|
173
|
+
it('defaults to managed and rejects unknown modes', () => {
|
|
174
|
+
assert.equal(interruptionMode(), 'managed');
|
|
175
|
+
assert.equal(interruptionMode('caller'), 'caller');
|
|
176
|
+
assert.throws(() => interruptionMode('detached'), TypeError);
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => startProcess(node, [], { interruption: 'none' }),
|
|
179
|
+
/interruption must be one of managed, caller/
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
startProcess,
|
|
10
10
|
} from '../../../src/utilities/subprocess.js';
|
|
11
11
|
import { repoPath } from '../../helpers/repo.js';
|
|
12
|
+
import { waitForFileText } from '../../helpers/temp-directory.js';
|
|
12
13
|
|
|
13
14
|
const node = process.execPath;
|
|
14
15
|
|
|
@@ -173,19 +174,11 @@ describe(
|
|
|
173
174
|
assert.equal(await launcher.exited, 0, launcher.stderrTail);
|
|
174
175
|
assert.ok(pid > 0);
|
|
175
176
|
await assert.rejects(fs.readFile(marker), { code: 'ENOENT' });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (error.code !== 'ENOENT') {
|
|
183
|
-
throw error;
|
|
184
|
-
}
|
|
185
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
assert.fail('detached child did not survive the launcher exit');
|
|
177
|
+
assert.equal(
|
|
178
|
+
await waitForFileText(marker, 'survived'),
|
|
179
|
+
true,
|
|
180
|
+
'detached child did not survive the launcher exit'
|
|
181
|
+
);
|
|
189
182
|
});
|
|
190
183
|
}
|
|
191
184
|
);
|