Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions internal/logging/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime/debug"
"strings"
)

Expand All @@ -22,10 +23,11 @@ const maxLogSize = 5 << 20

// Init points slog's default logger at stderr plus <dir>/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)
Expand All @@ -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
}

Expand Down
53 changes: 51 additions & 2 deletions internal/logging/logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package logging
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strings"
"testing"
)
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
14 changes: 14 additions & 0 deletions internal/terminal/cwd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions internal/terminal/cwd_track_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions internal/terminal/cwd_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down