diff --git a/src/update/self-update.ts b/src/update/self-update.ts index 1542b69..1b401ce 100644 --- a/src/update/self-update.ts +++ b/src/update/self-update.ts @@ -101,7 +101,9 @@ async function verifySha256(filePath: string, expected: string): Promise { } } -async function downloadFile(url: string, dest: string, onProgress?: (pct: number) => void): Promise { +// Exported for unit tests (see test/update/self-update.test.ts). Not part of +// the public CLI surface; callers go through `applySelfUpdate`. +export async function downloadFile(url: string, dest: string, onProgress?: (pct: number) => void): Promise { const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); if (!res.ok) throw new CLIError(`Download failed: ${res.status} ${res.statusText}`, ExitCode.GENERAL); @@ -111,22 +113,50 @@ async function downloadFile(url: string, dest: string, onProgress?: (pct: number const writer = createWriteStream(dest); const reader = res.body!.getReader(); - await new Promise((resolve, reject) => { - writer.on('error', reject); - const pump = async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { writer.end(); break; } - writer.write(value); - received += value.length; - if (onProgress && total > 0) onProgress(Math.round(received / total * 100)); - } - resolve(); - } catch (e) { reject(e); } - }; - pump(); - }); + try { + await new Promise((resolve, reject) => { + writer.on('error', reject); + // Resolve only after the writer has actually flushed and closed — + // otherwise verifySha256() can race the kernel's pagecache flush and + // produce spurious checksum mismatches on slow disks. + writer.on('finish', () => resolve()); + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { writer.end(); break; } + // Honour backpressure so we don't grow the writer's internal buffer + // unboundedly on large binaries / slow disks. + if (!writer.write(value)) { + await new Promise(r => writer.once('drain', r)); + } + received += value.length; + if (onProgress && total > 0) onProgress(Math.round(received / total * 100)); + } + } catch (e) { reject(e); } + }; + pump(); + }); + } catch (err) { + // Tear down the writer BEFORE unlinking dest so any buffered bytes + // can't race the unlink and leave a partial file in the pagecache, and + // so a slow flush doesn't race the next process opening the same path. + // Wait for `close` (fires after destroy on Node streams) unless the + // writer has already torn down. + if (!writer.destroyed) { + await new Promise(r => { + writer.once('close', () => r()); + writer.destroy(); + }); + } + // Don't leave a half-downloaded binary in /tmp on failure. + try { (await import('fs')).unlinkSync(dest); } catch { /* best-effort — race with concurrent cleanup is fine */ } + throw err; + } finally { + // Always release the Web Streams reader lock — the API contract requires + // a paired acquire/release, and not doing so traps the underlying body. + reader.releaseLock(); + } } export async function resolveUpdateTarget(channel: Channel): Promise { diff --git a/test/update/self-update.test.ts b/test/update/self-update.test.ts new file mode 100644 index 0000000..58847a3 --- /dev/null +++ b/test/update/self-update.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { downloadFile } from '../../src/update/self-update'; + +const originalFetch = globalThis.fetch; +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'mmx-self-update-test-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('self-update downloadFile', () => { + it('cleans up the temp file when the response stream fails mid-download', async () => { + // Regression for review feedback on PR #158: a network failure mid-stream + // must leave NOTHING behind at `dest` — earlier versions of this path + // left a partial binary in tmpdir after a 5xx mid-stream / TLS reset. + const dir = makeTempDir(); + const destPath = join(dir, 'mmx-update'); + + globalThis.fetch = (async () => { + let sentChunk = false; + const stream = new ReadableStream({ + pull(controller) { + if (!sentChunk) { + sentChunk = true; + controller.enqueue(new TextEncoder().encode('half-of-the-binary')); + return; + } + controller.error(new Error('connection reset by peer')); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'content-length': '1000' }, + }); + }) as unknown as typeof fetch; + + await expect( + downloadFile('https://example.com/mmx-linux-amd64', destPath), + ).rejects.toThrow('connection reset by peer'); + + // The exact assertion the reviewer asked for: the partial file is gone, + // i.e. cleanup runs AFTER the writer is torn down. The directory should + // contain no leftover artefact at all. + expect(existsSync(destPath)).toBe(false); + expect(readdirSync(dir)).toEqual([]); + }); + + it('writes the full payload and waits for the writer to flush on success', async () => { + // Pair-test: the happy path must still resolve with a fully-written file. + // Catches a regression where the new error-path teardown accidentally + // also runs on success and unlinks a valid binary. + const dir = makeTempDir(); + const destPath = join(dir, 'mmx-update'); + const payload = new TextEncoder().encode('the-entire-binary-payload'); + + globalThis.fetch = (async () => new Response(payload, { + status: 200, + headers: { 'content-length': String(payload.byteLength) }, + })) as unknown as typeof fetch; + + await expect( + downloadFile('https://example.com/mmx-linux-amd64', destPath), + ).resolves.toBeUndefined(); + + expect(existsSync(destPath)).toBe(true); + expect(readdirSync(dir)).toEqual(['mmx-update']); + }); + + it('rejects with a CLIError on non-2xx response without creating a file', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'mmx-update'); + + globalThis.fetch = (async () => new Response('not found', { + status: 404, + statusText: 'Not Found', + })) as unknown as typeof fetch; + + await expect( + downloadFile('https://example.com/missing', destPath), + ).rejects.toThrow(/Download failed: 404/); + + expect(existsSync(destPath)).toBe(false); + expect(readdirSync(dir)).toEqual([]); + }); +});