Skip to content

fix: replace os.Exit with graceful shutdown in mail watch#269

Open
luojiyin1987 wants to merge 5 commits intolarksuite:mainfrom
luojiyin1987:fix/remove-os-exit-mail-watch
Open

fix: replace os.Exit with graceful shutdown in mail watch#269
luojiyin1987 wants to merge 5 commits intolarksuite:mainfrom
luojiyin1987:fix/remove-os-exit-mail-watch

Conversation

@luojiyin1987
Copy link
Copy Markdown

@luojiyin1987 luojiyin1987 commented Apr 5, 2026

Summary

  • Remove os.Exit(0) from signal handler in mail watch command
  • Implement graceful shutdown using context.WithCancel and channel signaling
  • Move signal.Stop(sigCh) to defer for proper resource cleanup

Problem

The os.Exit(0) call in the signal handler:

  • Makes the code untestable (process terminates immediately)
  • Bypasses deferred cleanup functions in the call stack
  • Is not idiomatic for Go CLI tools

Solution

Use a shutdownBySignal channel to coordinate shutdown between the signal handler and the main goroutine. The signal handler now:

  1. Closes the shutdownBySignal channel
  2. Calls cancelWatch() to cancel the WebSocket context

The main goroutine checks shutdownBySignal to distinguish between signal-triggered shutdown and actual connection errors.

Test plan

  • Code review
  • Manual testing of mail watch command with Ctrl+C
  • Add unit test for shutdown behavior (future work)

Closes #268

Summary by CodeRabbit

  • Improvements
    • Mailbox watch now performs coordinated, cancellable shutdown: interrupt signals trigger graceful termination that cancels the watch and switches the client to a cancellable mode.
    • Unsubscribe is now idempotent and emits a single status message to avoid duplicate logs.
    • Event counting and shutdown reporting are made reliable.
    • Spurious error reports are suppressed when shutdown is user-initiated or cancelled.

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.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 5, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added domain/mail PR touches the mail domain size/M Single-domain feat or fix with limited business impact labels Apr 5, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6e9cd0d-94d3-434a-90f3-d76ee114ed91

📥 Commits

Reviewing files that changed from the base of the PR and between e4656a4 and 8ea53f9.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go

📝 Walkthrough

Walkthrough

Mailbox watch shutdown now uses a cancellable context and coordinated signal handling: a watchCtx from context.WithCancel, a shutdownBySignal channel, deferred signal.Stop(sigCh), idempotent unsubscribe via sync.Once, atomic event counting, and cli.Start run with watchCtx with adjusted error suppression.

Changes

Cohort / File(s) Summary
Mail watch shutdown
shortcuts/mail/mail_watch.go
Replaced immediate os.Exit(0) with context-based shutdown: create watchCtx := context.WithCancel(ctx), defer signal.Stop(sigCh), signal handler closes shutdownBySignal and calls cancel, pass watchCtx to cli.Start, suppress returning errors when shutdown was signal-driven or context.Canceled, make unsubscribe idempotent via sync.Once, switch event counter to int64 with atomic ops and read with atomic.LoadInt64.

Sequence Diagram(s)

sequenceDiagram
  participant OS as OS Signal
  participant Sig as Signal Handler
  participant Main as Main Goroutine
  participant CLI as cli.Start
  participant WS as WebSocket / Unsubscribe

  OS->>Sig: SIGINT / SIGTERM
  Sig->>Sig: close(shutdownBySignal)
  Sig->>Main: call cancelWatch() / cancel(watchCtx)
  Note right of Main: watchCtx cancelled (context.WithCancel)
  Main->>CLI: cli.Start(watchCtx)
  CLI->>WS: maintain connection / handle messages
  CLI-->>Main: returns (err or nil)
  Main->>WS: deferred unsubscribeWithLog() (sync.Once)
  alt shutdownBySignal set or err == context.Canceled
    Main-->>Main: return nil
  else
    Main-->>Main: return output.ErrNetwork(...)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • infeng

Poem

"I watched the signals softly bend,
No sudden end — just graceful send.
I hopped, I logged, I closed the door,
Canceled gently, tidy core.
A rabbit's bounce — shutdown restored." 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: replacing os.Exit with graceful shutdown in the mail watch command.
Description check ✅ Passed The PR description includes all required template sections: Summary (with clear problem and solution), Changes (as part of summary), and Test Plan (with checkboxes and issue reference).
Linked Issues check ✅ Passed The PR fully implements all requirements from issue #268: removes os.Exit, implements graceful shutdown with context.WithCancel and shutdownBySignal channel, and defers signal.Stop for cleanup.
Out of Scope Changes check ✅ Passed All changes in mail_watch.go are directly related to implementing graceful shutdown as required by issue #268; no out-of-scope modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 5, 2026

Greptile Summary

This PR removes os.Exit(0) from the signal handler in mail watch, replacing it with a coordinated graceful shutdown using context.WithCancel and a shutdownBySignal channel. It also fixes a data race on eventCount with sync/atomic and makes unsubscribeWithLog idempotent via sync.Once registered as a defer.

The previously flagged race (cancel before close) is correctly resolved: close(shutdownBySignal) now fires before cancelWatch(), all code paths unsubscribe via defer, and goroutine leak is prevented by the case <-watchCtx.Done() branch.

Confidence Score: 5/5

Safe to merge — shutdown logic is correct, channel/cancel ordering prevents the previously flagged race, all paths unsubscribe via defer

All remaining concerns are P2 or lower. The ordering fix (close channel before cancel) is correctly implemented, goroutine leak is handled via the watchCtx.Done case, and atomic eventCount fixes the original data race. Prior review concern is resolved.

No files require special attention

Important Files Changed

Filename Overview
shortcuts/mail/mail_watch.go Replaces os.Exit(0) with graceful shutdown via channel signaling and context cancellation; fixes eventCount data race with sync/atomic, adds idempotent unsubscription via sync.Once and defer

Sequence Diagram

sequenceDiagram
    participant User as User (Ctrl+C)
    participant Goroutine as Signal Goroutine
    participant Main as Main Goroutine
    participant WS as cli.Start (WebSocket)
    participant API as Unsubscribe API

    Main->>WS: cli.Start(watchCtx)
    Main->>Goroutine: spawn (listening for sigCh / watchCtx.Done)

    User->>Goroutine: SIGINT/SIGTERM
    Goroutine->>Goroutine: signal.Stop + signal.Reset
    Goroutine->>Main: close(shutdownBySignal)
    Goroutine->>WS: cancelWatch() - context cancelled
    WS-->>Main: return context.Canceled error
    Main->>Main: select shutdownBySignal → return nil
    Main->>API: defer unsubscribeWithLog() via sync.Once
    API-->>Main: unsubscribed
Loading

Reviews (5): Last reviewed commit: "fix: harden mail watch signal shutdown p..." | Re-trigger Greptile

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shortcuts/mail/mail_watch.go (1)

434-450: ⚠️ Potential issue | 🟠 Major

Publish shutdown before cleanup, and let the signal goroutine exit when the watch stops.

Line 441 runs unsubscribe() before cancelWatch()/close(shutdownBySignal), so Ctrl+C can keep cli.Start blocked on a slow unsubscribe and can race into ErrNetwork if the socket exits while cleanup is in flight. Separately, line 435 only waits on sigCh; on parent-context cancel or any other non-signal exit, signal.Stop(sigCh) does not close the channel, so this goroutine leaks indefinitely.

