Interrupted downloads fail without automatic retry or resume
2026-09-30 · Bug · Medium priority · Medium effort
PARTIALLY REPRODUCED · High confidence in the download-helper result.
Claim and tested scope
The actual downloadVerifiedFile implementation was exercised against a disposable loopback HTTP server. Each scenario serves 8,192 of 32,768 expected synthetic bytes on the first request and the complete correct payload on every later request. One case ends a chunked response normally but early; the other destroys the socket after the downloader reports receiving the prefix. Both fail after exactly one request, remove the partial file, and leave no final file. A separate manual invocation succeeds against the same now-healthy endpoint, with correct size and SHA-256.
This directly tests real fetch, streaming, checksum/size validation, temporary disk writes and cleanup. It does not run an actual server move, Connect tunnel, package installation, archive extraction, daemon reconnect or user interface. The payload is a buffer of synthetic bytes, not an executable package or valid archive; the download helper treats it as opaque bytes. No visual behavior was tested and no screenshot is needed. The reported misleading “Host is not connected” status, production failure rate, platform-specific behavior and tunnel-drop cause remain unverified. The overall issue verdict is therefore partial.
Expected and actual
Desired behavior: a bounded retry after an interrupted transfer should reach the now-healthy endpoint and finish a verified download. Resume is optional for recovery, but would require retained partial data and appropriate response validation.
| Case | Automatic requests | Bytes received | Actual error | Partial/final files |
|---|---|---|---|---|
| Short chunked body | 1 | 8,192 | server_move_digest_mismatch | Both absent |
| Socket destroyed | 1 | 8,192 | TypeError: terminated | Both absent |
In both cases a manual second invocation downloads all 32,768 bytes and matches the expected digest. Neither request sends Range. The socket-reset error differs from the short-body error; this report does not assume all transport interruptions produce a size-mismatch code.
Base and environment
Trusted fetched origin/main: 79172bcaaaaadec37ae6ab7b098f25b06c7e6e99. Linux x86_64, Node 24.19.0, repository-pinned pnpm 9.15.0, Vitest 4.1.1. Two clean detached checkouts received the identical test only. Each frozen install exited 0 and each host-daemon build completed four tasks with zero cache hits. Each test case used a new temporary directory, synthetic payload, abort signal and OS-assigned loopback port. The socket interruption is triggered by receipt of the prefix, not a timing race. There is no benchmark or production throughput claim.
Dependency downloads were shared; node_modules and writable build caches were separate. No dependency or production code changed. Neither a real runtime nor external service was contacted by the test. Both tests were run with --force to avoid cached evidence.
Repeatable steps
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 79172bcaaaaadec37ae6ab7b098f25b06c7e6e99 corepack pnpm install --frozen-lockfile # Save the full inline test below as apps/host-daemon/src/server-move/issue-4394.test.ts. corepack pnpm exec turbo run build --filter=@bb/host-daemon --force corepack pnpm exec turbo run test --filter=@bb/host-daemon --force -- src/server-move/issue-4394.test.ts --silent=false
Ensure subprocess pnpm uses the pinned version. This execution used a task-local Corepack shim and writable npm/native-build caches and pnpm store. Repeat the same test personally in another clean checkout of the same SHA. The test command exits 1 with two failing desired-recovery assertions; the manual download controls and characterization checks pass. No live host configuration is needed.
Complete derived test
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { downloadVerifiedFile } from "./download.js";
for (const mode of ["short-body", "socket-reset"] as const) {
it(`4394 recovers an interrupted download: ${mode}`, async () => {
const root = await mkdtemp(join(tmpdir(), "download-fixture-"));
const destinationPath = join(root, "synthetic-package.bin");
const payload = Buffer.alloc(32768, 65);
const expectedSha256 = createHash("sha256").update(payload).digest("hex");
const ranges: (string | null)[] = [];
let requests = 0;
let interrupt = () => {};
const server = createServer((request, response) => {
requests++;
ranges.push(request.headers.range ?? null);
if (requests === 1) {
response.writeHead(200, { "content-type": "application/octet-stream" });
interrupt = () => response.destroy();
response.write(payload.subarray(0, 8192));
if (mode === "short-body") response.end();
} else {
response.writeHead(200, { "content-length": payload.length });
response.end(payload);
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("No local address");
let receivedBytes = 0;
const exists = async (file: string) => stat(file).then(() => true, () => false);
const download = () => downloadVerifiedFile({
fetchFn: fetch,
url: `http://127.0.0.1:${address.port}/synthetic-package.bin`,
headers: {}, destinationPath, expectedSha256, expectedSizeBytes: payload.length,
maxSizeBytes: payload.length, signal: AbortSignal.timeout(5000),
onProgress: (received) => {
receivedBytes = received;
if (mode === "socket-reset" && requests === 1) interrupt();
},
});
try {
let errorDetails: { name: string; message: string; code: unknown } | null = null;
try { await download(); } catch (error) {
if (!(error instanceof Error)) throw error;
errorDetails = { name: error.name, message: error.message,
code: "code" in error ? error.code : null };
}
const first = { mode, requests, receivedBytes, error: errorDetails,
destinationExists: await exists(destinationPath),
partialExists: await exists(`${destinationPath}.partial`), ranges: [...ranges] };
console.log(JSON.stringify({ phase: "automatic-attempt", ...first }));
expect.soft(first.requests).toBe(1);
expect.soft(first.receivedBytes).toBe(8192);
expect.soft(first.partialExists).toBe(false);
await download();
const content = await readFile(destinationPath);
console.log(JSON.stringify({ phase: "manual-retry-control", mode, requests,
sizeBytes: content.length, digestMatches: createHash("sha256").update(content).digest("hex") === expectedSha256,
ranges }));
expect(content).toEqual(payload);
expect(requests).toBe(2);
expect(ranges).toEqual([null, null]);
expect(first.error, "Desired behavior: retry the now-healthy endpoint automatically").toBeNull();
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
}
Actual evidence from both clean runs
First clean checkout
@bb/host-daemon:test: {"phase":"automatic-attempt","mode":"short-body","requests":1,"receivedBytes":8192,"error":{"name":"CommandDispatchError","message":"Download size mismatch: expected 32768 bytes, received 8192","code":"server_move_digest_mismatch"},"destinationExists":false,"partialExists":false,"ranges":[null]}
@bb/host-daemon:test: {"phase":"manual-retry-control","mode":"short-body","requests":2,"sizeBytes":32768,"digestMatches":true,"ranges":[null,null]}
@bb/host-daemon:test: {"phase":"automatic-attempt","mode":"socket-reset","requests":1,"receivedBytes":8192,"error":{"name":"TypeError","message":"terminated","code":null},"destinationExists":false,"partialExists":false,"ranges":[null]}
@bb/host-daemon:test: {"phase":"manual-retry-control","mode":"socket-reset","requests":2,"sizeBytes":32768,"digestMatches":true,"ranges":[null,null]}
@bb/host-daemon:test: Test Files 1 failed (1)
@bb/host-daemon:test: Tests 2 failed (2)
Frozen install and build exit 0. Focused test exits 1 with two failing desired automatic-recovery assertions. Both manual-retry controls complete with verified content.
Second clean checkout
@bb/host-daemon:test: {"phase":"automatic-attempt","mode":"short-body","requests":1,"receivedBytes":8192,"error":{"name":"CommandDispatchError","message":"Download size mismatch: expected 32768 bytes, received 8192","code":"server_move_digest_mismatch"},"destinationExists":false,"partialExists":false,"ranges":[null]}
@bb/host-daemon:test: {"phase":"manual-retry-control","mode":"short-body","requests":2,"sizeBytes":32768,"digestMatches":true,"ranges":[null,null]}
@bb/host-daemon:test: {"phase":"automatic-attempt","mode":"socket-reset","requests":1,"receivedBytes":8192,"error":{"name":"TypeError","message":"terminated","code":null},"destinationExists":false,"partialExists":false,"ranges":[null]}
@bb/host-daemon:test: {"phase":"manual-retry-control","mode":"socket-reset","requests":2,"sizeBytes":32768,"digestMatches":true,"ranges":[null,null]}
@bb/host-daemon:test: Test Files 1 failed (1)
@bb/host-daemon:test: Tests 2 failed (2)
Frozen install and build exit 0. Focused test exits 1 with two failing desired automatic-recovery assertions. Both manual-retry controls complete with verified content.
The same agent personally repeated the test in the second clean checkout at the identical SHA, with fresh local ports and data. This is not independent verification. Structured results match across both runs. No unsupported production claim was promoted from this fixture.
Root cause and source evidence
- Single fetch has no retry loop or resume offset.
- Size validation and failure cleanup rejects a short body; the catch removes the partial file and rethrows stream or integrity failures.
- Package preparation calls download awaits the download before installation, while the service download wrapper directly awaits this helper.
- Prepare failure cleanup normalizes errors, cleans the move and rethrows. These service-level links are inspected source evidence; this experiment does not invoke the full service.
Proposed fix and next useful test
Add bounded, abort-aware retry/backoff for retryable transport interruptions while preserving full size and digest verification. If implementing Range resume, retain only validated partial state and validate the response range and object identity; otherwise a fresh verified retry is the smaller change. Preserve the underlying transfer cause in move status. No fix was applied. Next test: invoke the full prepare service with synthetic installation hooks and an isolated reconnecting transport, then verify user-visible error reporting and move state.
Trust, concurrency and publication checks
Issue prose, code, commands and links were treated as untrusted claims only. The test was derived from trusted main and its helper contract; no issue commands, patches, branches or external links were executed. No secrets, real user files, actual Connect tunnel or real package installation were used. The repository was confirmed public. All comments were read (zero initially); no PR reference appeared among 57 open PRs or linked timeline metadata. A related issue reference is not a PR or proof of duplicate scope. No report for this issue was present before publication. Only this HTML and summary metadata are published, with the test and output inline. There are no external assets.