Skip to content

Commit afc27c8

Browse files
drewteachoutAgentEnder
authored andcommitted
fix(core): do not throw error if worker.stdout is not instanceof socket (#34224)
deno worker.stdout is a Readable/Writeable. To provide better deno support an error should not be thrown <!-- Please make sure you have read the submission guidelines before posting an PR --> <!-- https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr --> <!-- Please make sure that your commit message follows our format --> <!-- Example: `fix(nx): must begin with lowercase` --> <!-- If this is a particularly complex change or feature addition, you can request a dedicated Nx release for this pull request branch. Mention someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they will confirm if the PR warrants its own release for testing purposes, and generate it for you if appropriate. --> ## Current Behavior <!-- This is the behavior we have today --> If `worker.stdout` or `worker.stderr` are not instanceof Socket then nx throws an error. This is problematic in Deno where `stdout` and `stderr` are Readable/Writable and not Socket. ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> `startupPluginWorker` function should work in Deno runtime ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> - denoland/deno#31961 - oven-sh/bun#26505 --------- Co-authored-by: Craigory Coppola <[email protected]> (cherry picked from commit e189dcc)
1 parent 089db87 commit afc27c8

1 file changed

Lines changed: 83 additions & 26 deletions

File tree

packages/nx/src/project-graph/plugins/isolation/plugin-pool.ts

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ChildProcess, spawn } from 'child_process';
22
import { Socket, connect } from 'net';
3+
import { Readable, Writable } from 'stream';
34
import path = require('path');
45

56
import { PluginConfiguration } from '../../../config/nx-json';
@@ -496,35 +497,13 @@ async function startPluginWorker(name: string) {
496497
if (stderrMaxListeners !== 0) {
497498
process.stderr.setMaxListeners(stderrMaxListeners + 1);
498499
}
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');
501504

502505
// 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.
513506
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-
}
528507

529508
let attempts = 0;
530509
return new Promise<{
@@ -587,3 +566,81 @@ function getTypeName(u: unknown): string {
587566
}
588567
return u.constructor?.name ?? 'unknown object';
589568
}
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

Comments
 (0)