From 8ccf7845cd4009eb25618172b7ef7a2792515a19 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 13 Sep 2024 11:46:45 +0200 Subject: [PATCH] backend-app-api: single registry and shutdown hook for backend instances Signed-off-by: Patrik Oldsberg --- .changeset/chatty-bikes-yawn.md | 5 ++ .../src/wiring/BackendInitializer.ts | 79 +++++++++++++++---- 2 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 .changeset/chatty-bikes-yawn.md diff --git a/.changeset/chatty-bikes-yawn.md b/.changeset/chatty-bikes-yawn.md new file mode 100644 index 0000000000..74ce535c68 --- /dev/null +++ b/.changeset/chatty-bikes-yawn.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +All created backend instances now share a the same `process` exit listeners, and on exit the process will wait for all backend instances to shut down before exiting. This fixes the `EventEmitter` leak warnings in tests. diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 7e29276a77..834c025e35 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -49,8 +49,56 @@ export interface BackendRegisterInit { }; } +/** + * A registry of backend instances, used to manage process shutdown hooks across all instances. + */ +const instanceRegistry = new (class InstanceRegistry { + #registered = false; + #instances = new Set(); + + register(instance: BackendInitializer) { + if (!this.#registered) { + this.#registered = true; + + process.addListener('SIGTERM', this.#exitHandler); + process.addListener('SIGINT', this.#exitHandler); + process.addListener('beforeExit', this.#exitHandler); + } + + this.#instances.add(instance); + } + + unregister(instance: BackendInitializer) { + this.#instances.delete(instance); + } + + #exitHandler = async () => { + try { + const results = await Promise.allSettled( + Array.from(this.#instances).map(b => b.stop()), + ); + const errors = results.flatMap(r => + r.status === 'rejected' ? [r.reason] : [], + ); + + if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + process.exit(1); + } else { + process.exit(0); + } + } catch (error) { + console.error(error); + process.exit(1); + } + }; +})(); + export class BackendInitializer { #startPromise?: Promise; + #stopPromise?: Promise; #registrations = new Array(); #extensionPoints = new Map(); #serviceRegistry: ServiceRegistry; @@ -129,24 +177,11 @@ export class BackendInitializer { if (this.#startPromise) { throw new Error('Backend has already started'); } + if (this.#stopPromise) { + throw new Error('Backend has already stopped'); + } - const exitHandler = async () => { - process.removeListener('SIGTERM', exitHandler); - process.removeListener('SIGINT', exitHandler); - process.removeListener('beforeExit', exitHandler); - - try { - await this.stop(); - process.exit(0); - } catch (error) { - console.error(error); - process.exit(1); - } - }; - - process.addListener('SIGTERM', exitHandler); - process.addListener('SIGINT', exitHandler); - process.addListener('beforeExit', exitHandler); + instanceRegistry.register(this); this.#startPromise = this.#doStart(); await this.#startPromise; @@ -336,7 +371,17 @@ export class BackendInitializer { } } + // It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit async stop(): Promise { + instanceRegistry.register(this); + + if (!this.#stopPromise) { + this.#stopPromise = this.#doStop(); + } + await this.#stopPromise; + } + + async #doStop(): Promise { if (!this.#startPromise) { return; }