@zintrust/core 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import { Logger } from '../../config/logger.js';
11
11
  import notificationConfig from '../../config/notification.js';
12
12
  import { StartupConfigValidator } from '../../config/StartupConfigValidator.js';
13
13
  import { ErrorFactory } from '../../exceptions/ZintrustError.js';
14
- import { isNonEmptyString } from '../../helper/index.js';
14
+ import { isNonEmptyString, ShutdownTrace } from '../../helper/index.js';
15
15
  import { existsSync } from '../../node-singletons/fs.js';
16
16
  import * as path from '../../node-singletons/path.js';
17
17
  import { pathToFileURL } from '../../node-singletons/url.js';
@@ -23,6 +23,7 @@ import { SocketFeature } from '../../sockets/SocketRuntime.js';
23
23
  import { SocketRuntimeRegistry } from '../../sockets/SocketRuntimeRegistry.js';
24
24
  import { registerBroadcastersFromRuntimeConfig } from '../../tools/broadcast/BroadcastRuntimeRegistration.js';
25
25
  import { registerNotificationChannelsFromRuntimeConfig } from '../../tools/notification/NotificationRuntimeRegistration.js';
26
+ import { QueueReliabilityOrchestrator } from '../../tools/queue/QueueReliabilityOrchestrator.js';
26
27
  import { registerQueuesFromRuntimeConfig } from '../../tools/queue/QueueRuntimeRegistration.js';
27
28
  import { registerDisksFromRuntimeConfig } from '../../tools/storage/StorageRuntimeRegistration.js';
28
29
  const importFromExistingCandidates = async (moduleCandidates) => {
@@ -332,6 +333,10 @@ const initializeQueueMonitor = async (router) => {
332
333
  knownQueues: resolveKnownQueues,
333
334
  redis: redisConfig,
334
335
  });
336
+ runtimeQueueMonitor = monitor;
337
+ ShutdownTrace.logHandles('runtime.queue-monitor.created', {
338
+ basePath: monitorConfig.basePath ?? '',
339
+ });
335
340
  try {
336
341
  monitor.registerRoutes(router);
337
342
  }
@@ -341,6 +346,7 @@ const initializeQueueMonitor = async (router) => {
341
346
  Logger.info(`Queue Monitor routes registered at http://127.0.0.1:${appConfig.port}${monitorConfig.basePath ?? ''}`);
342
347
  Logger.info(`Queue Monitor enqueue endpoint at http://127.0.0.1:${appConfig.port}/test/enqueue`);
343
348
  };
