Skip to content

Commit a75ea46

Browse files
authored
fix(server): keep a remote server update running when the client disconnects (pingdotgg#18980)
Co-authored-by: macodev00 <[email protected]> Co-authored-by: maco <[email protected]>
1 parent 1b5c2d1 commit a75ea46

3 files changed

Lines changed: 74 additions & 26 deletions

File tree

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
88
import * as FileSystem from "effect/FileSystem";
99
import * as Fiber from "effect/Fiber";
1010
import * as Path from "effect/Path";
11+
import * as Stream from "effect/Stream";
1112
import { HttpClient, HttpClientResponse } from "effect/http";
1213
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1314

@@ -22,12 +23,13 @@ interface HarnessOptions {
2223
readonly mode?: "web" | "desktop";
2324
readonly managed?: boolean;
2425
readonly preflight?: "ready" | "blocked";
26+
readonly beforeDownload?: Effect.Effect<void>;
2527
readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
2628
readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
2729
}
2830

2931
const archiveBytes = new TextEncoder().encode("not really a tarball");
30-
const releaseHttpClient = (order: string[]) =>
32+
const releaseHttpClient = (order: string[], beforeDownload?: Effect.Effect<void>) =>
3133
HttpClient.make((request) =>
3234
Effect.gen(function* () {
3335
if (request.url.endsWith("/SHA256SUMS")) {
@@ -40,6 +42,7 @@ const releaseHttpClient = (order: string[]) =>
4042
new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`),
4143
);
4244
}
45+
if (beforeDownload) yield* beforeDownload;
4346
order.push("download");
4447
return HttpClientResponse.fromWeb(request, new Response(archiveBytes));
4548
}),
@@ -116,7 +119,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
116119
run: () => Effect.die("unexpected desktop app update run"),
117120
},
118121
),
119-
Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)),
122+
Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order, options.beforeDownload)),
120123
Effect.provideService(HostProcess.Platform, "linux"),
121124
Effect.provideService(HostProcess.Architecture, "x64"),
122125
Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
@@ -418,4 +421,46 @@ it.layer(NodeServices.layer)("server self update", (it) => {
418421
expect((yield* Fiber.join(first)).updateId).toBe("launcher-id");
419422
}),
420423
);
424+
425+
it.effect("keeps preparing after the progress stream closes", () =>
426+
Effect.gen(function* () {
427+
const releaseDownload = yield* Deferred.make<void>();
428+
const sawDownloading = yield* Deferred.make<void>();
429+
const accepted = yield* Deferred.make<void>();
430+
const { selfUpdate, order } = yield* makeHarness({
431+
beforeDownload: Deferred.await(releaseDownload),
432+
requestUpdate: () =>
433+
Effect.sync(() => {
434+
order.push("accept");
435+
}).pipe(Effect.andThen(Deferred.succeed(accepted, undefined)), Effect.as("launcher-id")),
436+
});
437+
438+
const consumed = yield* ServerSelfUpdate.updateProgressStream(selfUpdate, {
439+
targetVersion: "1.1.0",
440+
}).pipe(
441+
Stream.tap((event) =>
442+
event.type === "progress" && event.stage === "downloading"
443+
? Deferred.succeed(sawDownloading, undefined)
444+
: Effect.void,
445+
),
446+
Stream.runDrain,
447+
Effect.forkChild,
448+
);
449+
450+
yield* Deferred.await(sawDownloading);
451+
// The socket drop ends the stream. The download is still held.
452+
yield* Fiber.interrupt(consumed);
453+
expect((yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip)).reason).toBe(
454+
"A server update is already in progress.",
455+
);
456+
457+
yield* Deferred.succeed(releaseDownload, undefined);
458+
// `installing` is offered only after the stream queue has shut down.
459+
yield* Deferred.await(accepted);
460+
expect(order).toEqual(["download", "extract", "preflight", "accept"]);
461+
expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe(
462+
"A server update is already in progress.",
463+
);
464+
}),
465+
);
421466
});

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ServerSelfUpdateError,
33
type ServerSelfUpdateCapability,
44
type ServerSelfUpdateInput,
5+
type ServerSelfUpdateProgressEvent,
56
type ServerSelfUpdateProgressStage,
67
type ServerSelfUpdateResult,
78
type ThreadId,
@@ -18,6 +19,8 @@ import * as Ref from "effect/Ref";
1819
import * as FileSystem from "effect/FileSystem";
1920
import * as Layer from "effect/Layer";
2021
import * as Path from "effect/Path";
22+
import * as Queue from "effect/Queue";
23+
import * as Stream from "effect/Stream";
2124
import { HttpClient } from "effect/http";
2225

2326
import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";
@@ -339,3 +342,26 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
339342
export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
340343
Layer.provide(ProcessRunner.layer),
341344
);
345+
346+
// The RPC stream used to own this fiber, so a dropped client socket interrupted
347+
// the download. Offers after the queue shuts down are ignored; the update
348+
// still finishes, and the in-flight guard stays as it was.
349+
export const updateProgressStream = (
350+
selfUpdate: ServerSelfUpdate["Service"],
351+
input: ServerSelfUpdateInput,
352+
) =>
353+
Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) => {
354+
const offer = (event: ServerSelfUpdateProgressEvent) =>
355+
Queue.offer(queue, event).pipe(Effect.ignore);
356+
357+
return selfUpdate
358+
.update(input, (stage) => offer({ type: "progress", stage }))
359+
.pipe(
360+
Effect.flatMap((result) => offer({ type: "complete", result })),
361+
Effect.catchTags({
362+
ServerSelfUpdateError: (error) => Queue.fail(queue, error).pipe(Effect.ignore),
363+
}),
364+
Effect.andThen(Queue.end(queue).pipe(Effect.ignore)),
365+
Effect.forkDetach,
366+
);
367+
});

‎apps/server/src/ws.ts‎

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,6 @@ import {
7777
ProviderSetupError,
7878
RelayClientInstallFailedError,
7979
type RelayClientInstallProgressEvent,
80-
type ServerSelfUpdateError,
81-
type ServerSelfUpdateProgressEvent,
8280
type ServerConfig as ClientServerConfig,
8381
type ServerConfigStreamEvent,
8482
type ServerLifecycleStreamEvent,
@@ -2335,28 +2333,7 @@ const layerWsRpc = (
23352333
[WS_METHODS.providerInstallRemove]: (input) => providerInstallation.remove(input),
23362334
[WS_METHODS.serverUpdateServer]: (input) => serverSelfUpdate.update(input),
23372335
[WS_METHODS.serverUpdateServerWithProgress]: (input) =>
2338-
Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
2339-
serverSelfUpdate
2340-
.update(input, (stage) =>
2341-
Queue.offer(queue, {
2342-
type: "progress",
2343-
stage,
2344-
}).pipe(Effect.asVoid),
2345-
)
2346-
.pipe(
2347-
Effect.flatMap((result) =>
2348-
Queue.offer(queue, {
2349-
type: "complete",
2350-
result,
2351-
}),
2352-
),
2353-
Effect.catchTags({
2354-
ServerSelfUpdateError: (error) => Queue.fail(queue, error),
2355-
}),
2356-
Effect.andThen(Queue.end(queue)),
2357-
Effect.forkScoped,
2358-
),
2359-
),
2336+
ServerSelfUpdate.updateProgressStream(serverSelfUpdate, input),
23602337
[WS_METHODS.serverCommitDesktopUpdate]: (input) =>
23612338
serverSelfUpdate.commitDesktopUpdate(input.requestId),
23622339
[WS_METHODS.serverUpsertKeybinding]: (rule) =>

0 commit comments

Comments
 (0)