Skip to content

Commit 3d81780

Browse files
committed
feat(server): environments update themselves when nothing is running
Background service installs stage the newest release on their channel, then hand off to the launcher once the update window opens. Desktop-hosted servers relaunch the app into an update it already downloaded once no window is in use. Both follow the Update automatically setting. A rolled-back target is remembered by the launcher and not retried, and a release that changes the orchestration protocol is left for a manual update so connected clients are not locked out.
1 parent 25b6b08 commit 3d81780

13 files changed

Lines changed: 340 additions & 95 deletions

File tree

‎apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
473473
projectSelected
474474
? "Environment-wide setting. Select All projects to change it."
475475
: supportsAutomaticUpdates
476-
? "Install provider CLI updates while nothing is running."
476+
? "Install T3 Code and provider updates while nothing is running."
477477
: "Update older servers to control automatic updates."
478478
}
479479
value={uniform("automaticUpdates")}

‎apps/server/src/cli/update.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const RELEASE_INDEX_TIMEOUT = Duration.seconds(30);
6262
const RELEASE_INDEX_MAX_PAGES = 10;
6363

6464
/** Asks GitHub for the newest published version on a channel, page by page. */
65-
const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
65+
export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
6666
channel: CliReleaseChannel,
6767
releaseBaseUrl: string | undefined,
6868
) {

‎apps/server/src/cloud/selfUpdate.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
105105
return "launcher-id";
106106
})),
107107
prepareTrial: Effect.undefined,
108+
lastOutcome: undefined,
108109
});
109110
const config = yield* ServerConfig.ServerConfig.pipe(
110111
Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
@@ -127,13 +128,16 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
127128
return { selfUpdate, order };
128129
});
129130

