authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-01 22:42:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-01 22:42:39-07:00
log64bf0161919ec0e41470af3304928d9fc8711bf4
tree98caf7f24875e42ea5cfa05f85d07878de1c2a36
parent5192f777fe54c2a2a359f6c25ecf5fbde46d49b0
signature Signed by PGP key B5690EEEBB952194

fix(server): stop npx service updates from silently leaving the old server running (#5217)

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

2 files changed, 22 insertions(+), 15 deletions(-)

apps/server/src/cloud/bootService.test.ts+17-2
...@@ -33,6 +33,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",...@@ -33,6 +33,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
3333
34const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (34const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
35 platform: NodeJS.Platform = "linux",35 platform: NodeJS.Platform = "linux",
36 usePinnedLauncher = false,
36) {37) {
37 const fs = yield* FileSystem.FileSystem;38 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;39 const path = yield* Path.Path;
...@@ -44,6 +45,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -44,6 +45,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
44 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3");45 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3");
45 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });46 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
46 yield* fs.writeFileString(runtime.entryPath, "export {};\n");47 yield* fs.writeFileString(runtime.entryPath, "export {};\n");
48 yield* fs.writeFileString(
49 path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"),
50 "export const source = 'pinned runtime';\n",
51 );
47 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");52 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
4853
49 const commands: string[] = [];54 const commands: string[] = [];
...@@ -69,8 +74,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -69,8 +74,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
69 cliVersion: "1.2.3",74 cliVersion: "1.2.3",
70 host: {75 host: {
71 execPath: "/usr/bin/node",76 execPath: "/usr/bin/node",
72 cliEntryPath: path.join(home, "bin.mjs"),77 ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
73 launcherSourcePath: sourceLauncher,
74 },78 },
75 }).pipe(79 }).pipe(
76 Effect.provideService(ProcessRunner.ProcessRunner, runner),80 Effect.provideService(ProcessRunner.ProcessRunner, runner),
...@@ -109,6 +113,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -109,6 +113,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
109 }),113 }),
110 );114 );
111115
116 it.effect("copies the launcher from the prepared pinned runtime", () =>
117 Effect.gen(function* () {
118 const { service, fs } = yield* makeHarness("linux", true);
119 const plan = yield* service.install;
120
121 expect(yield* fs.readFileString(plan.launcherPath)).toBe(
122 "export const source = 'pinned runtime';\n",
123 );
124 }),
125 );
126
112 it.effect("restarts an installed service when repair fails", () =>127 it.effect("restarts an installed service when repair fails", () =>
113 Effect.gen(function* () {128 Effect.gen(function* () {
114 const { service, commands, control } = yield* makeHarness();129 const { service, commands, control } = yield* makeHarness();
apps/server/src/cloud/bootService.ts+5-13
...@@ -1,8 +1,4 @@...@@ -1,8 +1,4 @@
1import {1import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2 HostProcessArguments,
3 HostProcessExecutablePath,
4 HostProcessPlatform,
5} from "@t3tools/shared/hostProcess";
6import * as Config from "effect/Config";2import * as Config from "effect/Config";
7import * as Context from "effect/Context";3import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";4import * as DateTime from "effect/DateTime";
...@@ -138,7 +134,6 @@ export class BootService extends Context.Service<...@@ -138,7 +134,6 @@ export class BootService extends Context.Service<
138134
139export interface BootServiceHost {135export interface BootServiceHost {
140 readonly execPath: string;136 readonly execPath: string;
141 readonly cliEntryPath: string;
142 readonly launcherSourcePath?: string;137 readonly launcherSourcePath?: string;
143}138}
144139
...@@ -148,26 +143,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -148,26 +143,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
148 readonly cliVersion: string;143 readonly cliVersion: string;
149 readonly host?: BootServiceHost;144 readonly host?: BootServiceHost;
150}) {145}) {
151 const hostArguments = yield* HostProcessArguments;
152 const hostExecPath = yield* HostProcessExecutablePath;146 const hostExecPath = yield* HostProcessExecutablePath;
153 const platform = yield* HostProcessPlatform;147 const platform = yield* HostProcessPlatform;
154 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));148 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
155 const fs = yield* FileSystem.FileSystem;149 const fs = yield* FileSystem.FileSystem;
156 const path = yield* Path.Path;150 const path = yield* Path.Path;
157 const runner = yield* ProcessRunner.ProcessRunner;151 const runner = yield* ProcessRunner.ProcessRunner;
158 const host = input.host ?? {152 const host = input.host ?? { execPath: hostExecPath };
159 execPath: hostExecPath,
160 cliEntryPath: hostArguments[1] ?? "",
161 };
162153
163 const unitDir = path.join(homeDir, ".config", "systemd", "user");154 const unitDir = path.join(homeDir, ".config", "systemd", "user");
164 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);155 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
165 const logPath = path.join(input.logsDir, "boot-service.log");156 const logPath = path.join(input.logsDir, "boot-service.log");
166 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);157 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
167 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);158 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
168 const launcherSourcePath =
169 host.launcherSourcePath ?? path.join(path.dirname(host.cliEntryPath), SERVICE_LAUNCHER_FILE);
170 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);159 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
160 const launcherSourcePath =
161 host.launcherSourcePath ??
162 path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE);
171 const writeDurably = (filePath: string, contents: string) =>163 const writeDurably = (filePath: string, contents: string) =>
172 Effect.scoped(164 Effect.scoped(
173 Effect.gen(function* () {165 Effect.gen(function* () {