Every command ends on SIGINT, SIGTERM or SIGHUP; an interrupted age -o leaves no file #48

Closed
opened 2026-10-04 06:27:13 +02:00 by clawbot · 2 comments
Collaborator

From the whole-branch review of #28 (#28 (comment)), finding 1:

internal/cli/cli.go (Main) catches SIGINT, SIGTERM and SIGHUP for every command, but only the ssh and sftp children act on them. Ctrl-C at the mnemonic prompt does nothing until Enter is pressed, and age encrypt and age decrypt keep reading after a signal: an interrupted producer | keyfunc age encrypt -o file puts a complete, decryptable encryption of the cut-off input in place and exits 0.

Work, on a branch cut from next, PR to next: every command ends promptly with a non-zero status on those signals, either by catching them only while ssh or sftp runs, or by having the prompt and the age copy stop on the cancelled context. Pick the plainer of the two and say why in the PR. The cleanup that ssh to and ssh install do on a signal stays as it is.

Definition of done

  • SIGINT, SIGTERM or SIGHUP ends every command promptly with a non-zero status, including at the mnemonic prompt.
  • An interrupted age encrypt -o or age decrypt -o puts no output file in place; a test covers the encrypt case.
  • ssh to and ssh install still remove their agent socket and working files on those signals.
  • make check passes.

Model: opus-5-5

From the whole-branch review of https://git.eeqj.de/sneak/keyfunc/pulls/28 (https://git.eeqj.de/sneak/keyfunc/pulls/28#issuecomment-120217), finding 1: `internal/cli/cli.go` (`Main`) catches SIGINT, SIGTERM and SIGHUP for every command, but only the `ssh` and `sftp` children act on them. Ctrl-C at the mnemonic prompt does nothing until Enter is pressed, and `age encrypt` and `age decrypt` keep reading after a signal: an interrupted `producer | keyfunc age encrypt -o file` puts a complete, decryptable encryption of the cut-off input in place and exits 0. Work, on a branch cut from `next`, PR to `next`: every command ends promptly with a non-zero status on those signals, either by catching them only while `ssh` or `sftp` runs, or by having the prompt and the age copy stop on the cancelled context. Pick the plainer of the two and say why in the PR. The cleanup that `ssh to` and `ssh install` do on a signal stays as it is. ## Definition of done - SIGINT, SIGTERM or SIGHUP ends every command promptly with a non-zero status, including at the mnemonic prompt. - An interrupted `age encrypt -o` or `age decrypt -o` puts no output file in place; a test covers the encrypt case. - `ssh to` and `ssh install` still remove their agent socket and working files on those signals. - `make check` passes. Model: opus-5-5
clawbot self-assigned this 2026-10-04 06:27:13 +02:00
Author
Collaborator

Built in #54: only ssh to and ssh install catch the signals now, from once the mnemonic is read until their cleanup has run; every other command, the prompt included, ends at once.

Model: opus-5-5

Built in https://git.eeqj.de/sneak/keyfunc/pulls/54: only `ssh to` and `ssh install` catch the signals now, from once the mnemonic is read until their cleanup has run; every other command, the prompt included, ends at once. Model: opus-5-5
Author
Collaborator

Replaces the tenth-of-a-second wait in #54; the rest stays.

Guarantee. "Any signal sent before the rename" cannot be promised: Go takes a signal on a thread the kernel picks, and nothing lets a program wait for a handler that has not run yet. Promise: a signal keyfunc has received when its input ends leaves no new file and status 1; otherwise the whole file goes in place; never an unfinished one.

Mechanism. No timer; delete signalWait.

  1. signals.Notify(c): signal.Notify for the signals Context catches.
  2. output keeps signals.Context as now, so a late signal cannot kill the tool beside an unfinished file, and registers a size-1 channel with signals.Notify. When the work ends, signal.Stop on it returns once the runtime has handed over every signal it holds; empty means finish(file, name, failed), else ErrInterrupted.
  3. cli gets func init() { runtime.LockOSThread() }, so that check runs on the main thread. Linux hands a process's signal to that thread first, and a thread runs a pending handler before its own code: a signal sent before the input ended (Ctrl-C on a pipeline) has been received at the check.

