Signals end every command, not only ssh to and ssh install (closes #48) #54

Merged
clawbot merged 1 commits from issue-48-signals-end-every-command into next 2026-10-04 13:42:50 +02:00
Collaborator

Implements #48, to the design in #48 (comment).

SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. ssh to and ssh install catch them from once the mnemonic is read until their cleanup has run, so the agent socket or working directory is still removed.

age encrypt -o and age decrypt -o catch them while they write. A signal received by the time the work ends removes the unfinished file and exits 1: signal.Stop on a channel registered through signals.Notify returns once every received signal is handed over, so an empty channel means the file goes in place. cli keeps the tool on the main thread, where Linux delivers the signal first, so Ctrl-C on a pipeline has been received by then. No timed wait.

These four leave alone a signal the tool was started ignoring, as under nohup.

This is the plainer of the issue's two ways: stopping on a cancelled context would need the prompt read in a goroutine and the terminal restored by hand.

  • Judgement call: "puts no output file in place" holds for a signal received when the input ends; a later one leaves the whole file.
  • Judgement call: a signal at the prompt leaves terminal echo for the shell to restore.
  • Unverified: which thread macOS delivers the signal to.
  • Partially verified: the prompt case needs a terminal; checked by hand only.
  • Rule suppressed: gochecknoinits, since only an init stays on the main thread.

Model: opus-5-5

Implements https://git.eeqj.de/sneak/keyfunc/issues/48, to the design in https://git.eeqj.de/sneak/keyfunc/issues/48#issuecomment-122586. SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. `ssh to` and `ssh install` catch them from once the mnemonic is read until their cleanup has run, so the agent socket or working directory is still removed. `age encrypt -o` and `age decrypt -o` catch them while they write. A signal received by the time the work ends removes the unfinished file and exits 1: `signal.Stop` on a channel registered through `signals.Notify` returns once every received signal is handed over, so an empty channel means the file goes in place. `cli` keeps the tool on the main thread, where Linux delivers the signal first, so Ctrl-C on a pipeline has been received by then. No timed wait. These four leave alone a signal the tool was started ignoring, as under `nohup`. This is the plainer of the issue's two ways: stopping on a cancelled context would need the prompt read in a goroutine and the terminal restored by hand. - Judgement call: "puts no output file in place" holds for a signal received when the input ends; a later one leaves the whole file. - Judgement call: a signal at the prompt leaves terminal echo for the shell to restore. - Unverified: which thread macOS delivers the signal to. - Partially verified: the prompt case needs a terminal; checked by hand only. - Rule suppressed: `gochecknoinits`, since only an `init` stays on the main thread. Model: opus-5-5
clawbot added the needs-review label 2026-10-04 06:44:05 +02:00
clawbot self-assigned this 2026-10-04 06:44:05 +02:00
Author
Collaborator
  1. internal/cli/age/age.go (output, finish) and the new paragraph in README.md under Errors: when SIGINT, SIGTERM or SIGHUP ends an age encrypt -o or age decrypt -o run, the unfinished file beside the named one stays on disk (for example notes.txt.123456). For age decrypt -o that file holds the plaintext decrypted so far, under a name the user never gave and has no reason to look for. That is not acceptable for a tool that keeps secrets, and #48 asks that an interrupted age -o leave no file. Acceptable: while that file exists, those three signals remove it before the tool exits with a non-zero status (the blocked read does not have to stop for this); the encrypt test requires the directory to hold nothing new after the signal; the README says an interrupted -o run leaves no file and gives the exit status the tool then has.
  2. PR body: about 270 words, over the limit of about 250. Trim it; the disclosure about the leftover file goes away with item 1.

Model: opus-5-5

1. `internal/cli/age/age.go` (`output`, `finish`) and the new paragraph in `README.md` under Errors: when SIGINT, SIGTERM or SIGHUP ends an `age encrypt -o` or `age decrypt -o` run, the unfinished file beside the named one stays on disk (for example `notes.txt.123456`). For `age decrypt -o` that file holds the plaintext decrypted so far, under a name the user never gave and has no reason to look for. That is not acceptable for a tool that keeps secrets, and https://git.eeqj.de/sneak/keyfunc/issues/48 asks that an interrupted `age -o` leave no file. Acceptable: while that file exists, those three signals remove it before the tool exits with a non-zero status (the blocked read does not have to stop for this); the encrypt test requires the directory to hold nothing new after the signal; the README says an interrupted `-o` run leaves no file and gives the exit status the tool then has. 2. PR body: about 270 words, over the limit of about 250. Trim it; the disclosure about the leftover file goes away with item 1. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 07:15:02 +02:00
clawbot force-pushed issue-48-signals-end-every-command from ca1faa5551 to a31a03b2c3 2026-10-04 07:33:00 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 07:40:29 +02:00
Author
Collaborator

Rebased onto next. While age encrypt -o or age decrypt -o writes, SIGINT, SIGTERM and SIGHUP now remove the unfinished file and end the tool with status 1, with a test for each and the README saying so; the PR body is trimmed.

Model: opus-5-5

Rebased onto `next`. While `age encrypt -o` or `age decrypt -o` writes, SIGINT, SIGTERM and SIGHUP now remove the unfinished file and end the tool with status 1, with a test for each and the README saying so; the PR body is trimmed. Model: opus-5-5
Author
Collaborator
  1. internal/cli/age/age.go (output) and the README paragraph under Errors: Ctrl-C on producer | keyfunc age encrypt -o file, the case #48 is about, still sometimes puts the encryption of the cut-off input in place. The same Ctrl-C ends the producer, so the input ends just as the signal arrives, and the rename can happen before the goroutine that removes the file acts; the tool then exits 1, or 0 when Main returns before that goroutine's os.Exit(1), and a file already at the named path is replaced. age decrypt -o has the same race. The new tests signal the tool while its input stays open, so they never reach this. Acceptable: a signal that reaches the tool before the file is renamed always ends the run with no new file at the named path and a non-zero status, however the input ends; a test signals the tool and ends its input at the same moment, as Ctrl-C on a pipeline does, repeated often enough to catch the race.

Model: opus-5-5

1. `internal/cli/age/age.go` (`output`) and the README paragraph under Errors: Ctrl-C on `producer | keyfunc age encrypt -o file`, the case https://git.eeqj.de/sneak/keyfunc/issues/48 is about, still sometimes puts the encryption of the cut-off input in place. The same Ctrl-C ends the producer, so the input ends just as the signal arrives, and the rename can happen before the goroutine that removes the file acts; the tool then exits 1, or 0 when `Main` returns before that goroutine's `os.Exit(1)`, and a file already at the named path is replaced. `age decrypt -o` has the same race. The new tests signal the tool while its input stays open, so they never reach this. Acceptable: a signal that reaches the tool before the file is renamed always ends the run with no new file at the named path and a non-zero status, however the input ends; a test signals the tool and ends its input at the same moment, as Ctrl-C on a pipeline does, repeated often enough to catch the race. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 07:58:50 +02:00
clawbot force-pushed issue-48-signals-end-every-command from a31a03b2c3 to 893b351eb6 2026-10-04 08:39:40 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 08:45:00 +02:00
Author
Collaborator

The write now runs in the background while the tool waits for it to end or for a signal, and a signal up to a tenth of a second after the input ends still counts, so Ctrl-C on a pipeline leaves no file; a new test signals the tool and ends its input together, many times over, for both age encrypt -o and age decrypt -o.

Model: opus-5-5

The write now runs in the background while the tool waits for it to end or for a signal, and a signal up to a tenth of a second after the input ends still counts, so Ctrl-C on a pipeline leaves no file; a new test signals the tool and ends its input together, many times over, for both `age encrypt -o` and `age decrypt -o`. Model: opus-5-5
Author
Collaborator
  1. internal/cli/age/age.go (output): it catches SIGINT, SIGTERM and SIGHUP even when the tool was started with one of them ignored, and catching a signal in Go turns an ignored one back on. So nohup keyfunc age decrypt -o out file.age & is ended by the hangup it was started to survive and throws away what it decrypted, and age encrypt -o run in the background of a script is ended by a Ctrl-C meant for the script. The same runs without -o, and with -o on next, carry on. Acceptable: output catches only those of the three signals that signal.Ignored reports as not ignored, so such a run finishes and puts its file in place; a test starts age encrypt -o with SIGHUP ignored (for example through nohup), sends it SIGHUP, ends its input, and requires status 0 and the named file in place.
  2. README.md under Errors: "An interrupted age encrypt -o or age decrypt -o ... exits with status 1" is not true at the mnemonic prompt, where a signal ends it with the shell's status (130 for SIGINT) like any other command. Acceptable: tie the status 1 to a signal that comes while it writes, as the ssh to sentence does with "While ... has ssh or sftp running".

Model: opus-5-5

1. `internal/cli/age/age.go` (`output`): it catches SIGINT, SIGTERM and SIGHUP even when the tool was started with one of them ignored, and catching a signal in Go turns an ignored one back on. So `nohup keyfunc age decrypt -o out file.age &` is ended by the hangup it was started to survive and throws away what it decrypted, and `age encrypt -o` run in the background of a script is ended by a Ctrl-C meant for the script. The same runs without `-o`, and with `-o` on `next`, carry on. Acceptable: `output` catches only those of the three signals that `signal.Ignored` reports as not ignored, so such a run finishes and puts its file in place; a test starts `age encrypt -o` with SIGHUP ignored (for example through `nohup`), sends it SIGHUP, ends its input, and requires status 0 and the named file in place. 2. `README.md` under Errors: "An interrupted `age encrypt -o` or `age decrypt -o` ... exits with status 1" is not true at the mnemonic prompt, where a signal ends it with the shell's status (130 for SIGINT) like any other command. Acceptable: tie the status 1 to a signal that comes while it writes, as the `ssh to` sentence does with "While ... has `ssh` or `sftp` running". Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 09:29:16 +02:00
clawbot force-pushed issue-48-signals-end-every-command from 893b351eb6 to 6300a48451 2026-10-04 09:54:47 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 09:58:38 +02:00
Author
Collaborator

Rebased onto next. age encrypt -o, age decrypt -o, ssh to and ssh install now catch only the signals the tool was not started ignoring, through one function in internal/cli/signals, with a test that age encrypt -o under nohup survives a hangup; the README ties status 1 to a signal that comes while the file is being written.

Model: opus-5-5

Rebased onto `next`. `age encrypt -o`, `age decrypt -o`, `ssh to` and `ssh install` now catch only the signals the tool was not started ignoring, through one function in `internal/cli/signals`, with a test that `age encrypt -o` under `nohup` survives a hangup; the README ties status 1 to a signal that comes while the file is being written. Model: opus-5-5
Author
Collaborator
  1. internal/cli/ssh/install.go (session), and the new README sentence that these signals end any command at once: a SIGINT, SIGTERM or SIGHUP sent to the tool alone (for example kill <pid>) while ssh install is reaching a host that does not answer does not end the tool. It keeps running until the connection attempt gives up. The signal kills sftp outright, the ssh that sftp started stays alive holding the output the tool reads sftp through, and the tool waits for that output to close. next has the same wait; the new test's stalled stand-in avoids it with its exec. Acceptable: such a signal ends the tool within a second, with status 1 and its working directory removed, even when something sftp started is still running. One way is to end sftp with SIGTERM, as connect does for ssh (sftp then stops its own ssh), and give the command a WaitDelay. The stand-in should leave a child holding sftp's output (no exec) so that the test covers this case.

Model: opus-5-5

1. `internal/cli/ssh/install.go` (`session`), and the new README sentence that these signals end any command at once: a SIGINT, SIGTERM or SIGHUP sent to the tool alone (for example `kill <pid>`) while `ssh install` is reaching a host that does not answer does not end the tool. It keeps running until the connection attempt gives up. The signal kills `sftp` outright, the `ssh` that `sftp` started stays alive holding the output the tool reads `sftp` through, and the tool waits for that output to close. `next` has the same wait; the new test's `stalled` stand-in avoids it with its `exec`. Acceptable: such a signal ends the tool within a second, with status 1 and its working directory removed, even when something `sftp` started is still running. One way is to end `sftp` with SIGTERM, as `connect` does for `ssh` (`sftp` then stops its own `ssh`), and give the command a `WaitDelay`. The stand-in should leave a child holding `sftp`'s output (no `exec`) so that the test covers this case. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 10:18:36 +02:00
Author
Collaborator

Split: the case in #54 (comment) is how next already behaves, so it moves to #57. This PR is gated on everything else; it has had four reviews and three reworks.

Model: opus-5-5

Split: the case in https://git.eeqj.de/sneak/keyfunc/pulls/54#issuecomment-121965 is how `next` already behaves, so it moves to https://git.eeqj.de/sneak/keyfunc/issues/57. This PR is gated on everything else; it has had four reviews and three reworks. Model: opus-5-5
clawbot added needs-review and removed needs-rework labels 2026-10-04 10:26:06 +02:00
Author
Collaborator
  1. internal/cli/age/age.go (output) and its test TestASignalAsTheInputEndsLeavesNoFile in internal/cli/age_test.go: the test fails now and then in make check on the shared build host. The tool got SIGINT before its input ended, yet put the file in place and exited 0, which is the defect #48 is about. output counts a signal only if the tool has acted on it within a tenth of a second of the work ending, and under the race detector on a busy machine that takes longer. A gate that goes red at random also breaks next. Acceptable: make check passes on every run on the shared host (for example five runs in a row). Either the tool stops depending on acting on the signal within a fixed time, or the wait is long enough to hold there; in both cases the README's "a tenth of a second" sentence says what the tool then does.
  2. README.md, Design: the sentence listing the packages under internal/cli ("Under it, cli/options holds the flags every command shares, and cli/ssh, cli/age and cli/mnemonic are the command groups.") leaves out the new cli/signals. Acceptable: that sentence also names cli/signals and says what it does: it catches SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end.

Model: opus-5-5

1. `internal/cli/age/age.go` (`output`) and its test `TestASignalAsTheInputEndsLeavesNoFile` in `internal/cli/age_test.go`: the test fails now and then in `make check` on the shared build host. The tool got SIGINT before its input ended, yet put the file in place and exited 0, which is the defect https://git.eeqj.de/sneak/keyfunc/issues/48 is about. `output` counts a signal only if the tool has acted on it within a tenth of a second of the work ending, and under the race detector on a busy machine that takes longer. A gate that goes red at random also breaks `next`. Acceptable: `make check` passes on every run on the shared host (for example five runs in a row). Either the tool stops depending on acting on the signal within a fixed time, or the wait is long enough to hold there; in both cases the README's "a tenth of a second" sentence says what the tool then does. 2. `README.md`, Design: the sentence listing the packages under `internal/cli` ("Under it, `cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups.") leaves out the new `cli/signals`. Acceptable: that sentence also names `cli/signals` and says what it does: it catches SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 10:44:18 +02:00
clawbot force-pushed issue-48-signals-end-every-command from 6300a48451 to dcb7c7270c 2026-10-04 12:08:24 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 12:21:43 +02:00
Author
Collaborator

Reworked to #48 (comment): signalWait and the tenth-of-a-second wait are gone; once the work ends, output stops a channel registered through the new signals.Notify and treats any signal handed over as an interrupt, and cli keeps the tool on the main thread. A new in-process test covers a signal received as the input ends, the subprocess test (now TestASignalAsTheInputEndsLeavesNoUnfinishedFile) also accepts a whole file in place, and the README's Errors section says so and its Design section names cli/signals.

Model: opus-5-5

Reworked to https://git.eeqj.de/sneak/keyfunc/issues/48#issuecomment-122586: `signalWait` and the tenth-of-a-second wait are gone; once the work ends, `output` stops a channel registered through the new `signals.Notify` and treats any signal handed over as an interrupt, and `cli` keeps the tool on the main thread. A new in-process test covers a signal received as the input ends, the subprocess test (now `TestASignalAsTheInputEndsLeavesNoUnfinishedFile`) also accepts a whole file in place, and the README's Errors section says so and its Design section names `cli/signals`. Model: opus-5-5
Author
Collaborator
  1. internal/cli/age/age.go, the comment on ErrInterrupted: it says the error is returned when SIGINT, SIGTERM or SIGHUP came before the file --output names was put in place. #48 (comment) says that cannot be promised: a signal that arrives after the work has ended, while the file is being put in place, leaves the whole file and returns no error. Acceptable: the comment says the error is returned when one of those signals has been received by the time the work ends, as the comment on output already says.

Model: opus-5-5

1. `internal/cli/age/age.go`, the comment on `ErrInterrupted`: it says the error is returned when SIGINT, SIGTERM or SIGHUP came before the file `--output` names was put in place. https://git.eeqj.de/sneak/keyfunc/issues/48#issuecomment-122586 says that cannot be promised: a signal that arrives after the work has ended, while the file is being put in place, leaves the whole file and returns no error. Acceptable: the comment says the error is returned when one of those signals has been received by the time the work ends, as the comment on `output` already says. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 13:01:49 +02:00
clawbot added 1 commit 2026-10-04 13:12:17 +02:00
SIGINT, SIGTERM and SIGHUP were caught for the whole run, so the
mnemonic prompt waited for Enter and an interrupted `age encrypt -o`
put the encryption of the cut-off input in place. Now they end the
tool at once, except where a command cleans up first: `ssh to` and
`ssh install` while ssh or sftp runs, and `age encrypt -o` and
`age decrypt -o` while they write. A signal those two have received
when their input ends removes the unfinished file and exits 1; a later
one leaves the whole file in place. The tool stays on the main thread,
where Linux delivers the signal first. Signals the tool was started
ignoring stay ignored, so a run under nohup survives a hangup.

Model: opus-5-5
clawbot force-pushed issue-48-signals-end-every-command from dcb7c7270c to add39a24c1 2026-10-04 13:12:17 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 13:12:23 +02:00
Author
Collaborator

The comment on ErrInterrupted in internal/cli/age/age.go now says the error is returned when SIGINT, SIGTERM or SIGHUP has been received by the time the work writing the file ends; no other comment or README sentence still made the old promise.

Model: opus-5-5

The comment on `ErrInterrupted` in `internal/cli/age/age.go` now says the error is returned when SIGINT, SIGTERM or SIGHUP has been received by the time the work writing the file ends; no other comment or README sentence still made the old promise. Model: opus-5-5
Author
Collaborator

Review passed.

Model: opus-5-5

Review passed. Model: opus-5-5
clawbot merged commit c96b77dd67 into next 2026-10-04 13:42:50 +02:00
clawbot deleted branch issue-48-signals-end-every-command 2026-10-04 13:42:50 +02:00
Sign in to join this conversation.