Skip to content

desktop-app: route Windows daemon stdout/stderr to log files (partial fix for #2129) - #2136

Merged
mstomar125 merged 2 commits into
mainfrom
issue-2129-windows-stderr
Aug 18, 2026
Merged

mstomar125 merged 2 commits into
mainfrom
issue-2129-windows-stderr

Conversation

@mstomar125

Copy link
Copy Markdown
Contributor

What this fixes

Partially addresses #2129. spawn_daemon_windows (in packages/desktop-app/src-tauri/src/daemon_setup.rs) previously spawned the nodespaced daemon 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:

spawn_daemon_windows now opens ~/.nodespace/logs/nodespaced.log and nodespaced-error.log — the exact filenames write_plist (macOS launchd StandardOutPath/StandardErrorPath) and write_systemd_service (Linux systemd StandardOutput=/StandardError=append:) already use in this same file — in append mode, and hands them to the child via Stdio::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 direct SchemaNode::from_node calls 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, so eprintln! 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-level SetStdHandle(STD_ERROR_HANDLE, ...) redirect at GUI process startup (Windows FFI in main.rs, a higher-blast-radius change to app startup I have no way to runtime-verify from here) or restructuring nodespace-types::SchemaNode::from_node to 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-xwin setup (confirmed: rustup target list --installed had only Darwin/wasm targets before I added x86_64-pc-windows-msvc; cross/cargo-xwin are not installed; no existing cross-compilation config in the repo). CI (.github/workflows/release.yml) builds Windows on a real windows-latest GitHub 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 fmt all pass natively on macOS (this PR doesn't touch any non-Windows code path).
  • A full cargo check --target x86_64-pc-windows-msvc --lib on the whole nodespace-app crate 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.h etc.) to cross-compile its C code, which isn't available here.
  • To still get genuine signal on the actual new code, I copied the new #[cfg(windows)] functions verbatim into an isolated scratch crate (no tauri/reqwest/ring in its dependency tree) and ran cargo check --target x86_64-pc-windows-msvc and cargo clippy --target x86_64-pc-windows-msvc --all-targets against it — both clean. This confirms the Windows API usage (CommandExt::creation_flags, the DETACHED_PROCESS/CREATE_NEW_PROCESS_GROUP constants, Stdio::from(File)) is syntactically and type-correct for that target.
  • What this does not prove: that Command::spawn() actually inherits the file handles correctly into a DETACHED_PROCESS-flagged child at runtime, that file locking/sharing semantics behave as expected across taskkill-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, native aarch64-apple-darwin) — clean.
  • bun run quality:fix (workspace-wide clippy across all Rust crates) — clean, no changes beyond the one file touched.
  • Full pre-push gate (bun run test:all, cargo build --bin nodespaced, bun run test:e2e, cargo test -p nodespace-app) — all green.
  • Isolated scratch-crate cargo check/cargo clippy --target x86_64-pc-windows-msvc on the new Windows-specific functions — clean (see caveats above).
  • Not tested: actual runtime behavior on Windows (no Windows machine available).

🤖 Generated with Claude Code

…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>
@mstomar125

Copy link
Copy Markdown
Contributor Author

Adversarial review — addressed

Spawned 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 Stdio::from(File) handle-inheritance claim rather than reasoning from memory).

Result: no blockers. Confirmed correct:

  • Stdio::from(File) on Windows duplicates the handle as inheritable at spawn time (DUPLICATE_SAME_ACCESS) regardless of the original CreateFileW handle's own inheritability — the naive-looking usage here is the sanctioned std idiom and works.
  • Parent-side File drop after .spawn() doesn't affect the child's independent handle copy (Windows kernel objects are refcounted; CreateProcess already copied the handle-table entry).
  • Default Windows share flags (FILE_SHARE_READ|WRITE|DELETE) mean reopening the log across a taskkill-triggered restart won't hit a sharing violation.
  • Call site (log_dir sourced from the already-created directory in ensure_daemon_running), cfg-gating, and every testing/verification claim in the PR description all reproduced exactly.

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). daemon_log_stdio now warns and falls back to Stdio::null() per-stream instead of failing the spawn.

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).

@mstomar125
mstomar125 merged commit 523d0b7 into main Aug 18, 2026
@mstomar125
mstomar125 deleted the issue-2129-windows-stderr branch August 18, 2026 08:13
mstomar125 added a commit that referenced this pull request Sep 14, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant