Repository navigation
test(cli): runServe spawns the source entry under node, not the tsx binary, and settles once its child is gone - #28666
Merged
Conversation
…the tsx binary The tsx binary is a launcher process that spawns a second node to run the entry. Spawning the entry with node directly keeps the same entry, argv, cwd, env, stdio handling, waitFor matching and timeouts, and drops the extra process start per runServe() boot. The TSX export stays for its other users. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mQCvMVzNjNr4dStTS
…r 1 s The tsx binary's signal relay SIGKILLed a child that had not acknowledged a relayed SIGTERM within about 30 ms. With the child now spawned by node directly, a child that outlives its SIGTERM stayed running after its file ended, and no timer inside the vitest worker can fire for a file's last boot. finish() now sends SIGTERM, waits for the child's exit for at most 1 s, sends SIGKILL if it is still running, and settles after that. The output a caller reads is snapshotted at the match, as before. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mQCvMVzNjNr4dStTS
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contributor
Author
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Part of #23181. One file, packages/cli/test/helpers/serve-process.ts (+64 / −4), label skip-changeset. Clause-②: no, Metadata-protocol: none. |
| Diff check (seat) | runServe spawns process.execPath with [CLI, 'serve', …]; argv, cwd and the childEnv({...}) block are unchanged. finish() still sends SIGTERM first, snapshots stdout/stderr at the match, then waits on childGone(): it resolves at once when there is no pid or the child has already exited, and otherwise arms a SIGKILL_GRACE_MS = 1_000 fallback that the child's exit clears. The promise settles only after that, which is A2 exactly. TSX stays exported for its other importers, no caller file changes, and the prose that named the tsx wrapper for this spawn is corrected. No caller-visible deadline changes. |
| Evidence | CI green on head 23e32949 (35 success, 18 skipped, 0 failed; the required Test Core, TypeScript Type Check and Lint & Repo Gates contexts are success in the latest reading). Governed check: NOT governed, 68 changed lines. The report gives the deterministic probe at 0 of 3 survivors with A2 and 3 of 3 with the switch alone, all 20 listed callers passing on the merged head, 41 of 42 derived gates run and exit 0 with 1 NOT MEASURED (check:dual-build-cjs-loads, which needs a full build), and the out-of-scope os serve SIGTERM hang recorded as not reproduced (5 of 5 plain-node runs exit 1 within about 80 ms). The ruling accepts the measured seven-caller wall: 231.1 s against 345.9 s on base. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip. |
Generated by Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Leehom · project thread
Part of #23181 (slice 35, the tsx wrapper lever inside the shared
runServe()helper, with seat ruling 6556896224 as amended by 6557574123). The card stays open for later slices.Clause-②: no
Metadata-protocol: none
维护者速读
packages/cli/test/helpers/serve-process.ts里的共享测试辅助函数runServe()起os serve子进程时,不再经node_modules/.bin/tsx包一层,改为用当前node(process.execPath)直接跑bin/run-dev.js。入口、参数、cwd、childEnv({...})环境、stdio 处理、waitFor匹配与超时都不变;TSX导出保留(别的文件还在用)。另外,停子进程时先发 SIGTERM,最多等 1 秒,子进程还在就 SIGKILL,确认子进程已退出后才返回结果(按席位裁决 A2)。只改这一个文件,调用方一个没动。test/helpers/**,这是剩下的那一处。os serve子进程在测试结束后残留(约 900 MB);本 PR 用上面的 1 秒兜底补回,确定性探针证明残留归零。只改测试辅助文件,不发布任何东西(cli 的files[]只有 dist、README、CHANGELOG);没有改任何断言、用例、超时、分层或// test-budget:头;回滚即 revert。packages/cli/test/helpers/serve-process.ts的runServe():spawn(process.execPath, [CLI, 'serve', …])那一行,finish()/childGone()/settle()三段,以及SIGKILL_GRACE_MS的说明。cd packages/cli && OS_TEST_TIERS=nightly pnpm exec vitest run test/serve-organizations-mount-failure.e2e.test.ts→ 4/4 通过,墙钟约 30 s(改前约 48 s)。What changes
runServe()inpackages/cli/test/helpers/serve-process.tsspawnedTSX, [CLI, 'serve', …]. It now spawnsprocess.execPath, [CLI, 'serve', …], slice 12's way. Nothing else in the call changes: the argv (including the--port 0default),cwd, thechildEnv({...})block, the stdout/stderr collection, thewaitFormatching, the port read-back and the timeout are byte-identical.TSXexport stays:oclif-manifest.global-setup.tsand other test files import it.CLIdocblock, the "other entrypoint" header (two lines) and a new paragraph in therunServe()docblock stating why the child is node and not the tsx binary.finish()now sends SIGTERM, waits for the child'sexitfor at mostSIGKILL_GRACE_MS(1 s), sends SIGKILL if it is still running, and only then settles.stdoutandstderrare snapshotted at the match, so a caller reads the same bytes as before. A child whose spawn failed (no pid) settles at once. No deadline a caller reads changes.exithook never fires for a file's last boot. Measured on the probe below: 1 of 1, 2 of 2 and 3 of 3 survivors.src/:bin/run-dev.jssetsNODE_ENV=development, under which @oclif/core registers tsx in-process for the command modules, as slices 12, 29 and 34 rely on.Left on tsx, deliberately:
test/helpers/oclif-manifest.global-setup.ts.tsfile through the tsx binary (execFile(TSX, [THIS_FILE, …])), and that file imports./serve-process.js, a.jsspecifier onto a.tsfile, which plain node cannot resolve. It is also outside this slice's file surface.serve-process.tsnode -eport-draw probe, alreadyprocess.execPath.Callers, re-derived on
0e78922e. The order's list (pm-orders/server/evidence/23181-s35/callers.txt) is a text grep: 20 files, 61 mentions. Counting only files that importrunServefrom the helper and call it in code (comments masked), the real population is 10 files, 22 call sites:serve-app-runtime-hooks.e2e(1) ·serve-boot-diagnostics-noise-budget.e2e(1) ·serve-boot-diagnostics.e2e(2) ·serve-cluster-driver-diagnosis.e2e(1) ·serve-no-artifact.e2e(5) ·serve-organizations-host-resolution.e2e(3) ·serve-organizations-mount-failure.e2e(4) ·serve-package-declared-capabilities(1) ·serve-package-registry-always-on.e2e(1) ·serve-port-readback.e2e(3)The other 10 listed files name
runServein prose, or define a local function of the same name (artifact-pinned-boot.e2e,uncreatable-directory-doors.integration), or hold it as census vocabulary (released-port-production-spawn.pin). All 20 import the helper module, so all 20 were run.No caller asserts on the child's process title,
argv[0], a wrapper process or signal relay through tsx, and all pass unchanged. The relay mattered to the helper itself, not to a caller: see Stopping the child.Not changed: no caller file, no assertion, expected output, case name, timeout, tier, project,
// test-budget:header, other helper or source file.Tests
Every listed file passes on the final head
23e32949(after mergingorigin/mainonce, which brought in slices 29, 30 and 34). Queue tier: 6/6 files, 41/41 tests. Nightly tier (OS_TEST_TIERS=nightly, where the.e2efiles run): 14/14 files, 69/69 tests. Both exit 0, no skips. Same counts on95062773and3bb649e4.Leak red/green (the deterministic probe, seat amendment 6557574123). A temporary test file, never committed, whose only case is
runServeon a config that installs a SIGTERM listener and never finishes loading. Read withpgrep+ the child's cwd 3 s after vitest exits. With A2 (3bb649e4): 0 survivors, 3 of 3 runs. The switch alone (95062773bytes written into the same path, blob-hash checked, restored fromHEAD): 1 survivor in each of 3 of 3 runs. Probe and rows:pm-orders/server/evidence/23181-s35/sigterm-probe.test.ts.txt,sig-probe.sh,redgreen.sh,sig-rows.txt.serve-package-declared-capabilities' refused-boot case (tiers): on the switch alone it left itsos serverunning after the file, about 900 MB each, 4 of 4 runs between 09:10Z and 09:41Z, and 0 of 3 on base. It then read 0 of 4 on the same bytes between 11:36Z and 11:40Z, so it is intermittent and is not the proof.A/B (one box, one lock hold, N=3,
--maxWorkers=1, one vitest invocation per leg, base first in rounds 1 and 3, head first in round 2). For each base leg the base bytes ofserve-process.tswere written into the same path and restored fromHEADafter it, checked by blob hash; tree restored,git diff HEADempty..e2efiles ran withOS_TEST_TIERS=nightly,serve-package-declared-capabilitieswithqueue(the oclif test manifest on, as in CI). Whole process wall per run, seconds; every leg exit 0 with the same test count on both sides:serve-package-declared-capabilities(queue)serve-no-artifact.e2eserve-organizations-mount-failure.e2eserve-port-readback.e2eserve-boot-diagnostics.e2eserve-cluster-driver-diagnosis.e2eserve-app-runtime-hooks.e2eRows and script:
pm-orders/server/evidence/23181-s35/ab-rows.txt,ab.sh(project files). This A/B is the switch alone (95062773). With A2 the same seven files read 231.1 s (N=1), so −33% against base: the price of waiting for each child to exit.cli integration tier wall (
OS_TEST_TIERS=nightly,--project integration,--maxWorkers=2, N=1 each side, one lock hold, the switch alone): base 3126.6 s → head 2972.2 s (−4.9%), 85/85 files and 887/887 tests both sides. Only 9 of its 85 files callrunServe. The queue integration tier holds one caller, so its whole-tier wall was not measured: one file's −22.6 s is inside N=1 noise there.pnpm --filter @objectstack/cli typecheckexit 0 on the final head (withNODE_OPTIONS=--max-old-space-size=4096; the default heap OOMs incheck:test-typecheck, unrelated to the diff).Ablation: none. Test-only; no tested behaviour changed.
Acceptance notes
skip-changeset. Test-only.@objectstack/cli'sfiles[]isdist,README.md,CHANGELOG.md;test/helpers/does not build intodist/, so nofiles[]content changes.os serveignoring SIGTERM outside the test. A refusedos serveboot (thetiersfixture) under plain node, given SIGTERM 0, 20, 40 or 60 ms after its fatal line or none at all, exited with code 1 within about 80 ms, 5 of 5 (sigterm-probe.mjs,sigterm-delay.mjsin the evidence folder). The orphans from the intermittent reading sat with their main thread infutex_do_wait, a SIGTERM listener installed (SigCgt) and an esbuild service child, so no JS was running to act on the signal. No card filed, because it does not reproduce through the public door..e2efiles, which run only in the nightly battery, not in PR or merge-queue CI. So this PR's own Test Core shards carry one caller (serve-package-declared-capabilities); the rest of the saving shows in the hourly/nightly runs.Verification
Derived gates on the final head 23e3294
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 42 commands. Each ran with its exit code captured before any pipe: 41 exit 0.pnpm check:dual-build-cjs-loadsexit 3 (prerequisite not met: it needs a fullpnpm build, and six packages outside the cli closure have nodist/): NOT MEASURED locally, CI runs it.dispatch-gates --ran: "42 derived famil(ies) accounted for — 41 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". Record:pm-orders/server/evidence/23181-s35/gates-ran-23e32949.txt.Also, on the final head:
eslint --no-inline-config packages/cli/test/helpers/serve-process.tsexit 0 (@objectstack/clihas no lint script of its own);check-commit-card-trailers --range origin/main..HEADexit 0;check-nul-bytesexit 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5mQCvMVzNjNr4dStTS