Skip to content

🐛 Settle exec on the command's exit, not its pipes' close - #344

Open
taras wants to merge 2 commits into
mainfrom
fix/issue-343-bun-exec-suite
Open

🐛 Settle exec on the command's exit, not its pipes' close#344
taras wants to merge 2 commits into
mainfrom
fix/issue-343-bun-exec-suite

Conversation

@taras

@taras taras commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Why

Fixes #343. The Bun 1.3.14 runtime gate could time out packages/core/tests/execute.test.ts D3b only inside the complete 168-file suite: exec(bash) timed out after 30000ms, no partial stdout, while the same test passes focused in 59 ms. The issue forbids raising the timeout or waiving the gate without locating the lifetime problem.

What changes

The Process API's default exec measured its 30s deadline against @effectionx/process join(), which resolves only after the child's exit and stdio EOF. Anything that holds a pipe open past the deadline — a pipe fd inherited by a straggling grandchild, or stream delivery lagging under a loaded host — turns a command that finished within its budget into a timeout and discards the output it already produced. The full suite supplies exactly that pressure (concurrent daemons, workers, CLI churn); a focused run never does.

Before:

  • exec({command: ["bash", "-c", "(sleep 30 &); echo partial; exit 1"], timeout: 1000}) throws exec(bash) timed out after 1000ms on every runtime — the exact D3b failure signature — even though bash printed partial and exited 1 within milliseconds.

After:

  • The same call returns {stdout: "partial\n", exitCode: 1} in ~15 ms. The budget is unchanged and still bounds the command: a command still running at its deadline times out exactly as before.

How it works

exec → spawn detached (own process group) → race [exit | spawn-error | deadline]
     → exit in budget: SIGTERM the group (reap pipe-holding stragglers),
       drain pumped output (bounded grace), return {exitCode, stdout, stderr}
     → deadline first: SIGTERM the group, throw the timeout error

Exit is the ground truth for "the command finished". On exit the command's process group is reaped, so pipe EOF follows promptly and stragglers cannot defer the result; output is accumulated by pumps that also forward through the @effectionx/process Stdio API, preserving middleware capture and passthrough. Windows keeps the previous close-settled path — process groups and kill(-pid) do not exist there.

Diagnosis trail

  • 15 instrumented full-suite runs under pinned Bun 1.3.14 (child-event spy, event-loop heartbeat, OS process sampling, per-file JUnit) plus contention runs (two concurrent suites + CPU load) reproduced no D3b failure directly, but caught one green run where the suite made zero progress for 216 s while a worker child lived exactly that long — proof that suite-level delivery stalls of this class exist.
  • Ruled out empirically: event-loop freeze losing the race (a completed child's result survives a 3 s synchronous block past a 2 s timeout), blocking stdout writes (Bun's pipe writes are async-buffered), fd/zombie leaks (all 15 runs: every spawn closed, zero zombies), and sync spawn cost (≤ 42 ms at load 25).
  • Reproduced deterministically: a grandchild inheriting the stdout pipe defers close past the deadline → the exact D3b failure on Bun and Node. That reproduction is the regression test.

How to verify it

  • packages/runtime/tests/exec-timeout.test.ts "delivers a completed command's output while a straggler holds the pipe" proves exit-settled semantics and fails with the exact D3b signature under the diagnosed mutation (reverting to close-settled join()), not because any timeout increased.
  • "still times out a command that outlives its budget" proves the deadline still kills a genuinely running command — it fails if the timeout is disarmed by the fix.
  • D3b itself is untouched and still proves stdout precedes the printed error from a non-zero exit.
  • Gates: deno task lint / scoped deno check packages scripts / deno task test (361 files, 0 failed) / deno task check:jsr / tsc --project tsconfig.node.json / test:node on Node 22 (2369 pass, 0 fail) / npx --yes --package=bun@1.3.14 bun run test:bun twice (2369 pass, 0 fail, 169 files).

Scope

Included

  • Exit-settled exec in the default Process API handler (packages/runtime/apis.ts), same budget, same error text.
  • Regression tests for both sides of the semantics (packages/runtime/tests/, first tests dir for this package — discovery picks it up on all three runtimes without config edits).

Intentionally unchanged

  • daemon() and every other @effectionx/process use — teardown semantics there are scope-owned, not deadline-owned.
  • The 30s exec budget in execFactory, the D3b test, and the Bun gate.
  • Windows exec path (close-settled, as before).

Risks and limitations

  • A command that exits while its group's stragglers survive SIGTERM forfeits the EOF wait after a 1 s drain grace; output already delivered is returned, later straggler output is not attributed to the command. That is the exec contract — long-lived background work belongs to daemon.
  • @effectionx/process exposes no exit-settled join, which is why the handler spawns directly via node:child_process (mirroring the package's own detached-group discipline). Worth filing upstream as a primitive gap.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

The Process API's default exec measured its deadline against
@effectionx/process join(), which resolves only after exit AND stdio EOF.
A pipe fd held open past the deadline — an inherited fd in a straggling
grandchild, or stream delivery lagging under a loaded host — reported a
command that finished within its budget as timed out and discarded the
output it had already produced. Full runtime-suite conditions supply
exactly that pressure, which is how packages/core execute.test.ts D3b
could time out at 30s in the complete Bun run while passing focused
(#343).

exec now settles on the child's exit: the unchanged budget bounds
spawn→exit, the command's process group is reaped the moment it exits so
EOF follows promptly, and the output its pipes delivered is returned. A
command still running at the deadline times out exactly as before.
Windows keeps the close-settled path, where process groups don't exist.

The regression test spawns a straggler that inherits the stdout pipe and
would hold EOF open thirty times past the budget: close-settled
execution fails it with the exact D3b signature; exit-settled execution
returns the printed output and exit code on every runtime.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 redundant comments. Inline suggestions to remove them below.

Comment thread packages/runtime/apis.ts
try {
process.kill(-child.pid, "SIGTERM");
} catch {
// the group is already gone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the group is already gone

Comment thread packages/runtime/apis.ts
]);

if (settled.kind === "timeout") {
// ensure() reaps the group on the way out of this scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// ensure() reaps the group on the way out of this scope.

Comment thread packages/runtime/apis.ts
};
// Process groups and `kill(-pid)` do not exist on Windows, so the
// exit-settled path below cannot reap stragglers there; Windows keeps
// the close-settled `@effectionx/process` path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the close-settled `@effectionx/process` path.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR #344: 🐛 Settle exec on the command's exit, not its pipes' close

2 files, +178 / -16

Scope

✅ PR scope looks good.

Structural

✅ No structural bloat detected.

Slop

  • packages/runtime/apis.ts:184// the group is already gone
  • packages/runtime/apis.ts:275// ensure() reaps the group on the way out of this scope.
  • packages/runtime/apis.ts:491// the close-settled @effectionx/process path.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 redundant comments. Inline suggestions to remove them below.

Comment thread packages/runtime/apis.ts
try {
process.kill(-child.pid, "SIGTERM");
} catch {
// the group is already gone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the group is already gone

Comment thread packages/runtime/apis.ts
]);

if (settled.kind === "timeout") {
// ensure() reaps the group on the way out of this scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// ensure() reaps the group on the way out of this scope.

Comment thread packages/runtime/apis.ts
};
// Process groups and `kill(-pid)` do not exist on Windows, so the
// exit-settled path below cannot reap stragglers there; Windows keeps
// the close-settled `@effectionx/process` path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the close-settled `@effectionx/process` path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make Bun exec output reliable in the full runtime suite

1 participant