command-stream 0.3.2 → 0.4.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.
package/src/$.mjs CHANGED
@@ -5,9 +5,7 @@
5
5
  // 3. EventEmitter: $`command`.on('data', chunk => ...).on('end', result => ...)
6
6
  // 4. Stream access: $`command`.stdout, $`command`.stderr
7
7
 
8
- import { createRequire } from 'module';
9
8
  import cp from 'child_process';
10
- import fs from 'fs';
11
9
  import path from 'path';
12
10
 
13
11
  const isBun = typeof globalThis.Bun !== 'undefined';
@@ -54,8 +52,203 @@ function isInteractiveCommand(command) {
54
52
  let parentStreamsMonitored = false;
55
53
  const activeProcessRunners = new Set();
56
54
 
55
+ // Track if SIGINT handler has been installed
56
+ let sigintHandlerInstalled = false;
57
+ let sigintHandler = null; // Store reference to remove it later
58
+
59
+ function installSignalHandlers() {
60
+ // Check if our handler is actually installed (not just the flag)
61
+ // This is more robust against test cleanup that manually removes listeners
62
+ const currentListeners = process.listeners('SIGINT');
63
+ const hasOurHandler = currentListeners.some(l => {
64
+ const str = l.toString();
65
+ return str.includes('activeProcessRunners') &&
66
+ str.includes('ProcessRunner') &&
67
+ str.includes('activeChildren');
68
+ });
69
+
70
+ if (sigintHandlerInstalled && hasOurHandler) {
71
+ trace('SignalHandler', () => 'SIGINT handler already installed, skipping');
72
+ return;
73
+ }
74
+
75
+ // Reset flag if handler was removed externally
76
+ if (sigintHandlerInstalled && !hasOurHandler) {
77
+ trace('SignalHandler', () => 'SIGINT handler flag was set but handler missing, resetting');
78
+ sigintHandlerInstalled = false;
79
+ sigintHandler = null;
80
+ }
81
+
82
+ trace('SignalHandler', () => `Installing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}`);
83
+ sigintHandlerInstalled = true;
84
+
85
+ // Forward SIGINT to all active child processes
86
+ // The parent process continues running - it's up to the parent to decide what to do
87
+ sigintHandler = () => {
88
+ // Check for other handlers immediately at the start, before doing any processing
89
+ const currentListeners = process.listeners('SIGINT');
90
+ const hasOtherHandlers = currentListeners.length > 1;
91
+
92
+ trace('ProcessRunner', () => `SIGINT handler triggered - checking active processes`);
93
+
94
+ // Count active processes (both child processes and virtual commands)
95
+ const activeChildren = [];
96
+ for (const runner of activeProcessRunners) {
97
+ if (!runner.finished) {
98
+ // Real child process
99
+ if (runner.child && runner.child.pid) {
100
+ activeChildren.push(runner);
101
+ trace('ProcessRunner', () => `Found active child: PID ${runner.child.pid}, command: ${runner.spec?.command || 'unknown'}`);
102
+ }
103
+ // Virtual command (no child process but still active)
104
+ else if (!runner.child) {
105
+ activeChildren.push(runner);
106
+ trace('ProcessRunner', () => `Found active virtual command: ${runner.spec?.command || 'unknown'}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ trace('ProcessRunner', () => `Parent received SIGINT | ${JSON.stringify({
112
+ activeChildrenCount: activeChildren.length,
113
+ hasOtherHandlers,
114
+ platform: process.platform,
115
+ pid: process.pid,
116
+ ppid: process.ppid,
117
+ activeCommands: activeChildren.map(r => ({
118
+ hasChild: !!r.child,
119
+ childPid: r.child?.pid,
120
+ hasVirtualGenerator: !!r._virtualGenerator,
121
+ finished: r.finished,
122
+ command: r.spec?.command?.slice(0, 30)
123
+ }))
124
+ }, null, 2)}`);
125
+
126
+ // Only handle SIGINT if we have active child processes
127
+ // Otherwise, let other handlers or default behavior handle it
128
+ if (activeChildren.length === 0) {
129
+ trace('ProcessRunner', () => `No active children - skipping SIGINT forwarding, letting other handlers handle it`);
130
+ return; // Let other handlers or default behavior handle it
131
+ }
132
+
133
+ trace('ProcessRunner', () => `Beginning SIGINT forwarding to ${activeChildren.length} active processes`);
134
+
135
+ // Forward signal to all active processes (child processes and virtual commands)
136
+ for (const runner of activeChildren) {
137
+ try {
138
+ if (runner.child && runner.child.pid) {
139
+ // Real child process - send SIGINT to it
140
+ trace('ProcessRunner', () => `Sending SIGINT to child process | ${JSON.stringify({
141
+ pid: runner.child.pid,
142
+ killed: runner.child.killed,
143
+ runtime: isBun ? 'Bun' : 'Node.js',
144
+ command: runner.spec?.command?.slice(0, 50)
145
+ }, null, 2)}`);
146
+
147
+ if (isBun) {
148
+ runner.child.kill('SIGINT');
149
+ trace('ProcessRunner', () => `Bun: SIGINT sent to PID ${runner.child.pid}`);
150
+ } else {
151
+ // Send to process group if detached, otherwise to process directly
152
+ try {
153
+ process.kill(-runner.child.pid, 'SIGINT');
154
+ trace('ProcessRunner', () => `Node.js: SIGINT sent to process group -${runner.child.pid}`);
155
+ } catch (err) {
156
+ trace('ProcessRunner', () => `Node.js: Process group kill failed, trying direct: ${err.message}`);
157
+ process.kill(runner.child.pid, 'SIGINT');
158
+ trace('ProcessRunner', () => `Node.js: SIGINT sent directly to PID ${runner.child.pid}`);
159
+ }
160
+ }
161
+ } else {
162
+ // Virtual command - cancel it using the runner's kill method
163
+ trace('ProcessRunner', () => `Cancelling virtual command | ${JSON.stringify({
164
+ hasChild: !!runner.child,
165
+ hasVirtualGenerator: !!runner._virtualGenerator,
166
+ finished: runner.finished,
167
+ cancelled: runner._cancelled,
168
+ command: runner.spec?.command?.slice(0, 50)
169
+ }, null, 2)}`);
170
+ runner.kill('SIGINT');
171
+ trace('ProcessRunner', () => `Virtual command kill() called`);
172
+ }
173
+ } catch (err) {
174
+ trace('ProcessRunner', () => `Error in SIGINT handler for runner | ${JSON.stringify({
175
+ error: err.message,
176
+ stack: err.stack?.slice(0, 300),
177
+ hasPid: !!(runner.child && runner.child.pid),
178
+ pid: runner.child?.pid,
179
+ command: runner.spec?.command?.slice(0, 50)
180
+ }, null, 2)}`);
181
+ }
182
+ }
183
+
184
+ // We've forwarded SIGINT to all active processes/commands
185
+ // Use the hasOtherHandlers flag we calculated at the start (before any processing)
186
+ trace('ProcessRunner', () => `SIGINT forwarded to ${activeChildren.length} active processes, other handlers: ${hasOtherHandlers}`);
187
+
188
+ if (!hasOtherHandlers) {
189
+ // No other handlers - we should exit like a proper shell
190
+ trace('ProcessRunner', () => `No other SIGINT handlers, exiting with code 130`);
191
+ // Ensure stdout/stderr are flushed before exiting
192
+ if (process.stdout && typeof process.stdout.write === 'function') {
193
+ process.stdout.write('', () => {
194
+ process.exit(130); // 128 + 2 (SIGINT)
195
+ });
196
+ } else {
197
+ process.exit(130); // 128 + 2 (SIGINT)
198
+ }
199
+ } else {
200
+ // Other handlers exist - let them handle the exit completely
201
+ // Do NOT call process.exit() ourselves when other handlers are present
202
+ trace('ProcessRunner', () => `Other SIGINT handlers present, letting them handle the exit completely`);
203
+ }
204
+ };
205
+
206
+ process.on('SIGINT', sigintHandler);
207
+ }
208
+
209
+ function uninstallSignalHandlers() {
210
+ if (!sigintHandlerInstalled || !sigintHandler) {
211
+ trace('SignalHandler', () => 'SIGINT handler not installed or missing, skipping removal');
212
+ return;
213
+ }
214
+
215
+ trace('SignalHandler', () => `Removing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}`);
216
+ process.removeListener('SIGINT', sigintHandler);
217
+ sigintHandlerInstalled = false;
218
+ sigintHandler = null;
219
+ }
220
+
221
+ // Force cleanup of all command-stream SIGINT handlers and state - for testing
222
+ function forceCleanupAll() {
223
+ // Remove all command-stream SIGINT handlers
224
+ const sigintListeners = process.listeners('SIGINT');
225
+ const commandStreamListeners = sigintListeners.filter(l => {
226
+ const str = l.toString();
227
+ return str.includes('activeProcessRunners') ||
228
+ str.includes('ProcessRunner') ||
229
+ str.includes('activeChildren');
230
+ });
231
+
232
+ commandStreamListeners.forEach(listener => {
233
+ process.removeListener('SIGINT', listener);
234
+ });
235
+
236
+ // Clear activeProcessRunners
237
+ activeProcessRunners.clear();
238
+
239
+ // Reset flags
240
+ sigintHandlerInstalled = false;
241
+ sigintHandler = null;
242
+
243
+ trace('SignalHandler', () => `Force cleanup completed - removed ${commandStreamListeners.length} handlers`);
244
+ }
245
+
57
246
  function monitorParentStreams() {
58
- if (parentStreamsMonitored) return;
247
+ if (parentStreamsMonitored) {
248
+ trace('StreamMonitor', () => 'Parent streams already monitored, skipping');
249
+ return;
250
+ }
251
+ trace('StreamMonitor', () => 'Setting up parent stream monitoring');
59
252
  parentStreamsMonitored = true;
60
253
 
61
254
  const checkParentStream = (stream, name) => {
@@ -279,78 +472,6 @@ const StreamUtils = {
279
472
  }
280
473
  };
281
474
 
282
- // Virtual command utility functions for consistent behavior and error handling
283
- const VirtualUtils = {
284
- /**
285
- * Create standardized error response for missing operands
286
- */
287
- missingOperandError(commandName, customMessage = null) {
288
- const message = customMessage || `${commandName}: missing operand`;
289
- return { stderr: message, code: 1 };
290
- },
291
-
292
- /**
293
- * Create standardized error response for invalid arguments
294
- */
295
- invalidArgumentError(commandName, message) {
296
- return { stderr: `${commandName}: ${message}`, code: 1 };
297
- },
298
-
299
- /**
300
- * Create standardized success response
301
- */
302
- success(stdout = '', code = 0) {
303
- return { stdout, stderr: '', code };
304
- },
305
-
306
- /**
307
- * Create standardized error response
308
- */
309
- error(stderr = '', code = 1) {
310
- return { stdout: '', stderr, code };
311
- },
312
-
313
- /**
314
- * Validate that command has required number of arguments
315
- */
316
- validateArgs(args, minCount, commandName) {
317
- if (args.length < minCount) {
318
- if (minCount === 1) {
319
- return this.missingOperandError(commandName);
320
- } else {
321
- return this.invalidArgumentError(commandName, `requires at least ${minCount} arguments`);
322
- }
323
- }
324
- return null; // No error
325
- },
326
-
327
- /**
328
- * Resolve file path with optional cwd parameter
329
- */
330
- resolvePath(filePath, cwd = null) {
331
- const basePath = cwd || process.cwd();
332
- return path.isAbsolute(filePath) ? filePath : path.resolve(basePath, filePath);
333
- },
334
-
335
- /**
336
- * Safe file system operation wrapper
337
- */
338
- async safeFsOperation(operation, errorPrefix) {
339
- try {
340
- return await operation();
341
- } catch (error) {
342
- return this.error(`${errorPrefix}: ${error.message}`);
343
- }
344
- },
345
-
346
- /**
347
- * Create async wrapper for Promise-based operations
348
- */
349
- createAsyncWrapper(promiseFactory) {
350
- return new Promise(promiseFactory);
351
- }
352
- };
353
-
354
475
  let globalShellSettings = {
355
476
  errexit: false, // set -e equivalent: exit on error
356
477
  verbose: false, // set -v equivalent: print commands
@@ -394,6 +515,11 @@ class StreamEmitter {
394
515
 
395
516
  emit(event, ...args) {
396
517
  const eventListeners = this.listeners.get(event);
518
+ trace('StreamEmitter', () => `Emitting event | ${JSON.stringify({
519
+ event,
520
+ hasListeners: !!eventListeners,
521
+ listenerCount: eventListeners?.length || 0
522
+ })}`);
397
523
  if (eventListeners) {
398
524
  for (const listener of eventListeners) {
399
525
  listener(...args);
@@ -498,20 +624,80 @@ class ProcessRunner extends StreamEmitter {
498
624
  this._mode = null; // 'async' or 'sync'
499
625
 
500
626
  this._cancelled = false;
627
+ this._cancellationSignal = null; // Track which signal caused cancellation
501
628
  this._virtualGenerator = null;
502
629
  this._abortController = new AbortController();
503
630
 
504
631
  activeProcessRunners.add(this);
632
+
633
+ // Ensure parent stream monitoring is set up for all ProcessRunners
634
+ monitorParentStreams();
635
+
636
+ trace('ProcessRunner', () => `Added to activeProcessRunners | ${JSON.stringify({
637
+ command: this.spec?.command || 'unknown',
638
+ totalActive: activeProcessRunners.size
639
+ }, null, 2)}`);
640
+ installSignalHandlers();
505
641
 
506
- // Track finished state changes to trigger cleanup
507
- this._finished = false;
642
+ this.finished = false;
508
643
  }
509
644
 
510
- get finished() {
511
- return this._finished;
645
+ // Stream property getters for child process streams (null for virtual commands)
646
+ get stdout() {
647
+ return this.child ? this.child.stdout : null;
648
+ }
649
+
650
+ get stderr() {
651
+ return this.child ? this.child.stderr : null;
652
+ }
653
+
654
+ get stdin() {
655
+ return this.child ? this.child.stdin : null;
656
+ }
657
+
658
+ // Centralized method to properly finish a process with correct event emission order
659
+ finish(result) {
660
+ trace('ProcessRunner', () => `finish() called | ${JSON.stringify({
661
+ alreadyFinished: this.finished,
662
+ resultCode: result?.code,
663
+ hasStdout: !!result?.stdout,
664
+ hasStderr: !!result?.stderr,
665
+ command: this.spec?.command?.slice(0, 50)
666
+ }, null, 2)}`);
667
+
668
+ // Make finish() idempotent - safe to call multiple times
669
+ if (this.finished) {
670
+ trace('ProcessRunner', () => `Already finished, returning existing result`);
671
+ return this.result || result;
672
+ }
673
+
674
+ // Store result
675
+ this.result = result;
676
+ trace('ProcessRunner', () => `Result stored, about to emit events`);
677
+
678
+ // Emit completion events BEFORE setting finished to prevent _cleanup() from clearing listeners
679
+ this.emit('end', result);
680
+ trace('ProcessRunner', () => `'end' event emitted`);
681
+ this.emit('exit', result.code);
682
+ trace('ProcessRunner', () => `'exit' event emitted with code ${result.code}`);
683
+
684
+ // Set finished after events are emitted
685
+ this.finished = true;
686
+ trace('ProcessRunner', () => `Marked as finished, calling cleanup`);
687
+
688
+ // Trigger cleanup now that process is finished
689
+ this._cleanup();
690
+ trace('ProcessRunner', () => `Cleanup completed`);
691
+
692
+ return result;
512
693
  }
513
694
 
514
695
  _emitProcessedData(type, buf) {
696
+ // Don't emit data if we've been cancelled
697
+ if (this._cancelled) {
698
+ trace('ProcessRunner', () => 'Skipping data emission - process cancelled');
699
+ return;
700
+ }
515
701
  const processedBuf = processOutput(buf, this.options.ansi);
516
702
  this.emit(type, processedBuf);
517
703
  this.emit('data', { type, data: processedBuf });
@@ -531,6 +717,36 @@ class ProcessRunner extends StreamEmitter {
531
717
 
532
718
  // Forward stdin data to child process
533
719
  const onData = (chunk) => {
720
+ // Check for CTRL+C (ASCII code 3)
721
+ if (chunk[0] === 3) {
722
+ trace('ProcessRunner', () => 'CTRL+C detected, sending SIGINT to child process');
723
+ // Send SIGINT to the child process
724
+ if (this.child && this.child.pid) {
725
+ try {
726
+ if (isBun) {
727
+ this.child.kill('SIGINT');
728
+ } else {
729
+ // In Node.js, send SIGINT to the process group if detached
730
+ // or to the process directly if not
731
+ if (this.child.pid > 0) {
732
+ try {
733
+ // Try process group first if detached
734
+ process.kill(-this.child.pid, 'SIGINT');
735
+ } catch (err) {
736
+ // Fall back to direct process
737
+ process.kill(this.child.pid, 'SIGINT');
738
+ }
739
+ }
740
+ }
741
+ } catch (err) {
742
+ trace('ProcessRunner', () => `Error sending SIGINT: ${err.message}`);
743
+ }
744
+ }
745
+ // Don't forward CTRL+C to stdin, just handle the signal
746
+ return;
747
+ }
748
+
749
+ // Forward other input to child stdin
534
750
  if (this.child.stdin) {
535
751
  if (isBun && this.child.stdin.write) {
536
752
  this.child.stdin.write(chunk);
@@ -564,17 +780,15 @@ class ProcessRunner extends StreamEmitter {
564
780
  }
565
781
  }
566
782
 
567
- set finished(value) {
568
- if (value === true && this._finished === false) {
569
- this._finished = true;
570
- this._cleanup(); // Trigger cleanup when process finishes
571
- } else {
572
- this._finished = value;
573
- }
574
- }
575
783
 
576
784
  _handleParentStreamClosure() {
577
- if (this.finished || this._cancelled) return;
785
+ if (this.finished || this._cancelled) {
786
+ trace('ProcessRunner', () => `Parent stream closure ignored | ${JSON.stringify({
787
+ finished: this.finished,
788
+ cancelled: this._cancelled
789
+ })}`);
790
+ return;
791
+ }
578
792
 
579
793
  trace('ProcessRunner', () => `Handling parent stream closure | ${JSON.stringify({
580
794
  started: this.started,
@@ -614,11 +828,111 @@ class ProcessRunner extends StreamEmitter {
614
828
  }
615
829
  }
616
830
 
617
- activeProcessRunners.delete(this);
831
+ this._cleanup();
618
832
  }
619
833
 
620
834
  _cleanup() {
835
+ trace('ProcessRunner', () => `_cleanup() called | ${JSON.stringify({
836
+ wasActiveBeforeCleanup: activeProcessRunners.has(this),
837
+ totalActiveBefore: activeProcessRunners.size,
838
+ finished: this.finished,
839
+ hasChild: !!this.child,
840
+ command: this.spec?.command?.slice(0, 50)
841
+ }, null, 2)}`);
842
+
843
+ const wasActive = activeProcessRunners.has(this);
621
844
  activeProcessRunners.delete(this);
845
+
846
+ if (wasActive) {
847
+ trace('ProcessRunner', () => `Removed from activeProcessRunners | ${JSON.stringify({
848
+ command: this.spec?.command || 'unknown',
849
+ totalActiveAfter: activeProcessRunners.size,
850
+ remainingCommands: Array.from(activeProcessRunners).map(r => r.spec?.command?.slice(0, 30))
851
+ }, null, 2)}`);
852
+ } else {
853
+ trace('ProcessRunner', () => `Was not in activeProcessRunners (already cleaned up)`);
854
+ }
855
+
856
+ // If this is a pipeline runner, also clean up the source and destination
857
+ if (this.spec?.mode === 'pipeline') {
858
+ trace('ProcessRunner', () => 'Cleaning up pipeline components');
859
+ if (this.spec.source && typeof this.spec.source._cleanup === 'function') {
860
+ this.spec.source._cleanup();
861
+ }
862
+ if (this.spec.destination && typeof this.spec.destination._cleanup === 'function') {
863
+ this.spec.destination._cleanup();
864
+ }
865
+ }
866
+
867
+ // If no more active ProcessRunners, remove the SIGINT handler
868
+ if (activeProcessRunners.size === 0) {
869
+ uninstallSignalHandlers();
870
+ }
871
+
872
+ // Clean up event listeners from StreamEmitter
873
+ if (this.listeners) {
874
+ this.listeners.clear();
875
+ }
876
+
877
+ // Clean up abort controller
878
+ if (this._abortController) {
879
+ trace('ProcessRunner', () => `Cleaning up abort controller during cleanup | ${JSON.stringify({
880
+ wasAborted: this._abortController?.signal?.aborted
881
+ }, null, 2)}`);
882
+ try {
883
+ this._abortController.abort();
884
+ trace('ProcessRunner', () => `Abort controller aborted successfully during cleanup`);
885
+ } catch (e) {
886
+ trace('ProcessRunner', () => `Error aborting controller during cleanup: ${e.message}`);
887
+ }
888
+ this._abortController = null;
889
+ trace('ProcessRunner', () => `Abort controller reference cleared during cleanup`);
890
+ } else {
891
+ trace('ProcessRunner', () => `No abort controller to clean up during cleanup`);
892
+ }
893
+
894
+ // Clean up child process reference
895
+ if (this.child) {
896
+ trace('ProcessRunner', () => `Cleaning up child process reference | ${JSON.stringify({
897
+ hasChild: true,
898
+ childPid: this.child.pid,
899
+ childKilled: this.child.killed
900
+ }, null, 2)}`);
901
+ try {
902
+ this.child.removeAllListeners?.();
903
+ trace('ProcessRunner', () => `Child process listeners removed successfully`);
904
+ } catch (e) {
905
+ trace('ProcessRunner', () => `Error removing child process listeners: ${e.message}`);
906
+ }
907
+ this.child = null;
908
+ trace('ProcessRunner', () => `Child process reference cleared`);
909
+ } else {
910
+ trace('ProcessRunner', () => `No child process reference to clean up`);
911
+ }
912
+
913
+ // Clean up virtual generator
914
+ if (this._virtualGenerator) {
915
+ trace('ProcessRunner', () => `Cleaning up virtual generator | ${JSON.stringify({
916
+ hasReturn: !!this._virtualGenerator.return
917
+ }, null, 2)}`);
918
+ try {
919
+ if (this._virtualGenerator.return) {
920
+ this._virtualGenerator.return();
921
+ trace('ProcessRunner', () => `Virtual generator return() called successfully`);
922
+ }
923
+ } catch (e) {
924
+ trace('ProcessRunner', () => `Error calling virtual generator return(): ${e.message}`);
925
+ }
926
+ this._virtualGenerator = null;
927
+ trace('ProcessRunner', () => `Virtual generator reference cleared`);
928
+ } else {
929
+ trace('ProcessRunner', () => `No virtual generator to clean up`);
930
+ }
931
+
932
+ trace('ProcessRunner', () => `_cleanup() completed | ${JSON.stringify({
933
+ totalActiveAfter: activeProcessRunners.size,
934
+ sigintListenerCount: process.listeners('SIGINT').length
935
+ }, null, 2)}`);
622
936
  }
623
937
 
624
938
  // Unified start method that can work in both async and sync modes
@@ -637,6 +951,79 @@ class ProcessRunner extends StreamEmitter {
637
951
  // Create a new options object merging the current ones with the new ones
638
952
  this.options = { ...this.options, ...options };
639
953
 
954
+ // Handle external abort signal
955
+ if (this.options.signal && typeof this.options.signal.addEventListener === 'function') {
956
+ trace('ProcessRunner', () => `Setting up external abort signal listener | ${JSON.stringify({
957
+ hasSignal: !!this.options.signal,
958
+ signalAborted: this.options.signal.aborted,
959
+ hasInternalController: !!this._abortController,
960
+ internalAborted: this._abortController?.signal.aborted
961
+ }, null, 2)}`);
962
+
963
+ this.options.signal.addEventListener('abort', () => {
964
+ trace('ProcessRunner', () => `External abort signal triggered | ${JSON.stringify({
965
+ externalSignalAborted: this.options.signal.aborted,
966
+ hasInternalController: !!this._abortController,
967
+ internalAborted: this._abortController?.signal.aborted,
968
+ command: this.spec?.command?.slice(0, 50)
969
+ }, null, 2)}`);
970
+
971
+ // Kill the process when abort signal is triggered
972
+ trace('ProcessRunner', () => `External abort signal received - killing process | ${JSON.stringify({
973
+ hasChild: !!this.child,
974
+ childPid: this.child?.pid,
975
+ finished: this.finished,
976
+ command: this.spec?.command?.slice(0, 50)
977
+ }, null, 2)}`);
978
+ this.kill('SIGTERM');
979
+ trace('ProcessRunner', () => 'Process kill initiated due to external abort signal');
980
+
981
+ if (this._abortController && !this._abortController.signal.aborted) {
982
+ trace('ProcessRunner', () => 'Aborting internal controller due to external signal');
983
+ this._abortController.abort();
984
+ trace('ProcessRunner', () => `Internal controller aborted | ${JSON.stringify({
985
+ internalAborted: this._abortController?.signal?.aborted
986
+ }, null, 2)}`);
987
+ } else {
988
+ trace('ProcessRunner', () => `Cannot abort internal controller | ${JSON.stringify({
989
+ hasInternalController: !!this._abortController,
990
+ internalAlreadyAborted: this._abortController?.signal?.aborted
991
+ }, null, 2)}`);
992
+ }
993
+ });
994
+
995
+ // If the external signal is already aborted, abort immediately
996
+ if (this.options.signal.aborted) {
997
+ trace('ProcessRunner', () => `External signal already aborted, killing process and aborting internal controller | ${JSON.stringify({
998
+ hasInternalController: !!this._abortController,
999
+ internalAborted: this._abortController?.signal.aborted
1000
+ }, null, 2)}`);
1001
+
1002
+ // Kill the process immediately since signal is already aborted
1003
+ trace('ProcessRunner', () => `Signal already aborted - killing process immediately | ${JSON.stringify({
1004
+ hasChild: !!this.child,
1005
+ childPid: this.child?.pid,
1006
+ finished: this.finished,
1007
+ command: this.spec?.command?.slice(0, 50)
1008
+ }, null, 2)}`);
1009
+ this.kill('SIGTERM');
1010
+ trace('ProcessRunner', () => 'Process kill initiated due to pre-aborted signal');
1011
+
1012
+ if (this._abortController && !this._abortController.signal.aborted) {
1013
+ this._abortController.abort();
1014
+ trace('ProcessRunner', () => `Internal controller aborted immediately | ${JSON.stringify({
1015
+ internalAborted: this._abortController?.signal?.aborted
1016
+ }, null, 2)}`);
1017
+ }
1018
+ }
1019
+ } else {
1020
+ trace('ProcessRunner', () => `No external signal to handle | ${JSON.stringify({
1021
+ hasSignal: !!this.options.signal,
1022
+ signalType: typeof this.options.signal,
1023
+ hasAddEventListener: !!(this.options.signal && typeof this.options.signal.addEventListener === 'function')
1024
+ }, null, 2)}`);
1025
+ }
1026
+
640
1027
  // Reinitialize chunks based on updated capture option
641
1028
  if ('capture' in options) {
642
1029
  trace('ProcessRunner', () => `BRANCH: capture => REINIT_CHUNKS | ${JSON.stringify({
@@ -699,6 +1086,9 @@ class ProcessRunner extends StreamEmitter {
699
1086
  this.started = true;
700
1087
  this._mode = 'async';
701
1088
 
1089
+ // Ensure cleanup happens even if execution fails
1090
+ try {
1091
+
702
1092
  const { cwd, env, stdin } = this.options;
703
1093
 
704
1094
  if (this.spec.mode === 'pipeline') {
@@ -726,25 +1116,55 @@ class ProcessRunner extends StreamEmitter {
726
1116
  }, null, 2)}`);
727
1117
  return await this._runPipeline(parsed.commands);
728
1118
  } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd)) {
729
- trace('ProcessRunner', () => `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify({
730
- isVirtual: true,
731
- args: parsed.args
732
- }, null, 2)}`);
733
- return await this._runVirtual(parsed.cmd, parsed.args, this.spec.command);
1119
+ // For built-in virtual commands that have real counterparts (like sleep),
1120
+ // skip the virtual version when custom stdin is provided to ensure proper process handling
1121
+ const hasCustomStdin = this.options.stdin &&
1122
+ this.options.stdin !== 'inherit' &&
1123
+ this.options.stdin !== 'ignore';
1124
+
1125
+ // List of built-in virtual commands that should fallback to real commands with custom stdin
1126
+ const builtinCommands = ['sleep', 'echo', 'pwd', 'true', 'false', 'yes', 'cat', 'ls', 'which'];
1127
+ const shouldBypassVirtual = hasCustomStdin && builtinCommands.includes(parsed.cmd);
1128
+
1129
+ if (shouldBypassVirtual) {
1130
+ trace('ProcessRunner', () => `Bypassing built-in virtual command due to custom stdin | ${JSON.stringify({
1131
+ cmd: parsed.cmd,
1132
+ stdin: typeof this.options.stdin
1133
+ }, null, 2)}`);
1134
+ // Fall through to run as real command
1135
+ } else {
1136
+ trace('ProcessRunner', () => `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify({
1137
+ isVirtual: true,
1138
+ args: parsed.args
1139
+ }, null, 2)}`);
1140
+ trace('ProcessRunner', () => `Executing virtual command | ${JSON.stringify({
1141
+ cmd: parsed.cmd,
1142
+ argsLength: parsed.args.length,
1143
+ command: this.spec.command
1144
+ }, null, 2)}`);
1145
+ return await this._runVirtual(parsed.cmd, parsed.args, this.spec.command);
1146
+ }
734
1147
  }
735
1148
  }
736
1149
  }
737
1150
 
738
1151
  const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1152
+ trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
1153
+ mode: this.spec.mode,
1154
+ argv: argv,
1155
+ originalCommand: this.spec.command
1156
+ }, null, 2)}`);
739
1157
 
740
1158
  if (globalShellSettings.xtrace) {
741
1159
  const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
742
1160
  console.log(`+ ${traceCmd}`);
1161
+ trace('ProcessRunner', () => `xtrace output displayed: + ${traceCmd}`);
743
1162
  }
744
1163
 
745
1164
  if (globalShellSettings.verbose) {
746
1165
  const verboseCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
747
1166
  console.log(verboseCmd);
1167
+ trace('ProcessRunner', () => `verbose output displayed: ${verboseCmd}`);
748
1168
  }
749
1169
 
750
1170
  // Detect if this is an interactive command that needs direct TTY access
@@ -754,28 +1174,154 @@ class ProcessRunner extends StreamEmitter {
754
1174
  process.stdout.isTTY === true &&
755
1175
  process.stderr.isTTY === true &&
756
1176
  (this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file));
1177
+
1178
+ trace('ProcessRunner', () => `Interactive command detection | ${JSON.stringify({
1179
+ isInteractive,
1180
+ stdinInherit: stdin === 'inherit',
1181
+ stdinTTY: process.stdin.isTTY,
1182
+ stdoutTTY: process.stdout.isTTY,
1183
+ stderrTTY: process.stderr.isTTY,
1184
+ commandCheck: this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file)
1185
+ }, null, 2)}`);
757
1186
 
758
1187
  const spawnBun = (argv) => {
1188
+ trace('ProcessRunner', () => `spawnBun: Creating process | ${JSON.stringify({
1189
+ command: argv[0],
1190
+ args: argv.slice(1),
1191
+ isInteractive,
1192
+ cwd,
1193
+ platform: process.platform
1194
+ }, null, 2)}`);
1195
+
759
1196
  if (isInteractive) {
760
1197
  // For interactive commands, use inherit to provide direct TTY access
761
- return Bun.spawn(argv, { cwd, env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
1198
+ trace('ProcessRunner', () => `spawnBun: Using interactive mode with inherited stdio`);
1199
+ const child = Bun.spawn(argv, { cwd, env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
1200
+ trace('ProcessRunner', () => `spawnBun: Interactive process created | ${JSON.stringify({
1201
+ pid: child.pid,
1202
+ killed: child.killed
1203
+ }, null, 2)}`);
1204
+ return child;
762
1205
  }
763
- return Bun.spawn(argv, { cwd, env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
1206
+ // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1207
+ // This allows us to send signals to the entire process group, killing shell and all its children
1208
+ trace('ProcessRunner', () => `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}`);
1209
+ const child = Bun.spawn(argv, {
1210
+ cwd,
1211
+ env,
1212
+ stdin: 'pipe',
1213
+ stdout: 'pipe',
1214
+ stderr: 'pipe',
1215
+ detached: process.platform !== 'win32' // Create process group on Unix-like systems
1216
+ });
1217
+ trace('ProcessRunner', () => `spawnBun: Non-interactive process created | ${JSON.stringify({
1218
+ pid: child.pid,
1219
+ killed: child.killed,
1220
+ hasStdout: !!child.stdout,
1221
+ hasStderr: !!child.stderr,
1222
+ hasStdin: !!child.stdin
1223
+ }, null, 2)}`);
1224
+ return child;
764
1225
  };
765
1226
  const spawnNode = async (argv) => {
1227
+ trace('ProcessRunner', () => `spawnNode: Creating process | ${JSON.stringify({
1228
+ command: argv[0],
1229
+ args: argv.slice(1),
1230
+ isInteractive,
1231
+ cwd,
1232
+ platform: process.platform
1233
+ })}`);
1234
+
766
1235
  if (isInteractive) {
767
1236
  // For interactive commands, use inherit to provide direct TTY access
768
1237
  return cp.spawn(argv[0], argv.slice(1), { cwd, env, stdio: 'inherit' });
769
1238
  }
770
- return cp.spawn(argv[0], argv.slice(1), { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
1239
+ // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1240
+ // This allows us to send signals to the entire process group
1241
+ const child = cp.spawn(argv[0], argv.slice(1), {
1242
+ cwd,
1243
+ env,
1244
+ stdio: ['pipe', 'pipe', 'pipe'],
1245
+ detached: process.platform !== 'win32' // Create process group on Unix-like systems
1246
+ });
1247
+
1248
+ trace('ProcessRunner', () => `spawnNode: Process created | ${JSON.stringify({
1249
+ pid: child.pid,
1250
+ killed: child.killed,
1251
+ hasStdout: !!child.stdout,
1252
+ hasStderr: !!child.stderr,
1253
+ hasStdin: !!child.stdin
1254
+ })}`);
1255
+
1256
+ return child;
771
1257
  };
772
1258
 
773
1259
  const needsExplicitPipe = stdin !== 'inherit' && stdin !== 'ignore';
774
1260
  const preferNodeForInput = isBun && needsExplicitPipe;
1261
+ trace('ProcessRunner', () => `About to spawn process | ${JSON.stringify({
1262
+ needsExplicitPipe,
1263
+ preferNodeForInput,
1264
+ runtime: isBun ? 'Bun' : 'Node',
1265
+ command: argv[0],
1266
+ args: argv.slice(1)
1267
+ }, null, 2)}`);
775
1268
  this.child = preferNodeForInput ? await spawnNode(argv) : (isBun ? spawnBun(argv) : await spawnNode(argv));
1269
+
1270
+ // Add detailed logging for CI debugging
1271
+ if (this.child) {
1272
+ trace('ProcessRunner', () => `Child process created | ${JSON.stringify({
1273
+ pid: this.child.pid,
1274
+ detached: this.child.options?.detached,
1275
+ killed: this.child.killed,
1276
+ exitCode: this.child.exitCode,
1277
+ signalCode: this.child.signalCode,
1278
+ hasStdout: !!this.child.stdout,
1279
+ hasStderr: !!this.child.stderr,
1280
+ hasStdin: !!this.child.stdin,
1281
+ platform: process.platform,
1282
+ command: this.spec?.command?.slice(0, 100)
1283
+ }, null, 2)}`);
1284
+
1285
+ // Add event listeners with detailed tracing (only for Node.js child processes)
1286
+ if (this.child && typeof this.child.on === 'function') {
1287
+ this.child.on('spawn', () => {
1288
+ trace('ProcessRunner', () => `Child process spawned successfully | ${JSON.stringify({
1289
+ pid: this.child.pid,
1290
+ command: this.spec?.command?.slice(0, 50)
1291
+ }, null, 2)}`);
1292
+ });
1293
+
1294
+ this.child.on('error', (error) => {
1295
+ trace('ProcessRunner', () => `Child process error event | ${JSON.stringify({
1296
+ pid: this.child?.pid,
1297
+ error: error.message,
1298
+ code: error.code,
1299
+ errno: error.errno,
1300
+ syscall: error.syscall,
1301
+ command: this.spec?.command?.slice(0, 50)
1302
+ }, null, 2)}`);
1303
+ });
1304
+ } else {
1305
+ trace('ProcessRunner', () => `Skipping event listeners - child does not support .on() method (likely Bun process)`);
1306
+ }
1307
+ } else {
1308
+ trace('ProcessRunner', () => `No child process created | ${JSON.stringify({
1309
+ spec: this.spec,
1310
+ hasVirtualGenerator: !!this._virtualGenerator
1311
+ }, null, 2)}`);
1312
+ }
776
1313
 
777
1314
  // For interactive commands with stdio: 'inherit', stdout/stderr will be null
1315
+ const childPid = this.child?.pid; // Capture PID once at the start
778
1316
  const outPump = this.child.stdout ? pumpReadable(this.child.stdout, async (buf) => {
1317
+ trace('ProcessRunner', () => `stdout data received | ${JSON.stringify({
1318
+ pid: childPid,
1319
+ bufferLength: buf.length,
1320
+ capture: this.options.capture,
1321
+ mirror: this.options.mirror,
1322
+ preview: buf.toString().slice(0, 100)
1323
+ })}`);
1324
+
779
1325
  if (this.options.capture) this.outChunks.push(buf);
780
1326
  if (this.options.mirror) safeWrite(process.stdout, buf);
781
1327
 
@@ -784,6 +1330,14 @@ class ProcessRunner extends StreamEmitter {
784
1330
  }) : Promise.resolve();
785
1331
 
786
1332
  const errPump = this.child.stderr ? pumpReadable(this.child.stderr, async (buf) => {
1333
+ trace('ProcessRunner', () => `stderr data received | ${JSON.stringify({
1334
+ pid: childPid,
1335
+ bufferLength: buf.length,
1336
+ capture: this.options.capture,
1337
+ mirror: this.options.mirror,
1338
+ preview: buf.toString().slice(0, 100)
1339
+ })}`);
1340
+
787
1341
  if (this.options.capture) this.errChunks.push(buf);
788
1342
  if (this.options.mirror) safeWrite(process.stderr, buf);
789
1343
 
@@ -792,28 +1346,78 @@ class ProcessRunner extends StreamEmitter {
792
1346
  }) : Promise.resolve();
793
1347
 
794
1348
  let stdinPumpPromise = Promise.resolve();
1349
+ trace('ProcessRunner', () => `Setting up stdin handling | ${JSON.stringify({
1350
+ stdinType: typeof stdin,
1351
+ stdin: stdin === 'inherit' ? 'inherit' : stdin === 'ignore' ? 'ignore' : (typeof stdin === 'string' ? `string(${stdin.length})` : 'other'),
1352
+ isInteractive,
1353
+ hasChildStdin: !!this.child?.stdin,
1354
+ processTTY: process.stdin.isTTY
1355
+ }, null, 2)}`);
1356
+
795
1357
  if (stdin === 'inherit') {
796
1358
  if (isInteractive) {
797
1359
  // For interactive commands with stdio: 'inherit', stdin is handled automatically
1360
+ trace('ProcessRunner', () => `stdin: Using inherit mode for interactive command`);
798
1361
  stdinPumpPromise = Promise.resolve();
799
1362
  } else {
800
1363
  const isPipedIn = process.stdin && process.stdin.isTTY === false;
1364
+ trace('ProcessRunner', () => `stdin: Non-interactive inherit mode | ${JSON.stringify({
1365
+ isPipedIn,
1366
+ stdinTTY: process.stdin.isTTY
1367
+ }, null, 2)}`);
801
1368
  if (isPipedIn) {
1369
+ trace('ProcessRunner', () => `stdin: Pumping piped input to child process`);
802
1370
  stdinPumpPromise = this._pumpStdinTo(this.child, this.options.capture ? this.inChunks : null);
803
1371
  } else {
804
1372
  // For TTY (interactive terminal), forward stdin directly for non-interactive commands
1373
+ trace('ProcessRunner', () => `stdin: Forwarding TTY stdin for non-interactive command`);
805
1374
  stdinPumpPromise = this._forwardTTYStdin();
806
1375
  }
807
1376
  }
808
1377
  } else if (stdin === 'ignore') {
809
- if (this.child.stdin && typeof this.child.stdin.end === 'function') this.child.stdin.end();
1378
+ trace('ProcessRunner', () => `stdin: Ignoring and closing stdin`);
1379
+ if (this.child.stdin && typeof this.child.stdin.end === 'function') {
1380
+ this.child.stdin.end();
1381
+ trace('ProcessRunner', () => `stdin: Child stdin closed successfully`);
1382
+ }
810
1383
  } else if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) {
811
1384
  const buf = Buffer.isBuffer(stdin) ? stdin : Buffer.from(stdin);
1385
+ trace('ProcessRunner', () => `stdin: Writing buffer to child | ${JSON.stringify({
1386
+ bufferLength: buf.length,
1387
+ willCapture: this.options.capture && !!this.inChunks
1388
+ }, null, 2)}`);
812
1389
  if (this.options.capture && this.inChunks) this.inChunks.push(Buffer.from(buf));
813
1390
  stdinPumpPromise = this._writeToStdin(buf);
814
- }
815
-
816
- const exited = isBun ? this.child.exited : new Promise((resolve) => this.child.on('close', resolve));
1391
+ } else {
1392
+ trace('ProcessRunner', () => `stdin: Unhandled stdin type: ${typeof stdin}`);
1393
+ }
1394
+
1395
+ const exited = isBun ? this.child.exited : new Promise((resolve) => {
1396
+ trace('ProcessRunner', () => `Setting up child process event listeners for PID ${this.child.pid}`);
1397
+ this.child.on('close', (code, signal) => {
1398
+ trace('ProcessRunner', () => `Child process close event | ${JSON.stringify({
1399
+ pid: this.child.pid,
1400
+ code,
1401
+ signal,
1402
+ killed: this.child.killed,
1403
+ exitCode: this.child.exitCode,
1404
+ signalCode: this.child.signalCode,
1405
+ command: this.command
1406
+ }, null, 2)}`);
1407
+ resolve(code);
1408
+ });
1409
+ this.child.on('exit', (code, signal) => {
1410
+ trace('ProcessRunner', () => `Child process exit event | ${JSON.stringify({
1411
+ pid: this.child.pid,
1412
+ code,
1413
+ signal,
1414
+ killed: this.child.killed,
1415
+ exitCode: this.child.exitCode,
1416
+ signalCode: this.child.signalCode,
1417
+ command: this.command
1418
+ }, null, 2)}`);
1419
+ });
1420
+ });
817
1421
  const code = await exited;
818
1422
  await Promise.all([outPump, errPump, stdinPumpPromise]);
819
1423
 
@@ -825,35 +1429,126 @@ class ProcessRunner extends StreamEmitter {
825
1429
  isBun
826
1430
  }, null, 2)}`);
827
1431
 
1432
+ // When a process is killed, it may not have an exit code
1433
+ // If cancelled and no exit code, assume it was killed with SIGTERM
1434
+ let finalExitCode = code;
1435
+ trace('ProcessRunner', () => `Processing exit code | ${JSON.stringify({
1436
+ rawCode: code,
1437
+ cancelled: this._cancelled,
1438
+ childKilled: this.child?.killed,
1439
+ childExitCode: this.child?.exitCode,
1440
+ childSignalCode: this.child?.signalCode
1441
+ }, null, 2)}`);
1442
+
1443
+ if (finalExitCode === undefined || finalExitCode === null) {
1444
+ if (this._cancelled) {
1445
+ // Process was killed, use SIGTERM exit code
1446
+ finalExitCode = 143; // 128 + 15 (SIGTERM)
1447
+ trace('ProcessRunner', () => `Process was killed, using SIGTERM exit code 143`);
1448
+ } else {
1449
+ // Process exited without a code, default to 0
1450
+ finalExitCode = 0;
1451
+ trace('ProcessRunner', () => `Process exited without code, defaulting to 0`);
1452
+ }
1453
+ }
1454
+
828
1455
  const resultData = {
829
- code: code ?? 0, // Default to 0 if exit code is null/undefined
1456
+ code: finalExitCode,
830
1457
  stdout: this.options.capture ? (this.outChunks && this.outChunks.length > 0 ? Buffer.concat(this.outChunks).toString('utf8') : '') : undefined,
831
1458
  stderr: this.options.capture ? (this.errChunks && this.errChunks.length > 0 ? Buffer.concat(this.errChunks).toString('utf8') : '') : undefined,
832
1459
  stdin: this.options.capture && this.inChunks ? Buffer.concat(this.inChunks).toString('utf8') : undefined,
833
1460
  child: this.child
834
1461
  };
