rtk-ai / rtk-ai/rtk

`rtk proxy` never returns after the child exits when a descendant keeps the stdout/stderr pipes open (Windows repro, same mechanism as #2320)

Open
#2,829 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area:cli bug priority:high
Dominant language
Rust
Stars
81k
Forks
5.1k
Avg merge
4d 21h
Merged PRs (30d)
35

Description

Summary

When the command wrapped by rtk proxy (or by the unknown-command fallback) spawns any background/daemon process, rtk keeps running after the wrapped command has already exited. All output is printed, the direct child is gone, but rtk never returns, so the calling shell (or the coding agent driving it through the hook) waits indefinitely. Killed instances accumulate as zombie rtk processes; after one evening of agent sessions I found 27 of them alive on a single machine.

This is the same mechanism as #2320 (reported for rtk dotnet on Linux), but it is not specific to dotnet or Linux: it lives in the generic proxy path in src/main.rs and reproduces deterministically on Windows.

Environment

  • rtk 0.43.0 (winget, rtk-x86_64-pc-windows-msvc)
  • Windows 11 Pro 10.0.26200
  • The mechanism is platform-independent (fd/handle inheritance) — a unix repro is included below.

Deterministic reproduction

Windows (PowerShell):

PS> Measure-Command { rtk proxy cmd /c "start /b waitfor ZZZ /t 20 & exit /b 0" }
# rtk returns only after ~20.2s — exactly when the background waitfor dies.

PS> Measure-Command { cmd /c "start /b waitfor ZZZ /t 20 & exit /b 0" }
# ~0.1s without rtk. cmd.exe exits immediately in both cases.

Linux/macOS equivalent:

$ time rtk proxy sh -c 'echo ready; sleep 20 & exit 0'   # ~20s
$ time sh -c 'echo ready; sleep 20 & exit 0'             # ~0s

Replace the 20-second sleeper with a process that never exits (a build server, a file watcher, a background sync helper) and rtk never returns at all. That is the real-world case: the wrapped command finishes, its output is fully printed, and rtk hangs forever.

Root cause

In the proxy arm of src/main.rs (lines ~2531–2587 on current develop):

  1. The child is spawned with Stdio::piped() for stdout and stderr.
  2. Two reader threads pump the pipes until EOF.
  3. After child.wait() returns, both threads are join()ed unconditionally.

EOF on those pipes does not arrive when the child exits — it arrives when the last handle to the write end closes. Any descendant of the child inherits the write ends (handle inheritance is table-wide on Windows; fds are inherited across fork on unix). A daemon or background job left behind by the child keeps the pipes open, so the join() blocks forever even though the wrapped command finished and its exit status is already known.

The unknown-command fallback with a matching TOML filter (run_fallback.output() on a piped child) has the same exposure, since output() also drains the pipes to EOF. The stream::run_streaming capture paths share the pattern as well — that is the #2320 variant.

Impact

  • Coding-agent setups that route shell commands through rtk (the primary use case) hang on every proxied command that leaves a daemon behind: build servers, watchers, language servers, package-manager helpers.
  • Each hang leaks one live rtk process; under concurrent agent workloads they pile up quickly and every one of them looks to the agent like a command that "never finishes".

Suggested fix

Once child.wait() has returned, the command is done and the exit code is known. Join the reader threads with a short grace period (enough to drain anything still buffered in the pipes), then detach any reader still blocked on an orphaned pipe. main() already ends with std::process::exit, which reaps detached threads. I have a PR ready for the proxy path and will link it here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in the proxy arm of src/main.rs around lines 2531–2587, then inspect the unknown-command fallback and stream::run_streaming for the same pipe-handling pattern. Run the Windows or Unix reproduction from the issue and verify that rtk returns after the direct child exits even when a descendant keeps stdout or stderr open; the issue notes that a PR is already prepared.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.