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) => {...@@ -545,11 +545,19 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
545 yield* TestClock.adjust(Duration.seconds(10));545 yield* TestClock.adjust(Duration.seconds(10));
546 assert.deepEqual(context.commands[3], {546 assert.deepEqual(context.commands[3], {
547 command: "systemctl",547 command: "systemctl",
548 args: ["--user", "restart", "t3code.service"],548 args: ["--user", "restart", "--no-block", "t3code.service"],
549 });549 });
550 assert.lengthOf(context.spawns, 0);550 assert.lengthOf(context.spawns, 0);
551 // systemd replaces the process; the server must not exit itself.551 // systemd replaces the process; the server must not exit itself.
552 assert.equal(context.exitCount(), 0);552 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");
553 }).pipe(Effect.provide(TestClock.layer())),561 }).pipe(Effect.provide(TestClock.layer())),
554 );562 );
555563
...@@ -583,7 +591,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {...@@ -583,7 +591,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
583 assert.deepEqual(591 assert.deepEqual(
584 context.commands.slice(-2).map((entry) => entry.args),592 context.commands.slice(-2).map((entry) => entry.args),
585 [593 [
586 ["--user", "restart", BOOT_SERVICE_UNIT_FILE],594 ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
587 ["--user", "daemon-reload"],595 ["--user", "daemon-reload"],
588 ],596 ],
589 );597 );
apps/server/src/cloud/selfUpdate.ts+13-4
...@@ -354,14 +354,19 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -354,14 +354,19 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
354 targetVersion,354 targetVersion,
355 });355 });
356 // Restart after the acknowledgement has had time to cross any relay356 // Restart after the acknowledgement has had time to cross any relay
357 // hop. If systemd rejects the handoff, restore the previous unit while357 // hop. --no-block queues the restart job and exits before systemd
358 // this process is still alive and log the failure for diagnostics.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.
359 yield* scheduleRestart(364 yield* scheduleRestart(
360 Effect.gen(function* () {365 Effect.gen(function* () {
361 const restart = yield* runner366 const restart = yield* runner
362 .run({367 .run({
363 command: "systemctl",368 command: "systemctl",
364 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],369 args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
365 })370 })
366 .pipe(371 .pipe(
367 Effect.mapError((cause) =>372 Effect.mapError((cause) =>
...@@ -389,9 +394,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option...@@ -389,9 +394,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
389 Effect.catch((error) =>394 Effect.catch((error) =>
390 Effect.logError("Server self-update could not restart the boot service.").pipe(395 Effect.logError("Server self-update could not restart the boot service.").pipe(
391 Effect.annotateLogs({ targetVersion, error: error.reason }),396 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)),
392 ),402 ),
393 ),403 ),
394 Effect.ensuring(Ref.set(inFlight, false)),
395 ),404 ),
396 );405 );
397 } else {406 } else {