Repository navigation
desktop-app: route Windows daemon stdout/stderr to log files (partial fix for #2129) - #2136
Conversation
…of Stdio::null() spawn_daemon_windows previously discarded the daemon child process's stdout/stderr outright (Stdio::null()), unlike the macOS launchd plist's StandardOutPath/StandardErrorPath and the Linux systemd unit's StandardOutput=/StandardError=append: already defined in this same file. Any diagnostic the daemon wrote there — tracing_subscriber::fmt()'s default stdout writer, or an eprintln!-based diagnostic such as SchemaNode::from_node's fields-parse-failure message — vanished with no trace on Windows. spawn_daemon_windows now opens ~/.nodespace/logs/nodespaced.log and nodespaced-error.log (same filenames the other two platforms already use) in append mode and hands them to the child via Stdio::from(File), mirroring the existing launchd/systemd pattern. Adds daemon_log_paths and open_daemon_log as small, platform-independent helpers so the path construction and append-not-truncate file semantics are covered by ordinary #[test]s runnable on any machine. This addresses only the daemon-child-process half of the stdio problem. The desktop-app GUI process's own console-less stderr under windows_subsystem = "windows" (the second half named in the issue) is not touched here — see the issue comment for why that's scoped out. Co-Authored-By: Claude <noreply@anthropic.com>
Address adversarial review of PR #2136: previously a failure to open nodespaced.log/nodespaced-error.log aborted the whole daemon spawn on Windows, which was a new failure mode this fix introduced (on macOS/Linux, a launchd/systemd log-file problem never blocks the service itself, since the app only ever writes the plist/unit text — the service manager owns opening the log). daemon_log_stdio now logs a warning and falls back to Stdio::null() per-stream instead of propagating the open error, so a locked-file/permissions edge case degrades to the pre-fix behavior rather than preventing the daemon from starting at all. Co-Authored-By: Claude <noreply@anthropic.com>
Adversarial review — addressedSpawned an independent sub-agent to adversarially review this PR (didn't trust my own claims — it cloned the repo, re-ran every test/clippy/fmt/scratch-crate check itself, and pulled Rust std's actual Windows source to verify the Result: no blockers. Confirmed correct:
One legitimate finding, now fixed (commit 5ab7fe1): a log-file-open failure (e.g. AV lock, permissions edge case) was propagating as a hard error and aborting daemon startup entirely — a new Windows-only failure mode this fix would have introduced, since macOS/Linux never fail service startup over a log-write problem (the app only writes the plist/unit text; the service manager owns opening the log). Two pre-existing, out-of-scope findings filed separately rather than folded into this diff:
Self-approving and merging — this PR stands on its own as a correct, tested fix for the daemon-child-process half of #2129 (kept open, not closed by this PR, since the GUI-process half and live validation remain outstanding — see #2137). |
… log files (closes #2137) (#2614) Real-Windows validation of PR #2136/#2140's daemon stderr-to-file fix surfaced a genuine bug: extract_sidecar_if_changed installed the daemon binary under ~/.nodespace/bin/ using the bare daemon_binary_name() ("nodespaced"), never routed through bundled_sidecar_name(), so the extracted file carried no .exe extension. The daemon still spawned (a literal existing path resolves via Command::new even without an extension), but registered in the Windows process table under the bare image name, so kill_running_daemon's taskkill /IM nodespaced.exe never matched it -- confirmed live on a Windows box: taskkill reported "process not found" while the real process kept running, for both community and Pro edition names. A restart could therefore never kill the previous daemon before spawning a new one. Fix: route both the install destination (extract_sidecar_if_changed) and the launch/kill path (ensure_daemon_running's daemon_bin) through a single new sidecar_install_path() helper, so they can't drift apart again. Everything else in the daemon stderr-to-file path -- log file creation/append, handle inheritance into a DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP child, append-not-truncate across a simulated restart, and the real SchemaNode::from_node fields-parse diagnostic landing in nodespaced-error.log -- verified working as-is on the same Windows box. Separately, add a fix for the GUI process's own console-less stderr (schemas.rs's two direct SchemaNode::from_node calls, discarded by windows_subsystem = "windows" in release builds): redirect this process's own STD_OUTPUT_HANDLE/STD_ERROR_HANDLE to log files via SetStdHandle at startup, gated to release-on-Windows so debug builds keep their attached console. Verified on a real Windows box with a standalone windows_subsystem = "windows" harness launched with no inherited stdio: eprintln!/println! output is lost before the redirect and lands correctly in the log file after it, including the real SchemaNode::from_node diagnostic on a genuinely malformed schema. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
What this fixes
Partially addresses #2129.
spawn_daemon_windows(inpackages/desktop-app/src-tauri/src/daemon_setup.rs) previously spawned thenodespaceddaemon with.stdout(Stdio::null()).stderr(Stdio::null())— any output the daemon wrote to stdout/stderr was discarded outright, with no destination at all (unlike/dev/null, which is at least a consistent sink). This included:tracing_subscriber::fmt()'s default writer inpackages/daemon/src/main.rs, which writes to stdout with no explicit.with_writer(...)override.eprintln!-based diagnostics reachable throughpackages/core'snode_ops/query_ops→nodespace_types::convert::node_to_typed_value→SchemaNode::from_node(the nodespace-types::SchemaNode::from_node silently swallows a schema field parse failure to an empty Vec #2107/nodespace-types: surface SchemaNode field-parse failures via eprintln (closes #2107) #2128 fields-parse-failure diagnostic), which the daemon hits server-side on everyGetAllSchemas/GetSchemaDefinition/generic node-read call.spawn_daemon_windowsnow opens~/.nodespace/logs/nodespaced.logandnodespaced-error.log— the exact filenameswrite_plist(macOS launchdStandardOutPath/StandardErrorPath) andwrite_systemd_service(Linux systemdStandardOutput=/StandardError=append:) already use in this same file — in append mode, and hands them to the child viaStdio::from(File). This mirrors the existing macOS/Linux pattern instead of inventing a new one.Two small platform-independent helpers were extracted so the fix is genuinely testable on any machine:
daemon_log_paths(log_dir) -> (PathBuf, PathBuf)— pure path construction.open_daemon_log(path) -> io::Result<File>—OpenOptions::new().create(true).append(true), no Windows-specific API.Four new
#[test]s cover: filename parity with the macOS/Linux paths, path/dir-scoping, file creation, and — the one that actually matters for a service that gets restarted on every binary update — that reopening the log across a daemon restart appends rather than truncates. All pass locally; existing macOS codesign tests are untouched and still pass.What this does NOT fix — scope and honesty about verification
1. The desktop-app GUI process's own console-less stderr is out of scope here. The issue's title and acceptance criteria also cover the client-side half:
packages/desktop-app/src-tauri/src/commands/schemas.rs's two directSchemaNode::from_nodecalls run inside the Tauri GUI process itself, which builds with#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]— a release build has no console at all, soeprintln!there goes nowhere regardless of the daemon fix above (this is a different mechanism: no child-process stdio to redirect, it's the process's own stderr handle). Fixing that would mean either an OS-levelSetStdHandle(STD_ERROR_HANDLE, ...)redirect at GUI process startup (Windows FFI inmain.rs, a higher-blast-radius change to app startup I have no way to runtime-verify from here) or restructuringnodespace-types::SchemaNode::from_nodeto return the diagnostic instead of printing it internally (which PR #2128's own rationale explicitly chose against, for uniformity across its three callers). I did not attempt either in this PR — see the follow-up issue.2. No Windows machine or emulator was available to validate runtime behavior. This machine is macOS with no Windows box, VM, or
cross/cargo-xwinsetup (confirmed:rustup target list --installedhad only Darwin/wasm targets before I addedx86_64-pc-windows-msvc;cross/cargo-xwinare not installed; no existing cross-compilation config in the repo). CI (.github/workflows/release.yml) builds Windows on a realwindows-latestGitHub Actions runner, not by cross-compiling from macOS — that's further evidence there's no expectation of local Windows cross-compilation here.What I could verify:
cargo check --lib/cargo test/cargo clippy --all-targets/cargo fmtall pass natively on macOS (this PR doesn't touch any non-Windows code path).cargo check --target x86_64-pc-windows-msvc --libon the wholenodespace-appcrate fails — but on a pre-existing, unrelated issue:ring(a transitive TLS dependency, not touched by this PR) needs a real Windows SDK/MSVC C toolchain (assert.hetc.) to cross-compile its C code, which isn't available here.#[cfg(windows)]functions verbatim into an isolated scratch crate (notauri/reqwest/ringin its dependency tree) and rancargo check --target x86_64-pc-windows-msvcandcargo clippy --target x86_64-pc-windows-msvc --all-targetsagainst it — both clean. This confirms the Windows API usage (CommandExt::creation_flags, theDETACHED_PROCESS/CREATE_NEW_PROCESS_GROUPconstants,Stdio::from(File)) is syntactically and type-correct for that target.Command::spawn()actually inherits the file handles correctly into aDETACHED_PROCESS-flagged child at runtime, that file locking/sharing semantics behave as expected acrosstaskkill-triggered restarts, or anything else that only a real Windows execution can confirm.Given that gap, I'm not closing #2129 with this PR — see the issue comment and the follow-up issue for live-validation + the GUI-console half.
Testing
cargo test --lib daemon_setup(nodespace-app crate) — 6/6 pass (4 new, 2 pre-existing macOS codesign tests unaffected).cargo clippy --all-targets(nodespace-app crate) — clean.cargo check --lib(nodespace-app crate, nativeaarch64-apple-darwin) — clean.bun run quality:fix(workspace-wide clippy across all Rust crates) — clean, no changes beyond the one file touched.bun run test:all,cargo build --bin nodespaced,bun run test:e2e,cargo test -p nodespace-app) — all green.cargo check/cargo clippy --target x86_64-pc-windows-msvcon the new Windows-specific functions — clean (see caveats above).🤖 Generated with Claude Code