Exit 130 and say so when a command is interrupted (closes #267)
check / check (push) Waiting to run

Ctrl-C or SIGTERM during snapshot create, restore or verify exited 0
with no error line (1, also silent, under snapshot verify --json), so
an unfinished --cron backup looked like a success. RunOperation now
counts an op as interrupted when the Vaultik context was cancelled
before it returned, rather than when its error wraps context.Canceled,
which verify --json does not. Entry prints "interrupted before the
command finished" on stderr for it and returns 130, under --cron and
--json too.

SIGTERM also gives 130, as the issue asks, not 143.
The test does not reach the branch for an op still running when the
30s shutdown timeout ends.

Model: opus-5-5
This commit is contained in:
2026-10-07 17:28:24 +00:00
parent d87202fb70
commit 5045bac92f
4 changed files with 266 additions and 16 deletions
+39 -12
View File
@@ -206,6 +206,10 @@ func RunApp(ctx context.Context, app *fx.App) error {
// RunOperation through cobra to Entry.
var errReported = errors.New("operation failed")
// errInterrupted marks an operation that SIGINT or SIGTERM stopped
// before it finished. Entry shows it and exits with exitCodeInterrupted.
var errInterrupted = errors.New("interrupted before the command finished")
// RunOperation runs op against the Vaultik instance inside the fx app
// and turns a failure into a returned error rather than an os.Exit from
// within the goroutine. An os.Exit there skipped main's deferred
@@ -220,17 +224,17 @@ var errReported = errors.New("operation failed")
// interrupt OnStop cancels op and waits for the goroutine to return, so
// op's cleanup (removing decrypted scratch files) runs before the
// process exits; the wait is bounded by shutdownTimeout. report is
// called with a non-canceled failure so the caller can show it to the
// user before it becomes errReported. A context cancellation is the
// interrupt path, not a failure: it is neither reported nor counted as
// one.
// called with a failure so the caller can show it to the user before
// it becomes errReported. An interrupted op is not reported, whatever
// it returned; RunOperation returns errInterrupted instead.
func RunOperation(
ctx context.Context, opts AppOptions,
op func(v *vaultik.Vaultik) error, report func(err error),
) error {
var (
mu sync.Mutex
failed bool
mu sync.Mutex
failed bool
interrupted bool
)
opts.Invokes = append(opts.Invokes,
@@ -241,7 +245,19 @@ func RunOperation(
OnStart: func(_ context.Context) error {
stop = v.StartOperation(func() {
err := op(v)
if err != nil && !errors.Is(err, context.Canceled) {
// Only stop, called from OnStop below, cancels the
// Vaultik context; before op has returned, that
// happens only on an interrupt. Check the context,
// not err: an interrupted op need not return
// context.Canceled (`snapshot verify --json`
// returns a verification failure).
switch {
case v.Context().Err() != nil:
mu.Lock()
interrupted = true
mu.Unlock()
case err != nil:
report(err)
mu.Lock()
@@ -266,6 +282,12 @@ func RunOperation(
if !stop(ctx) {
log.Warn("Shutdown timed out before the operation " +
"finished; decrypted temporary files may remain")
// op has not returned, so the app is stopping on
// an interrupt.
mu.Lock()
interrupted = true
mu.Unlock()
}
return nil
@@ -278,16 +300,21 @@ func RunOperation(
return err
}
// The goroutine sets failed before triggering the shutdown that lets
// RunWithApp return, so the write is in place by the time we read it.
// The goroutine sets failed or interrupted before triggering the
// shutdown that lets RunWithApp return, so the write is in place by
// the time we read it. If OnStop timed out, the goroutine has not
// got that far, and OnStop has set interrupted itself.
mu.Lock()
defer mu.Unlock()
if failed {
switch {
case interrupted:
return errInterrupted
case failed:
return errReported
default:
return nil
}
return nil
}
// runVaultikApp runs the standard single-operation command lifecycle