Skip to content

Node process hangs during import('process') if a sibling process reads from stdin and stdin is piped to a parent process #56537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Jimbly opened this issue Jan 9, 2025 · 5 comments

Comments

@Jimbly
Copy link
Contributor

Jimbly commented Jan 9, 2025

Version

All versions, tested on v12 through v23.6.0

Platform

Windows 10 and 11

What steps will reproduce the bug?

Launching a process tree like the following causes grandchild2 to hang (cannot attach Node inspector, no JS code executes) after calling import('process') (which never resolves, unless its sibling grandchild1 exits):

  main.js - launches child with piped stdin
    middle.js - launches children with inherited stdin
      grandchild1.js - attaches a listener to stdin
      (1s later) grandchild2.js - imports node:process and then hangs

Example code:

main.js

require('child_process').spawn('node', ['middle.js'], { stdio: ['pipe', 'inherit', 'inherit'] });

middle.js

const { spawn } = require('child_process');

spawn('node', ['grandchild1.js'], { stdio: 'inherit' });
// Note: this also works: spawn('cmd', ['/c', 'pause'], { stdio: 'inherit' });

setTimeout(function () {
  spawn('node', ['grandchild2.js'], { stdio: 'inherit' });
}, 1000);

grandchild1.js

process.stdin.on('data', console.log);

grandchild2.js

import('process').then(function (mod) {
  console.log('import(node:process) completed'); // this line is never executed
});

console.log(process.pid, `grandchild2 started - if you don't see "not stuck" below, it's stuck`);
setInterval(function () {
  console.log(process.pid, 'not stuck');
}, 1000);

Example repo with the above code (plus a bit more logging): https://github.com/Jimbly/node-stdin-hang-bug-demo

Tested on (all fail):

  • Node v12...v22
  • Windows 10, 11

Works fine on Linux, presumably all non-Windows platforms.

How often does it reproduce? Is there a required condition?

Always.

Additional information

Hang does not occur if any of these happen:

  • grandchild1 is launched before grandchild2
  • middle's stdin is not piped to main
  • grandchild1's or grandchild2's stdin is not inherit
  • grandchild1 exits (causes grandchild2 to get unstuck)

Hang still occurs if:

  • grandchild1 is anything that reads from stdio (e.g. cmd /c pause)

The hung process's (native) call stack is:

NtSetInformationFile()
SetNamedPipeHandleState(pipeHandle, PIPE_READMODE_BYTE | PIPE_WAIT)
node.exe!uv__set_pipe_handle(loop=0x00007ff71d27f130, handle=0x000001c0c83367f0, pipeHandle=0x0000000000000278, fd=-1, duplex_flags=49152)
	at deps\uv\src\win\pipe.c(482)
node.exe!uv_pipe_open(pipe=0x000001c0c83367f0, file=0)
	at deps\uv\src\win\pipe.c(2480)
node.exe!node::PipeWrap::Open(args={...})
	at src\pipe_wrap.cc(209)
node.exe!Builtins_CallApiCallbackGeneric()
	at out\Release\obj\v8_snapshot\embedded.S(4265)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_InterpreterPushArgsThenFastConstructFunction()
	at out\Release\obj\v8_snapshot\embedded.S(4038)
node.exe!Builtins_ConstructHandler()
	at out\Release\obj\v8_snapshot\embedded.S(56237)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_GetPropertyWithReceiver()
	at out\Release\obj\v8_snapshot\embedded.S(24522)
node.exe!Builtins_ReflectGet()
	at out\Release\obj\v8_snapshot\embedded.S(41510)
00007ff69938aaf7()
00007ff69938acf4()
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_JSEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3658)
node.exe!Builtins_JSEntry()
	at out\Release\obj\v8_snapshot\embedded.S(3616)
[Inline Frame] node.exe!v8::internal::GeneratedCode<unsigned __int64,unsigned __int64,unsigned __int64,unsigned __int64,unsigned __int64,__int64,unsigned __int64 * *>::Call()
	at deps\v8\src\execution\simulator.h(178)
node.exe!v8::internal::`anonymous namespace'::Invoke(isolate=0x000001c0c82c1000, params={...})
	at deps\v8\src\execution\execution.cc(420)
node.exe!v8::internal::Execution::Call(isolate=0x000001c0c82c1000, callable, receiver, argc=0, argv=0x0000000000000000)
	at deps\v8\src\execution\execution.cc(506)
node.exe!v8::Function::Call(context, recv={...}, argc=0, argv=0x0000000000000000)
	at deps\v8\src\api\api.cc(5484)
node.exe!node::loader::ModuleWrap::SyntheticModuleEvaluationStepsCallback(context={...}, module)
	at src\module_wrap.cc(948)
node.exe!v8::internal::SyntheticModule::Evaluate(isolate=0x000001c0c82c1000, module={...})
	at deps\v8\src\objects\synthetic-module.cc(108)
node.exe!v8::internal::Module::Evaluate(isolate=0x000001c0c82c1000, module={...})
	at deps\v8\src\objects\module.cc(284)
node.exe!v8::Module::Evaluate(context)
	at deps\v8\src\api\api.cc(2461)
[Inline Frame] node.exe!node::loader::ModuleWrap::Evaluate::__l2::<lambda_1>::operator()()
	at src\module_wrap.cc(562)
node.exe!node::loader::ModuleWrap::Evaluate(args={...})
	at src\module_wrap.cc(578)
node.exe!Builtins_CallApiCallbackGeneric()
	at out\Release\obj\v8_snapshot\embedded.S(4265)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_InterpreterEntryTrampoline()
	at out\Release\obj\v8_snapshot\embedded.S(3946)
node.exe!Builtins_AsyncFunctionAwaitResolveClosure()
	at out\Release\obj\v8_snapshot\embedded.S(11765)
node.exe!Builtins_PromiseFulfillReactionJob()
	at out\Release\obj\v8_snapshot\embedded.S(40771)
node.exe!Builtins_RunMicrotasks()
	at out\Release\obj\v8_snapshot\embedded.S(9703)
(truncated for size, see above repo if more stack is useful)
@Jimbly
Copy link
Contributor Author

Jimbly commented Jan 9, 2025

This has been causing me trouble in development with a process tree of:

nodemon
  our app's build script
    electron-forge child process (reads from stdin)
    imagemin child process (imports node:process)

This appears slightly similar to #10836 (which was just a single grandchild, but otherwise the same piping / inheriting tree).

Jimbly added a commit to Jimbly/glov-build that referenced this issue Jan 9, 2025
By not inheriting stdin, it will avoid a potential process hang
Reference: nodejs/node#56537
@huseyinacacak-janea
Copy link
Contributor

I’ve investigated this issue in detail, and here are my findings:

Node.js uses the DuplicateHandle API to create a separate handle for an existing pipe so it can be shared with a child process. In this particular case, there is a pipe between the main and middle processes, and this pipe is inherited by two grandchild processes. As a result, Node.js duplicates the pipe in both child processes.

Since the first child process is created first, it accesses process.stdin before the second child. According to the intended code flow, both child processes should call SetNamedPipeHandleState to configure the state of the pipe. However, Node.js invokes this function in a blocking (wait) mode. As stated in the documentation, this function waits indefinitely unless there is an operation (e.g., read or write) occurring on the pipe.

From what I understand, this creates a race condition because the pipe is already being read by the first child process. Furthermore, the documentation clarifies that only one reader can access a pipe at a time.

A potential solution to this issue could be pausing the first child process after its work is done by calling process.stdin.pause();. This call would pause child1 from listening to the pipe and let child2 start listening to the pipe. Child1 could call resume() to continue listening again.

I hope this explanation helps!

@Jimbly
Copy link
Contributor Author

Jimbly commented Jan 24, 2025

Thanks for the information and detailed investigation!

From your investigation, do you happen to know why import('process') hangs in child2 but require('process') does not? I guess, presumably only one of those flows are calling SetNamedPipeHandle, but that seems odd, unless I'm missing something fundamental about the different ways of importing modules... That seems to suggest there may be some way to avoid this on the Node side.

@huseyinacacak-janea
Copy link
Contributor

Yes, for some reason, they work differently. However, in your case, if you want to use process.stdin in grandChild2.js, your code will still get stuck. For example, when I add the following code to grandChild2.js, it hangs.

const process = require('process');
process.stdin.on('data', data => {console.log("data: ", data)});

@Jimbly
Copy link
Contributor Author

Jimbly commented Jan 28, 2025

Luckily I don't want to use stdin - in my real situation, I'm just forking a child running TypeScript and spawning an Electron Forge child as part of a build script, and fork() inherits stdin by default, which then sometimes hangs as described above, so just overriding to not inherit stdin for either child process is the simplest workaround.

Hopefully the hanging is a fixable bug though. Perfectly fine for the stdin stream to never yield any data until the other child with a blocking read on it exits, and maybe even understandable if trying to read from stdin hangs the process (though that's unlike how reading from any other blocked stream behaves in Node), but hanging (apparently to the user) just because some module somewhere uses an ESM-style import of process seems not great =).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants