Skip to content

test(cli): runServe spawns the source entry under node, not the tsx binary, and settles once its child is gone - #28666

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-6gvqbs
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-6gvqbs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 budget (domain:cli): 119 test file(s) over 10 s of wall in the hourly full battery (Part of #23151) #23181 的测试提速职责。tsx 可执行文件本身是一个启动进程,再起第二个 node 去跑入口,每次启动白付几秒。slice 29、34 已对测试文件自己的子进程做了同样替换,但都不许碰 test/helpers/**,这是剩下的那一处。
  • 风险与回滚:tsx 以前会在转发 SIGTERM 约 30 ms 后 SIGKILL 子进程,换成 node 后没有这层,曾有被拒绝启动的 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() in packages/cli/test/helpers/serve-process.ts spawned TSX, [CLI, 'serve', …]. It now spawns process.execPath, [CLI, 'serve', …], slice 12's way. Nothing else in the call changes: the argv (including the --port 0 default), cwd, the childEnv({...}) block, the stdout/stderr collection, the waitFor matching, the port read-back and the timeout are byte-identical.

  • The TSX export stays: oclif-manifest.global-setup.ts and other test files import it.
  • The helper's own prose that said this spawn goes through tsx now says what it does: the CLI docblock, the "other entrypoint" header (two lines) and a new paragraph in the runServe() docblock stating why the child is node and not the tsx binary.
  • Stopping the child (seat ruling A2). The tsx binary's signal relay SIGKILLed a child that had not acknowledged a relayed SIGTERM within about 30 ms, and that is what kept the docblock's "never leaves the child running" true. A node child has no relay. finish() now sends SIGTERM, waits for the child's exit for at most SIGKILL_GRACE_MS (1 s), sends SIGKILL if it is still running, and only then settles. stdout and stderr are 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.
    • Why not a timer: the vitest forks worker is torn down soon after its last case, so a timer inside it (unref'd or ref'd) or a process exit hook never fires for a file's last boot. Measured on the probe below: 1 of 1, 2 of 2 and 3 of 3 survivors.
  • Why plain node still runs src/: bin/run-dev.js sets NODE_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:

spawn why
test/helpers/oclif-manifest.global-setup.ts runs its own .ts file through the tsx binary (execFile(TSX, [THIS_FILE, …])), and that file imports ./serve-process.js, a .js specifier onto a .ts file, which plain node cannot resolve. It is also outside this slice's file surface.
any other spawn in serve-process.ts there is none: the helper's only other child is the node -e port-draw probe, already process.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 import runServe from 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 runServe in 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 merging origin/main once, which brought in slices 29, 30 and 34). Queue tier: 6/6 files, 41/41 tests. Nightly tier (OS_TEST_TIERS=nightly, where the .e2e files run): 14/14 files, 69/69 tests. Both exit 0, no skips. Same counts on 95062773 and 3bb649e4.

  • Leak red/green (the deterministic probe, seat amendment 6557574123). A temporary test file, never committed, whose only case is runServe on a config that installs a SIGTERM listener and never finishes loading. Read with pgrep + the child's cwd 3 s after vitest exits. With A2 (3bb649e4): 0 survivors, 3 of 3 runs. The switch alone (95062773 bytes written into the same path, blob-hash checked, restored from HEAD): 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.

    • The first reading was serve-package-declared-capabilities' refused-boot case (tiers): on the switch alone it left its os serve running 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 of serve-process.ts were written into the same path and restored from HEAD after it, checked by blob hash; tree restored, git diff HEAD empty. .e2e files ran with OS_TEST_TIERS=nightly, serve-package-declared-capabilities with queue (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:

    file base head mean change
    serve-package-declared-capabilities (queue) 67.0, 72.2, 69.0 45.6, 47.0, 47.7 69.4 → 46.8 −32.6%
    serve-no-artifact.e2e 63.2, 69.0, 72.1 39.3, 41.5, 41.2 68.1 → 40.7 −40.3%
    serve-organizations-mount-failure.e2e 47.8, 47.0, 48.8 27.9, 27.9, 28.3 47.9 → 28.0 −41.4%
    serve-port-readback.e2e 29.1, 30.3, 30.3 18.6, 20.6, 20.7 29.9 → 20.0 −33.2%
    serve-boot-diagnostics.e2e 28.2, 29.0, 32.3 18.3, 18.8, 20.1 29.8 → 19.1 −36.1%
    serve-cluster-driver-diagnosis.e2e 63.5, 65.8, 70.4 37.9, 37.4, 40.7 66.6 → 38.7 −41.9%
    serve-app-runtime-hooks.e2e 33.3, 32.6, 36.7 26.1, 27.2, 28.9 34.2 → 27.4 −19.9%
    sum of means 345.9 → 220.6 −36.2%

    Rows 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 call runServe. 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 typecheck exit 0 on the final head (with NODE_OPTIONS=--max-old-space-size=4096; the default heap OOMs in check:test-typecheck, unrelated to the diff).

  • Ablation: none. Test-only; no tested behaviour changed.

Acceptance notes

  • skip-changeset. Test-only. @objectstack/cli's files[] is dist, README.md, CHANGELOG.md; test/helpers/ does not build into dist/, so no files[] content changes.
  • Not reproduced: os serve ignoring SIGTERM outside the test. A refused os serve boot (the tiers fixture) 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.mjs in the evidence folder). The orphans from the intermittent reading sat with their main thread in futex_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.
  • Where the saving lands. 9 of the 10 real callers are .e2e files, 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-loads exit 3 (prerequisite not met: it needs a full pnpm build, and six packages outside the cli closure have no dist/): 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.ts exit 0 (@objectstack/cli has no lint script of its own); check-commit-card-trailers --range origin/main..HEAD exit 0; check-nul-bytes exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5mQCvMVzNjNr4dStTS

…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
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 1, 2026
@github-actions github-actions Bot added the size/s label Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json 9da2f4b095e05fb27748742d45388fb7042771cd → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-01T12:50Z

Head 23e32949, report 6557990604 (on #23181), claim 6555841491 (slice 35), seat ruling 6556896224 as amended by 6557574123 (A2). No contract review is owed: test-only, no packages/spec/** path, no governed path, no narrowing and no Clause-②: yes.

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 12:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 2678600 Nov 1, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-6gvqbs branch November 1, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant