authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-31 03:46:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 03:46:47-07:00
logef4ec2ad4b9b08d1aca31d68aea28f0a846ef295
tree577179355579f677b4dec2e9d23aa61ab260e6aa
parent61514c1294ff79e2a9924551b2042fcc0d1c3a34
signature Signed by PGP key B5690EEEBB952194

fix(server): self-update no longer rolls itself back on restart (#5095)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 23 insertions(+), 6 deletions(-)

apps/server/src/cloud/selfUpdate.test.ts+10-2
......@@ -545,11 +545,19 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
545545 yield* TestClock.adjust(Duration.seconds(10));
546546 assert.deepEqual(context.commands[3], {
547547 command: "systemctl",
548 args: ["--user", "restart", "t3code.service"],
548 args: ["--user", "restart", "--no-block", "t3code.service"],
549549 });
550550 assert.lengthOf(context.spawns, 0);
551551 // systemd replaces the process; the server must not exit itself.
552552 assert.equal(context.exitCount(), 0);
553
554 // The queued restart returns while this process is still shutting
555 // down; the lock must stay held so a second update cannot rewrite the
556 // unit mid-teardown.
557 const concurrentError = yield* context.service
558 .update({ targetVersion: "0.0.30" })
559 .pipe(Effect.flip);
560 assert.include(concurrentError.reason, "already in progress");
553561 }).pipe(Effect.provide(TestClock.layer())),
554562 );
555563
......@@ -583,7 +591,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
583591 assert.deepEqual(
584592 context.commands.slice(-2).map((entry) => entry.args),
585593 [
586 ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
594 ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
587595 ["--user", "daemon-reload"],
588596 ],
589597 );
apps/server/src/cloud/selfUpdate.ts+13-4
......@@ -354,14 +354,19 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
354354 targetVersion,
355355 });
356356 // Restart after the acknowledgement has had time to cross any relay
357 // hop. If systemd rejects the handoff, restore the previous unit while
358 // this process is still alive and log the failure for diagnostics.
357 // hop. --no-block queues the restart job and exits before systemd
358 // stops this unit: a blocking restart's SIGTERM reaches the systemctl
359 // child (it shares this service's cgroup), which read as a restart
360 // failure and rolled the new unit back while the old server finished
361 // shutting down. With the handoff race gone, a non-zero exit or spawn
362 // error means systemd genuinely rejected the job while this process is
363 // still alive, so restoring the previous unit below stays correct.
359364 yield* scheduleRestart(
360365 Effect.gen(function* () {
361366 const restart = yield* runner
362367 .run({
363368 command: "systemctl",
364 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
369 args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
365370 })
366371 .pipe(
367372 Effect.mapError((cause) =>
......@@ -389,9 +394,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
389394 Effect.catch((error) =>
390395 Effect.logError("Server self-update could not restart the boot service.").pipe(
391396 Effect.annotateLogs({ targetVersion, error: error.reason }),
397 // Permit a retry only after the failed handoff was rolled
398 // back. A queued restart returns while this process is still
399 // shutting down; releasing the lock then would let a second
400 // update rewrite the unit mid-teardown.
401 Effect.andThen(Ref.set(inFlight, false)),
392402 ),
393403 ),
394 Effect.ensuring(Ref.set(inFlight, false)),
395404 ),
396405 );
397406 } else {