Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/server/src/cli/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Terminal from "effect/Terminal";

import * as BootService from "../cloud/bootService.ts";
import {
acquireRelayClientForLink,
formatHeadlessAuthorizationPrompt,
Expand Down Expand Up @@ -63,6 +64,15 @@ it.effect("treats cancelling optional background setup as a successful skip", ()
}),
);

it.effect("keeps a successful connection when a remote service update is pending", () =>
Effect.gen(function* () {
const result = yield* recoverServiceOnboardingOffer(
Effect.fail(new BootService.BootServiceUpdatePendingError()),
);
assert.isFalse(result);
}),
);

it.effect("does not install the relay client when the user declines the managed download", () =>
Effect.gen(function* () {
let installCalls = 0;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export const recoverServiceOnboardingOffer = <R>(
Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
BootServiceInstallError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
BootServiceUpdatePendingError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
}),
);

Expand Down
52 changes: 46 additions & 6 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
import * as ProcessRunner from "../processRunner.ts";
import * as BootService from "./bootService.ts";
import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
import { parseServiceState } from "./serviceProtocol.ts";
import {
parseServiceState,
SERVICE_LAUNCHER_PROTOCOL,
serviceStateHasPendingUpdate,
} from "./serviceProtocol.ts";

it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
const unit = BootService.renderBootServiceUnit({
Expand Down Expand Up @@ -97,15 +101,24 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
const plan = yield* service.install;

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
protocol: 1,
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.3",
});
expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
expect((yield* service.status).current).toBe(true);
yield* fs.writeFileString(
statePath,
'{"protocol":1,"activeVersion":"1.2.3","update":{"id":"u","fromVersion":"1.2.3","targetVersion":"1.2.4","status":"pending"}}',
);
// @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
const pendingState = JSON.stringify({
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.3",
update: {
id: "u",
fromVersion: "1.2.3",
targetVersion: "1.2.4",
dbPath: "/tmp/state.sqlite",
status: "pending",
},
});
yield* fs.writeFileString(statePath, pendingState);
expect((yield* service.status).current).toBe(false);
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
Expand Down Expand Up @@ -141,6 +154,33 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
}),
);

it.effect("restarts without overwriting a pending remote update", () =>
Effect.gen(function* () {
const { service, fs, statePath, commands } = yield* makeHarness();
yield* service.install;
// @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
const pendingState = JSON.stringify({
protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
activeVersion: "1.2.3",
update: {
id: "remote-update",
fromVersion: "1.2.3",
targetVersion: "1.2.4",
status: "pending",
},
});
yield* fs.writeFileString(statePath, pendingState);
commands.length = 0;

expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
"systemctl --user stop t3code.service",
"systemctl --user restart t3code.service",
]);
}),
);

it.effect("fails closed off Linux", () =>
Effect.gen(function* () {
const { service } = yield* makeHarness("darwin");
Expand Down
22 changes: 21 additions & 1 deletion apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
SERVICE_LAUNCHER_PROTOCOL,
SERVICE_STATE_FILE,
parseServiceState,
serviceStateHasPendingUpdate,
type ServiceState,
} from "./serviceProtocol.ts";

Expand Down Expand Up @@ -110,10 +111,20 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService
}
}

export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()(
"BootServiceUpdatePendingError",
{},
) {
override get message(): string {
return "A remote server update is still pending. Wait for it to finish, then retry.";
}
}

export type BootServiceError =
| BootServiceUnsupportedError
| BootServiceCommandError
| BootServiceInstallError;
| BootServiceInstallError
| BootServiceUpdatePendingError;
Comment thread
t3dotgg marked this conversation as resolved.

export interface BootServiceStatus {
readonly supported: boolean;
Expand Down Expand Up @@ -288,6 +299,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
}

