Skip to content

Commit 50693ed

Browse files
committed
desktop: record how the previous run ended, and which webview loaded
Two additions to the Linux startup log, both passive — no behaviour change, no new UI, nothing the user has to opt into. A desktop app cannot log its own segfault, so a crash report has no way to say whether the last session died or was simply quit, and the journal shows the same thing either way: a process that stopped. A marker file carrying the running PID closes that gap. Still present at the next start, naming a process that is gone, means the previous run crashed or was force-quit. The PID is what makes it trustworthy. A marker naming a live process means a second instance is open, not that anything crashed, and a shutdown only clears a marker it owns — otherwise quitting one window would erase another's, and every second instance would be reported as a crash. Also log the webview library actually mapped into the process. Which rendering bugs apply depends on the WebKitGTK build, and the package manager's answer is not necessarily the library that got loaded. The lookup already exists for the diagnostics snapshot; this exposes it early enough for the startup log, so it lands in the journal excerpt users paste into bug reports. Claude-Session: https://claude.ai/code/session_01KEyZgyPtfpXdbWqPPsTXZe
1 parent 939b85a commit 50693ed

10 files changed

Lines changed: 304 additions & 1 deletion

File tree

cmd/desktop/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func (a *DesktopApp) beforeClose(ctx context.Context) bool {
118118
// shutdown is called when the application is shutting down.
119119
func (a *DesktopApp) shutdown(ctx context.Context) {
120120
stopNativeMouseMonitor()
121+
markSessionEnd()
121122
log.Println("Desktop app shutting down...")
122123
app.Shutdown(a.srv)
123124
}

cmd/desktop/boot_env_linux.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import (
1818
// are also served in the diagnostics snapshot — see internal/desktopenv.
1919
func logBootEnv() {
2020
log.Printf("[desktop] session: %s", joinEnv(desktopenv.SessionKeys, true))
21+
// The webview build decides which rendering bugs apply, and the package
22+
// manager's answer is not necessarily the library that got loaded.
23+
if lib := desktopenv.WebviewLibrary(); lib != "" {
24+
log.Printf("[desktop] webview: %s", lib)
25+
}
2126
if s := joinEnv(desktopenv.OverrideKeys, false); s != "" {
2227
log.Printf("[desktop] render overrides: %s", s)
2328
}

cmd/desktop/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ func main() {
8787
// No-op on macOS/Windows.
8888
logBootEnv()
8989

90+
// Record this run and report how the last one ended. The app cannot log
91+
// its own crash, so without this a report cannot say whether the previous
92+
// session died or was quit.
93+
markSessionStart()
94+
9095
// Disable WebKit's DMABUF renderer on Linux unless the user opts in —
9196
// it produces blank windows on Wayland+KDE/NVIDIA and upstream won't fix.
9297
// Must run before Wails initializes WebKit.

cmd/desktop/process_unix.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
import (
6+
"errors"
7+
"os"
8+
"syscall"
9+
)
10+
11+
// processAlive reports whether pid names a running process. On Unix
12+
// os.FindProcess never fails, so liveness has to be probed with signal 0.
13+
// EPERM means the process exists but belongs to someone else.
14+
func processAlive(pid int) bool {
15+
proc, err := os.FindProcess(pid)
16+
if err != nil {
17+
return false
18+
}
19+
err = proc.Signal(syscall.Signal(0))
20+
return err == nil || errors.Is(err, syscall.EPERM)
21+
}

cmd/desktop/process_windows.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package main
2+
3+
import "os"
4+
5+
// processAlive reports whether pid names a running process. On Windows
6+
// os.FindProcess opens a real handle, so its error already answers the
7+
// question and signal probing is unavailable.
8+
func processAlive(pid int) bool {
9+
proc, err := os.FindProcess(pid)
10+
if err != nil {
11+
return false
12+
}
13+
_ = proc.Release()
14+
return true
15+
}

cmd/desktop/session_marker.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// The desktop app cannot log its own segfault, so a bug report has no way to
12+
// say whether the last run crashed or the user simply quit. A marker file
13+
// carrying the running PID closes that gap: still present at the next start,
14+
// naming a process that is gone, means the previous run died.
15+
func sessionMarkerPath() string {
16+
home, err := os.UserHomeDir()
17+
if err != nil {
18+
return ""
19+
}
20+
return filepath.Join(home, ".radar", "desktop-session")
21+
}
22+
23+
func markSessionStart() { claimSession(sessionMarkerPath()) }
24+
func markSessionEnd() { releaseSession(sessionMarkerPath()) }
25+
26+
// claimSession reports how the previous run ended and records this one. A
27+
// marker naming a live process means a second instance is running, which says
28+
// nothing about how the last one ended — reporting a crash there would be a
29+
// lie every time someone opens two windows.
30+
func claimSession(path string) {
31+
if path == "" {
32+
return
33+
}
34+
35+
if pid, ok := readSessionMarker(path); ok {
36+
switch {
37+
case pid == os.Getpid():
38+
// Same PID reused after an unclean exit; nothing to distinguish.
39+
case processAlive(pid):
40+
log.Printf("[desktop] another instance is already running (pid %d)", pid)
41+
default:
42+
log.Printf("[desktop] previous run (pid %d) did not exit cleanly — it crashed or was force-quit", pid)
43+
}
44+
}
45+
46+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
47+
log.Printf("[desktop] could not record session marker: %v", err)
48+
return
49+
}
50+
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil {
51+
log.Printf("[desktop] could not record session marker: %v", err)
52+
}
53+
}
54+
55+
// releaseSession clears the marker on a graceful shutdown so the next start
56+
// does not report a crash that never happened. A marker owned by a different
57+
// PID belongs to another instance and is left alone.
58+
func releaseSession(path string) {
59+
if path == "" {
60+
return
61+
}
62+
if pid, ok := readSessionMarker(path); ok && pid != os.Getpid() {
63+
return
64+
}
65+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
66+
log.Printf("[desktop] could not clear session marker: %v", err)
67+
}
68+
}
69+
70+
func readSessionMarker(path string) (int, bool) {
71+
data, err := os.ReadFile(path)
72+
if err != nil {
73+
return 0, false
74+
}
75+
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
76+
if err != nil || pid <= 0 {
77+
return 0, false
78+
}
79+
return pid, true
80+
}

cmd/desktop/session_marker_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"log"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"testing"
12+
)
13+
14+
func captureLog(t *testing.T, fn func()) string {
15+
t.Helper()
16+
var buf bytes.Buffer
17+
flags := log.Flags()
18+
log.SetOutput(&buf)
19+
log.SetFlags(0)
20+
t.Cleanup(func() {
21+
log.SetOutput(os.Stderr)
22+
log.SetFlags(flags)
23+
})
24+
fn()
25+
return buf.String()
26+
}
27+
28+
func markerPath(t *testing.T) string {
29+
t.Helper()
30+
return filepath.Join(t.TempDir(), ".radar", "desktop-session")
31+
}
32+
33+
// deadPID returns a PID that has certainly exited. Picking an arbitrary high
34+
// number risks colliding with a live process on a busy machine.
35+
func deadPID(t *testing.T) int {
36+
t.Helper()
37+
cmd := exec.Command("go", "version")
38+
if err := cmd.Start(); err != nil {
39+
t.Skipf("cannot spawn a throwaway process: %v", err)
40+
}
41+
pid := cmd.Process.Pid
42+
_ = cmd.Wait()
43+
return pid
44+
}
45+
46+
func writeMarker(t *testing.T, path string, pid int) {
47+
t.Helper()
48+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
49+
t.Fatalf("prepare marker dir: %v", err)
50+
}
51+
if err := os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644); err != nil {
52+
t.Fatalf("write marker: %v", err)
53+
}
54+
}
55+
56+
func TestClaimSessionReportsUncleanExit(t *testing.T) {
57+
path := markerPath(t)
58+
writeMarker(t, path, deadPID(t))
59+
60+
out := captureLog(t, func() { claimSession(path) })
61+
62+
if !strings.Contains(out, "did not exit cleanly") {
63+
t.Errorf("log = %q, want an unclean-exit report", out)
64+
}
65+
if pid, ok := readSessionMarker(path); !ok || pid != os.Getpid() {
66+
t.Errorf("marker pid = %d (ok=%v), want this process %d", pid, ok, os.Getpid())
67+
}
68+
}
69+
70+
// A live PID means a second window is open, not that anything crashed.
71+
// Reporting a crash here would fire every time someone runs two instances.
72+
func TestClaimSessionDoesNotBlameALiveInstance(t *testing.T) {
73+
path := markerPath(t)
74+
cmd := exec.Command("sleep", "30")
75+
if err := cmd.Start(); err != nil {
76+
t.Skipf("cannot spawn a helper process: %v", err)
77+
}
78+
t.Cleanup(func() {
79+
_ = cmd.Process.Kill()
80+
_ = cmd.Wait()
81+
})
82+
writeMarker(t, path, cmd.Process.Pid)
83+
84+
out := captureLog(t, func() { claimSession(path) })
85+
86+
if strings.Contains(out, "did not exit cleanly") {
87+
t.Errorf("log = %q, want no crash claim while another instance runs", out)
88+
}
89+
if !strings.Contains(out, "another instance") {
90+
t.Errorf("log = %q, want the concurrent instance reported", out)
91+
}
92+
}
93+
94+
func TestClaimSessionSilentOnFirstRun(t *testing.T) {
95+
path := markerPath(t)
96+
97+
out := captureLog(t, func() { claimSession(path) })
98+
99+
if out != "" {
100+
t.Errorf("log = %q, want silence when no previous run is recorded", out)
101+
}
102+
if _, ok := readSessionMarker(path); !ok {
103+
t.Error("first run did not record a marker")
104+
}
105+
}
106+
107+
// A graceful shutdown must leave nothing behind, or every clean quit is
108+
// reported as a crash on the next launch.
109+
func TestReleaseSessionClearsOwnMarker(t *testing.T) {
110+
path := markerPath(t)
111+
claimSession(path)
112+
113+
releaseSession(path)
114+
115+
if _, err := os.Stat(path); !os.IsNotExist(err) {
116+
t.Errorf("marker still present after shutdown (err=%v)", err)
117+
}
118+
out := captureLog(t, func() { claimSession(path) })
119+
if strings.Contains(out, "did not exit cleanly") {
120+
t.Errorf("log = %q, want a clean shutdown to leave no crash report", out)
121+
}
122+
}
123+
124+
// Quitting one window must not clear a marker another instance owns.
125+
func TestReleaseSessionLeavesAnotherInstanceMarker(t *testing.T) {
126+
path := markerPath(t)
127+
other := deadPID(t)
128+
writeMarker(t, path, other)
129+
130+
releaseSession(path)
131+
132+
pid, ok := readSessionMarker(path)
133+
if !ok || pid != other {
134+
t.Errorf("marker pid = %d (ok=%v), want %d left untouched", pid, ok, other)
135+
}
136+
}
137+
138+
func TestReadSessionMarkerRejectsGarbage(t *testing.T) {
139+
for _, content := range []string{"", " ", "not-a-pid", "-1", "0"} {
140+
path := markerPath(t)
141+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
142+
t.Fatalf("prepare marker dir: %v", err)
143+
}
144+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
145+
t.Fatalf("write marker: %v", err)
146+
}
147+
if pid, ok := readSessionMarker(path); ok {
148+
t.Errorf("readSessionMarker(%q) = %d, true; want not ok", content, pid)
149+
}
150+
}
151+
}
152+
153+
func TestSessionHelpersTolerateNoHomeDirectory(t *testing.T) {
154+
// sessionMarkerPath returns "" when the home directory is unknown; the
155+
// helpers must degrade to doing nothing rather than panicking.
156+
claimSession("")
157+
releaseSession("")
158+
}
159+
160+
func TestProcessAliveOnSelf(t *testing.T) {
161+
if !processAlive(os.Getpid()) {
162+
t.Error("processAlive(self) = false, want true")
163+
}
164+
if processAlive(deadPID(t)) {
165+
t.Error("processAlive(exited process) = true, want false")
166+
}
167+
}

