Skip to content

Commit b683f6f

Browse files
committed
Fix goroutine leaks, panic recovery, and zombie process
1 parent 90726f8 commit b683f6f

4 files changed

Lines changed: 157 additions & 34 deletions

File tree

cmd/event-service/di/service.go

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package di
22

33
import (
44
"context"
5+
"os"
6+
"time"
57

68
"github.com/boreq/errors"
79
"github.com/hashicorp/go-multierror"
10+
"github.com/planetary-social/nos-event-service/internal/goroutine"
811
"github.com/planetary-social/nos-event-service/internal/logging"
912
"github.com/planetary-social/nos-event-service/internal/migrations"
1013
"github.com/planetary-social/nos-event-service/service/adapters/sqlite"
@@ -72,6 +75,8 @@ func (s Service) ExecuteMigrations(ctx context.Context) error {
7275
return s.migrationsRunner.Run(ctx, s.migrations, s.migrationsProgressCallback)
7376
}
7477

78+
const shutdownTimeout = 30 * time.Second
79+
7580
func (s Service) Run(ctx context.Context) error {
7681
ctx, cancel := context.WithCancel(ctx)
7782
defer cancel()
@@ -81,66 +86,83 @@ func (s Service) Run(ctx context.Context) error {
8186

8287
runners++
8388
// Serve http
84-
go func() {
85-
errCh <- errors.Wrap(s.server.ListenAndServe(ctx), "server error")
86-
}()
89+
goroutine.Run(s.logger, errCh, func() error {
90+
return errors.Wrap(s.server.ListenAndServe(ctx), "server error")
91+
})
8792

8893
// Fetch events from the database relays and send them to the in memory pubsub
8994
runners++
90-
go func() {
91-
errCh <- errors.Wrap(s.downloader.Run(ctx), "downloader error")
92-
}()
95+
goroutine.Run(s.logger, errCh, func() error {
96+
return errors.Wrap(s.downloader.Run(ctx), "downloader error")
97+
})
9398

9499
// Subscribe to the in memory pubsub of events, emit a NewSaveReceivedEvent
95100
// command that will make some checks on the event, save it if the check
96101
// passes and emit a EventSavedEvent to the sqlite pubsub.
97102
runners++
98-
go func() {
99-
errCh <- errors.Wrap(s.receivedEventSubscriber.Run(ctx), "received event subscriber error")
100-
}()
103+
goroutine.Run(s.logger, errCh, func() error {
104+
return errors.Wrap(s.receivedEventSubscriber.Run(ctx), "received event subscriber error")
105+
})
101106

102107
// Subscribe to saved events in the database. This uses the sqlite pubsub. This triggers:
103108
// - analysis to extract new relays and store them in db. They will be used by the downloader.
104109
// - analysis to store pubkeys and store them in the db (contacts_followees, pubkeys, contacts_events). This will be used by the downloader.
105110
// - publish to watermill pubsub
106111
// - may publish the event in wss://relay.nos.social if they are metadata related
107112
runners++
108-
go func() {
109-
errCh <- errors.Wrap(s.eventSavedEventSubscriber.Run(ctx), "event saved subscriber error")
110-
}()
113+
goroutine.Run(s.logger, errCh, func() error {
114+
return errors.Wrap(s.eventSavedEventSubscriber.Run(ctx), "event saved subscriber error")
115+
})
111116

112117
// The metrics timer collects metrics from the app.
113118
runners++
114-
go func() {
115-
errCh <- errors.Wrap(s.metricsTimer.Run(ctx), "metrics timer error")
116-
}()
119+
goroutine.Run(s.logger, errCh, func() error {
120+
return errors.Wrap(s.metricsTimer.Run(ctx), "metrics timer error")
121+
})
117122

118123
// Sqlite transaction runner
119124
runners++
120-
go func() {
121-
errCh <- errors.Wrap(s.transactionRunner.Run(ctx), "transaction runner error")
122-
}()
125+
goroutine.Run(s.logger, errCh, func() error {
126+
return errors.Wrap(s.transactionRunner.Run(ctx), "transaction runner error")
127+
})
123128

124129
// The task scheduler creates sequential time window based tasks that
125130
// contain filters to be applied to each relay to fetch the events we want.
126131
// Event downloaders subscribe to this.
127132
runners++
128-
go func() {
129-
errCh <- errors.Wrap(s.taskScheduler.Run(ctx), "task scheduler error")
130-
}()
133+
goroutine.Run(s.logger, errCh, func() error {
134+
return errors.Wrap(s.taskScheduler.Run(ctx), "task scheduler error")
135+
})
131136

132137
// The cleanup timer periodically removes processed events older than retention period
133138
runners++
134-
go func() {
135-
errCh <- errors.Wrap(s.cleanupTimer.Run(ctx), "cleanup timer error")
136-
}()
139+
goroutine.Run(s.logger, errCh, func() error {
140+
return errors.Wrap(s.cleanupTimer.Run(ctx), "cleanup timer error")
141+
})
142+
143+
// Wait for the first runner to exit.
144+
firstErr := <-errCh
145+
s.logger.Error().WithError(firstErr).Message("first runner terminated, shutting down")
146+
cancel()
137147

148+
// Collect remaining errors with a timeout to prevent zombie processes.
138149
var compoundErr error
139-
for i := 0; i < runners; i++ {
140-
err := errors.Wrap(<-errCh, "error returned by runner")
141-
s.logger.Error().WithError(err).Message("runner terminated")
142-
compoundErr = multierror.Append(compoundErr, err)
143-
cancel()
150+
compoundErr = multierror.Append(compoundErr, errors.Wrap(firstErr, "error returned by runner"))
151+
152+
timer := time.NewTimer(shutdownTimeout)
153+
defer timer.Stop()
154+
155+
for i := 1; i < runners; i++ {
156+
select {
157+
case err := <-errCh:
158+
err = errors.Wrap(err, "error returned by runner")
159+
s.logger.Error().WithError(err).Message("runner terminated")
160+
compoundErr = multierror.Append(compoundErr, err)
161+
case <-timer.C:
162+
s.logger.Error().Message("shutdown timeout exceeded, forcing exit")
163+
os.Exit(1)
164+
}
144165
}
166+
145167
return compoundErr
146168
}

internal/goroutine/goroutine.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package goroutine
2+
3+
import (
4+
"fmt"
5+
"runtime/debug"
6+
7+
"github.com/planetary-social/nos-event-service/internal/logging"
8+
)
9+
10+
// Run starts a goroutine that executes fn and sends its return value to errCh.
11+
// If fn panics, the panic is recovered, logged with a full stack trace, and
12+
// converted to an error sent to errCh. This prevents goroutines from silently
13+
// dying and leaving the service in a zombie state.
14+
func Run(logger logging.Logger, errCh chan<- error, fn func() error) {
15+
go func() {
16+
defer func() {
17+
if r := recover(); r != nil {
18+
stack := string(debug.Stack())
19+
err := fmt.Errorf("goroutine panicked: %v\n%s", r, stack)
20+
logger.Error().WithError(err).Message("recovered from panic in goroutine")
21+
errCh <- err
22+
}
23+
}()
24+
25+
errCh <- fn()
26+
}()
27+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package goroutine_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/boreq/errors"
8+
"github.com/planetary-social/nos-event-service/internal/goroutine"
9+
"github.com/planetary-social/nos-event-service/internal/logging"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestRun_PropagatesError(t *testing.T) {
15+
errCh := make(chan error, 1)
16+
expectedErr := errors.New("test error")
17+
18+
goroutine.Run(logging.NewDevNullLogger(), errCh, func() error {
19+
return expectedErr
20+
})
21+
22+
err := <-errCh
23+
assert.ErrorIs(t, err, expectedErr)
24+
}
25+
26+
func TestRun_RecoversPanic(t *testing.T) {
27+
errCh := make(chan error, 1)
28+
29+
goroutine.Run(logging.NewDevNullLogger(), errCh, func() error {
30+
panic("something went wrong")
31+
})
32+
33+
err := <-errCh
34+
require.Error(t, err)
35+
assert.Contains(t, err.Error(), "goroutine panicked: something went wrong")
36+
assert.Contains(t, err.Error(), "goroutine.go")
37+
}
38+
39+
func TestRun_RecoversPanicWithError(t *testing.T) {
40+
errCh := make(chan error, 1)
41+
42+
goroutine.Run(logging.NewDevNullLogger(), errCh, func() error {
43+
panic(errors.New("panic error"))
44+
})
45+
46+
err := <-errCh
47+
require.Error(t, err)
48+
assert.True(t, strings.Contains(err.Error(), "panic error"))
49+
}
50+
51+
func TestRun_SuccessfulCompletion(t *testing.T) {
52+
errCh := make(chan error, 1)
53+
54+
goroutine.Run(logging.NewDevNullLogger(), errCh, func() error {
55+
return nil
56+
})
57+
58+
err := <-errCh
59+
assert.NoError(t, err)
60+
}

service/domain/relays/relay_connection.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func (r *RelayConnection) GetEvents(ctx context.Context, filter domain.Filter) (
150150
// Context is cancelled from a task.CheckIfDoneAndEnd() triggered from EOSE messages
151151
<-ctx.Done()
152152
if err := r.removeSubscriptionChannel(ch); err != nil {
153-
panic(err)
153+
r.logger.Error().WithError(err).Message("error removing subscription channel")
154154
}
155155
}()
156156

@@ -176,7 +176,7 @@ func (r *RelayConnection) SendEvent(ctx context.Context, event domain.Event) err
176176

177177
defer func() {
178178
if err := r.removeEventChannel(event, ch); err != nil {
179-
panic(err)
179+
r.logger.Error().WithError(err).Message("error removing event channel")
180180
}
181181
}()
182182

@@ -303,12 +303,18 @@ func (r *RelayConnection) run(ctx context.Context) error {
303303
r.setState(RelayConnectionStateConnected)
304304
r.logger.Trace().Message("connected")
305305

306+
var wg sync.WaitGroup
307+
308+
wg.Add(1)
306309
go func() {
310+
defer wg.Done()
307311
<-ctx.Done()
308312
_ = conn.Close()
309313
}()
310314

315+
wg.Add(1)
311316
go func() {
317+
defer wg.Done()
312318
if err := r.manageSubs(ctx, conn); err != nil {
313319
if !r.writeErrorShouldNotBeLogged(err) {
314320
r.logger.Error().
@@ -318,7 +324,9 @@ func (r *RelayConnection) run(ctx context.Context) error {
318324
}
319325
}()
320326

327+
wg.Add(1)
321328
go func() {
329+
defer wg.Done()
322330
// Loops through newEventsCh and sends events to the relay
323331
if err := r.sendEvents(ctx, conn); err != nil {
324332
if !r.writeErrorShouldNotBeLogged(err) {
@@ -329,15 +337,17 @@ func (r *RelayConnection) run(ctx context.Context) error {
329337
}
330338
}()
331339

340+
var readErr error
332341
for {
333342
messageBytes, err := conn.ReadMessage()
334343
if err != nil {
335-
return NewReadMessageError(err)
344+
readErr = NewReadMessageError(err)
345+
break
336346
}
337347

338348
if r.state == RelayConnectionStateBackPressured {
339-
// Load shedding under backpressure, we just disconnect
340-
return BackPressureError
349+
readErr = BackPressureError
350+
break
341351
}
342352

343353
if err := r.handleMessage(messageBytes); err != nil {
@@ -348,6 +358,10 @@ func (r *RelayConnection) run(ctx context.Context) error {
348358
Message("error handling an incoming message")
349359
}
350360
}
361+
362+
cancel()
363+
wg.Wait()
364+
return readErr
351365
}
352366

353367
func (r *RelayConnection) writeErrorShouldNotBeLogged(err error) bool {

0 commit comments

Comments
 (0)