Tests.

  • New, in-process via cli.Root() and SetIn: an input that at its end sends the test process the signal (once; a later send kills the test binary), waits for it on the test's own signal.Notify channel, then returns io.EOF. For each signal, encrypt and decrypt, over an existing file: age.ErrInterrupted, that file unchanged, nothing beside it. No timing.
  • TestASignalAsTheInputEndsLeavesNoFile: renamed; accept status 1 with an empty directory, or another status with only the named file, complete. A strict form rests on chance, so no gate test covers step 3.

README. Errors, replacing "leaves no file" and the tenth-of-a-second sentence: removal and status 1 hold for a signal that has reached keyfunc when its input ends; Ctrl-C on a pipeline ends the input at the same moment, and on Linux keyfunc sees the signal first, though no system promises that. Design: name cli/signals (finding 2 of #54 (comment)).

Rejected. A wait: loses under load, or costs seconds. Deciding by how the input ended: a killed writer ends a pipe like a finished one. Not catching: same gap, file left.

Measured on the shared host (-race, load 60 to 110, throwaway copy): signal and end of input together put the file in place in 0 of 5,523 runs, 21 of 436 without step 3; make test 5 of 5.

  • Judgement call: the issue's "puts no output file in place" is restated as above.
  • Unverified: which thread macOS picks.

Model: fable-5-1

Replaces the tenth-of-a-second wait in https://git.eeqj.de/sneak/keyfunc/pulls/54; the rest stays. **Guarantee.** "Any signal sent before the rename" cannot be promised: Go takes a signal on a thread the kernel picks, and nothing lets a program wait for a handler that has not run yet. Promise: a signal `keyfunc` has received when its input ends leaves no new file and status 1; otherwise the whole file goes in place; never an unfinished one. **Mechanism.** No timer; delete `signalWait`. 1. `signals.Notify(c)`: `signal.Notify` for the signals `Context` catches. 2. `output` keeps `signals.Context` as now, so a late signal cannot kill the tool beside an unfinished file, and registers a size-1 channel with `signals.Notify`. When the work ends, `signal.Stop` on it returns once the runtime has handed over every signal it holds; empty means `finish(file, name, failed)`, else `ErrInterrupted`. 3. `cli` gets `func init() { runtime.LockOSThread() }`, so that check runs on the main thread. Linux hands a process's signal to that thread first, and a thread runs a pending handler before its own code: a signal sent before the input ended (Ctrl-C on a pipeline) has been received at the check. **Tests.** - New, in-process via `cli.Root()` and `SetIn`: an input that at its end sends the test process the signal (once; a later send kills the test binary), waits for it on the test's own `signal.Notify` channel, then returns `io.EOF`. For each signal, encrypt and decrypt, over an existing file: `age.ErrInterrupted`, that file unchanged, nothing beside it. No timing. - `TestASignalAsTheInputEndsLeavesNoFile`: renamed; accept status 1 with an empty directory, or another status with only the named file, complete. A strict form rests on chance, so no gate test covers step 3. **README.** Errors, replacing "leaves no file" and the tenth-of-a-second sentence: removal and status 1 hold for a signal that has reached `keyfunc` when its input ends; Ctrl-C on a pipeline ends the input at the same moment, and on Linux `keyfunc` sees the signal first, though no system promises that. Design: name `cli/signals` (finding 2 of https://git.eeqj.de/sneak/keyfunc/pulls/54#issuecomment-122120). **Rejected.** A wait: loses under load, or costs seconds. Deciding by how the input ended: a killed writer ends a pipe like a finished one. Not catching: same gap, file left. **Measured** on the shared host (`-race`, load 60 to 110, throwaway copy): signal and end of input together put the file in place in 0 of 5,523 runs, 21 of 436 without step 3; `make test` 5 of 5. - Judgement call: the issue's "puts no output file in place" is restated as above. - Unverified: which thread macOS picks. Model: fable-5-1
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/keyfunc#48