1 Commits
Author SHA1 Message Date
sneak 893b351eb6 Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Failing after 3s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the
ssh and sftp children acted on them: the mnemonic prompt waited for
Enter, and an interrupted `age encrypt -o` put the encryption of the
cut-off input in place. Now `ssh to` and `ssh install` catch them from
once the mnemonic is read until their cleanup has run, and everywhere
else they end the tool at once, except while `age encrypt -o` or
`age decrypt -o` writes. There the work runs in the background, and a
signal that comes before it ends, or within a tenth of a second after,
removes the unfinished file and ends the tool with status 1, since
Ctrl-C on a pipeline can end the input just before the signal arrives.

Model: opus-5-5
2026-10-04 06:39:38 +00:00
3 changed files with 89 additions and 57 deletions
+7 -4
View File
@@ -292,10 +292,13 @@ SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
with the status a shell gives a program killed by that signal (130 for SIGINT). with the status a shell gives a program killed by that signal (130 for SIGINT).
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
the unfinished file it was writing, leaves a file already at the named path as the unfinished file it was writing, leaves a file already at the named path as
it was, and exits with status 1. While `ssh to` or `ssh install` has `ssh` or it was, and exits with status 1. It puts the file in place a tenth of a second
`sftp` running, the signal ends that program instead, the tool removes its agent after its input ends, and a signal in that time still counts: Ctrl-C on a
socket or working files, and it exits with status 1, or for `ssh to` with pipeline also ends the program feeding it, so the input can end just before the
`ssh`'s own status if `ssh` reported one. signal arrives. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the
signal ends that program instead, the tool removes its agent socket or working
files, and it exits with status 1, or for `ssh to` with `ssh`'s own status if
`ssh` reported one.
## Entrypoints ## Entrypoints
+49 -44
View File
@@ -3,12 +3,14 @@
package age package age
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/agekey"
@@ -16,6 +18,18 @@ import (
"sneak.berlin/go/keyfunc/internal/derive" "sneak.berlin/go/keyfunc/internal/derive"
) )
// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP came before
// the file --output names was put in place.
var ErrInterrupted = errors.New(
"interrupted by a signal; the output file was left as it was",
)
// signalWait is how long after the work has ended a signal still keeps
// the new file from being put in place. Ctrl-C on "producer | keyfunc
// age encrypt -o file" ends the producer as well, and the end of the
// input can reach the work a moment before the signal reaches the tool.
const signalWait = 100 * time.Millisecond
// Command returns the age command and everything under it. // Command returns the age command and everything under it.
func Command() *cobra.Command { func Command() *cobra.Command {
group := &cobra.Command{ group := &cobra.Command{
@@ -130,8 +144,9 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
return through(cmd, args, key.Decrypt) return through(cmd, args, key.Decrypt)
} }
// through opens the input and the output the arguments ask for, hands // through opens the input the arguments ask for and hands it to the
// them to the work, and finishes the output afterwards either way. // work, with the file --output names to write to, or the command's own
// output when it names none.
func through( func through(
cmd *cobra.Command, args []string, cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error, work func(io.Writer, io.Reader) error,
@@ -143,14 +158,16 @@ func through(
defer closeSrc() defer closeSrc()
dst, done, err := output(cmd) name, err := cmd.Flags().GetString("output")
if err != nil { if err != nil {
return err return fmt.Errorf("reading the output file: %w", err)
} }
err = work(dst, src) if name == "" {
return work(cmd.OutOrStdout(), src)
}
return done(err) return output(name, src, work)
} }
// input returns what to read from: the named file, or the command's // input returns what to read from: the named file, or the command's
@@ -169,57 +186,45 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
return file, func() { _ = file.Close() }, nil return file, func() { _ = file.Close() }, nil
} }
// output returns what to write to: a new file beside the one --output // output has the work write a new file beside the named one, and puts
// names, or the command's own output when it names none. The second // the new file in the named file's place only when the work succeeded,
// result finishes the write, and is given whatever the work returned: // so a file that is already there survives a run that failed.
// the new file takes the named file's place only when the work //
// succeeded, so a file that is already there survives a run that // Meanwhile SIGINT, SIGTERM and SIGHUP are caught. One that comes while
// failed. // the work runs, or within signalWait after it has ended, wins: the new
func output(cmd *cobra.Command) (io.Writer, func(error) error, error) { // file is removed and ErrInterrupted returned at once, without waiting
name, err := cmd.Flags().GetString("output") // for the work, which may be blocked reading its input.
if err != nil { func output(
return nil, nil, fmt.Errorf("reading the output file: %w", err) name string, src io.Reader, work func(io.Writer, io.Reader) error,
} ) error {
if name == "" {
return cmd.OutOrStdout(), func(failed error) error {
return failed
}, nil
}
// From before the new file is made until it is renamed or removed,
// a signal removes it and ends the tool with status 1, even while
// the work is blocked reading its input, so an interrupted run
// leaves no file.
signals := make(chan os.Signal, 1) signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(signals)
// The file is made in the same directory so that putting it in // The file is made in the same directory so that putting it in
// place is a rename and never a copy, and it is readable only by // place is a rename and never a copy, and it is readable only by
// its owner, which is the mode it keeps once renamed. // its owner, which is the mode it keeps once renamed.
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".") file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
if err != nil { if err != nil {
signal.Stop(signals) return fmt.Errorf("creating a file beside %s: %w", name, err)
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
} }
go func() { worked := make(chan error, 1)
if _, received := <-signals; received {
_ = os.Remove(file.Name())
os.Exit(1) go func() { worked <- work(file, src) }()
select {
case failed := <-worked:
select {
case <-signals:
case <-time.After(signalWait):
return finish(file, name, failed)
}
case <-signals:
} }
}()
return file, func(failed error) error { return finish(file, name, ErrInterrupted)
finished := finish(file, name, failed)
signal.Stop(signals)
close(signals)
return finished
}, nil
} }
// finish closes the new file and puts it in the named file's place, or // finish closes the new file and puts it in the named file's place, or
+33 -9
View File
@@ -100,7 +100,7 @@ func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
for _, ending := range []os.Signal{ for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} { } {
interrupted(t, ending, "encrypt", "the start of the secret\n") interrupted(t, ending, "encrypt", "the start of the secret\n", false)
} }
} }
@@ -115,19 +115,38 @@ func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
for _, ending := range []os.Signal{ for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} { } {
interrupted(t, ending, "decrypt", cut) interrupted(t, ending, "decrypt", cut, false)
}
}
func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
// Ctrl-C on "producer | keyfunc age encrypt -o file" ends the
// producer too, so the input ends just as the signal comes, with
// enough of it in hand for a whole encryption or decryption. Which
// of the two reaches the tool first varies, so it is tried often.
for range 25 {
interrupted(t, syscall.SIGINT, "encrypt", "the start of the secret\n", true)
interrupted(t, syscall.SIGINT, "decrypt", sealed, true)
} }
} }
// interrupted runs "age encrypt -o" or "age decrypt -o", as the // interrupted runs "age encrypt -o" or "age decrypt -o", as the
// operation says, as a subprocess writing into a directory of its own // operation says, as a subprocess writing into a directory of its own
// and reading the input from a pipe that stays open. It waits until the // and reading the input from a pipe. It waits until the tool has begun
// tool has begun writing the file beside the one it was named, and // writing the file beside the one it was named, and sends it the
// sends it the signal. The tool has to end on the signal alone, with // signal, then ends the input if endInput says so and otherwise leaves
// status 1, and leave the directory empty. A tool that went on reading // it open. The tool has to end with status 1 and leave the directory
// would not end until the input did; one that did not remove the file // empty. A tool that went on reading would not end until the input
// it was writing would leave it there, with what it had written so far. // did; one that did not remove the file it was writing would leave it
func interrupted(t *testing.T, signal os.Signal, operation, input string) { // there, with what it had written so far; one that put that file in
// place because the input ended would leave the named file.
func interrupted(
t *testing.T, signal os.Signal, operation, input string, endInput bool,
) {
t.Helper() t.Helper()
name := operation + " " + signal.String() name := operation + " " + signal.String()
@@ -157,6 +176,11 @@ func interrupted(t *testing.T, signal os.Signal, operation, input string) {
}, 5*time.Second, 5*time.Millisecond) }, 5*time.Second, 5*time.Millisecond)
require.NoError(t, command.Process.Signal(signal)) require.NoError(t, command.Process.Signal(signal))
if endInput {
require.NoError(t, producer.Close())
}
waitForTool(t, name, command) waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name) require.Equal(t, 1, command.ProcessState.ExitCode(), name)