Skip to content

Give the version probe a deadline, so a hung binary cannot hang the tool - #11

Merged
wormeyman merged 1 commit into
mainfrom
read-version-timeout
Aug 18, 2026
Merged

Give the version probe a deadline, so a hung binary cannot hang the tool#11
wormeyman merged 1 commit into
mainfrom
read-version-timeout

Conversation

@wormeyman

Copy link
Copy Markdown
Contributor

install::read_version was the one subprocess in this crate not behind the Spawner trait, and it ran with no deadline at all. A hung Factorio hung every command that picks an install: installs list, run, provenance report, refs docs and refs sync. It also meant refs sync --check, documented as "report only, never fetches, never writes", could hang forever.

What changed

No signature change, and no new dependency. None already meant "this binary will not tell us its version", so a timeout is one more way to reach an answer every caller already handled. discover, select and everything below them needed no edit. The crate holds at five dependencies.

A reader thread drains stdout while the main thread watches the clock. That split is the point:

  • The Child stays on the main thread, so kill is reachable. Handing the whole child to a thread reads more simply but leaves nothing able to stop the process.
  • The pipe is drained continuously, so a child that filled it is never misread as a hang.
  • On timeout the child is killed and reaped. kill alone leaves a zombie, because Rust's Child does not reap on drop.

The numbers are measured

15 runs on each of two installs, 2026-08-18. factorio --version took a median of 47.6 ms on the 2.1.14 Steam install (min 44.9, max 54.9) and 43.9 ms on the 2.0.77 standalone (min 41.7, max 44.6). Each printed 116 bytes or fewer, all on stdout and zero on stderr, which is this repo's long-standing stderr finding measured a fourth time. Ten seconds is about 200 times the median.

The worst case is two deadlines, not one

The poll loop and the reader each get the full time, and they are additive whenever the child exits while something else still holds the write end of stdout. Measured at 3.18 seconds with a 3 second grandchild, returning the right version.

Waiting is the intended behaviour. Returning early would drop output the child had already written, which is the failure the reader thread exists to prevent. Two tests pin it, one per property, because a single test cannot fail on both: one grandchild shorter than the deadline checks the version still parses, one longer checks the wait is bounded. CLAUDE.md records the 20 second worst case.

Tests

Six added, all #[cfg(unix)], because Windows CreateProcess runs neither a shebang script nor a .cmd, and building a hanging binary there needs a second cargo target or a sixth dependency. CI is ubuntu-latest, so all six run on every push. The timeout is untested on Windows.

Every one was checked by mutation, not by reading. Removing the kill, removing the reap, ignoring the deadline, forcing a 1 ms deadline, making the second wait unbounded, and making it return immediately each failed the test that claims to cover it.

One trap measured while debugging and recorded in a comment: a script file written seconds ago takes 345 to 438 ms on its first exec on macOS while the suite is spawning, so a 200 ms deadline killed the shell before its first line ran.

cargo fmt, cargo clippy -D warnings and cargo test --all-targets all clean: 253 unit and 13 integration, 266 green, up from 260. The shared factorio-data clone is unchanged, still master at a784954.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FpiiN19F5Yfjc6jn4y7ttW

`install::read_version` was the one subprocess in this crate not behind the
`Spawner` trait, and it ran with no deadline at all. A hung Factorio hung every
command that picks an install: `installs list`, `run`, `provenance report`,
`refs docs` and `refs sync`.

No signature change. `None` already meant "this binary will not tell us its
version", so a timeout is one more way to reach an answer every caller already
handled, and `discover`, `select` and the callers below them needed no edit.
No new dependency either; the crate holds at five.

A reader thread drains stdout while the main thread watches the clock. That
split is the point: the `Child` stays where `kill` can reach it, and the pipe is
drained continuously, so a child that filled it is never misread as a hang. On
timeout the child is killed and reaped, because `kill` alone leaves a zombie.

The 10 second number is measured, not guessed. 15 runs on each of two installs
on 2026-08-18: `factorio --version` took a median of 47.6 ms on 2.1.14 and
43.9 ms on 2.0.77, all output on stdout and zero bytes on stderr.

The worst case is two deadlines, not one, because the poll loop and the reader
each get the full time and they are additive when a grandchild holds the write
end. Measured at 3.18 seconds with a 3 second grandchild. Two tests pin that,
one per property, since a single test cannot fail on both.

Six tests added, all `#[cfg(unix)]` because Windows `CreateProcess` runs neither
a shebang script nor a `.cmd`. Every one was checked by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpiiN19F5Yfjc6jn4y7ttW
@wormeyman
wormeyman merged commit 2d5803b into main Aug 18, 2026
1 check passed
@wormeyman
wormeyman deleted the read-version-timeout branch August 18, 2026 15:10
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.

1 participant