Implementation notes β signal-aware execution context¶
Spec: 2026-06-12-signal-aware-execution-context (now IMPLEMENTED).
What was implemented¶
pkg/cmd/root/execute.go¶
Executenow runs the command tree viarootCmd.ExecuteContext(ctx)under a cancellable context watchingos.Interruptandsyscall.SIGTERM(D1, O1 β no build tags; the SIGTERM registration is inert on Windows).- First signal cancels
cmd.Context()and logs a warning ("press again to force quit"). Second signal force-exits immediately with128+signum(D2/O4, kubectl/docker UX). - A signal-terminated run exits
128+signum(130 SIGINT, 143 SIGTERM) through the ErrorHandler's fatal path, not a parallelos.Exitcall site (D2/O2). - The signal watcher is fully injectable for tests (
executeOptions{signals, forceExit}), so no test raises real OS signals or kills the test process. - Telemetry flush (D4) now runs on every path via a
sync.Once-guarded flush: deferred for the non-exiting paths and invoked explicitly beforeErrorHandler.Check(..., LevelFatal)firesos.Exit. The flush keeps its bounded background context, so cancellation cannot abort the flush itself.
pkg/errorhandling β exit-code threading¶
- New
WithExitCode(err, code)/ExitCode(err)pair (exitcode.go). The attachment is transparent toerrors.Is/errors.As, survives wrapping, and the fatal path inlogErrornow callsh.Exit(ExitCode(err))(default remains1;0for nil). This is additive β no existing public API changed.
Generator (D3)¶
internal/generator/templates/skeleton_main.go: the scaffoldedmain.gonow carries a doc comment describing the signal contract. No structural change was needed β the skeleton already delegates togtbRoot.Execute, which is where the signal context lives, so every scaffolded tool (and every existing tool that rebuilds against this version) gets the behaviour automatically.- Regeneration verified:
just build && go run ./cmd/gtb generate project -n tmptool -r acme/tmptool -p tmp --overwrite allow --ciproduced amain.gowith the documentedgtbRoot.Executedelegation;tmp/deleted afterwards.
TUI coexistence (D5) β analysis, not code¶
Bubble Tea v2 (charm.land/bubbletea/[email protected], tea.go handleSignals) confirms:
- With a TTY, the terminal is in raw mode while a prompt is active, so Ctrl-C is a keystroke, not a signal β huh aborts the current prompt (
ErrUserAborted) and no SIGINT reaches the outer watcher. A stray Ctrl-C in a prompt aborts the prompt, not the run. This is exactly the resolved O3 semantic and required no code. - An external
kill -INT/kill -TERMis delivered to both Bubble Tea's notify channel and the outer watcher (Go fan-outs signals to allsignal.Notifysubscribers): the prompt aborts and the run cancels gracefully with exit 130/143. We judged this the correct semantic for supervisors (systemd, CI runners) β an external terminate should end the run, prompt or no prompt.
Update-temp cleanup (D4)¶
pkg/setup/update.go extractAndInstallBinary previously left the partial "<target>_" temp file behind whenever the copy failed β exactly what happens when SIGINT cancels the download mid-install. A deferred cleanup now removes it on every failure path; the successful rename path is untouched. Covered by pkg/setup/update_extract_test.go.
Docs¶
docs/components/commands/root.mdβ new "Signal Handling" section (lifecycle, exit codes, TUI note,cmd.Context()usage example).docs/concepts/architecture.mdβ new "Signal-aware Execution Lifecycle" workflow.docs/components/error-handling.mdβ new "Custom Exit Codes" section forWithExitCode/ExitCode.
Tests¶
pkg/cmd/root/execute_signal_test.goβ blocking-command cancellation (SIGINTβ130, SIGTERMβ143), second-signal force-exit, telemetry flush on the cancellation path (spy backend), flush-before-fatal-exit on the ordinary error path, success/ErrUpdateCompleteno-exit paths, and thesignalExitCodemapping table. All injectable β no real signals β so they aret.Parallel()-safe and race-clean.pkg/errorhandling/exitcode_test.goβWithExitCode/ExitCodesemantics and the fatal path honouring attached codes.internal/generator/templates/skeleton_main_test.goβ scaffoldedmain.godelegates togtbRoot.Executeand documents the contract.- Coverage on the changed surface:
execute95.2%, every other new/changed function 100%.
Deviations from the spec¶
signal.NotifyContextis not used literally. D1 namessignal.NotifyContext, but it cannot distinguish which signal fired (needed for128+signum) nor observe a second signal (needed for force-exit). The implementation uses the equivalent primitive composition βsignal.Notify+context.WithCancelβ with identical semantics plus the D2 requirements.signal.Stoprestores default disposition on return, equivalent todefer stop().- Skeleton template changed only in comments. D3 anticipated a
main.gotemplate change; because the context derivation lives insideExecute(the better place β every existing tool inherits it on rebuild), the template only gained documentation. Regeneration verification was still run. - Bonus fix surfaced by D4: previously, a normal fatal error skipped the deferred telemetry flush entirely (
ErrorHandler.Checkβos.Exitskips defers). The flush-once-before-exit structure fixes that path too, with a regression test.
Open questions for review¶
- E2E Gherkin SIGINT smoke scenario (spec verification item 3). Resolved β added.
features/cli/signal.featuredrives the dedicated e2e binary (cmd/e2e): a hidden, contrivedblockfixture command (cmd/e2e/block.go) parks oncmd.Context().Done()and, on cancellation, printsgraceful shutdown completeto stdout. The scenario starts the binary, waits for its readiness marker, sends a realos.Interrupt, then asserts the process exits with code 130 and thegraceful shutdown completemarker is on stdout β proving real OS signal delivery cancelled the context and the command unwound gracefully rather than being hard-killed. Step definitions live intest/e2e/steps/signal_steps_test.go(spawns the binary, signals it, waits for exit, asserts code + output). Tagged@cli @smoke @signal, so it runs underINT_TEST_E2E_CLI=1and the smoke set; confirmed green. - TUI coexistence integration test. Stays analysis-backed (not changed). D5 remains satisfied by analysis of Bubble Tea v2's documented raw-mode behaviour (and its own
handleSignalscomment). The new SIGINT process-level E2E (above) covers the external-signal path at the OS level; the PTY/TUI raw-mode^C-as-keystroke case is still covered by analysis rather than acreack/ptyharness, as agreed. - External SIGINT during a prompt cancels the whole run (both Bubble Tea and the outer watcher are notified). I judged this correct for supervisors, but if you'd rather the prompt absorb externally-delivered SIGINT too, the outer watcher would need a "TUI active" gate (e.g. a flag set around
form.Run()call sites) β more invasive, touches every prompt call site. - Exit message wording. Resolved β interrupt notice demoted to debug. An interrupt is a deliberate user choice, not a failure, so the
interrupted by signal: β¦notice is no longer logged at error. It now routes through the newerrorhandling.LevelFatalQuietlevel, which exits identically toLevelFatal(the 128+signumWithExitCodecode is still honoured, the telemetry flush still runs) but logs the notice at debug β invisible to end users on a normal run, still surfaced under--debug. The change is additive to theerrorhandlingpublic API (LevelFatalQuietconstant; newlogErrorcase). Covered bypkg/errorhandling/exitcode_test.go(TestCheck_FatalQuietLogsDebugButStillExits) andpkg/cmd/root/execute_signal_test.go(TestExecute_InterruptNoticeIsDebugNotError).