Skip to content

Bound tmux/git calls with a CommandRunner trait + timeouts#30

Merged
Junyi-99 merged 1 commit into
mainfrom
refactor/command-runner
May 18, 2026
Merged

Bound tmux/git calls with a CommandRunner trait + timeouts#30
Junyi-99 merged 1 commit into
mainfrom
refactor/command-runner

Conversation

@Junyi-99
Copy link
Copy Markdown
Member

Summary

  • Introduce CommandRunner trait + RealRunner (module-static via OnceLock) so all tmux/git external calls go through a single, timeout-bounded path.
  • Apply timeouts: TMUX_TIMEOUT = 1s, GIT_TIMEOUT = 2s (both pub const, can be referenced from tests/future code).
  • Factor pure parsing out of tmux.rs / git.rs so it can be exercised against a FakeRunner in unit tests.
  • pub signatures of tmux::* / git::* are unchanged — refresh.rs is not touched.

Addresses codex review items:

  • refresh worker 仍可能被单个 git 命令无限卡住
  • tmux/git/proc 命令是硬编码全局函数,集成逻辑难单测
  • tmux() helper 丢弃 stderr 和 exit status 细节

See docs/review/codex/2026-05-14.md for the full review.

Test plan

  • cargo test — 215 passed (was 177; +38 new tests for command runner / tmux & git parsers / timeout paths)
  • No new clippy warnings (one pre-existing collapsible_match in src/app/mod.rs is untouched)
  • Manual: launch deck against a slow / hung git repo and confirm refresh continues instead of hanging

🤖 Generated with Claude Code

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.
Copilot AI review requested due to automatic review settings May 14, 2026 11:28
Copy link
Copy Markdown
Contributor

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

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::spawn fails after the child process has already been spawned, this returns CommandError::Spawn without killing or waiting on that child. Dropping Child does 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 Child into the helper thread. If the process exits between recv_timeout firing and this kill, and the OS reuses the PID, SIGKILL can be sent to an unrelated process. Keep a Child handle 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.

Comment thread src/infra/mod.rs
@@ -1,4 +1,5 @@
pub mod claude_state;
pub mod command;
Comment thread src/infra/command.rs
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.
@Junyi-99 Junyi-99 merged commit 2d2a0d5 into main May 18, 2026
4 checks passed
@Junyi-99 Junyi-99 deleted the refactor/command-runner branch May 19, 2026 06:27
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.

2 participants