Skip to content

fix: launch child processes asynchronously in unit tests - #1432

Merged
exKAZUu merged 5 commits into
mainfrom
fix/wb-spawnsync-hang-no-isolate
Oct 2, 2026
Merged

exKAZUu merged 5 commits into
mainfrom
fix/wb-spawnsync-hang-no-isolate

Conversation

@exKAZUu

@exKAZUu exKAZUu commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Close #1429

Why

After #1421 made wb test run unit tests with --parallel --no-isolate, a test still hung in a synchronous spawn: in run 36857401401 (attempt 1), startCommand.test.ts's wb start --dry-run test ran into its 5 s timeout and Bun printed killed 1 dangling process. In 21 other CI runs the same test took 0.38–2.7 s.

The hang is rare. Since #1421, 1 of 22 Test workflow attempts hit it. 220 extra runs of the wb suite on ubuntu-24.04 did not hit it at all. Those runs had a watchdog that dumped /proc state for any test-worker child that stayed a zombie for 1.5 s, and it never fired. So no live process state was captured. The signature matches oven-sh/bun#34069, and the maintainers' fix PR describes a cause that --no-isolate does not remove. While spawnSync waits, the GC can finalize any object that owns a poll on the main loop (a FileSink, a Subprocess, a socket). That corrupts the poll count of the private loop the wait runs on, so a later spawnSync misses its child's exit. --no-isolate only removed the most frequent such object (each file's stdout/stderr writers). The wb test workers still made about 900 synchronous spawns per run (about 360 s spent waiting, 196 s of it in deployRailway.test.ts's 60-second CLI runs). They did this next to in-process async spawns, sockets, and servers.

Async spawns never use that private loop, so this PR moves the unit tests off synchronous spawns instead of tuning timeouts or retries.

Requirements

  • required: determine whether the hang is the same Bun spawnSync bug surviving --no-isolate or a different cause, from the evidence, and fix it at that root
  • required: remove the dependency on Bun's buggy spawnSync path, e.g. by replacing spawnSync in the affected code paths and tests with async spawns where feasible
  • required: prefer a fix in production code where production commands can hit the same hang
  • required: wb test keeps running unit test files in parallel worker processes (the contract in docs/expected-repository-rules.md)
  • chosen: tests use spawnAsync of @willbooster/shared-lib-node rather than a new helper, because it already returns spawnSync's status/stdout/stderr shape and every package can import it
  • chosen: spawnAsync closes the child's stdin after the input (or at once without input), because spawnSync does so and tests rely on it (verifyOutput.test.ts checks stdin EOF). Before, nothing could ever write to that pipe or close it.
  • chosen: wb's production code keeps spawnSync except buildIfNeeded, because the released wb runs under Node (bin/index.js's #!/usr/bin/env node, which bun wb honors), where spawnSync has no such bug. buildIfNeeded changes because its tests run it in-process 25 times per suite run.
  • chosen: shared-lib-node's synchronous APIs (readEnvironmentVariables, treeKill) keep spawnSync, because their callers depend on them being synchronous
  • chosen: startCommand.test.ts gets the 30 s timeout of the other CLI-spawning tests, because running the CLI from source took up to 2.7 s on CI against the 5 s default

Customer Summary

wb test no longer fails at random because a unit test in this repository waits on a child process that has already exited. docs/expected-repository-rules.md now tells every repository's unit tests to launch child processes asynchronously, for the same reason. spawnAsync now closes the child's stdin after the input, like spawnSync, so a child that reads stdin no longer waits forever.

Technical Summary

  1. Unit tests in wb, wbfy, shared-lib-node, shared-lib, and the root test/helpers replace spawnSync/execSync/execFileSync/Bun.spawnSync with spawnAsync (Bun.spawn in shared-lib, which does not depend on shared-lib-node). Their helpers and callers become async. Command-availability checks use Bun.which instead of running --version.
  2. packages/wb/src/commands/buildIfNeeded.ts: the git rev-parse, git status, and build-command runs use spawnAsync.
  3. packages/shared-lib-node/src/spawn.ts: stdin is always ended. index.ts also exports the SpawnAsyncOptions and SpawnAsyncReturns types for the tests' helper signatures.
  4. docs/expected-repository-rules.md: the unit-test rule asks for asynchronous child processes (Bun.spawn or spawn of node:child_process, which every repository has).
  5. The tests merged from fix(wbfy,wb): ignore gitignored files when detecting repository contents #1431 (verifyOutput.test.ts's fixture git init, wbfy's packageConfig.test.ts) launch git through spawnAsync too.

