Problem
The CLI has no top-level signal handler. When a user presses ctrl+c (SIGINT), Go's default handler calls os.Exit() immediately, skipping all deferred functions in ghcmd.Main().
This means:
defer telemetryService.Flush() never runs, so telemetry is never sent for commands exited via ctrl+c
- Connection cleanup defers in codespace commands are skipped
Affected commands
Paged commands (likely high volume)
Any command that uses a pager (less, etc.) where the user exits with ctrl+c instead of q:
gh pr list, gh issue list, gh repo list, etc.
gh pr view, gh issue view, gh pr diff, etc.
- Any command with paged output
When ctrl+c is pressed while a pager is active, SIGINT is delivered to the entire process group. The pager handles it gracefully, but gh gets killed by Go's default handler before telemetry can flush.
Long-running commands (ctrl+c is the expected exit)
gh cs ports forward - blocks forever, ctrl+c is the only exit
gh cs ssh - interactive session, ctrl+c to exit
gh cs logs - streaming logs, ctrl+c to stop
gh cs jupyter - forwards port and opens browser, ctrl+c to stop
These commands also skip connection cleanup defers (safeClose(fwd), listen.Close(), safeClose(invoker)).
Other
gh run download - creates temp zip files with deferred Close() + Remove(), interrupted downloads can leave orphaned temp files
Practical impact
- Telemetry: commands exited via ctrl+c report zero telemetry. For paged commands this could be a significant fraction of invocations. For long-running commands like
cs ports forward, telemetry is never sent since ctrl+c is the only exit.
- Resource cleanup: low impact since the OS reclaims sockets/FDs on process exit, but temp files can accumulate
- Correctness:
ghcmd.Main() returns an exitCode that is passed to os.Exit() in main.go - this exit code path is also bypassed
Note on alternate screen buffer
There is an existing SIGINT handler in iostreams.go:376-384 for the alternate screen buffer, but it calls os.Exit(1) directly, which also skips defers. This would need to be coordinated with any top-level signal handling.
Suggested fix
Use signal.NotifyContext to intercept SIGINT so that Main() can return normally and defers execute:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
However, this only helps commands that actually honor context cancellation in their blocking calls (e.g. select on <-ctx.Done()). Commands blocked on raw I/O, pagerProcess.Wait(), or other non-context-aware operations would still hang after context cancellation, preventing Main() from returning. A more complete solution would likely need to combine signal.NotifyContext with auditing downstream blocking calls to ensure they are cancellable, or use a fallback that forces exit after a short grace period.
References
internal/ghcmd/cmd.go:125 - defer telemetryService.Flush()
internal/ghcmd/cmd.go:137 - ctx := context.Background()
cmd/gh/main.go:10-12 - os.Exit(int(ghcmd.Main())), no signal handling
pkg/iostreams/iostreams.go:376-384 - alternate screen buffer SIGINT handler
Problem
The CLI has no top-level signal handler. When a user presses ctrl+c (SIGINT), Go's default handler calls
os.Exit()immediately, skipping all deferred functions inghcmd.Main().This means:
defer telemetryService.Flush()never runs, so telemetry is never sent for commands exited via ctrl+cAffected commands
Paged commands (likely high volume)
Any command that uses a pager (
less, etc.) where the user exits with ctrl+c instead ofq:gh pr list,gh issue list,gh repo list, etc.gh pr view,gh issue view,gh pr diff, etc.When ctrl+c is pressed while a pager is active, SIGINT is delivered to the entire process group. The pager handles it gracefully, but
ghgets killed by Go's default handler before telemetry can flush.Long-running commands (ctrl+c is the expected exit)
gh cs ports forward- blocks forever, ctrl+c is the only exitgh cs ssh- interactive session, ctrl+c to exitgh cs logs- streaming logs, ctrl+c to stopgh cs jupyter- forwards port and opens browser, ctrl+c to stopThese commands also skip connection cleanup defers (
safeClose(fwd),listen.Close(),safeClose(invoker)).Other
gh run download- creates temp zip files with deferredClose()+Remove(), interrupted downloads can leave orphaned temp filesPractical impact
cs ports forward, telemetry is never sent since ctrl+c is the only exit.ghcmd.Main()returns anexitCodethat is passed toos.Exit()inmain.go- this exit code path is also bypassedNote on alternate screen buffer
There is an existing SIGINT handler in
iostreams.go:376-384for the alternate screen buffer, but it callsos.Exit(1)directly, which also skips defers. This would need to be coordinated with any top-level signal handling.Suggested fix
Use
signal.NotifyContextto intercept SIGINT so thatMain()can return normally and defers execute:However, this only helps commands that actually honor context cancellation in their blocking calls (e.g.
selecton<-ctx.Done()). Commands blocked on raw I/O,pagerProcess.Wait(), or other non-context-aware operations would still hang after context cancellation, preventingMain()from returning. A more complete solution would likely need to combinesignal.NotifyContextwith auditing downstream blocking calls to ensure they are cancellable, or use a fallback that forces exit after a short grace period.References
internal/ghcmd/cmd.go:125-defer telemetryService.Flush()internal/ghcmd/cmd.go:137-ctx := context.Background()cmd/gh/main.go:10-12-os.Exit(int(ghcmd.Main())), no signal handlingpkg/iostreams/iostreams.go:376-384- alternate screen buffer SIGINT handler