Skip to content

Missing top-level signal handler causes defers to be skipped on ctrl+c #13401

@williammartin

Description

@williammartin

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcoreThis issue is not accepting PRs from outside contributorspriority-3Affects a small number of users or is largely cosmetic

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions