// The membrane's shutdown must END, so an OTA respawn always happens.
//
// ⛔ THE INCIDENT (0.9.50 OTA, 2026-10-12). release-ready logged "exiting 75", then the process took
// ~50 s to exit while pds.7rnx.net answered 503, and it exited 0, not 75. The exit code was wrong
// because shutdown() called process.exit(0) itself before release-ready's `.then(exit 75)` could run.
// Which await was slow was never recorded.
//
// So the sequence must (1) exit with the code its caller asked for, (2) time every step, and (3) give
// up at a deadline, naming the step it was stuck on, so the next slow OTA says where the time went.
import '../../lib/bare-globals.js' // FIRST: cborg touches TextEncoder at module load
import test from 'brittle'
import tcp from 'bare-tcp'
import { createPdsServer } from '../../lib/server-bare.js'
import { createShutdown } from '../../lib/shutdown.js'
const never = () => new Promise(() => {})
// Records exit() instead of exiting, and resolves when it is called.
function fakeExit () {
let resolve = null
const exited = new Promise((r) => { resolve = r })
const exit = (code) => { exit.code = code; resolve(code) }
exit.code = null
exit.exited = exited
return exit
}
const DID = 'did:plc:shutdownhold'
// A fronted DID whose front never resolves: every request for it stays in flight.
function hangingPeers () {
let called = null
const entered = new Promise((resolve) => { called = resolve })
return {
entered,
isFronted: (did) => did === DID,
resolveHandle: () => null,
front: () => { called(); return new Promise(() => {}) }
}
}
async function listening (peers) {
const server = createPdsServer({ peers })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return server
}
// A raw request, so no client-side agent or keep-alive policy decides anything.
function holdRequest (port) {
const socket = tcp.createConnection(port, '127.0.0.1')
socket.on('error', () => {})
socket.write(`GET /xrpc/com.atproto.sync.getLatestCommit?did=${DID} HTTP/1.1\r\nHost: x\r\n\r\n`)
return socket
}
const within = (p, ms) => Promise.race([p.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), ms))])
test('⛔ an OTA shutdown exits 75, not 0', async (t) => {
const exit = fakeExit()
const shutdown = createShutdown({ steps: [['a', async () => {}]], exit, log: () => {} })
await shutdown(75)
t.is(await exit.exited, 75, 'the code the caller asked for — 0.9.50 exited 0 on release-ready')
})
test('⛔ a step that never settles cannot hold the exit past the deadline', async (t) => {
const exit = fakeExit()
const lines = []
const shutdown = createShutdown({
steps: [['control', async () => {}], ['swarm', never], ['server', async () => {}]],
deadlineMs: 300,
exit,
log: (o) => lines.push(o)
})
shutdown(75)
t.ok(await within(exit.exited, 2000), 'exit is called although a step is still pending')
t.is(exit.code, 75, 'with the requested code')
t.ok(lines.some((o) => o.stuck === 'swarm'), 'and the log names the step it was stuck on')
})
test('every step is timed, and a throwing step does not stop the ones after it', async (t) => {
const exit = fakeExit()
const ran = []
const lines = []
const shutdown = createShutdown({
steps: [['a', async () => { ran.push('a'); throw new Error('boom') }], ['b', async () => { ran.push('b') }]],
exit,
log: (o) => lines.push(o)
})
await shutdown(0)
await exit.exited
t.alike(ran, ['a', 'b'], 'b still runs after a throws')
const steps = lines.filter((o) => o.step).map((o) => o.step)
t.alike(steps, ['a', 'b'], 'one timing line per step')
t.ok(lines.every((o) => !o.step || Number.isFinite(o.ms)), 'each with its duration in ms')
})
test('a second call (SIGTERM during an OTA) does not run the steps again', async (t) => {
const exit = fakeExit()
let runs = 0
const shutdown = createShutdown({ steps: [['a', async () => { runs++ }]], exit, log: () => {} })
await Promise.all([shutdown(75), shutdown(0)])
await exit.exited
t.is(runs, 1, 'steps run once')
t.is(exit.code, 75, 'and the first caller\'s code wins')
})
// MEASURED, and kept as a guard on the reading above: bare-http1 4.5.7 marks a connection idle as soon
// as its REQUEST stream closes (server-connection.js `_req.on('close')`), which for a bodyless GET is
// immediate, so close() destroys it even while the handler is still awaiting. An in-flight GET is not
// what held 0.9.50's exit.
test('server.close() is not held by an in-flight bodyless GET', async (t) => {
const peers = hangingPeers()
const server = await listening(peers)
const client = holdRequest(server.address().port)
await peers.entered
const closed = within(new Promise((resolve) => server.close(resolve)), 1500)
t.ok(await closed, 'server.close() completes with a request in flight')
client.destroy()
})