diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f2ea65d1..4fc20d0f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **lich no longer vanishes mid-session on Windows.** Tracking a session's
+ working directory reads it out of the child process's own memory, and a length
+ that memory reported odd — which a 32-bit child, or one exiting mid-read, does
+ — crashed the whole app. The window stayed on screen still showing its
+ terminal, so the only sign was that nothing ever updated again and reloading
+ found nothing listening. The read now refuses a length it cannot use. A crash
+ that does happen is also written to `lich.log` now: it used to go to a console
+ the Windows build does not have, leaving the log to simply stop with no trace
+ of why.
- **A session no longer offers to resume a conversation that is gone.** Reopening
a session lich had run Claude Code in asked whether to continue that
conversation — and answering yes could drop you into a terminal showing
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index bbbe78c27..e118ef2e4 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -12,6 +12,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "runtime/debug"
"strings"
)
@@ -22,10 +23,11 @@ const maxLogSize = 5 << 20
// Init points slog's default logger at stderr plus
/lich.log
// (lich-dev.log under LICH_DEV, mirroring the store's database split), with
-// the level taken from LICH_LOG_LEVEL (debug|warn|error; default info). The
-// returned closer owns the file. When the file cannot be opened, logging
-// still works on stderr alone: the closer is nil and the error says why the
-// persistent half is missing — the caller reports it and carries on.
+// the level taken from LICH_LOG_LEVEL (debug|warn|error; default info), and
+// routes the runtime's crash output to the same file. The returned closer owns
+// the file. When the file cannot be opened, logging still works on stderr
+// alone: the closer is nil and the error says why the persistent half is
+// missing — the caller reports it and carries on.
func Init(dir string) (io.Closer, error) {
file, err := openLogFile(dir)
out := io.Writer(os.Stderr)
@@ -42,6 +44,14 @@ func Init(dir string) (io.Closer, error) {
AddSource: true,
Level: parseLevel(os.Getenv("LICH_LOG_LEVEL")),
})))
+ if file != nil {
+ // A panic bypasses slog entirely: the runtime prints it to stderr, which
+ // the windowsgui build does not have. Without this the log simply stops
+ // mid-session and the crash leaves no trace anywhere.
+ if err := debug.SetCrashOutput(file, debug.CrashOptions{}); err != nil {
+ slog.Warn("crash output unavailable — a panic will not reach the log", "err", err)
+ }
+ }
return closer, err
}
diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go
index 1eb7abc59..4c97e575d 100644
--- a/internal/logging/logging_test.go
+++ b/internal/logging/logging_test.go
@@ -3,7 +3,9 @@ package logging
import (
"log/slog"
"os"
+ "os/exec"
"path/filepath"
+ "runtime/debug"
"strings"
"testing"
)
@@ -16,6 +18,19 @@ func restoreDefault(t *testing.T) {
t.Cleanup(func() { slog.SetDefault(prev) })
}
+// logDir is a temp dir for a test that lets Init succeed. Init points the
+// runtime's crash output at the log file, and that keeps a duplicate of the
+// file's handle open for as long as the registration stands — on Windows a
+// file another handle still holds cannot be removed, so TempDir's cleanup
+// fails unless the registration is dropped first. Registered after TempDir on
+// purpose: cleanups run last-in-first-out, so this one runs before the removal.
+func logDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Cleanup(func() { _ = debug.SetCrashOutput(nil, debug.CrashOptions{}) })
+ return dir
+}
+
// TestParseLevel proves the LICH_LOG_LEVEL values map onto slog levels and
// that anything unrecognized falls back to Info.
func TestParseLevel(t *testing.T) {
@@ -54,7 +69,7 @@ func TestInitWritesRecordWithSource(t *testing.T) {
restoreDefault(t)
t.Setenv("LICH_DEV", "")
t.Setenv("LICH_LOG_LEVEL", "")
- dir := t.TempDir()
+ dir := logDir(t)
closer, err := Init(dir)
if err != nil {
@@ -80,7 +95,7 @@ func TestInitWritesRecordWithSource(t *testing.T) {
func TestInitRotatesOversizedLog(t *testing.T) {
restoreDefault(t)
t.Setenv("LICH_DEV", "")
- dir := t.TempDir()
+ dir := logDir(t)
path := filepath.Join(dir, "lich.log")
if err := os.WriteFile(path, []byte("old generation\n"), 0o600); err != nil {
t.Fatal(err)
@@ -146,3 +161,37 @@ func TestInitSurvivesUnwritableDir(t *testing.T) {
}
slog.Info("must not panic")
}
+
+// crashProbeEnv hands the child half of TestInitRoutesPanicToLogFile the
+// directory to log into; empty means this process is the parent.
+const crashProbeEnv = "LICH_TEST_CRASH_DIR"
+
+// TestInitRoutesPanicToLogFile proves an unhandled panic reaches the log file
+// rather than only the stderr a windowsgui process does not have. Runs the
+// crash in a subprocess, because a panic ends the process it happens in.
+func TestInitRoutesPanicToLogFile(t *testing.T) {
+ if dir := os.Getenv(crashProbeEnv); dir != "" {
+ if _, err := Init(dir); err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ panic("crash probe")
+ }
+
+ dir := t.TempDir()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestInitRoutesPanicToLogFile$")
+ cmd.Env = append(os.Environ(), crashProbeEnv+"="+dir, "LICH_DEV=")
+ if err := cmd.Run(); err == nil {
+ t.Fatal("child exited cleanly, want the panic to kill it")
+ }
+
+ content, err := os.ReadFile(filepath.Join(dir, "lich.log"))
+ if err != nil {
+ t.Fatalf("read log: %v", err)
+ }
+ // The message and a stack: a trace without the panic value names no cause.
+ for _, want := range []string{"crash probe", "goroutine "} {
+ if !strings.Contains(string(content), want) {
+ t.Errorf("panic did not reach the log file, missing %q:\n%s", want, content)
+ }
+ }
+}
diff --git a/internal/terminal/cwd.go b/internal/terminal/cwd.go
index 1c9307e3f..3372cdd52 100644
--- a/internal/terminal/cwd.go
+++ b/internal/terminal/cwd.go
@@ -24,6 +24,20 @@ type cwdEvent struct {
// only on change, so a static directory costs nothing to re-render.
const cwdPollInterval = 300 * time.Millisecond
+// dosPathRunes is how many UTF-16 code units a DosPath of n bytes holds, and 0
+// when n is not a length a UTF-16 buffer can have. n comes out of the child's
+// own memory (the Windows processCwd walks its PEB), where x/sys documents it
+// as "should always be even" — a 32-bit child, or one exiting mid-walk, hands
+// back whatever happens to sit there. An odd n would size the buffer to zero
+// and take the read path straight into a panic, on a bare goroutine that ends
+// the process. Kept out of the build-tagged file so the guard tests on any OS.
+func dosPathRunes(n uint16) int {
+ if n == 0 || n%2 != 0 {
+ return 0
+ }
+ return int(n / 2)
+}
+
// watchCwd reports the session child's working directory to the frontend
// whenever it changes, until done closes. Each platform brings its own read
// (see the cwd_* files); on one without any, and when the PTY reports no
diff --git a/internal/terminal/cwd_track_test.go b/internal/terminal/cwd_track_test.go
index 279a590ae..f51152442 100644
--- a/internal/terminal/cwd_track_test.go
+++ b/internal/terminal/cwd_track_test.go
@@ -38,6 +38,30 @@ func waitEmit(t *testing.T, emits <-chan string) string {
}
}
+// TestDosPathRunesRejectsUnusableLengths proves a DosPath byte length that no
+// UTF-16 buffer can have is refused outright rather than sized down to an
+// empty buffer — the Windows walk reads into &buf[0], so a zero-length buffer
+// there panics and, on its bare goroutine, takes the whole app with it.
+func TestDosPathRunesRejectsUnusableLengths(t *testing.T) {
+ cases := []struct {
+ name string
+ in uint16
+ want int
+ }{
+ {"empty", 0, 0},
+ {"odd", 1, 0},
+ {"odd garbage", 4097, 0},
+ {"one code unit", 2, 1},
+ {"C:\\", 6, 3},
+ {"max even", 65534, 32767},
+ }
+ for _, tc := range cases {
+ if got := dosPathRunes(tc.in); got != tc.want {
+ t.Errorf("%s: dosPathRunes(%d) = %d, want %d", tc.name, tc.in, got, tc.want)
+ }
+ }
+}
+
// TestProcessCwdReadsSelf proves the platform read resolves a live process's
// working directory — exercised against the test process itself.
func TestProcessCwdReadsSelf(t *testing.T) {
diff --git a/internal/terminal/cwd_windows.go b/internal/terminal/cwd_windows.go
index 9c939a76e..fb9bbaa7f 100644
--- a/internal/terminal/cwd_windows.go
+++ b/internal/terminal/cwd_windows.go
@@ -52,10 +52,11 @@ func processCwd(pid int) string {
}
dos := params.CurrentDirectory.DosPath
- if dos.Length == 0 || dos.Buffer == nil {
+ runes := dosPathRunes(dos.Length)
+ if runes == 0 || dos.Buffer == nil {
return ""
}
- buf := make([]uint16, dos.Length/2)
+ buf := make([]uint16, runes)
if err := readMemory(h, uintptr(unsafe.Pointer(dos.Buffer)),
unsafe.Pointer(&buf[0]), uintptr(dos.Length)); err != nil {
return ""