Skip to content
Open
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
36 changes: 36 additions & 0 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_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
DesktopTelemetryControlMessage,
Expand Down Expand Up @@ -124,6 +125,7 @@ interface MakeInstanceInput {
readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
readonly onReady?: Effect.Effect<void>;
readonly onShutdown?: Effect.Effect<void>;
readonly onStateDirOwned?: Effect.Effect<void>;
readonly onPreflightFailed?: (
failure: DesktopBackendManager.PreflightFailure,
) => Effect.Effect<boolean>;
Expand Down Expand Up @@ -186,6 +188,7 @@ function makeTestInstance(input: MakeInstanceInput) {
configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig),
...(input.onReady ? { onReady: () => input.onReady! } : {}),
...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}),
...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}),
...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}),
});

Expand Down Expand Up @@ -1206,6 +1209,39 @@ describe("DesktopBackendManager", () => {
),
);

it.effect("reports state-directory ownership once without scheduling another backend", () =>
Effect.scoped(
Effect.gen(function* () {
let startCount = 0;
const refused = yield* Deferred.make<void>();
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.sync(() => {
startCount += 1;
return makeProcess({
exitCode: Effect.succeed(
ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED),
),
});
}),
),
),
httpClientLayer: layerHttpClient(() => Effect.never),
onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid),
});
yield* instance.start;
yield* Deferred.await(refused);
const snapshot = yield* instance.snapshot;
assert.isFalse(snapshot.desiredRunning);
assert.isFalse(snapshot.restartScheduled);
yield* TestClock.adjust(Duration.minutes(1));
assert.equal(startCount, 1);
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { HttpClient } from "effect/http";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import {
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
PRIMARY_LOCAL_ENVIRONMENT_ID,
Expand Down Expand Up @@ -304,6 +305,7 @@ export interface BackendInstanceSpec {
// retries. Returns true when the callback changed configuration and the
// manager should resolve once more; false stops the failed instance.
readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect<boolean>;
readonly onStateDirOwned?: () => Effect.Effect<void>;
}

interface ActiveBackendRun {
Expand Down Expand Up @@ -867,7 +869,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (

const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* (
reason: string,
exitCode?: number,
) {
const stateDirOwned = exitCode === SERVER_EXIT_CODE_STATE_DIR_OWNED;
yield* mutex.withPermits(1)(
Effect.gen(function* () {
const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } =
Expand Down Expand Up @@ -937,6 +941,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
}

if (isCurrentRun && nextState.desiredRunning) {
if (stateDirOwned && !stopRequested) {
yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false }));
yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore);
return;
}
yield* scheduleRestart(reason);
}
}),
Expand Down Expand Up @@ -1013,7 +1022,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
Scope.provide(runScope),
Effect.matchEffect({
onFailure: (error) => finalizeRun(error.message),
onSuccess: (exit) => finalizeRun(exit.reason),
onSuccess: (exit) => finalizeRun(exit.reason, Option.getOrUndefined(exit.code)),
}),
Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
Expand Down Expand Up @@ -86,6 +87,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
DesktopAppSettings.layerTest(),
DesktopWslEnvironment.layerTest(),
ElectronDialog.layer,
ElectronApp.layer,
Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window create"),
ensureMain: Effect.die("unexpected window ensure"),
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/backend/DesktopBackendPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";

const { logWarning: logBackendPoolWarning } =
DesktopObservability.makeComponentLogger("desktop-backend-pool");
Expand Down Expand Up @@ -215,6 +216,7 @@ export const layer = Layer.effect(
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const electronApp = yield* ElectronApp.ElectronApp;
const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
// Anchor the pool's lifetime to its layer scope so registered
// instance scopes can be forked off it. Without this, instance
Expand Down Expand Up @@ -303,6 +305,13 @@ export const layer = Layer.effect(
),
onShutdown: () => desktopWindow.handleBackendNotReady,
onPreflightFailed: handlePrimaryPreflightFailure,
onStateDirOwned: () =>
Comment thread
maria-rcks marked this conversation as resolved.
electronDialog
.showErrorBox(
"This T3 home is unavailable",
"Another T3 Code server, such as the background service, is using this data directory. Stop that server and reopen T3 Code, or set a separate T3CODE_HOME and pair with the running server.",
)
.pipe(Effect.andThen(electronApp.quit)),
});

const instancesRef = yield* SynchronizedRef.make<
Expand Down
54 changes: 45 additions & 9 deletions apps/desktop/src/wsl/DesktopWslBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,31 +109,39 @@ describe("DesktopWslBackend", () => {
);
it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => {
let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined;
let registeredWsl: DesktopBackendPool.DesktopBackendInstance | undefined;
let startCount = 0;
const primary = makeStubInstance({
id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
label: "Windows",
snapshot: primarySnapshot,
});
const wsl = makeStubInstance({
id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"),
label: "WSL (Ubuntu)",
snapshot: primarySnapshot,
});
const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
get: (id) =>
Effect.succeed(
id === DesktopBackendPool.PRIMARY_INSTANCE_ID
? Option.some(primary)
: Option.none<DesktopBackendPool.DesktopBackendInstance>(),
),
list: Effect.succeed([primary]),
list: Effect.sync(() => (registeredWsl === undefined ? [primary] : [primary, registeredWsl])),
primary: Effect.succeed(primary),
register: (spec) =>
Effect.sync(() => {
registeredSpec = spec;
return wsl;
registeredWsl = makeStubInstance({
id: spec.id,
label: "WSL",
snapshot: idleSnapshot,
start: Effect.sync(() => {
startCount += 1;
}),
});
return registeredWsl;
}),
unregister: () =>
Effect.sync(() => {
registeredWsl = undefined;
}),
unregister: () => Effect.die("unexpected unregister"),
} satisfies DesktopBackendPool.DesktopBackendPool["Service"]);

return Effect.gen(function* () {
Expand All @@ -146,18 +154,46 @@ describe("DesktopWslBackend", () => {
throw new Error("Expected WSL backend registration");
}
const recordFailure = spec.onPreflightFailed;
const recordOwnershipFailure = spec.onStateDirOwned;
const clearFailure = spec.onReady;
assert.isDefined(recordFailure);
assert.isDefined(recordOwnershipFailure);
assert.isDefined(clearFailure);
if (recordFailure === undefined || clearFailure === undefined) {
if (
recordFailure === undefined ||
recordOwnershipFailure === undefined ||
clearFailure === undefined
) {
throw new Error("Expected WSL backend callbacks");
}

assert.isFalse(yield* recordFailure({ reason: "Node.js not found", fatal: true }));
assert.deepEqual(yield* backend.lastPreflightError, Option.some("Node.js not found"));

yield* recordOwnershipFailure();
const refusal = yield* backend.lastPreflightError;
assert.include(Option.getOrThrow(refusal), "Another T3 Code server");
yield* backend.reconcile;
assert.equal(startCount, 1);
assert.deepEqual(yield* backend.lastPreflightError, refusal);

yield* clearFailure(new URL("http://127.0.0.1:41773"));
assert.deepEqual(yield* backend.lastPreflightError, Option.none());
yield* recordOwnershipFailure();
const settings = yield* DesktopAppSettings.DesktopAppSettings;
yield* settings.setWslDistro("Debian");
yield* backend.reconcile;
assert.equal(startCount, 2);
assert.deepEqual(yield* backend.lastPreflightError, Option.none());
const nextSpec = registeredSpec;
if (nextSpec?.onStateDirOwned === undefined) throw new Error("Expected ownership callback");
yield* nextSpec.onStateDirOwned();
yield* settings.setWslBackendEnabled(false);
yield* backend.reconcile;
yield* settings.setWslBackendEnabled(true);
yield* backend.reconcile;
assert.equal(startCount, 3);
assert.deepEqual(yield* backend.lastPreflightError, Option.none());
}).pipe(
Effect.provide(
DesktopWslBackend.layer.pipe(
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/wsl/DesktopWslBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export const layer = Layer.effect(
// Windows, so we record the reason rather than interrupting. Cleared on any
// reconcile state change so it reflects the current attempt.
const preflightErrorRef = yield* Ref.make(Option.none<string>());
const ownershipRefusedRef = yield* Ref.make(
Option.none<DesktopBackendPool.BackendInstanceId>(),
);

const findExistingWslInstance = pool.list.pipe(
Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))),
Expand Down Expand Up @@ -168,7 +171,21 @@ export const layer = Layer.effect(
// fallback — Windows is the primary and keeps working.
onPreflightFailed: (failure) =>
Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)),
onReady: () => Ref.set(preflightErrorRef, Option.none()),
onStateDirOwned: () =>
Ref.set(ownershipRefusedRef, Option.some(targetId)).pipe(
Effect.andThen(
Ref.set(
preflightErrorRef,
Option.some(
"Another T3 Code server is using this distro's T3 home. Stop it, or pair with it instead.",
),
),
),
),
onReady: () =>
Ref.set(ownershipRefusedRef, Option.none()).pipe(
Effect.andThen(Ref.set(preflightErrorRef, Option.none())),
),
})
.pipe(
Effect.asSome,
Expand Down Expand Up @@ -217,6 +234,8 @@ export const layer = Layer.effect(
const isIdle =
!snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled;
if (isIdle) {
const refused = yield* Ref.get(ownershipRefusedRef);
if (Option.isSome(refused) && refused.value === existingInstance.id) return;
yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id });
yield* Ref.set(preflightErrorRef, Option.none());
yield* existingInstance.start;
Expand All @@ -228,6 +247,7 @@ export const layer = Layer.effect(
// any stale secondary preflight error so it reflects this fresh attempt;
// onPreflightFailed re-sets it only if the new secondary exhausts retries.
yield* Ref.set(preflightErrorRef, Option.none());
yield* Ref.set(ownershipRefusedRef, Option.none());

if (Option.isSome(existingId)) {
yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value });
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";

import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ServerOwnership from "../serverOwnership.ts";
import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
Expand Down Expand Up @@ -370,7 +372,7 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu
origin: runtimeState.value.origin,
cause: attempted.failure,
});
yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
// The offline path decides whether that server still owns the home.
return Option.none<{ readonly origin: string }>();
},
);
Expand Down Expand Up @@ -421,6 +423,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
);

// An unreachable server may still be running; never write behind it.
yield* ServerOwnership.acquireServerOwnership(config.serverRuntimeStatePath).pipe(
Effect.provide(ProcessRunner.layer),
);
yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
return yield* Effect.gen(function* () {
const snapshot = yield* getOfflineSnapshot();
const projects = yield* ProjectService.ProjectService;
Expand All @@ -432,6 +439,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
yield* Console.log(output);
}).pipe(Effect.provide(layerOfflineRuntime));
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(EnvironmentAuth.layerRuntime, WorkspacePaths.layer).pipe(
Layer.provideMerge(FetchHttpClient.layer),
Expand Down
Loading
Loading