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
This commit is contained in:
2026-10-04 06:39:38 +00:00
parent d4fbcbc83d
commit 893b351eb6
7 changed files with 267 additions and 48 deletions
+55 -26
View File
@@ -3,10 +3,14 @@
package age
import (
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey"
@@ -14,6 +18,18 @@ import (
"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.
func Command() *cobra.Command {
group := &cobra.Command{
@@ -128,8 +144,9 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
return through(cmd, args, key.Decrypt)
}
// through opens the input and the output the arguments ask for, hands
// them to the work, and finishes the output afterwards either way.
// through opens the input the arguments ask for and hands it to the
// work, with the file --output names to write to, or the command's own
// output when it names none.
func through(
cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error,
@@ -141,14 +158,16 @@ func through(
defer closeSrc()
dst, done, err := output(cmd)
name, err := cmd.Flags().GetString("output")
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
@@ -167,35 +186,45 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
return file, func() { _ = file.Close() }, nil
}
// output returns what to write to: a new file beside the one --output
// names, or the command's own output when it names none. The second
// result finishes the write, and is given whatever the work returned:
// 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
// failed.
func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
name, err := cmd.Flags().GetString("output")
if err != nil {
return nil, nil, fmt.Errorf("reading the output file: %w", err)
}
// output has the work write a new file beside the named one, and puts
// the new file in the named file's place only when the work succeeded,
// so a file that is already there survives a run that failed.
//
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught. One that comes while
// the work runs, or within signalWait after it has ended, wins: the new
// file is removed and ErrInterrupted returned at once, without waiting
// for the work, which may be blocked reading its input.
func output(
name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error {
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
if name == "" {
return cmd.OutOrStdout(), func(failed error) error {
return failed
}, nil
}
defer signal.Stop(signals)
// 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
// its owner, which is the mode it keeps once renamed.
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
if err != nil {
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
return fmt.Errorf("creating a file beside %s: %w", name, err)
}
return file, func(failed error) error {
return finish(file, name, failed)
}, nil
worked := make(chan error, 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 finish(file, name, ErrInterrupted)
}
// finish closes the new file and puts it in the named file's place, or