Skip to content

fix(dev): capture Windows output in .lt-dev/*.log, and say when a component dies on start - #121

Merged
DKoenig9 merged 1 commit into
mainfrom
fix/windows-dev-logs
Sep 25, 2026
Merged

DKoenig9 merged 1 commit into
mainfrom
fix/windows-dev-logs

Conversation

@DKoenig9

Copy link
Copy Markdown
Contributor

Why

On a Windows laptop, every .lt-dev/*.log stayed at 0 bytes, including the log of an app that was demonstrably running (port bound, nuxt dev answering). The files existed and were even rotated ("Rotated previous api log → api.log.1 (0B)"), but nothing was ever written to them. The output went to an extra console window that lt dev up opened. In the same session an API died on start while up reported Started. Its error sat in that window for two hours before anyone found it. A log that exists but stays empty is worse than no log, because it looks trustworthy.

Cause (measured, not inferred)

The measurements ran on windows-latest and reproduce the production path: a fake pnpm.cmd shim runs node through cmd.exe, and that node starts a node grandchild. Both write to stdout and stderr. Temporary branch tmp/measure-windows-spawn, runs 35856960971 and 35857372014:

Case Log bytes Child/grandchild lines Tree
today's spawnDetached 0 0 / 0 cmd.exe > node > conhost > node
+ windowsHide 0 0 / 0 same
not detached 0 process dead –
node directly, detached 1698 94 / 46 node > node > conhost
cmd.exe redirects itself with >> 0 0 / 0 cmd.exe > node > conhost > node
node trampoline → cmd → node 1666 92 / 46 node > cmd.exe > conhost > node > node
trampoline + windowsHide 1698 94 / 46 same

detached starts cmd.exe without a console. The console program below it gets a new one, and Windows then replaces the standard handles that cmd.exe only inherited, the log file among them. The console window itself is not the problem: a node child that gets its own console still writes to the file, because node passes the handles explicitly. Even cmd.exe's own >> redirect is lost.

Fix

On Windows, a small node -e trampoline (windowsTrampoline) sits in front of the command:

  • libuv hands the trampoline the log file explicitly.
  • The trampoline starts the real command via cross-spawn with stdio: 'inherit', which passes the handles explicitly again. cmd.exe then owns a console, and its children inherit both the console and the file.
  • It exits with the command's code, so a dead command is still a dead pid.
  • It turns an unstartable command into exit 127 with lt dev: could not start <cmd>: ….
  • The code is one line: a newline inside a Windows command-line argument is asking for trouble.
  • cross-spawn is required by absolute path, because -e resolves modules from the user's project, not from the CLI.
  • The recorded pid is the trampoline's. It lives exactly as long as the command, and taskkill /T from it reaches the whole tree (AP-5, fix(dev): stop Windows stacks with taskkill /T /F, and never turn a stored pid into a broadcast (AP-5) #118). The PID-identity test is POSIX-only for that reason, with a comment explaining why.
  • windowsHide: true on the spawn is meant to keep the console out of sight. Whether a window still shows up cannot be measured in CI (no interactive session). That is for the laptop.

The test that goes red when the log stays empty

__tests__/dev-log-capture.test.ts runs the real spawnDetached, through a .cmd shim on Windows and through the sh wrapper on POSIX. It needs the child's and the grandchild's stdout and stderr in the file.

windows-latest, run 35859338816 Result
with this change green
same test against main's dev-process.ts red, received "", i.e. exactly the laptop symptom

The run before that was worthless, and I discarded it. My test set env.PATH, but on Windows the key is Path. In a copied (case-sensitive) env, env.PATH read undefined, so node did not start in either run, and both logs contained only cmd.exe's own error message. That also shows that cmd.exe itself writes correctly and only the console programs below it lose their output.

Also: saying when a component dies on start

  • lt dev up watches the freshly started pids for 3 s. A component that is gone by then is an error (exit 1). It shows the last log lines, or the statement that the log is EMPTY and the reason is therefore not in it. It used to report Started. The limit: a crash under nodemon keeps the supervisor alive, so that case goes to status.
  • lt dev status shows the last 8 log lines of a component that is down. It warns when a component has been up for 15 s with its log still empty ("its output is not being captured"). That is the silent failure from the laptop, which is now visible.
  • The decisions are in the pure helper src/lib/dev-log-tail.ts (diagnoseLog, describeLog, isSilentLog, findEarlyExits), with tests. up/status only have a static wiring test, because the commands have no harness.

Mutation check (reverted from a backup)

Mutation Result
Trampoline drops the output (stdio: 'ignore') 2 red
Trampoline always exits 0 1 red
Empty log goes unmentioned 1 red
"Silent log" never flagged 1 red
Early deaths ignored 1 red
spawnDetached without stdio (POSIX) 1 red (dev-log-capture)
up wiring removed 1 red

npm test: 80 suites, 1224 tests. Lint and compile are clean.

For the laptop

After this change, check:

  • that .lt-dev\api.log and .lt-dev\app.log contain the startup banners
  • whether a window still shows up
node $lt dev up; "up exit=$LASTEXITCODE"
Start-Sleep 20
Get-ChildItem .lt-dev\*.log | Select-Object Name, Length      # api.log / app.log > 0
Select-String -Path .lt-dev\app.log -Pattern 'Local:' -SimpleMatch | Select-Object -First 1   # the Nuxt line
node $lt dev status                                            # no "still EMPTY" warning

Not in this PR: #118 (AP-5) and #120 (AP-7). They are finished and only wait for the laptop measurement.

🤖 Generated with Claude Code

…ponent dies on start

On Windows every `.lt-dev/*.log` stayed at 0 bytes, including the log of an app
that was demonstrably running. The output went to an extra console window
instead. On a laptop an API died on start with its error visible only there,
and it took two hours to find.

Cause, measured on windows-latest with a fake `pnpm.cmd` → node → node
grandchild: `detached` starts `cmd.exe` without a console. The console program
below it gets a new one, and Windows replaces the std handles `cmd.exe` only
inherited, the log file among them. The result was 0 bytes, even when `cmd.exe`
redirected with `>>` itself. Not the console window as such: a node child that
gets its own console still writes to the file when node passes it the handles.

Fix: on Windows a `node -e` trampoline sits in front of the command. libuv hands
it the file explicitly, and it starts the command with `stdio: 'inherit'`, so
`cmd.exe` gets a console of its own and its children inherit both. It exits
with the command's code and turns an unstartable command into 127 with a
readable line. The recorded pid is the trampoline's; `taskkill /T` from it
reaches the tree.

`__tests__/dev-log-capture.test.ts` spawns through a `.cmd` shim on Windows and
needs child AND grandchild output in the file. On windows-latest it is green
with this change and red against main (received ""). On POSIX it guards the sh
wrapper the same way.

Also:
- `lt dev up` watches freshly started pids for 3 s. A component that is gone
  by then is an error (exit 1) with the last log lines, or the statement that
  the log is empty. It used to report "Started".
- `lt dev status` shows the log tail of a component that is down, and warns
  when a component has been up for 15 s with its log still empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@DKoenig9
DKoenig9 marked this pull request as ready for review September 25, 2026 10:30
@DKoenig9
DKoenig9 merged commit 07aacdc into main Sep 25, 2026
2 checks passed
@DKoenig9
DKoenig9 deleted the fix/windows-dev-logs branch September 25, 2026 10:30
kaihaase added a commit that referenced this pull request Sep 27, 2026
…rter's peer rules

Releases #114–#121 (lt dev Windows port AP-1/2/3/5, absolute registry paths,
up-to-date server README) and hoists peerDependencyRules into the workspace
root, so the first `pnpm run check` of a generated project no longer fails at
`check:peers`.

Co-Authored-By: Claude Opus 5.5 (1M context) <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