fix(resident): drop stdout redirect to unblock parent on Windows

Redirecting the resident child's stdout caused Windows CreateProcess
to set bInheritHandles=TRUE, leaking the outer shell's pipe handle
into the resident. The shell then blocked for ~60s waiting for EOF
on a pipe the resident still owned, even though officecli main had
already exited.

- Remove RedirectStandardOutput from the resident ProcessStartInfo
  (stderr redirect is kept for startup failure diagnostics)
- Dispose the Process on all exit paths (success/exited/timeout) to
  release the remaining pipe handle promptly
- Wrap resident-side background Console.Error.WriteLine calls in a
  LogStderr helper that swallows IOException, so the resident does
  not crash when writing diagnostics to a stderr pipe whose read-end
  was closed by the parent exiting
This commit is contained in:
zmworm 2026-04-14 22:48:19 +08:00
parent 96fee3399b
commit ae51cbce5e
2 changed files with 29 additions and 11 deletions

View file

@ -166,7 +166,11 @@ static partial class CommandBuilder
Arguments = $"__resident-serve__ \"{filePath}\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
// Do NOT redirect stdout: on Windows, RedirectStandardOutput
// causes bInheritHandles=TRUE which leaks the outer shell's
// pipe handle into the resident, blocking the caller for ~60s
// until the resident's idle timeout. Stderr is still redirected
// to capture diagnostics if the resident fails during startup.
RedirectStandardError = true
};
@ -185,16 +189,21 @@ static partial class CommandBuilder
{
Thread.Sleep(100);
if (ResidentClient.TryConnect(filePath, out _))
{
process.Dispose();
return true;
}
if (process.HasExited)
{
var stderr = process.StandardError.ReadToEnd();
error = $"Resident process exited. {stderr}";
process.Dispose();
return false;
}
}
error = "Resident process started but not responding.";
process.Dispose();
return false;
}

View file

@ -43,6 +43,15 @@ public class ResidentServer : IDisposable
private CancellationTokenSource _idleCts = new();
private bool _disposed;
// Safe stderr logging: the parent process may have redirected our stderr
// to a pipe whose read-end closes when the parent exits, so any
// Console.Error.WriteLine after that point throws IOException. Swallow
// it silently — these are best-effort diagnostics, not critical output.
private static void LogStderr(string message)
{
try { Console.Error.WriteLine(message); } catch (IOException) { }
}
// Valid idle-timeout range: 1s .. 24h. Anything outside falls back to
// the 12min default. A value of "0" is rejected (would be an infinite-
// busy spin on the watchdog task). Shared between the startup env-var
@ -203,7 +212,7 @@ public class ResidentServer : IDisposable
}
catch (Exception ex)
{
Console.Error.WriteLine($"Resident error: {ex.Message}");
LogStderr($"Resident error: {ex.Message}");
// currentMain is still the pre-created replacement; it is
// still valid for the next iteration's WaitForConnectionAsync.
}
@ -258,7 +267,7 @@ public class ResidentServer : IDisposable
// cancelling _mainCts / _pingCts, so the "ping liveness ⇔
// file locked" invariant is preserved end-to-end: the
// ping pipe stays alive until handler.Dispose() completes.
Console.Error.WriteLine($"Resident idle for {currentTimeout.TotalMinutes} minutes, closing.");
LogStderr($"Resident idle for {currentTimeout.TotalMinutes} minutes, closing.");
_ = ShutdownAsync();
break;
}
@ -317,7 +326,7 @@ public class ResidentServer : IDisposable
}
catch (Exception ex)
{
Console.Error.WriteLine($"Ping responder error: {ex.Message}");
LogStderr($"Ping responder error: {ex.Message}");
// currentMain/current is already the replacement;
// loop continues.
}
@ -383,7 +392,7 @@ public class ResidentServer : IDisposable
try { await ShutdownAsync(); }
catch (Exception ex)
{
Console.Error.WriteLine($"Shutdown error during __close__: {ex.Message}");
LogStderr($"Shutdown error during __close__: {ex.Message}");
}
var response = MakeResponse(0, "Closing resident.", "");
@ -397,7 +406,7 @@ public class ResidentServer : IDisposable
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"Ping handler error: {ex.Message}");
LogStderr($"Ping handler error: {ex.Message}");
}
finally
{
@ -424,7 +433,7 @@ public class ResidentServer : IDisposable
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"Resident error: {ex.Message}");
LogStderr($"Resident error: {ex.Message}");
}
finally
{
@ -1091,13 +1100,13 @@ public class ResidentServer : IDisposable
{
if (!ShutdownAsync().Wait(TimeSpan.FromMinutes(10)))
{
Console.Error.WriteLine("Warning: shutdown timed out after 10 minutes, forcing exit.");
LogStderr("Warning: shutdown timed out after 10 minutes, forcing exit.");
Environment.Exit(1);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: shutdown error: {ex.Message}");
LogStderr($"Warning: shutdown error: {ex.Message}");
}
try { _commandLock.Dispose(); } catch { }
@ -1160,7 +1169,7 @@ public class ResidentServer : IDisposable
}
else
{
Console.Error.WriteLine("Warning: timeout waiting for in-flight command to drain.");
LogStderr("Warning: timeout waiting for in-flight command to drain.");
}
}
catch (ObjectDisposedException) { /* _commandLock already disposed */ }
@ -1172,7 +1181,7 @@ public class ResidentServer : IDisposable
try { _handler.Dispose(); }
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: handler dispose error: {ex.Message}");
LogStderr($"Warning: handler dispose error: {ex.Message}");
}
// 5. NOW cancel ping + idle. Clients observing the ping pipe from