yield* Effect.gen(function* () {
if (installed) {
const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
if (
Option.isSome(previousStateText) &&
serviceStateHasPendingUpdate(previousStateText.value)
) {
return yield* new BootServiceUpdatePendingError();
}
}
yield* fs
.makeDirectory(unitDir, { recursive: true })
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/cloud/selfUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
import * as ServerSelfUpdate from "./selfUpdate.ts";

interface HarnessOptions {
Expand Down Expand Up @@ -50,7 +51,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
const result =
options.preflight === "blocked"
? { status: "blocked", version: "1.1.0", reason: "local update required" }
: { status: "ready", version: "1.1.0", launcherProtocol: 1 };
: {
status: "ready",
version: "1.1.0",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
};
return {
// @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout.
stdout: JSON.stringify(result),
Expand All @@ -64,7 +69,6 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
});
const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
managed: options.managed ?? true,
trial: false,
requestUpdate:
options.requestUpdate ??
(() =>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cloud/selfUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {

yield* reportProgress("installing");
const updateId = yield* launcher
.requestUpdate({ targetVersion })
.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath })
.pipe(
Effect.mapError((error) =>
failWith(
Expand Down
28 changes: 18 additions & 10 deletions apps/server/src/cloud/serviceLauncherClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber";

import {
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
type ServiceLauncherChildMessage,
type ServiceLauncherParentMessage,
} from "./serviceProtocol.ts";
Expand Down Expand Up @@ -53,10 +54,11 @@ it.effect("waits for the launcher to durably commit the trial update ID", () =>
id: "update-1",
fromVersion: "1.0.0",
targetVersion: "1.1.0",
dbPath: "/tmp/state.sqlite",
status: "pending" as const,
};
const host = new FakeLauncherProcess({
protocol: 1,
protocol: SERVICE_LAUNCHER_PROTOCOL,
childVersion: "1.1.0",
update: pending,
});
Expand All @@ -79,13 +81,14 @@ it.effect("waits for the launcher to durably commit the trial update ID", () =>
it.effect("returns the launcher-generated ID only after update acceptance", () =>
Effect.gen(function* () {
const host = new FakeLauncherProcess({
protocol: 1,
protocol: SERVICE_LAUNCHER_PROTOCOL,
childVersion: "1.0.0",
});
const client = yield* makeClient(host, "1.0.0");
const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
startImmediately: true,
});
const requested = yield* Effect.forkChild(
client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
{ startImmediately: true },
);
yield* Effect.yieldNow;
host.emit({
type: "update-accepted",
Expand All @@ -97,11 +100,15 @@ it.effect("returns the launcher-generated ID only after update acceptance", () =

it.effect("preserves a launcher rejection as a distinct error", () =>
Effect.gen(function* () {
const host = new FakeLauncherProcess({ protocol: 1, childVersion: "1.0.0" });
const client = yield* makeClient(host, "1.0.0");
const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
startImmediately: true,
const host = new FakeLauncherProcess({
protocol: SERVICE_LAUNCHER_PROTOCOL,
childVersion: "1.0.0",
});
const client = yield* makeClient(host, "1.0.0");
const requested = yield* Effect.forkChild(
client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
{ startImmediately: true },
);
yield* Effect.yieldNow;
host.emit({ type: "update-rejected", reason: "requires local update" });
expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({
Expand All @@ -115,12 +122,13 @@ it.effect("preserves a launcher rejection as a distinct error", () =>
it.effect("rejects contradictory trial context instead of leaving activation closed", () =>
Effect.gen(function* () {
const host = new FakeLauncherProcess({
protocol: 1,
protocol: SERVICE_LAUNCHER_PROTOCOL,
childVersion: "1.1.0",
update: {
id: "update-1",
fromVersion: "1.0.0",
targetVersion: "1.2.0",
dbPath: "/tmp/state.sqlite",
status: "pending",
},
});
Expand Down
16 changes: 10 additions & 6 deletions apps/server/src/cloud/serviceLauncherClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ export class ServiceLauncherClient extends Context.Service<
ServiceLauncherClient,
{
readonly managed: boolean;
readonly trial: boolean;
readonly requestUpdate: (input: {
readonly targetVersion: string;
readonly dbPath: string;
}) => Effect.Effect<string, ServiceLauncherClientError | ServiceLauncherRejectedError>;
readonly prepareTrial: Effect.Effect<
ServerSelfUpdateOutcome | undefined,
Expand Down Expand Up @@ -137,8 +137,8 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup"

export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")(
function* () {
const { context, managed } = yield* resolveStartup();
return { managed, trial: context?.update?.status === "pending" };
const { managed } = yield* resolveStartup();
return { managed };
},
);

Expand Down Expand Up @@ -199,7 +199,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
}),
);

const requestUpdate = (input: { readonly targetVersion: string }) =>
const requestUpdate = (input: { readonly targetVersion: string; readonly dbPath: string }) =>
exchange(
{ type: "request-update", ...input },
(reply) => reply.type === "update-accepted" || reply.type === "update-rejected",
Expand Down Expand Up @@ -233,14 +233,18 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
if (reply.type !== "committed") {
return Effect.die("service launcher returned an impossible prepared response");
}
return Effect.succeed({ ...pending, status: "committed" as const });
return Effect.succeed({
id: pending.id,
fromVersion: pending.fromVersion,
targetVersion: pending.targetVersion,
status: "committed" as const,
});
}),
)
: Effect.succeed(outcome);

return ServiceLauncherClient.of({
managed,
trial: pending !== undefined,
requestUpdate,
prepareTrial,
});
Expand Down
59 changes: 19 additions & 40 deletions apps/server/src/cloud/servicePreflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,26 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as NodeSqlite from "node:sqlite";

import { migrationManifest } from "../persistence/Migrations.ts";
import { runServicePreflight } from "./servicePreflight.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";

it.layer(NodeServices.layer)("service update preflight", (it) => {
it.effect("requires exact migration-manifest equality without mutating the database", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-preflight-test-" });
const databasePath = path.join(root, "state.sqlite");
const database = new NodeSqlite.DatabaseSync(databasePath);
database.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER, name TEXT)");
const insert = database.prepare(
"INSERT INTO effect_sql_migrations (migration_id, name) VALUES (?, ?)",
);
for (const [id, name] of migrationManifest) insert.run(id, name);
database.close();

expect(runServicePreflight({ databasePath, launcherProtocol: 1, version: "1.2.3" })).toEqual({
status: "ready",
version: "1.2.3",
launcherProtocol: 1,
});
it("requires the database-snapshot launcher protocol", () => {
expect(
runServicePreflight({
databasePath: "/missing/state.sqlite",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL - 1,
version: "1.2.3",
}),
).toMatchObject({ status: "blocked", version: "1.2.3" });

const changed = new NodeSqlite.DatabaseSync(databasePath);
changed.exec("DELETE FROM effect_sql_migrations WHERE migration_id = 35");
changed.close();
const blocked = runServicePreflight({
databasePath,
launcherProtocol: 1,
version: "1.2.3",
});
expect(blocked.status).toBe("blocked");
if (blocked.status === "blocked") {
expect(blocked.reason).toContain("npx t3@1.2.3 service update");
}
expect(
runServicePreflight({
databasePath: "/missing/state.sqlite",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
version: "1.2.3",
}),
);
).toEqual({
status: "ready",
version: "1.2.3",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
});
});
Loading
Loading