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