check / check (push) Successful in 1m0s
Every gpg invocation went through runGPG, which built its command with exec.CommandContext(context.Background(), ...). That is the right call with the wrong context: context.Background() never expires, so no deadline was ever enforced on any of the five gpg call sites. runGPG now takes a context and derives a gpgTimeout deadline from it, honouring an earlier caller deadline when there is one. The gpg-touching library entry points take a ctx as their first argument so cancellation propagates from above: Builder.Build, NewManifestFromReader, NewManifestFromFile, NewChecker, Checker.ExtractEmbeddedSigningKeyFP. Scanner.ToManifest already had a ctx and now passes it down, which withdraws the //nolint:contextcheck claiming signing was "not cancellable by design" -- it is, and now it is. A deadline alone is not enough, and the added test proves it. gpg delegates to helpers (gpg-agent, pinentry) that inherit the captured stdout and stderr pipes. Go's default cancellation kills only the direct child, so the helper keeps the pipes open and Cmd.Wait blocks on the output-copying goroutines forever -- a dead process and a call that still never returns. Two additions fix that: the child runs in its own process group and cancellation kills the group, and Cmd.WaitDelay caps how long Wait will hold on for the pipes if something escapes the group anyway. Measured with the stand-in gpg from the new test: neither mechanism, hangs until `go test` gives up; WaitDelay only, returns in 2.2s; both, returns in 0.20s. Timeout errors now name the operation and how long gpg ran instead of surfacing a bare "signal: killed" or "context deadline exceeded", and a cancellation from above is reported as a cancellation rather than a timeout, so an abort is distinguishable from a stall. The test helper's own keygen invocations had the same unbounded context.Background() and the same pipe-inheriting agent problem, which makes them the actual mechanism behind the intermittent suite timeout noted in the issue: keygen starts gpg-agent, and a stalled agent hung the suite rather than failing it. They now run under a deadline with the same hardening, so a broken gpg environment skips instead of hanging. Verified with a cold `docker buildx build --no-cache`: prettier, gofmt, `make lint` (0 issues) and `make test` all executed and passed. The golang:1.23 image ships gpg, so the real signing, export, fingerprint, import and verify tests run against real gpg there, not skipped. The process-group kill is unix-only and lives in a build-tagged file; on other platforms the deadline is still enforced via cancellation plus WaitDelay, only the group kill of helpers is unavailable.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
//go:build unix
|
|
|
|
// The deadline tests need an executable stand-in for gpg first on PATH,
|
|
// which is written as a POSIX shell script, and they exercise the
|
|
// process-group kill that only exists on unix. There is no equivalent
|
|
// coverage on other platforms.
|
|
|
|
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// gpgTestDeadline is the deadline the deadline tests give gpg. It is
|
|
// short so the suite stays fast; gpgTimeout itself is far too long to
|
|
// wait out in a test, and passing an earlier deadline is exactly the
|
|
// caller-propagation path under test.
|
|
gpgTestDeadline = 200 * time.Millisecond
|
|
|
|
// fakeGPGSleepSeconds is how long the stand-in gpg blocks. It must be
|
|
// long enough that only cancellation can end it -- if the deadline is
|
|
// not enforced, the test hangs until `go test` times out, which is the
|
|
// loud failure we want rather than a silently passing race.
|
|
fakeGPGSleepSeconds = 30
|
|
|
|
// fakeGPGPerms makes the stand-in script executable.
|
|
fakeGPGPerms os.FileMode = 0o700
|
|
)
|
|
|
|
// fakeSlowGPGOnPath puts a stand-in for gpg first on PATH that blocks
|
|
// until it is killed, so a deadline is the only thing that can end the
|
|
// invocation.
|
|
//
|
|
// The stand-in is a shell script that spawns a child, which means the
|
|
// child inherits the write end of the captured stdout and stderr pipes.
|
|
// That mirrors a real gpg delegating to a helper process, and it is the
|
|
// case that hangs Cmd.Wait if only the direct child is killed.
|
|
func fakeSlowGPGOnPath(t *testing.T) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
script := "#!/bin/sh\nsleep " + strconv.Itoa(fakeGPGSleepSeconds) + "\n"
|
|
|
|
// fakeGPGPerms is 0700 rather than 0600 because the stand-in has to
|
|
// be executable to be run at all; it lives in a test-controlled
|
|
// t.TempDir().
|
|
require.NoError(t,
|
|
os.WriteFile(filepath.Join(dir, "gpg"), []byte(script), fakeGPGPerms))
|
|
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
|
|
// TestGPGDeadlineIsEnforced pins that a gpg invocation which outlives its
|
|
// deadline returns a timeout error naming the operation, rather than
|
|
// hanging or surfacing a bare context error.
|
|
//
|
|
//nolint:paralleltest // fakeSlowGPGOnPath mutates the process-global PATH
|
|
func TestGPGDeadlineIsEnforced(t *testing.T) {
|
|
fakeSlowGPGOnPath(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), gpgTestDeadline)
|
|
defer cancel()
|
|
|
|
started := time.Now()
|
|
sig, err := gpgSign(ctx, []byte("data to sign"), GPGKeyID("KEYID"))
|
|
elapsed := time.Since(started)
|
|
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errGPGTimeout)
|
|
assert.Empty(t, sig)
|
|
|
|
// The error must say which invocation stalled. "context deadline
|
|
// exceeded" alone tells an operator nothing.
|
|
assert.Contains(t, err.Error(), gpgOpSign)
|
|
|
|
// Returning at all is the headline, but it has to return promptly:
|
|
// if the stand-in's child kept the output pipes open, Cmd.Wait would
|
|
// sit there until gpgWaitDelay elapsed.
|
|
assert.Less(t, elapsed, gpgWaitDelay,
|
|
"Run should return on cancellation, not wait out gpgWaitDelay")
|
|
}
|
|
|
|
// TestGPGCancellationPropagates pins that a caller's cancellation reaches
|
|
// gpg and is reported as a cancellation rather than as a timeout.
|
|
//
|
|
//nolint:paralleltest // fakeSlowGPGOnPath mutates the process-global PATH
|
|
func TestGPGCancellationPropagates(t *testing.T) {
|
|
fakeSlowGPGOnPath(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := gpgSign(ctx, []byte("data to sign"), GPGKeyID("KEYID"))
|
|
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.NotErrorIs(t, err, errGPGTimeout)
|
|
assert.Contains(t, err.Error(), gpgOpSign)
|
|
}
|