Skip to content

fix(minimal): add idle-progress timeout to upload to prevent indefinite hang - #920

Merged
0chroma merged 4 commits into
mainfrom
0chroma/fix-upload-hang-886
Jul 25, 2026
Merged

fix(minimal): add idle-progress timeout to upload to prevent indefinite hang#920
0chroma merged 4 commits into
mainfrom
0chroma/fix-upload-hang-886

Conversation

@0chroma

@0chroma 0chroma commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

When the SSH connection drops mid-upload, russh's ChannelTx can block on window availability indefinitely — the peer is gone and no window adjustment ever arrives. The tokio::io::copy call in stream_tar_zstd had no deadline, so the client hung forever showing "Uploading project files..." with no way to detect the dead peer.

Root cause

stream_tar_zstd used tokio::io::copy to pump data from the tar builder pipe to the SSH channel writer. tokio::io::copy has no deadline. russh's ChannelTx blocks on window availability when the SSH receive window is full. If the peer dies without sending a window adjustment (e.g. a vsock reset mid-transfer), the writer parks with no waker and the copy never returns.

The post-upload drain loop in upload_workspace_files (while let Some(msg) = channel.wait().await) also had no timeout — a wedged transport that never delivers a Close would hang there too.

Fix

  • stream_tar_zstd: Replaced tokio::io::copy with a manual copy loop. Each direction (pipe read + channel write) gets a 30s idle-progress timeout. On timeout, the tar builder task is aborted rather than left detached.
  • upload_workspace_files: Added a 120s idle-progress backstop on the channel.wait() drain loop — generous enough for a legitimate large unpack, but catches a wedged transport.

Closes #886.

Note

Add idle-progress timeouts to upload to prevent indefinite hangs

  • Adds a 30s IDLE_TIMEOUT to the pipe-through loop in file_upload.rs, replacing tokio::io::copy with a manual read/write loop where each rx.read and w.write_all is individually wrapped in tokio::time::timeout.
  • Adds a 120s DRAIN_IDLE_TIMEOUT to the post-upload channel drain loop in client.rs, replacing an unbounded channel.wait() loop with one that times out if no message arrives.
  • On timeout or write error, the pipe is drained into a sink so the tar builder can finish cleanly before returning an error, avoiding panics from dropping an unfinished builder.
  • Adds tests for the stalling writer case and a CountingReader adapter to verify read byte counts.
  • Behavioral Change: uploads that previously hung indefinitely when the peer stopped advancing the window now fail with a stall error after 30s (pipe) or 120s (drain).

Changes since #920 opened

  • Added idle-progress timeout mechanism to stream_via_pipe function to prevent indefinite hangs during upload [65c9764]
  • Added test case stream_via_pipe_bails_when_builder_stalls to validate timeout behavior [65c9764]
  • Reduced read buffer size in stream_via_pipe manual copy loop [65c9764]
  • Updated error messages in stream_via_pipe function [65c9764]

Macroscope summarized c87fb24.

Summary by CodeRabbit

  • Bug Fixes
    • Uploads now detect idle/stalled transfers and fail fast with a clear “upload stalled” error instead of hanging indefinitely.
    • Improved robustness of post-upload draining, including better interpretation of clean vs unexpected connection closures.
  • Tests
    • Added regression coverage to ensure streaming errors occur promptly when the builder/writer stalls.
    • Added assertions for reliable byte-pull accounting during small, repeated reads.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Upload streaming now detects stalled pipe reads, channel writes, finalization, and daemon-unpack drains using idle timeouts. Regression tests cover stalled writers, stalled builders, byte counting, and VCS-root behavior.

Changes

Upload stall protection

Layer / File(s) Summary
Streaming pipeline watchdog
crates/minimal/src/file_upload.rs
The tar+zstd pipeline uses timed reads, writes, flushes, and shutdown, reports targeted stall errors, updates related documentation, and adds regression coverage.
Daemon-unpack drain timeout
crates/minimal/src/client.rs
Post-upload channel draining now fails after 120 seconds without a message while retaining extended-data error handling and close tracking.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TarBuilder
  participant UploadStream
  participant SSHChannel
  participant Daemon
  TarBuilder->>UploadStream: produce tar+zstd bytes
  UploadStream->>SSHChannel: read and write with idle timeouts
  SSHChannel->>Daemon: transmit upload stream
  Daemon-->>SSHChannel: send unpack errors or close
  UploadStream->>SSHChannel: drain completion messages with timeout
Loading

