Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Successful in 2m35s

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` went on to put the
encryption of the cut-off input in place. Now only `ssh to` and
`ssh install` catch them, from once the mnemonic is read until their
cleanup has run; everywhere else they end the tool at once. Tests cover
an interrupted `age encrypt -o` and the install working directory on a
signal.

Model: opus-5-5
This commit is contained in:
2026-10-04 04:40:57 +00:00
parent 1ddc2c747a
commit ca1faa5551
6 changed files with 163 additions and 22 deletions
+9
View File
@@ -274,6 +274,15 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
Errors go to standard error and the exit status is 1, except for `ssh to`, which
passes through `ssh`'s own exit status.
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).
An interrupted `age encrypt -o` or `age decrypt -o` leaves the named file as it
was; the unfinished file it was writing stays beside it, named
`<file>.<digits>`. 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
The repo adheres to the
+56
View File
@@ -1,10 +1,14 @@
package cli_test
import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
"sneak.berlin/go/keyfunc/internal/agekey"
@@ -90,6 +94,58 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
require.Equal(t, "what was already there\n", string(kept))
}
func TestASignalStopsAnEncryptionAndPutsNoFileInPlace(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} {
encryptionInterrupted(t, ending.String(), ending)
}
}
// encryptionInterrupted runs "age encrypt -o" as a subprocess reading
// from a pipe that stays open, waits until the tool has begun writing
// the file beside the one it was named, and sends it the signal. The
// tool has to end on the signal alone, with a failure, and leave
// nothing at the name it was given. A tool that went on reading would
// not end until the input did, and would then put the encryption of the
// cut-off input in place.
func encryptionInterrupted(t *testing.T, name string, signal os.Signal) {
t.Helper()
directory := t.TempDir()
sealed := filepath.Join(directory, "notes.age")
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], "age", "encrypt", "-o", sealed,
)
command.Env = append(os.Environ(), runAsTool+"=1")
producer, err := command.StdinPipe()
require.NoError(t, err)
require.NoError(t, command.Start())
_, err = io.WriteString(producer, "the start of the secret\n")
require.NoError(t, err)
// The file beside the named one is made once the mnemonic has been
// read, just before the encryption starts.
require.Eventually(t, func() bool {
entries, err := os.ReadDir(directory)
return err == nil && len(entries) > 0
}, 5*time.Second, 5*time.Millisecond)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
require.False(t, command.ProcessState.Success(), name)
require.NoFileExists(t, sealed, name)
}
// written puts the contents in a file of that name in a directory of
// this test's own and returns the path to it.
func written(t *testing.T, name, contents string) string {
+6 -14
View File
@@ -2,13 +2,10 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"runtime/debug"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/age"
@@ -70,18 +67,13 @@ func Root() *cobra.Command {
// ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
// program, so a command waiting at the mnemonic prompt or reading what
// it encrypts or decrypts goes no further. The exception is "ssh to"
// and "ssh install" while they have ssh or sftp running: they catch the
// signals there, so the child ends and their own cleanup still runs.
func Main() int {
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
err := Root().Execute()
if err == nil {
return 0
}
+13
View File
@@ -7,9 +7,11 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"github.com/spf13/cobra"
)
@@ -55,6 +57,17 @@ func install() *cobra.Command {
return err
}
// From here on a signal cancels the context, which
// sftp runs under, instead of ending the tool, so sftp
// ends and the working directory is still removed.
ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
cmd.SetContext(ctx)
return add(cmd, args[0], args[1:], line)
},
}
+14 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"slices"
"syscall"
@@ -42,7 +43,17 @@ func to() *cobra.Command {
return err
}
served, err := key.Serve(cmd.Context(), comment)
// From here until the agent is taken down, a signal
// cancels the context instead of ending the tool, so
// ssh ends and the socket and its directory are still
// removed.
ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
served, err := key.Serve(ctx, comment)
if err != nil {
return err
}
@@ -53,7 +64,7 @@ func to() *cobra.Command {
"-o", "IdentityAgent=" + served.Socket(),
}, args)
return connect(cmd.Context(), argv)
return connect(ctx, argv)
},
}
@@ -76,7 +87,7 @@ func connect(ctx context.Context, argv []string) error {
command.Stdout = os.Stdout
command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// A cancelled context means a signal arrived. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
+65 -5
View File
@@ -20,13 +20,13 @@ import (
// runAsTool, set in the environment of a re-executed test binary, tells
// TestMain to run the tool through Main rather than the suite, so the
// signal test can drive the real signal path in a process it can send a
// signal to.
// signal tests can drive the real signal path in a process they can
// send a signal to.
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
// TestMain re-executes the test binary as the tool when runAsTool is
// set, and otherwise runs the suite. The signal test starts the tool
// this way, as a subprocess it can signal and watch clean up.
// set, and otherwise runs the suite. The signal tests start the tool
// this way, as a subprocess they can signal and watch end.
func TestMain(m *testing.M) {
if os.Getenv(runAsTool) == "1" {
os.Exit(cli.Main())
@@ -201,6 +201,16 @@ fi
sleep 5
`
// stalled is a stand-in for the system sftp that notes it has started
// and then blocks, so a test can signal the tool while sftp is running
// and watch it remove its working directory. The exec keeps the shell
// from leaving a sleep behind that holds the output the tool reads sftp
// through.
const stalled = `
touch "$KEYFUNC_TEST_STARTED"
exec sleep 5
`
// pretended is where a stand-in writes down what it was asked to do.
type pretended struct {
// home stands in for the home directory on the host.
@@ -510,7 +520,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
// ssh that blocks, waits until the agent is up and ssh is running
// against it, sends the tool the signal, and requires the agent socket
// and its directory to be gone once the tool has ended. The subprocess
// goes through Main and its signal handling, so with that handling
// goes through Main and the command's signal handling, so with that handling
// removed the signal kills the tool outright, no deferred cleanup runs,
// the directory is left behind, and the check fails.
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
@@ -577,6 +587,56 @@ func waitForSocket(t *testing.T, noted string) string {
return socket
}
func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} {
signalEndsTheInstall(t, ending.String(), ending)
}
}
// signalEndsTheInstall runs "ssh install" as a subprocess against a
// stand-in sftp that blocks, with a temporary directory of the test's
// own, waits until sftp is running, sends the tool the signal, and
// requires the working directory the tool made there to be gone once
// the tool has ended.
func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) {
t.Helper()
temporary := t.TempDir()
started := filepath.Join(t.TempDir(), "started")
t.Setenv("KEYFUNC_TEST_STARTED", started)
standIn(t, "sftp", stalled)
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], subcommand, installing, host,
)
command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary)
require.NoError(t, command.Start())
// sftp is started only once the working directory has been made.
require.Eventually(t, func() bool {
_, err := os.Stat(started)
return err == nil
}, 5*time.Second, 5*time.Millisecond)
working, err := os.ReadDir(temporary)
require.NoError(t, err)
require.Len(t, working, 1, name)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
left, err := os.ReadDir(temporary)
require.NoError(t, err)
require.Empty(t, left, name)
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example())