internal/desktopenv/desktopenv.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,8 @@ func GPUPolicy() string {
6262
// Collect returns the current environment, or nil on platforms where none of
6363
// this applies.
6464
func Collect() *Snapshot { return collect() }
65+
66+
// WebviewLibrary returns the webview library mapped into this process, or ""
67+
// where that cannot be determined. Exposed separately from Collect so startup
68+
// logging can name the build before anything else has run.
69+
func WebviewLibrary() string { return webviewLibrary() }

internal/desktopenv/desktopenv_linux.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func collect() *Snapshot {
1616
DisplayServer: displayServer(),
1717
RenderOverrides: readAll(OverrideKeys),
1818
Sandbox: readSet(SandboxKeys),
19-
WebKitLibrary: webKitLibrary("/proc/self/maps"),
19+
WebKitLibrary: webviewLibrary(),
2020
GPUPolicy: GPUPolicy(),
2121
}
2222
return s
@@ -56,6 +56,8 @@ func readSet(keys []string) []EnvVar {
5656
return out
5757
}
5858

59+
func webviewLibrary() string { return webKitLibrary("/proc/self/maps") }
60+
5961
// webKitLibrary returns the basename of the webview library mapped into this
6062
// process. The soname's trailing version identifies the WebKitGTK build, which
6163
// a bug report otherwise has no way to state. The prefix is matched loosely

internal/desktopenv/desktopenv_other.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ package desktopenv
55
// collect reports nothing off Linux. macOS (WKWebView) and Windows (WebView2)
66
// have no equivalent set of host render knobs to surface.
77
func collect() *Snapshot { return nil }
8+
9+
func webviewLibrary() string { return "" }

0 commit comments

Comments
 (0)