Skip to content

fix(gradle): destroy streams on exit to prevent daemon pipe hang#34705

Open
nimo71 wants to merge 1 commit intonrwl:masterfrom
nimo71:fix/gradle-daemon-pipe-hang
Open

fix(gradle): destroy streams on exit to prevent daemon pipe hang#34705
nimo71 wants to merge 1 commit intonrwl:masterfrom
nimo71:fix/gradle-daemon-pipe-hang

Conversation

@nimo71
Copy link

@nimo71 nimo71 commented Mar 4, 2026

Current Behavior

execGradleAsync() in exec-gradle.ts can hang indefinitely during project graph creation. This manifests as Nx freezing on "Creating project graph nodes" — particularly in CI environments, but also reproducible locally.

Expected Behavior

execGradleAsync() should resolve/reject promptly after the Gradle task completes, regardless of whether the Gradle daemon continues running in the background.

Root Cause

execGradleAsync() uses execFile() with shell: true, which spawns a shell wrapper process. The Gradle daemon, started by gradlew, inherits the shell's stdout/stderr pipe file descriptors. After the nxProjectGraph task completes:

  1. The gradlew wrapper script exits
  2. The Gradle daemon continues running, holding the inherited pipe FDs open
  3. The shell wrapper process cannot exit because its child (the daemon) still holds its pipes
  4. Node.js never fires the exit event on the child process
  5. The Promise in execGradleAsync() never resolves

This is a well-documented interaction between:

Fix

Destroy stdout/stderr streams immediately after the exit event fires. By this point, all output data has already been buffered into the stdout variable via the data event handlers, so no output is lost. Destroying the streams releases the pipe FDs from Node's perspective, allowing the shell wrapper to exit and the Promise to resolve — regardless of daemon state.

cp.on('exit', (code, signal) => {
    if (code === null) code = signalToCode(signal);
    // Forcibly destroy streams to prevent Gradle daemon pipe hang.
    cp.stdout?.destroy();
    cp.stderr?.destroy();
    if (code === 0) {
        res(stdout);
    } else {
        rej(stdout);
    }
});

Workarounds Users Currently Need

Without this fix, users must resort to:

  • org.gradle.daemon.idletimeout=5000 in gradle.properties (adds 5s latency per invocation)
  • CI retry loops with timeout + pkill -f GradleDaemon (unreliable, wastes CI minutes)
  • --no-daemon flag (doesn't work reliably with Gradle 8.1+/9.x — a "single-use daemon" is still forked when org.gradle.jvmargs is set)

Related Issues

@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for nx-docs canceled.

Name Link
🔨 Latest commit 29adb96
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/69bc140b3fd0510008fcc3ae

@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for nx-dev canceled.

Name Link
🔨 Latest commit 29adb96
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/69bc140b091c9600080bda5a

When `execGradleAsync()` spawns Gradle via `execFile` with `shell: true`,
the Gradle daemon inherits the shell's stdout/stderr pipe file descriptors.
After the task completes, the daemon continues running and holds these
pipes open, preventing the shell wrapper from exiting. Since Node.js
resolves the `exit` event based on the shell process terminating, the
Promise hangs indefinitely.

Fix: Forcibly destroy stdout/stderr streams after the `exit` event fires.
By this point all output has been buffered, so no data is lost. Destroying
the streams releases the pipe FDs from Node's perspective, allowing the
shell to exit and the Promise to resolve.

Refs: nodejs/node#5637, gradle/gradle#3987
@AgentEnder AgentEnder force-pushed the fix/gradle-daemon-pipe-hang branch from 956a456 to 29adb96 Compare March 19, 2026 15:19
@AgentEnder AgentEnder requested a review from a team as a code owner March 19, 2026 15:19
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

Successfully merging this pull request may close these issues.

1 participant