1462
+
1463
+ trace('ProcessRunner', () => `Process completed | ${JSON.stringify({
1464
+ command: this.command,
1465
+ finalExitCode,
1466
+ captured: this.options.capture,
1467
+ hasStdout: !!resultData.stdout,
1468
+ hasStderr: !!resultData.stderr,
1469
+ stdoutLength: resultData.stdout?.length || 0,
1470
+ stderrLength: resultData.stderr?.length || 0,
1471
+ stdoutPreview: resultData.stdout?.slice(0, 100),
1472
+ stderrPreview: resultData.stderr?.slice(0, 100),
1473
+ childPid: this.child?.pid,
1474
+ cancelled: this._cancelled,
1475
+ cancellationSignal: this._cancellationSignal,
1476
+ platform: process.platform,
1477
+ runtime: isBun ? 'Bun' : 'Node.js'
1478
+ }, null, 2)}`);
835
1479
 
836
- this.result = {
1480
+ const result = {
837
1481
  ...resultData,
838
1482
  async text() {
839
1483
  return resultData.stdout || '';
840
1484
  }
841
1485
  };
842
1486
 
843
- this.finished = true;
844
- this.emit('end', this.result);
845
- this.emit('exit', this.result.code);
1487
+ trace('ProcessRunner', () => `About to finish process with result | ${JSON.stringify({
1488
+ exitCode: result.code,
1489
+ finished: this.finished
1490
+ }, null, 2)}`);
1491
+
1492
+ // Finish the process with proper event emission order
1493
+ this.finish(result);
1494
+
1495
+ trace('ProcessRunner', () => `Process finished, result set | ${JSON.stringify({
1496
+ finished: this.finished,
1497
+ resultCode: this.result?.code
1498
+ }, null, 2)}`);
846
1499
 
847
1500
  if (globalShellSettings.errexit && this.result.code !== 0) {
1501
+ trace('ProcessRunner', () => `Errexit mode: throwing error for non-zero exit code | ${JSON.stringify({
1502
+ exitCode: this.result.code,
1503
+ errexit: globalShellSettings.errexit,
1504
+ hasStdout: !!this.result.stdout,
1505
+ hasStderr: !!this.result.stderr
1506
+ }, null, 2)}`);
1507
+
848
1508
  const error = new Error(`Command failed with exit code ${this.result.code}`);
849
1509
  error.code = this.result.code;
850
1510
  error.stdout = this.result.stdout;
851
1511
  error.stderr = this.result.stderr;
852
1512
  error.result = this.result;
1513
+
1514
+ trace('ProcessRunner', () => `About to throw errexit error`);
853
1515
  throw error;
854
1516
  }
1517
+
1518
+ trace('ProcessRunner', () => `Returning result successfully | ${JSON.stringify({
1519
+ exitCode: this.result.code,
1520
+ errexit: globalShellSettings.errexit
1521
+ }, null, 2)}`);
855
1522
 
856
1523
  return this.result;
1524
+ } catch (error) {
1525
+ trace('ProcessRunner', () => `Caught error in _doStartAsync | ${JSON.stringify({
1526
+ errorMessage: error.message,
1527
+ errorCode: error.code,
1528
+ isCommandError: error.isCommandError,
1529
+ hasResult: !!error.result,
1530
+ command: this.spec?.command?.slice(0, 100)
1531
+ }, null, 2)}`);
1532
+
1533
+ // Ensure cleanup happens even if execution fails
1534
+ trace('ProcessRunner', () => `_doStartAsync caught error: ${error.message}`);
1535
+
1536
+ if (!this.finished) {
1537
+ // Create a result from the error
1538
+ const errorResult = createResult({
1539
+ code: error.code ?? 1,
1540
+ stdout: error.stdout ?? '',
1541
+ stderr: error.stderr ?? error.message ?? '',
1542
+ stdin: ''
1543
+ });
1544
+
1545
+ // Finish to trigger cleanup
1546
+ this.finish(errorResult);
1547
+ }
1548
+
1549
+ // Re-throw the error after cleanup
1550
+ throw error;
1551
+ }
857
1552
  }
