fix(core): gracefully shutdown the app when repl exits

The application module we pass to repl may have long-running phases
(e.g. database connection polling) and if we don't finish those
long-running phases the repl would stuck on exit possibly forever.

I encoutered this issue while using MikroORM.forRoot module.

The solution is to call `app.close()` when repl exits. Modules should
implement graceful shutdown using lifecycle hooks.
This commit is contained in:
Dmitry Zhlobo
2025-05-28 14:05:28 +02:00
parent 7b7ef73ebd
commit b84c1fe459
6 changed files with 132 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import { expect } from 'chai';
import { spawn } from 'child_process';
const PROMPT = '> ';
describe('REPL process', function () {
let replProcess: ReturnType<typeof spawn>;
function waitForReplToStart(
process: ReturnType<typeof spawn>,
message,
timeout = 10000,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('REPL did not start in time'));
}, timeout);
if (!process.stdout || !process.stderr) {
return reject(new Error('REPL stdout or stderr is not available'));
}
process.stdout.on('data', data => {
if (data.toString().includes(message)) {
clearTimeout(timer);
resolve();
}
});
process.stderr.on('data', data => {
if (data.toString().includes(message)) {
clearTimeout(timer);
reject(new Error(`REPL started with error: ${data}`));
}
});
});
}
beforeEach(async function () {
this.timeout(15000);
replProcess = spawn('ts-node', ['../src/repl.ts'], { cwd: __dirname });
await waitForReplToStart(replProcess, PROMPT);
});
afterEach(function () {
if (replProcess) {
replProcess.kill(9);
}
});
it('exits on .exit', async function () {
this.timeout(1000);
return new Promise((resolve, reject) => {
replProcess.on('exit', _ => {
expect(replProcess.exitCode).to.equal(0);
resolve();
});
replProcess.on('error', err => {
reject(err);
});
if (replProcess.stdin) {
replProcess.stdin.write('.exit\n');
} else {
reject(new Error('REPL stdin is not available'));
}
});
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
@Injectable()
export class DatabaseConnection implements OnModuleDestroy {
keepAlive = true;
static connect(): DatabaseConnection {
const connection = new DatabaseConnection();
connection.maintainConnection();
return connection;
}
onModuleDestroy() {
this.keepAlive = false;
}
maintainConnection() {
setTimeout(() => {
if (this.keepAlive) {
this.maintainConnection();
}
}, 10);
}
}

View File

@@ -0,0 +1,20 @@
import { DynamicModule, Module } from '@nestjs/common';
import { DatabaseConnection } from './database.connection';
@Module({})
export class DatabaseModule {
static forRoot(): DynamicModule {
const connectionProvider = {
provide: DatabaseConnection,
useFactory: () => {
return DatabaseConnection.connect();
},
};
return {
global: true,
module: DatabaseModule,
providers: [connectionProvider],
exports: [connectionProvider],
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [DatabaseModule.forRoot()],
})
export class LongLivingAppModule {}

View File

@@ -0,0 +1,7 @@
import { repl } from '@nestjs/core';
import { LongLivingAppModule } from './long-living-app.module';
async function bootstrap() {
await repl(LongLivingAppModule);
}
bootstrap();

View File

@@ -32,5 +32,9 @@ export async function repl(
defineDefaultCommandsOnRepl(replServer);
replServer.on('exit', async () => {
await app.close();
});
return replServer;
}