detached 588fe2c

fix(web): recover a panic in the event watch goroutine

net/http only recovers the handler goroutine; a panic in the watch now ends
the stream with a 500 and a log line instead of the process.
commit
588fe2c3caa3bed3a8983e3ab70b8b625fef316f
author
brice · · 2026-10-12 06:08 UTC
parent
2bedec2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +31 -0
M internal/adapters/web/events.go +12 -0
@@ -5,6 +5,7 @@ package web
55 import (
66 "context"
77 "errors"
8+ "fmt"
89 "io"
910 "net/http"
1011 "net/url"
@@ -128,6 +129,17 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
128129 go func() {
129130 defer close(finished)
130131 defer close(msgs)
132+ // net/http only recovers the handler goroutine: a panic in the watch
133+ // would end the process. It ends this stream as an error instead.
134+ defer func() {
135+ if v := recover(); v != nil {
136+ log.Error().Interface("panic", v).Msg("event watch panicked")
137+ select {
138+ case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}:
139+ case <-ctx.Done():
140+ }
141+ }
142+ }()
131143 err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {
132144 start()
133145 select {
@@ -5,6 +5,7 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "fmt"
8 "io"9 "io"
9 "net/http"10 "net/http"
10 "net/url"11 "net/url"
@@ -128,6 +129,17 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
128 go func() {129 go func() {
129 defer close(finished)130 defer close(finished)
130 defer close(msgs)131 defer close(msgs)
132+ // net/http only recovers the handler goroutine: a panic in the watch
133+ // would end the process. It ends this stream as an error instead.
134+ defer func() {
135+ if v := recover(); v != nil {
136+ log.Error().Interface("panic", v).Msg("event watch panicked")
137+ select {
138+ case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}:
139+ case <-ctx.Done():
140+ }
141+ }
142+ }()
131 err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {143 err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {
132 start()144 start()
133 select {145 select {
M internal/adapters/web/events_test.go +19 -0
@@ -233,6 +233,25 @@ func TestEventsRefusals(t *testing.T) {
233233 }
234234 }
235235
236+// A panic in the watch runs outside the handler goroutine, where net/http
237+// would not recover it: it ends the stream with a 500 instead of the process.
238+func TestEventsWatchPanicIsAnError(t *testing.T) {
239+ t.Parallel()
240+ m := mocks.NewEventService(t)
241+ m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
242+ RunAndReturn(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error {
243+ panic("boom")
244+ })
245+ ts := httptest.NewServer(newTestServer(t, Config{
246+ Repos: reposServing(t, publicRepo(t, "x")),
247+ Browse: mocks.NewBrowseService(t),
248+ Events: m,
249+ }))
250+ t.Cleanup(ts.Close)
251+ resp, _ := open(t, ts, "topic=home", nil)
252+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
253+}
254+
236255 // The topic of a repository the visitor cannot see is refused before any
237256 // watch is opened: a private repository is a missing one.
238257 func TestEventsPrivateRepositoryTopic(t *testing.T) {
@@ -233,6 +233,25 @@ func TestEventsRefusals(t *testing.T) {
233 }233 }
234 }234 }
235 235
236+// A panic in the watch runs outside the handler goroutine, where net/http
237+// would not recover it: it ends the stream with a 500 instead of the process.
238+func TestEventsWatchPanicIsAnError(t *testing.T) {
239+ t.Parallel()
240+ m := mocks.NewEventService(t)
241+ m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
242+ RunAndReturn(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error {
243+ panic("boom")
244+ })
245+ ts := httptest.NewServer(newTestServer(t, Config{
246+ Repos: reposServing(t, publicRepo(t, "x")),
247+ Browse: mocks.NewBrowseService(t),
248+ Events: m,
249+ }))
250+ t.Cleanup(ts.Close)
251+ resp, _ := open(t, ts, "topic=home", nil)
252+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
253+}
254+
236 // The topic of a repository the visitor cannot see is refused before any255 // The topic of a repository the visitor cannot see is refused before any
237 // watch is opened: a private repository is a missing one.256 // watch is opened: a private repository is a missing one.
238 func TestEventsPrivateRepositoryTopic(t *testing.T) {257 func TestEventsPrivateRepositoryTopic(t *testing.T) {

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 4

Tags 45

↑↓ moveenter openesc close