2 Commits
Author SHA1 Message Date
sneak 6300a48451 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 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, where
a signal up to a tenth of a second after the input ends still removes
the unfinished file and exits 1. Those commands catch only the signals
the tool was not started ignoring, so a run under nohup survives a
hangup.

Model: opus-5-5
2026-10-04 07:52:57 +00:00
clawbot 1d1c8182be Current templates: safe.directory, golangci-lint v2.14.0, fetch-depth 0, the policy's last stage (closes #50)
check / check (push) Failing after 2s
Brings keyfunc to the current sneak/prompts templates: REPO_POLICIES.md and .golangci.yml are the template copies, the lint phase runs golangci-lint v2.14.0 on the template digest (its one new finding fixed), and .dockerignore gains the template line for submodule configs. The stage that compiles keyfunc marks /src safe for git, so a context sent as a tar stream still stamps the tag or short commit. The last stage is now a development environment, as the policy asks of a non-server repo: run the tool as docker run IMAGE keyfunc .... The CI checkout fetches tags.

Deviation: .gitea/workflows/check.yml differs from the template copy by fetch-depth: 0, which REPO_POLICIES.md requires.

Model: opus-5-5
2026-10-04 08:59:08 +02:00
12 changed files with 217 additions and 128 deletions
+3
View File
@@ -17,7 +17,10 @@
# 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,4 +6,7 @@ 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,6 +17,7 @@ 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
+27 -29
View File
@@ -1,11 +1,11 @@
# The lint phase, the test phase and the build. script/lint and # The lint phase, the test phase and a development environment.
# script/test each build one phase alone; a plain `docker build .` builds # script/lint and script/test each build one phase alone; a plain
# both, because the build stage copies a file from each. Formatting is # `docker build .` builds both, because the last stage copies a file from
# checked on the host by script/fmt-check, not here. # each. Formatting is checked on the host by script/fmt-check, not here.
# Lint phase # Lint phase
# golangci/golangci-lint:v2.12.2, 2026-09-07 # golangci/golangci-lint:v2.14.0, 2026-10-04
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
@@ -16,13 +16,12 @@ COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version # image ships.
# script/bootstrap installs on the host; change both together. # golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test # script/bootstrap installs on the host; change both together, and the
# same image in the last stage.
# -race needs cgo, and cgo needs a C toolchain. FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test
RUN apk add --no-cache gcc musl-dev
WORKDIR /src WORKDIR /src
@@ -35,21 +34,27 @@ 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; }
# Build stage. Nothing is wanted from either phase above; the copies # Development environment, and the last stage: a plain `docker build .`
# are what make BuildKit build them first, so this stage cannot run # builds this one. It holds the source tree in /src, what
# unless lint and test passed. # script/bootstrap installs, and keyfunc built from that tree on the
# golang:1.26-alpine, 2026-09-07 # PATH. Nothing is wanted from either phase above; the copies are what
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder # make BuildKit build them first, so this stage cannot run unless lint
# 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
RUN apk add --no-cache make 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 ./ # script/bootstrap needs only script/ and the dependency manifests.
RUN go mod download COPY script/ script/
COPY go.mod go.sum package.json yarn.lock ./
RUN script/bootstrap
COPY . . COPY . .
@@ -64,11 +69,4 @@ 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" make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc
# alpine:3.23, 2026-09-07
FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc
ENTRYPOINT ["keyfunc"]
+13 -10
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: it removes An interrupted `age encrypt -o` or `age decrypt -o` leaves no file. While it is
the unfinished file it was writing, leaves a file already at the named path as writing the file, the signal makes it remove the unfinished file, leave a file
it was, and exits with status 1. It puts the file in place a tenth of a second already at the named path as it was, and exit with status 1. It puts the file in
after its input ends, and a signal in that time still counts: Ctrl-C on a place a tenth of a second after its input ends, and a signal in that time still
pipeline also ends the program feeding it, so the input can end just before the counts: Ctrl-C on a pipeline also ends the program feeding it, so the input can
signal arrives. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the end just before the signal arrives. While `ssh to` or `ssh install` has `ssh` or
signal ends that program instead, the tool removes its agent socket or working `sftp` running, the signal ends that program instead, the tool removes its agent
files, and it exits with status 1, or for `ssh to` with `ssh`'s own status if socket or working files, and it exits with status 1, or for `ssh to` with
`ssh` reported one. `ssh`'s own status if `ssh` reported one.
## Entrypoints ## Entrypoints
@@ -343,7 +343,10 @@ 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. `docker build .` runs them too. The image is a development environment, not a
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
+55 -36
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-02 last_modified: 2026-10-04
--- ---
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 Go image. The canonical Go repo and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -173,8 +173,9 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
# 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 ./
@@ -192,6 +193,8 @@ 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
@@ -236,19 +239,23 @@ 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`, which `git describe` does not need and which can hold a `.git/config` and each submodule's `config` under `.git/modules/` at any
credential: a password in a remote URL, or the token the CI checkout step depth (`.git/modules/**/config`), which `git describe` does not need and
stores there. The stage that compiles has `git` (the Debian Go image has which can hold a credential: a password in a remote URL, or the token the
it; an alpine one needs `apk add --no-cache git`) and takes the version CI checkout step stores there. The stage that compiles has `git` (the
from the `VERSION` build argument when one is given, otherwise from Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
`git describe --tags --always`. That gives the tag on a tagged commit; on takes the version from the `VERSION` build argument when one is given,
a later commit, the tag, the number of commits since it and the short otherwise from `git describe --tags --always`. That gives the tag on a
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is tagged commit; on a later commit, the tag, the number of commits since it
reachable. `ARG VERSION` has no default, and the build fails if the and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
context carries `.git` and the version still comes out empty, `dev` or tag is reachable. The stage that compiles also marks its working directory
`unknown`. A plain `docker build .` with no build arguments must succeed; safe for git (`git config --system --add safe.directory /src`): a context
a Dockerfile that refuses an empty build argument drops that refusal and sent as a tar stream keeps the sender's file owners, and git refuses a
keeps the argument. checkout owned by another user, so the version would come out empty.
`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.
@@ -310,17 +317,19 @@ 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 the target cannot report a pass it did not earn, and the rerun cache, so neither run can report a stored pass in place of running the
reproduces a failure instead of replaying it. It leaves the build cache tests. It leaves the build cache alone, so it costs the runtime of the suite
alone, so it costs the runtime of the suite and no recompilation. and no recompilation.
Note that this is a second, independent cache, stacked below the Docker That cache is Go's own, separate from Docker's layer cache. Go stores a
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) passing result in its cache directory (`GOCACHE`), and when the same tests
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; run again on unchanged code it prints that result, marked `(cached)`,
it does not guarantee `go test` inside that step does any work, because the without running them. That matters on a developer's machine, where this
`GOCACHE` baked into earlier image layers survives into the re-executed target runs and the directory lasts from one run to the next. The `test`
step. They are two separate defects requiring two separate fixes, and a fix phase of the `Dockerfile` needs no `-count=1`: its base image holds no
for one must not be recorded as covering the other. result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -451,12 +460,18 @@ 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.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
only pin, since no repo installs golangci-lint on the host: bumping the directive must not name a newer Go minor version than the one golangci-lint
version means changing it and nothing else. was built with, or golangci-lint refuses to lint it: this release lints
`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
@@ -592,10 +607,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`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical 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
@@ -626,3 +641,7 @@ 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.
+11 -12
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,17 +190,16 @@ 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. One that comes while // Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context
// the work runs, or within signalWait after it has ended, wins: the new // does. One that comes while the work runs, or within signalWait after
// file is removed and ErrInterrupted returned at once, without waiting // it has ended, wins: the new file is removed and ErrInterrupted
// for the work, which may be blocked reading its input. // returned at once, without waiting for the work, which may be blocked
// 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 {
signals := make(chan os.Signal, 1) interrupted, stop := signals.Context(context.Background())
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer stop()
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
@@ -217,11 +216,11 @@ func output(
select { select {
case failed := <-worked: case failed := <-worked:
select { select {
case <-signals: case <-interrupted.Done():
case <-time.After(signalWait): case <-time.After(signalWait):
return finish(file, name, failed) return finish(file, name, failed)
} }
case <-signals: case <-interrupted.Done():
} }
return finish(file, name, ErrInterrupted) return finish(file, name, ErrInterrupted)
+64 -26
View File
@@ -134,16 +134,40 @@ 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, as a subprocess writing into a directory of its own // operation says, writing into a directory of its own, and once it has
// and reading the input from a pipe. It waits until the tool has begun // begun writing sends it the signal, then ends the input if endInput
// writing the file beside the one it was named, and sends it the // says so and otherwise leaves it open. The tool has to end with status
// signal, then ends the input if endInput says so and otherwise leaves // 1 and leave the directory empty. A tool that went on reading would
// it open. The tool has to end with status 1 and leave the directory // not end until the input did; one that did not remove the file it was
// empty. A tool that went on reading would not end until the input // writing would leave it there, with what it had written so far; one
// did; one that did not remove the file it was writing would leave it // that put that file in place because the input ended would leave the
// there, with what it had written so far; one that put that file in // named file.
// 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,
) { ) {
@@ -152,12 +176,38 @@ func interrupted(
name := operation + " " + signal.String() name := operation + " " + signal.String()
directory := t.TempDir() directory := t.TempDir()
//nolint:gosec // the binary is this test's own, re-run as the tool command, producer := writing(
command := exec.CommandContext( t, directory, input,
t.Context(), os.Args[0], "age", operation, os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
"-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()
@@ -175,19 +225,7 @@ func interrupted(
return err == nil && len(entries) > 0 return err == nil && len(entries) > 0
}, 5*time.Second, 5*time.Millisecond) }, 5*time.Second, 5*time.Millisecond)
require.NoError(t, command.Process.Signal(signal)) return command, producer
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
+1 -2
View File
@@ -80,8 +80,7 @@ func Main() int {
return 0 return 0
} }
var passed ssh.StatusError if passed, ok := errors.AsType[ssh.StatusError](err); ok {
if errors.As(err, &passed) {
return passed.Status return passed.Status
} }
+34
View File
@@ -0,0 +1,34 @@
// 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...)
}
+2 -6
View File
@@ -8,13 +8,12 @@ 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
@@ -67,10 +66,7 @@ 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 := signal.NotifyContext( ctx, stop := signals.Context(cmd.Context())
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop() defer stop()
cmd.SetContext(ctx) cmd.SetContext(ctx)
+3 -7
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,10 +47,7 @@ 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 := signal.NotifyContext( ctx, stop := signals.Context(cmd.Context())
cmd.Context(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop() defer stop()
served, err := key.Serve(ctx, comment) served, err := key.Serve(ctx, comment)
@@ -99,8 +96,7 @@ func connect(ctx context.Context, argv []string) error {
return nil return nil
} }
var ended *exec.ExitError if ended, ok := errors.AsType[*exec.ExitError](err); ok {
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