Skip to content

Die of the signal instead of reporting an interrupt as a failure - #29

Merged
178inaba merged 6 commits into
mainfrom
fix/26-signal-reraise
Aug 18, 2026
Merged

Die of the signal instead of reporting an interrupt as a failure#29
178inaba merged 6 commits into
mainfrom
fix/26-signal-reraise

Conversation

@178inaba

Copy link
Copy Markdown
Owner

Why

rdsh caught SIGINT and SIGTERM and then reported the interruption as a failure — differently on every path, and worst of all as a timeout:

Command Before After
query, polling a job Error: context canceled, exit 1 nothing, killed by the signal
query, with the job cancellation hanging Error: query timed out after 1m30s (use --timeout to allow more time): …, exit 124, ten seconds later nothing, killed by the signal
data-source list Error: Get "http://…": interrupt signal received, exit 1 nothing, killed by the signal
auth login Error: verification failed, nothing saved: …, exit 1 nothing, killed by the signal

Ctrl-C is not a failure — someone asked the run to stop — and the 124 row actively misled: a run interrupted a second and a half in claimed the 90 s deadline had expired and advised a longer --timeout that would have changed nothing. Two related problems went with it: a second Ctrl-C was swallowed, leaving the user stuck for up to the ten-second job-cancellation timeout with no way out, and rdsh always exited normally, so a parent saw WIFEXITED and a shell loop over rdsh invocations kept going.

curl, git, gh, python3 and node all die of the signal and print nothing. rdsh cannot simply stop catching — it has a server-side job to cancel and a terminal to restore — so it catches, finishes that work, and re-raises.

What

  • Execute replaces signal.NotifyContext with an explicit signal.Notify channel. NotifyContext cannot support either half of this: its cancellation cause is an unexported type that is not an os.Signal, so the signal cannot be recovered, and it keeps the handler installed for the whole run.
  • The watcher resets every registered signal at receipt, hands the signal on, then cancels the context. Resetting all of them is what makes a second signal — either of the two, not just a repeat of the first — end the process at once, by the default disposition rather than a second code path. Resetting before handing it on is load-bearing: the other order leaves a window in which the re-raise lands in a channel nobody reads any more.
  • When the command returns, Execute checks for a signal before it looks at the error at all, and discards the error unread. That is what makes the 124 path unreachable on an interrupted run, with no change in query.go, auth.go or internal/redash.
  • internal/cmd/signal_unix.go re-raises with syscall.Kill; signal_other.go (Windows, wasm) returns 128 + signum, and signal_plan9.go exists only so the tree keeps building where syscall.Signal does not exist. go install is rdsh's only distribution channel, so all of these have to compile.

Exit codes are otherwise unchanged: 0 on success, 124 on a --timeout expiry nobody interrupted, 1 for every other failure.

Two measured details worth calling out

The wait after syscall.Kill is bounded, not indefinite. The issue comment prescribes select {} after the Kill, since Kill is not raise(3) and execution can continue past it. Blocking forever turns out to have its own failure: a shell hands a background job SIGINT as SIG_IGN, signal.Reset restores that, and Kill then reports success for a signal that never arrives. Measured with a for loop started with & — rdsh outlived the interrupt entirely and only a SIGTERM ended it, which is worse than the bug being fixed. A /tmp/siginfo probe confirmed the cause: a bash background job reports inherited SIGINT ignored: true, a foreground one false. So the wait is 500 ms and then falls back to 128 + signum; when the signal can arrive, the process dies inside the sleep. TestExecuteUndeliverableSignalStillExits pins it (verified red against select {}: the process never exited). The same applies to cflio/slio if they land select {}.

plan9 keeps compiling. syscall.SIGTERM exists there but the syscall.Signal type does not, so typing the recorded signal as syscall.Signal in root.go would have broken a target that builds on main today. Keeping the value as an os.Signal until the platform file converts it costs one small file and no distortion of the shared path. GOOS=windows, GOOS=plan9 and GOOS=js all build.

Verified by hand

Re-raising kills the test process, so the signal-termination half is not observable from go test. Against a stub Redash whose job never finishes (and, where noted, whose cancel endpoint hangs), driving the built binary:

