Programming

Graceful SIGINT Shutdown in Node.js CLI with Commander

Handle SIGINT gracefully in Node.js TypeScript CLI apps using Commander.js. Finish database updates, per-item calculations, flush stdout/stderr logs, and ensure shell prompt returns only after complete shutdown—no more lost logs or early prompts.

1 answer 1 view

How can I gracefully handle SIGINT (Ctrl+C) in a Node.js (TypeScript) CLI app using commander so the process finishes current database updates and per-item calculations before exiting, and the shell prompt (or ability to type a new command) doesn’t appear before the final logs are flushed? I currently set an abort flag in process.on('SIGINT', ...), check it inside my loops, run cleanup in a finally block and call process.exit(0), but when I press Ctrl+C the terminal prompt sometimes returns before the last logs are printed and I have to press Enter to see them. What causes this behavior and how can I ensure all logs are flushed and the terminal prompt only returns after graceful shutdown completes?

To achieve a true graceful shutdown in your Node.js CLI app built with commander js and TypeScript, stop calling process.exit(0) right away—instead, on the first SIGINT (Ctrl+C), set your abort flag to halt new work, let loops finish current database updates and per-item calculations, then explicitly drain stdout/stderr buffers and flush any loggers before a controlled exit. The premature shell prompt happens because process.exit() kills the event loop instantly, abandoning queued writes and letting the terminal reclaim control mid-flush. Here’s the fix: use stream callbacks or promises to wait for buffers to empty, ensuring every log line hits the screen before the prompt blinks back.


Contents


Why the Shell Prompt Returns Too Early

Hit Ctrl+C in your CLI, and bam—the shell prompt winks back before your “Shutting down gracefully…” log even shows. Frustrating, right? This stems from Node.js’s process lifecycle. When you call process.exit(0) in your finally block, it immediately halts the event loop, ditching any pending I/O like console writes. Stdout and stderr are buffered streams; they don’t instantly sync to the terminal.

The Node.js process docs warn that SIGINT’s default handler resets terminal modes and exits harshly. Your custom handler catches it, but process.exit() mimics that abruptness. Community reports confirm: logs vanish or lag because writes get orphaned. On Unix-like systems, the terminal (TTY) detects process death and redraws the prompt pronto, even if buffers lag. Windows? Readline quirks can compound it.

Short fix preview: don’t exit until streams drain. But first, grasp the full shutdown dance.


Principles of Graceful Shutdown in Node.js

Graceful shutdown boils down to three beats: stop new work, finish ongoing tasks, clean up and flush, then exit. Think of it like closing a restaurant—turn off the “open” sign, let tables eat out their meals, bus the dishes, lock up.

Node.js shines here with signals like SIGINT (Ctrl+C) and SIGTERM. Default behavior? Harsh kill. Your job: override politely. Express.js shutdown guide nails it for servers, but it translates perfectly to CLIs: reject new inputs, complete inflight ops (your DB txns), release resources.

Key gotcha from Stack Overflow deep dives: the ‘exit’ event fires too late for async work—event loop’s toast. Do everything before exit. And for that prompt issue? Buffers. Always.


Handling SIGINT Signals Properly

Your abort flag setup is smart—checking it in loops prevents endless spins. But one handler? Risky. Users mash Ctrl+C twice for “force quit.” Handle it like this:

  • First SIGINT: flag abort, kick off shutdown promise.
  • Second: force exit (after a short grace window).

Code sketch:

typescript
let shutdownInProgress = false;
let sigintCount = 0;

const handleSigint = async () => {
 sigintCount++;
 if (shutdownInProgress) {
 console.log('Force exiting...');
 process.exit(1);
 }
 shutdownInProgress = true;
 await gracefulShutdown();
 process.exit(0);
};

process.on('SIGINT', handleSigint);
process.on('SIGTERM', handleSigint); // Bonus: handles kill commands too

Why async? Shutdown’s rarely sync—DB commits? Async city. This pattern, echoed in Node.js docs examples, buys time without blocking the signal.

Pro tip: Detach listeners post-shutdown to avoid loops. Ever seen infinite handlers? Nightmare.


Completing In-Flight Database Updates and Loops

Your per-item calcs and DB updates are the heart. Polling the abort flag in loops works, but make it snappy—break after current item.

Pseudocode for a loop:

typescript
while (!abortFlag && hasMoreItems()) {
 const item = getNextItem();
 if (abortFlag) break;
 
 await calculateItem(item); // Your heavy sync/async work
 await db.update(item); // Await commits!
}

For DBs like Postgres (via pg) or Mongo: transactions need explicit commit/rollback. Wrap batches:

typescript
const tx = await db.begin();
try {
 // Updates...
 await tx.commit();
} catch {
 await tx.rollback();
}

If using an ORM like Prisma or TypeORM, call .$disconnect() post-cleanup. Tools like node-graceful-shutdown chain these—named handlers run in order: DB after loops.

