History

fix: harden automatic update transitions

t3-code[bot] committed 2 months ago

Commit 6ec81ac1 parent 46ed0925 files · +80 −27Browse files

Unified · Split

5 files
  1. apps/server/src/cloud/selfUpdate.test.ts +34 −0
  2. apps/server/src/cloud/selfUpdate.ts +20 −18
  3. apps/server/src/orchestration/http.ts +15 −5
  4. apps/web/src/components/ChatView.tsx +6 −2
  5. apps/web/src/components/settings/ConnectionsSettings.tsx +5 −2
apps/server/src/cloud/selfUpdate.test.ts+34−0
@@ -20,6 +20,7 @@ interface HarnessOptions { · 19 unmodified lines
20 readonly managed?: boolean;20 readonly managed?: boolean;
21 readonly preflight?: "ready" | "blocked";21 readonly preflight?: "ready" | "blocked";
22 readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>;22 readonly automaticUpdateIdle?: (order: string[]) => Effect.Effect<void, ServerSelfUpdateError>;
23 readonly install?: (order: string[]) => Effect.Effect<void>;
23 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];24 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
24}25}
25 26
@@ -35,6 +36,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( · 9 unmodified lines
35 Effect.gen(function* () {36 Effect.gen(function* () {
36 if (input.command === "npm") {37 if (input.command === "npm") {
37 order.push("install");38 order.push("install");
39 yield* options.install?.(order) ?? Effect.void;
38 const prefix = input.args[input.args.indexOf("--prefix") + 1];40 const prefix = input.args[input.args.indexOf("--prefix") + 1];
39 if (prefix === undefined) return yield* Effect.die("missing npm prefix");41 if (prefix === undefined) return yield* Effect.die("missing npm prefix");
40 const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");42 const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");
@@ -236,6 +238,38 @@ it.layer(NodeServices.layer)("server self update", (it) => { · 195 unmodified lines
236 }),238 }),
237 );239 );
238 240
241 it.effect("allows automatic update staging to be interrupted before handoff", () =>
242 Effect.gen(function* () {
243 const installStarted = yield* Deferred.make<void>();
244 const releaseInstall = yield* Deferred.make<void>();
245 let installs = 0;
246 const { selfUpdate, order } = yield* makeHarness({
247 install: () => {
248 installs += 1;
249 return installs === 1
250 ? Deferred.succeed(installStarted, undefined).pipe(
251 Effect.andThen(Deferred.await(releaseInstall)),
252 )
253 : Effect.void;
254 },
255 });
256 const updateFiber = yield* selfUpdate
257 .update({ targetVersion: "1.1.0", automatic: true })
258 .pipe(Effect.forkChild);
259 yield* Deferred.await(installStarted);
260 const interruptFiber = yield* Fiber.interrupt(updateFiber).pipe(Effect.forkChild);
261 yield* Deferred.succeed(releaseInstall, undefined);
262 yield* Fiber.join(interruptFiber);
263
264 expect(order).toEqual(["install"]);
265 expect(yield* selfUpdate.update({ targetVersion: "1.1.0", automatic: true })).toEqual({
266 targetVersion: "1.1.0",
267 method: "boot-service",
268 updateId: "launcher-id",
269 });
270 }),
271 );
272
239 it.effect("blocks new commands after an automatic handoff is accepted", () =>273 it.effect("blocks new commands after an automatic handoff is accepted", () =>
240 Effect.gen(function* () {274 Effect.gen(function* () {
241 const { selfUpdate } = yield* makeHarness();275 const { selfUpdate } = yield* makeHarness();
apps/server/src/cloud/selfUpdate.ts+20−18
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";8import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
9import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";9import { hasAutomaticServerUpdateActiveWork } from "@t3tools/shared/automaticServerUpdate";
10import { compareSemverVersions } from "@t3tools/shared/semver";10import { compareSemverVersions } from "@t3tools/shared/semver";
11import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
12import * as Duration from "effect/Duration";13import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
@@ -243,23 +244,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 229 unmodified lines
243 ? failWith(error.reason, error)244 ? failWith(error.reason, error)
244 : failWith(`Could not prepare t3@${targetVersion}.`, error),245 : failWith(`Could not prepare t3@${targetVersion}.`, error),
245 ),246 ),
247 Effect.interruptible,
246 );248 );
247 249
248 if (input.automatic === true) {
249 yield* commandAdmission.withPermits(1)(
250 Effect.gen(function* () {
251 yield* idleCheck.assertIdle.pipe(
252 Effect.tapError((error) =>
253 Effect.sync(() => {
254 retryWhenIdleFailure = error.retryWhenIdle === true;
255 }),
256 ),
257 );
258 yield* Ref.set(automaticHandoffPending, true);
259 }),
260 );
261 }
262
263 const activatePreparedUpdate = Effect.gen(function* () {250 const activatePreparedUpdate = Effect.gen(function* () {
264 yield* reportProgress("installing");251 yield* reportProgress("installing");
265 const updateId = yield* launcher252 const updateId = yield* launcher
@@ -282,16 +269,31 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { · 16 unmodified lines
282 });269 });
283 return { targetVersion, method: "boot-service" as const, updateId };270 return { targetVersion, method: "boot-service" as const, updateId };
284 });271 });
285 return yield* activatePreparedUpdate;272 if (input.automatic !== true) return yield* activatePreparedUpdate;
273
274 return yield* commandAdmission
275 .withPermits(1)(
276 Effect.gen(function* () {
277 yield* idleCheck.assertIdle.pipe(
278 Effect.tapError((error) =>
279 Effect.sync(() => {
280 retryWhenIdleFailure = error.retryWhenIdle === true;
281 }),
282 ),
283 );
284 yield* Ref.set(automaticHandoffPending, true);
285 }),
286 )
287 .pipe(Effect.andThen(activatePreparedUpdate));
286 }).pipe(288 }).pipe(
287 Effect.onError(() =>289 Effect.onError((cause) =>
288 Effect.all(290 Effect.all(
289 [291 [
290 Ref.set(inFlight, false),292 Ref.set(inFlight, false),
291 Ref.set(automaticHandoffPending, false),293 Ref.set(automaticHandoffPending, false),
292 Ref.set(294 Ref.set(
293 lastFailedTarget,295 lastFailedTarget,
294 retryWhenIdleFailure296 retryWhenIdleFailure || Cause.hasInterruptsOnly(cause)
295 ? previousFailedTarget297 ? previousFailedTarget
296 : input.automatic === true ||298 : input.automatic === true ||
297 previousFailedTarget === null ||299 previousFailedTarget === null ||
apps/server/src/orchestration/http.ts+15−5
@@ -95,13 +95,23 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 94 unmodified lines
95 Effect.fn("environment.orchestration.dispatch")(function* (args) {95 Effect.fn("environment.orchestration.dispatch")(function* (args) {
96 yield* annotateEnvironmentRequest(args.endpoint.name);96 yield* annotateEnvironmentRequest(args.endpoint.name);
97 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);97 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);
98 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
99 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
100 );
101 return yield* serverSelfUpdate98 return yield* serverSelfUpdate
102 .withCommandAdmission(orchestrationEngine.dispatch(normalizedCommand))99 .withCommandAdmission(
100 Effect.gen(function* () {
101 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
102 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
103 );
104 return yield* orchestrationEngine
105 .dispatch(normalizedCommand)
106 .pipe(
107 Effect.catch((cause) =>
108 failEnvironmentInternal("orchestration_dispatch_failed", cause),
109 ),
110 );
111 }),
112 )
103 .pipe(113 .pipe(
104 Effect.catch((cause) =>114 Effect.catchTag("ServerSelfUpdateError", (cause) =>
105 failEnvironmentInternal("orchestration_dispatch_failed", cause),115 failEnvironmentInternal("orchestration_dispatch_failed", cause),
106 ),116 ),
107 );117 );
apps/web/src/components/ChatView.tsx+6−2
@@ -2130,8 +2130,11 @@ function ChatViewContent(props: ChatViewProps) { · 2129 unmodified lines
2130 if (2130 if (
2131 serverUpdateEnvironmentId &&2131 serverUpdateEnvironmentId &&
2132 !reconnectingThroughVersionSkew &&2132 !reconnectingThroughVersionSkew &&
2133 (serverUpdateState.status !== "idle" ||2133 (serverUpdateState.status === "running" ||
2134 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))2134 serverUpdateState.status === "failed" ||
2135 (showVersionMismatchBanner &&
2136 (serverUpdateState.status === "pending" ||
2137 (versionMismatch && versionMismatchDismissKey))))
2135 ) {2138 ) {
2136 const updateInProgress = serverUpdateState.status === "running";2139 const updateInProgress = serverUpdateState.status === "running";
2137 const updatePending = serverUpdateState.status === "pending";2140 const updatePending = serverUpdateState.status === "pending";
@@ -2181,6 +2184,7 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines
2181 // slot would only repeat it.2184 // slot would only repeat it.
2182 actions:2185 actions:
2183 updateInProgress ||2186 updateInProgress ||
2187 updatePending ||
2184 !versionMismatch ||2188 !versionMismatch ||
2185 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (2189 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
2186 <ServerUpdateAction2190 <ServerUpdateAction
apps/web/src/components/settings/ConnectionsSettings.tsx+5−2
@@ -1395,10 +1395,13 @@ function AutomaticServerUpdateControl({ · 1394 unmodified lines
1395 1395
1396 if (compact) {1396 if (compact) {
1397 return (1397 return (
1398 <div className="flex items-center gap-2 pt-1 text-xs text-muted-foreground">1398 <label
1399 className="flex w-fit cursor-pointer items-center gap-2 pt-1 text-xs text-muted-foreground"
1400 onClick={(event) => event.stopPropagation()}
1401 >
1399 {controlWithExplanation}1402 {controlWithExplanation}
1400 <span>Auto-update when idle</span>1403 <span>Auto-update when idle</span>
1401 </div>1404 </label>
1402 );1405 );
1403 }1406 }
1404 1407

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t