Skip to content

Commit f65819f

Browse files
committed
Fix Windows daemon captured start
1 parent b031b3c commit f65819f

4 files changed

Lines changed: 102 additions & 28 deletions

File tree

crates/agent-guardrails-cli/src/daemon.rs

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -144,21 +144,7 @@ fn run_start(args: &[String]) -> Result<i32, String> {
144144
}
145145

146146
ensure_guardrails_dir(&repo_root)?;
147-
let current_exe =
148-
env::current_exe().map_err(|error| format!("failed to resolve current exe: {error}"))?;
149-
let mut command = Command::new(current_exe);
150-
command
151-
.arg("daemon-worker")
152-
.arg("--repo-root")
153-
.arg(repo_root.to_string_lossy().to_string())
154-
.stdin(Stdio::null())
155-
.stdout(Stdio::null())
156-
.stderr(Stdio::null())
157-
.current_dir(&repo_root);
158-
detach_daemon_command(&mut command);
159-
command
160-
.spawn()
161-
.map_err(|error| format!("failed to spawn daemon worker: {error}"))?;
147+
spawn_daemon_worker(&repo_root)?;
162148

163149
let status = wait_for_running_status(&repo_root, daemon_start_timeout());
164150
let ok = status.running;
@@ -505,15 +491,71 @@ fn stop_process(pid: u32) -> bool {
505491
}
506492
}
507493

