diff --git a/integration/hooks/e2e/enable-shutdown-hook.spec.ts b/integration/hooks/e2e/enable-shutdown-hook.spec.ts index 6cd93d9a1..9e35bb1d0 100644 --- a/integration/hooks/e2e/enable-shutdown-hook.spec.ts +++ b/integration/hooks/e2e/enable-shutdown-hook.spec.ts @@ -51,4 +51,21 @@ describe('enableShutdownHooks', () => { expect(result.stdout.toString().trim()).to.be.eq(''); done(); }).timeout(10000); + + it('should call the correct hooks with useProcessExit option', done => { + const result = spawnSync('ts-node', [ + join(__dirname, '../src/enable-shutdown-hooks-main.ts'), + 'SIGHUP', + 'SIGHUP', + 'graceful', + ]); + const calls = result.stdout + .toString() + .split('\n') + .map((call: string) => call.trim()); + expect(calls[0]).to.equal('beforeApplicationShutdown SIGHUP'); + expect(calls[1]).to.equal('onApplicationShutdown SIGHUP'); + expect(result.status).to.equal(0); + done(); + }).timeout(10000); }); diff --git a/integration/hooks/src/enable-shutdown-hooks-main.ts b/integration/hooks/src/enable-shutdown-hooks-main.ts index 565b2d59f..8c98caff6 100644 --- a/integration/hooks/src/enable-shutdown-hooks-main.ts +++ b/integration/hooks/src/enable-shutdown-hooks-main.ts @@ -7,6 +7,7 @@ import { import { NestFactory } from '@nestjs/core'; const SIGNAL = process.argv[2]; const SIGNAL_TO_LISTEN = process.argv[3]; +const USE_GRACEFUL_EXIT = process.argv[4] === 'graceful'; @Injectable() class TestInjectable @@ -29,10 +30,12 @@ class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: false }); + const shutdownOptions = USE_GRACEFUL_EXIT ? { useProcessExit: true } : {}; + if (SIGNAL_TO_LISTEN && SIGNAL_TO_LISTEN !== 'NONE') { - app.enableShutdownHooks([SIGNAL_TO_LISTEN]); + app.enableShutdownHooks([SIGNAL_TO_LISTEN], shutdownOptions); } else if (SIGNAL_TO_LISTEN !== 'NONE') { - app.enableShutdownHooks(); + app.enableShutdownHooks([], shutdownOptions); } await app.listen(1800); diff --git a/packages/common/interfaces/index.ts b/packages/common/interfaces/index.ts index 206cdac8b..94ceff93c 100644 --- a/packages/common/interfaces/index.ts +++ b/packages/common/interfaces/index.ts @@ -24,6 +24,7 @@ export * from './nest-application-options.interface'; export * from './nest-application.interface'; export * from './nest-microservice.interface'; export * from './scope-options.interface'; +export * from './shutdown-hooks-options.interface'; export * from './type.interface'; export * from './version-options.interface'; export * from './websockets/web-socket-adapter.interface'; diff --git a/packages/common/interfaces/nest-application-context.interface.ts b/packages/common/interfaces/nest-application-context.interface.ts index 3a8fe4396..3c2d5c4a3 100644 --- a/packages/common/interfaces/nest-application-context.interface.ts +++ b/packages/common/interfaces/nest-application-context.interface.ts @@ -2,6 +2,7 @@ import { ShutdownSignal } from '../enums/shutdown-signal.enum'; import { LoggerService, LogLevel } from '../services/logger.service'; import { DynamicModule } from './modules'; import { NestApplicationContextOptions } from './nest-application-context-options.interface'; +import { ShutdownHooksOptions } from './shutdown-hooks-options.interface'; import { Type } from './type.interface'; export type SelectOptions = Pick; @@ -143,9 +144,15 @@ export interface INestApplicationContext { * `onApplicationShutdown` function of a provider if the * process receives a shutdown signal. * + * @param {ShutdownSignal[] | string[]} [signals] The system signals to listen to + * @param {ShutdownHooksOptions} [options] Options for configuring shutdown hooks behavior + * * @returns {this} The Nest application context instance */ - enableShutdownHooks(signals?: ShutdownSignal[] | string[]): this; + enableShutdownHooks( + signals?: ShutdownSignal[] | string[], + options?: ShutdownHooksOptions, + ): this; /** * Initializes the Nest application. diff --git a/packages/common/interfaces/shutdown-hooks-options.interface.ts b/packages/common/interfaces/shutdown-hooks-options.interface.ts new file mode 100644 index 000000000..3508c9467 --- /dev/null +++ b/packages/common/interfaces/shutdown-hooks-options.interface.ts @@ -0,0 +1,21 @@ +/** + * Options for configuring shutdown hooks behavior. + * + * @publicApi + */ +export interface ShutdownHooksOptions { + /** + * If true, uses `process.exit()` instead of `process.kill(process.pid, signal)` + * after shutdown hooks complete. This ensures the 'exit' event is properly + * triggered, which is required for async loggers (like Pino with transports) + * to flush their buffers before the process terminates. + * + * Note: Using `process.exit()` will: + * - Change the exit code (e.g., SIGTERM: 143 → 0) + * - May not trigger other signal handlers from third-party libraries + * - May affect orchestrator (Kubernetes, Docker) behavior + * + * @default false + */ + useProcessExit?: boolean; +} diff --git a/packages/core/nest-application-context.ts b/packages/core/nest-application-context.ts index 8d9eb92e0..209283655 100644 --- a/packages/core/nest-application-context.ts +++ b/packages/core/nest-application-context.ts @@ -10,6 +10,7 @@ import { DynamicModule, GetOrResolveOptions, SelectOptions, + ShutdownHooksOptions, Type, } from '@nestjs/common/interfaces'; import { NestApplicationContextOptions } from '@nestjs/common/interfaces/nest-application-context-options.interface'; @@ -316,10 +317,14 @@ export class NestApplicationContext< * process receives a shutdown signal. * * @param {ShutdownSignal[]} [signals=[]] The system signals it should listen to + * @param {ShutdownHooksOptions} [options={}] Options for configuring shutdown hooks behavior * * @returns {this} The Nest application context instance */ - public enableShutdownHooks(signals: (ShutdownSignal | string)[] = []): this { + public enableShutdownHooks( + signals: (ShutdownSignal | string)[] = [], + options: ShutdownHooksOptions = {}, + ): this { if (isEmpty(signals)) { signals = Object.keys(ShutdownSignal).map( (key: string) => ShutdownSignal[key], @@ -336,7 +341,7 @@ export class NestApplicationContext< .filter(signal => !this.activeShutdownSignals.includes(signal)) .toArray(); - this.listenToShutdownSignals(signals); + this.listenToShutdownSignals(signals, options); return this; } @@ -351,8 +356,12 @@ export class NestApplicationContext< * process events * * @param {string[]} signals The system signals it should listen to + * @param {ShutdownHooksOptions} options Options for configuring shutdown hooks behavior */ - protected listenToShutdownSignals(signals: string[]) { + protected listenToShutdownSignals( + signals: string[], + options: ShutdownHooksOptions = {}, + ) { let receivedSignal = false; const cleanup = async (signal: string) => { try { @@ -368,7 +377,15 @@ export class NestApplicationContext< await this.dispose(); await this.callShutdownHook(signal); signals.forEach(sig => process.removeListener(sig, cleanup)); - process.kill(process.pid, signal); + + if (options.useProcessExit) { + // Use process.exit() to ensure the 'exit' event is properly triggered. + // This is required for async loggers (like Pino with transports) + // to flush their buffers before the process terminates. + process.exit(0); + } else { + process.kill(process.pid, signal); + } } catch (err) { Logger.error( MESSAGES.ERROR_DURING_SHUTDOWN, diff --git a/packages/core/test/nest-application-context.spec.ts b/packages/core/test/nest-application-context.spec.ts index 13ce6e660..7188f1e07 100644 --- a/packages/core/test/nest-application-context.spec.ts +++ b/packages/core/test/nest-application-context.spec.ts @@ -1,4 +1,4 @@ -import { InjectionToken, Provider, Scope, Injectable } from '@nestjs/common'; +import { Injectable, InjectionToken, Provider, Scope } from '@nestjs/common'; import { expect } from 'chai'; import * as sinon from 'sinon'; import { setTimeout } from 'timers/promises'; @@ -154,6 +154,56 @@ describe('NestApplicationContext', () => { clock.restore(); }); + + it('should use process.exit when useProcessExit option is enabled', async () => { + const signal = 'SIGTERM'; + const applicationContext = await testHelper(A, Scope.DEFAULT); + + const processExitStub = sinon.stub(process, 'exit'); + const processKillStub = sinon.stub(process, 'kill'); + + applicationContext.enableShutdownHooks([signal], { + useProcessExit: true, + }); + + const hookStub = sinon + .stub(applicationContext as any, 'callShutdownHook') + .callsFake(async () => undefined); + + const shutdownCleanupRef = applicationContext['shutdownCleanupRef']!; + await shutdownCleanupRef(signal); + + hookStub.restore(); + processExitStub.restore(); + processKillStub.restore(); + + expect(processExitStub.calledOnceWith(0)).to.be.true; + expect(processKillStub.called).to.be.false; + }); + + it('should use process.kill when useProcessExit option is not enabled', async () => { + const signal = 'SIGTERM'; + const applicationContext = await testHelper(A, Scope.DEFAULT); + + const processExitStub = sinon.stub(process, 'exit'); + const processKillStub = sinon.stub(process, 'kill'); + + applicationContext.enableShutdownHooks([signal]); + + const hookStub = sinon + .stub(applicationContext as any, 'callShutdownHook') + .callsFake(async () => undefined); + + const shutdownCleanupRef = applicationContext['shutdownCleanupRef']!; + await shutdownCleanupRef(signal); + + hookStub.restore(); + processExitStub.restore(); + processKillStub.restore(); + + expect(processKillStub.calledOnceWith(process.pid, signal)).to.be.true; + expect(processExitStub.called).to.be.false; + }); }); describe('get', () => {