Backup spawns (restic/pg_dump) have no timeout - a hung repo silently stops all backups #86
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/TeaLeaves#86
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium · Confidence: High · Effort: S · Category: ops
Evidence
api/src/services/backup.ts:94-155-run/runBuffer/spawnPgDumphave no timeout, nosignal.api/src/services/backup.ts:452- backup-schedule workerconcurrency: 1.Problem
A stuck
restic(unreachable B2/S3/SFTP, network stall) orpg_dumpnever resolves. With concurrency 1 and no timeout, the single backup worker blocks permanently; every subsequent scheduled backup never runs while the job sits instatus='running'forever.Impact
Silent failure of the backup promise - the dashboard shows "running", not "failed", so no alert fires. Backups quietly stop.
Fix
Wrap spawns with a timeout (kill child + reject after N minutes) and, on the next scheduler tick, treat a job
runninglonger than a threshold as failed (fire the alert).Acceptance criteria
runningjob is reconciled to failed and fires the configured alert.Related: F-07 (restore), F-08 (shutdown).
Filed from the 2026-07-15 codebase audit. Full report:
docs/.internal/report-2026-07-15.md(gitignored).Fixed in
b640072(v7.1.0 wave 3).run,runBuffer,runWithStdinBufferandspawnPgDumpnow arm a kill-timer (SPAWN_TIMEOUT_MS, 2 h) via a sharedarmTimeout()helper — the child isSIGKILLed and the promise rejects, so the job is failed rather than hanging the concurrency-1 worker forever.errorhandler, so a spawn failure (e.g.restic/pg_dumpbinary missing) left the promise pending forever — a second silent-hang path the issue didn't name. All four nowrejecton'error'.reconcileStaleJobs()fails any job stillrunningpastSTALE_JOB_MS(3 h — deliberately > the spawn timeout, so a timed-out spawn fails the job first and this only catches jobs orphaned by a hard crash), sets a descriptiveerror_message, and fires the destination'salert_webhook_url. It runs on each backup-worker tick (before the run, so a stale row can't block the tick) and errors are caught so it can't break the backup itself.Threshold values are parameterised (
make_interval(secs => $1)), per the project's SQL invariant.Acceptance criteria:
runningjob is reconciled to failed and fires the configured alert.CI green (238/238 tests). Related: #85 (graceful shutdown) prevents the common cause of a stranded
runningjob in the first place.