Skip to content

fix: stop a session's directory read from killing the app on Windows - #124

Merged
omartelo merged 2 commits into
mainfrom
fix/windows-silent-crash
Jul 28, 2026
Merged

omartelo merged 2 commits into
mainfrom
fix/windows-silent-crash

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

What happened

A Windows lich.log ends mid-session — no window closed, exiting, no
chromium shell error, no transport listener stopped. The window stayed on
screen still rendering its terminal, but nothing updated again and a reload
found nothing listening.

Two things were wrong, and the second is why the first went undiagnosed for so
long.

The crash

processCwd (internal/terminal/cwd_windows.go) walks the child's PEB and
reads CurrentDirectory.DosPath out of that process's memory.
NTUnicodeString.Length is a byte count that x/sys documents as "should
always be even"
— a should about a value read from another process, which a
32-bit child or one exiting mid-walk does not honour.

The guard only rejected zero. An odd length sized the buffer to zero and the
read went straight into &buf[0]:

buf := make([]uint16, dos.Length/2)          // Length=1 -> len 0
readMemory(h, ..., unsafe.Pointer(&buf[0]), ...)  // index out of range [0] with length 0

That runs on the bare goroutine watchCwd spawns per session, every 300ms, so
the panic ended the process. net/http recovers panics in its handlers, which
is why RPC and WebSocket traffic never showed this — the poller was outside
that net.

The length now goes through dosPathRunes, which refuses anything a UTF-16
buffer cannot have. It sits in the untagged cwd.go, following
windowsBrowserCandidates in internal/chromium, so the guard is tested on
every OS rather than only where it runs.

Why the log said nothing

The runtime prints a panic to stderr. The Windows build is -H=windowsgui
(Taskfile.yml), so there is no console and fd 2 goes nowhere; slog never sees
a panic, so the file half of the log was never going to carry it.

logging.Init now calls debug.SetCrashOutput with the log file it already
holds. Verified end to end — the child half of the new test writes a real trace
into lich.log:

panic: crash probe [recovered, repanicked]

goroutine 35 [running]:
...
github.com/omartelo/lich/internal/logging.TestInitRoutesPanicToLogFile(...)

Test plan

  • gofmt -l . and go vet ./... clean
  • go test ./... — 531 pass
  • New tests green under -race: TestDosPathRunesRejectsUnusableLengths,
    TestInitRoutesPanicToLogFile (subprocess, asserts the trace lands in the
    file)
  • Cross-compile loop GOOS=linux|darwin|windows — go build + go vet clean
  • biome check, vitest run (501 pass), tsc && vite build — untouched by
    this change, run for the gate
  • ci:os — the backend suite on a real Windows runner
  • Manual: run on Windows, open a session, confirm it survives past the point
    it used to disappear

🤖 Generated with Claude Code

Tracking a session's working directory walks the child process's PEB and reads
DosPath.Length out of that process's memory. x/sys documents the length as
"should always be even"; a 32-bit child, or one exiting mid-walk, hands back
whatever happens to sit there. An odd length passed the `== 0` guard, sized the
buffer to zero, and panicked on `&buf[0]` — on the bare goroutine watchCwd runs
on, so it took the whole process with it. The window survived, still rendering
its terminal, with nothing behind it.

The crash left no evidence: the runtime prints a panic to stderr, the
windowsgui build has no console, and slog never sees it — lich.log simply
stopped mid-session. Init now points the runtime's crash output at the same
file, so the next one is readable.

The length check is a pure helper outside the build-tagged file, following the
chromium candidate lists, so the guard is tested on every OS.
@omartelo omartelo added the ci:os Runs the backend suite on Windows and macOS runners label Jul 28, 2026
SetCrashOutput keeps a duplicate of the log file's handle for as long as the
registration stands. Windows refuses to remove a file another handle still
holds, so TempDir's cleanup failed in the two tests that let Init open a file.
The clear is registered after TempDir so the LIFO order runs it first.
@omartelo
omartelo merged commit 60dad5c into main Jul 28, 2026
4 checks passed
@omartelo
omartelo deleted the fix/windows-silent-crash branch July 28, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:os Runs the backend suite on Windows and macOS runners

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant