From fa9e317ac711662f749367c279b2974efe6de230 Mon Sep 17 00:00:00 2001 From: vibecoder <16832299+vibecoder@user.noreply.gitee.com> Date: Tue, 28 Apr 2026 20:13:28 +0800 Subject: [PATCH] fix: add exit event fallback for child process close hang on Windows On Windows, when a child process spawns grandchild processes (e.g., build tools like hvigor/Gradle daemons), the grandchild may inherit the stdout/stderr pipe handles. Even after the direct child exits, these inherited handles keep the pipe open, preventing Node.js 'close' event from firing. Since exitCode was resolved only on 'close', this caused bash tool to hang indefinitely in Effect.raceAll waiting for handle.exitCode. Fix: resolve exitCode on 'exit' event with a 2-second fallback. If 'close' fires within 2 seconds (normal case), behavior is unchanged. If 'close' does not fire (daemon holds pipe handle), fallback resolves after 2 seconds. Added test for Windows-specific grandchild holding pipe scenario. --- packages/core/src/cross-spawn-spawner.ts | 6 ++++ .../test/effect/cross-spawn-spawner.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d454..d24b9f5c1c1c 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -273,6 +273,12 @@ export const make = Effect.gen(function* () { }) proc.on("exit", (...args) => { exit = args + setTimeout(() => { + if (!end) { + end = true + Deferred.doneUnsafe(signal, Exit.succeed(args)) + } + }, 2000) }) proc.on("close", (...args) => { if (end) return diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index 2612b75e464c..2a7fbfc40157 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -282,6 +282,36 @@ describe("cross-spawn spawner", () => { expect(running).toBe(false) }), ) + + fx.effect( + "exit fallback resolves when close hangs due to grandchild holding pipe", + Effect.gen(function* () { + // Windows-specific: grandchild inheriting stdout pipe can block close event + if (process.platform !== "win32") return + + // Spawn child that creates grandchild inheriting stdout and running 10s. + // Child exits immediately; grandchild holds pipe, preventing close event. + // Expected: exitCode resolves after ~2s fallback, not hangs indefinitely. + const started = Date.now() + const handle = yield* js(` + const { spawn } = require('child_process') + spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 10000)'], { + stdio: ['ignore', process.stdout, 'ignore'], + detached: true + }) + process.exit(0) + `) + const code = yield* handle.exitCode + const elapsed = Date.now() - started + + // Fallback timeout = 2000ms in cross-spawn-spawner.ts spawn function. + // - Lower bound 1500ms: ensures fallback was triggered (not close firing early) + // - Upper bound 5000ms: allows for child spawn overhead + system load variance + expect(elapsed).toBeGreaterThan(1500) + expect(elapsed).toBeLessThan(5000) + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) }) describe("error handling", () => {