Possibly related issues

  • #886 — Covers the indefinite upload hang addressed by the new streaming and drain timeouts.
  • gominimal/inbox#335 — Matches the idle timeouts added to both upload streaming and post-upload draining.

Possibly related PRs

  • gominimal/minimal#919 — Modifies the same stream_upload channel-draining and extended-data error flow.

Poem

A rabbit watched the upload stall,
Then set a timer over all.
The tar now hops, the channel speaks,
No silent wait for endless weeks.
Safe bytes bound through every squeak!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has a strong Summary and Fix section, but it is missing the template's Testing and Checklist sections. Add a Testing section with commands/output and fill out the Checklist, including docs updates and any BREAKING CHANGE footer if needed.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses #886 by adding upload and drain timeouts so stalled transfers fail instead of hanging.
Out of Scope Changes check ✅ Passed The documented changes stay focused on upload hang prevention, tests, and supporting docs updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is a clear conventional-commit summary of the main change: adding idle-progress timeouts to uploads.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/minimal/src/file_upload.rs (1)

592-595: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good regression coverage for the write-stall path; the read-stall path is untestable as currently designed.

StallingWriter/stream_tar_zstd_bails_when_writer_stalls correctly exercises the channel-write-stall branch under paused time. Note that a symmetrical test for the "no data from tar builder" branch (a build closure that never writes to tx) would currently hang the test runner rather than pass — under start_paused = true, the read-timeout timer fires and copy_fut resolves, but build_fut is left pending with no timer of its own, so tokio::join! (and the paused-clock auto-advance, which needs a pending timer to jump to) can't make further progress. That's independent confirmation of the join! issue flagged above rather than a gap in this specific test file — please don't add such a test until the underlying deadlock is addressed, since it would hang CI rather than fail cleanly.

Also applies to: 884-993, 1005-1006

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/src/file_upload.rs` around lines 592 - 595, Fix the underlying
deadlock in the stream_tar_zstd flow rather than adding a read-stall test.
Update the coordination of build_fut and copy_fut so a read-timeout from
copy_fut also cancels or otherwise terminates a build closure that never writes
to tx, allowing the operation to resolve without join! waiting indefinitely.
Preserve the existing writer-stall behavior and
stream_tar_zstd_bails_when_writer_stalls coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimal/src/file_upload.rs`:
- Around line 144-146: Update both stall error messages in the upload path’s
bail! calls to use Rust’s backslash-newline continuation instead of an escaped
\n followed by indentation. Preserve the existing message text while ensuring
the rendered error is a single clean line without leading whitespace before the
parenthetical.
- Around line 162-163: Wrap the trailing w.flush() and w.shutdown() calls in the
same IDLE_TIMEOUT-bounded operation used for reads and writes inside the upload
loop. Preserve their existing error contexts while ensuring both finalization
steps cannot hang indefinitely when the peer stops accepting data.
- Around line 59-63: Update stream_via_pipe so a timeout or error from rx.read
can return without waiting for build_fut to complete. Make the tar-building
future independently cancellable, abortable, or spawn it as a task that is
explicitly stopped when the read side exits, while preserving cleanup of the
pipe and existing error propagation.

---

Nitpick comments:
In `@crates/minimal/src/file_upload.rs`:
- Around line 592-595: Fix the underlying deadlock in the stream_tar_zstd flow
rather than adding a read-stall test. Update the coordination of build_fut and
copy_fut so a read-timeout from copy_fut also cancels or otherwise terminates a
build closure that never writes to tx, allowing the operation to resolve without
join! waiting indefinitely. Preserve the existing writer-stall behavior and
stream_tar_zstd_bails_when_writer_stalls coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21da20a2-db40-4073-b142-439c998256cd

📥 Commits

Reviewing files that changed from the base of the PR and between 9f56446 and 9313608.

📒 Files selected for processing (2)
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs

