@moreapp/common-nodejs 0.12.7 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -3,9 +3,11 @@ import ExpressRequestTracker from "./ExpressRequestTracker";
|
|
|
3
3
|
import ObservabilityServer from "./ObservabilityServer";
|
|
4
4
|
export default class TerminationHandler {
|
|
5
5
|
private readonly server;
|
|
6
|
-
private readonly
|
|
6
|
+
private readonly observabilityServer;
|
|
7
7
|
private readonly requestTracker;
|
|
8
|
+
private readonly onTermination;
|
|
8
9
|
private readonly sockets;
|
|
9
|
-
|
|
10
|
+
private isShuttingDown;
|
|
11
|
+
constructor(server: Server, observabilityServer: ObservabilityServer, requestTracker: ExpressRequestTracker, onTermination?: () => Promise<void>);
|
|
10
12
|
shutdown(): void;
|
|
11
13
|
}
|
|
@@ -10,13 +10,16 @@ const TERMINATION_SIGNALS = ["SIGTERM", "SIGHUP", "SIGINT"];
|
|
|
10
10
|
const SHUTDOWN_TIMEOUT = 30_000;
|
|
11
11
|
class TerminationHandler {
|
|
12
12
|
server;
|
|
13
|
-
|
|
13
|
+
observabilityServer;
|
|
14
14
|
requestTracker;
|
|
15
|
+
onTermination;
|
|
15
16
|
sockets = new Set();
|
|
16
|
-
|
|
17
|
+
isShuttingDown = false;
|
|
18
|
+
constructor(server, observabilityServer, requestTracker, onTermination = () => Promise.resolve()) {
|
|
17
19
|
this.server = server;
|
|
18
|
-
this.
|
|
20
|
+
this.observabilityServer = observabilityServer;
|
|
19
21
|
this.requestTracker = requestTracker;
|
|
22
|
+
this.onTermination = onTermination;
|
|
20
23
|
for (const signal of TERMINATION_SIGNALS) {
|
|
21
24
|
process.on(signal, () => {
|
|
22
25
|
logger_1.logger.info("Received termination signal", { signal });
|
|
@@ -27,21 +30,35 @@ class TerminationHandler {
|
|
|
27
30
|
this.sockets.add(socket);
|
|
28
31
|
socket.on("close", () => this.sockets.delete(socket));
|
|
29
32
|
});
|
|
33
|
+
// Handles errors like `EADDRINUSE`
|
|
34
|
+
server.on("error", (err) => {
|
|
35
|
+
logger_1.logger.error("Server threw an unrecoverable error, shutting down", err);
|
|
36
|
+
this.shutdown();
|
|
37
|
+
});
|
|
30
38
|
}
|
|
31
39
|
shutdown() {
|
|
32
|
-
this.
|
|
40
|
+
if (this.isShuttingDown) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.isShuttingDown = true;
|
|
44
|
+
this.observabilityServer.setReady(false);
|
|
33
45
|
logger_1.logger.info("Stopping server");
|
|
34
46
|
this.server.close();
|
|
35
47
|
const interval = (0, node_timers_1.setInterval)(() => {
|
|
36
48
|
const activeRequests = this.requestTracker.nrOfActiveRequests();
|
|
37
49
|
if (activeRequests === 0) {
|
|
38
|
-
(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
logger_1.logger.
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
void (async () => {
|
|
51
|
+
(0, node_timers_1.clearInterval)(interval);
|
|
52
|
+
// Destroy any remaining sockets
|
|
53
|
+
this.sockets.forEach((socket) => socket.destroy());
|
|
54
|
+
await this.onTermination().catch((err) => logger_1.logger.error("Error occurred in termination handler", err));
|
|
55
|
+
void Promise.all([this.observabilityServer.stop(), tracer_1.default.shutdown()])
|
|
56
|
+
.catch((err) => logger_1.logger.error("Error occurred during shutdown", err))
|
|
57
|
+
.finally(() => {
|
|
58
|
+
logger_1.logger.info("Server stopped");
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
})();
|
|
45
62
|
}
|
|
46
63
|
else {
|
|
47
64
|
logger_1.logger.info("Waiting for requests to finish", { activeRequests });
|
|
@@ -40,6 +40,7 @@ const vitest_1 = require("vitest");
|
|
|
40
40
|
const node_events_1 = require("node:events");
|
|
41
41
|
const timers = __importStar(require("node:timers"));
|
|
42
42
|
const TerminationHandler_1 = __importDefault(require("../TerminationHandler"));
|
|
43
|
+
const logger_1 = require("../../logger");
|
|
43
44
|
vitest_1.vi.mock("../../logger", () => ({
|
|
44
45
|
logger: {
|
|
45
46
|
info: vitest_1.vi.fn(),
|
|
@@ -115,12 +116,9 @@ vitest_1.vi.mock("node:timers", () => ({
|
|
|
115
116
|
(0, vitest_1.expect)(server.close).toHaveBeenCalled();
|
|
116
117
|
// First interval tick should see 0 active and exit
|
|
117
118
|
intervalCb?.();
|
|
118
|
-
//
|
|
119
|
-
(0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
|
|
120
|
-
// Wait for Promise.all and .finally() to execute
|
|
119
|
+
// Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
|
|
121
120
|
await new Promise((resolve) => setImmediate(resolve));
|
|
122
|
-
|
|
123
|
-
timeoutCallbacks[1]?.();
|
|
121
|
+
(0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
|
|
124
122
|
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
|
|
125
123
|
});
|
|
126
124
|
(0, vitest_1.test)("shutdown polls until requests finish then exits 0", async () => {
|
|
@@ -140,11 +138,9 @@ vitest_1.vi.mock("node:timers", () => ({
|
|
|
140
138
|
(0, vitest_1.expect)(exitSpy).not.toHaveBeenCalled();
|
|
141
139
|
// Second tick: now 0
|
|
142
140
|
intervalCb?.();
|
|
143
|
-
(
|
|
144
|
-
// Wait for Promise.all and .finally() to execute
|
|
141
|
+
// Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
|
|
145
142
|
await new Promise((resolve) => setImmediate(resolve));
|
|
146
|
-
|
|
147
|
-
timeoutCallbacks[1]?.();
|
|
143
|
+
(0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
|
|
148
144
|
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
|
|
149
145
|
});
|
|
150
146
|
(0, vitest_1.test)("forceful stop after 30s destroys sockets and exits 1", () => {
|
|
@@ -168,4 +164,71 @@ vitest_1.vi.mock("node:timers", () => ({
|
|
|
168
164
|
(0, vitest_1.expect)(socket.destroy).toHaveBeenCalled();
|
|
169
165
|
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(1);
|
|
170
166
|
});
|
|
167
|
+
(0, vitest_1.test)("invokes onTermination during graceful shutdown then exits 0", async () => {
|
|
168
|
+
const server = createFakeServer();
|
|
169
|
+
const hs = {
|
|
170
|
+
setReady: vitest_1.vi.fn(),
|
|
171
|
+
stop: vitest_1.vi.fn(() => Promise.resolve()),
|
|
172
|
+
};
|
|
173
|
+
const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
|
|
174
|
+
const onTermination = vitest_1.vi.fn(() => Promise.resolve());
|
|
175
|
+
const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
|
|
176
|
+
new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
|
|
177
|
+
// First interval tick sees 0 active and runs the graceful path
|
|
178
|
+
intervalCb?.();
|
|
179
|
+
// Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
|
|
180
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
181
|
+
(0, vitest_1.expect)(onTermination).toHaveBeenCalledTimes(1);
|
|
182
|
+
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
|
|
183
|
+
});
|
|
184
|
+
(0, vitest_1.test)("catches and logs onTermination rejection but still exits 0", async () => {
|
|
185
|
+
const server = createFakeServer();
|
|
186
|
+
const hs = {
|
|
187
|
+
setReady: vitest_1.vi.fn(),
|
|
188
|
+
stop: vitest_1.vi.fn(() => Promise.resolve()),
|
|
189
|
+
};
|
|
190
|
+
const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
|
|
191
|
+
const onTermination = vitest_1.vi.fn(() => Promise.reject(new Error("boom")));
|
|
192
|
+
const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
|
|
193
|
+
new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
|
|
194
|
+
intervalCb?.();
|
|
195
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
196
|
+
(0, vitest_1.expect)(vitest_1.vi.mocked(logger_1.logger.error)).toHaveBeenCalledWith("Error occurred in termination handler", vitest_1.expect.any(Error));
|
|
197
|
+
// A failing hook must not divert to the forceful exit-1 path
|
|
198
|
+
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
|
|
199
|
+
});
|
|
200
|
+
(0, vitest_1.test)("shuts down when the server emits an error", async () => {
|
|
201
|
+
const server = createFakeServer();
|
|
202
|
+
const hs = {
|
|
203
|
+
setReady: vitest_1.vi.fn(),
|
|
204
|
+
stop: vitest_1.vi.fn(() => Promise.resolve()),
|
|
205
|
+
};
|
|
206
|
+
const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
|
|
207
|
+
const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
|
|
208
|
+
new TerminationHandler_1.default(server, hs, requestTracker);
|
|
209
|
+
// e.g. EADDRINUSE surfaces as a server "error" event
|
|
210
|
+
server.emit("error", new Error("EADDRINUSE"));
|
|
211
|
+
(0, vitest_1.expect)(vitest_1.vi.mocked(logger_1.logger.error)).toHaveBeenCalledWith("Server threw an unrecoverable error, shutting down", vitest_1.expect.any(Error));
|
|
212
|
+
(0, vitest_1.expect)(hs.setReady).toHaveBeenCalledWith(false);
|
|
213
|
+
(0, vitest_1.expect)(server.close).toHaveBeenCalled();
|
|
214
|
+
intervalCb?.();
|
|
215
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
216
|
+
(0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
|
|
217
|
+
});
|
|
218
|
+
(0, vitest_1.test)("ignores a second shutdown call", () => {
|
|
219
|
+
const server = createFakeServer();
|
|
220
|
+
const hs = {
|
|
221
|
+
setReady: vitest_1.vi.fn(),
|
|
222
|
+
stop: vitest_1.vi.fn(() => Promise.resolve()),
|
|
223
|
+
};
|
|
224
|
+
// Never reaches 0 so the poll loop stays active between the two calls
|
|
225
|
+
const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 5) };
|
|
226
|
+
vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
|
|
227
|
+
const th = new TerminationHandler_1.default(server, hs, requestTracker);
|
|
228
|
+
th.shutdown();
|
|
229
|
+
th.shutdown();
|
|
230
|
+
(0, vitest_1.expect)(hs.setReady).toHaveBeenCalledTimes(1);
|
|
231
|
+
(0, vitest_1.expect)(server.close).toHaveBeenCalledTimes(1);
|
|
232
|
+
(0, vitest_1.expect)(vitest_1.vi.mocked(timers.setInterval)).toHaveBeenCalledTimes(1);
|
|
233
|
+
});
|
|
171
234
|
});
|