494+
fn spawn_daemon_worker(repo_root: &Path) -> Result<(), String> {
495+
let current_exe =
496+
env::current_exe().map_err(|error| format!("failed to resolve current exe: {error}"))?;
497+
498+
spawn_daemon_worker_for_platform(current_exe, repo_root)
499+
}
500+
508501
#[cfg(windows)]
509-
fn detach_daemon_command(command: &mut Command) {
510-
use std::os::windows::process::CommandExt;
502+
fn spawn_daemon_worker_for_platform(current_exe: PathBuf, repo_root: &Path) -> Result<(), String> {
503+
let exe = powershell_single_quote(&current_exe.to_string_lossy());
504+
let repo = powershell_single_quote(&repo_root.to_string_lossy());
505+
// Hidden Start-Process gives the daemon worker fresh stdio handles. Direct
506+
// Command::spawn can leave a captured caller stdout pipe open on Windows.
507+
let script = format!(
508+
"$ErrorActionPreference='Stop'; Start-Process -FilePath '{exe}' -ArgumentList @('daemon-worker','--repo-root','{repo}') -WorkingDirectory '{repo}' -WindowStyle Hidden"
509+
);
510+
let status = Command::new("powershell.exe")
511+
.arg("-NoProfile")
512+
.arg("-NonInteractive")
513+
.arg("-WindowStyle")
514+
.arg("Hidden")
515+
.arg("-ExecutionPolicy")
516+
.arg("Bypass")
517+
.arg("-Command")
518+
.arg(script)
519+
.stdin(Stdio::null())
520+
.stdout(Stdio::null())
521+
.stderr(Stdio::null())
522+
.current_dir(repo_root)
523+
.status()
524+
.map_err(|error| format!("failed to launch daemon worker via PowerShell: {error}"))?;
525+
if status.success() {
526+
Ok(())
527+
} else {
528+
Err(format!(
529+
"failed to launch daemon worker via PowerShell: exit code {}",
530+
status
531+
.code()
532+
.map(|code| code.to_string())
533+
.unwrap_or_else(|| "unknown".to_string())
534+
))
535+
}
536+
}
511537

512-
const DETACHED_PROCESS: u32 = 0x0000_0008;
513-
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
514-
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
538+
#[cfg(windows)]
539+
fn powershell_single_quote(value: &str) -> String {
540+
value.replace('\'', "''")
541+
}
515542

516-
command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
543+
#[cfg(not(windows))]
544+
fn spawn_daemon_worker_for_platform(current_exe: PathBuf, repo_root: &Path) -> Result<(), String> {
545+
let mut command = Command::new(current_exe);
546+
command
547+
.arg("daemon-worker")
548+
.arg("--repo-root")
549+
.arg(repo_root.to_string_lossy().to_string())
550+
.stdin(Stdio::null())
551+
.stdout(Stdio::null())
552+
.stderr(Stdio::null())
553+
.current_dir(repo_root);
554+
detach_daemon_command(&mut command);
555+
command
556+
.spawn()
557+
.map(|_| ())
558+
.map_err(|error| format!("failed to spawn daemon worker: {error}"))
517559
}
518560

519561
#[cfg(not(windows))]

lib/cli.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export async function runCli(argv) {
226226
if (command === "start") {
227227
const startRuntime = selectStartRuntime();
228228
if (startRuntime.kind === "rust") {
229-
await runRustRuntime("start", argv.slice(1), { binary: startRuntime.binary });
229+
await runRustRuntime("start", argv.slice(1), { binary: startRuntime.binary, stdio: "pipe" });
230230
return;
231231
}
232232
await startDaemon(resolveRepoRoot(process.cwd()), { locale, foreground: flags.foreground || false });
@@ -236,7 +236,7 @@ export async function runCli(argv) {
236236
if (command === "stop") {
237237
const stopRuntime = selectStopRuntime();
238238
if (stopRuntime.kind === "rust") {
239-
await runRustRuntime("stop", argv.slice(1), { binary: stopRuntime.binary });
239+
await runRustRuntime("stop", argv.slice(1), { binary: stopRuntime.binary, stdio: "pipe" });
240240
return;
241241
}
242242
stopDaemon(resolveRepoRoot(process.cwd()), { locale });
@@ -246,7 +246,7 @@ export async function runCli(argv) {
246246
if (command === "status") {
247247
const statusRuntime = selectStatusRuntime();
248248
if (statusRuntime.kind === "rust") {
249-
await runRustRuntime("status", argv.slice(1), { binary: statusRuntime.binary });
249+
await runRustRuntime("status", argv.slice(1), { binary: statusRuntime.binary, stdio: "pipe" });
250250
return;
251251
}
252252
showDaemonStatus(resolveRepoRoot(process.cwd()), { locale });

lib/rust-runtime.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,11 @@ const forwardedSignalExitCodes = new Map([
211211
["SIGTERM", 143]
212212
]);
213213

214-
export async function runRustRuntime(command, commandArgs, { env = process.env, root = packageRoot, binary = null } = {}) {
214+
export async function runRustRuntime(
215+
command,
216+
commandArgs,
217+
{ env = process.env, root = packageRoot, binary = null, stdio = "inherit" } = {}
218+
) {
215219
const resolvedBinary = binary ?? resolveRustCheckRuntime({ env, root });
216220
const childEnv = {
217221
...env,
@@ -223,10 +227,19 @@ export async function runRustRuntime(command, commandArgs, { env = process.env,
223227
const child = spawn(resolvedBinary, [command, ...commandArgs], {
224228
cwd: process.cwd(),
225229
env: childEnv,
226-
stdio: "inherit",
230+
stdio,
227231
windowsHide: true
228232
});
229233

234+
if (stdio === "pipe") {
235+
child.stdout?.on("data", (chunk) => {
236+
process.stdout.write(chunk);
237+
});
238+
child.stderr?.on("data", (chunk) => {
239+
process.stderr.write(chunk);
240+
});
241+
}
242+
230243
const onParentExit = () => {
231244
if (!child.killed) {
232245
child.kill();

tests/rust-installed-runtime-smoke.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function runNpm(args, cwd, env = {}) {
6666
return run(npm.command, [...npm.prefixArgs, ...args], cwd, env);
6767
}
6868

69-
function runProcess(command, args, cwd, env = {}) {
69+
function runProcess(command, args, cwd, env = {}, timeoutMs = 120_000) {
7070
return new Promise((resolve, reject) => {
7171
const child = spawn(command, args, {
7272
cwd,
@@ -76,14 +76,22 @@ function runProcess(command, args, cwd, env = {}) {
7676
});
7777
let stdout = "";
7878
let stderr = "";
79+
const timeout = setTimeout(() => {
80+
child.kill();
81+
reject(new Error(`Process timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
82+
}, timeoutMs);
7983
child.stdout.on("data", (chunk) => {
8084
stdout += chunk.toString("utf8");
8185
});
8286
child.stderr.on("data", (chunk) => {
8387
stderr += chunk.toString("utf8");
8488
});
85-
child.on("error", reject);
89+
child.on("error", (error) => {
90+
clearTimeout(timeout);
91+
reject(error);
92+
});
8693
child.on("exit", (code, signal) => {
94+
clearTimeout(timeout);
8795
resolve({ code: code ?? 1, signal, stdout, stderr });
8896
});
8997
});
@@ -315,6 +323,17 @@ async function assertInstalledDaemonUsesRustDefault(cliPath, repoDir, env) {
315323
const finalStatus = parseJsonCommand(process.execPath, [cliPath, "status", "--json"], repoDir, env);
316324
trace("daemon final status returned");
317325
assert.equal(finalStatus.status.running, false);
326+
327+
const humanStart = await runProcess(process.execPath, [cliPath, "start", "--lang", "en"], repoDir, env, 15_000);
328+
assert.equal(
329+
humanStart.code,
330+
0,
331+
`installed Rust human start should exit successfully when stdout is captured\nstdout:\n${humanStart.stdout}\nstderr:\n${humanStart.stderr}\n` +
332+
`diagnostics:\n${JSON.stringify(installedDaemonDiagnostics(repoDir), null, 2)}`
333+
);
334+
assert.match(humanStart.stdout, /daemon started/i);
335+
const humanStop = parseJsonCommand(process.execPath, [cliPath, "stop", "--json"], repoDir, env);
336+
assert.equal(humanStop.ok, true);
318337
} finally {
319338
try {
320339
run(process.execPath, [cliPath, "stop", "--json"], repoDir, env);

0 commit comments

Comments
 (0)