131+
const withoutStaging = { capability: null, stage: () => Effect.die("unused") } as const;
132+
130133
it.layer(NodeServices.layer)("server self update", (it) => {
131134
it.effect("marks running threads at the boot-service handoff", () =>
132135
Effect.gen(function* () {
133136
const events: string[] = [];
134137
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
135138
mode: "web",
136139
selfUpdate: {
140+
...withoutStaging,
137141
update: (_input, reportProgress = () => Effect.void) =>
138142
reportProgress("downloading").pipe(
139143
Effect.andThen(reportProgress("installing")),
@@ -168,6 +172,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
168172
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
169173
mode: "desktop",
170174
selfUpdate: {
175+
...withoutStaging,
171176
update: (_input, reportProgress = () => Effect.void) =>
172177
reportProgress("installing").pipe(
173178
Effect.as({
@@ -216,6 +221,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
216221
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
217222
mode: "web",
218223
selfUpdate: {
224+
...withoutStaging,
219225
update: (_input, reportProgress = () => Effect.void) =>
220226
reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))),
221227
commitDesktopUpdate: () => Effect.never,
@@ -238,6 +244,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
238244
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
239245
mode: "web",
240246
selfUpdate: {
247+
...withoutStaging,
241248
update: (
242249
_input,
243250
reportProgress = () => Effect.void,
@@ -271,6 +278,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
271278
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
272279
mode: "desktop",
273280
selfUpdate: {
281+
...withoutStaging,
274282
update: () =>
275283
Effect.succeed({
276284
targetVersion: "1.2.0",
@@ -307,6 +315,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
307315
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
308316
mode: "desktop",
309317
selfUpdate: {
318+
...withoutStaging,
310319
update: () =>
311320
Effect.succeed({
312321
targetVersion: "1.2.0",

‎apps/server/src/cloud/selfUpdate.ts‎

Lines changed: 124 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as Effect from "effect/Effect";
1515
import * as HashSet from "effect/HashSet";
1616
import * as Option from "effect/Option";
1717
import * as Ref from "effect/Ref";
18+
import * as Semaphore from "effect/Semaphore";
1819
import * as FileSystem from "effect/FileSystem";
1920
import * as Layer from "effect/Layer";
2021
import * as Path from "effect/Path";
@@ -59,6 +60,15 @@ export class ServerSelfUpdate extends Context.Service<
5960
requestId: string,
6061
onHandoffAccepted?: () => Effect.Effect<void>,
6162
) => Effect.Effect<never, ServerSelfUpdateError>;
63+
/** How this server can update itself, or null when it cannot. */
64+
readonly capability: ServerSelfUpdateCapability | null;
65+
/** Downloads and preflights a boot-service runtime without restarting. */
66+
readonly stage: (
67+
targetVersion: string,
68+
) => Effect.Effect<
69+
{ readonly orchestrationProtocol: number | undefined },
70+
ServerSelfUpdateError
71+
>;
6272
}
6373
>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
6474

@@ -137,6 +147,7 @@ export const withRunningThreadContinuation = Effect.fn(
137147
};
138148

139149
return ServerSelfUpdate.of({
150+
...input.selfUpdate,
140151
update,
141152
commitDesktopUpdate: (requestId) =>
142153
Effect.gen(function* () {
@@ -185,6 +196,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
185196
yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
186197
);
187198
const inFlight = yield* Ref.make(false);
199+
// A background stage and a manual update may target the same version.
200+
const stageLock = yield* Semaphore.make(1);
188201

189202
const capability: ServerSelfUpdateCapability | null =
190203
serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
@@ -193,6 +206,109 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
193206
? new ServerSelfUpdateError({ reason })
194207
: new ServerSelfUpdateError({ reason, cause });
195208

209+
/** Downloads and preflights a runtime without handing off, so a background
210+
update can stage first and restart later. */
211+
const stageRuntime = Effect.fn("cloud.server_self_update.stage")(function* (
212+
targetVersion: string,
213+
) {
214+
return yield* stageLock.withPermits(1)(stageRuntimeUnlocked(targetVersion));
215+
});
216+
const stageRuntimeUnlocked = Effect.fn("cloud.server_self_update.stageUnlocked")(function* (
217+
targetVersion: string,
218+
) {
219+
let orchestrationProtocol: number | undefined;
220+
const paths = yield* ensurePinnedRuntimeInstalled({
221+
baseDir: serverConfig.baseDir,
222+
version: targetVersion,
223+
fs,
224+
path,
225+
runner,
226+
httpClient,
227+
platform,
228+
arch,
229+
releaseBaseUrl,
230+
validate: (runtime) =>
231+
runner
232+
.run({
233+
command: pinnedRuntimeCommand(runtime).command,
234+
args: [
235+
...pinnedRuntimeCommand(runtime).args,
236+
"__service-preflight",
237+
"--database-path",
238+
serverConfig.dbPath,
239+
"--launcher-protocol",
240+
String(SERVICE_LAUNCHER_PROTOCOL),
241+
],
242+
timeout: PREFLIGHT_TIMEOUT,
243+
})
244+
.pipe(
245+
Effect.mapError(
246+
(cause) =>
247+
new PinnedRuntimeInstallError({
248+
step: "running the staged service preflight",
249+
cause,
250+
}),
251+
),
252+
Effect.flatMap(
253+
(
254+
result,
255+
): Effect.Effect<
256+
void,
257+
PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
258+
> => {
259+
if (result.code !== 0) {
260+
return Effect.fail(
261+
new PinnedRuntimeInstallError({
262+
step: "running the staged service preflight",
263+
exitCode: Number(result.code),
264+
stdoutLength: result.stdout.length,
265+
stderrLength: result.stderr.length,
266+
}),
267+
);
268+
}
269+
let parsed: unknown;
270+
try {
271+
parsed = JSON.parse(result.stdout.trim());
272+
} catch (cause) {
273+
return Effect.fail(
274+
new PinnedRuntimeInstallError({
275+
step: "decoding the staged service preflight",
276+
cause,
277+
}),
278+
);
279+
}
280+
const preflight = decodeServicePreflightResult(parsed);
281+
if (preflight === undefined || preflight.version !== targetVersion) {
282+
return Effect.fail(
283+
new PinnedRuntimeInstallError({
284+
step: "verifying the staged service preflight",
285+
}),
286+
);
287+
}
288+
if (preflight.status === "ready") {
289+
orchestrationProtocol = preflight.orchestrationProtocol;
290+
}
291+
return preflight.status === "ready"
292+
? Effect.void
293+
: Effect.fail(
294+
new PinnedRuntimePreflightBlockedError({
295+
version: targetVersion,
296+
reason: preflight.reason,
297+
}),
298+
);
299+
},
300+
),
301+
),
302+
}).pipe(
303+
Effect.mapError((error) =>
304+
error._tag === "PinnedRuntimePreflightBlockedError"
305+
? failWith(error.reason, error)
306+
: failWith(`Could not prepare t3@${targetVersion}.`, error),
307+
),
308+
);
309+
return { paths, orchestrationProtocol };
310+
});
311+
196312
const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
197313
"cloud.server_self_update.update",
198314
)(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
@@ -223,92 +339,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
223339

224340
return yield* Effect.gen(function* () {
225341
yield* reportProgress("downloading");
226-
const paths = yield* ensurePinnedRuntimeInstalled({
227-
baseDir: serverConfig.baseDir,
228-
version: targetVersion,
229-
fs,
230-
path,
231-
runner,
232-
httpClient,
233-
platform,
234-
arch,
235-
releaseBaseUrl,
236-
validate: (runtime) =>
237-
runner
238-
.run({
239-
command: pinnedRuntimeCommand(runtime).command,
240-
args: [
241-
...pinnedRuntimeCommand(runtime).args,
242-
"__service-preflight",
243-
"--database-path",
244-
serverConfig.dbPath,
245-
"--launcher-protocol",
246-
String(SERVICE_LAUNCHER_PROTOCOL),
247-
],
248-
timeout: PREFLIGHT_TIMEOUT,
249-
})
250-
.pipe(
251-
Effect.mapError(
252-
(cause) =>
253-
new PinnedRuntimeInstallError({
254-
step: "running the staged service preflight",
255-
cause,
256-
}),
257-
),
258-
Effect.flatMap(
259-
(
260-
result,
261-
): Effect.Effect<
262-
void,
263-
PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
264-
> => {
265-
if (result.code !== 0) {
266-
return Effect.fail(
267-
new PinnedRuntimeInstallError({
268-
step: "running the staged service preflight",
269-
exitCode: Number(result.code),
270-
stdoutLength: result.stdout.length,
271-
stderrLength: result.stderr.length,
272-
}),
273-
);
274-
}
275-
let parsed: unknown;
276-
try {
277-
parsed = JSON.parse(result.stdout.trim());
278-
} catch (cause) {
279-
return Effect.fail(
280-
new PinnedRuntimeInstallError({
281-
step: "decoding the staged service preflight",
282-
cause,
283-
}),
284-
);
285-
}
286-
const preflight = decodeServicePreflightResult(parsed);
287-
if (preflight === undefined || preflight.version !== targetVersion) {
288-
return Effect.fail(
289-
new PinnedRuntimeInstallError({
290-
step: "verifying the staged service preflight",
291-
}),
292-
);
293-
}
294-
return preflight.status === "ready"
295-
? Effect.void
296-
: Effect.fail(
297-
new PinnedRuntimePreflightBlockedError({
298-
version: targetVersion,
299-
reason: preflight.reason,
300-
}),
301-
);
302-
},
303-
),
304-
),
305-
}).pipe(
306-
Effect.mapError((error) =>
307-
error._tag === "PinnedRuntimePreflightBlockedError"
308-
? failWith(error.reason, error)
309-
: failWith(`Could not prepare t3@${targetVersion}.`, error),
310-
),
311-
);
342+
const { paths } = yield* stageRuntime(targetVersion);
312343

313344
yield* reportProgress("installing");
314345
const updateId = yield* Effect.uninterruptible(
@@ -338,6 +369,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
338369
update,
339370
commitDesktopUpdate: (requestId, onHandoffAccepted) =>
340371
desktopAppUpdate.commit(requestId, onHandoffAccepted),
372+
capability,
373+
stage: (targetVersion) =>
374+
capability === "boot-service"
375+
? stageRuntime(targetVersion).pipe(
376+
Effect.map(({ orchestrationProtocol }) => ({ orchestrationProtocol })),
377+
)
378+
: Effect.fail(failWith("Only background service installs can stage an update.")),
341379
});
342380
});
343381

‎apps/server/src/cloud/serviceLauncherClient.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export class ServiceLauncherClient extends Context.Service<
108108
ServerSelfUpdateOutcome | undefined,
109109
ServiceLauncherClientError
110110
>;
111+
/** The launcher's last finished update, kept until the next one, so a
112+
rolled-back target is not retried on every boot. */
113+
readonly lastOutcome: ServerSelfUpdateOutcome | undefined;
111114
}
112115
>()("t3/cloud/serviceLauncherClient") {}
113116

@@ -247,6 +250,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
247250
managed,
248251
requestUpdate,
249252
prepareTrial,
253+
lastOutcome: outcome,
250254
});
251255
});
252256

‎apps/server/src/cloud/servicePreflight.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, it } from "@effect/vitest";
2+
import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
23

34
import { runServicePreflight } from "./servicePreflight.ts";
45
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
@@ -29,5 +30,6 @@ it("accepts the current launcher protocol", () => {
2930
status: "ready",
3031
version: "1.2.3",
3132
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
33+
orchestrationProtocol: ORCHESTRATION_PROTOCOL_VERSION,
3234
});
3335
});

0 commit comments

Comments
 (0)