Reports · Issue #4394

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.

CaseAutomatic requestsBytes receivedActual errorPartial/final files
Short chunked body18,192server_move_digest_mismatchBoth absent
Socket destroyed18,192TypeError: terminatedBoth 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

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.