From 7e45bcad6670155fd730c4ad7d542e76783dd177 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Apr 2026 17:19:44 +0800 Subject: [PATCH 1/5] fix: replace os.Exit with graceful shutdown in mail watch Remove os.Exit(0) from signal handler and implement proper shutdown coordination using context cancellation and channel signaling. This makes the code testable and allows proper cleanup of resources. --- shortcuts/mail/mail_watch.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index c5699427..d4fd47e5 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -426,6 +426,12 @@ var MailWatch = common.Shortcut{ sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + + shutdownBySignal := make(chan struct{}) go func() { defer func() { if r := recover(); r != nil { @@ -440,12 +446,21 @@ var MailWatch = common.Shortcut{ } else { info("Mailbox unsubscribed.") } - signal.Stop(sigCh) - os.Exit(0) + cancelWatch() + close(shutdownBySignal) }() info("Connected. Waiting for mail events... (Ctrl+C to stop)") - if err := cli.Start(ctx); err != nil { + if err := cli.Start(watchCtx); err != nil { + select { + case <-shutdownBySignal: + return nil + default: + } + if errors.Is(err, context.Canceled) { + unsubscribe() //nolint:errcheck // best-effort cleanup + return nil + } unsubscribe() //nolint:errcheck // best-effort cleanup return output.ErrNetwork("WebSocket connection failed: %v", err) } From 9c934018c620e703c66ced3e38f304ee6d25ff3a Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Apr 2026 17:44:06 +0800 Subject: [PATCH 2/5] fix: order signal shutdown markers in mail watch --- shortcuts/mail/mail_watch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index d4fd47e5..594cbf5b 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -446,8 +446,8 @@ var MailWatch = common.Shortcut{ } else { info("Mailbox unsubscribed.") } - cancelWatch() close(shutdownBySignal) + cancelWatch() }() info("Connected. Waiting for mail events... (Ctrl+C to stop)") From c5e7b5a3a8e48a08f724ae2afb7e93e246ce4a52 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Apr 2026 18:00:40 +0800 Subject: [PATCH 3/5] fix: improve signal handler to avoid blocking shutdown - Add select with watchCtx.Done() to handle non-signal exit paths - Move close(shutdownBySignal) and cancelWatch() before unsubscribe() - Execute unsubscribe() in main goroutine after cli.Start() returns This ensures: - Signal handler goroutine can exit on non-signal paths - Shutdown signal is published immediately without blocking on network I/O - No race condition between shutdownBySignal and context.Canceled --- shortcuts/mail/mail_watch.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index 594cbf5b..cc663509 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -438,27 +438,36 @@ var MailWatch = common.Shortcut{ fmt.Fprintf(errOut, "panic in signal handler: %v\n", r) } }() - <-sigCh - info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount)) - info("Unsubscribing mailbox events...") - if unsubErr := unsubscribe(); unsubErr != nil { - fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) - } else { - info("Mailbox unsubscribed.") + select { + case <-sigCh: + info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount)) + close(shutdownBySignal) + cancelWatch() + case <-watchCtx.Done(): + return } - close(shutdownBySignal) - cancelWatch() }() info("Connected. Waiting for mail events... (Ctrl+C to stop)") if err := cli.Start(watchCtx); err != nil { select { case <-shutdownBySignal: + info("Unsubscribing mailbox events...") + if unsubErr := unsubscribe(); unsubErr != nil { + fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) + } else { + info("Mailbox unsubscribed.") + } return nil default: } if errors.Is(err, context.Canceled) { - unsubscribe() //nolint:errcheck // best-effort cleanup + info("Unsubscribing mailbox events...") + if unsubErr := unsubscribe(); unsubErr != nil { + fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) + } else { + info("Mailbox unsubscribed.") + } return nil } unsubscribe() //nolint:errcheck // best-effort cleanup From e4656a4b3c2b185c84796908bcce37fa70863000 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Apr 2026 18:14:15 +0800 Subject: [PATCH 4/5] refactor: use defer + sync.Once for cleanup on all exit paths - Create unsubscribeWithLog() with sync.Once to ensure single execution - Use defer to guarantee cleanup on all exit paths - Remove duplicate unsubscribe logic from error handling branches This ensures unsubscribe runs exactly once regardless of how the function exits: - Normal exit - Early return (e.g., profile error) - Signal-triggered shutdown - Context cancellation - Network errors --- shortcuts/mail/mail_watch.go | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index cc663509..33c8fb13 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -259,13 +259,24 @@ var MailWatch = common.Shortcut{ }) return unsubErr } + var unsubscribeLogOnce sync.Once + unsubscribeWithLog := func() { + unsubscribeLogOnce.Do(func() { + info("Unsubscribing mailbox events...") + if err := unsubscribe(); err != nil { + fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", err) + } else { + info("Mailbox unsubscribed.") + } + }) + } + defer unsubscribeWithLog() // Resolve "me" to the actual email address so we can filter events. mailboxFilter := mailbox if mailbox == "me" { resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me") if profileErr != nil { - unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr return enhanceProfileError(profileErr) } mailboxFilter = resolved @@ -452,25 +463,12 @@ var MailWatch = common.Shortcut{ if err := cli.Start(watchCtx); err != nil { select { case <-shutdownBySignal: - info("Unsubscribing mailbox events...") - if unsubErr := unsubscribe(); unsubErr != nil { - fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) - } else { - info("Mailbox unsubscribed.") - } return nil default: } if errors.Is(err, context.Canceled) { - info("Unsubscribing mailbox events...") - if unsubErr := unsubscribe(); unsubErr != nil { - fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) - } else { - info("Mailbox unsubscribed.") - } return nil } - unsubscribe() //nolint:errcheck // best-effort cleanup return output.ErrNetwork("WebSocket connection failed: %v", err) } return nil From 8ea53f9ea0cc834ac3345b550728b33145d2266a Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Apr 2026 18:30:38 +0800 Subject: [PATCH 5/5] fix: harden mail watch signal shutdown path --- shortcuts/mail/mail_watch.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index 33c8fb13..0c1d84a2 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -18,6 +18,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "syscall" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -282,7 +283,7 @@ var MailWatch = common.Shortcut{ mailboxFilter = resolved } - eventCount := 0 + var eventCount int64 handleEvent := func(data map[string]interface{}) { // Extract event body @@ -348,7 +349,7 @@ var MailWatch = common.Shortcut{ } } - eventCount++ + atomic.AddInt64(&eventCount, 1) // Prompt injection detection: warn when email body contains known injection patterns. // Body fields may be base64url-encoded; decode before scanning. @@ -451,7 +452,10 @@ var MailWatch = common.Shortcut{ }() select { case <-sigCh: - info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount)) + // Restore default signal behavior so a second Ctrl+C can force terminate. + signal.Stop(sigCh) + signal.Reset(os.Interrupt, syscall.SIGTERM) + info(fmt.Sprintf("\nShutting down... (received %d events)", atomic.LoadInt64(&eventCount))) close(shutdownBySignal) cancelWatch() case <-watchCtx.Done():