Bound tmux/git calls with a CommandRunner trait + timeouts#30
Merged
Conversation
The refresh worker is single-threaded, so a single hung `git status` (e.g. on a network mount, with slow hooks, or a wedged tmux server) froze the whole status pipeline. Wrap external tool invocation in a `CommandRunner` trait that enforces a per-call timeout, with a production `RealRunner` that SIGKILLs strays. Refactor `tmux.rs` and `git.rs` to route through the runner via module-static `RealRunner`s so public function signatures stay stable. On any failure (spawn / non-zero / timeout) callers receive the same default fallback they got before, so happy/sad UX paths are unchanged; only the previously-hanging path now returns instead of blocking. Default timeouts: 1s for tmux (local IPC) and 2s for git. Parsing is factored into pure functions and `_with_runner` seams so a `FakeRunner` can drive empty / multi-line / malformed inputs deterministically in unit tests.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a timeout-bounded command execution abstraction and routes tmux/git status paths through it to reduce refresh hangs and improve parser testability.
Changes:
- Added
CommandRunner,RealRunner, structured command errors, and timeout enforcement. - Refactored tmux/git command paths to use runner-backed helpers and pure parsers.
- Added unit tests for command execution, timeout behavior, and tmux/git parsing.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/infra/command.rs |
Adds bounded external command runner and error model. |
src/infra/git.rs |
Routes git status through the runner and factors parsing. |
src/infra/tmux.rs |
Routes tmux/ps calls through the runner and factors parsing. |
src/infra/mod.rs |
Exposes the new command module. |
tests/unit/infra/command.rs |
Adds tests for real command execution and timeout classification. |
tests/unit/infra/git.rs |
Adds parser and runner-seam tests for git status. |
tests/unit/infra/tmux.rs |
Adds parser and runner-seam tests for tmux helpers. |
Comments suppressed due to low confidence (3)
src/infra/command.rs:167
- If
thread::Builder::spawnfails after the child process has already been spawned, this returnsCommandError::Spawnwithout killing or waiting on that child. DroppingChilddoes not terminate the process, so a long-running or wedged command can be left running outside the timeout path; kill/reap the child before returning this error or restructure so the child is spawned inside the worker.
thread::Builder::new()
.name(format!("cmd-{prog_owned}"))
.spawn(move || {
let result = child.wait_with_output();
let _ = tx.send(result);
})
.map_err(|source| CommandError::Spawn {
program: program.to_string(),
source,
})?;
src/infra/command.rs:193
- This timeout path only keeps a numeric PID after moving
Childinto the helper thread. If the process exits betweenrecv_timeoutfiring and thiskill, and the OS reuses the PID,SIGKILLcan be sent to an unrelated process. Keep aChildhandle on the thread that performs the kill, or have the owner thread kill via the handle, to avoid PID-reuse races.
Err(mpsc::RecvTimeoutError::Timeout) => {
// Best-effort kill. We can't use the moved `Child` handle
// anymore so we go through libc directly.
kill_pid(pid);
Err(CommandError::Timeout {
src/infra/command.rs:123
- This describes the success path as “Zero-cost” while the implementation spawns a thread for every command run. That wording is misleading for future performance work; it should be corrected to describe the actual per-call thread overhead.
/// Zero-cost in the success path — we still go through one thread spawn
/// per call, but external commands here run at most a few times per
/// second so the overhead is negligible compared to the spawn itself.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,5 @@ | |||
| pub mod claude_state; | |||
| pub mod command; | |||
Comment on lines
+13
to
+15
| //! (`RealRunner`) backs this with a worker thread that polls | ||
| //! `Child::try_wait`; tests can swap in a `FakeRunner` to drive | ||
| //! parsing/timeout branches deterministically. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CommandRunnertrait +RealRunner(module-static viaOnceLock) so all tmux/git external calls go through a single, timeout-bounded path.TMUX_TIMEOUT = 1s,GIT_TIMEOUT = 2s(bothpub const, can be referenced from tests/future code).tmux.rs/git.rsso it can be exercised against aFakeRunnerin unit tests.pubsignatures oftmux::*/git::*are unchanged —refresh.rsis not touched.Addresses codex review items:
refresh worker 仍可能被单个 git 命令无限卡住tmux/git/proc 命令是硬编码全局函数,集成逻辑难单测tmux() helper 丢弃 stderr 和 exit status 细节See
docs/review/codex/2026-05-14.mdfor the full review.Test plan
cargo test— 215 passed (was 177; +38 new tests for command runner / tmux & git parsers / timeout paths)collapsible_matchinsrc/app/mod.rsis untouched)🤖 Generated with Claude Code