349
+ let runtimeQueueMonitor = null;
344
350
  const initializeWorkers = async (router) => {
345
351
  const workers = await loadWorkersModule({ allowWhenDisabled: true });
346
352
  if (workers?.WorkerInit !== undefined && typeof workers.registerWorkerRoutes === 'function') {
@@ -489,8 +495,34 @@ const initializeSockets = (router) => {
489
495
  Logger.info(`Transport: ${diagnostics.transport}`);
490
496
  Logger.info(`Path: ${diagnostics.path}`);
491
497
  };
492
- export const createLifecycle = (params) => {
493
- const boot = async () => {
498
+ const initializeRuntimeRoutes = async (params) => {
499
+ await initializeArtifactDirectories(params.resolvedBasePath);
500
+ await registerMasterRoutes(params.resolvedBasePath, params.router);
501
+ initializeSockets(params.router);
502
+ await initializeSystemTrace(params.router);
503
+ if (Cloudflare.getWorkersEnv() === null && appConfig.dockerWorker === false) {
504
+ if (appConfig.worker === true) {
505
+ await initializeWorkers(params.router);
506
+ }
507
+ else {
508
+ Logger.info('Skipping worker route registration (WORKER_ENABLED=false).');
509
+ }
510
+ await initializeQueueMonitor(params.router);
511
+ if (appConfig.worker === true) {
512
+ await initializeQueueHttpGateway(params.router);
513
+ await initializeScheduleHttpGateway(params.router);
514
+ }
515
+ else {
516
+ Logger.info('Skipping worker execution/gateway initialization (WORKER_ENABLED=false).');
517
+ }
518
+ return;
519
+ }
520
+ if (!appConfig.dockerWorker) {
521
+ Logger.info('Skipping local worker dashboards in Cloudflare Workers runtime.');
522
+ }
523
+ };
524
+ const createBootLifecycle = (params) => {
525
+ return async () => {
494
526
  if (params.getBooted())
495
527
  return;
496
528
  Logger.info(`🚀 Booting ZinTrust Application in ${params.environment} mode...`);
@@ -509,7 +541,6 @@ export const createLifecycle = (params) => {
509
541
  warnings: startupConfigValidation.warnings,
510
542
  });
511
543
  }
512
- // Preload project-owned config overrides that must be available synchronously.
513
544
  await StartupConfigFileRegistry.preload([
514
545
  StartupConfigFile.Middleware,
515
546
  StartupConfigFile.Cache,
@@ -523,38 +554,43 @@ export const createLifecycle = (params) => {
523
554
  FeatureFlags.initialize();
524
555
  await StartupHealthChecks.assertHealthy();
525
556
  await registerFromRuntimeConfig();
526
- await initializeArtifactDirectories(params.resolvedBasePath);
527
- await registerMasterRoutes(params.resolvedBasePath, params.router);
528
- initializeSockets(params.router);
529
- await initializeSystemTrace(params.router);
530
- if (Cloudflare.getWorkersEnv() === null && appConfig.dockerWorker === false) {
531
- await initializeWorkers(params.router);
532
- await initializeQueueMonitor(params.router);
533
- if (appConfig.worker === true) {
534
- await initializeQueueHttpGateway(params.router);
535
- await initializeScheduleHttpGateway(params.router);
536
- }
537
- else {
538
- Logger.info('Skipping worker execution/gateway initialization (WORKER_ENABLED=false).');
539
- }
540
- }
541
- else if (!appConfig.dockerWorker) {
542
- Logger.info('Skipping local worker dashboards in Cloudflare Workers runtime.');
543
- }
544
- // Register service providers
545
- // Bootstrap services
557
+ await initializeRuntimeRoutes(params);
546
558
  Logger.info('✅ Application booted successfully');
547
559
  params.setBooted(true);
548
560
  };
549
- const shutdown = async () => {
561
+ };
562
+ const createShutdownLifecycle = (params) => {
563
+ return async () => {
550
564
  Logger.info('🛑 Shutting down application...');
565
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.start');
566
+ QueueReliabilityOrchestrator.stop();
567
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.after-queue-reliability-stop');
568
+ if (runtimeQueueMonitor !== null && typeof runtimeQueueMonitor.close === 'function') {
569
+ try {
570
+ ShutdownTrace.log('runtime.lifecycle.shutdown.queue-monitor-close.start');
571
+ await runtimeQueueMonitor.close();
572
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.queue-monitor-close.complete');
573
+ }
574
+ catch (error) {
575
+ Logger.warn('Queue Monitor shutdown failed', error);
576
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.queue-monitor-close.failed', {
577
+ error: error instanceof Error ? error.message : String(error),
578
+ });
579
+ }
580
+ finally {
581
+ runtimeQueueMonitor = null;
582
+ }
583
+ }
551
584
  try {
552
585
  await params.shutdownManager.run();
586
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.after-hooks');
553
587
  }
554
588
  catch (error) {
555
589
  Logger.error('Shutdown hook failed:', error);
590
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.hooks-failed', {
591
+ error: error instanceof Error ? error.message : String(error),
592
+ });
556
593
  }
557
- // Ensure FileLogWriter.flush is attempted even if dynamic registration failed.
558
594
  try {
559
595
  const fileLogWriter = await tryImportOptional('@config/FileLogWriter');
560
596
  fileLogWriter?.FileLogWriter?.flush?.();
@@ -563,6 +599,11 @@ export const createLifecycle = (params) => {
563
599
  /* best-effort */
564
600
  }
565
601
  params.setBooted(false);
602
+ ShutdownTrace.logHandles('runtime.lifecycle.shutdown.complete');
566
603
  };
604
+ };
605
+ export const createLifecycle = (params) => {
606
+ const boot = createBootLifecycle(params);
607
+ const shutdown = createShutdownLifecycle(params);
567
608
  return { boot, shutdown };
568
609
  };
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../../src/boot/registry/worker.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGvD;;GAEG;AACH,eAAO,MAAM,0BAA0B,GACrC,iBAAiB,gBAAgB,KAChC,OAAO,CAAC,IAAI,CA0Cd,CAAC"}
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../../src/boot/registry/worker.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGvD;;GAEG;AACH,eAAO,MAAM,0BAA0B,GACrC,iBAAiB,gBAAgB,KAChC,OAAO,CAAC,IAAI,CA2Cd,CAAC"}
@@ -5,7 +5,8 @@ import { loadWorkersModule } from '../../runtime/WorkersModule.js';
5
5
  * Helper: Register Worker management system shutdown hook
6
6
  */
7
7
  export const registerWorkerShutdownHook = async (shutdownManager) => {
8
- if (Env.getBool('WORKER_SHUTDOWN_ON_APP_EXIT', true) === false ||
8
+ if (appConfig.worker !== true ||
9
+ Env.getBool('WORKER_SHUTDOWN_ON_APP_EXIT', true) === false ||
9
10
  appConfig.dockerWorker === true) {
10
11
  return Promise.resolve(); // NOSONAR - Skip worker shutdown hook registration
11
12
  }
@@ -17,7 +18,7 @@ export const registerWorkerShutdownHook = async (shutdownManager) => {
17
18
  const mod = (await loadWorkersModule());
18
19
  const isShuttingDown = typeof mod.WorkerShutdown.isShuttingDown === 'function'
19
20
  ? mod.WorkerShutdown.isShuttingDown()
20
- : mod.WorkerShutdown.getShutdownState?.().isShuttingDown ?? false;
21
+ : (mod.WorkerShutdown.getShutdownState?.().isShuttingDown ?? false);
21
22
  const completedAt = mod.WorkerShutdown.getShutdownState?.().completedAt ?? null;
22
23
  if (isShuttingDown || completedAt !== null)
23
24
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../../src/cli/utils/spawn.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA8HD,eAAO,MAAM,SAAS;wBACM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC;EA2F7D,CAAC"}
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../../src/cli/utils/spawn.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAyYD,eAAO,MAAM,SAAS;wBACM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC;EA8E7D,CAAC"}
@@ -4,6 +4,36 @@ import { spawn } from '../../node-singletons/child-process.js';
4
4
  import { existsSync } from '../../node-singletons/fs.js';
5
5
  import * as path from '../../node-singletons/path.js';
6
6
  import { fileURLToPath } from '../../node-singletons/url.js';
7
+ const CLI_SPAWN_TRACE_ENV_KEYS = ['CLI_SPAWN_TRACE', 'ZIN_SPAWN_TRACE'];
8
+ const getCliSpawnTracePid = () => {
9
+ if (typeof process === 'undefined')
10
+ return undefined;
11
+ return process.pid;
12
+ };
13
+ const isCliSpawnTraceEnabled = () => {
14
+ if (typeof process === 'undefined')
15
+ return false;
16
+ return CLI_SPAWN_TRACE_ENV_KEYS.some((key) => {
17
+ const raw = process.env[key];
18
+ if (typeof raw !== 'string')
19
+ return false;
20
+ const normalized = raw.trim().toLowerCase();
21
+ return (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on');
22
+ });
23
+ };
24
+ const writeCliSpawnTrace = (label, details = {}) => {
25
+ if (!isCliSpawnTraceEnabled())
26
+ return;
27
+ const line = JSON.stringify({
28
+ trace: 'cli-spawn',
29
+ label,
30
+ pid: getCliSpawnTracePid(),
31
+ details,
32
+ });
33
+ if (typeof process !== 'undefined' && typeof process.stderr?.write === 'function') {
34
+ process.stderr.write(`${line}\n`);
35
+ }
36
+ };
7
37
  const getExitCode = (exitCode, signal) => {
8
38
  if (typeof exitCode === 'number')
9
39
  return exitCode;
@@ -48,35 +78,210 @@ const buildCommandNotFoundMessage = (command) => {
48
78
  const waitForChildExit = async (child, onExit) => {
49
79
  return new Promise((resolve, reject) => {
50
80
  let settled = false;
51
- const finish = (result) => {
81
+ let childResult = {
82
+ exitCode: null,
83
+ signal: null,
84
+ };
85
+ const finish = () => {
52
86
  if (settled)
53
87
  return;
54
88
  settled = true;
89
+ writeCliSpawnTrace('spawn.wait.finish', {
90
+ childPid: child.pid,
91
+ exitCode: childResult.exitCode,
92
+ signal: childResult.signal,
93
+ });
55
94
  child.off?.('exit', onExitEvent);
56
95
  child.off?.('close', onCloseEvent);
57
96
  onExit();
58
- resolve(result);
97
+ resolve(childResult);
59
98
  };
60
99
  const onExitEvent = (code, signal) => {
61
- finish({ exitCode: code, signal });
100
+ writeCliSpawnTrace('spawn.child.exit', {
101
+ childPid: child.pid,
102
+ exitCode: code,
103
+ signal,
104
+ });
105
+ childResult = { exitCode: code, signal };
62
106
  };
63
107
  const onCloseEvent = (code, signal) => {
64
- finish({ exitCode: code, signal });
108
+ writeCliSpawnTrace('spawn.child.close', {
109
+ childPid: child.pid,
110
+ exitCode: code,
111
+ signal,
112
+ });
113
+ childResult = {
114
+ exitCode: childResult.exitCode ?? code,
115
+ signal: childResult.signal ?? signal,
116
+ };
117
+ finish();
65
118
  };
66
119
  child.once('error', (error) => {
120
+ writeCliSpawnTrace('spawn.child.error', {
121
+ childPid: child.pid,
122
+ error: error instanceof Error ? error.message : String(error),
123
+ });
67
124
  reject(error);
68
125
  });
69
126
  child.once('exit', onExitEvent);
70
127
  child.once('close', onCloseEvent);
71
128
  });
72
129
  };
130
+ const resolveSignalHandling = (input) => {
131
+ const forwardSignals = typeof input.forwardSignals === 'boolean' ? input.forwardSignals : !process.stdin.isTTY;
132
+ const ttySignalForwardDelayMs = process.stdin.isTTY === true && forwardSignals === false
133
+ ? Math.max(0, input.ttySignalForwardDelayMs ?? 0)
134
+ : 0;
135
+ return {
136
+ forwardSignals,
137
+ ttySignalForwardDelayMs,
138
+ };
139
+ };
140
+ const spawnChildProcess = (input) => {
141
+ writeCliSpawnTrace('spawn.child.start', {
142
+ command: input.command,
143
+ args: input.args,
144
+ cwd: input.cwd,
145
+ shell: input.shell,
146
+ });
147
+ const child = spawn(input.command, input.args, {
148
+ cwd: input.cwd,
149
+ env: input.env,
150
+ stdio: ['inherit', 'pipe', 'pipe'],
151
+ shell: input.shell,
152
+ });
153
+ child.stdout?.on('data', (chunk) => {
154
+ writeCliSpawnTrace('spawn.child.stdout.data', {
155
+ childPid: child.pid,
156
+ bytes: typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length,
157
+ });
158
+ process.stdout.write(chunk);
159
+ });
160
+ child.stdout?.on('end', () => {
161
+ writeCliSpawnTrace('spawn.child.stdout.end', {
162
+ childPid: child.pid,
163
+ });
164
+ });
165
+ child.stdout?.on('close', () => {
166
+ writeCliSpawnTrace('spawn.child.stdout.close', {
167
+ childPid: child.pid,
168
+ });
169
+ });
170
+ child.stderr?.on('data', (chunk) => {
171
+ writeCliSpawnTrace('spawn.child.stderr.data', {
172
+ childPid: child.pid,
173
+ bytes: typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length,
174
+ });
175
+ process.stderr.write(chunk);
176
+ });
177
+ child.stderr?.on('end', () => {
178
+ writeCliSpawnTrace('spawn.child.stderr.end', {
179
+ childPid: child.pid,
180
+ });
181
+ });
182
+ child.stderr?.on('close', () => {
183
+ writeCliSpawnTrace('spawn.child.stderr.close', {
184
+ childPid: child.pid,
185
+ });
186
+ });
187
+ writeCliSpawnTrace('spawn.child.started', {
188
+ childPid: child.pid,
189
+ command: input.command,
190
+ });
191
+ return child;
192
+ };
193
+ const createForwardSignal = (input) => {
194
+ return (signal) => {
195
+ writeCliSpawnTrace('spawn.signal.forward.attempt', {
196
+ childPid: input.child.pid,
197
+ signal,
198
+ });
199
+ try {
200
+ input.child.kill(signal);
201
+ writeCliSpawnTrace('spawn.signal.forward.complete', {
202
+ childPid: input.child.pid,
203
+ signal,
204
+ });
205
+ }
206
+ catch (error) {
207
+ writeCliSpawnTrace('spawn.signal.forward.failed', {
208
+ childPid: input.child.pid,
209
+ signal,
210
+ error: error instanceof Error ? error.message : String(error),
211
+ });
212
+ const wrapped = ErrorFactory.createTryCatchError('Failed to forward signal to child process', error);
213
+ try {
214
+ process.stderr.write(`${String(wrapped.message)}\n`);
215
+ }
216
+ catch {
217
+ // ignore
218
+ }
219
+ throw wrapped;
220
+ }
221
+ };
222
+ };
223
+ const createSignalHandlers = (input) => {
224
+ const onSigint = () => {
225
+ writeCliSpawnTrace('spawn.signal.received', {
226
+ signal: 'SIGINT',
227
+ forwardSignals: input.forwardSignals,
228
+ });
229
+ if (input.forwardSignals) {
230
+ input.forwardSignal('SIGINT');
231
+ return;
232
+ }
233
+ input.delayedSignalForwarder.schedule('SIGINT');
234
+ };
235
+ const onSigterm = () => {
236
+ writeCliSpawnTrace('spawn.signal.received', {
237
+ signal: 'SIGTERM',
238
+ forwardSignals: input.forwardSignals,
239
+ });
240
+ if (input.forwardSignals) {
241
+ input.forwardSignal('SIGTERM');
242
+ return;
243
+ }
244
+ input.delayedSignalForwarder.schedule('SIGTERM');
245
+ };
246
+ return { onSigint, onSigterm };
247
+ };
73
248
  const createDelayedSignalForwarder = (input) => {
74
249
  let delayedSignalTimer;
250
+ let escalationTimer;
251
+ const clearEscalation = () => {
252
+ if (escalationTimer === undefined)
253
+ return;
254
+ clearTimeout(escalationTimer);
255
+ escalationTimer = undefined;
256
+ };
75
257
  const clear = () => {
76
- if (delayedSignalTimer === undefined)
258
+ if (delayedSignalTimer !== undefined) {
259
+ clearTimeout(delayedSignalTimer);
260
+ delayedSignalTimer = undefined;
261
+ }
262
+ clearEscalation();
263
+ writeCliSpawnTrace('spawn.signal.delay.clear');
264
+ };
265
+ const scheduleEscalation = (signal) => {
266
+ if (escalationTimer !== undefined || input.isChildClosed())
77
267
  return;
78
- clearTimeout(delayedSignalTimer);
79
- delayedSignalTimer = undefined;
268
+ const nextSignal = signal === 'SIGINT' ? 'SIGTERM' : signal;
269
+ const escalationDelayMs = Math.max(250, Math.min(1000, input.ttySignalForwardDelayMs));
270
+ escalationTimer = globalThis.setTimeout(() => {
271
+ escalationTimer = undefined;
272
+ if (input.isChildClosed())
273
+ return;
274
+ try {
275
+ writeCliSpawnTrace('spawn.signal.escalation.fire', {
276
+ signal: nextSignal,
277
+ });
278
+ input.forwardSignal(nextSignal);
279
+ }
280
+ catch {
281
+ // best-effort fallback for interactive watch processes
282
+ }
283
+ }, escalationDelayMs);
284
+ escalationTimer.unref?.();
80
285
  };
81
286
  const schedule = (signal) => {
82
287
  if (input.ttySignalForwardDelayMs <= 0 ||
@@ -89,12 +294,20 @@ const createDelayedSignalForwarder = (input) => {
89
294
  if (input.isChildClosed())
90
295
  return;
91
296
  try {
297
+ writeCliSpawnTrace('spawn.signal.delay.fire', {
298
+ signal,
299
+ });
92
300
  input.forwardSignal(signal);
301
+ scheduleEscalation(signal);
93
302
  }
94
303
  catch {
95
304
  // best-effort fallback for interactive watch processes
96
305
  }
97
306
  }, input.ttySignalForwardDelayMs);
307
+ writeCliSpawnTrace('spawn.signal.delay.schedule', {
308
+ signal,
309
+ delayMs: input.ttySignalForwardDelayMs,
310
+ });
98
311
  delayedSignalTimer.unref?.();
99
312
  };
100
313
  return { clear, schedule };
@@ -103,64 +316,57 @@ export const SpawnUtil = Object.freeze({
103
316
  async spawnAndWait(input) {
104
317
  const cwd = input.cwd ?? process.cwd();
105
318
  const resolvedCommand = input.shell === true ? input.command : resolveLocalBin(input.command, cwd);
106
- const child = spawn(resolvedCommand, input.args, {
319
+ const signalHandling = resolveSignalHandling(input);
320
+ writeCliSpawnTrace('spawn.and-wait.start', {
321
+ command: resolvedCommand,
322
+ args: input.args,
323
+ cwd,
324
+ shell: input.shell === true,
325
+ forwardSignals: signalHandling.forwardSignals,
326
+ ttySignalForwardDelayMs: signalHandling.ttySignalForwardDelayMs,
327
+ });
328
+ const child = spawnChildProcess({
329
+ command: resolvedCommand,
330
+ args: input.args,
107
331
  cwd,
108
332
  env: input.env ?? appConfig.getSafeEnv(),
109
- stdio: 'inherit',
110
333
  shell: input.shell === true,
111
334
  });
112
335
  // In interactive shells, the foreground process group already receives SIGINT
113
336
  // (and often SIGTERM) so forwarding can cause duplicates. `tsx watch` is
114
337
  // especially sensitive here and can print "Previous process hasn't exited yet. Force killing...".
115
- const forwardSignals = typeof input.forwardSignals === 'boolean' ? input.forwardSignals : !process.stdin.isTTY;
116
- const ttySignalForwardDelayMs = process.stdin.isTTY === true && forwardSignals === false
117
- ? Math.max(0, input.ttySignalForwardDelayMs ?? 0)
118
- : 0;
338
+ const forwardSignals = signalHandling.forwardSignals;
339
+ const ttySignalForwardDelayMs = signalHandling.ttySignalForwardDelayMs;
119
340
  let childClosed = false;
120
- const forwardSignal = (signal) => {
121
- try {
122
- child.kill(signal);
123
- }
124
- catch (error) {
125
- const wrapped = ErrorFactory.createTryCatchError('Failed to forward signal to child process', error);
126
- // Best-effort logging; then rethrow (tests/assertions rely on this behavior).
127
- try {
128
- process.stderr.write(`${String(wrapped.message)}\n`);
129
- }
130
- catch {
131
- // ignore
132
- }
133
- throw wrapped;
134
- }
135
- };
341
+ const forwardSignal = createForwardSignal({ child });
136
342
  const delayedSignalForwarder = createDelayedSignalForwarder({
137
343
  ttySignalForwardDelayMs,
138
344
  isChildClosed: () => childClosed,
139
345
  forwardSignal,
140
346
  });
141
- const onSigint = () => {
142
- if (forwardSignals) {
143
- forwardSignal('SIGINT');
144
- return;
145
- }
146
- // In interactive TTY mode, let the child receive the terminal SIGINT directly first.
147
- // If it is still alive after a short grace period, send one fallback signal so the
148
- // watcher exits without requiring a second Ctrl+C from the user.
149
- delayedSignalForwarder.schedule('SIGINT');
150
- };
151
- const onSigterm = () => {
152
- if (forwardSignals) {
153
- forwardSignal('SIGTERM');
154
- return;
155
- }
156
- delayedSignalForwarder.schedule('SIGTERM');
157
- };
347
+ const { onSigint, onSigterm } = createSignalHandlers({
348
+ forwardSignals,
349
+ delayedSignalForwarder,
350
+ forwardSignal,
351
+ });
158
352
  process.on('SIGINT', onSigint);
159
353
  process.on('SIGTERM', onSigterm);
354
+ writeCliSpawnTrace('spawn.signal.handlers.registered', {
355
+ childPid: child.pid,
356
+ });
160
357
  try {
161
358
  const result = await waitForChildExit(child, () => {
162
359
  childClosed = true;
163
360
  delayedSignalForwarder.clear();
361
+ writeCliSpawnTrace('spawn.child.mark-closed', {
362
+ childPid: child.pid,
363
+ });
364
+ });
365
+ writeCliSpawnTrace('spawn.and-wait.result', {
366
+ childPid: child.pid,
367
+ exitCode: result.exitCode,
368
+ signal: result.signal,
369
+ normalizedExitCode: getExitCode(result.exitCode, result.signal),
164
370
  });
165
371
  return getExitCode(result.exitCode, result.signal);
166
372
  }
@@ -175,6 +381,9 @@ export const SpawnUtil = Object.freeze({
175
381
  delayedSignalForwarder.clear();
176
382
  process.off('SIGINT', onSigint);
177
383
  process.off('SIGTERM', onSigterm);
384
+ writeCliSpawnTrace('spawn.signal.handlers.removed', {
385
+ childPid: child.pid,
386
+ });
178
387
  }
179
388
  },
180
389
  });
@@ -0,0 +1,9 @@
1
+ type ShutdownTraceDetails = Record<string, unknown>;
2
+ export declare const ShutdownTrace: Readonly<{
3
+ isEnabled: () => boolean;
4
+ log: (label: string, details?: ShutdownTraceDetails) => void;
5
+ logHandles: (label: string, details?: ShutdownTraceDetails) => void;
6
+ logBullMQWorker: (label: string, worker: unknown, details?: ShutdownTraceDetails) => void;
7
+ }>;
8
+ export {};
9
+ //# sourceMappingURL=ShutdownTrace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ShutdownTrace.d.ts","sourceRoot":"","sources":["../../../src/helper/ShutdownTrace.ts"],"names":[],"mappings":"AAKA,KAAK,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAoLpD,eAAO,MAAM,aAAa;qBAnKJ,OAAO;iBAwFT,MAAM,YAAW,oBAAoB,KAAQ,IAAI;wBAY1C,MAAM,YAAW,oBAAoB,KAAQ,IAAI;6BA8BnE,MAAM,UACL,OAAO,YACN,oBAAoB,KAC5B,IAAI;EAmCL,CAAC"}