Question: What if a loop’s eternal? Timeout the whole shutdown after 10s.


Flushing Stdout, Stderr, and Loggers

Here’s the prompt killer: unflushed buffers. console.log queues writes; process.exit yeets the queue.

Gold standard from this Stack Overflow gem:

typescript
const flushStreams = (): Promise<void> =>
 new Promise((resolve, reject) => {
 const doFlush = (streams: NodeJS.WriteStream[]) =>
 streams.reduce((promise, stream) =>
 promise.then(() =>
 new Promise((res) => {
 if (stream.writableLength === 0) return res();
 stream.write('', () => res());
 })
 ), Promise.resolve());

 Promise.resolve()
 .then(() => doFlush([process.stdout, process.stderr]))
 .then(resolve)
 .catch(reject);
 });

Tack it onto shutdown: await flushStreams(); process.exit(0);. Forces empty writes, draining via callbacks.

Loggers? Winston/Pino: logger.end() or transports.forEach(t => t.close()) with awaits. Winston flush tips stress callbacks—wait or lose logs.

TTY magic: Once flushed, terminal waits for process death. No more Enter-hunt.


Commander.js and TypeScript Integration

Commander.js (that commander js package) sets up your CLI args flawlessly. Hook SIGINT early, before program.parse().

TypeScript twist: Strong types for flags/abort state.

typescript
interface AppState {
 abortFlag: boolean;
 shutdownInProgress: boolean;
}

const state: AppState = { abortFlag: false, shutdownInProgress: false };

program
 .option('--db-url <url>')
 .action(async (opts) => {
 process.on('SIGINT', handleSigint.bind(null, state));
 // Your main loop...
 });

Windows caveat: Ctrl+C via Readline might skip SIGINT—override readline.emitKeypressEvents. But for most, it just works. Test cross-platform; TTY behaves differently.

Commander doesn’t meddle with signals—your playground.


Full Working Example

Put it together. Minimal TypeScript CLI with fake DB/loop, proper flush.

typescript
#!/usr/bin/env node
import { Command } from 'commander';
import { setTimeout as sleep } from 'timers/promises';

interface State {
 abortFlag: boolean;
 shutdownInProgress: boolean;
}

const state: State = { abortFlag: false, shutdownInProgress: false };

const flushStreams = (): Promise<void> =>
 new Promise((resolve) => {
 const streams = [process.stdout, process.stderr];
 let pending = streams.length;
 streams.forEach((stream) => {
 if (stream.writableLength === 0) {
 if (--pending === 0) resolve();
 return;
 }
 stream.once('drain', () => {
 if (--pending === 0) resolve();
 });
 stream.write('');
 });
 });

const gracefulShutdown = async () => {
 console.log('🛑 Starting graceful shutdown... Finishing current work.');
 
 // Simulate DB disconnect
 await sleep(100);
 console.log('✅ DB disconnected.');
 
 // Flush everything
 await flushStreams();
 console.log('📝 All logs flushed.');
};

const handleSigint = async () => {
 if (state.shutdownInProgress) {
 console.log('💥 Force exit.');
 process.exit(1);
 }
 state.shutdownInProgress = true;
 state.abortFlag = true;
 await gracefulShutdown();
 process.exit(0);
};

const program = new Command();
program
 .name('graceful-cli')
 .description('Node.js CLI with proper SIGINT handling')
 .argument('[items...]', 'Items to process')
 .action(async (items) => {
 process.on('SIGINT', handleSigint);
 process.on('SIGTERM', handleSigint);

 let i = 0;
 while (i < items.length || i < 20) { // Fake loop
 if (state.abortFlag) {
 console.log(`⏹️ Aborting after item ${i}.`);
 break;
 }
 console.log(`Processing item ${i++}...`);
 await sleep(500); // Simulate calc + DB update
 }
 console.log('🎉 All done naturally.');
 });

program.parse();

Run npm init -y; npm i commander tsx; tsx graceful-cli -- a b c, Ctrl+C mid-run. Watch: clean exit, full logs, prompt after.


Sources

  1. Node.js Process Documentation
  2. Flush/drain stdout/stderr in Node.js process before exiting - Stack Overflow
  3. Doing a cleanup action just before Node.js exits - Stack Overflow
  4. Health Checks and Graceful Shutdown - Express
  5. node-graceful-shutdown (GitHub)
  6. How to flush winston logs? - Stack Overflow

Conclusion

Nail graceful shutdown in your TypeScript CLI with commander js by flagging abort on SIGINT, wrapping up DB updates and loops, flushing streams explicitly, and exiting only when buffers empty—no more ghost prompts or lost logs. Test it: first Ctrl+C cleans house, second forces out. This pattern scales to real loggers or DB pools. Your users get reliability; you dodge half-flushed frustration. Tweak timeouts for paranoia, and you’re golden.

Authors
Verified by moderation
Moderation
Graceful SIGINT Shutdown in Node.js CLI with Commander