Ctrl+C doesn't work — the process hangs and must be killed via Task Manager.
-
Signal handler set up too late — if
signal.Notifyis called after a blocking operation likegrpc.Connect()orstream.Recv(), Ctrl+C during that phase has no custom handler. -
stream.CloseSend()doesn't unblockRecv()—CloseSend()only closes the client's send side. The receive side keeps blocking, waiting for server messages. -
Manual signal channel + goroutine races with process exit — a goroutine that prints feedback and calls
cancel()may not execute before the process terminates. -
No force-quit on second Ctrl+C — if graceful shutdown stalls, the user is stuck.
// BEFORE (broken)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := client.Run(ctx, ...) // blocks — Ctrl+C not handled here!
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
_ = stream.CloseSend()
cancel()
}()// AFTER (works)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Force-quit on second Ctrl+C: stop() resets signal handling to default,
// so the next SIGINT terminates the process immediately.
go func() {
<-ctx.Done()
stop()
}()
stream, err := client.Run(ctx, ...) // Ctrl+C cancels ctx, Recv() unblockssignal.NotifyContextcreates a context that cancels immediately when SIGINT/SIGTERM arrives.- The gRPC stream is created with this context (
client.Connect(ctx)), so all blockingstream.Recv()calls unblock when the context is cancelled. - Signal handling is active before any blocking call, so Ctrl+C works at every stage.
// BEFORE (race — goroutine may not print before exit)
go func() {
<-ctx.Done()
log.Printf("Shutting down...") // might never print
stop()
}()
// AFTER (guaranteed to print)
go func() {
<-ctx.Done()
stop() // only reset signal handling here
}()
// In the main flow, after each blocking call:
result, err := client.SomeBlockingCall(ctx, ...)
if err != nil {
if ctx.Err() != nil {
log.Printf("Disconnecting...") // runs synchronously, always prints
return nil
}
return err
}-
signal.NotifyContextis called before any blocking I/O - The gRPC stream/connection is created with the cancellable
ctx - A background goroutine calls
stop()afterctx.Done()to enable force-quit - Every
ctx.Err()check prints a shutdown message before returning - No
os.Exit(0)— usereturn nilso defers run cleanly - No
stream.CloseSend()needed — context cancellation handles everything
import (
"context"
"os/signal"
"syscall"
)