One way to structure it
 shutdownBySignal := make(chan struct{})
 go func() {
     defer func() {
         if r := recover(); r != nil {
             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.")
-    }
-    cancelWatch()
-    close(shutdownBySignal)
+    select {
+    case <-sigCh:
+        close(shutdownBySignal)
+        cancelWatch()
+    case <-watchCtx.Done():
+        return
+    }
 }()
 
 info("Connected. Waiting for mail events... (Ctrl+C to stop)")
 if err := cli.Start(watchCtx); err != nil {
     select {
     case <-shutdownBySignal:
+        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.")
+        }
         return nil
     default:
     }

Also applies to: lines 454-458

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 434 - 450, The signal-handler
goroutine can block on a slow unsubscribe and never exit on non-signal shutdown;
fix it by making the handler publish termination first and not block cli.Start:
on receiving from sigCh (or ctx.Done), call cancelWatch() and
close(shutdownBySignal) before calling unsubscribe(), then perform unsubscribe()
in a separate goroutine (or with a bounded timeout) so it cannot block the main
shutdown path; also make the goroutine select on both sigCh and the parent
context (or ctx.Done) so it will exit when the watch is cancelled, referencing
the symbols sigCh, shutdownBySignal, cancelWatch(), unsubscribe(), and
cli.Start/ErrNetwork in your changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 434-450: The signal-handler goroutine can block on a slow
unsubscribe and never exit on non-signal shutdown; fix it by making the handler
publish termination first and not block cli.Start: on receiving from sigCh (or
ctx.Done), call cancelWatch() and close(shutdownBySignal) before calling
unsubscribe(), then perform unsubscribe() in a separate goroutine (or with a
bounded timeout) so it cannot block the main shutdown path; also make the
goroutine select on both sigCh and the parent context (or ctx.Done) so it will
exit when the watch is cancelled, referencing the symbols sigCh,
shutdownBySignal, cancelWatch(), unsubscribe(), and cli.Start/ErrNetwork in your
changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 28c6021c-938a-450b-91c0-50bd5a6c8723

📥 Commits

Reviewing files that changed from the base of the PR and between 0c77c95 and 7e45bca.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go

@luojiyin1987
Copy link
Copy Markdown
Author

Addressed.

Updated the signal-handler ordering in mail_watch.go to close shutdownBySignal before calling cancelWatch(), so signal-triggered shutdown deterministically hits the shutdown channel branch first.

Commit: 9c93401

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shortcuts/mail/mail_watch.go (1)

429-450: ⚠️ Potential issue | 🟠 Major

Don't block shutdown behind unsubscribe() or leave the signal goroutine parked.

Line 444 does network I/O before Lines 449-450 publish the shutdown marker and cancel the websocket, so Ctrl+C can hang on unsubscribe() and cli.Start() can still report a connection error before the signal path is visible. Also, signal.Stop on Line 429 does not close sigCh, so this goroutine never exits on non-signal paths.

Suggested shape
 		shutdownBySignal := make(chan struct{})
 		go func() {
 			defer func() {
 				if r := recover(); r != nil {
 					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.")
-			}
-			close(shutdownBySignal)
-			cancelWatch()
+			select {
+			case <-sigCh:
+				info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
+				close(shutdownBySignal)
+				cancelWatch()
+				info("Unsubscribing mailbox events...")
+				if unsubErr := unsubscribe(); unsubErr != nil {
+					fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
+				} else {
+					info("Mailbox unsubscribed.")
+				}
+			case <-watchCtx.Done():
+				return
+			}
 		}()

If you need to wait for unsubscribe() before returning, do that after cli.Start() unwinds or under a bounded timeout rather than before cancellation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 429 - 450, The signal handler
currently performs network I/O by calling unsubscribe() before closing
shutdownBySignal and cancelWatch(), and it never reliably exits because
signal.Stop(sigCh) does not close sigCh; change the handler so that on receiving
from sigCh you immediately log and then close shutdownBySignal and call
cancelWatch() (so cli.Start() and websocket teardown are not blocked), then
launch a separate goroutine to perform unsubscribe() with a bounded
timeout/context and log its result; ensure the spawned unsubscribe goroutine
does not block the signal handler and that the signal handler returns (so the
goroutine can exit on non-signal paths), and keep using signal.Stop(sigCh) but
do not rely on sigCh being closed to terminate the goroutine.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 429-450: The signal handler currently performs network I/O by
calling unsubscribe() before closing shutdownBySignal and cancelWatch(), and it
never reliably exits because signal.Stop(sigCh) does not close sigCh; change the
handler so that on receiving from sigCh you immediately log and then close
shutdownBySignal and call cancelWatch() (so cli.Start() and websocket teardown
are not blocked), then launch a separate goroutine to perform unsubscribe() with
a bounded timeout/context and log its result; ensure the spawned unsubscribe
goroutine does not block the signal handler and that the signal handler returns
(so the goroutine can exit on non-signal paths), and keep using
signal.Stop(sigCh) but do not rely on sigCh being closed to terminate the
goroutine.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6228aff1-b905-4072-992c-6f8c845d89cf

📥 Commits

Reviewing files that changed from the base of the PR and between 7e45bca and 9c93401.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go

- 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
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shortcuts/mail/mail_watch.go (1)

475-476: ⚠️ Potential issue | 🟡 Minor

Missing unsubscribe when cli.Start returns without error.

If cli.Start returns nil (e.g., server-initiated clean disconnect), the function exits without unsubscribing. While this is likely an edge case for a long-running WebSocket watch, consider whether unsubscribe should occur on all exit paths for consistency.

🛠️ Possible fix
 		}
+		// Clean exit without error - still unsubscribe for completeness
+		info("Unsubscribing mailbox events...")
+		if unsubErr := unsubscribe(); unsubErr != nil {
+			fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
+		} else {
+			info("Mailbox unsubscribed.")
+		}
 		return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 475 - 476, The code currently
exits without calling unsubscribe when cli.Start returns nil; ensure unsubscribe
is invoked on every exit path by moving the unsubscribe call into a deferred or
finally-style cleanup that runs after the call to cli.Start, so that regardless
of whether cli.Start returns an error or nil the unsubscribe routine is
executed; specifically update the function that calls cli.Start to call
unsubscribe (the existing unsubscribe function/closure) in a defer immediately
after successful subscription (before calling cli.Start) or in a single cleanup
block after cli.Start returns, so both error and clean-disconnect returns
trigger unsubscribe.
🧹 Nitpick comments (1)
shortcuts/mail/mail_watch.go (1)

453-472: Consider extracting duplicate unsubscribe logic.

The unsubscribe pattern at lines 455-460 and 466-470 is duplicated. This could be consolidated for maintainability.

♻️ Suggested refactor to reduce duplication
 		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
-			}
+			isSignalShutdown := false
+			select {
+			case <-shutdownBySignal:
+				isSignalShutdown = true
+			default:
+			}
+			if isSignalShutdown || 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)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 453 - 472, The unsubscribe logic
is duplicated; extract it into a single helper and call it from both branches.
Create a small function (e.g., performUnsubscribe or handleUnsubscribe) that
captures necessary closures/vars (unsubscribe, info, errOut) and performs the
fmt.Fprintf on error or info on success, and then replace the repeated blocks in
the select case and the errors.Is(err, context.Canceled) branch with a call to
that helper and return nil. Ensure the helper lives in the same scope so it can
access unsubscribe, info and errOut without changing signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 475-476: The code currently exits without calling unsubscribe when
cli.Start returns nil; ensure unsubscribe is invoked on every exit path by
moving the unsubscribe call into a deferred or finally-style cleanup that runs
after the call to cli.Start, so that regardless of whether cli.Start returns an
error or nil the unsubscribe routine is executed; specifically update the
function that calls cli.Start to call unsubscribe (the existing unsubscribe
function/closure) in a defer immediately after successful subscription (before
calling cli.Start) or in a single cleanup block after cli.Start returns, so both
error and clean-disconnect returns trigger unsubscribe.

