@bongos/core 1.19.79 → 1.19.80
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/.bongos-core.json +19 -14
- package/.claude/skills/builder-box/SKILL.md +3 -2
- package/modules/hall-ui/public/box-panel.js +30 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +7 -3
- package/scripts/gds/cli-lib.js +28 -0
- package/src/module-api.js +3 -1
- package/tests/cli_exit_no_abort.mjs +135 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.80",
|
|
6
|
+
"core_contract": "1.19.80",
|
|
7
|
+
"source_commit": "e9830526a46cd238c6ea856406a1bcde6fcb6e4f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T23:02:51.567Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 295,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1226,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1542,
|
|
20
|
+
"tree_sha256": "8efe860e490a9f9bca1f3739c652e205e29d50643e86361a1eb631f7474a1ba9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
{
|
|
38
38
|
"path": ".claude/skills/builder-box/SKILL.md",
|
|
39
39
|
"mode": "0000644",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "d8bfc9298bbb136742147e83d2500b9cb0961fec35ca15dc3cb773c9ecdf8b4b"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/builder-claim/SKILL.md",
|
|
@@ -2982,7 +2982,7 @@
|
|
|
2982
2982
|
{
|
|
2983
2983
|
"path": "modules/hall-ui/public/box-panel.js",
|
|
2984
2984
|
"mode": "0000644",
|
|
2985
|
-
"sha256": "
|
|
2985
|
+
"sha256": "3834d4aac8cb7ebc604973e145f396d0893b4cf62abc530f2027c491b31c625d"
|
|
2986
2986
|
},
|
|
2987
2987
|
{
|
|
2988
2988
|
"path": "modules/hall-ui/public/brand-apply.js",
|
|
@@ -4057,12 +4057,12 @@
|
|
|
4057
4057
|
{
|
|
4058
4058
|
"path": "package-lock.json",
|
|
4059
4059
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4060
|
+
"sha256": "b7e3e581321a968a2978affc8766e5bc9ec8f412f0c3e7a94c47916138724de9"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "283e5a12a702be6937db0eb28a69775e3cb6b760ae217b3824d65ba7feb6743b"
|
|
4066
4066
|
},
|
|
4067
4067
|
{
|
|
4068
4068
|
"path": "public-docs/index.html",
|
|
@@ -4467,7 +4467,7 @@
|
|
|
4467
4467
|
{
|
|
4468
4468
|
"path": "scripts/gds/api.js",
|
|
4469
4469
|
"mode": "0000644",
|
|
4470
|
-
"sha256": "
|
|
4470
|
+
"sha256": "8f7332da44883801fb4674747c9e9a60e485353b36df6bebe1db8fc1ebd3558f"
|
|
4471
4471
|
},
|
|
4472
4472
|
{
|
|
4473
4473
|
"path": "scripts/gds/api.ps1",
|
|
@@ -4617,7 +4617,7 @@
|
|
|
4617
4617
|
{
|
|
4618
4618
|
"path": "scripts/gds/cli-lib.js",
|
|
4619
4619
|
"mode": "0000644",
|
|
4620
|
-
"sha256": "
|
|
4620
|
+
"sha256": "e86444334085b00ff4256fcb6bf15e5f443dc75bdcbf8ca6eb87f0b41fcd3230"
|
|
4621
4621
|
},
|
|
4622
4622
|
{
|
|
4623
4623
|
"path": "scripts/gds/code.js",
|
|
@@ -5687,7 +5687,7 @@
|
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-api.js",
|
|
5689
5689
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
5690
|
+
"sha256": "e85465ed2f13494396cf2d74bad0c8a2b6e52d42d48c493a3d0dcf4694f6072e"
|
|
5691
5691
|
},
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -6129,6 +6129,11 @@
|
|
|
6129
6129
|
"mode": "0000644",
|
|
6130
6130
|
"sha256": "b9258f690d5dc7c87bcbb12d510b3071a38669777ae9a92a99e683e76ea15e83"
|
|
6131
6131
|
},
|
|
6132
|
+
{
|
|
6133
|
+
"path": "tests/cli_exit_no_abort.mjs",
|
|
6134
|
+
"mode": "0000644",
|
|
6135
|
+
"sha256": "7bc4d54d29458c70cd545aecac1ca1f7b8cc13d7b1dacaa90a8c2184ef553304"
|
|
6136
|
+
},
|
|
6132
6137
|
{
|
|
6133
6138
|
"path": "tests/cli_lib_formatters.mjs",
|
|
6134
6139
|
"mode": "0000644",
|
|
@@ -7,7 +7,7 @@ You are showing a builder the status of **their own** dev box and helping them g
|
|
|
7
7
|
|
|
8
8
|
## Background
|
|
9
9
|
|
|
10
|
-
The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. The read API:
|
|
10
|
+
The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. **The idle sweep parks; it does not destroy** (task 1002726 — it *did* destroy droplet and snapshot for a long stretch, so a builder who remembers losing work that way is remembering something real that is now fixed). Full reclaim happens only after `BOX_DORMANT_DAYS` (14) parked, and **only after a warning was actually delivered** — an unwarned box is deferred, not reclaimed (task 1002727). The read API:
|
|
11
11
|
- `GET /api/gds/box/me` → the caller's box: `state`, `scope`, `ip`, `hostname`, cost, activity timestamps.
|
|
12
12
|
- `GET /api/gds/box/ssh-key` → the caller's registered SSH keys (fingerprints + labels).
|
|
13
13
|
- `GET /api/gds/box/terminal` → the caller's active box's browser-terminal `url` + basic-auth `credential` (the Chromebook/browser on-ramp, ADR 0038), once the box has published them.
|
|
@@ -26,7 +26,8 @@ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-a
|
|
|
26
26
|
- **state = `none`** → they have no box yet. On a Mac/Windows: run `/builder-connect` (registers their SSH key + installs the Desktop file). To actually create the box hands-off, `POST /api/gds/box/ensure` queues the auto-provision (no operator CLI needed). *(The old path — ask an operator to `box.js onboard <login>` — still works but is no longer required.)*
|
|
27
27
|
- **state = `active`** → box is up at `hostname` (`ip`). They can connect from Claude Desktop → Code → Environment → "Example Dev Box". For a browser/Chromebook (ADR 0038), the terminal comes from the web: `GET /api/gds/box/terminal` returns a `url` + a basic-auth `credential` (username `otb`, password = <redacted>; open the url, enter them, then type `claude` in the terminal. No SSH needed.
|
|
28
28
|
- **Live game preview — the sandbox (ADR 0044).** If `/box/terminal` returns a `url` (i.e. the box is on the named tunnel), the builder also has a private live preview of the game running on their own box, at the **same hostname with `term-` swapped for `sandbox-`** — e.g. terminal `https://term-<login>.example.com` → preview `https://sandbox-<login>.example.com`. It serves their working copy of the game (degraded — fully playable, no saved state), so they can review changes in a browser **without shipping to prod**. The loop: edit in the terminal → run **`/builder-stage`** (or `box-preview restart`) → refresh the preview tab. Staging via `/builder-stage` also records the review marker that `/builder-ship`'s sandbox-first gate checks (#927, ADR 0046) — on a dev box, game changes are staged + reviewed on the sandbox before they ship to the main game. `box-preview status` shows whether it's up + the URL; `box-preview logs` tails the server. (No preview on a quick-tunnel box — that path can't carry the game port; it needs the named tunnel.)
|
|
29
|
-
- **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed).
|
|
29
|
+
- **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed). Their data IS safe in the snapshot — say so plainly, and mean it: the idle path preserves the filesystem, and the only thing that deletes the snapshot warns them first with days of lead time.
|
|
30
|
+
- **state = `destroyed`** → the box is genuinely gone, and so is anything uncommitted on it. This is NOT the idle path — it means they closed it themselves, an operator deprovisioned it, or it sat parked past the dormancy window after being warned. `POST /box/ensure` gives them a **fresh** box, not their old one; don't imply otherwise.
|
|
30
31
|
- **state = `provisioning` / `waking`** → in flight; check back in a minute.
|
|
31
32
|
- **state = `error`** → flag it to an operator (don't try to fix infra from here).
|
|
32
33
|
- **SSH keys** → list the fingerprints + labels. If empty, they haven't connected yet → `/builder-connect`. They can remove a stale key with `DELETE /api/gds/box/ssh-key/:id` (own-scoped).
|
|
@@ -299,23 +299,43 @@
|
|
|
299
299
|
schedulePoll(5000);
|
|
300
300
|
return;
|
|
301
301
|
}
|
|
302
|
-
// No open intent and not active/provisioning/waking.
|
|
303
|
-
// •
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
302
|
+
// No open intent and not active/provisioning/waking. Three cases:
|
|
303
|
+
// • parked → asleep after the idle sweep, filesystem preserved (task 1002726).
|
|
304
|
+
// Reachable again as of that task; offer a wake, and say the files are safe.
|
|
305
|
+
// • destroyed → the builder HAD a box that is now gone — they closed it, an
|
|
306
|
+
// operator deprovisioned it, or it passed the dormancy window after being
|
|
307
|
+
// warned. Either way the tunnel + DNS are torn down, so opening the old
|
|
308
|
+
// terminal/sandbox URL fails with a raw browser "can't find the server" and
|
|
309
|
+
// no explanation (idea #295). Don't leave them staring at a bare button.
|
|
308
310
|
// • none → never had a box (first run). Keep the clean first-run button.
|
|
309
311
|
closeTermWindow();
|
|
310
312
|
stopPoll();
|
|
311
313
|
enableOpenBtn(null);
|
|
314
|
+
// task 1002728 — PARKED is a real, reachable state again since task 1002726
|
|
315
|
+
// made the idle sweep park instead of destroy. Before that it could never
|
|
316
|
+
// happen, so there was no branch for it and a parked box fell through to
|
|
317
|
+
// the first-run "Request dev box" screen — which reads as "your box is
|
|
318
|
+
// gone" at the exact moment the box and its files are safe.
|
|
319
|
+
if (state === 'parked') {
|
|
320
|
+
setRequestBtn(true, 'Wake my box');
|
|
321
|
+
setOut('<p class="setup__box-msg">Your dev box is asleep — wake it below.</p>'
|
|
322
|
+
+ '<p class="setup__box-close-hint">It was put to sleep after a couple of hours idle so it stops '
|
|
323
|
+
+ 'costing money. <strong>Your files are kept</strong>, including anything you had not committed — '
|
|
324
|
+
+ 'waking it brings the box back as you left it, in about a minute. A sleeping box has no terminal '
|
|
325
|
+
+ 'or live-preview URL, so an old link will fail to load until it is awake.</p>'
|
|
326
|
+
+ '<p class="setup__box-close-hint">If it stays asleep for a couple of weeks we clean it up to save '
|
|
327
|
+
+ 'cost, but you will get a warning first with several days to wake it.</p>');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
312
330
|
if (state === 'destroyed') {
|
|
313
331
|
setRequestBtn(true, 'Turn box back on');
|
|
314
332
|
setOut('<p class="setup__box-msg">Your dev box is stopped — turn it back on below.</p>'
|
|
315
|
-
+ '<p class="setup__box-close-hint">
|
|
316
|
-
+ '
|
|
317
|
-
+ 'link will fail to load. Turning it back on takes about a minute
|
|
318
|
-
+ '<strong>
|
|
333
|
+
+ '<p class="setup__box-close-hint">This happens when you close a box yourself, or after it has been '
|
|
334
|
+
+ 'asleep long enough to be cleaned up (you are warned first). A stopped box has no terminal or '
|
|
335
|
+
+ 'live-preview URL, so an old link will fail to load. Turning it back on takes about a minute, and '
|
|
336
|
+
+ 'gives you a <strong>fresh</strong> box — <strong>anything not committed or shipped on the old one '
|
|
337
|
+
+ 'is gone</strong>. (Going idle no longer does this: an idle box is put to sleep with your files '
|
|
338
|
+
+ 'intact.)</p>'
|
|
319
339
|
+ '<p class="setup__box-close-hint">The box you turn on has a <strong>new identity</strong>. If Claude '
|
|
320
340
|
+ 'Desktop says <strong>“Host denied”</strong> or asks for a password when reconnecting, fully quit '
|
|
321
341
|
+ 'and reopen Desktop (it re-pins your box automatically) or press <strong>Repair</strong> — do not keep turning '
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.80",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.80",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.80",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/api.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
|
-
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit } = require('./cli-lib');
|
|
21
|
+
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
(async () => {
|
|
@@ -62,7 +62,7 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
62
62
|
res = await api.request(method, relPath, body != null ? { body } : {});
|
|
63
63
|
} catch (e) {
|
|
64
64
|
console.error(`api: request failed: ${e.message}`);
|
|
65
|
-
process.exit(
|
|
65
|
+
return cliExit(1); // task 1002734: never process.exit() after a request
|
|
66
66
|
}
|
|
67
67
|
if (!quiet) console.error(`${method} ${urlPath} → ${res.status}`);
|
|
68
68
|
// task 1501: on a dev box the baked session is box-scoped (ADR 0053) and every
|
|
@@ -79,5 +79,9 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
79
79
|
if (res.data !== undefined && res.data !== null) {
|
|
80
80
|
console.log(JSON.stringify(res.data, null, 2));
|
|
81
81
|
}
|
|
82
|
-
process.exit(
|
|
82
|
+
// task 1002734: process.exit() here aborted node 5/5 on Windows —
|
|
83
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
|
|
84
|
+
// — AFTER the JSON had already printed, so every caller that checks the exit code
|
|
85
|
+
// read a successful command as a failure. Set the code and let the loop drain.
|
|
86
|
+
cliExit(res.ok ? 0 : 1);
|
|
83
87
|
})();
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -115,6 +115,20 @@ function hallBase(base = API_BASE) {
|
|
|
115
115
|
// buys these one-shot processes nothing, so route requests through a dispatcher
|
|
116
116
|
// with keep-alive effectively disabled (mirrors cf-tunnel.js's dispatcher
|
|
117
117
|
// pattern). Degrades to the default dispatcher if undici can't be required.
|
|
118
|
+
//
|
|
119
|
+
// task 1002734 — THIS ALONE IS NOT ENOUGH, and the measurements say so: with the
|
|
120
|
+
// dispatcher wired in and undici resolving fine, `node scripts/gds/api.js GET
|
|
121
|
+
// /tasks/<id>` still aborted 5/5 on Windows 11 / Node 24. The dispatcher removes
|
|
122
|
+
// one contributing handle; the actual trigger is calling process.exit() at all
|
|
123
|
+
// while the request machinery is still tearing down. A minimal synthetic
|
|
124
|
+
// fetch-then-exit does NOT reproduce, so it takes the real stack (the dynamic ESM
|
|
125
|
+
// client import + the Agent + any child spawn / consumed stdin) to line up — which
|
|
126
|
+
// is exactly why hunting the single guilty handle is a dead end.
|
|
127
|
+
//
|
|
128
|
+
// The rule for every CLI here: after a request, DO NOT call process.exit().
|
|
129
|
+
// Set process.exitCode and return, and let the loop drain — see cliExit below.
|
|
130
|
+
// Verified: the pre-push gate went 8/8 aborting to 0/8 on that change alone
|
|
131
|
+
// (task 1002725), and api.js 5/5 to 0/5 (this task).
|
|
118
132
|
let _noKeepAliveDispatcher;
|
|
119
133
|
let _noKeepAliveTried = false;
|
|
120
134
|
function noKeepAliveDispatcher() {
|
|
@@ -127,6 +141,19 @@ function noKeepAliveDispatcher() {
|
|
|
127
141
|
return _noKeepAliveDispatcher;
|
|
128
142
|
}
|
|
129
143
|
|
|
144
|
+
// cliExit(code) — the post-request replacement for process.exit(code). Records the
|
|
145
|
+
// exit status and lets node exit naturally once the loop drains, which is what
|
|
146
|
+
// keeps the UV_HANDLE_CLOSING abort above from firing. Callers must `return` after
|
|
147
|
+
// calling it: unlike process.exit() it does NOT stop execution.
|
|
148
|
+
//
|
|
149
|
+
// Gotcha (learned in task 1002725): a hard exit MASKS leaked timers/handles. Once a
|
|
150
|
+
// CLI exits naturally, anything still armed — an uncleared setTimeout, an open
|
|
151
|
+
// interval — keeps the process alive. Clear them, or the CLI hangs instead of
|
|
152
|
+
// crashing.
|
|
153
|
+
function cliExit(code = 0) {
|
|
154
|
+
process.exitCode = code;
|
|
155
|
+
}
|
|
156
|
+
|
|
130
157
|
async function readSessionFile(p) {
|
|
131
158
|
try {
|
|
132
159
|
const raw = await fsp.readFile(p, 'utf8');
|
|
@@ -760,6 +787,7 @@ module.exports = {
|
|
|
760
787
|
resolveQuiet,
|
|
761
788
|
readSessionToken,
|
|
762
789
|
apiCall,
|
|
790
|
+
cliExit,
|
|
763
791
|
cliClient,
|
|
764
792
|
requireSession,
|
|
765
793
|
printUnauthGuidanceAndExit,
|
package/src/module-api.js
CHANGED
|
@@ -585,8 +585,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
585
585
|
// landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
|
|
586
586
|
// 1.19.79 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
587
587
|
// landed since 1.19.78 with no explicit bump. run 31648954641. (task 1002620)
|
|
588
|
+
// 1.19.80 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
589
|
+
// landed since 1.19.79 with no explicit bump. run 31649366675. (task 1002620)
|
|
588
590
|
// ---------------------------------------------------------------------------
|
|
589
|
-
const CORE_VERSION = '1.19.
|
|
591
|
+
const CORE_VERSION = '1.19.80'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
590
592
|
|
|
591
593
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
592
594
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/cli_exit_no_abort.mjs — the GDS CLIs must not abort natively on exit
|
|
2
|
+
// (task 1002734; the pre-push twin of this is tests/pre_push_hook.mjs, task 1002725).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. On Windows + Node 24, calling process.exit() after the CLI has made a
|
|
5
|
+
// request aborts the process before it can exit cleanly:
|
|
6
|
+
//
|
|
7
|
+
// Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
|
|
8
|
+
//
|
|
9
|
+
// `node scripts/gds/api.js GET /tasks/<id>` reproduced this 5/5: the JSON printed
|
|
10
|
+
// correctly and THEN the process died with exit 127, so every caller that checks an
|
|
11
|
+
// exit code — CI, shell scripts, the ship pipeline — read a successful command as a
|
|
12
|
+
// failure. Setting process.exitCode and letting the loop drain took it to 0/6.
|
|
13
|
+
//
|
|
14
|
+
// A no-keep-alive undici dispatcher was already wired into cli-lib for this exact
|
|
15
|
+
// assertion and was NOT sufficient on its own; only dropping the hard exit fixed it.
|
|
16
|
+
//
|
|
17
|
+
// NETWORK-FREE, but not server-free. The failure cases point the CLI at a dead
|
|
18
|
+
// localhost port; the success case runs a throwaway stub server in its own process.
|
|
19
|
+
// Both matter: the crash is in the TEARDOWN, and a 200 tears down more machinery than
|
|
20
|
+
// a refused connection — the wild repro was a successful call.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/cli_exit_no_abort.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const API = path.join(ROOT, 'scripts', 'gds', 'api.js');
|
|
33
|
+
const CLI_LIB = path.join(ROOT, 'scripts', 'gds', 'cli-lib.js');
|
|
34
|
+
const DEAD_BASE = 'http://127.0.0.1:9'; // discard port — connection refused, instantly
|
|
35
|
+
|
|
36
|
+
// The native abort prints this and nothing else can be relied on; it is NOT a
|
|
37
|
+
// JS-level error, so it never appears as a caught exception.
|
|
38
|
+
const ABORT_RE = /Assertion failed|UV_HANDLE_CLOSING/i;
|
|
39
|
+
|
|
40
|
+
function runApi(args, env = {}) {
|
|
41
|
+
return spawnSync(process.execPath, [API, ...args], {
|
|
42
|
+
cwd: ROOT,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
env: { ...process.env, GDS_API_BASE: DEAD_BASE, ...env },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('api.js does not abort natively when a request fails', () => {
|
|
49
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet']);
|
|
50
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `native abort on the request-failure path:\n${r.stderr}`);
|
|
51
|
+
assert.equal(r.status, 1, `a failed request must exit 1, not ${r.status} (127 = the abort)`);
|
|
52
|
+
assert.match(r.stderr, /request failed/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('api.js reports a usage error without aborting (pre-request path)', () => {
|
|
56
|
+
const r = runApi(['GET']); // missing path
|
|
57
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE);
|
|
58
|
+
assert.equal(r.status, 2, 'bad invocation is exit 2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('api.js exit code survives repeated runs (the abort was intermittent-looking)', () => {
|
|
62
|
+
// The synthetic minimal case (bare fetch + process.exit) does NOT reproduce, so a
|
|
63
|
+
// single green run proves little — the real stack has to line up. Run it enough
|
|
64
|
+
// times that a reintroduced hard exit shows up rather than slipping through.
|
|
65
|
+
for (let i = 0; i < 5; i++) {
|
|
66
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet']);
|
|
67
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively`);
|
|
68
|
+
assert.equal(r.status, 1, `run ${i + 1} exited ${r.status}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The SUCCESS teardown is the path that actually aborted in the wild: api.js printed
|
|
73
|
+
// its JSON and then died at exit 127. A failed request tears down less machinery, so
|
|
74
|
+
// the failure cases above cannot cover this. Serve a real 200 — still no external
|
|
75
|
+
// network — and assert the clean exit 0.
|
|
76
|
+
//
|
|
77
|
+
// The stub MUST run in its own process. An in-process http.createServer here would
|
|
78
|
+
// deadlock: spawnSync blocks this process's event loop, so the server could never
|
|
79
|
+
// accept the child's connection and the test would hang forever.
|
|
80
|
+
const STUB_SRC = `
|
|
81
|
+
const http = require('node:http');
|
|
82
|
+
const s = http.createServer((q, r) => {
|
|
83
|
+
r.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
|
+
r.end(JSON.stringify({ ok: true, builder: { id: 1, rank: 'archon' } }));
|
|
85
|
+
});
|
|
86
|
+
s.listen(0, '127.0.0.1', () => console.log('PORT=' + s.address().port));
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
function startStub() {
|
|
90
|
+
const proc = spawn(process.execPath, ['-e', STUB_SRC], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let buf = '';
|
|
93
|
+
const timer = setTimeout(() => { proc.kill(); reject(new Error('stub server did not report a port')); }, 10_000);
|
|
94
|
+
proc.stdout.on('data', (d) => {
|
|
95
|
+
buf += d.toString();
|
|
96
|
+
const m = buf.match(/PORT=(\d+)/);
|
|
97
|
+
if (m) { clearTimeout(timer); resolve({ proc, port: Number(m[1]) }); }
|
|
98
|
+
});
|
|
99
|
+
proc.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('api.js exits 0 after a SUCCESSFUL request — the path that aborted in the wild', async () => {
|
|
104
|
+
const { proc, port } = await startStub();
|
|
105
|
+
try {
|
|
106
|
+
for (let i = 0; i < 3; i++) {
|
|
107
|
+
const r = runApi(['GET', '/api/gds/me', '--quiet'], { GDS_API_BASE: `http://127.0.0.1:${port}` });
|
|
108
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
|
|
109
|
+
assert.equal(r.status, 0, `a successful request must exit 0, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
|
|
110
|
+
assert.match(r.stdout, /"ok"/, 'the JSON body must still reach stdout');
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
proc.kill();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('cli-lib exports cliExit — the documented replacement for a post-request exit', async () => {
|
|
118
|
+
const mod = await import(`file://${CLI_LIB.split(path.sep).join('/')}`);
|
|
119
|
+
const cliExit = mod.default?.cliExit ?? mod.cliExit;
|
|
120
|
+
assert.equal(typeof cliExit, 'function', 'cliExit must be exported for the CLI sweep to adopt');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('api.js routes its post-request exits through cliExit, not process.exit', () => {
|
|
124
|
+
const src = fs.readFileSync(API, 'utf8');
|
|
125
|
+
// The two surviving process.exit() calls are the pre-request guards (usage + body
|
|
126
|
+
// parse), which run before any network work and are safe. Everything after the
|
|
127
|
+
// request must use cliExit.
|
|
128
|
+
const afterRequest = src.slice(src.indexOf('let res;'));
|
|
129
|
+
const stripped = afterRequest.replace(/\/\/[^\n]*/g, '');
|
|
130
|
+
assert.ok(
|
|
131
|
+
!/process\.exit\s*\(/.test(stripped),
|
|
132
|
+
'no process.exit() may remain after the request is issued — that is what aborts node on Windows'
|
|
133
|
+
);
|
|
134
|
+
assert.match(afterRequest, /cliExit\(/, 'the post-request paths must set the exit code via cliExit');
|
|
135
|
+
});
|