Skip to content

Commit f0d9e3d

Browse files
authored
fix(bootstrap): test shutdown signal handling (#365)
* fix(bootstrap): test shutdown signal handling * fix(test): exec bootstrap fixture before signaling
1 parent 683de4d commit f0d9e3d

5 files changed

Lines changed: 170 additions & 28 deletions

File tree

bootstrap.signal.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { assert, assertEquals } from '@std/assert';
2+
3+
const FIXTURE_PATH = 'tests/fixtures/signal_runtime_fixture.ts';
4+
type ShutdownSignal = 'SIGINT' | 'SIGTERM';
5+
6+
const readStream = async (stream: ReadableStream<Uint8Array> | null) => {
7+
if (!stream) return '';
8+
return await new Response(stream).text();
9+
};
10+
11+
const readUntil = async (
12+
reader: ReadableStreamDefaultReader<Uint8Array>,
13+
expected: string,
14+
) => {
15+
const decoder = new TextDecoder();
16+
let output = '';
17+
18+
while (!output.includes(expected)) {
19+
const { done, value } = await reader.read();
20+
if (done) break;
21+
output += decoder.decode(value, { stream: true });
22+
}
23+
24+
return output;
25+
};
26+
27+
const assertSignalShutdown = async (signal: ShutdownSignal) => {
28+
const child = new Deno.Command('/bin/sh', {
29+
args: ['-c', `exec deno run -P ${FIXTURE_PATH} --signal=${signal}`],
30+
stdout: 'piped',
31+
stderr: 'piped',
32+
}).spawn();
33+
34+
const reader = child.stdout!.getReader();
35+
let stdout = await readUntil(reader, 'fixture-ready');
36+
37+
child.kill(signal);
38+
39+
const status = await child.status;
40+
const remainder = await readStream(new ReadableStream({
41+
async start(controller) {
42+
while (true) {
43+
const { done, value } = await reader.read();
44+
if (done) break;
45+
controller.enqueue(value);
46+
}
47+
controller.close();
48+
reader.releaseLock();
49+
},
50+
}));
51+
stdout += remainder;
52+
const stderr = await readStream(child.stderr);
53+
54+
assertEquals(status.success, true, stderr);
55+
assert(stdout.includes(`received:${signal}`));
56+
assert(stdout.includes('close-called'));
57+
assert(stdout.includes('cleanup-complete'));
58+
};
59+
60+
Deno.test('process handles SIGTERM and exits cleanly', async () => {
61+
await assertSignalShutdown('SIGTERM');
62+
});
63+
64+
Deno.test('process handles SIGINT and exits cleanly', async () => {
65+
await assertSignalShutdown('SIGINT');
66+
});

bootstrap.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,17 @@
11
import '@std/dotenv/load';
22
import { DanetApplication, Logger } from '@danet/core';
3+
import { installShutdownHandler } from './src/common/shutdown.ts';
34
import { setup } from './src/setup.ts';
45

56
const logger = new Logger('Bootstrap');
67
const application = new DanetApplication();
78

8-
const onDispose = (tokens: number[]) => {
9-
logger.log('Deno resource cleanup initiated');
10-
Deno.removeSignalListener('SIGINT', onTerminationRequest);
11-
Deno.removeSignalListener('SIGTERM', onTerminationRequest);
12-
setTimeout(() => {
13-
tokens.forEach((token) => clearTimeout(token));
14-
logger.log('Deno resource cleanup completed, exiting process now!');
15-
Deno.exit();
16-
}, 2000);
17-
};
18-
19-
const onTerminationRequest = (): void => {
20-
logger.log('Deno recieved shutdown request from user or system');
21-
const shutDown = setTimeout(async () => {
22-
try {
23-
await application.close();
24-
} catch (error: Error | unknown) {
25-
error instanceof Error
26-
? logger.warn(error.stack ?? error.message)
27-
: logger.warn(`Unable to gracefully shutdown application: ${error}`);
28-
}
29-
});
30-
onDispose([shutDown]);
31-
};
32-
33-
Deno.addSignalListener('SIGINT', onTerminationRequest);
34-
Deno.addSignalListener('SIGTERM', onTerminationRequest);
9+
const onTerminationRequest = installShutdownHandler({
10+
close: () => application.close(),
11+
log: (message) => logger.log(message),
12+
warn: (message) => logger.warn(message),
13+
exit: () => Deno.exit(),
14+
});
3515

3616
const swaggerGen = Deno.args.includes('--swagger');
3717

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@
9090
"test": {
9191
"permissions": {
9292
"read": [".env"],
93-
"env": true
93+
"env": true,
94+
"run": ["/bin/sh"]
9495
},
9596
"include": ["**/*.test.ts", "**/*.spec.ts", "**/testing"]
9697
},

src/common/shutdown.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
export type SignalName = 'SIGINT' | 'SIGTERM';
2+
3+
type TimerHandle = ReturnType<typeof setTimeout>;
4+
5+
export type ShutdownDependencies = {
6+
signals?: SignalName[];
7+
close: () => Promise<void>;
8+
log: (message: string) => void;
9+
warn: (message: string) => void;
10+
exit: () => void;
11+
setTimer?: typeof setTimeout;
12+
clearTimer?: typeof clearTimeout;
13+
addSignalListener?: typeof Deno.addSignalListener;
14+
removeSignalListener?: typeof Deno.removeSignalListener;
15+
cleanupDelayMs?: number;
16+
};
17+
18+
export const installShutdownHandler = ({
19+
signals = ['SIGINT', 'SIGTERM'],
20+
close,
21+
log,
22+
warn,
23+
exit,
24+
setTimer = setTimeout,
25+
clearTimer = clearTimeout,
26+
addSignalListener = Deno.addSignalListener,
27+
removeSignalListener = Deno.removeSignalListener,
28+
cleanupDelayMs = 2000,
29+
}: ShutdownDependencies) => {
30+
const onDispose = (tokens: TimerHandle[]) => {
31+
log('Deno resource cleanup initiated');
32+
for (const signal of signals) {
33+
removeSignalListener(signal, onTerminationRequest);
34+
}
35+
36+
setTimer(() => {
37+
tokens.forEach((token) => clearTimer(token));
38+
log('Deno resource cleanup completed, exiting process now!');
39+
exit();
40+
}, cleanupDelayMs);
41+
};
42+
43+
const onTerminationRequest = (): void => {
44+
log('Deno recieved shutdown request from user or system');
45+
const shutDown = setTimer(async () => {
46+
try {
47+
await close();
48+
} catch (error: Error | unknown) {
49+
error instanceof Error
50+
? warn(error.stack ?? error.message)
51+
: warn(`Unable to gracefully shutdown application: ${error}`);
52+
}
53+
});
54+
55+
onDispose([shutDown]);
56+
};
57+
58+
for (const signal of signals) {
59+
addSignalListener(signal, onTerminationRequest);
60+
}
61+
62+
return onTerminationRequest;
63+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { installShutdownHandler } from '../../src/common/shutdown.ts';
2+
3+
const signalArg = Deno.args.find((arg) => arg.startsWith('--signal='));
4+
const selectedSignal = signalArg?.split('=')[1] === 'SIGINT'
5+
? 'SIGINT'
6+
: 'SIGTERM';
7+
8+
const onTerminationRequest = installShutdownHandler({
9+
close: async () => {
10+
console.log('close-called');
11+
},
12+
log: (message) => {
13+
if (message.includes('shutdown request')) {
14+
console.log(`received:${selectedSignal}`);
15+
}
16+
17+
if (message.includes('cleanup completed')) {
18+
console.log('cleanup-complete');
19+
}
20+
},
21+
warn: (message) => console.warn(message),
22+
exit: () => Deno.exit(0),
23+
cleanupDelayMs: 1,
24+
signals: [selectedSignal],
25+
});
26+
27+
console.log('fixture-ready');
28+
setInterval(() => {}, 1000);
29+
30+
if (Deno.args.includes('--trigger')) {
31+
onTerminationRequest();
32+
}

0 commit comments

Comments
 (0)