OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / pipe-file-abort.test.js

192 lines · javascript ref:
// 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))
})