The deadline
A bounded shutdown: closeout stops waiting for exit handlers after a deadline (2000 ms by default), leaves with the code it was leaving with, and names the handler that hung.
A handler that awaits something that never resolves — a socket that will not close, a lock nobody releases — turns Ctrl-C into a process the user has to kill twice. The second time is SIGKILL, which runs no handlers at all. closeout bounds the shutdown instead: after the deadline it stops waiting, and says which handler it stopped waiting for.
import { install } from 'closeout';
const { onExit } = install({ deadline: 200 });
onExit(() => console.log('flushed the log'), 'flush');
onExit(() => new Promise(() => {}), { label: 'close-the-socket' });
onExit(() => console.log('terminal handed back'), 'restore');
setTimeout(() => {}, 10_000);
process.kill(process.pid, 'SIGTERM');flushed the log
terminal handed back
closeout: shutdown deadline of 200ms expired; exiting anyway. Handlers that had not returned: close-the-socketThree things happened there:
- The hang was named. The line on stderr says which handler had not returned, by its
label— or by the function's own name, or(anonymous). An anonymous arrow is exactly the shape that hangs, so give the ones that can hang a label. - The restore still ran. The deadline stops waiting for the phase that hung; it does not skip the phases after it.
- The process left the way it was leaving. It still died of
SIGTERM. A breach never invents an exit code: a program that calledprocess.exit(3)leaves with 3.
Setting it
import { install } from 'closeout';
const closeout = install({ deadline: 5000 });
closeout.onExit(releaseTheLock);install() wires a registry of its own to the process. The process-wide onExit uses the
default, DEFAULT_DEADLINE, which is 2000 ms. That figure is provisional: measured on
five cleanup shapes, four finished inside 70 ms and the fifth (removing a directory of 100
files) depended entirely on the disk it was queued behind. closeout's README has the
measurement.
Infinity and 0 are refused where they are written, with a DeadlineError whose code is
USAGE:
import { install } from 'closeout';
try {
install({ deadline: Infinity });
} catch (error) {
console.log(error.code);
}USAGEBoth reintroduce the failure the deadline removes: one waits forever, and the other gives no asynchronous handler a turn at all.
Reporting the breach yourself
onTimeout replaces the stderr line. It is handed the whole report, so a program with a
--json mode can print the breach as JSON instead (Reports):
install({ deadline: 2000, onTimeout: (report) => logger.warn({ unfinished: report.unfinished }) });When the deadline does not apply
- Nothing hangs. When every handler settles, shutdown returns at once. A clean shutdown never pays for the bound, and one whose handlers are all synchronous returns without waiting on the clock at all.
exit. Onprocess.exit()Node gives no time at all, so there is nothing to bound: async handlers are abandoned and listed as unfinished.closeout/exit-hook. The drop-in keeps exit-hook's own bound, the per-hookwait, because a drop-in that silently tightened your timeout would not be a drop-in.
What is tested
matrix.test.ts: a handler that never returns does not stop a signal, a throw, a rejection orbeforeExitfrom ending the process, and is named in the report; a breached deadline exits with the signal's code, not one a handler set on its way past.deadline.test.ts:Infinityand0are refused; restore runs after a hang inflush.signal.test.ts: a hang that holds nothing in the event loop does not turn a signal into exit 0.
Phases
flush, release, restore: closeout runs exit handlers in declared phases, each awaited before the next, so the terminal is handed back last whatever order things registered in.
The terminal
hideCursor, rawMode and alternateScreen: change the terminal and register the undo in one call, so a program that dies by any door — even mid-shutdown — hands back a usable terminal.