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
12 changed files with 128 additions and 217 deletions
-3
View File
@@ -17,10 +17,7 @@
# stage that compiles runs `git describe --tags --always` on .git, which # stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there. # password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules.
.git/config .git/config
.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root. # Anchored because it occurs once where agents run at the repo root.
-3
View File
@@ -6,7 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
# All history and tags, which `git describe --tags` needs.
fetch-depth: 0
- run: script/cibuild - run: script/cibuild
-1
View File
@@ -17,7 +17,6 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
+29 -27
View File
@@ -1,11 +1,11 @@
# The lint phase, the test phase and a development environment. # The lint phase, the test phase and the build. script/lint and
# script/lint and script/test each build one phase alone; a plain # script/test each build one phase alone; a plain `docker build .` builds
# `docker build .` builds both, because the last stage copies a file from # both, because the build stage copies a file from each. Formatting is
# each. Formatting is checked on the host by script/fmt-check, not here. # checked on the host by script/fmt-check, not here.
# Lint phase # Lint phase
# golangci/golangci-lint:v2.14.0, 2026-10-04 # golangci/golangci-lint:v2.12.2, 2026-09-07
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src WORKDIR /src
@@ -16,12 +16,13 @@ COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase
# image ships. # golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version
# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version # script/bootstrap installs on the host; change both together.
# script/bootstrap installs on the host; change both together, and the FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test
# same image in the last stage.
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test # -race needs cgo, and cgo needs a C toolchain.
RUN apk add --no-cache gcc musl-dev
WORKDIR /src WORKDIR /src
@@ -34,27 +35,21 @@ RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Development environment, and the last stage: a plain `docker build .` # Build stage. Nothing is wanted from either phase above; the copies
# builds this one. It holds the source tree in /src, what # are what make BuildKit build them first, so this stage cannot run
# script/bootstrap installs, and keyfunc built from that tree on the # unless lint and test passed.
# PATH. Nothing is wanted from either phase above; the copies are what # golang:1.26-alpine, 2026-09-07
# make BuildKit build them first, so this stage cannot run unless lint FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder
# and test passed.
# golang:1.26.8-trixie, 2026-10-04
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses. RUN apk add --no-cache make git
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
# script/bootstrap needs only script/ and the dependency manifests. COPY go.mod go.sum ./
COPY script/ script/ RUN go mod download
COPY go.mod go.sum package.json yarn.lock ./
RUN script/bootstrap
COPY . . COPY . .
@@ -69,4 +64,11 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
echo "no version could be derived although the build context carries .git" >&2; \ echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \ exit 1; \
fi; \ fi; \
make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc make build VERSION="$version"
# alpine:3.23, 2026-09-07
FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc
ENTRYPOINT ["keyfunc"]
+10 -13
View File
@@ -290,15 +290,15 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, 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. While it is An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
writing the file, the signal makes it remove the unfinished file, leave a file the unfinished file it was writing, leaves a file already at the named path as
already at the named path as it was, and exit with status 1. It puts the file in it was, and exits with status 1. It puts the file in place a tenth of a second
place a tenth of a second after its input ends, and a signal in that time still after its input ends, and a signal in that time still counts: Ctrl-C on a
counts: Ctrl-C on a pipeline also ends the program feeding it, so the input can pipeline also ends the program feeding it, so the input can end just before the
end just before the signal arrives. While `ssh to` or `ssh install` has `ssh` or signal arrives. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the
`sftp` running, the signal ends that program instead, the tool removes its agent signal ends that program instead, the tool removes its agent socket or working
socket or working files, and it exits with status 1, or for `ssh to` with files, and it exits with status 1, or for `ssh to` with `ssh`'s own status if
`ssh`'s own status if `ssh` reported one. `ssh` reported one.
## Entrypoints ## Entrypoints
@@ -343,10 +343,7 @@ standard: most Makefile targets are thin shims over an executable in `script/`
- `script/docker` builds the Docker image, uncached, tagged with the project - `script/docker` builds the Docker image, uncached, tagged with the project
name and stamped with the version `git describe` gives on the host. The image name and stamped with the version `git describe` gives on the host. The image
cannot be built unless the `lint` and `test` phases pass, so a plain cannot be built unless the `lint` and `test` phases pass, so a plain
`docker build .` runs them too. The image is a development environment, not a `docker build .` runs them too.
runtime image: the Debian Go image with what `script/bootstrap` installs, the
source tree in `/src`, and `keyfunc` built from it on the `PATH`.
`docker run --rm -it keyfunc` opens a shell in it.
- `script/cibuild` is the CI build the Gitea workflow calls: it runs - `script/cibuild` is the CI build the Gitea workflow calls: it runs
`bootstrap`, then `check`, then builds the image as `script/docker` does. `bootstrap`, then `check`, then builds the image as `script/docker` does.
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and - `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
+36 -55
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-04 last_modified: 2026-10-02
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -160,7 +160,7 @@ style conventions are in separate documents:
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -173,9 +173,8 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase
# image ships and the alpine one does not. # golang:1.x-alpine, YYYY-MM-DD
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -193,8 +192,6 @@ style conventions are in separate documents:
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -239,23 +236,19 @@ style conventions are in separate documents:
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint phase with `apk add`. `vips-dev`), install them in the lint phase with `apk add`.
- `.dockerignore` lets `.git` into the build context. It keeps out - `.dockerignore` lets `.git` into the build context. It keeps out
`.git/config` and each submodule's `config` under `.git/modules/` at any `.git/config`, which `git describe` does not need and which can hold a
depth (`.git/modules/**/config`), which `git describe` does not need and credential: a password in a remote URL, or the token the CI checkout step
which can hold a credential: a password in a remote URL, or the token the stores there. The stage that compiles has `git` (the Debian Go image has
CI checkout step stores there. The stage that compiles has `git` (the it; an alpine one needs `apk add --no-cache git`) and takes the version
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and from the `VERSION` build argument when one is given, otherwise from
takes the version from the `VERSION` build argument when one is given, `git describe --tags --always`. That gives the tag on a tagged commit; on
otherwise from `git describe --tags --always`. That gives the tag on a a later commit, the tag, the number of commits since it and the short
tagged commit; on a later commit, the tag, the number of commits since it commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no reachable. `ARG VERSION` has no default, and the build fails if the
tag is reachable. The stage that compiles also marks its working directory context carries `.git` and the version still comes out empty, `dev` or
safe for git (`git config --system --add safe.directory /src`): a context `unknown`. A plain `docker build .` with no build arguments must succeed;
sent as a tar stream keeps the sender's file owners, and git refuses a a Dockerfile that refuses an empty build argument drops that refusal and
checkout owned by another user, so the version would come out empty. keeps the argument.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -317,19 +310,17 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the cache, so the target cannot report a pass it did not earn, and the rerun
tests. It leaves the build cache alone, so it costs the runtime of the suite reproduces a failure instead of replaying it. It leaves the build cache
and no recompilation. alone, so it costs the runtime of the suite and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a Note that this is a second, independent cache, stacked below the Docker
passing result in its cache directory (`GOCACHE`), and when the same tests layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
run again on unchanged code it prints that result, marked `(cached)`, addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
without running them. That matters on a developer's machine, where this it does not guarantee `go test` inside that step does any work, because the
target runs and the directory lasts from one run to the next. The `test` `GOCACHE` baked into earlier image layers survives into the re-executed
phase of the `Dockerfile` needs no `-count=1`: its base image holds no step. They are two separate defects requiring two separate fixes, and a fix
result for this repo's tests and nothing before its `go test` step runs a for one must not be recorded as covering the other.
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -460,18 +451,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
directive must not name a newer Go minor version than the one golangci-lint only pin, since no repo installs golangci-lint on the host: bumping the
was built with, or golangci-lint refuses to lint it: this release lints version means changing it and nothing else.
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -607,10 +592,10 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
and language-specific config). Everything else goes in a subdirectory. language-specific config). Everything else goes in a subdirectory. Canonical
Canonical subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -641,7 +626,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+12 -11
View File
@@ -3,18 +3,18 @@
package age package age
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"syscall"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/agekey"
"sneak.berlin/go/keyfunc/internal/cli/options" "sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/derive" "sneak.berlin/go/keyfunc/internal/derive"
) )
@@ -190,16 +190,17 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
// the new file in the named file's place only when the work succeeded, // 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. // so a file that is already there survives a run that failed.
// //
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context // Meanwhile SIGINT, SIGTERM and SIGHUP are caught. One that comes while
// does. One that comes while the work runs, or within signalWait after // the work runs, or within signalWait after it has ended, wins: the new
// it has ended, wins: the new file is removed and ErrInterrupted // file is removed and ErrInterrupted returned at once, without waiting
// returned at once, without waiting for the work, which may be blocked // for the work, which may be blocked reading its input.
// reading its input.
func output( func output(
name string, src io.Reader, work func(io.Writer, io.Reader) error, name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error { ) error {
interrupted, stop := signals.Context(context.Background()) signals := make(chan os.Signal, 1)
defer stop() 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
@@ -216,11 +217,11 @@ func output(
select { select {
case failed := <-worked: case failed := <-worked:
select { select {
case <-interrupted.Done(): case <-signals:
case <-time.After(signalWait): case <-time.After(signalWait):
return finish(file, name, failed) return finish(file, name, failed)
} }
case <-interrupted.Done(): case <-signals:
} }
return finish(file, name, ErrInterrupted) return finish(file, name, ErrInterrupted)
+26 -64
View File
@@ -134,40 +134,16 @@ func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) {
} }
} }
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
directory := t.TempDir()
named := filepath.Join(directory, "notes")
// nohup starts the tool with SIGHUP ignored. A tool that caught it
// anyway would turn it back on and be ended by it.
command, producer := writing(
t, directory, "the secret\n",
"nohup", os.Args[0], "age", "encrypt", "-o", named,
)
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
require.NoError(t, producer.Close())
waitForTool(t, "SIGHUP under nohup", command)
require.Equal(t, 0, command.ProcessState.ExitCode())
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Len(t, left, 1)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
}
// interrupted runs "age encrypt -o" or "age decrypt -o", as the // interrupted runs "age encrypt -o" or "age decrypt -o", as the
// operation says, writing into a directory of its own, and once it has // operation says, as a subprocess writing into a directory of its own
// begun writing sends it the signal, then ends the input if endInput // and reading the input from a pipe. It waits until the tool has begun
// says so and otherwise leaves it open. The tool has to end with status // writing the file beside the one it was named, and sends it the
// 1 and leave the directory empty. A tool that went on reading would // signal, then ends the input if endInput says so and otherwise leaves
// not end until the input did; one that did not remove the file it was // it open. The tool has to end with status 1 and leave the directory
// writing would leave it there, with what it had written so far; one // empty. A tool that went on reading would not end until the input
// that put that file in place because the input ended would leave the // did; one that did not remove the file it was writing would leave it
// named file. // 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( func interrupted(
t *testing.T, signal os.Signal, operation, input string, endInput bool, t *testing.T, signal os.Signal, operation, input string, endInput bool,
) { ) {
@@ -176,38 +152,12 @@ func interrupted(
name := operation + " " + signal.String() name := operation + " " + signal.String()
directory := t.TempDir() directory := t.TempDir()
command, producer := writing( //nolint:gosec // the binary is this test's own, re-run as the tool
t, directory, input, command := exec.CommandContext(
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"), t.Context(), os.Args[0], "age", operation,
"-o", filepath.Join(directory, "notes"),
) )
require.NoError(t, command.Process.Signal(signal))
if endInput {
require.NoError(t, producer.Close())
}
waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left, name)
}
// writing starts argv, the tool told to write into directory, as a
// subprocess reading the input from a pipe, and returns once the tool
// has begun writing the file beside the one it was named. The pipe is
// left open for the caller to end.
func writing(
t *testing.T, directory, input string, argv ...string,
) (*exec.Cmd, io.WriteCloser) {
t.Helper()
//nolint:gosec // this test's own binary as the tool, or nohup running it
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
command.Env = append(os.Environ(), runAsTool+"=1") command.Env = append(os.Environ(), runAsTool+"=1")
producer, err := command.StdinPipe() producer, err := command.StdinPipe()
@@ -225,7 +175,19 @@ func writing(
return err == nil && len(entries) > 0 return err == nil && len(entries) > 0
}, 5*time.Second, 5*time.Millisecond) }, 5*time.Second, 5*time.Millisecond)
return command, producer require.NoError(t, command.Process.Signal(signal))
if endInput {
require.NoError(t, producer.Close())
}
waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, left, name)
} }
// written puts the contents in a file of that name in a directory of // written puts the contents in a file of that name in a directory of
+2 -1
View File
@@ -80,7 +80,8 @@ func Main() int {
return 0 return 0
} }
if passed, ok := errors.AsType[ssh.StatusError](err); ok { var passed ssh.StatusError
if errors.As(err, &passed) {
return passed.Status return passed.Status
} }
-34
View File
@@ -1,34 +0,0 @@
// Package signals catches the signals that end the tool, for the
// commands that clean up before they end.
package signals
import (
"context"
"os"
"os/signal"
"syscall"
)
// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the
// context it returns is cancelled when one of them arrives, and stop
// stops catching them. It leaves out any of the three the tool was
// started with set to be ignored, as nohup does with SIGHUP, because
// catching a signal turns an ignored one back on and would end a run
// that was meant to survive it.
func Context(parent context.Context) (context.Context, context.CancelFunc) {
endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
caught := make([]os.Signal, 0, len(endings))
for _, ending := range endings {
if !signal.Ignored(ending) {
caught = append(caught, ending)
}
}
// Given no signals at all, NotifyContext would catch every one.
if len(caught) == 0 {
return context.WithCancel(parent)
}
return signal.NotifyContext(parent, caught...)
}
+6 -2
View File
@@ -8,12 +8,13 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
) )
// Where the key goes on the host and what the file it arrives in is // Where the key goes on the host and what the file it arrives in is
@@ -66,7 +67,10 @@ func install() *cobra.Command {
// From here on a signal cancels the context, which // From here on a signal cancels the context, which
// sftp runs under, instead of ending the tool, so sftp // sftp runs under, instead of ending the tool, so sftp
// ends and the working directory is still removed. // ends and the working directory is still removed.
ctx, stop := signals.Context(cmd.Context()) ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop() defer stop()
cmd.SetContext(ctx) cmd.SetContext(ctx)
+7 -3
View File
@@ -6,11 +6,11 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"slices" "slices"
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
) )
// StatusError says the tool should end with the status ssh ended with. // StatusError says the tool should end with the status ssh ended with.
@@ -47,7 +47,10 @@ func to() *cobra.Command {
// cancels the context instead of ending the tool, so // cancels the context instead of ending the tool, so
// ssh ends and the socket and its directory are still // ssh ends and the socket and its directory are still
// removed. // removed.
ctx, stop := signals.Context(cmd.Context()) ctx, stop := signal.NotifyContext(
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop() defer stop()
served, err := key.Serve(ctx, comment) served, err := key.Serve(ctx, comment)
@@ -96,7 +99,8 @@ func connect(ctx context.Context, argv []string) error {
return nil return nil
} }
if ended, ok := errors.AsType[*exec.ExitError](err); ok { var ended *exec.ExitError
if errors.As(err, &ended) {
status := ended.ExitCode() status := ended.ExitCode()
if status < 0 { if status < 0 {
// A signal ended ssh, and a signal has no status of its // A signal ended ssh, and a signal has no status of its