858
1553
 
859
1554
  async _pumpStdinTo(child, captureChunks) {
@@ -1005,8 +1700,18 @@ class ProcessRunner extends StreamEmitter {
1005
1700
  const commandOptions = {
1006
1701
  ...this.options,
1007
1702
  isCancelled: () => this._cancelled,
1008
- signal: this._abortController.signal
1703
+ signal: this._abortController?.signal
1009
1704
  };
1705
+
1706
+ trace('ProcessRunner', () => `_runVirtual signal details | ${JSON.stringify({
1707
+ cmd,
1708
+ hasAbortController: !!this._abortController,
1709
+ signalAborted: this._abortController?.signal?.aborted,
1710
+ signalExists: !!commandOptions.signal,
1711
+ commandOptionsSignalAborted: commandOptions.signal?.aborted,
1712
+ optionsSignalExists: !!this.options.signal,
1713
+ optionsSignalAborted: this.options.signal?.aborted
1714
+ }, null, 2)}`);
1010
1715
 
1011
1716
  const generator = handler({ args: argValues, stdin: stdinData, ...commandOptions });
1012
1717
  this._virtualGenerator = generator;
@@ -1020,12 +1725,27 @@ class ProcessRunner extends StreamEmitter {
1020
1725
  let done = false;
1021
1726
 
1022
1727
  while (!done && !this._cancelled) {
1728
+ trace('ProcessRunner', () => `Virtual command iteration starting | ${JSON.stringify({
1729
+ cancelled: this._cancelled,
1730
+ streamBreaking: this._streamBreaking
1731
+ }, null, 2)}`);
1732
+
1023
1733
  const result = await Promise.race([
1024
1734
  iterator.next(),
1025
1735
  cancelPromise.then(() => ({ done: true, cancelled: true }))
1026
1736
  ]);
1027
1737
 
1738
+ trace('ProcessRunner', () => `Virtual command iteration result | ${JSON.stringify({
1739
+ hasValue: !!result.value,
1740
+ done: result.done,
1741
+ cancelled: result.cancelled || this._cancelled
1742
+ }, null, 2)}`);
1743
+
1028
1744
  if (result.cancelled || this._cancelled) {
1745
+ trace('ProcessRunner', () => `Virtual command cancelled - closing generator | ${JSON.stringify({
1746
+ resultCancelled: result.cancelled,
1747
+ thisCancelled: this._cancelled
1748
+ }, null, 2)}`);
1029
1749
  // Cancelled - close the generator
1030
1750
  if (iterator.return) {
1031
1751
  await iterator.return();
@@ -1036,18 +1756,35 @@ class ProcessRunner extends StreamEmitter {
1036
1756
  done = result.done;
1037
1757
 
1038
1758
  if (!done) {
1759
+ // Check cancellation again before processing the chunk
1760
+ if (this._cancelled) {
1761
+ trace('ProcessRunner', () => 'Skipping chunk processing - cancelled during iteration');
1762
+ break;
1763
+ }
1764
+
1039
1765
  const chunk = result.value;
1040
1766
  const buf = Buffer.from(chunk);
1767
+
1768
+ // Check cancelled flag once more before any output
1769
+ if (this._cancelled || this._streamBreaking) {
1770
+ trace('ProcessRunner', () => `Cancelled or stream breaking before output - skipping | ${JSON.stringify({
1771
+ cancelled: this._cancelled,
1772
+ streamBreaking: this._streamBreaking
1773
+ }, null, 2)}`);
1774
+ break;
1775
+ }
1776
+
1041
1777
  chunks.push(buf);
1042
1778
 
1043
- // Only output if not cancelled
1044
- if (!this._cancelled) {
1045
- if (this.options.mirror) {
1046
- safeWrite(process.stdout, buf);
1047
- }
1048
-
1049
- this._emitProcessedData('stdout', buf);
1779
+ // Only output if not cancelled and stream not breaking
1780
+ if (!this._cancelled && !this._streamBreaking && this.options.mirror) {
1781
+ trace('ProcessRunner', () => `Mirroring virtual command output | ${JSON.stringify({
1782
+ chunkSize: buf.length
1783
+ }, null, 2)}`);
1784
+ safeWrite(process.stdout, buf);
1050
1785
  }
1786
+
1787
+ this._emitProcessedData('stdout', buf);
1051
1788
  }
1052
1789
  }
1053
1790
  } finally {
@@ -1063,8 +1800,53 @@ class ProcessRunner extends StreamEmitter {
1063
1800
  stdin: this.options.capture ? stdinData : undefined
1064
1801
  };
1065
1802
  } else {
1066
- // Regular async function
1067
- result = await handler({ args: argValues, stdin: stdinData, ...this.options });
1803
+ // Regular async function - race with abort signal
1804
+ const commandOptions = {
1805
+ ...this.options,
1806
+ isCancelled: () => this._cancelled,
1807
+ signal: this._abortController?.signal
1808
+ };
1809
+
1810
+ trace('ProcessRunner', () => `_runVirtual signal details (non-generator) | ${JSON.stringify({
1811
+ cmd,
1812
+ hasAbortController: !!this._abortController,
1813
+ signalAborted: this._abortController?.signal?.aborted,
1814
+ signalExists: !!commandOptions.signal,
1815
+ commandOptionsSignalAborted: commandOptions.signal?.aborted,
1816
+ optionsSignalExists: !!this.options.signal,
1817
+ optionsSignalAborted: this.options.signal?.aborted
1818
+ }, null, 2)}`);
1819
+
1820
+ const handlerPromise = handler({ args: argValues, stdin: stdinData, ...commandOptions });
1821
+
1822
+ // Create an abort promise that rejects when cancelled
1823
+ const abortPromise = new Promise((_, reject) => {
1824
+ if (this._abortController && this._abortController.signal.aborted) {
1825
+ reject(new Error('Command cancelled'));
1826
+ }
1827
+ if (this._abortController) {
1828
+ this._abortController.signal.addEventListener('abort', () => {
1829
+ reject(new Error('Command cancelled'));
1830
+ });
1831
+ }
1832
+ });
1833
+
1834
+ try {
1835
+ result = await Promise.race([handlerPromise, abortPromise]);
1836
+ } catch (err) {
1837
+ if (err.message === 'Command cancelled') {
1838
+ // Command was cancelled, return appropriate exit code based on signal
1839
+ const exitCode = this._cancellationSignal === 'SIGINT' ? 130 : 143; // 130 for SIGINT, 143 for SIGTERM
1840
+ trace('ProcessRunner', () => `Virtual command cancelled with signal ${this._cancellationSignal}, exit code: ${exitCode}`);
1841
+ result = {
1842
+ code: exitCode,
1843
+ stdout: '',
1844
+ stderr: ''
1845
+ };
1846
+ } else {
1847
+ throw err;
1848
+ }
1849
+ }
1068
1850
 
1069
1851
  result = {
1070
1852
  ...result,
@@ -1092,13 +1874,8 @@ class ProcessRunner extends StreamEmitter {
1092
1874
  }
1093
1875
  }
1094
1876
 
1095
- // Store result
1096
- this.result = result;
1097
- this.finished = true;
1098
-
1099
- // Emit completion events
1100
- this.emit('end', result);
1101
- this.emit('exit', result.code);
1877
+ // Finish the process with proper event emission order
1878
+ this.finish(result);
1102
1879
 
1103
1880
  if (globalShellSettings.errexit && result.code !== 0) {
1104
1881
  const error = new Error(`Command failed with exit code ${result.code}`);
@@ -1118,9 +1895,6 @@ class ProcessRunner extends StreamEmitter {
1118
1895
  stdin: ''
1119
1896
  };
1120
1897
 
1121
- this.result = result;
1122
- this.finished = true;
1123
-
1124
1898
  if (result.stderr) {
1125
1899
  const buf = Buffer.from(result.stderr);
1126
1900
  if (this.options.mirror) {
@@ -1129,8 +1903,7 @@ class ProcessRunner extends StreamEmitter {
1129
1903
  this._emitProcessedData('stderr', buf);
1130
1904
  }
1131
1905
 
1132
- this.emit('end', result);
1133
- this.emit('exit', result.code);
1906
+ this.finish(result);
1134
1907
 
1135
1908
  if (globalShellSettings.errexit) {
1136
1909
  error.result = result;
@@ -1321,11 +2094,8 @@ class ProcessRunner extends StreamEmitter {
1321
2094
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1322
2095
  });
1323
2096
 
1324
- this.result = result;
1325
- this.finished = true;
1326
-
1327
- this.emit('end', result);
1328
- this.emit('exit', result.code);
2097
+ // Finish the process with proper event emission order
2098
+ this.finish(result);
1329
2099
 
1330
2100
  if (globalShellSettings.errexit && result.code !== 0) {
1331
2101
  const error = new Error(`Pipeline failed with exit code ${result.code}`);
@@ -1499,11 +2269,8 @@ class ProcessRunner extends StreamEmitter {
1499
2269
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1500
2270
  });
1501
2271
 
1502
- this.result = result;
1503
- this.finished = true;
1504
-
1505
- this.emit('end', result);
1506
- this.emit('exit', result.code);
2272
+ // Finish the process with proper event emission order
2273
+ this.finish(result);
1507
2274
 
1508
2275
  if (globalShellSettings.errexit && result.code !== 0) {
1509
2276
  const error = new Error(`Pipeline failed with exit code ${result.code}`);
@@ -1728,11 +2495,8 @@ class ProcessRunner extends StreamEmitter {
1728
2495
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1729
2496
  });
1730
2497
 
1731
- this.result = result;
1732
- this.finished = true;
1733
-
1734
- this.emit('end', result);
1735
- this.emit('exit', result.code);
2498
+ // Finish the process with proper event emission order
2499
+ this.finish(result);
1736
2500
 
1737
2501
  return result;
1738
2502
  }
@@ -1835,12 +2599,8 @@ class ProcessRunner extends StreamEmitter {
1835
2599
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1836
2600
  });
1837
2601
 
1838
- this.result = finalResult;
1839
- this.finished = true;
1840
-
1841
- // Emit completion events
1842
- this.emit('end', finalResult);
1843
- this.emit('exit', finalResult.code);
2602
+ // Finish the process with proper event emission order
2603
+ this.finish(finalResult);
1844
2604
 
1845
2605
  if (globalShellSettings.errexit && finalResult.code !== 0) {
1846
2606
  const error = new Error(`Pipeline failed with exit code ${finalResult.code}`);
@@ -1871,9 +2631,6 @@ class ProcessRunner extends StreamEmitter {
1871
2631
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1872
2632
  });
1873
2633
 
1874
- this.result = result;
1875
- this.finished = true;
1876
-
1877
2634
  if (result.stderr) {
1878
2635
  const buf = Buffer.from(result.stderr);
1879
2636
  if (this.options.mirror) {
@@ -1882,8 +2639,7 @@ class ProcessRunner extends StreamEmitter {
1882
2639
  this._emitProcessedData('stderr', buf);
1883
2640
  }
1884
2641
 
1885
- this.emit('end', result);
1886
- this.emit('exit', result.code);
2642
+ this.finish(result);
1887
2643
 
1888
2644
  if (globalShellSettings.errexit) {
1889
2645
  throw error;
@@ -1928,17 +2684,47 @@ class ProcessRunner extends StreamEmitter {
1928
2684
  const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => {
1929
2685
 
1930
2686
  return new Promise((resolve, reject) => {
2687
+ trace('ProcessRunner', () => `spawnNodeAsync: Creating child process | ${JSON.stringify({
2688
+ command: argv[0],
2689
+ args: argv.slice(1),
2690
+ cwd: this.options.cwd,
2691
+ isLastCommand
2692
+ })}`);
2693
+
1931
2694
  const proc = cp.spawn(argv[0], argv.slice(1), {
1932
2695
  cwd: this.options.cwd,
1933
2696
  env: this.options.env,
1934
2697
  stdio: ['pipe', 'pipe', 'pipe']
1935
2698
  });
1936
2699
 
2700
+ trace('ProcessRunner', () => `spawnNodeAsync: Child process created | ${JSON.stringify({
2701
+ pid: proc.pid,
2702
+ killed: proc.killed,
2703
+ hasStdout: !!proc.stdout,
2704
+ hasStderr: !!proc.stderr
2705
+ })}`);
2706
+
1937
2707
  let stdout = '';
1938
2708
  let stderr = '';
2709
+ let stdoutChunks = 0;
2710
+ let stderrChunks = 0;
1939
2711
 
2712
+ const procPid = proc.pid; // Capture PID once to avoid null reference
2713
+
1940
2714
  proc.stdout.on('data', (chunk) => {
1941
- stdout += chunk.toString();
2715
+ const chunkStr = chunk.toString();
2716
+ stdout += chunkStr;
2717
+ stdoutChunks++;
2718
+
2719
+ trace('ProcessRunner', () => `spawnNodeAsync: stdout chunk received | ${JSON.stringify({
2720
+ pid: procPid,
2721
+ chunkNumber: stdoutChunks,
2722
+ chunkLength: chunk.length,
2723
+ totalStdoutLength: stdout.length,
2724
+ isLastCommand,
2725
+ preview: chunkStr.slice(0, 100)
2726
+ })}`);
2727
+
1942
2728
  // If this is the last command, emit streaming data
1943
2729
  if (isLastCommand) {
1944
2730
  if (this.options.mirror) {
@@ -1949,7 +2735,19 @@ class ProcessRunner extends StreamEmitter {
1949
2735
  });
1950
2736
 
1951
2737
  proc.stderr.on('data', (chunk) => {
1952
- stderr += chunk.toString();
2738
+ const chunkStr = chunk.toString();
2739
+ stderr += chunkStr;
2740
+ stderrChunks++;
2741
+
2742
+ trace('ProcessRunner', () => `spawnNodeAsync: stderr chunk received | ${JSON.stringify({
2743
+ pid: procPid,
2744
+ chunkNumber: stderrChunks,
2745
+ chunkLength: chunk.length,
2746
+ totalStderrLength: stderr.length,
2747
+ isLastCommand,
2748
+ preview: chunkStr.slice(0, 100)
2749
+ })}`);
2750
+
1953
2751
  // If this is the last command, emit streaming data
1954
2752
  if (isLastCommand) {
1955
2753
  if (this.options.mirror) {
@@ -1960,6 +2758,15 @@ class ProcessRunner extends StreamEmitter {
1960
2758
  });
1961
2759
 
1962
2760
  proc.on('close', (code) => {
2761
+ trace('ProcessRunner', () => `spawnNodeAsync: Process closed | ${JSON.stringify({
2762
+ pid: procPid,
2763
+ code,
2764
+ stdoutLength: stdout.length,
2765
+ stderrLength: stderr.length,
2766
+ stdoutChunks,
2767
+ stderrChunks
2768
+ })}`);
2769
+
1963
2770
  resolve({
1964
2771
  status: code,
1965
2772
  stdout,
@@ -2040,12 +2847,8 @@ class ProcessRunner extends StreamEmitter {
2040
2847
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2041
2848
  });
2042
2849
 
2043
- this.result = finalResult;
2044
- this.finished = true;
2045
-
2046
- // Emit completion events
2047
- this.emit('end', finalResult);
2048
- this.emit('exit', finalResult.code);
2850
+ // Finish the process with proper event emission order
2851
+ this.finish(finalResult);
2049
2852
 
2050
2853
  if (globalShellSettings.errexit && finalResult.code !== 0) {
2051
2854
  const error = new Error(`Pipeline failed with exit code ${finalResult.code}`);
@@ -2068,9 +2871,6 @@ class ProcessRunner extends StreamEmitter {
2068
2871
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2069
2872
  });
2070
2873
 
2071
- this.result = result;
2072
- this.finished = true;
2073
-
2074
2874
  if (result.stderr) {
2075
2875
  const buf = Buffer.from(result.stderr);
2076
2876
  if (this.options.mirror) {
@@ -2079,8 +2879,7 @@ class ProcessRunner extends StreamEmitter {
2079
2879
  this._emitProcessedData('stderr', buf);
2080
2880
  }
2081
2881
 
2082
- this.emit('end', result);
2083
- this.emit('exit', result.code);
2882
+ this.finish(result);
2084
2883
 
2085
2884
  if (globalShellSettings.errexit) {
2086
2885
  throw error;
@@ -2163,17 +2962,13 @@ class ProcessRunner extends StreamEmitter {
2163
2962
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2164
2963
  });
2165
2964
 
2166
- this.result = result;
2167
- this.finished = true;
2168
-
2169
2965
  const buf = Buffer.from(result.stderr);
2170
2966
  if (this.options.mirror) {
2171
2967
  safeWrite(process.stderr, buf);
2172
2968
  }
2173
2969
  this._emitProcessedData('stderr', buf);
2174
2970
 
2175
- this.emit('end', result);
2176
- this.emit('exit', result.code);
2971
+ this.finish(result);
2177
2972
 
2178
2973
  return result;
2179
2974
  }
@@ -2194,12 +2989,16 @@ class ProcessRunner extends StreamEmitter {
2194
2989
  let resolve, reject;
2195
2990
  let ended = false;
2196
2991
  let cleanedUp = false;
2992
+ let killed = false;
2197
2993
 
2198
2994
  const onData = (chunk) => {
2199
- buffer.push(chunk);
2200
- if (resolve) {
2201
- resolve();
2202
- resolve = reject = null;
2995
+ // Don't buffer more data if we're being killed
2996
+ if (!killed) {
2997
+ buffer.push(chunk);
2998
+ if (resolve) {
2999
+ resolve();
3000
+ resolve = reject = null;
3001
+ }
2203
3002
  }
2204
3003
  };
2205
3004
 
@@ -2216,8 +3015,18 @@ class ProcessRunner extends StreamEmitter {
2216
3015
 
2217
3016
  try {
2218
3017
  while (!ended || buffer.length > 0) {
3018
+ // Check if we've been killed and should stop immediately
3019
+ if (killed) {
3020
+ trace('ProcessRunner', () => 'Stream killed, stopping iteration');
3021
+ break;
3022
+ }
2219
3023
  if (buffer.length > 0) {
2220
- yield buffer.shift();
3024
+ const chunk = buffer.shift();
3025
+ // Set a flag that we're about to yield - if the consumer breaks,
3026
+ // we'll know not to process any more data
3027
+ this._streamYielding = true;
3028
+ yield chunk;
3029
+ this._streamYielding = false;
2221
3030
  } else if (!ended) {
2222
3031
  await new Promise((res, rej) => {
2223
3032
  resolve = res;
@@ -2232,41 +3041,97 @@ class ProcessRunner extends StreamEmitter {
2232
3041
 
2233
3042
  // This happens when breaking from a for-await loop
2234
3043
  if (!this.finished) {
3044
+ killed = true;
3045
+ buffer = []; // Clear any buffered data
3046
+ this._streamBreaking = true; // Signal that stream is breaking
2235
3047
  this.kill();
2236
3048
  }
2237
3049
  }
2238
3050
  }
2239
3051
 
2240
- kill() {
3052
+ kill(signal = 'SIGTERM') {
2241
3053
  trace('ProcessRunner', () => `kill ENTER | ${JSON.stringify({
3054
+ signal,
2242
3055
  cancelled: this._cancelled,
2243
3056
  finished: this.finished,
2244
3057
  hasChild: !!this.child,
2245
- hasVirtualGenerator: !!this._virtualGenerator
3058
+ hasVirtualGenerator: !!this._virtualGenerator,
3059
+ command: this.spec?.command?.slice(0, 50) || 'unknown'
2246
3060
  }, null, 2)}`);
2247
3061
 
2248
- // Mark as cancelled for virtual commands
3062
+ if (this.finished) {
3063
+ trace('ProcessRunner', () => 'Already finished, skipping kill');
3064
+ return;
3065
+ }
3066
+
3067
+ // Mark as cancelled for virtual commands and store the signal
3068
+ trace('ProcessRunner', () => `Marking as cancelled | ${JSON.stringify({
3069
+ signal,
3070
+ previouslyCancelled: this._cancelled,
3071
+ previousSignal: this._cancellationSignal
3072
+ }, null, 2)}`);
2249
3073
  this._cancelled = true;
3074
+ this._cancellationSignal = signal;
3075
+
3076
+ // If this is a pipeline runner, also kill the source and destination
3077
+ if (this.spec?.mode === 'pipeline') {
3078
+ trace('ProcessRunner', () => 'Killing pipeline components');
3079
+ if (this.spec.source && typeof this.spec.source.kill === 'function') {
3080
+ this.spec.source.kill(signal);
3081
+ }
3082
+ if (this.spec.destination && typeof this.spec.destination.kill === 'function') {
3083
+ this.spec.destination.kill(signal);
3084
+ }
3085
+ }
2250
3086
 
2251
3087
  if (this._cancelResolve) {
2252
3088
  trace('ProcessRunner', () => 'Resolving cancel promise');
2253
3089
  this._cancelResolve();
3090
+ trace('ProcessRunner', () => 'Cancel promise resolved');
3091
+ } else {
3092
+ trace('ProcessRunner', () => 'No cancel promise to resolve');
2254
3093
  }
2255
3094
 
2256
3095
  // Abort any async operations
2257
3096
  if (this._abortController) {
2258
- trace('ProcessRunner', () => 'Aborting controller');
3097
+ trace('ProcessRunner', () => `Aborting internal controller | ${JSON.stringify({
3098
+ wasAborted: this._abortController?.signal?.aborted
3099
+ }, null, 2)}`);
2259
3100
  this._abortController.abort();
3101
+ trace('ProcessRunner', () => `Internal controller aborted | ${JSON.stringify({
3102
+ nowAborted: this._abortController?.signal?.aborted
3103
+ }, null, 2)}`);
3104
+ } else {
3105
+ trace('ProcessRunner', () => 'No abort controller to abort');
2260
3106
  }
2261
3107
 
2262
3108
  // If it's a virtual generator, try to close it
2263
- if (this._virtualGenerator && this._virtualGenerator.return) {
2264
- trace('ProcessRunner', () => 'Closing virtual generator');
2265
- try {
2266
- this._virtualGenerator.return();
2267
- } catch (err) {
2268
- trace('ProcessRunner', () => `Error closing generator | ${JSON.stringify({ error: err.message }, null, 2)}`);
3109
+ if (this._virtualGenerator) {
3110
+ trace('ProcessRunner', () => `Virtual generator found for cleanup | ${JSON.stringify({
3111
+ hasReturn: typeof this._virtualGenerator.return === 'function',
3112
+ hasThrow: typeof this._virtualGenerator.throw === 'function',
3113
+ cancelled: this._cancelled,
3114
+ signal
3115
+ }, null, 2)}`);
3116
+
3117
+ if (this._virtualGenerator.return) {
3118
+ trace('ProcessRunner', () => 'Closing virtual generator with return()');
3119
+ try {
3120
+ this._virtualGenerator.return();
3121
+ trace('ProcessRunner', () => 'Virtual generator closed successfully');
3122
+ } catch (err) {
3123
+ trace('ProcessRunner', () => `Error closing generator | ${JSON.stringify({
3124
+ error: err.message,
3125
+ stack: err.stack?.slice(0, 200)
3126
+ }, null, 2)}`);
3127
+ }
3128
+ } else {
3129
+ trace('ProcessRunner', () => 'Virtual generator has no return() method');
2269
3130
  }
3131
+ } else {
3132
+ trace('ProcessRunner', () => `No virtual generator to cleanup | ${JSON.stringify({
3133
+ hasVirtualGenerator: !!this._virtualGenerator
3134
+ }, null, 2)}`);
2270
3135
  }
2271
3136
 
2272
3137
  // Kill child process if it exists
@@ -2276,14 +3141,112 @@ class ProcessRunner extends StreamEmitter {
2276
3141
  if (this.child.pid) {
2277
3142
  if (isBun) {
2278
3143
  trace('ProcessRunner', () => `Killing Bun process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
2279
- this.child.kill();
3144
+
3145
+ // For Bun, use the same enhanced kill logic as Node.js for CI reliability
3146
+ const killOperations = [];
3147
+
3148
+ // Try SIGTERM first
3149
+ try {
3150
+ process.kill(this.child.pid, 'SIGTERM');
3151
+ trace('ProcessRunner', () => `Sent SIGTERM to Bun process ${this.child.pid}`);
3152
+ killOperations.push('SIGTERM to process');
3153
+ } catch (err) {
3154
+ trace('ProcessRunner', () => `Error sending SIGTERM to Bun process: ${err.message}`);
3155
+ }
3156
+
3157
+ // Try process group SIGTERM
3158
+ try {
3159
+ process.kill(-this.child.pid, 'SIGTERM');
3160
+ trace('ProcessRunner', () => `Sent SIGTERM to Bun process group -${this.child.pid}`);
3161
+ killOperations.push('SIGTERM to group');
3162
+ } catch (err) {
3163
+ trace('ProcessRunner', () => `Bun process group SIGTERM failed: ${err.message}`);
3164
+ }
3165
+
3166
+ // Immediately follow with SIGKILL for both process and group
3167
+ try {
3168
+ process.kill(this.child.pid, 'SIGKILL');
3169
+ trace('ProcessRunner', () => `Sent SIGKILL to Bun process ${this.child.pid}`);
3170
+ killOperations.push('SIGKILL to process');
3171
+ } catch (err) {
3172
+ trace('ProcessRunner', () => `Error sending SIGKILL to Bun process: ${err.message}`);
3173
+ }
3174
+
3175
+ try {
3176
+ process.kill(-this.child.pid, 'SIGKILL');
3177
+ trace('ProcessRunner', () => `Sent SIGKILL to Bun process group -${this.child.pid}`);
3178
+ killOperations.push('SIGKILL to group');
3179
+ } catch (err) {
3180
+ trace('ProcessRunner', () => `Bun process group SIGKILL failed: ${err.message}`);
3181
+ }
3182
+
3183
+ trace('ProcessRunner', () => `Bun kill operations attempted: ${killOperations.join(', ')}`);
3184
+
3185
+ // Also call the original Bun kill method as backup
3186
+ try {
3187
+ this.child.kill();
3188
+ trace('ProcessRunner', () => `Called child.kill() for Bun process ${this.child.pid}`);
3189
+ } catch (err) {
3190
+ trace('ProcessRunner', () => `Error calling child.kill(): ${err.message}`);
3191
+ }
3192
+
3193
+ // Force cleanup of child reference
3194
+ if (this.child) {
3195
+ this.child.removeAllListeners?.();
3196
+ this.child = null;
3197
+ }
2280
3198
  } else {
2281
- // In Node.js, kill the process group
2282
- trace('ProcessRunner', () => `Killing Node process group | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
2283
- process.kill(-this.child.pid, 'SIGTERM');
3199
+ // In Node.js, use a more robust approach for CI environments
3200
+ trace('ProcessRunner', () => `Killing Node process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
3201
+
3202
+ // Use immediate and aggressive termination for CI environments
3203
+ const killOperations = [];
3204
+
3205
+ // Try SIGTERM to the process directly
3206
+ try {
3207
+ process.kill(this.child.pid, 'SIGTERM');
3208
+ trace('ProcessRunner', () => `Sent SIGTERM to process ${this.child.pid}`);
3209
+ killOperations.push('SIGTERM to process');
3210
+ } catch (err) {
3211
+ trace('ProcessRunner', () => `Error sending SIGTERM to process: ${err.message}`);
3212
+ }
3213
+
3214
+ // Try process group if detached (negative PID)
3215
+ try {
3216
+ process.kill(-this.child.pid, 'SIGTERM');
3217
+ trace('ProcessRunner', () => `Sent SIGTERM to process group -${this.child.pid}`);
3218
+ killOperations.push('SIGTERM to group');
3219
+ } catch (err) {
3220
+ trace('ProcessRunner', () => `Process group SIGTERM failed: ${err.message}`);
3221
+ }
3222
+
3223
+ // Immediately follow up with SIGKILL for CI reliability
3224
+ try {
3225
+ process.kill(this.child.pid, 'SIGKILL');
3226
+ trace('ProcessRunner', () => `Sent SIGKILL to process ${this.child.pid}`);
3227
+ killOperations.push('SIGKILL to process');
3228
+ } catch (err) {
3229
+ trace('ProcessRunner', () => `Error sending SIGKILL to process: ${err.message}`);
3230
+ }
3231
+
3232
+ try {
3233
+ process.kill(-this.child.pid, 'SIGKILL');
3234
+ trace('ProcessRunner', () => `Sent SIGKILL to process group -${this.child.pid}`);
3235
+ killOperations.push('SIGKILL to group');
3236
+ } catch (err) {
3237
+ trace('ProcessRunner', () => `Process group SIGKILL failed: ${err.message}`);
3238
+ }
3239
+
3240
+ trace('ProcessRunner', () => `Kill operations attempted: ${killOperations.join(', ')}`);
3241
+
3242
+ // Force cleanup of child reference to prevent hanging awaits
3243
+ if (this.child) {
3244
+ this.child.removeAllListeners?.();
3245
+ this.child = null;
3246
+ }
2284
3247
  }
2285
3248
  }
2286
- this.finished = true;
3249
+ // finished will be set by the main cleanup below
2287
3250
  } catch (err) {
2288
3251
  // Process might already be dead
2289
3252
  trace('ProcessRunner', () => `Error killing process | ${JSON.stringify({ error: err.message }, null, 2)}`);
@@ -2291,8 +3254,14 @@ class ProcessRunner extends StreamEmitter {
2291
3254
  }
2292
3255
  }
2293
3256
 
2294
- // Mark as finished
2295
- this.finished = true;
3257
+ // Mark as finished and emit completion events
3258
+ const result = createResult({
3259
+ code: signal === 'SIGKILL' ? 137 : signal === 'SIGTERM' ? 143 : 130,
3260
+ stdout: '',
3261
+ stderr: `Process killed with ${signal}`,
3262
+ stdin: ''
3263
+ });
3264
+ this.finish(result);
2296
3265
 
2297
3266
  trace('ProcessRunner', () => `kill EXIT | ${JSON.stringify({
2298
3267
  cancelled: this._cancelled,
@@ -2353,7 +3322,20 @@ class ProcessRunner extends StreamEmitter {
2353
3322
  if (!this.promise) {
2354
3323
  this.promise = this._startAsync();
2355
3324
  }
2356
- return this.promise.finally(onFinally);
3325
+ return this.promise.finally(() => {
3326
+ // Ensure cleanup happened
3327
+ if (!this.finished) {
3328
+ trace('ProcessRunner', () => 'Finally handler ensuring cleanup');
3329
+ const fallbackResult = createResult({
3330
+ code: 1,
3331
+ stdout: '',
3332
+ stderr: 'Process terminated unexpectedly',
3333
+ stdin: ''
3334
+ });
3335
+ this.finish(fallbackResult);
3336
+ }
3337
+ if (onFinally) onFinally();
3338
+ });
2357
3339
  }
2358
3340
 
2359
3341
  // Internal sync execution
@@ -2438,9 +3420,6 @@ class ProcessRunner extends StreamEmitter {
2438
3420
  this.outChunks = result.stdout ? [Buffer.from(result.stdout)] : [];
2439
3421
  this.errChunks = result.stderr ? [Buffer.from(result.stderr)] : [];
2440
3422
 
2441
- this.result = result;
2442
- this.finished = true;
2443
-
2444
3423
  // Emit batched events after completion
2445
3424
  if (result.stdout) {
2446
3425
  const stdoutBuf = Buffer.from(result.stdout);
@@ -2452,8 +3431,7 @@ class ProcessRunner extends StreamEmitter {
2452
3431
  this._emitProcessedData('stderr', stderrBuf);
2453
3432
  }
2454
3433
 
2455
- this.emit('end', result);
2456
- this.emit('exit', result.code);
3434
+ this.finish(result);
2457
3435
 
2458
3436
  if (globalShellSettings.errexit && result.code !== 0) {
2459
3437
  const error = new Error(`Command failed with exit code ${result.code}`);
@@ -2467,18 +3445,6 @@ class ProcessRunner extends StreamEmitter {
2467
3445
  return result;
2468
3446
  }
2469
3447
 
2470
- // Stream properties
2471
- get stdout() {
2472
- return this.child?.stdout;
2473
- }
2474
-
2475
- get stderr() {
2476
- return this.child?.stderr;
2477
- }
2478
-
2479
- get stdin() {
2480
- return this.child?.stdin;
2481
- }
2482
3448
  }
2483
3449
 
2484
3450
  // Public APIs
@@ -2526,6 +3492,28 @@ async function run(commandOrTokens, options = {}) {
2526
3492
  }
2527
3493
 
2528
3494
  function $tagged(strings, ...values) {
3495
+ // Check if called as a function with options object: $({ options })
3496
+ if (!Array.isArray(strings) && typeof strings === 'object' && strings !== null) {
3497
+ const options = strings;
3498
+ trace('API', () => `$tagged called with options | ${JSON.stringify({ options }, null, 2)}`);
3499
+
3500
+ // Return a new tagged template function with those options
3501
+ return (innerStrings, ...innerValues) => {
3502
+ trace('API', () => `$tagged.withOptions ENTER | ${JSON.stringify({
3503
+ stringsLength: innerStrings.length,
3504
+ valuesLength: innerValues.length,
3505
+ options
3506
+ }, null, 2)}`);
3507
+
3508
+ const cmd = buildShellCommand(innerStrings, innerValues);
3509
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...options });
3510
+
3511
+ trace('API', () => `$tagged.withOptions EXIT | ${JSON.stringify({ command: cmd }, null, 2)}`);
3512
+ return runner;
3513
+ };
3514
+ }
3515
+
3516
+ // Normal tagged template literal usage
2529
3517
  trace('API', () => `$tagged ENTER | ${JSON.stringify({
2530
3518
  stringsLength: strings.length,
2531
3519
  valuesLength: values.length
@@ -2779,6 +3767,7 @@ export {
2779
3767
  AnsiUtils,
2780
3768
  configureAnsi,
2781
3769
  getAnsiConfig,
2782
- processOutput
3770
+ processOutput,
3771
+ forceCleanupAll
2783
3772
  };
2784
3773
  export default $tagged;