|
1 | 1 | import { ChildProcess, spawn } from 'child_process'; |
2 | 2 | import { Socket, connect } from 'net'; |
| 3 | +import { Readable, Writable } from 'stream'; |
3 | 4 | import path = require('path'); |
4 | 5 |
|
5 | 6 | import { PluginConfiguration } from '../../../config/nx-json'; |
@@ -496,35 +497,13 @@ async function startPluginWorker(name: string) { |
496 | 497 | if (stderrMaxListeners !== 0) { |
497 | 498 | process.stderr.setMaxListeners(stderrMaxListeners + 1); |
498 | 499 | } |
499 | | - worker.stdout.pipe(process.stdout); |
500 | | - worker.stderr.pipe(process.stderr); |
| 500 | + |
| 501 | + // Pipe and unref the worker streams. The utility handles cross-runtime compatibility. |
| 502 | + pipeAndUnrefChildStream(worker.stdout, process.stdout, 'stdout'); |
| 503 | + pipeAndUnrefChildStream(worker.stderr, process.stderr, 'stderr'); |
501 | 504 |
|
502 | 505 | // Unref the worker process so it doesn't prevent the parent from exiting. |
503 | | - // IMPORTANT: We must also unref the stdout/stderr streams. When streams are |
504 | | - // piped, they maintain internal references in Node's event loop. Without |
505 | | - // unreferencing them, the parent process will wait for the worker to exit |
506 | | - // even after worker.unref() is called. This causes e2e tests to hang on CI |
507 | | - // where test frameworks wait for all handles to be released. |
508 | | - // |
509 | | - // Although TypeScript types these as Readable/Writable, they are actually |
510 | | - // net.Socket instances at runtime. Node.js internally creates sockets for |
511 | | - // stdio pipes (see lib/internal/child_process.js createSocket function). |
512 | | - // Socket.unref() allows the event loop to exit if these are the only handles. |
513 | 506 | worker.unref(); |
514 | | - if (worker.stdout instanceof Socket) { |
515 | | - worker.stdout.unref(); |
516 | | - } else { |
517 | | - throw new Error( |
518 | | - `Expected worker.stdout to be an instance of Socket, but got ${getTypeName(worker.stdout)}` |
519 | | - ); |
520 | | - } |
521 | | - if (worker.stderr instanceof Socket) { |
522 | | - worker.stderr.unref(); |
523 | | - } else { |
524 | | - throw new Error( |
525 | | - `Expected worker.stderr to be an instance of Socket, but got ${getTypeName(worker.stderr)}` |
526 | | - ); |
527 | | - } |
528 | 507 |
|
529 | 508 | let attempts = 0; |
530 | 509 | return new Promise<{ |
@@ -587,3 +566,81 @@ function getTypeName(u: unknown): string { |
587 | 566 | } |
588 | 567 | return u.constructor?.name ?? 'unknown object'; |
589 | 568 | } |
| 569 | + |
| 570 | +/** |
| 571 | + * Detects if we're running under an alternative JavaScript runtime (Bun or Deno). |
| 572 | + * Returns the runtime name for helpful error messages, or null if running on Node.js. |
| 573 | + */ |
| 574 | +function detectAlternativeRuntime(): 'bun' | 'deno' | null { |
| 575 | + // Check for Bun runtime - the Bun global is only available in Bun |
| 576 | + if ('Bun' in globalThis && typeof (globalThis as any).Bun !== 'undefined') { |
| 577 | + return 'bun'; |
| 578 | + } |
| 579 | + // Check for Deno runtime - the Deno global is only available in Deno |
| 580 | + if ('Deno' in globalThis && typeof (globalThis as any).Deno !== 'undefined') { |
| 581 | + return 'deno'; |
| 582 | + } |
| 583 | + return null; |
| 584 | +} |
| 585 | + |
| 586 | +/** |
| 587 | + * Pipes a child process stream to a destination and attempts to unref it to prevent |
| 588 | + * the stream from keeping the parent process alive. |
| 589 | + * |
| 590 | + * In Node.js, child process stdio streams are actually net.Socket instances when |
| 591 | + * using 'pipe' stdio option. However, alternative runtimes like Bun and Deno may |
| 592 | + * use standard Readable/Writable streams instead. |
| 593 | + * |
| 594 | + * This function: |
| 595 | + * 1. Pipes the source to the destination |
| 596 | + * 2. Attempts to unref the source stream to allow the parent process to exit |
| 597 | + * 3. Uses duck-typing to check for unref support when not a Socket instance |
| 598 | + * 4. Emits helpful warnings for alternative runtimes if unref is not available |
| 599 | + * |
| 600 | + * @param source - The child process stream (stdout or stderr) |
| 601 | + * @param destination - The process stream to pipe to (process.stdout or process.stderr) |
| 602 | + * @param streamName - Name of the stream for warning messages ('stdout' or 'stderr') |
| 603 | + */ |
| 604 | +function pipeAndUnrefChildStream( |
| 605 | + source: Readable | null, |
| 606 | + destination: Writable, |
| 607 | + streamName: 'stdout' | 'stderr' |
| 608 | +): void { |
| 609 | + if (!source) { |
| 610 | + return; |
| 611 | + } |
| 612 | + |
| 613 | + source.pipe(destination); |
| 614 | + |
| 615 | + // Node.js creates net.Socket instances for stdio pipes. Use instanceof check first. |
| 616 | + if (source instanceof Socket) { |
| 617 | + source.unref(); |
| 618 | + return; |
| 619 | + } |
| 620 | + |
| 621 | + // For non-Socket streams (e.g., in Bun/Deno), use duck-typing to check for unref |
| 622 | + // NOTE: These should also be a Socket, but alternative runtimes may implement differently... |
| 623 | + // See: |
| 624 | + // - https://github.com/denoland/deno/issues/31961 |
| 625 | + // - https://github.com/oven-sh/bun/issues/26505 |
| 626 | + if (typeof (source as any).unref === 'function') { |
| 627 | + (source as any).unref(); |
| 628 | + return; |
| 629 | + } |
| 630 | + |
| 631 | + // Stream doesn't support unref - emit a warning with runtime-specific guidance |
| 632 | + const runtime = detectAlternativeRuntime(); |
| 633 | + if (runtime) { |
| 634 | + console.warn( |
| 635 | + `[NX] worker.${streamName} does not support unref() in ${runtime}. ` + |
| 636 | + `This may cause the process to hang when waiting for plugin workers to exit. ` + |
| 637 | + `This is a known limitation of ${runtime}'s Node.js compatibility layer.` |
| 638 | + ); |
| 639 | + } else { |
| 640 | + console.warn( |
| 641 | + `[NX] worker.${streamName} is not a net.Socket and does not have an unref() method. ` + |
| 642 | + `Expected Socket, got ${getTypeName(source)}. ` + |
| 643 | + `This may cause the process to hang when waiting for plugin workers to exit.` |
| 644 | + ); |
| 645 | + } |
| 646 | +} |
0 commit comments