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
14 changes: 14 additions & 0 deletions apps/desktop/src/app/DesktopStartupRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ describe("resolveStartupRecoveryOffer", () => {
).toEqual({ _tag: "Reinstall", version: "0.1.2" });
});

it("blames the managed policy unless a newer version can read it", () => {
expect(
resolveStartupRecoveryOffer(
updateState({ status: "available", availableVersion: "0.1.4" }),
"0.1.2",
true,
),
).toEqual({ _tag: "Update", version: "0.1.4" });
expect(
resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2", true),
).toEqual({ _tag: "ManagedPolicy" });
});

it("falls back to the releases page when this version never started", () => {
expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({
_tag: "Releases",
Expand Down Expand Up @@ -168,6 +181,7 @@ it.effect.each([
yield* SubscriptionRef.set(state.primaryBackendStartup, {
_tag: "Failing",
reason: "Server failed before readiness",
managedPolicy: false,
output: "Failure details",
});
yield* Deferred.await(checked);
Expand Down
24 changes: 20 additions & 4 deletions apps/desktop/src/app/DesktopStartupRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export type StartupRecoveryOffer =
| { readonly _tag: "Update"; readonly version: string }
| { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null }
| { readonly _tag: "Reinstall"; readonly version: string }
| { readonly _tag: "Releases" };
| { readonly _tag: "Releases" }
| { readonly _tag: "ManagedPolicy" };

const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const;
type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number];
Expand All @@ -41,6 +42,7 @@ type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number];
export function resolveStartupRecoveryOffer(
update: DesktopUpdateState,
lastStartedVersion: string | null,
managedPolicy = false,
): StartupRecoveryOffer {
if (update.status === "downloading" && update.availableVersion !== null) {
return {
Expand All @@ -55,6 +57,8 @@ export function resolveStartupRecoveryOffer(
if (updateVersion !== null && updateVersion !== update.notAppliedVersion) {
return { _tag: "Update", version: updateVersion };
}
// A newer release can know a key this one refuses; an older one or the releases page cannot help.
if (managedPolicy) return { _tag: "ManagedPolicy" };
if (update.enabled && (update.status === "idle" || update.status === "checking")) {
return { _tag: "Checking" };
}
Expand Down Expand Up @@ -98,6 +102,11 @@ function describeOffer(offer: StartupRecoveryOffer): { text: string; action: str
text: "Get a different version from the releases page.",
action: actionLink("releases", "Open releases", true),
};
case "ManagedPolicy":
return {
text: "Your organization's managed policy stopped T3 Code from starting. Send the details below to your IT administrator.",
action: "",
};
}
}

Expand Down Expand Up @@ -171,8 +180,14 @@ export const listen: Effect.Effect<
const { latest, changes: updateChanges } = yield* updates.subscribe;
const updateStateRef = yield* Ref.make(latest);

const offer = Ref.get(updateStateRef).pipe(
Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)),
const offer = Effect.all([Ref.get(updateStateRef), Ref.get(failureRef)]).pipe(
Effect.map(([state, failure]) =>
resolveStartupRecoveryOffer(
state,
lastStartedVersion,
Option.exists(failure, ({ managedPolicy }) => managedPolicy),
),
),
);

const render = Effect.gen(function* () {
Expand Down Expand Up @@ -332,7 +347,8 @@ export const listen: Effect.Effect<
}
if (startup._tag !== "Failing") return;
if (Option.isSome(yield* Ref.get(failureRef))) return;
yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output }));
const { _tag, ...failure } = startup;
yield* Ref.set(failureRef, Option.some(failure));
// The backend manager already logs each failure's reason.
yield* logWarning("primary backend failing to start");
yield* show();
Expand Down
100 changes: 54 additions & 46 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
Expand Down Expand Up @@ -623,56 +624,63 @@ describe("DesktopBackendManager", () => {
),
);

it.effect("reports a crash loop with the server output on the third restart", () =>
Effect.scoped(
Effect.gen(function* () {
const message = "Error: Cannot find module 'zod/v4' in /Users/René";
let exitCount = 0;
const firstExit = yield* Deferred.make<void>();
const reports = yield* Queue.unbounded<{
readonly exitCount: number;
readonly failure: DesktopBackendManager.BackendStartupFailure;
}>();
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
// "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut
// lands inside the emoji's surrogate pair.
stderr: Stream.make(
new TextEncoder().encode(
`😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`,
// A policy refusal is reported on the first exit, since retrying cannot fix it.
it.effect.each([
{ code: 1, exits: 3, managedPolicy: false },
{ code: SERVER_EXIT_CODE_MANAGED_POLICY, exits: 1, managedPolicy: true },
])(
"reports exit $code with the server output after $exits exits",
({ code, exits, managedPolicy }) =>
Effect.scoped(
Effect.gen(function* () {
const message = "Error: Cannot find module 'zod/v4' in /Users/René";
let exitCount = 0;
const firstExit = yield* Deferred.make<void>();
const reports = yield* Queue.unbounded<{
readonly exitCount: number;
readonly failure: DesktopBackendManager.BackendStartupFailure;
}>();
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
// "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut
// lands inside the emoji's surrogate pair.
stderr: Stream.make(
new TextEncoder().encode(
`😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`,
),
new Uint8Array([0xc3]),
new Uint8Array([0xa9]),
),
new Uint8Array([0xc3]),
new Uint8Array([0xa9]),
),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
}),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)),
}),
),
),
),
),
httpClientLayer: layerHttpClient(() => Effect.never),
backendOutputLog: {
persistFailure: () => {
exitCount += 1;
return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid);
httpClientLayer: layerHttpClient(() => Effect.never),
backendOutputLog: {
persistFailure: () => {
exitCount += 1;
return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid);
},
},
},
onStartupFailure: (failure) =>
Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid),
});
yield* instance.start;
yield* Deferred.await(firstExit);
yield* TestClock.adjust(Duration.minutes(1));
const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports);
assert.equal(exitsWhenReported, 3);
assert.equal(failure.reason, "The server could not start (code=1).");
assert.isTrue(failure.output.endsWith(message));
assert.isTrue(failure.output.isWellFormed());
}).pipe(Effect.provide(TestClock.layer())),
),
onStartupFailure: (failure) =>
Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid),
});
yield* instance.start;
yield* Deferred.await(firstExit);
yield* TestClock.adjust(Duration.minutes(1));
const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports);
assert.equal(exitsWhenReported, exits);
assert.equal(failure.reason, `The server could not start (code=${code}).`);
assert.equal(failure.managedPolicy, managedPolicy);
assert.isTrue(failure.output.endsWith(message));
assert.isTrue(failure.output.isWellFormed());
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("waits out a slow startup and reports one that never becomes ready", () =>
Expand Down
18 changes: 13 additions & 5 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HttpClient } from "effect/http";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
Expand Down Expand Up @@ -223,6 +224,8 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId(

export interface BackendStartupFailure {
readonly reason: string;
/** The server refused to start under this machine's managed policy. */
readonly managedPolicy: boolean;
/** Tail of the last run's stdout and stderr. */
readonly output: string;
}
Expand Down Expand Up @@ -635,9 +638,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
// One streaming decoder per stream keeps UTF-8 characters split across chunks intact.
const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() });
let outputDecoders = makeOutputDecoders();
const reportStartupFailure = (reason: string) =>
const reportStartupFailure = (reason: string, managedPolicy = false) =>
Ref.get(outputTail).pipe(
Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void),
Effect.flatMap(
(output) => spec.onStartupFailure?.({ reason, managedPolicy, output }) ?? Effect.void,
),
);

