Wait for the interrupted operation to clean up before exit (closes #159)
check / check (pull_request) Successful in 1m21s

On SIGINT/SIGTERM, RunOperation's OnStop hook only cancelled the running
command and returned; nothing waited for its goroutine, so the process
could exit before restore's cleanup defers ran. That left decrypted data
in the temp directory: the blob cache (vaultik-blobcache-*) and the
decrypted snapshot-database directory (vaultik-restore-*).

StartOperation now returns a stop function that cancels the context and
waits for the goroutine to return, bounded by the existing
shutdownTimeout; OnStop calls it and warns if it times out. The fix is in
the shared runner, so it covers every command, not only restore.
Restore's chunk-write and blob-download loops also check the context
between steps so the wait ends promptly.

Model: opus-4-8
This commit is contained in:
2026-09-22 11:07:59 +00:00
parent 3a58377127
commit 1e9aa5deda
4 changed files with 223 additions and 5 deletions
+18 -5
View File
@@ -238,7 +238,10 @@ var errReported = errors.New("operation failed")
//
// op runs in a goroutine so OnStart returns promptly and an interrupt
// can still cancel through OnStop; when it finishes, success or failure,
// it triggers shutdown, which is what lets RunWithApp return. report is
// it triggers shutdown, which is what lets RunWithApp return. On an
// 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 log it (and
// suppress it under --json) before it becomes errReported. A context
// cancellation is the interrupt path, not a failure: it is neither
@@ -254,9 +257,11 @@ func RunOperation(
opts.Invokes = append(opts.Invokes,
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
var stop func(context.Context) bool
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
go func() {
stop = v.StartOperation(func() {
err := op(v)
if err != nil && !errors.Is(err, context.Canceled) {
report(err)
@@ -270,12 +275,20 @@ func RunOperation(
if stopErr != nil {
log.Error("Failed to shutdown", "error", stopErr)
}
}()
})
return nil
},
OnStop: func(_ context.Context) error {
v.Cancel()
// On an interrupt, cancel the operation and wait for it to
// unwind so its cleanup defers (which remove decrypted
// scratch files from the temp directory) run before the
// process exits. The wait is bounded by ctx, the existing
// shutdownTimeout.
OnStop: func(ctx context.Context) error {
if !stop(ctx) {
log.Warn("Shutdown timed out before the operation " +
"finished; decrypted temporary files may remain")
}
return nil
},