---

Nitpick comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 453-472: The unsubscribe logic is duplicated; extract it into a
single helper and call it from both branches. Create a small function (e.g.,
performUnsubscribe or handleUnsubscribe) that captures necessary closures/vars
(unsubscribe, info, errOut) and performs the fmt.Fprintf on error or info on
success, and then replace the repeated blocks in the select case and the
errors.Is(err, context.Canceled) branch with a call to that helper and return
nil. Ensure the helper lives in the same scope so it can access unsubscribe,
info and errOut without changing signatures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ca74fcd-8009-4599-bf32-501dd2e77070

📥 Commits

Reviewing files that changed from the base of the PR and between 9c93401 and c5e7b5a.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go

- 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
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/mail/mail_watch.go`:
- Around line 262-273: The unsubscribe failure is currently only logged as a
warning by unsubscribeWithLog and the shutdown/signal handling code still
returns nil, so process exits 0 even if unsubscribe failed; change
unsubscribeWithLog to capture the unsubscribe error into a shared variable
(e.g., declare var unsubscribeErr error outside, call unsubscribeErr = err
inside the Do block when unsubscribe() fails) and have the deferred call
set/return that error via a named return or by wrapping the shutdown path: on
graceful-exit paths (the signal/cancel shutdown code that currently returns
nil), check unsubscribeErr (or call unsubscribe synchronously and capture its
error) and return that error instead of nil so unsubscribe failures propagate;
reference unsubscribeWithLog, unsubscribeLogOnce and the unsubscribe() call when
making these changes.
- Around line 452-456: The signal handler reads eventCount while handleEvent()
increments it, causing a data race; protect both sites with the same
synchronization primitive (e.g., add a sync.Mutex named eventCountMu or use
atomic.Int64) and use it whenever mutating or reading eventCount: lock/unlock
around the increment inside handleEvent() and lock/unlock (or atomic load)
around the read in the goroutine that selects on sigCh before logging and
closing shutdownBySignal; ensure the mutex/atomic is declared at the same scope
as eventCount so both code paths reference the same guard.
- Around line 440-456: The signal goroutine (reading from sigCh) currently
consumes the first interrupt but leaves signal.Notify active and doesn't restore
default behavior, so additional Ctrl+C presses are queued/dropped; update the
handler in the goroutine that handles sigCh so that when it receives the first
signal it (1) calls signal.Stop(sigCh) and signal.Reset(os.Interrupt,
syscall.SIGTERM) (importing os and syscall) to restore default handling, then
performs the existing shutdown steps (info(...), close(shutdownBySignal),
cancelWatch()); this ensures a second Ctrl+C will invoke the default terminate
behavior if shutdown blocks; keep using sigCh, shutdownBySignal and cancelWatch
names to locate the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 354ed9b8-9a8a-4989-97af-f5779534c8a1

📥 Commits

Reviewing files that changed from the base of the PR and between c5e7b5a and e4656a4.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_watch.go

Comment on lines +262 to +273
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Propagate unsubscribe failures on the graceful-exit paths.

Lines 262-273 downgrade unsubscribe failures to a warning, and Lines 463-470 still return nil for signal/cancel shutdown. That means this command exits 0 even when the subscription it created earlier was not cleaned up successfully.

Also applies to: 463-470

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_watch.go` around lines 262 - 273, The unsubscribe failure
is currently only logged as a warning by unsubscribeWithLog and the
shutdown/signal handling code still returns nil, so process exits 0 even if
unsubscribe failed; change unsubscribeWithLog to capture the unsubscribe error
into a shared variable (e.g., declare var unsubscribeErr error outside, call
unsubscribeErr = err inside the Do block when unsubscribe() fails) and have the
deferred call set/return that error via a named return or by wrapping the
shutdown path: on graceful-exit paths (the signal/cancel shutdown code that
currently returns nil), check unsubscribeErr (or call unsubscribe synchronously
and capture its error) and return that error instead of nil so unsubscribe
failures propagate; reference unsubscribeWithLog, unsubscribeLogOnce and the
unsubscribe() call when making these changes.

