Skip to content

fix: destroy piped streams on child exit to prevent grandchild deadlock#137

Merged
43081j merged 8 commits into
tinylibs:mainfrom
Mearman:fix/grandchild-pipe-deadlock
May 27, 2026
Merged

fix: destroy piped streams on child exit to prevent grandchild deadlock#137
43081j merged 8 commits into
tinylibs:mainfrom
Mearman:fix/grandchild-pipe-deadlock

Conversation

@Mearman
Copy link
Copy Markdown
Contributor

@Mearman Mearman commented May 26, 2026

Fixes #138

When a child process spawns a grandchild that inherits the piped stdout/stderr fds, the grandchild holds them open after the child exits. Node's close event waits for all fds to be released, so it never fires. Both await exec() and the async iterator hang.

This is what causes lint-staged to deadlock when eslint uses typescript-eslint's projectService (tsserver inherits the piped fds). See lint-staged/lint-staged#1800.

The fix listens for exit and schedules stream destruction after a 100ms grace period. If close fires normally (no grandchild), the timer is cancelled. This avoids truncating buffered output while still breaking the deadlock when grandchildren hold the pipes.

Two tests in src/test/grandchild_test.ts, both running in a subprocess with a 10s timeout so orphaned grandchildren don't block vitest:

  • await x() completes and resolves
  • for await (const line of x()) yields the output lines and completes

Without the fix the subprocess hangs and gets killed by the timeout.

When a child process spawns a grandchild that inherits the piped stdout
fd (fd 1), the grandchild keeps the pipe open after the child exits.
Without the fix, both `await exec()` and the async iterator hang
indefinitely because the piped streams never end.

Each test runs in a subprocess (spawnSync with a 10s timeout) so the
orphaned grandchild doesn't block vitest. A 5s inner race detects
whether exec completes or hangs.
Copilot AI review requested due to automatic review settings May 26, 2026 18:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds an exit-based cleanup to prevent hangs when a grandchild process keeps inherited pipe FDs open, and introduces regression tests that reproduce the scenario in a subprocess.

Changes:

  • Add an exit handler in ExecProcess to destroy piped stdout/stderr streams on child exit.
  • Add Vitest coverage for “grandchild holds stdout open” for both await and async-iterator usage patterns.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/main.ts Destroys piped streams on exit to avoid waiting forever for close when grandchildren keep FDs open.
src/test/grandchild_test.ts Adds regression tests that spawn a child which spawns an orphaned grandchild inheriting stdout, ensuring x() completes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/main.ts
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

Comment thread src/main.ts
Comment thread src/main.ts
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
When a child process spawns a grandchild that inherits the piped
stdout/stderr file descriptors, the grandchild holds them open after the
child exits. Node's close event waits for all fds to be released, so
it never fires. readStream and combineStreams hang indefinitely because
the streams never end.

Listen for the exit event (fires when the process exits, regardless of
pipe state) and destroy the piped streams. This forces the PassThrough
and readline consumers to complete.

Refs: lint-staged/lint-staged#1800
@Mearman Mearman force-pushed the fix/grandchild-pipe-deadlock branch from df68f2d to 9657bb5 Compare May 26, 2026 19:38
Comment thread src/main.ts Outdated
Comment thread src/test/grandchild_test.ts Outdated
Comment thread src/main.ts
Mearman and others added 4 commits May 27, 2026 19:14
Static test fixtures that spawn a long-lived grandchild process
inheriting the piped stdout fd, simulating tsserver inheriting
eslint's piped streams through lint-staged/tinyexec.
Use committed fixture scripts instead of generated inline scripts.
Run tests in a subprocess with isolated stdio so orphaned
grandchildren don't block vitest's teardown. Clean up grandchildren
via pkill with a marker comment in the fixture scripts.
setImmediate is sufficient to let buffered data drain before destroying
the streams. Removes the timer tracking in _resetState and _onClose
that was needed for the setTimeout approach.
@43081j
Copy link
Copy Markdown
Member

43081j commented May 27, 2026

the test doesn't really belong in its own file as its still a main test.

ill move it myself though as this otherwise looks good 👍

@43081j 43081j merged commit 20e4117 into tinylibs:main May 27, 2026
9 checks passed
@Mearman Mearman deleted the fix/grandchild-pipe-deadlock branch May 27, 2026 21:33
@iiroj
Copy link
Copy Markdown
Contributor

iiroj commented May 28, 2026

Thank you @Mearman for taking the time to fix this in upstream instead of lint-staged! Do you @43081j have plans to create patch release for this?

@43081j
Copy link
Copy Markdown
Member

43081j commented May 28, 2026

it'll be in v1.2.3 👍

most likely today

@iiroj
Copy link
Copy Markdown
Contributor

iiroj commented May 28, 2026

@43081j nice, I see it's already published as staged, but not public on npmjs.com yet: https://github.com/tinylibs/tinyexec/actions/runs/26569657183/job/78273310463

@43081j
Copy link
Copy Markdown
Member

43081j commented May 29, 2026

yup just hadn't got around to it yet 👍 don't worry, i didn't forget 😄

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.

exec hangs when grandchild process holds piped stdout open

4 participants