Repository navigation
Conversation
…ase backup Before an update, the service launcher copies the state database and only then starts the new server. For a large database the copy takes minutes, and the launcher said nothing and ignored SIGTERM until it finished, so the server looked dead and service managers killed it after their stop timeout. The next start then began the copy from scratch. The launcher now logs the backup's size and destination, a progress line every 15 seconds (including while it flushes to disk), and the elapsed time. A stop during the backup aborts the copy, removes the staging files, and leaves the update pending for the next launch. Filesystems that can clone still get an instant copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cloned file never went through the streamed copy, so the heartbeat kept reporting 0% while the clone was being flushed to disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Additional ext4 data from the performance audits, consolidated here because this PR already identifies the offline database-copy step. No separate new issue is needed for the same update gap. Ubuntu service, nightly Current upstream This does not establish that all measured downtime is copying, that a safe online-copy alternative is trivial, or that every historical update copied today's database size. The audit's initial estimate of ~30 GB of past backup writes extrapolated the present size to every update and should not be treated as a measurement. No live update or service restart was performed for this consolidation, and this is not validation of the PR's current head. |
Problem
When the background service updates itself, the launcher stops the old server and copies the state database as its rollback snapshot before it starts the new one. On a headless Linux box with a multi-GB database, that copy took about 4 minutes. During the copy, three things went wrong:
systemctl --user stopgot no reaction. systemd waited outTimeoutStopSecand then SIGKILLed the launcher.Merge Danger
Warning
🟡 Merge risk: medium · two-way door (merge-risk rubric)
fs.copyFile. A bad copy would leave a broken snapshot, and only a failed update would expose it, by restoring that snapshot.pipelineresolves only after the write stream has finished, and each file is stillfsynced before the staging directory is renamed into place. The existing snapshot and restore tests pass unchanged, and new tests cover the stop path. No state format changes, so a revert fully undoes it.Change
The launcher now makes this step observable and stoppable:
fsync), and the elapsed time at the end. A failed backup logs its cause before the launcher returns to the previous version.AbortSignal. The staging directory is removed, no trial starts, and the update stays pending, so the next launch retries it. The launcher exits cleanly instead of waiting for SIGKILL.COPYFILE_FICLONE_FORCEfirst. That keeps the instant clone thatcopy_file_rangealready gave on Linux btrfs/XFS, which a plain streamed copy would lose. Everywhere else (ext4, and macOS, where the clone fails with ENOSYS as [Bug]: Server: service self-update stops the server before checking the database backup fits #14515 notes), it streams.Trade-off: managed tunnel. Today, a stop during the handoff lets the trial start, and the trial releases a CLI-managed tunnel on its way down. When a stop now interrupts the snapshot, no trial starts, so that tunnel is kept for the next launch. Starting a trial without a complete snapshot would be worse, because its migrations could not be rolled back. The tunnel is already kept the same way today whenever systemd SIGKILLs a long backup. I added one sentence about this rule to
docs/internals/server-updates.md.Not in this PR, as follow-ups:
#handleUpdateRequest.orchestration_eventstable. Bounding or compacting it would shrink the snapshot itself, and needs its own design.Scope and approval
This is a focused reliability fix to one launcher step: the pre-update snapshot. There is no triaged issue for the silent, unstoppable copy itself. Related: #14515 (triaged: the same step, missing a free-space check) and #17937 (upgrade brittleness on large databases).
Verification
Test seams, both through the public
Launcher(run(),stop(), and the persisted service state):pendingand that neitherdb-backup/<id>nor its.stagingdirectory exists. Before the fix, it failed: the backup completed and the trial spawned anyway.Checks run:
vp test run apps/server/src/serviceLauncher.test.ts: 11 passed (9 existing, including database restore after a failed trial, plus 2 new)vp linton both files, andvp run typecheckinapps/server: cleanManual check: I ran the real launcher (
main()withT3CODE_HOMEset) on a pending update with an 8 GiB database on ext4, using fake runtimes that only idle, and sent SIGTERM from a shell.SIGTERM 8 seconds into the copy, after the fix: exits in 1.0s, update still pending, no staging left behind.
Same SIGTERM, before the fix: no output at all, and the launcher outlived the signal by 187s. The backup finished, the trial started anyway, and the trial's 120s timer kept the process alive.
A full backup, after the fix, showing the heartbeat through the final
fsync:Review: one cross-model pass (Codex, GPT-6.1 Sol, high effort) found no blocking defects. It flagged that a cloned file never counted toward progress (fixed in the follow-up commit), and the pre-existing staging fsync gap (listed above as a follow-up).
Gaps:
fsyncstill waits for it to finish, becausefsynccannot be cancelled. On this machine that took about 50s for 8 GiB.Model and harness: Claude Opus 5.5 in Claude Code, inside T3 Code.
🤖 Generated with Claude Code