Support graceful daemon shutdown on Windows (#42364)

## Why

Detached Windows processes cannot receive console signals, so app-server daemons
need another way to enter the existing graceful and forced shutdown flow.

## What changed

- Watch the path in `CODEX_DAEMON_SHUTDOWN_FILE` for a request addressed to the
  current process, and consume only a matching PID.
- Race daemon shutdown requests with Ctrl-C on Windows while leaving unmanaged
  launches on the existing console-signal path.
- Exercise the SIGTERM-equivalent graceful and forced WebSocket shutdown cases
  on Windows.

## Testing

Added unit coverage ensuring inherited control paths and malformed requests
cannot consume another process's shutdown request.

GitOrigin-RevId: 426a3c12edc344802395d4d26288f2bb015c484f
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-02 21:20:03 +00:00
1 parent 577a4fcd06
commit 10aca93f18
7 files changed
+135 -9

No files matched your search

@@ -0,0 +1,54 @@
//! Detached Windows processes cannot receive console signals. The daemon passes
//! a request path inside its user-only state directory. Only the addressed PID
//! consumes a request, triggering the SIGTERM drain without changing the wire API.
use std::io;
use std::io::Read;
use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
#[cfg(windows)]
use std::time::Duration;
#[cfg(windows)]
pub const DAEMON_SHUTDOWN_FILE_ENV: &str = "CODEX_DAEMON_SHUTDOWN_FILE";
/// Waits for and consumes one daemon shutdown request. Without a managed launch
/// environment this stays pending, leaving normal console shutdown unchanged.
#[cfg(windows)]
pub async fn daemon_shutdown_signal() -> io::Result<()> {
let Some(path) = std::env::var_os(DAEMON_SHUTDOWN_FILE_ENV).map(PathBuf::from) else {
return std::future::pending().await;
};
loop {
if take_shutdown_request(&path, std::process::id())? {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn take_shutdown_request(path: &Path, pid: u32) -> io::Result<bool> {
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err),
};
// Descendant app-servers may inherit the control path. Only the intended
// process can consume a request. Bound reads to a u32 PID plus one extra byte.
let mut contents = Vec::new();
file.take(/*limit*/ 11).read_to_end(&mut contents)?;
if contents != pid.to_string().as_bytes() {
return Ok(false);
}
// Synchronous consumption cannot lose a request to select cancellation.
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
#[cfg(test)]
#[path = "daemon_shutdown_tests.rs"]
mod tests;
@@ -0,0 +1,22 @@
use super::take_shutdown_request;
#[test]
fn inherited_control_path_cannot_consume_another_process_request() {
let directory = tempfile::tempdir().expect("directory");
let request = directory.path().join("shutdown");
std::fs::write(&request, "1234").expect("request");
assert!(!take_shutdown_request(&request, /*pid*/ 5678).expect("descendant probe"));
assert!(take_shutdown_request(&request, /*pid*/ 1234).expect("parent probe"));
assert!(!take_shutdown_request(&request, /*pid*/ 1234).expect("already consumed"));
}
#[test]
fn incomplete_or_invalid_requests_are_not_consumed() {
let directory = tempfile::tempdir().expect("directory");
let request = directory.path().join("shutdown");
for contents in [b"".as_slice(), b"123", b"1234junk", &[0xff; 20]] {
std::fs::write(&request, contents).expect("request");
assert!(!take_shutdown_request(&request, /*pid*/ 1234).expect("probe"));
assert!(request.exists());
}
}
+6
View File
@@ -1,3 +1,9 @@
#[cfg(any(windows, test))]
mod daemon_shutdown;
#[cfg(windows)]
pub use daemon_shutdown::DAEMON_SHUTDOWN_FILE_ENV;
#[cfg(windows)]
pub use daemon_shutdown::daemon_shutdown_signal;
mod outgoing_message;
mod transport;