detached 591baeb

fix(logging): remove the crash file after a clean run

Close resets the runtime crash output and deletes the run's crash file
while it is still empty. The crash file is created before pruneRuns so
crash files rotate like logs.
commit
591baebaa3fa171abee3338db85fb7a0a3505a10
author
brice · · 2026-10-10 15:11 UTC
committer
brice · · 2026-10-10 15:39 UTC
parent
4d3f681
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +24 -8
M internal/adapters/logging/logging.go +19 -7
@@ -89,19 +89,24 @@ func Open(ctx context.Context, backend, level, debug string) (context.Context, f
8989 _ = file.Close()
9090 return nil, nil, err
9191 }
92- pruneRuns(dir)
9392 // Queued entries are lost on a fatal crash: the runtime's own report
94- // (panic and every stack) goes to its own file, besides stderr.
95- if crash, err := os.OpenFile(filepath.Join(dir, strings.TrimSuffix(name, ".log")+".crash"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600); err == nil {
93+ // (panic and every stack) goes to its own file, besides stderr. A clean
94+ // Close removes it while still empty. Created before pruneRuns so
95+ // crash files rotate like logs.
96+ crashPath := filepath.Join(dir, strings.TrimSuffix(name, ".log")+".crash")
97+ if crash, err := os.OpenFile(crashPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600); err == nil {
9698 _ = runtimedebug.SetCrashOutput(crash, runtimedebug.CrashOptions{})
9799 _ = crash.Close() // SetCrashOutput keeps its own duplicate
100+ } else {
101+ crashPath = ""
98102 }
103+ pruneRuns(dir)
99104 // Hide Close from the diode: asyncWriter closes the file once.
100105 var sink io.Writer = struct{ io.Writer }{file}
101106 if term.IsTerminal(int(os.Stderr.Fd())) {
102107 sink = &consoleAndFile{file: file}
103108 }
104- w := newAsyncWriter(sink, file)
109+ w := newAsyncWriter(sink, file, crashPath)
105110 ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: level, Format: "json", Output: w}))
106111 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
107112 ctx = context.WithValue(ctx, levelKey{}, level)
@@ -116,20 +121,27 @@ const logQueue = 8192
116121
117122 // newAsyncWriter writes entries to w from its own goroutine. Close drains
118123 // what is queued, then closes c.
119-func newAsyncWriter(w io.Writer, c io.Closer) io.WriteCloser {
124+func newAsyncWriter(w io.Writer, c io.Closer, crashPath string) io.WriteCloser {
120125 d := diode.NewWriter(w, logQueue, 0, func(missed int) {
121126 fmt.Fprintf(os.Stderr, "neferwl: %d log entries dropped: disk too slow\n", missed)
122127 })
123- return asyncWriter{Writer: d, closer: c}
128+ return asyncWriter{Writer: d, closer: c, crashPath: crashPath}
124129 }
125130
126131 type asyncWriter struct {
127132 diode.Writer
128- closer io.Closer
133+ closer io.Closer
134+ crashPath string
129135 }
130136
131137 func (a asyncWriter) Close() error {
132138 _ = a.Writer.Close() // drains the queue
139+ if a.crashPath != "" {
140+ _ = runtimedebug.SetCrashOutput(nil, runtimedebug.CrashOptions{})
141+ if fi, err := os.Stat(a.crashPath); err == nil && fi.Size() == 0 {
142+ _ = os.Remove(a.crashPath)
143+ }
144+ }
133145 return a.closer.Close()
134146 }
135147
@@ -89,19 +89,24 @@ func Open(ctx context.Context, backend, level, debug string) (context.Context, f
89 _ = file.Close()89 _ = file.Close()
90 return nil, nil, err90 return nil, nil, err
91 }91 }
92- pruneRuns(dir)
93 // Queued entries are lost on a fatal crash: the runtime's own report92 // Queued entries are lost on a fatal crash: the runtime's own report
94- // (panic and every stack) goes to its own file, besides stderr.93+ // (panic and every stack) goes to its own file, besides stderr. A clean
95- if crash, err := os.OpenFile(filepath.Join(dir, strings.TrimSuffix(name, ".log")+".crash"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600); err == nil {94+ // Close removes it while still empty. Created before pruneRuns so
95+ // crash files rotate like logs.
96+ crashPath := filepath.Join(dir, strings.TrimSuffix(name, ".log")+".crash")
97+ if crash, err := os.OpenFile(crashPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600); err == nil {
96 _ = runtimedebug.SetCrashOutput(crash, runtimedebug.CrashOptions{})98 _ = runtimedebug.SetCrashOutput(crash, runtimedebug.CrashOptions{})
97 _ = crash.Close() // SetCrashOutput keeps its own duplicate99 _ = crash.Close() // SetCrashOutput keeps its own duplicate
100+ } else {
101+ crashPath = ""
98 }102 }
103+ pruneRuns(dir)
99 // Hide Close from the diode: asyncWriter closes the file once.104 // Hide Close from the diode: asyncWriter closes the file once.
100 var sink io.Writer = struct{ io.Writer }{file}105 var sink io.Writer = struct{ io.Writer }{file}
101 if term.IsTerminal(int(os.Stderr.Fd())) {106 if term.IsTerminal(int(os.Stderr.Fd())) {
102 sink = &consoleAndFile{file: file}107 sink = &consoleAndFile{file: file}
103 }108 }
104- w := newAsyncWriter(sink, file)109+ w := newAsyncWriter(sink, file, crashPath)
105 ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: level, Format: "json", Output: w}))110 ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: level, Format: "json", Output: w}))
106 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))111 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
107 ctx = context.WithValue(ctx, levelKey{}, level)112 ctx = context.WithValue(ctx, levelKey{}, level)
@@ -116,20 +121,27 @@ const logQueue = 8192
116 121
117 // newAsyncWriter writes entries to w from its own goroutine. Close drains122 // newAsyncWriter writes entries to w from its own goroutine. Close drains
118 // what is queued, then closes c.123 // what is queued, then closes c.
119-func newAsyncWriter(w io.Writer, c io.Closer) io.WriteCloser {124+func newAsyncWriter(w io.Writer, c io.Closer, crashPath string) io.WriteCloser {
120 d := diode.NewWriter(w, logQueue, 0, func(missed int) {125 d := diode.NewWriter(w, logQueue, 0, func(missed int) {
121 fmt.Fprintf(os.Stderr, "neferwl: %d log entries dropped: disk too slow\n", missed)126 fmt.Fprintf(os.Stderr, "neferwl: %d log entries dropped: disk too slow\n", missed)
122 })127 })
123- return asyncWriter{Writer: d, closer: c}128+ return asyncWriter{Writer: d, closer: c, crashPath: crashPath}
124 }129 }
125 130
126 type asyncWriter struct {131 type asyncWriter struct {
127 diode.Writer132 diode.Writer
128- closer io.Closer133+ closer io.Closer
134+ crashPath string
129 }135 }
130 136
131 func (a asyncWriter) Close() error {137 func (a asyncWriter) Close() error {
132 _ = a.Writer.Close() // drains the queue138 _ = a.Writer.Close() // drains the queue
139+ if a.crashPath != "" {
140+ _ = runtimedebug.SetCrashOutput(nil, runtimedebug.CrashOptions{})
141+ if fi, err := os.Stat(a.crashPath); err == nil && fi.Size() == 0 {
142+ _ = os.Remove(a.crashPath)
143+ }
144+ }
133 return a.closer.Close()145 return a.closer.Close()
134 }146 }
135 147
M internal/adapters/logging/logging_test.go +5 -1
@@ -72,6 +72,10 @@ func TestComponentLevelsAndRotation(t *testing.T) {
7272 if len(runs) != keepRuns {
7373 t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
7474 }
75+ // A clean run leaves no empty crash file behind.
76+ if crashes, _ := filepath.Glob(filepath.Join(dir, "2*.crash")); len(crashes) != 0 {
77+ t.Fatalf("empty crash files kept: %v", crashes)
78+ }
7579 }
7680
7781 func splitLines(data []byte) [][]byte {
@@ -143,7 +147,7 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) {
143147 t.Fatal(err)
144148 }
145149 defer r.Close()
146- w := newAsyncWriter(pw, pw)
150+ w := newAsyncWriter(pw, pw, "")
147151 line := bytes.Repeat([]byte{'x'}, 1024)
148152 done := make(chan struct{})
149153 go func() {
@@ -72,6 +72,10 @@ func TestComponentLevelsAndRotation(t *testing.T) {
72 if len(runs) != keepRuns {72 if len(runs) != keepRuns {
73 t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)73 t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
74 }74 }
75+ // A clean run leaves no empty crash file behind.
76+ if crashes, _ := filepath.Glob(filepath.Join(dir, "2*.crash")); len(crashes) != 0 {
77+ t.Fatalf("empty crash files kept: %v", crashes)
78+ }
75 }79 }
76 80
77 func splitLines(data []byte) [][]byte {81 func splitLines(data []byte) [][]byte {
@@ -143,7 +147,7 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) {
143 t.Fatal(err)147 t.Fatal(err)
144 }148 }
145 defer r.Close()149 defer r.Close()
146- w := newAsyncWriter(pw, pw)150+ w := newAsyncWriter(pw, pw, "")
147 line := bytes.Repeat([]byte{'x'}, 1024)151 line := bytes.Repeat([]byte{'x'}, 1024)
148 done := make(chan struct{})152 done := make(chan struct{})
149 go func() {153 go func() {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close