From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 17:43:29 +0000 (-0300) Subject: Pass caller contexts through WhatsApp callbacks X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/3b2bf881d484ba1a33ff77a342564b8195a75c80?ds=inline Pass caller contexts through WhatsApp callbacks Library callbacks use the context of WhatsApp.Run, so daemon cancellation ends name lookups and image downloads. Each image keeps its own deadline without the shorter name lookup deadline. Tests use their caller context. --- diff --git a/daemon_log_test.go b/daemon_log_test.go index 31a3278..b3a0fa4 100644 --- a/daemon_log_test.go +++ b/daemon_log_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "errors" "io" "log/slog" @@ -124,7 +123,7 @@ func TestDaemonLogReturnsWriteFailure(t *testing.T) { mutex: &sync.Mutex{}, } - err := handler.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "ready", 0)) + err := handler.Handle(t.Context(), slog.NewRecord(time.Now(), slog.LevelInfo, "ready", 0)) require.ErrorIs(t, err, io.ErrClosedPipe) require.ErrorContains(t, err, "write daemon log") } diff --git a/whatsapp.go b/whatsapp.go index f225756..be1acc6 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -107,6 +107,7 @@ type WhatsAppClient interface { // the client of a device with its event handler. func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *slog.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) { w := &WhatsApp{ + ctx: ctx, dir: filepath.Join(dir, whatsappDirName), clock: clock, logger: logger, @@ -169,6 +170,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // creation, close, removal, and snapshots. It is held before mutex, and stays // held when removal releases mutex for client callbacks. // +// ctx is the caller context until Run sets its context for library callbacks. +// The mutex protects it, so callbacks follow daemon cancellation. +// // The inbox puts messages into batches and logs them, and groups holds the // names of the groups. Both get a WhatsAppLink from link for each call, so they // use the current device and client. @@ -181,6 +185,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // log, and those of private chats hold phone numbers, because the agent needs // them to send. type WhatsApp struct { + ctx context.Context dir string clock func() time.Time logger *slog.Logger @@ -224,6 +229,7 @@ type WhatsApp struct { func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error { w.mutex.Lock() + w.ctx = ctx w.notify = notify linked := w.linked w.retrying = linked @@ -900,9 +906,14 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) { // connected again with the length of the outage. The retries of the library and // Run between them log nothing. func (w *WhatsApp) handle(event any) bool { + w.mutex.Lock() + + ctx := w.ctx + w.mutex.Unlock() + switch event := event.(type) { case *events.Message: - return w.inbox.receive(w.link(), event) + return w.inbox.receive(ctx, w.link(), event) case *events.GroupInfo: if event.Name != nil { w.groups.set(event.JID.String(), event.Name.Name) @@ -987,7 +998,7 @@ func (w *WhatsApp) handle(event any) bool { w.mutex.Unlock() if line != "" { - w.logger.Log(context.Background(), level, line, attrs...) + w.logger.Log(ctx, level, line, attrs...) } if notice != "" { diff --git a/whatsapp_image_test.go b/whatsapp_image_test.go index f00bc3a..919d656 100644 --- a/whatsapp_image_test.go +++ b/whatsapp_image_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/require" @@ -103,6 +104,36 @@ func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) { require.Equal(t, data, saved) } +func TestWhatsAppRunCancellationEndsAnImageDownload(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.downloadWait = time.Hour + test.start(t) + synctest.Wait() + + acknowledged := make(chan bool, 1) + go func() { + acknowledged <- test.fake.Emit(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")) + }() + + synctest.Wait() + + select { + case <-acknowledged: + t.Fatal("download ended before daemon cancellation") + default: + } + + test.cancel() + require.True(t, <-acknowledged) + require.NoError(t, <-test.done) + require.Empty(t, test.fake.Downloads()) + require.Contains(t, test.logs.String(), "context canceled") + require.NoError(t, test.whatsapp.state.CloseAll()) + require.Equal(t, "(image not downloaded)\nKeep this caption.", test.whatsapp.state.Closed()[0].Messages[0].Text) + }) +} + func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) { dir := t.TempDir() whatsappTestSession(t, dir, true) diff --git a/whatsapp_inbox.go b/whatsapp_inbox.go index 5121976..06f1c55 100644 --- a/whatsapp_inbox.go +++ b/whatsapp_inbox.go @@ -228,17 +228,17 @@ func (i *WhatsAppInbox) startCatchup() { // message, so that the library does not acknowledge it, and Run stops the // daemon. An image that cannot be downloaded or stored gets a text marker. A // message that the batches do not take is handled with nothing to do. -func (i *WhatsAppInbox) receive(link WhatsAppLink, event *events.Message) bool { - ctx, cancel := context.WithTimeout(context.Background(), i.lookupTime) +func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *events.Message) bool { + lookupCtx, cancel := context.WithTimeout(ctx, i.lookupTime) defer cancel() - chat, message, found := whatsappMessage(ctx, link.device, link.own, event) + chat, message, found := whatsappMessage(lookupCtx, link.device, link.own, event) if !found { return true } if event.Message.GetImageMessage() != nil { - ctx, cancel := context.WithTimeout(context.Background(), i.imageTime) + ctx, cancel := context.WithTimeout(ctx, i.imageTime) defer cancel() message.Text = i.imageText(ctx, link, event, message.Text)