@aws-blocks/core 0.1.13 → 0.1.18

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.
Files changed (65) hide show
  1. package/README.md +180 -17
  2. package/dist/cdk/blocks-backend.d.ts +4 -0
  3. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  4. package/dist/cdk/blocks-backend.js +23 -1
  5. package/dist/cdk/blocks-backend.test.js +71 -1
  6. package/dist/cdk/blocks-stack.test.js +32 -1
  7. package/dist/cdk/index.d.ts +13 -0
  8. package/dist/cdk/index.d.ts.map +1 -1
  9. package/dist/cdk/index.js +24 -0
  10. package/dist/cors.d.ts +27 -1
  11. package/dist/cors.d.ts.map +1 -1
  12. package/dist/cors.js +55 -2
  13. package/dist/cors.test.js +81 -2
  14. package/dist/errors.test.js +26 -1
  15. package/dist/hosting.d.ts.map +1 -1
  16. package/dist/hosting.js +26 -1
  17. package/dist/hosting.test.js +73 -0
  18. package/dist/lambda-handler.d.ts.map +1 -1
  19. package/dist/lambda-handler.js +4 -17
  20. package/dist/lambda-handler.test.js +59 -2
  21. package/dist/rpc.test.js +77 -1
  22. package/dist/scripts/console.d.ts.map +1 -1
  23. package/dist/scripts/console.js +30 -2
  24. package/dist/scripts/deploy-stream.d.ts +181 -0
  25. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  26. package/dist/scripts/deploy-stream.js +332 -0
  27. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  28. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  29. package/dist/scripts/deploy-stream.test.js +845 -0
  30. package/dist/scripts/deploy.d.ts.map +1 -1
  31. package/dist/scripts/deploy.js +16 -9
  32. package/dist/scripts/dev-server-cors.test.js +19 -1
  33. package/dist/scripts/dev-server-rpc.test.js +50 -0
  34. package/dist/scripts/dev-server.d.ts +8 -0
  35. package/dist/scripts/dev-server.d.ts.map +1 -1
  36. package/dist/scripts/dev-server.js +35 -8
  37. package/dist/scripts/sandbox.js +1 -1
  38. package/dist/telemetry/client.js +4 -4
  39. package/dist/telemetry/telemetry-send-worker.js +4 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +10 -1
  43. package/src/cdk/blocks-backend.test.ts +90 -1
  44. package/src/cdk/blocks-backend.ts +24 -1
  45. package/src/cdk/blocks-stack.test.ts +41 -1
  46. package/src/cdk/index.ts +25 -0
  47. package/src/cors.test.ts +96 -2
  48. package/src/cors.ts +59 -2
  49. package/src/errors.test.ts +29 -1
  50. package/src/hosting.test.ts +107 -0
  51. package/src/hosting.ts +27 -1
  52. package/src/lambda-handler.test.ts +71 -2
  53. package/src/lambda-handler.ts +4 -20
  54. package/src/rpc.test.ts +96 -1
  55. package/src/scripts/console.ts +29 -2
  56. package/src/scripts/deploy-stream.test.ts +1035 -0
  57. package/src/scripts/deploy-stream.ts +475 -0
  58. package/src/scripts/deploy.ts +18 -11
  59. package/src/scripts/dev-server-cors.test.ts +26 -1
  60. package/src/scripts/dev-server-rpc.test.ts +54 -0
  61. package/src/scripts/dev-server.ts +38 -8
  62. package/src/scripts/sandbox.ts +1 -1
  63. package/src/telemetry/client.ts +4 -4
  64. package/src/telemetry/telemetry-send-worker.ts +5 -0
  65. package/src/version.ts +1 -1