What was checked and what it showed
  • query interrupted while pollingWIFSIGNALED=True WTERMSIG=SIGINT, stdout and stderr both empty. Same with SIGTERM, re-raised as SIGTERM.
  • Interrupted while the job cancellation hangs — dies of SIGINT after the ten-second cancellation timeout, with no 124 and no timeout message.
  • A second signal during that hang — ends in 0.6 s instead of ten seconds, both as SIGINT→SIGINT and as SIGINT→SIGTERM.
  • A shell loop, sending SIGINT to the loop's process group the way a terminal does. Before: Error: context canceled, exit 1, and the loop ran all three iterations. After: the loop stops at the first iteration and the loop shell itself ends signalled (-2). echo $? cannot tell these apart — both spell 130 — which is why the check reads WIFSIGNALED.
  • auth login under a pty, interrupted at the masked prompt: echo is off at the prompt and back on afterwards, the process dies of SIGINT, no profile is written, and nothing is reported. The pre-fix binary restores echo the same way but exits 1.
  • GOOS=windows GOARCH=amd64 go build ./..., plus plan9 and js/wasm.

Tests

Execute-level tests re-execute the test binary as rdsh through a TestMain helper branch, which is the only way to observe process termination: interrupted query (SIGINT and SIGTERM) and data-source list, a second signal during a hanging job cancellation after --timeout has already expired (the 124 path, both signal orders), an undeliverable signal, an uninterrupted --timeout expiry (still 124, still its message), and an ordinary failure (still 1, still reported). Every one of them was confirmed red against the old Execute first.

assertInterrupted still checks exit 1, which remains true below Execute, but now pins the sentinel itself rather than wording no user sees.

Closes #26

A run a signal ends did not fail, so nothing about it is reported now:
Execute checks whether a signal arrived before it looks at the error at
all, discards the error unread, and re-raises the signal so a parent sees
WIFSIGNALED rather than an exit status that only spells one.

The watcher resets every registered signal at receipt, which is what lets
a second signal end the process at once rather than waiting out the job
cancellation. Resetting before recording is load-bearing: the other order
leaves a window in which the re-raise lands in a channel nobody reads any
more and the process hangs.

Not reading the error is also what keeps an interrupted run from being
called a timeout, so the exit code 124 it used to produce is unreachable
without touching query.go, auth.go or internal/redash.
Its wording no longer has to read as an interruption on its own, now that
the user never sees it, and the newline a cancelled prompt writes is still
wanted for a different reason: nothing else ends that line. assertInterrupted
follows, pinning the sentinel itself rather than text that has stopped
being user-facing.
"Any other failure exits 1" stopped covering interrupts once they stopped
being reported at all, and an agent reading only that would treat 130 as a
failure worth retrying. The README gains the interrupt paragraph as a
section of its own, since it is no longer only about timeouts.
A shell hands a background job SIGINT as SIG_IGN, signal.Reset puts that
back, and Kill then reports success for a signal that is never delivered.
Waiting for it indefinitely wedged the process for good — measured with a
`for` loop over rdsh started with `&`, where the run outlived the interrupt
and only a SIGTERM ended it.

Waiting a bounded 500ms instead costs nothing when the signal does arrive,
since the process dies inside the sleep, and leaves the undeliverable case
exiting with the status a shell would have reported.
A buffered channel gives the ordering the reset relies on without any of
the reasoning about atomics that the comment had to carry, and drops the
pointer-to-local the store needed. Stopping the notification on the way out
keeps the registration from outliving the run it was made for.
@178inaba 178inaba self-assigned this Aug 18, 2026
Cancelling first would let the command unwind and the receive run while the
send was still pending, putting back the very report this removes. The
ordinary-failure test now checks the message it claims to, naming the
profile rather than only looking for the Error: prefix.
@178inaba
178inaba merged commit 698bad9 into main Aug 18, 2026
2 checks passed
@178inaba
178inaba deleted the fix/26-signal-reraise branch August 18, 2026 11:19
@daemon-bot daemon-bot Bot mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Die of the signal instead of reporting an interrupt as a failure

1 participant