Comment thread crates/minimal/src/file_upload.rs
Comment thread crates/minimal/src/file_upload.rs
Comment thread crates/minimal/src/file_upload.rs Outdated
let mut buf = [0u8; 64 * 1024];
loop {
match tokio::time::timeout(IDLE_TIMEOUT, rx.read(&mut buf)).await {
Ok(Ok(0)) => break,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this how EOF comes through? I always thought it was an error of type EOF, but i googled it and yeah its a 0 byte read. Hmmm.

@0chroma 0chroma Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeahhh, it's expected behavior so it comes through via an Ok(...) Unexpected EOF would be io::ErrorKind::UnexpectedEof. As an aside I also kinda hate how much I'm doing these nested result types, it feels very cursed.

Comment thread crates/minimal/src/file_upload.rs Outdated
// stall on either the pipe read (tar builder stuck) or the
// channel write (peer gone, window frozen) surfaces as an
// error instead of an indefinite hang (#886).
let mut buf = [0u8; 64 * 1024];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can turn this down to 4k or 8k, as the BufWriter will only emit 4k blocks anyway.

@0chroma
0chroma enabled auto-merge (squash) July 25, 2026 07:23
0chroma added 3 commits July 25, 2026 00:27
…te hang

When the SSH connection drops mid-upload, russh's `ChannelTx` can block
on window availability indefinitely — the peer is gone and no window
adjustment ever arrives. The `tokio::io::copy` call in `stream_tar_zstd`
had no deadline, so the client hung forever showing "Uploading project
files..." with no way to detect the dead peer.

Replace `tokio::io::copy` with a manual copy loop that bounds both the
pipe read (tar builder stalled) and the channel write (peer gone) with
a 30s idle-progress timeout. On timeout, the tar builder task is
aborted rather than left detached. A 120s idle backstop is also added
to the post-upload channel drain in `upload_workspace_files`.

Closes #886.
…ustfmt

The stall-detection logic works — the "upload stalled" message is the
source of the anyhow error, not the top-level context. The test asserted
on `to_string()` which only renders the outer context ("workspace upload
failed after N bytes"), so it never saw "stalled" and failed. Use
`format!("{:#}", err)` to render the full chain. Also apply rustfmt to
the `return Err(...)` expression that `fmt --check` flagged.
@0chroma
0chroma force-pushed the 0chroma/fix-upload-hang-886 branch from 9313608 to c87fb24 Compare July 25, 2026 07:35
@0chroma
0chroma disabled auto-merge July 25, 2026 07:38
Switch stream_via_pipe to try_join! so a read-timeout cancels the
build future (the old join! could deadlock when the builder stalled
and never produced bytes). TarZstArchive's Drop already mem::forgets
the inner async_tar::Builder, so cancellation is panic-safe.

Also guard the trailing flush/shutdown with the idle timeout, fix
stray literal \n in the stall error messages, shrink the read buffer
to match BufWriter's 4 KiB blocks, and add a read-stall regression
test that the try_join! switch makes possible.
@0chroma
0chroma enabled auto-merge (squash) July 25, 2026 07:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/minimal/src/file_upload.rs (1)

104-166: 🩺 Stability & Availability | 🔵 Trivial

Consider integration coverage for the stall/timeout behavior, not just mocked unit tests.

All new regression coverage (StallingWriter, stream_via_pipe_bails_when_builder_stalls, etc.) exercises the timeout logic against in-process mock readers/writers only — there's no exercise of the real SSH channel/daemon path this fix targets (#886's "receive window is full" scenario).

As per coding guidelines, "Do not rely only on unit tests for VM/networking behavior; preserve and run the applicable integration and root-integration harnesses" and "When changing VM or daemon paths, run the relevant integration coverage: just e2e and/or just test-vm."

Also applies to: 879-956

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/src/file_upload.rs` around lines 104 - 166, Add integration
coverage for the upload stall timeout through the real SSH channel/daemon path,
including a scenario where the receive window is full or the peer stops
consuming data. Extend the existing upload integration harness around the stream
upload flow, rather than relying only on StallingWriter and
stream_via_pipe_bails_when_builder_stalls unit tests, and ensure the applicable
just e2e or just test-vm coverage exercises this regression.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/minimal/src/file_upload.rs`:
- Around line 104-166: Add integration coverage for the upload stall timeout
through the real SSH channel/daemon path, including a scenario where the receive
window is full or the peer stops consuming data. Extend the existing upload
integration harness around the stream upload flow, rather than relying only on
StallingWriter and stream_via_pipe_bails_when_builder_stalls unit tests, and
ensure the applicable just e2e or just test-vm coverage exercises this
regression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 14b12998-2cce-4c78-a0da-f713e0369d4f

📥 Commits

Reviewing files that changed from the base of the PR and between c87fb24 and 65c9764.

📒 Files selected for processing (1)
  • crates/minimal/src/file_upload.rs

@0chroma
0chroma merged commit 376661f into main Jul 25, 2026
29 checks passed
@0chroma
0chroma deleted the 0chroma/fix-upload-hang-886 branch July 25, 2026 07:59
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.

minimal: upload failure sometimes hangs the client indefinitely instead of erroring

2 participants