@luojiyin1987
Copy link
Copy Markdown
Author

Addressed in 8ea53f9.

Implemented the two accepted points from this review:

  1. Fixed the eventCount data race by switching to atomic increment/load.
  2. Restored default signal behavior after first signal (signal.Stop(sigCh) + signal.Reset(os.Interrupt, syscall.SIGTERM)) so a second Ctrl+C can force terminate if shutdown gets stuck.

The unsubscribe-exit-code behavior is intentionally unchanged in this commit.

cookier pushed a commit to cookier/feishu-cli that referenced this pull request Apr 6, 2026
Replace os.Exit(0) in the signal handler of `mail +watch` with
context-based graceful shutdown using context.WithCancel and a
shutdown channel.

The os.Exit(0) call had several problems:
- Bypasses deferred cleanup functions in the call stack
- Makes the code untestable (process terminates immediately)
- Not idiomatic for Go CLI tools

Changes:
- Use context.WithCancel to create a cancellable watch context
- Signal handler now cancels the context and closes shutdownCh
- cli.Start uses watchCtx instead of the parent context
- Distinguish between signal-triggered shutdown and real errors
  via select on shutdownCh

Closes larksuite#269

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/mail PR touches the mail domain size/M Single-domain feat or fix with limited business impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: remove os.Exit from mail watch signal handler

2 participants