Check hardest: the converted tests that relied on execSync/execFileSync throwing on a non-zero exit. They now assert status explicitly (buildIfNeeded.test.ts's git helper, prismaScripts.test.ts, gitattributes.test.ts, workerTypesConsumption.test.ts).

Testing

  • A preload that wraps the synchronous spawn APIs counted the calls made inside test workers. In each package's unit suite, before → after (at 1f4740b0):
    • wb: 900 calls (≈360 s) → 12 (0.26 s, all in treeKill, readEnvironmentVariables, and killListeningProcessesByPort)
    • shared-lib-node: 95 → 56 (readEnvironmentVariables, treeKill)
    • shared-lib: 3 → 0
    • wbfy: 182 (83 s) → 125 (23 s, all in wbfy's own code)
  • CI: the PR's Test runs and 3 workflow_dispatch Test runs on this branch passed. A stress workflow ran the wb unit suite 80 times on GitHub-hosted ubuntu-24.04 (20 jobs × 4) at this branch: no test hit a timeout or killed 1 dangling process. The 2 failures were the separate treeKill race that fix(shared-lib-node): signal parents before their descendants in treeKill #1435 fixes.
  • New test: spawn.test.ts checks that stdin is closed with and without input. It times out without the spawn.ts change.
  • bun run verify and each package's lint/typecheck pass. Locally, the unit suites pass for wb (470), shared-lib-node (68), and shared-lib (284). wbfy passes 360 of 362: the 2 globalReleaseAgeGate.test.ts cases fail on main too, because mise rejects the untrusted global config under the test's HOME.

Notes

wbfy runs under Bun (#!/usr/bin/env bun) and still makes the remaining 125 synchronous spawns, and getGlobIgnore (#1431, merged after these measurements) adds synchronous git calls. #1433 tracks moving both to async spawns. --no-isolate stays (tracked by #1425): the synchronous APIs above and other repositories' tests still call spawnSync.

exKAZUu and others added 3 commits October 2, 2026 09:52
Bun 1.4.2's synchronous spawns can miss a child's exit and hang the calling
test until its timeout, also under `--no-isolate`. Unit tests and the
buildIfNeeded code they run in-process now use spawnAsync, which closes the
child's stdin after any input like spawnSync does.

Co-authored-by: WillBooster (Claude Code) <agent@willbooster.com>
Co-authored-by: WillBooster (Claude Code) <agent@willbooster.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses rare, intermittent test hangs observed in CI environments. By replacing synchronous process spawning with asynchronous alternatives across the test suite and specific production commands, the changes eliminate issues related to Bun's private loop corruption. The PR also enforces a new repository rule requiring asynchronous child process launching in unit tests to ensure long-term stability.

Highlights

  • Asynchronous Process Spawning: Migrated unit tests and specific production code paths from synchronous spawn APIs (spawnSync, execSync, etc.) to spawnAsync to resolve intermittent test hangs.
  • Documentation Update: Updated docs/expected-repository-rules.md to mandate that unit tests must launch child processes asynchronously.
  • Stdin Handling Improvement: Modified spawnAsync in shared-lib-node to ensure child process stdin is closed, matching the behavior of spawnSync and preventing potential deadlocks.
  • Production Code Migration: Updated packages/wb/src/commands/buildIfNeeded.ts to use asynchronous spawning for git and build operations.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


A test that would hang in the night, Gave developers quite a big fright. With spawnAsync now, We make a deep bow, And tests run with all of their might.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request migrates synchronous child process executions to asynchronous ones using spawnAsync (or Bun.spawn/Bun.which) across various packages and test suites to prevent tests from hanging under Bun 1.4.2. It also updates the repository rules documentation to reflect this requirement. A review comment correctly points out that using an async function as a describe callback in applyDockerEnv.test.ts is an anti-pattern and should be made synchronous.

Co-authored-by: WillBooster (Claude Code) <agent@willbooster.com>
Co-authored-by: WillBooster (Claude Code) <agent@willbooster.com>
@exKAZUu exKAZUu self-assigned this Oct 2, 2026
@exKAZUu
exKAZUu merged commit a735e98 into main Oct 2, 2026
7 checks passed
@exKAZUu
exKAZUu deleted the fix/wb-spawnsync-hang-no-isolate branch October 2, 2026 01:35
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.

fix: unit tests still hang in spawnSync until their timeout under --no-isolate

1 participant