// Regression: an aborted / failing file download must NOT kill the daemon.
//
// celltron, 2026-08-07: clicking a partially-synced repo repeatedly took the
// whole forge down. `fs.createReadStream(p).pipe(res)` had no error handler, so
// when the pipe was destroyed mid-body streamx raised
//
// Uncaught StreamError: Writable stream closed { code: 'PREMATURE_CLOSE' }
// at ReadableState.afterDestroy (streamx)
// at FileReadStream._destroy (bare-fs)
//
// with nothing to catch it, and Bare terminated the process. Each death cost
// ~2.5 min (60 s supervisor backoff + ~100 s remounting 36 repos), so a user
// retrying the link kept it in a self-sustaining crash loop.
//
// The enclosing try/catch at the call sites was useless: it only guards the
// synchronous setup, and the stream error arrives on a later tick.
//
// PASS = pipeFile survives (a) a reader that hangs up mid-body, (b) a read
// stream that errors outright, emitting no uncaught error in either case, and
// always tearing both ends down exactly once.
import test from 'brittle'
import { mkdtempSync, writeFileSync, createReadStream } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import http from '#node:http'
import net from '#node:net'
import { pipeFile } from '../lib/web/pipe-file.js'
// Minimal stand-ins with the only surface pipeFile touches. Using fakes rather
// than a real socket keeps the test deterministic — the production bug is about
// WHO handles the error, not about real I/O.
function fakeStream () {
const handlers = new Map()
return {
destroyed: false,
piped: null,
on (ev, fn) { handlers.set(ev, fn); return this },
emit (ev, arg) { const fn = handlers.get(ev); if (fn) fn(arg); return this },
pipe (dest) { this.piped = dest; return dest },
destroy () { this.destroyed = true },
_has (ev) { return handlers.has(ev) }
}
}
function fakeRes () {
const handlers = new Map()
return {
destroyed: false,
on (ev, fn) { handlers.set(ev, fn); return this },
emit (ev, arg) { const fn = handlers.get(ev); if (fn) fn(arg); return this },
destroy () { this.destroyed = true },
_has (ev) { return handlers.has(ev) }
}
}
test('pipeFile attaches an error handler before piping', (t) => {
const s = fakeStream()
const res = fakeRes()
const ret = pipeFile(s, res, 'x')
t.is(ret, true, 'returns true so callers keep the `return pipeFile(...)` shape')
t.ok(s._has('error'), 'read stream has an error handler — this is the whole fix')
t.ok(res._has('error'), 'response has an error handler')
t.is(s.piped, res, 'still actually pipes to the response')
})
test('PREMATURE_CLOSE from the read stream is swallowed, both ends destroyed', (t) => {
const s = fakeStream()
const res = fakeRes()
pipeFile(s, res, 'aborted-download')
const err = new Error('Writable stream closed')
err.code = 'PREMATURE_CLOSE'
// Before the fix this threw out of the emit and became an uncaught error.
t.execution(() => s.emit('error', err), 'PREMATURE_CLOSE does not escape')
t.ok(s.destroyed, 'read stream destroyed')
t.ok(res.destroyed, 'response destroyed')
})
test('a genuine read error is also contained', (t) => {
const s = fakeStream()
const res = fakeRes()
pipeFile(s, res, 'bad-file')
const err = new Error('EIO: i/o error')
err.code = 'EIO'
t.execution(() => s.emit('error', err), 'read error does not escape')
t.ok(s.destroyed, 'read stream destroyed')
t.ok(res.destroyed, 'response destroyed')
})
test('client hanging up (res close) destroys the read stream, no double-teardown', (t) => {
const s = fakeStream()
const res = fakeRes()
pipeFile(s, res, 'client-gone')
res.emit('close')
t.ok(s.destroyed, 'read stream destroyed when the reader goes away')
t.absent(res.destroyed, 'response not re-destroyed — it is already closing')
// A late error after close must stay contained too (the real ordering on an
// aborted download: close fires, then the stream reports PREMATURE_CLOSE).
const err = new Error('Writable stream closed')
err.code = 'PREMATURE_CLOSE'
t.execution(() => s.emit('error', err), 'late error after close does not escape')
})
test('teardown runs at most once', (t) => {
const s = fakeStream()
const res = fakeRes()
pipeFile(s, res, 'once')
let destroys = 0
s.destroy = () => { destroys++ }
const err = new Error('boom'); err.code = 'PREMATURE_CLOSE'
s.emit('error', err)
s.emit('error', err)
res.emit('close')
t.is(destroys, 1, 'stream destroyed exactly once across repeated failures')
})
// ── the real thing ────────────────────────────────────────────────
// The fakes above prove the handler is wired. This proves the actual
// production symptom is gone: a genuine fs read stream, a genuine HTTP
// response, and a client that rips the socket away mid-body.
//
// Without the fix this emits an UNCAUGHT PREMATURE_CLOSE. Under `bare` that
// terminates the process — which is precisely what killed the forge — and the
// test run dies rather than reporting a failure. With the fix the server keeps
// serving, which the second request asserts.
test('aborting a real download mid-body leaves the server alive', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'pgv3-pipe-'))
const file = join(dir, 'big.bin')
// Comfortably larger than any socket buffer so the abort lands mid-transfer
// rather than after the whole body is already flushed.
writeFileSync(file, Buffer.alloc(8 * 1024 * 1024, 0x61))
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/octet-stream' })
pipeFile(createReadStream(file), res, 'big.bin')
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
// Raw socket so we can hang up hard, mid-body, the way a browser does when
// the user navigates away from a slow download.
await new Promise((resolve, reject) => {
// createConnection, not connect: bare-net exports only the former, Node
// exports both — this test must run under `bare`, since the crash is a
// bare-fs + streamx behaviour that Node's own streams tolerate.
const sock = net.createConnection(port, '127.0.0.1', () => {
sock.write('GET /big.bin HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n')
})
let seen = 0
sock.on('data', (chunk) => {
seen += chunk.length
if (seen > 16 * 1024) { sock.destroy(); resolve() } // ← rip it away
})
sock.on('error', () => resolve()) // ECONNRESET is fine
sock.on('close', () => resolve())
setTimeout(() => { sock.destroy(); reject(new Error('never received a body')) }, 10000).unref?.()
})
// Give the aborted pipe a few ticks to surface its error. Reaching the next
// line at all is most of the point: an uncaught PREMATURE_CLOSE would have
// taken the process down before here.
await new Promise((r) => setTimeout(r, 250))
t.pass('server process survived the aborted download')
// And it is still functional, not just alive.
const ok = await new Promise((resolve, reject) => {
const req = http.request({ hostname: '127.0.0.1', port, path: '/again', method: 'GET' }, (res) => {
let n = 0
res.on('data', (c) => { n += c.length })
res.on('end', () => resolve({ status: res.statusCode, n }))
})
req.on('error', reject)
req.end()
})
t.is(ok.status, 200, 'still serving after the abort')
t.is(ok.n, 8 * 1024 * 1024, 'second download completes in full')
await new Promise((resolve) => server.close(resolve))
})