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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 89 | 89 | _ = file.Close() | |
| 90 | 90 | return nil, nil, err | |
| 91 | 91 | } | |
| 92 | - | pruneRuns(dir) | |
| 93 | 92 | // 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 { | |
| 96 | 98 | _ = runtimedebug.SetCrashOutput(crash, runtimedebug.CrashOptions{}) | |
| 97 | 99 | _ = crash.Close() // SetCrashOutput keeps its own duplicate | |
| 100 | + | } else { | |
| 101 | + | crashPath = "" | |
| 98 | 102 | } | |
| 103 | + | pruneRuns(dir) | |
| 99 | 104 | // Hide Close from the diode: asyncWriter closes the file once. | |
| 100 | 105 | var sink io.Writer = struct{ io.Writer }{file} | |
| 101 | 106 | if term.IsTerminal(int(os.Stderr.Fd())) { | |
| 102 | 107 | sink = &consoleAndFile{file: file} | |
| 103 | 108 | } | |
| 104 | - | w := newAsyncWriter(sink, file) | |
| 109 | + | w := newAsyncWriter(sink, file, crashPath) | |
| 105 | 110 | ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: level, Format: "json", Output: w})) | |
| 106 | 111 | ctx = context.WithValue(ctx, debugKey{}, debugSet(selected)) | |
| 107 | 112 | ctx = context.WithValue(ctx, levelKey{}, level) | |
| @@ -116,20 +121,27 @@ const logQueue = 8192 | |||
| 116 | 121 | ||
| 117 | 122 | // newAsyncWriter writes entries to w from its own goroutine. Close drains | |
| 118 | 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 | 125 | d := diode.NewWriter(w, logQueue, 0, func(missed int) { | |
| 121 | 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 | 131 | type asyncWriter struct { | |
| 127 | 132 | diode.Writer | |
| 128 | - | closer io.Closer | |
| 133 | + | closer io.Closer | |
| 134 | + | crashPath string | |
| 129 | 135 | } | |
| 130 | 136 | ||
| 131 | 137 | func (a asyncWriter) Close() error { | |
| 132 | 138 | _ = 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 | 145 | return a.closer.Close() | |
| 134 | 146 | } | |
| 135 | 147 | ||
| @@ -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, err | 90 | return nil, nil, err | ||
| 91 | } | 91 | } | ||
| 92 | - | pruneRuns(dir) | |||
| 93 | // Queued entries are lost on a fatal crash: the runtime's own report | 92 | // 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 duplicate | 99 | _ = 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 drains | 122 | // 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.Writer | 132 | diode.Writer | ||
| 128 | - | closer io.Closer | 133 | + | 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 queue | 138 | _ = 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) { | |||
| 72 | 72 | if len(runs) != keepRuns { | |
| 73 | 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 | 81 | func splitLines(data []byte) [][]byte { | |
| @@ -143,7 +147,7 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) { | |||
| 143 | 147 | t.Fatal(err) | |
| 144 | 148 | } | |
| 145 | 149 | defer r.Close() | |
| 146 | - | w := newAsyncWriter(pw, pw) | |
| 150 | + | w := newAsyncWriter(pw, pw, "") | |
| 147 | 151 | line := bytes.Repeat([]byte{'x'}, 1024) | |
| 148 | 152 | done := make(chan struct{}) | |
| 149 | 153 | 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() { | ||