fix(cmd): force-exit on a second SIGINT and stop skipping signal cleanup - #24
Merged
Merged
Conversation
14 tasks
hammadmajid
force-pushed
the
fix/16-signal-force-exit
branch
from
September 5, 2026 14:14
b3f0516 to
dc1f407
Compare
signal.NotifyContext's stop func is what calls signal.Stop and restores the default signal disposition, and it was only deferred until after the run had finished. The handler therefore stayed installed for the whole run, so the runtime consumed and discarded every SIGINT after the first: a user whose grg was wedged in a phase that cannot unwind (a blocking write to a stalled consumer, an uncancellable walk) had no way out but SIGKILL. A watcher goroutine now calls stop() as soon as the first signal cancels ctx, restoring the default disposition so a second Ctrl-C terminates the process. The goroutine cannot leak: defer stop() cancels ctx on the normal path too, so <-ctx.Done() always returns. os.Exit runs no deferred functions, so exiting from inside main skipped that cleanup on every non-zero exit - and exit code 1 (no match found) is the common case, leaving NotifyContext's relay goroutine and its signal registration behind. The body moves to realMain, which returns an exit code that main hands to os.Exit, so every deferred cleanup runs first. Also deletes the statically dead executor dispatch. searchPipeline is a concrete *search.Pipeline whose ExecuteContext returns channels, so the chanExecutor assertion always succeeded; the sliceExecutor branch and the bare Execute fallback were unreachable, and no single type can satisfy both interfaces because the method names collide with different signatures. Worse, the dead fallback called Execute, which hardcodes context.Background(), so it modelled an uncancellable path and invited a future fix to the wrong branch. runContext now calls ExecuteContext directly and keeps the drain-then-read- error sequence. Removing the fallbacks also made the generic `if err != nil` epilogue that followed the dispatch provably unreachable, since the error read from errCh already returns, so that block goes too; the live ctx.Err() check after it stays. The new integration test pins grg in a state where signal handling actually has to do the work: stdout is a pipe nobody reads and the workload emits far more than a pipe buffer holds, so the process blocks in write(2) with matches pending, where no context check can rescue it. The first SIGINT is absorbed by the handler and changes nothing; the second must kill the process. It fails against the previous code with "second SIGINT was swallowed" and passes now. The three pre-existing signal tests are documented as weak - their 25-commit, 10-file workload finishes in milliseconds and they all accept exit code 0 - so it is clear where the real assertion lives. Closes #16
hammadmajid
force-pushed
the
fix/16-signal-force-exit
branch
from
September 5, 2026 14:16
dc1f407 to
c429734
Compare
hammadmajid
marked this pull request as ready for review
September 5, 2026 14:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
A user who hit Ctrl-C during a long operation saw nothing happen, hit it again, and still saw nothing happen. Only
kill -9worked.Closes #16
Type of Change
Key Changes
A second SIGINT now force-exits.
signal.NotifyContext's stop func is what callssignal.Stopand restores default signal disposition, but it was onlydeferred — so the handler stayed installed for the entire run and the Go runtime silently swallowed every subsequent signal. A watcher goroutine now releases the handler as soon as the first signal cancels the context:That goroutine cannot leak:
defer stop()always cancelsctxon the normal path. The comment says so.os.Exitno longer skips cleanup.os.Exitruns no deferred functions, so on every non-zero exit — andnoMatchErroris exit 1, the common case —signal.Stopnever ran andNotifyContext's relay goroutine was never released. Split intofunc main() { os.Exit(realMain()) }with all deferred cleanup insiderealMain.Deleted the statically dead executor dispatch.
searchPipelineis a concrete*search.PipelinewhoseExecuteContextreturns channels, so thechanExecutorassertion always succeeded;sliceExecutorand the bare-Executefallback were unreachable, and a single type can never satisfy both interfaces because the method names collide with different signatures. Worse, the dead fallback calledExecute, which hardcodescontext.Background()— it modelled an uncancellable path and invited a future fix to the wrong branch. The generic error epilogue below it became provably unreachable once the fallbacks were gone and is deleted too; the livectx.Err()check after it is untouched.Verification & Testing
go test -v -count=1 ./...go test -race -shuffle=on -count=1 ./...go vet ./...New test
TestSignal_SecondSIGINT_ForceExits. The child's stdout is anos.Pipethe parent never reads, so with a ~260 KB match workload against a 64 KiB pipe buffergrgprovably blocks inwrite(2)with matches pending — a state no ctx check can unwind, which is what makes the second signal the only escape. The test asserts the premise (still alive after 1 s), sends SIGINT, waits 250 ms, sends a second, then requires death within 2 s with eitherWaitStatus.Signaled() == SIGINTor a non-zero exit code. Exit 0 is a hard failure.Discrimination proof: against a copy of the tree with
cmd/grg/main.gorestored fromHEAD, it fails after 4.77 s withsecond SIGINT was swallowed: grg was still alive 2s later and only SIGKILL ended it. Passes on the fixed tree in 2.10 s.The three pre-existing signal tests all accept exit code 0 as valid against a 25-commit × 10-file repo that finishes in milliseconds, so they only ever proved non-wedging. A doc comment on
setupWorkloadReponow records that.Exit-code smoke on a real repository: match=0, no-match=1 (silent), unknown flag=2 (message on stderr),
--version=0, quiet+match=0, no args=2.Note
Stacked on #23.
Checklist
gofmtclean