Skip to content

test(scripts): canary the bulk host-to-guest upload path - #887

Merged
norrietaylor merged 3 commits into
mainfrom
test/bulk-upload-canary-869
Jul 22, 2026
Merged

test(scripts): canary the bulk host-to-guest upload path#887
norrietaylor merged 3 commits into
mainfrom
test/bulk-upload-canary-869

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 22, 2026

Copy link
Copy Markdown
Member

What this covers

Failure class: bulk host→guest transfer over the vsock/SSH transport.

A guest kernel bump (6.12.43 → 6.12.94, gominimal/pkgs#311, 2026-07-01) crossed Linux 6.12.92, where a new queued-sk_buff-overhead check turned a previously silent vsock protocol violation into a fatal connection reset — gominimal/minimal#869. From that day every min activate carrying a real project failed:

error: Failed to upload project files: copying tar stream to channel: channel closed

with, guest side in boot.log:

session ended with error error=Protocol error: No buffer space available (os error 105)

Nothing went red for three weeks, until a human hit it by hand. Nothing we run moves enough data through the guest vsock to trip it — session-e2e.sh uploads a two-line minimal.toml.

scripts/bulk-upload-e2e.sh is that missing proof: min activate a project carrying a large, compressible fixture, five times over, failing if any attempt errors or hangs.

Why the fixture has to be compressible

The trigger is write rate, not volume, duration or file count — the client has to hand the channel a large number of already-compressed bytes as fast as it can produce them. From the measurements on the issue (three runs each):

Payload On the wire Result
45 MiB ext4-image slice 12.78 MB fails ~89% of runs
512 MB incompressible random 512 MB passes — the zstd encoder becomes the bottleneck and self-throttles the writer
20,000 files / 78 MB 78 MB passes
45 MiB slice, different offset 0.11 MB passes

So the obvious fixture — /dev/urandom — is exactly the wrong one: it hides the bug. And 0.11 MB on the wire is equally useless. The fixture therefore has to be large after compression and cheap to compress.

Rather than commit a 49 MiB binary, the script synthesizes one: each MiB is a fresh 256 KiB of xorshift32 output written four times, so zstd (level 3, 2 MiB window — what async_compression's ZstdEncoder defaults to) resolves three quarters of every MiB to matches while the literal quarter stays incompressible enough to be stored raw. Fixed seed, so it is byte-identical every run and a failure is reproducible.

Measured locally: 51,380,224 bytes in → 12,884,004 bytes out (3.99:1) — within 1% of the 12,784,144 bytes that reproduce on the issue — generated in ~0.35 s of perl.

That ratio is then asserted at run time (zstd -3 -T1, band 8–20 MB): if a future change makes the fixture incompressible, or trivially compressible, the script fails loudly instead of silently disarming itself. The check is skipped with a warning where zstd is absent (it is on the nightly's ubuntu-latest; the justfile already lists it as a macOS prereq).

Two related traps are closed the same way: the issue measured 4/4 passes under MINVMD_KRUN_LOG=debug, and client-side RUST_LOG=debug is enough on its own to make the bug vanish. The script therefore pins RUST_LOG=warn and unsets MINVMD_KRUN_LOG (printing a note when it drops an inherited value) — an inherited debug level would otherwise leave the canary unable to fail.

Iteration count: 5

The failure is probabilistic (~89%, not 100%), so one activate is not a detector.

N P(miss a live regression)
1 11%
3 0.13%
5 0.0016%

At the measured p = 0.89, three would already be enough (99.87%). Five is chosen for margin against CI hardware that fails to reproduce as reliably: even if the trigger rate halved to p = 0.5, five iterations still detect 96.9% of the time. The cost is five min activates against one already-booted VM (the daemon is warmed outside the loop, so a VM boot is not billed to any iteration's deadline).

Any single failure across the set fails the script ($fail -eq 0 && $pass -eq $ITER — the same shape soak-session-e2e.sh uses, which also refuses to call a short-circuited loop a pass).

Timeout handling (the hang case)

gominimal/inbox#335: the same defect sometimes hangs the client indefinitely instead of erroring (observed at 9 and 13 minutes before being killed). A canary that waits forever is a canary that reports nothing, so:

  • every min activate runs under a wall-clock deadline (default 420 s, BULK_ACTIVATE_TIMEOUT_SECS), SIGTERM then SIGKILL, returning 124;
  • 124 is reported as a distinct, named failure (the #869 hang), not a generic error;
  • a hang stops the loop — five deadlines back to back would burn the nightly's whole job budget, and one hang is already conclusive. Plain errors are cheap, so those keep going: the pass/fail tally is what identifies the ~89% signature.

timeout(1) is deliberately not used: it does not exist on a stock macOS, and running the VM stack under it wedges the boot (libkrun tcsetattr()s from timeout's background process group and SIGTTOU stops the group). The hand-rolled watchdog runs the client with </dev/null for the same reason — which also puts min activate in its non-interactive mode, where it neither prompts for the non-VCS upload confirmation (#770) nor waits on a keypress for policy gating.

How it hooks into the nightly canary

No workflow change is required, and none is made (.github/workflows/ is frozen and CODEOWNER-gated).

The nightly test tier does not discover scripts by convention: nightly-tests.yml's session-e2e-soak job invokes one entry point explicitly, ./scripts/soak-session-e2e.sh 10 "$RUNNER_TEMP/soak-logs". That script is reviewed code, so the hook goes there: after its session-e2e reps it reaps, runs the bulk-upload proof once (with MINVMD_BOOT_LOG pointed into the same log dir that is already uploaded as an artifact), and folds the result into its tally and exit status. It inherits the job's whole VM setup — images, gvproxy, PATH — for free.

just bulk-upload [n] runs the same proof locally, and just soak keeps nightly parity.

One thing a CODEOWNER may need to follow up on: the proof now shares the session-e2e-soak job's timeout-minutes: 60 with the ten session-e2e reps. It should add roughly 5–10 minutes (one VM boot plus five activates against a warm cache), but I could not measure it. If that job starts timing out, the fix is a workflow edit I cannot make — either bump timeout-minutes, or split it into its own job:

      - name: Bulk host→guest upload proof
        env: # same MINVMD_*/E2E_* block as the soak step
        run: ./scripts/bulk-upload-e2e.sh 5

Either way the failure is legible, and notify already files a tracking issue when session-e2e-soak fails.

Cleanup

A canary that leaks 89 sessions a week is worse than no canary:

  • each iteration destroys its session immediately on success;
  • a failed iteration sweeps with min destroy --all --force before the next pass;
  • the EXIT/INT/TERM trap runs destroy --all --force, stop --force, minvmd stop on VM targets, and removes the fixture, the project dir and the private state dir;
  • the script runs in its own XDG_STATE_HOME, so the sweep can never touch a developer's real sessions.

Verification

Verified. The script's own logic was exercised end to end against a stub min on PATH (the real VM was never started — see below):

Case Result
Happy path, 2 iterations 2 passed, 0 failed, exit 0; call trace shows lsactivatedestroy <sid> per pass, then destroy --all --force + stop --force; no temp dirs left in /tmp or $HOME
activate exits 1 both iterations reported with the #869 pointer, diagnostics dumped, 0 passed, 2 failed, exit 1
activate hangs (deadline 3 s) killed at the deadline, reported as the #869 hang, loop stopped with 2 iteration(s) not run, exit 1
activate prints a non-UUID reported as a UUID-validation failure, exit 1
Fixture outside the compressibility band run aborted before any activate, with the explanation, exit 1
Inherited RUST_LOG=debug / MINVMD_KRUN_LOG=debug both reported and overridden
Fixture generator byte-identical across runs (same sha256); 51,380,224 → 12,884,004 bytes under zstd -3 -T1
shellcheck scripts/bulk-upload-e2e.sh scripts/soak-session-e2e.sh clean, no findings, no suppressions beyond the two SC2086s that match session-e2e.sh's intentional E2E_MINIMAL_ARGS word-splitting
bash -n both scripts, just --list pass

Not verified — needs a reviewer or CI to run it. Another agent is holding the only VM on this machine for a timing-sensitive measurement, and a second VM would collide on the provider directory, so I could not run this against a real stack. Specifically unproven:

  1. The end-to-end run itself — that a real min activate of the 49 MiB project succeeds on a good stack (and, on a pre-fix stack, fails). Reviewer check: just bulk-upload on a KVM or macOS host. On a stack still carrying #869 it should fail ~89% of iterations with the channel closed line.
  2. scripts/soak-session-e2e.sh's new phase — syntax-checked only; running it invokes scripts/reap-vms.sh, which would have killed the VM I was told not to touch.
  3. Runtime cost inside the nightly's 60-minute budget (see the CODEOWNER note above).
  4. perl and zstd behaviour on the runner image — verified on macOS/arm64 only. The generator uses 32-bit arithmetic precisely so it does not depend on perl's integer width, and session-e2e.sh already depends on perl.

Refs: #869, gominimal/inbox#335

🤖 Generated with Claude Code

Note

Add canary e2e test for the bulk host-to-guest upload path

  • Adds scripts/bulk-upload-e2e.sh, a standalone bash harness that synthesizes a ~49 MiB compressible payload, then runs N timed min activate iterations, emitting CI annotations and failing if any iteration errors or hangs.
  • Adds a just bulk-upload task in justfile to invoke the new script with a configurable iteration count (default 5).
  • Updates scripts/soak-session-e2e.sh to run the bulk upload proof after session-e2e iterations and fail overall if the bulk proof fails; also fixes LOGDIR to use an absolute path and adds error handling for boot-log directory creation.

Macroscope summarized 66a09de.

Summary by CodeRabbit

  • New Features
    • Added a bulk-upload end-to-end verification task with a configurable repetition count (default 5).
    • Updated soak workflows to include a bulk host→guest upload proof after session testing.
  • Bug Fixes
    • Soak now requires both session checks and bulk-upload verification to pass.
    • Improved timeout handling and failure diagnostics for repeated checks.
  • Documentation
    • Updated task documentation to clarify the nightly soak flow and the added bulk upload step.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds a configurable bulk-upload end-to-end proof, generates and validates its upload fixture, runs repeated timed activations with cleanup, exposes the proof through just, and makes the soak workflow require the bulk proof to pass.

Changes

Bulk upload proof

Layer / File(s) Summary
Bulk harness setup
scripts/bulk-upload-e2e.sh
Configures isolated runtime state, project settings, diagnostics, deadlines, and a deterministic compressible upload fixture.
Bulk activation loop
scripts/bulk-upload-e2e.sh
Warms the daemon, repeats timed min activate . calls, validates session UUIDs, cleans up resources, and reports the final tally.
Soak and task integration
justfile, scripts/soak-session-e2e.sh
Adds the bulk-upload task, resolves absolute log paths, and runs the bulk proof after soak iterations as an overall success gate.

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

Sequence Diagram(s)

sequenceDiagram
  participant BulkUpload as bulk-upload-e2e.sh
  participant MinCLI as min CLI
  participant Daemon
  participant VMGuest as VM guest
  BulkUpload->>MinCLI: warm min ls
  MinCLI->>Daemon: start or query daemon
  Daemon->>VMGuest: initialize guest when applicable
  BulkUpload->>MinCLI: timed min activate .
  MinCLI->>Daemon: upload project and activate
  Daemon->>VMGuest: process activation when applicable
  MinCLI-->>BulkUpload: session UUID or timeout
  BulkUpload->>MinCLI: destroy session or sweep resources
Loading

Suggested reviewers: evanspearman

Poem

A rabbit packed a byte-sized sack,
Then hopped through uploads, forth and back.
Five checks thumped with tidy cheer,
While soak logs rang clear and near.
“All green!” cried Bun, and nibbled hay.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, conventional-commit shaped, and accurately describes the main change: adding a bulk host-to-guest upload canary.
Description check ✅ Passed The description is detailed and covers the change, rationale, testing, and verification, though it doesn't use the exact template headings or checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

Comment thread scripts/bulk-upload-e2e.sh
Comment thread scripts/bulk-upload-e2e.sh Outdated
Nothing in CI moved enough data through the guest vsock to catch a
transport regression: the PR-lane session e2e activates a small project,
and a guest-kernel bump broke every sizeable upload for three weeks
before a human hit it by hand.

Add scripts/bulk-upload-e2e.sh, which activates a project carrying a
synthesized ~49 MiB fixture five times and fails if any attempt errors
or hangs. The fixture is deliberately compressible — an incompressible
payload throttles the writer through the zstd encoder and hides the
failure — and its compressed size is asserted at run time so a fixture
that stops tripping the bug fails loudly instead of silently disarming
the canary. RUST_LOG and MINVMD_KRUN_LOG are pinned, both having been
measured to make the failure vanish.

Hook it into scripts/soak-session-e2e.sh, which the nightly job already
runs; no workflow change is needed.

Refs: #869

Co-Authored-By: macroscopeapp[bot] <macroscopeapp[bot]@users.noreply.github.com>
@norrietaylor
norrietaylor force-pushed the test/bulk-upload-canary-869 branch from a827cb4 to 7b90e63 Compare July 22, 2026 04:10
Comment thread scripts/soak-session-e2e.sh
…hildren

Both child harnesses cd into their own project dir before invoking `min`
(bulk-upload-e2e.sh does it at top level, session-e2e.sh inside the
activate subshell), and minvmd consumes MINVMD_BOOT_LOG verbatim — it
maps the env value straight to a PathBuf and only warns if the file
cannot be created. A relative boot-log dir therefore resolved against
the child's project dir and the guest console was discarded silently,
losing exactly the diagnostics a failing run needs.

Both current callers pass an absolute path, so this is latent, but the
directory is a documented positional argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@norrietaylor

Copy link
Copy Markdown
Member Author

@macroscope review

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Manual reviews triggered for commit a816872:

All prior checks · these links stay valid even if you push more commits.

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Just FYI for future @mentions, I'm Macroscope-App, not Macroscope.

Review triggered. The Approvability and Correctness checks are now running and will post results when complete.

Comment thread scripts/bulk-upload-e2e.sh
@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

1 blocking correctness issue found. Test-only changes owned by the author, but there's an unresolved high-severity comment about mktemp failure handling that could potentially destroy unrelated developer sessions. This should be addressed before approval.

You can customize Macroscope's approvability policy. Learn more.

`mktemp -d` was unchecked on both the /tmp and $HOME fallbacks, so a
failure left WORK empty and the script continued with an empty path.

Co-Authored-By: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@norrietaylor
norrietaylor force-pushed the test/bulk-upload-canary-869 branch from 8832709 to 66a09de Compare July 22, 2026 15:14
@norrietaylor
norrietaylor merged commit 0774b3b into main Jul 22, 2026
29 checks passed
@norrietaylor
norrietaylor deleted the test/bulk-upload-canary-869 branch July 22, 2026 17:13
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