const { logWarning: logInstanceWarning, logError: logInstanceError } =
Expand Down Expand Up @@ -880,7 +885,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore);
return;
}
yield* scheduleRestart(reason);
yield* scheduleRestart(reason, exitCode);
}
}),
);
Expand Down Expand Up @@ -980,6 +985,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (

const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* (
reason: string,
exitCode?: number,
) {
const scheduled = yield* SubscriptionRef.modify(state, (latest) => {
if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
Expand All @@ -1003,8 +1009,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
reason,
delayMs: Duration.toMillis(delay),
});
if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) {
yield* reportStartupFailure(`The server could not start (${reason}).`);
// Retrying cannot fix a policy refusal, so say so at once; retries still pick up a fix.
const managedPolicy = exitCode === SERVER_EXIT_CODE_MANAGED_POLICY;
if (managedPolicy || attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) {
yield* reportStartupFailure(`The server could not start (${reason}).`, managedPolicy);
}
const restartFiber = yield* Effect.forkIn(
Effect.sleep(delay).pipe(
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/desktopBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts

// A supervisor must not restart a server refused by the state-directory owner.
export const SERVER_EXIT_CODE_STATE_DIR_OWNED = 78;
// A server the managed policy refused, so the desktop app blames the policy rather than the release.
export const SERVER_EXIT_CODE_MANAGED_POLICY = 77;

export const DesktopBackendBootstrap = Schema.Struct({
mode: Schema.Literal("desktop"),
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/managedPolicyFile.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ManagedPolicyDocument } from "@t3tools/contracts";
import { ManagedPolicyDocument, SERVER_EXIT_CODE_MANAGED_POLICY } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Runtime from "effect/Runtime";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
Expand All @@ -20,6 +21,8 @@ export class ManagedPolicyFileError extends Schema.TaggedError<ManagedPolicyFile
cause: Schema.optional(Schema.Defect()),
},
) {
override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_MANAGED_POLICY;

override get message(): string {
return `T3 Code cannot use the managed policy at ${this.path}: ${this.reason}. It will not start until an administrator fixes this.`;
}
Expand Down
Loading