@@ -0,0 +1,1035 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it, type TestContext } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { EventEmitter } from 'node:events';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import {
12
+ buildCdkDeployArgs,
13
+ createLineAssembler,
14
+ decideSignalResponse,
15
+ formatElapsed,
16
+ runStreaming,
17
+ DeployProcessError,
18
+ SIGNAL_COALESCE_MS,
19
+ type OutputSink,
20
+ type SignalRegistry,
21
+ } from './deploy-stream.js';
22
+
23
+ const isWindows = process.platform === 'win32';
24
+ // The end-to-end signal tests below deliver a *process-group* signal
25
+ // (`kill -TERM -pgid`) — the exact shape of the reap that killed a backgrounded
26
+ // deploy in issue #222. Windows has no process groups, so those cases are
27
+ // POSIX-only; everything else runs everywhere.
28
+ const posixOnly = isWindows ? 'POSIX-only (delivers a process-group signal)' : false;
29
+
30
+ function collectingSink(): OutputSink & { text(): string } {
31
+ const chunks: string[] = [];
32
+ return {
33
+ write(chunk: string) {
34
+ chunks.push(chunk);
35
+ return true;
36
+ },
37
+ text: () => chunks.join(''),
38
+ };
39
+ }
40
+
41
+ async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs: number): Promise<boolean> {
42
+ const deadline = Date.now() + timeoutMs;
43
+ while (Date.now() < deadline) {
44
+ if (await predicate()) return true;
45
+ await new Promise((r) => setTimeout(r, 25));
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /** How many relayed lines mention `needle` — one signal must log one line. */
51
+ function countLines(text: string, needle: string): number {
52
+ return text.split('\n').filter((line) => line.includes(needle)).length;
53
+ }
54
+
55
+ /** Read an env switch without treating `''`, `'0'` or `'false'` as "on". */
56
+ function envFlag(value: string | undefined): boolean {
57
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
58
+ }
59
+
60
+ // ── signal policy ───────────────────────────────────────────────────────────
61
+ // The "decouple the CLI lifecycle from the in-flight deploy" rule, asserted
62
+ // directly: which signals abandon a converging CloudFormation deploy and which
63
+ // only warn.
64
+ describe('decideSignalResponse — a converging deploy is not abandoned by one signal', () => {
65
+ it('defers the first SIGTERM and explains how to force an abort', () => {
66
+ const { action, message } = decideSignalResponse('SIGTERM', null);
67
+ assert.strictEqual(action, 'defer');
68
+ assert.match(message, /Ignoring SIGTERM/);
69
+ assert.match(message, /send SIGTERM again to abort/i);
70
+ });
71
+
72
+ // One `kill -TERM -<pgid>` reaches this process twice under `npm run deploy`:
73
+ // the group delivers it, then tsx relays it to its child ~50ms later (measured
74
+ // on a real deploy). Reading that second delivery as "the operator insisted"
75
+ // aborted deploys that nobody asked to stop.
76
+ it('coalesces a duplicate delivery of the same SIGTERM instead of aborting', () => {
77
+ const { action, coalesced } = decideSignalResponse('SIGTERM', 50);
78
+ assert.strictEqual(action, 'defer');
79
+ assert.strictEqual(coalesced, true);
80
+ });
81
+
82
+ it('still coalesces at the edge of the window and aborts past it', () => {
83
+ assert.strictEqual(decideSignalResponse('SIGTERM', SIGNAL_COALESCE_MS - 1).action, 'defer');
84
+ assert.strictEqual(decideSignalResponse('SIGTERM', SIGNAL_COALESCE_MS).action, 'abort');
85
+ });
86
+
87
+ it('aborts on a deliberate second SIGTERM so an operator is never stuck', () => {
88
+ const { action, message } = decideSignalResponse('SIGTERM', 5_000);
89
+ assert.strictEqual(action, 'abort');
90
+ assert.match(message, /SIGTERM/);
91
+ });
92
+
93
+ it('always defers SIGHUP — a closed terminal must not kill a backgrounded deploy', () => {
94
+ assert.strictEqual(decideSignalResponse('SIGHUP', null).action, 'defer');
95
+ assert.strictEqual(decideSignalResponse('SIGHUP', 60_000).action, 'defer');
96
+ });
97
+
98
+ it('aborts on the first SIGINT — Ctrl-C is unambiguous intent', () => {
99
+ const { action, message } = decideSignalResponse('SIGINT', null);
100
+ assert.strictEqual(action, 'abort');
101
+ assert.match(message, /Interrupted/);
102
+ });
103
+ });
104
+
105
+ // ── line assembly ───────────────────────────────────────────────────────────
106
+ // Pipe chunks split wherever the kernel felt like it; a naive split emits torn
107
+ // CloudFormation event lines and drops the final one.
108
+ describe('createLineAssembler — whole lines across chunk boundaries', () => {
109
+ it('holds a partial line until the rest of it arrives', () => {
110
+ const assembler = createLineAssembler();
111
+ assert.deepStrictEqual(assembler.push('CREATE_IN_'), []);
112
+ assert.deepStrictEqual(assembler.push('PROGRESS\n'), ['CREATE_IN_PROGRESS']);
113
+ });
114
+
115
+ it('emits every complete line in one chunk and buffers the tail', () => {
116
+ const assembler = createLineAssembler();
117
+ assert.deepStrictEqual(assembler.push('one\ntwo\nthr'), ['one', 'two']);
118
+ assert.deepStrictEqual(assembler.flush(), ['thr']);
119
+ });
120
+
121
+ it('strips CRLF so Windows CDK output does not carry stray carriage returns', () => {
122
+ const assembler = createLineAssembler();
123
+ assert.deepStrictEqual(assembler.push('one\r\ntwo\r\n'), ['one', 'two']);
124
+ });
125
+
126
+ it('flushes an unterminated last line and then nothing', () => {
127
+ const assembler = createLineAssembler();
128
+ assembler.push('tail-without-newline');
129
+ assert.deepStrictEqual(assembler.flush(), ['tail-without-newline']);
130
+ assert.deepStrictEqual(assembler.flush(), []);
131
+ });
132
+ });
133
+
134
+ describe('formatElapsed', () => {
135
+ it('renders sub-minute and multi-minute durations', () => {
136
+ assert.strictEqual(formatElapsed(0), '0s');
137
+ assert.strictEqual(formatElapsed(45_000), '45s');
138
+ assert.strictEqual(formatElapsed(245_000), '4m 05s');
139
+ });
140
+
141
+ it('never renders a negative duration', () => {
142
+ assert.strictEqual(formatElapsed(-1_000), '0s');
143
+ });
144
+ });
145
+
146
+ // ── cdk argv contract ───────────────────────────────────────────────────────
147
+ // `--ci` is what moves CloudFormation events off stderr and onto stdout (the CDK
148
+ // CLI picks its stream as `isCI ? stdout : stderr`), and `--progress events`
149
+ // keeps them line-oriented when there is no TTY. Dropping either flag restores
150
+ // the 0-byte stdout, so the argv is pinned here.
151
+ describe('buildCdkDeployArgs — flags that make the deploy observable', () => {
152
+ const args = buildCdkDeployArgs({ projectRoot: '/app', outputsFile: '.blocks-sandbox/outputs.json' });
153
+
154
+ it('sends CDK logs to stdout instead of stderr', () => {
155
+ assert.ok(args.includes('--ci'), `expected --ci in: ${args.join(' ')}`);
156
+ });
157
+
158
+ it('asks for per-event progress rather than the TTY progress bar', () => {
159
+ assert.strictEqual(args[args.indexOf('--progress') + 1], 'events');
160
+ });
161
+
162
+ it('keeps the existing non-interactive deploy contract', () => {
163
+ assert.strictEqual(args[0], 'cdk');
164
+ assert.strictEqual(args[1], 'deploy');
165
+ assert.strictEqual(args[args.indexOf('--require-approval') + 1], 'never');
166
+ assert.strictEqual(args[args.indexOf('--outputs-file') + 1], '.blocks-sandbox/outputs.json');
167
+ assert.strictEqual(args[args.indexOf('--context') + 1], 'projectRoot=/app');
168
+ });
169
+ });
170
+
171
+ // ── streaming ───────────────────────────────────────────────────────────────
172
+ // Real child processes throughout: the proof that output is relayed *while the
173
+ // deploy is still running* is causal, not timing-based — the child only exits
174
+ // after the test has already seen its first line.
175
+ describe('runStreaming — relays output while the child is still running', () => {
176
+ it('surfaces a line before the child exits (the child waits for us to see it)', { timeout: 30_000 }, async () => {
177
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-stream-'));
178
+ try {
179
+ const gate = join(dir, 'seen-by-parent');
180
+ // The child prints one line, then blocks until the test writes the gate
181
+ // file — which the test only does after observing that line. If output
182
+ // were buffered until exit (the old behaviour), this deadlocks and fails.
183
+ const script = join(dir, 'gated.mjs');
184
+ writeFileSync(
185
+ script,
186
+ `import { existsSync } from 'node:fs';\n` +
187
+ `console.log('ProbeStack | CREATE_IN_PROGRESS | AWS::Lambda::Function | Handler');\n` +
188
+ `const wait = () => existsSync(${JSON.stringify(gate)}) ? console.log('done') : setTimeout(wait, 25);\n` +
189
+ `wait();\n`,
190
+ );
191
+
192
+ const stdout = collectingSink();
193
+ const stderr = collectingSink();
194
+ const run = runStreaming(process.execPath, [script], {
195
+ stdout,
196
+ stderr,
197
+ heartbeatMs: 0,
198
+ signalTarget: new EventEmitter() as unknown as SignalRegistry,
199
+ });
200
+
201
+ assert.ok(
202
+ await waitFor(() => stdout.text().includes('CREATE_IN_PROGRESS'), 15_000),
203
+ 'the CloudFormation event line must reach stdout while the child is still alive',
204
+ );
205
+ writeFileSync(gate, 'go');
206
+
207
+ await run;
208
+ assert.match(stdout.text(), /CREATE_IN_PROGRESS[\s\S]*done/);
209
+ assert.strictEqual(stderr.text(), '');
210
+ } finally {
211
+ rmSync(dir, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ it('keeps child stderr on stderr so error output is still distinguishable', { timeout: 30_000 }, async () => {
216
+ const stdout = collectingSink();
217
+ const stderr = collectingSink();
218
+ await runStreaming(
219
+ process.execPath,
220
+ ['-e', "console.log('out-line'); console.error('err-line');"],
221
+ { stdout, stderr, heartbeatMs: 0, signalTarget: new EventEmitter() as unknown as SignalRegistry },
222
+ );
223
+ assert.strictEqual(stdout.text(), 'out-line\n');
224
+ assert.strictEqual(stderr.text(), 'err-line\n');
225
+ });
226
+
227
+ it('prints an idle heartbeat so a silent CloudFormation phase still reports progress', { timeout: 30_000 }, async () => {
228
+ const stdout = collectingSink();
229
+ await runStreaming(process.execPath, ['-e', 'setTimeout(() => {}, 900);'], {
230
+ stdout,
231
+ stderr: collectingSink(),
232
+ heartbeatMs: 150,
233
+ label: 'cdk deploy',
234
+ signalTarget: new EventEmitter() as unknown as SignalRegistry,
235
+ });
236
+ const beats = stdout.text().split('\n').filter((line) => line.includes('still running'));
237
+ assert.ok(beats.length >= 2, `expected repeated heartbeats, got: ${JSON.stringify(stdout.text())}`);
238
+ assert.match(beats[0], /\[cdk deploy\] still running after \d+s/);
239
+ });
240
+
241
+ it('throws a DeployProcessError carrying the child exit code', { timeout: 30_000 }, async () => {
242
+ await assert.rejects(
243
+ () =>
244
+ runStreaming(process.execPath, ['-e', 'process.exit(7)'], {
245
+ stdout: collectingSink(),
246
+ stderr: collectingSink(),
247
+ heartbeatMs: 0,
248
+ signalTarget: new EventEmitter() as unknown as SignalRegistry,
249
+ }),
250
+ (error: unknown) => {
251
+ assert.ok(error instanceof DeployProcessError);
252
+ assert.strictEqual(error.exitCode, 7);
253
+ assert.strictEqual(error.aborted, false);
254
+ return true;
255
+ },
256
+ );
257
+ });
258
+
259
+ it('defers a SIGTERM and still completes the run it was told to abandon', { timeout: 30_000 }, async () => {
260
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-stream-'));
261
+ try {
262
+ const marker = join(dir, 'child-finished');
263
+ const script = join(dir, 'slow.mjs');
264
+ writeFileSync(
265
+ script,
266
+ `import { writeFileSync } from 'node:fs';\n` +
267
+ `console.log('ProbeStack | CREATE_IN_PROGRESS | AWS::S3::Bucket | Assets');\n` +
268
+ `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, 'ok'); console.log('ProbeStack | CREATE_COMPLETE'); }, 700);\n`,
269
+ );
270
+
271
+ const stdout = collectingSink();
272
+ const signals = new EventEmitter();
273
+ const run = runStreaming(process.execPath, [script], {
274
+ stdout,
275
+ stderr: collectingSink(),
276
+ heartbeatMs: 0,
277
+ signalTarget: signals as unknown as SignalRegistry,
278
+ });
279
+
280
+ assert.ok(
281
+ await waitFor(() => stdout.text().includes('CREATE_IN_PROGRESS'), 15_000),
282
+ 'child should be mid-run before the signal',
283
+ );
284
+ signals.emit('SIGTERM');
285
+
286
+ await run; // resolves ⇒ the run completed successfully despite the SIGTERM
287
+ assert.match(stdout.text(), /Ignoring SIGTERM/);
288
+ assert.match(stdout.text(), /CREATE_COMPLETE/);
289
+ assert.ok(existsSync(marker), 'the deferred SIGTERM must not have cut the child short');
290
+ } finally {
291
+ rmSync(dir, { recursive: true, force: true });
292
+ }
293
+ });
294
+
295
+ it('aborts on the second SIGTERM and reports it as an abort', { timeout: 30_000 }, async () => {
296
+ const stdout = collectingSink();
297
+ const signals = new EventEmitter();
298
+ const run = runStreaming(process.execPath, ['-e', "console.log('working'); setInterval(() => {}, 1000);"], {
299
+ stdout,
300
+ stderr: collectingSink(),
301
+ heartbeatMs: 0,
302
+ signalTarget: signals as unknown as SignalRegistry,
303
+ });
304
+
305
+ assert.ok(await waitFor(() => stdout.text().includes('working'), 15_000), 'child should be running');
306
+ signals.emit('SIGTERM');
307
+ assert.ok(await waitFor(() => stdout.text().includes('Ignoring SIGTERM'), 5_000), 'first SIGTERM is deferred');
308
+ // Past the coalescing window, so this reads as a deliberate second request
309
+ // rather than a duplicate delivery of the first.
310
+ await new Promise((r) => setTimeout(r, SIGNAL_COALESCE_MS + 250));
311
+ signals.emit('SIGTERM');
312
+
313
+ await assert.rejects(
314
+ () => run,
315
+ (error: unknown) => {
316
+ assert.ok(error instanceof DeployProcessError);
317
+ assert.strictEqual(error.aborted, true);
318
+ return true;
319
+ },
320
+ );
321
+ assert.match(stdout.text(), /Received SIGTERM while deploying/);
322
+ });
323
+
324
+ // The shape that broke a real deploy: `npm run deploy` is npm -> sh -> tsx ->
325
+ // node, so one `kill -TERM -<pgid>` lands on this process twice (group
326
+ // delivery, then tsx's relay). Both deliveries must count as one request.
327
+ it('survives a duplicate SIGTERM delivery and logs the deferral once', { timeout: 30_000 }, async () => {
328
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-stream-'));
329
+ try {
330
+ const marker = join(dir, 'child-finished');
331
+ const script = join(dir, 'dup.mjs');
332
+ writeFileSync(
333
+ script,
334
+ `import { writeFileSync } from 'node:fs';\n` +
335
+ `console.log('ProbeStack | CREATE_IN_PROGRESS | AWS::S3::Bucket | Assets');\n` +
336
+ `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, 'ok'); console.log('ProbeStack | CREATE_COMPLETE'); }, 700);\n`,
337
+ );
338
+
339
+ const stdout = collectingSink();
340
+ const signals = new EventEmitter();
341
+ const run = runStreaming(process.execPath, [script], {
342
+ stdout,
343
+ stderr: collectingSink(),
344
+ heartbeatMs: 0,
345
+ signalTarget: signals as unknown as SignalRegistry,
346
+ });
347
+
348
+ assert.ok(
349
+ await waitFor(() => stdout.text().includes('CREATE_IN_PROGRESS'), 15_000),
350
+ 'child should be mid-run before the signals',
351
+ );
352
+ signals.emit('SIGTERM');
353
+ await new Promise((r) => setTimeout(r, 50)); // tsx relays about this fast
354
+ signals.emit('SIGTERM');
355
+
356
+ await run; // resolves ⇒ the duplicate delivery did not abandon the deploy
357
+ const deferrals = countLines(stdout.text(), 'Ignoring SIGTERM');
358
+ assert.strictEqual(deferrals, 1, `expected one deferral line, got ${deferrals}`);
359
+ assert.doesNotMatch(stdout.text(), /Received SIGTERM while deploying/);
360
+ assert.ok(existsSync(marker), 'the deploy must have run to completion');
361
+ } finally {
362
+ rmSync(dir, { recursive: true, force: true });
363
+ }
364
+ });
365
+
366
+ // A hangup is delivered twice for the same reason a SIGTERM is (the group hangs
367
+ // up, then a wrapper relays it), and it used to print the deferral line once per
368
+ // delivery. One hangup is one line; a later hangup is a new event and reports
369
+ // again rather than being muted for the rest of a multi-minute deploy.
370
+ it('logs one line for a duplicated SIGHUP and reports a later one again', { timeout: 30_000 }, async () => {
371
+ const stdout = collectingSink();
372
+ const signals = new EventEmitter();
373
+ const run = runStreaming(
374
+ process.execPath,
375
+ ['-e', `console.log('working'); setTimeout(() => {}, ${SIGNAL_COALESCE_MS * 2 + 2_000});`],
376
+ { stdout, stderr: collectingSink(), heartbeatMs: 0, signalTarget: signals as unknown as SignalRegistry },
377
+ );
378
+
379
+ assert.ok(await waitFor(() => stdout.text().includes('working'), 15_000), 'child should be running');
380
+ signals.emit('SIGHUP');
381
+ await new Promise((r) => setTimeout(r, 50)); // a relay arrives about this fast
382
+ signals.emit('SIGHUP');
383
+ assert.ok(await waitFor(() => stdout.text().includes('Ignoring SIGHUP'), 5_000), 'the hangup is deferred');
384
+ assert.strictEqual(
385
+ countLines(stdout.text(), 'Ignoring SIGHUP'),
386
+ 1,
387
+ `one hangup must log one line, got: ${JSON.stringify(stdout.text())}`,
388
+ );
389
+
390
+ await new Promise((r) => setTimeout(r, SIGNAL_COALESCE_MS + 250));
391
+ signals.emit('SIGHUP'); // outside the window ⇒ a genuinely new hangup
392
+ assert.ok(
393
+ await waitFor(() => countLines(stdout.text(), 'Ignoring SIGHUP') === 2, 5_000),
394
+ `a later hangup must be reported too, got: ${JSON.stringify(stdout.text())}`,
395
+ );
396
+
397
+ await run; // resolves ⇒ no number of hangups abandons the deploy
398
+ });
399
+
400
+ // Each signal gets its own deferral window. A SIGHUP used to share the SIGTERM's
401
+ // window, so a hangup landing just after a deferred SIGTERM was swallowed with
402
+ // no line at all — and the operator lost the one hint that it had been ignored.
403
+ it('reports a SIGHUP that lands right after a deferred SIGTERM, and still defers', { timeout: 30_000 }, async () => {
404
+ const stdout = collectingSink();
405
+ const signals = new EventEmitter();
406
+ const run = runStreaming(process.execPath, ['-e', "console.log('working'); setTimeout(() => {}, 2500);"], {
407
+ stdout,
408
+ stderr: collectingSink(),
409
+ heartbeatMs: 0,
410
+ signalTarget: signals as unknown as SignalRegistry,
411
+ });
412
+
413
+ assert.ok(await waitFor(() => stdout.text().includes('working'), 15_000), 'child should be running');
414
+ signals.emit('SIGTERM');
415
+ assert.ok(await waitFor(() => stdout.text().includes('Ignoring SIGTERM'), 5_000), 'first SIGTERM is deferred');
416
+ signals.emit('SIGHUP');
417
+ assert.ok(
418
+ await waitFor(() => stdout.text().includes('Ignoring SIGHUP'), 5_000),
419
+ 'the SIGHUP must be reported, not swallowed by the SIGTERM window',
420
+ );
421
+
422
+ await run; // resolves ⇒ neither signal abandoned the deploy
423
+ assert.doesNotMatch(stdout.text(), /Received SIG(TERM|HUP) while deploying/);
424
+ });
425
+
426
+ // The stream contract from the failure side: progress goes to stdout, but the
427
+ // reason a deploy failed stays on stderr (the CDK CLI keeps error level there
428
+ // even under `--ci`) and the runner never merges the two.
429
+ it('keeps a deploy failure reason on stderr while progress stays on stdout', { timeout: 30_000 }, async () => {
430
+ const reason =
431
+ 'ProbeStack | CREATE_FAILED | AWS::S3::Bucket | Assets Resource handler returned message: "bucket already exists" (HandlerErrorCode: AlreadyExists)';
432
+ const stdout = collectingSink();
433
+ const stderr = collectingSink();
434
+
435
+ await assert.rejects(
436
+ () =>
437
+ runStreaming(
438
+ process.execPath,
439
+ [
440
+ '-e',
441
+ "console.log('ProbeStack | CREATE_IN_PROGRESS | AWS::S3::Bucket | Assets');" +
442
+ `console.error(${JSON.stringify(reason)});` +
443
+ 'process.exit(1);',
444
+ ],
445
+ { stdout, stderr, heartbeatMs: 0, signalTarget: new EventEmitter() as unknown as SignalRegistry },
446
+ ),
447
+ (error: unknown) => {
448
+ assert.ok(error instanceof DeployProcessError);
449
+ assert.strictEqual(error.exitCode, 1);
450
+ assert.strictEqual(error.aborted, false);
451
+ return true;
452
+ },
453
+ );
454
+
455
+ assert.match(stderr.text(), /Resource handler returned message/, 'the failure reason belongs on stderr');
456
+ assert.doesNotMatch(stdout.text(), /Resource handler returned message/, 'and must not be duplicated onto stdout');
457
+ assert.match(stdout.text(), /CREATE_IN_PROGRESS/, 'progress still streams to stdout');
458
+ });
459
+ });
460
+
461
+ // ── the root cause, verified against the real CDK CLI ───────────────────────
462
+ // `buildCdkDeployArgs` passing `--ci` is load-bearing, so the routing is checked
463
+ // against the actual CLI rather than trusted: a `cdk deploy` writes NOTHING to
464
+ // stdout by default (every line goes to stderr) and writes to stdout once CI mode
465
+ // is on, while the reason a deploy failed stays on stderr either way.
466
+ //
467
+ // The probe is hermetic. It deploys a pre-synthesized assembly pinned to the
468
+ // all-zeros account with every CI marker and credential source stripped from the
469
+ // child's environment, so the CLI stops at the same credential check on every
470
+ // machine: no credentials, no network calls, no mutation, the same two streams
471
+ // every run.
472
+ //
473
+ // Being hermetic is what lets it be mandatory, and mandatory is the point: a
474
+ // probe that quietly skips reads as "covered" while asserting nothing. So there
475
+ // is no environmental skip left. It fails when the CDK CLI cannot be resolved
476
+ // while the probe is required, fails (never skips) when the CLI stops anywhere
477
+ // other than the credential check, and prints CDK_ROUTING_PROBE_EXECUTED — which
478
+ // the last test in this block asserts, and which pr-checks.yml re-checks through
479
+ // BLOCKS_CDK_PROBE_MARKER so a probe that stops running fails the build.
480
+ describe('the real CDK CLI: --ci moves logs to stdout and keeps failures on stderr', () => {
481
+ const cdkBin = (() => {
482
+ try {
483
+ return createRequire(import.meta.url).resolve('aws-cdk/bin/cdk');
484
+ } catch {
485
+ return undefined;
486
+ }
487
+ })();
488
+
489
+ /** Printed on stdout (and written to `BLOCKS_CDK_PROBE_MARKER`) once the probe ran. */
490
+ const PROBE_MARKER = 'CDK_ROUTING_PROBE_EXECUTED';
491
+
492
+ // CI must run this probe, so a CDK CLI it cannot resolve is a failure there,
493
+ // not a skip. `BLOCKS_SKIP_CDK_PROBE=1` is the one explicit, greppable way to
494
+ // opt a runner out on purpose.
495
+ const probeRequired =
496
+ (envFlag(process.env.CI) || envFlag(process.env.BLOCKS_REQUIRE_CDK_PROBE)) &&
497
+ !envFlag(process.env.BLOCKS_SKIP_CDK_PROBE);
498
+
499
+ // The line the CLI stops on: the hermetic environment has no credentials for
500
+ // the all-zeros account, so every probe run reaches exactly this.
501
+ const FAILURE_REASON =
502
+ /Need to perform AWS calls for account 000000000000, but no credentials have been configured/;
503
+
504
+ // CI markers (the CDK CLI derives its CI default from them) plus every
505
+ // credential source, so neither the flag under test nor the stop point can be
506
+ // decided by the environment this suite happens to run in.
507
+ const STRIPPED_ENV = [
508
+ 'CI',
509
+ 'GITHUB_ACTIONS',
510
+ 'CONTINUOUS_INTEGRATION',
511
+ 'BUILD_NUMBER',
512
+ 'AWS_PROFILE',
513
+ 'AWS_ACCESS_KEY_ID',
514
+ 'AWS_SECRET_ACCESS_KEY',
515
+ 'AWS_SESSION_TOKEN',
516
+ 'AWS_ROLE_ARN',
517
+ 'AWS_WEB_IDENTITY_TOKEN_FILE',
518
+ 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
519
+ 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
520
+ ];
521
+
522
+ interface ProbeRun {
523
+ stdout: string;
524
+ stderr: string;
525
+ status: number | null;
526
+ }
527
+
528
+ function writeProbeAssembly(): string {
529
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-cdk-probe-'));
530
+ writeFileSync(
531
+ join(dir, 'ProbeStack.template.json'),
532
+ JSON.stringify({ Resources: { Probe: { Type: 'AWS::SNS::Topic' } } }),
533
+ );
534
+ writeFileSync(
535
+ join(dir, 'manifest.json'),
536
+ JSON.stringify({
537
+ version: '22.0.0',
538
+ artifacts: {
539
+ ProbeStack: {
540
+ type: 'aws:cloudformation:stack',
541
+ // The all-zeros account is never issued, so the deploy cannot touch
542
+ // real infrastructure even if the runner happens to have credentials.
543
+ environment: 'aws://000000000000/us-east-1',
544
+ properties: { templateFile: 'ProbeStack.template.json' },
545
+ },
546
+ },
547
+ }),
548
+ );
549
+ return dir;
550
+ }
551
+
552
+ /**
553
+ * argv for one probe deploy. The flag under test *replaces* the `--ci` that
554
+ * {@link buildCdkDeployArgs} already adds instead of being appended after it,
555
+ * so a probe never argues with itself over two conflicting CI flags.
556
+ */
557
+ function probeArgv(assembly: string, ciFlag: '--ci' | '--no-ci'): string[] {
558
+ const deployArgs = buildCdkDeployArgs({
559
+ projectRoot: assembly,
560
+ outputsFile: join(assembly, 'outputs.json'),
561
+ })
562
+ .slice(1) // drop the leading `cdk`: the CLI entry point is invoked directly
563
+ .filter((arg) => arg !== '--ci');
564
+ return [...deployArgs, ciFlag, 'ProbeStack', '--app', assembly];
565
+ }
566
+
567
+ function probeDeploy(assembly: string, ciFlag: '--ci' | '--no-ci'): ProbeRun {
568
+ const env: NodeJS.ProcessEnv = {
569
+ ...process.env,
570
+ AWS_EC2_METADATA_DISABLED: 'true',
571
+ // Point the shared config/credentials files at paths that do not exist so a
572
+ // developer's ~/.aws profile cannot carry the probe past the credential check.
573
+ AWS_SHARED_CREDENTIALS_FILE: join(assembly, 'no-credentials'),
574
+ AWS_CONFIG_FILE: join(assembly, 'no-config'),
575
+ };
576
+ for (const key of STRIPPED_ENV) delete env[key];
577
+ const result = spawnSync(process.execPath, [cdkBin as string, ...probeArgv(assembly, ciFlag)], {
578
+ encoding: 'utf-8',
579
+ env,
580
+ timeout: 120_000,
581
+ });
582
+ return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status };
583
+ }
584
+
585
+ function cdkVersion(): string {
586
+ try {
587
+ const manifest = createRequire(import.meta.url).resolve('aws-cdk/package.json');
588
+ return JSON.parse(readFileSync(manifest, 'utf-8')).version;
589
+ } catch {
590
+ return 'unknown';
591
+ }
592
+ }
593
+
594
+ let probes: { withCi: ProbeRun; withoutCi: ProbeRun } | undefined;
595
+ let executionMarker: string | undefined;
596
+
597
+ /**
598
+ * Run both probe deploys once (they only read the CLI's behaviour) and prove
599
+ * they got where they were meant to. Returns `undefined` only when there is no
600
+ * CDK CLI to probe *and* the probe is not required — the single skip left.
601
+ */
602
+ function probeOnce(t: TestContext): { withCi: ProbeRun; withoutCi: ProbeRun } | undefined {
603
+ if (!cdkBin) {
604
+ assert.ok(
605
+ !probeRequired,
606
+ 'the real-CDK stream-routing probe is required here but aws-cdk could not be resolved — run `npm ci` at the repo root, or set BLOCKS_SKIP_CDK_PROBE=1 to opt this runner out on purpose',
607
+ );
608
+ t.skip('aws-cdk CLI is not installed in this workspace');
609
+ return undefined;
610
+ }
611
+ if (!probes) {
612
+ const assembly = writeProbeAssembly();
613
+ try {
614
+ probes = { withCi: probeDeploy(assembly, '--ci'), withoutCi: probeDeploy(assembly, '--no-ci') };
615
+ } finally {
616
+ rmSync(assembly, { recursive: true, force: true });
617
+ }
618
+ }
619
+ // Stopping anywhere other than the credential check means the CLI changed or
620
+ // the environment leaked credentials, and either invalidates the probe. This
621
+ // used to skip, which is exactly how a probe rots into a no-op.
622
+ for (const [flag, run] of [
623
+ ['--ci', probes.withCi],
624
+ ['--no-ci', probes.withoutCi],
625
+ ] as const) {
626
+ assert.match(
627
+ run.stderr,
628
+ FAILURE_REASON,
629
+ `the ${flag} probe never reached the credential check (exit ${run.status}). stdout: ${JSON.stringify(run.stdout.slice(0, 400))} stderr: ${JSON.stringify(run.stderr.slice(0, 400))}`,
630
+ );
631
+ }
632
+ if (!executionMarker) {
633
+ executionMarker =
634
+ `${PROBE_MARKER} aws-cdk@${cdkVersion()} ` +
635
+ `--ci{stdout:${probes.withCi.stdout.length}B,stderr:${probes.withCi.stderr.length}B} ` +
636
+ `--no-ci{stdout:${probes.withoutCi.stdout.length}B,stderr:${probes.withoutCi.stderr.length}B}`;
637
+ console.log(executionMarker);
638
+ const markerFile = process.env.BLOCKS_CDK_PROBE_MARKER;
639
+ if (markerFile) writeFileSync(markerFile, `${executionMarker}\n`);
640
+ }
641
+ return probes;
642
+ }
643
+
644
+ it('sends every deploy log line to stderr by default, and to stdout with --ci', { timeout: 180_000 }, (t) => {
645
+ const probe = probeOnce(t);
646
+ if (!probe) return;
647
+ assert.strictEqual(
648
+ probe.withoutCi.stdout,
649
+ '',
650
+ 'the CDK default routes every log line to stderr — this is the 0-byte stdout in issue #222',
651
+ );
652
+ assert.notStrictEqual(probe.withoutCi.stderr, '', 'the log output still exists, just on the wrong stream');
653
+ assert.notStrictEqual(
654
+ probe.withCi.stdout,
655
+ '',
656
+ '--ci must move the deploy log (CloudFormation progress included) onto stdout',
657
+ );
658
+ });
659
+
660
+ // The other half of the stream contract this fix tightens: moving progress onto
661
+ // stdout must not drag the failure reason along with it. The CDK io host routes
662
+ // error level to stderr whatever CI mode says, so a caller grepping stderr for
663
+ // why a deploy failed still finds it there under `--ci`.
664
+ it('keeps a genuine deploy failure reason on stderr under --ci', { timeout: 180_000 }, (t) => {
665
+ const probe = probeOnce(t);
666
+ if (!probe) return;
667
+ assert.notStrictEqual(probe.withCi.status, 0, 'the probe deploy must really have failed');
668
+ assert.match(probe.withCi.stderr, FAILURE_REASON, 'the failure reason must stay on stderr under --ci');
669
+ assert.doesNotMatch(
670
+ probe.withCi.stdout,
671
+ FAILURE_REASON,
672
+ '--ci must not move the failure reason onto stdout — stderr stays the place to grep for it',
673
+ );
674
+ // Same failure under the default routing: stderr carries the reason *and* the
675
+ // progress that `--ci` lifts onto stdout.
676
+ assert.match(probe.withoutCi.stderr, FAILURE_REASON);
677
+ });
678
+
679
+ it('probes with exactly one CI flag on the argv', () => {
680
+ for (const flag of ['--ci', '--no-ci'] as const) {
681
+ const argv = probeArgv('/probe-assembly', flag);
682
+ assert.deepStrictEqual(
683
+ argv.filter((arg) => arg === '--ci' || arg === '--no-ci'),
684
+ [flag],
685
+ `the probe argv must carry only the flag under test: ${argv.join(' ')}`,
686
+ );
687
+ }
688
+ });
689
+
690
+ // Guards the guard. If the probe ever stops executing — a dropped dependency, a
691
+ // reordered CI step, an early return sneaking back in — this fails instead of
692
+ // the suite quietly shrinking to nothing.
693
+ it('leaves a greppable marker proving the probe executed', (t) => {
694
+ if (!cdkBin && !probeRequired) return t.skip('aws-cdk CLI is not installed in this workspace');
695
+ assert.ok(
696
+ executionMarker?.startsWith(PROBE_MARKER),
697
+ `the real-CDK stream-routing probe did not run: expected a ${PROBE_MARKER} line on stdout`,
698
+ );
699
+ });
700
+ });
701
+
702
+ interface FakeDeployOptions {
703
+ /** How many CloudFormation-shaped event lines the fake cdk prints. */
704
+ ticks: number;
705
+ /** Gap between those lines, so a test can keep the deploy mid-flight. */
706
+ tickMs: number;
707
+ /**
708
+ * When set, the fake cdk fails after its last tick instead of completing: it
709
+ * prints this reason on *stderr* — where the real CDK CLI keeps error level,
710
+ * `--ci` or not — and exits 1.
711
+ */
712
+ failWith?: string;
713
+ }
714
+
715
+ /**
716
+ * Write a fake CDK CLI plus the deploy wrapper that runs it through
717
+ * {@link runStreaming}.
718
+ *
719
+ * The wrapper mirrors the entrypoint the templates generate
720
+ * (`deploy(...).catch((error) => { console.error(error); process.exit(1); })`):
721
+ * the ❌ verdict goes to stdout so a stdout-only capture can tell a failed deploy
722
+ * from a killed process, and the error itself goes to stderr.
723
+ */
724
+ function scaffoldFakeDeploy({ ticks, tickMs, failWith }: FakeDeployOptions): { dir: string; wrapper: string } {
725
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-deploy-'));
726
+ const moduleUrl = new URL('./deploy-stream.js', import.meta.url).href;
727
+ const lastTick = failWith
728
+ ? ` console.error(${JSON.stringify(failWith)});\n` + ` process.exit(1);\n`
729
+ : ` writeFileSync(dir + '/cfn-done', 'ok');\n` +
730
+ ` console.log('ProbeStack | CREATE_COMPLETE | AWS::CloudFormation::Stack | ProbeStack');\n` +
731
+ ` return;\n`;
732
+ writeFileSync(
733
+ join(dir, 'fake-cdk.mjs'),
734
+ `import { writeFileSync } from 'node:fs';\n` +
735
+ `const dir = process.argv[2];\n` +
736
+ `writeFileSync(dir + '/cdk.pid', String(process.pid));\n` +
737
+ `let n = 0;\n` +
738
+ `const tick = () => {\n` +
739
+ ` n += 1;\n` +
740
+ ` console.log('ProbeStack | ' + n + '/${ticks} | CREATE_IN_PROGRESS | AWS::Lambda::Function | Handler' + n);\n` +
741
+ ` if (n >= ${ticks}) {\n` +
742
+ lastTick +
743
+ ` }\n` +
744
+ ` setTimeout(tick, ${tickMs});\n` +
745
+ `};\n` +
746
+ `tick();\n`,
747
+ );
748
+ const wrapper = join(dir, 'wrapper.mjs');
749
+ writeFileSync(
750
+ wrapper,
751
+ `import { writeFileSync } from 'node:fs';\n` +
752
+ `import { runStreaming } from ${JSON.stringify(moduleUrl)};\n` +
753
+ `const dir = process.argv[2];\n` +
754
+ `try {\n` +
755
+ ` await runStreaming(process.execPath, [dir + '/fake-cdk.mjs', dir], { label: 'cdk deploy', heartbeatMs: 0 });\n` +
756
+ ` console.log('✅ Deployment complete!');\n` +
757
+ ` writeFileSync(dir + '/wrapper-status', 'complete');\n` +
758
+ `} catch (error) {\n` +
759
+ ` console.log('\\n❌ Deployment failed.');\n` +
760
+ ` console.error(error);\n` +
761
+ ` writeFileSync(dir + '/wrapper-status', error && error.aborted ? 'aborted' : 'failed');\n` +
762
+ ` process.exitCode = 1;\n` +
763
+ `}\n`,
764
+ );
765
+ return { dir, wrapper };
766
+ }
767
+
768
+ // ── a failed deploy keeps its reason on stderr ──────────────────────────────
769
+ // The exact contract this fix tightens, end to end and platform-independent:
770
+ // moving CloudFormation progress onto stdout must not move the *reason* a deploy
771
+ // failed with it. The reason (and the error object) stay on stderr; only the
772
+ // human-facing ❌ verdict is on stdout, which is a deliberate change — grepping
773
+ // stderr for "Deployment failed" no longer finds it, grepping for the reason
774
+ // still does.
775
+ describe('a failed deploy reports its reason on stderr and its verdict on stdout', () => {
776
+ it('splits the CDK failure reason (stderr) from the ❌ verdict (stdout)', { timeout: 60_000 }, async () => {
777
+ const { dir, wrapper } = scaffoldFakeDeploy({
778
+ ticks: 3,
779
+ tickMs: 40,
780
+ failWith:
781
+ 'ProbeStack | CREATE_FAILED | AWS::S3::Bucket | Assets Resource handler returned message: "bucket already exists" (HandlerErrorCode: AlreadyExists)',
782
+ });
783
+ // No `detached` here: this asserts the stream contract, not signal handling,
784
+ // so it runs on every platform including Windows.
785
+ const child = spawn(process.execPath, [wrapper, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
786
+ let out = '';
787
+ let err = '';
788
+ child.stdout.setEncoding('utf-8');
789
+ child.stderr.setEncoding('utf-8');
790
+ child.stdout.on('data', (chunk: string) => { out += chunk; });
791
+ child.stderr.on('data', (chunk: string) => { err += chunk; });
792
+
793
+ try {
794
+ // 'close' (not 'exit') so both pipes are fully drained before asserting.
795
+ const code = await new Promise<number | null>((resolve) => child.once('close', resolve));
796
+
797
+ assert.strictEqual(code, 1, `a failed deploy must exit non-zero; stdout: ${out}\nstderr: ${err}`);
798
+ assert.match(err, /Resource handler returned message/, 'the failure reason must land on stderr');
799
+ assert.doesNotMatch(out, /Resource handler returned message/, 'the failure reason must not move to stdout');
800
+ assert.match(err, /DeployProcessError/, "the entrypoint's console.error(error) puts the error on stderr");
801
+ assert.match(out, /❌ Deployment failed\./, 'the verdict is on stdout so a stdout-only capture sees it');
802
+ assert.doesNotMatch(err, /❌ Deployment failed\./, 'the verdict is no longer on stderr (see the changeset)');
803
+ assert.match(out, /CREATE_IN_PROGRESS/, 'progress still streams to stdout');
804
+ assert.strictEqual(readFileSync(join(dir, 'wrapper-status'), 'utf-8'), 'failed');
805
+ assert.ok(!existsSync(join(dir, 'cfn-done')), 'the failing deploy must not report completion');
806
+ } finally {
807
+ if (child.pid) { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
808
+ rmSync(dir, { recursive: true, force: true });
809
+ }
810
+ });
811
+ });
812
+
813
+ // ── end-to-end: a backgrounded deploy reaped by its parent shell ────────────
814
+ // The issue #222 repro, with real OS signals and three real processes:
815
+ //
816
+ // test ──spawn(detached)──▶ deploy wrapper ──runStreaming──▶ fake cdk
817
+ //
818
+ // The wrapper is its own process-group leader, so `kill -TERM -pgid` reproduces
819
+ // exactly what a harness reaping a backgrounded `npm run deploy &` does. The
820
+ // fake cdk stands in for the CDK CLI: it prints CloudFormation-shaped events and
821
+ // installs no signal handler, so it dies if a signal reaches it.
822
+ //
823
+ // Process groups and OS-delivered SIGTERM/SIGHUP are POSIX-only, which is why
824
+ // this whole block is — and why the signal resilience is documented as POSIX-only
825
+ // rather than universal.
826
+ describe('a backgrounded deploy survives the group SIGTERM that killed it before', { skip: posixOnly }, () => {
827
+
828
+ // The mechanism behind every case below, asserted directly: on POSIX the CDK
829
+ // CLI is spawned into its own process group, which is what stops a reap aimed
830
+ // at the parent shell from reaching it. Windows has no equivalent.
831
+ it('spawns the CDK CLI into its own process group', { timeout: 30_000 }, async () => {
832
+ const dir = mkdtempSync(join(tmpdir(), 'blocks-pgid-'));
833
+ try {
834
+ const pidFile = join(dir, 'child.pid');
835
+ const gate = join(dir, 'may-exit');
836
+ const script = join(dir, 'report-pid.mjs');
837
+ writeFileSync(
838
+ script,
839
+ `import { existsSync, writeFileSync } from 'node:fs';\n` +
840
+ `writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` +
841
+ `const wait = () => existsSync(${JSON.stringify(gate)}) ? process.exit(0) : setTimeout(wait, 25);\n` +
842
+ `wait();\n`,
843
+ );
844
+
845
+ const run = runStreaming(process.execPath, [script], {
846
+ stdout: collectingSink(),
847
+ stderr: collectingSink(),
848
+ heartbeatMs: 0,
849
+ signalTarget: new EventEmitter() as unknown as SignalRegistry,
850
+ });
851
+
852
+ assert.ok(await waitFor(() => existsSync(pidFile), 15_000), 'child should report its pid');
853
+ const childPid = Number(readFileSync(pidFile, 'utf-8'));
854
+ // A process group whose id is the child's pid exists only if the child
855
+ // leads it — i.e. it was spawned detached, not into this process's group,
856
+ // so a signal sent to our group cannot reach it.
857
+ assert.doesNotThrow(
858
+ () => process.kill(-childPid, 0),
859
+ `the child must lead its own process group (pid ${childPid})`,
860
+ );
861
+
862
+ writeFileSync(gate, 'go');
863
+ await run;
864
+ } finally {
865
+ rmSync(dir, { recursive: true, force: true });
866
+ }
867
+ });
868
+
869
+ it('keeps streaming and finishes the deploy after one group SIGTERM', { timeout: 60_000 }, async () => {
870
+ const { dir, wrapper } = scaffoldFakeDeploy({ ticks: 12, tickMs: 80 });
871
+ // `detached` makes the wrapper a process-group leader, so the test can
872
+ // signal the whole group the way a shell/harness reaps a background job.
873
+ const child = spawn(process.execPath, [wrapper, dir], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
874
+ let out = '';
875
+ let err = '';
876
+ child.stdout.setEncoding('utf-8');
877
+ child.stderr.setEncoding('utf-8');
878
+ child.stdout.on('data', (chunk: string) => { out += chunk; });
879
+ child.stderr.on('data', (chunk: string) => { err += chunk; });
880
+
881
+ try {
882
+ assert.ok(
883
+ await waitFor(() => out.includes('CREATE_IN_PROGRESS'), 20_000),
884
+ `stdout must carry CloudFormation events while deploying, got: ${JSON.stringify(out)}`,
885
+ );
886
+
887
+ assert.ok(child.pid, 'wrapper pid');
888
+ process.kill(-child.pid, 'SIGTERM');
889
+
890
+ assert.ok(
891
+ await waitFor(() => out.includes('Ignoring SIGTERM'), 10_000),
892
+ 'the wrapper must report that it is ignoring the SIGTERM',
893
+ );
894
+ assert.strictEqual(child.exitCode, null, 'the wrapper must still be alive after the SIGTERM');
895
+ assert.strictEqual(child.signalCode, null, 'the wrapper must not have been killed by the SIGTERM');
896
+
897
+ assert.ok(
898
+ await waitFor(() => child.exitCode !== null || child.signalCode !== null, 30_000),
899
+ 'the wrapper should finish on its own',
900
+ );
901
+ assert.ok(existsSync(join(dir, 'cfn-done')), 'the deploy itself must have run to completion');
902
+ assert.strictEqual(child.signalCode, null, `wrapper was killed by a signal; stderr: ${err}`);
903
+ assert.strictEqual(child.exitCode, 0, `wrapper should exit 0; stdout: ${out}\nstderr: ${err}`);
904
+ assert.strictEqual(readFileSync(join(dir, 'wrapper-status'), 'utf-8'), 'complete');
905
+ assert.match(out, /✅ Deployment complete!/);
906
+ assert.ok(out.length > 0, 'stdout must not be empty (issue #222: 0-byte stdout)');
907
+ } finally {
908
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } }
909
+ rmSync(dir, { recursive: true, force: true });
910
+ }
911
+ });
912
+
913
+ it('stops the deploy when the group SIGTERM is repeated', { timeout: 60_000 }, async () => {
914
+ // 200 ticks: long enough that the deploy is still mid-flight when signalled.
915
+ const { dir, wrapper } = scaffoldFakeDeploy({ ticks: 200, tickMs: 80 });
916
+ const child = spawn(process.execPath, [wrapper, dir], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
917
+ let out = '';
918
+ child.stdout.setEncoding('utf-8');
919
+ child.stdout.on('data', (chunk: string) => { out += chunk; });
920
+
921
+ try {
922
+ assert.ok(await waitFor(() => out.includes('CREATE_IN_PROGRESS'), 20_000), 'deploy should be streaming');
923
+ const cdkPid = Number(readFileSync(join(dir, 'cdk.pid'), 'utf-8'));
924
+ assert.ok(cdkPid > 1, 'fake cdk pid');
925
+
926
+ assert.ok(child.pid, 'wrapper pid');
927
+ process.kill(-child.pid, 'SIGTERM');
928
+ assert.ok(await waitFor(() => out.includes('Ignoring SIGTERM'), 10_000), 'first SIGTERM is deferred');
929
+ // Wait out the coalescing window so this is read as a deliberate repeat
930
+ // rather than a duplicate delivery of the first signal.
931
+ await new Promise((r) => setTimeout(r, SIGNAL_COALESCE_MS + 250));
932
+ process.kill(-child.pid, 'SIGTERM');
933
+
934
+ assert.ok(
935
+ await waitFor(() => child.exitCode !== null || child.signalCode !== null, 30_000),
936
+ 'the wrapper should exit after the second SIGTERM',
937
+ );
938
+ assert.match(out, /Received SIGTERM while deploying/);
939
+ assert.strictEqual(child.exitCode, 1, `expected a failed exit, stdout: ${out}`);
940
+ assert.strictEqual(readFileSync(join(dir, 'wrapper-status'), 'utf-8'), 'aborted');
941
+ assert.ok(!existsSync(join(dir, 'cfn-done')), 'the aborted deploy must not have completed');
942
+ assert.ok(
943
+ await waitFor(() => {
944
+ try { process.kill(cdkPid, 0); return false; } catch { return true; }
945
+ }, 15_000),
946
+ 'the abort must reap the cdk child, not orphan it',
947
+ );
948
+ } finally {
949
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } }
950
+ rmSync(dir, { recursive: true, force: true });
951
+ }
952
+ });
953
+
954
+ // The real-world delivery shape, with real OS signals: `npm run deploy` puts
955
+ // npm, sh and tsx in the group alongside the deploy CLI, and tsx relays the
956
+ // SIGTERM it receives to its child, so the CLI is signalled twice in quick
957
+ // succession. Two group SIGTERMs ~50ms apart reproduce that, and the deploy
958
+ // must still finish (a real deploy against AWS aborted here before the
959
+ // coalescing window existed).
960
+ it('finishes the deploy when one reap delivers SIGTERM twice in quick succession', { timeout: 60_000 }, async () => {
961
+ const { dir, wrapper } = scaffoldFakeDeploy({ ticks: 14, tickMs: 80 });
962
+ const child = spawn(process.execPath, [wrapper, dir], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
963
+ let out = '';
964
+ child.stdout.setEncoding('utf-8');
965
+ child.stdout.on('data', (chunk: string) => { out += chunk; });
966
+
967
+ try {
968
+ assert.ok(await waitFor(() => out.includes('CREATE_IN_PROGRESS'), 20_000), 'deploy should be streaming');
969
+ assert.ok(child.pid, 'wrapper pid');
970
+
971
+ process.kill(-child.pid, 'SIGTERM');
972
+ await new Promise((r) => setTimeout(r, 50));
973
+ process.kill(-child.pid, 'SIGTERM'); // the wrapper relay, not a second request
974
+
975
+ assert.ok(
976
+ await waitFor(() => child.exitCode !== null || child.signalCode !== null, 30_000),
977
+ 'the wrapper should finish on its own',
978
+ );
979
+ assert.doesNotMatch(out, /Received SIGTERM while deploying/, 'a duplicate delivery must not abort');
980
+ assert.ok(existsSync(join(dir, 'cfn-done')), 'the deploy itself must have run to completion');
981
+ assert.strictEqual(child.exitCode, 0, `wrapper should exit 0; stdout: ${out}`);
982
+ assert.strictEqual(readFileSync(join(dir, 'wrapper-status'), 'utf-8'), 'complete');
983
+ } finally {
984
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } }
985
+ rmSync(dir, { recursive: true, force: true });
986
+ }
987
+ });
988
+
989
+ // Control: the shape the deploy CLI used before this fix — a blocking
990
+ // `spawnSync` with the child in the parent's process group and no signal
991
+ // handling. It proves the group SIGTERM above really is lethal, so the passing
992
+ // tests are not vacuous: here the wrapper dies (exit 143 / SIGTERM) and takes
993
+ // the in-flight deploy down with it, exactly as reported in issue #222.
994
+ it('demonstrates the old behaviour: a blocking spawnSync deploy is killed mid-flight', { timeout: 60_000 }, async () => {
995
+ const { dir } = scaffoldFakeDeploy({ ticks: 200, tickMs: 80 });
996
+ const baseline = join(dir, 'baseline.mjs');
997
+ writeFileSync(
998
+ baseline,
999
+ `import { spawnSync } from 'node:child_process';\n` +
1000
+ `import { writeFileSync } from 'node:fs';\n` +
1001
+ `const dir = process.argv[2];\n` +
1002
+ `const result = spawnSync(process.execPath, [dir + '/fake-cdk.mjs', dir], { stdio: 'inherit' });\n` +
1003
+ `writeFileSync(dir + '/wrapper-status', 'baseline:' + result.status);\n`,
1004
+ );
1005
+ const child = spawn(process.execPath, [baseline, dir], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
1006
+ let out = '';
1007
+ child.stdout.setEncoding('utf-8');
1008
+ child.stdout.on('data', (chunk: string) => { out += chunk; });
1009
+
1010
+ try {
1011
+ assert.ok(await waitFor(() => out.includes('CREATE_IN_PROGRESS'), 20_000), 'baseline deploy should be running');
1012
+ const cdkPid = Number(readFileSync(join(dir, 'cdk.pid'), 'utf-8'));
1013
+
1014
+ assert.ok(child.pid, 'baseline pid');
1015
+ process.kill(-child.pid, 'SIGTERM');
1016
+
1017
+ assert.ok(
1018
+ await waitFor(() => child.exitCode !== null || child.signalCode !== null, 15_000),
1019
+ 'the unguarded wrapper dies on the first group SIGTERM',
1020
+ );
1021
+ assert.strictEqual(child.signalCode, 'SIGTERM', 'the old shape is killed by the signal (exit 143)');
1022
+ assert.ok(!existsSync(join(dir, 'wrapper-status')), 'it never reports a terminal status');
1023
+ assert.ok(!existsSync(join(dir, 'cfn-done')), 'the in-flight deploy is cut short');
1024
+ assert.ok(
1025
+ await waitFor(() => {
1026
+ try { process.kill(cdkPid, 0); return false; } catch { return true; }
1027
+ }, 15_000),
1028
+ 'the deploy child shares the group, so it dies too',
1029
+ );
1030
+ } finally {
1031
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } }
1032
+ rmSync(dir, { recursive: true, force: true });
1033
+ }
1034
+ });
1035
+ });