Skip to content
Closed
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
47 changes: 47 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts";

import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
Expand Down Expand Up @@ -124,6 +126,7 @@ interface MakeInstanceInput {
readonly onPreflightFailed?: (
failure: DesktopBackendManager.PreflightFailure,
) => Effect.Effect<boolean>;
readonly onStateDirOwned?: Effect.Effect<void>;
readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
readonly configResolve?: Effect.Effect<
DesktopBackendManager.DesktopBackendStartConfig,
Expand Down Expand Up @@ -185,6 +188,7 @@ function makeTestInstance(input: MakeInstanceInput) {
...(input.onReady ? { onReady: () => input.onReady! } : {}),
...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}),
...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}),
...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}),
});

return instance.pipe(Effect.provide(servicesLayer));
Expand Down Expand Up @@ -1204,6 +1208,49 @@ describe("DesktopBackendManager", () => {
),
);

it.effect("stops instead of restarting when another server owns the state directory", () =>
Effect.scoped(
Effect.gen(function* () {
const starts = yield* Queue.unbounded<number>();
let startCount = 0;
let ownedNotices = 0;

const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.sync(() => {
startCount += 1;
return makeProcess({
exitCode: Queue.offer(starts, startCount).pipe(
Effect.as(ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED)),
),
});
}),
),
);

const instance = yield* makeTestInstance({
spawnerLayer,
httpClientLayer: httpClientLayer(() => Effect.never),
onStateDirOwned: Effect.sync(() => {
ownedNotices += 1;
}),
});

yield* instance.start;
assert.equal(yield* Queue.take(starts), 1);

// The lock never clears on its own, so no restart is scheduled at any delay.
yield* TestClock.adjust(Duration.seconds(30));
assert.equal(yield* Queue.size(starts), 0);
assert.equal(ownedNotices, 1);
const snapshot = yield* instance.snapshot;
assert.equal(snapshot.desiredRunning, false);
assert.equal(snapshot.restartScheduled, false);
}).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
21 changes: 20 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/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
PRIMARY_LOCAL_ENVIRONMENT_ID,
Expand Down Expand Up @@ -299,6 +300,10 @@ 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>;
// Fired once when the backend exits because another server already owns
// its state directory. The lock never clears by itself, so the instance
// stops instead of restarting. The primary uses this to tell the user.
readonly onStateDirOwned?: () => Effect.Effect<void>;
}

interface ActiveBackendRun {
Expand Down Expand Up @@ -828,7 +833,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 @@ -897,6 +904,18 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
}
}

if (isCurrentRun && stateDirOwned && !stopRequested) {
yield* logInstanceError(
"backend stopped: another server owns its state directory",
{
reason,
},
);
yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false }));
yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore);
return;
}

if (isCurrentRun && nextState.desiredRunning) {
yield* scheduleRestart(reason);
}
Expand Down Expand Up @@ -971,7 +990,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
7 changes: 7 additions & 0 deletions apps/desktop/src/backend/DesktopBackendPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,13 @@ export const layer = Layer.effect(
),
onShutdown: () => desktopWindow.handleBackendNotReady,
onPreflightFailed: handlePrimaryPreflightFailure,
// A held ownership lock never clears on its own. Retrying would only
// hide the cause, so say what to do and leave the app open.
onStateDirOwned: () =>
electronDialog.showErrorBox(
"Another T3 Code server is already running",
"A T3 Code server started elsewhere (a terminal, the background service, or an SSH session) already owns this data directory. Stop that server, then restart T3 Code.",
),
});

const instancesRef = yield* SynchronizedRef.make<
Expand Down
82 changes: 77 additions & 5 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as NodeChildProcess from "node:child_process";

import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as ProcessRunner from "./processRunner.ts";
import {
CommandId,
EnvironmentOrchestrationHttpApi,
Expand All @@ -19,6 +20,7 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as DateTime from "effect/DateTime";
import * as Layer from "effect/Layer";
import * as FileSystem from "effect/FileSystem";
import * as HttpRouter from "effect/unstable/http/HttpRouter";
import * as HttpServer from "effect/unstable/http/HttpServer";
import * as HttpApi from "effect/unstable/httpapi/HttpApi";
Expand All @@ -41,10 +43,8 @@ import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import {
makePersistedServerRuntimeState,
persistServerRuntimeState,
} from "./serverRuntimeState.ts";
import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts";
import { persistServerRuntimeState } from "./serverOwnership.ts";
import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
Expand Down Expand Up @@ -405,7 +405,9 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
);
});

it.layer(NodeServices.layer)("bin cli parsing", (it) => {
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));

it.layer(TestPlatformLayer)("bin cli parsing", (it) => {
it.effect("accepts the built-in lowercase log-level flag values", () =>
Effect.gen(function* () {
const { output } = yield* captureStdout(runCli(["--log-level", "debug", "--version"]));
Expand Down Expand Up @@ -825,6 +827,76 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
}),
);

it.effect("skips stale project discovery without deleting the record", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-project-stale-test-" });
const config = yield* makeCliTestServerConfig(baseDir);
let requests = 0;
const server = yield* Effect.acquireRelease(
Effect.callback<NodeHttp.Server>((resume) => {
const server = NodeHttp.createServer((_request, response) => {
requests += 1;
response.writeHead(503);
response.end();
});
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
}),
(server) => Effect.sync(() => server.close()),
);
const address = server.address();
if (!address || typeof address === "string") return yield* Effect.die("Expected TCP address");
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: {
...(yield* makePersistedServerRuntimeState({ config, port: address.port })),
pid: 2147483647,
},
});
const before = yield* fs.readFileString(config.serverRuntimeStatePath);
yield* runCliWithRuntime(["project", "add", baseDir, "--base-dir", baseDir]);
assert.equal(requests, 0);
assert.equal(yield* fs.readFileString(config.serverRuntimeStatePath), before);
}),
);

it.effect("keeps a replacement runtime record when a project CLI request fails", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-project-owner-test-" });
const config = yield* makeCliTestServerConfig(baseDir);
const newer = {
version: 1,
pid: process.pid,
ownerId: "replacement",
port: 45731,
origin: "http://127.0.0.1:45731",
startedAt: "2026-09-04T00:00:00.000Z",
};
// @effect-diagnostics-next-line preferSchemaOverJson:off - Simulate a replacement process publishing its discovery document.
const newerText = JSON.stringify(newer);
const server = yield* Effect.acquireRelease(
Effect.callback<NodeHttp.Server>((resume) => {
const server = NodeHttp.createServer((_request, response) => {
NodeFS.writeFileSync(config.serverRuntimeStatePath, newerText);
response.writeHead(503);
response.end();
});
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
}),
(server) => Effect.sync(() => server.close()),
);
const address = server.address();
if (!address || typeof address === "string") return yield* Effect.die("Expected TCP address");
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: yield* makePersistedServerRuntimeState({ config, port: address.port }),
});
yield* runCliWithRuntime(["project", "add", baseDir, "--base-dir", baseDir]);
assert.equal(yield* fs.readFileString(config.serverRuntimeStatePath), newerText);
}),
);

it.effect("rejects dev-url on project commands", () =>
Effect.gen(function* () {
const workspaceRoot = NodeFS.mkdtempSync(
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ExternalLauncher from "../process/externalLauncher.ts";
import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { requireServerStopped } from "../serverOwnership.ts";
import * as ProcessRunner from "../processRunner.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
import { resolveCliCommand } from "./invocation.ts";
import {
Expand Down Expand Up @@ -430,6 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
| FileSystem.FileSystem
| HttpClient.HttpClient
| Prompt.Environment
| ProcessRunner.ProcessRunner
| ServerConfig.ServerConfig
| ServerEnvironment.ServerEnvironmentIdentity
>,
Expand All @@ -449,6 +452,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
EnvironmentAuth.runtimeLayer,
bootServiceLayer(config),
ProcessRunner.layer,
headlessRelayClientTracingLayer,
).pipe(
Layer.provideMerge(FetchHttpClient.layer),
Expand Down Expand Up @@ -690,6 +694,18 @@ export const connectCommand = Command.make("connect", {
// machine is brought online.
yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`);

const config = yield* ServerConfig.ServerConfig;
const stopped = yield* requireServerStopped(config.serverRuntimeStatePath).pipe(
Effect.as(true),
Effect.catch((error) => Console.warn(error.message).pipe(Effect.as(false))),
);
if (!stopped) {
yield* Console.log(
"Authorization is saved. The existing server is unchanged. Restart it after active work finishes to apply T3 Connect setup.",
);
return;
}

// Authorization is stored. If service setup fails, preserve it and
// show how to run the server manually.
const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import * as ProcessRunner from "../processRunner.ts";
import * as NetService from "@t3tools/shared/Net";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import { assert, describe, expect, it } from "@effect/vitest";
Expand All @@ -21,9 +22,9 @@ import {
import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
import {
makePersistedServerRuntimeState,
persistServerRuntimeState,
type PersistedServerRuntimeState,
} from "../serverRuntimeState.ts";
import { persistServerRuntimeState } from "../serverOwnership.ts";
import {
DevServerNotProxiableError,
resolveDirectPairingBaseUrl,
Expand All @@ -32,6 +33,8 @@ import {

import packageJson from "../../package.json" with { type: "json" };

const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);

const baseState = {
Expand Down Expand Up @@ -179,7 +182,7 @@ describe("t3 pair", () => {
assert.equal(credentials[0]?.label, "t3 pair");
}),
).pipe(
Effect.provide(NodeServices.layer),
Effect.provide(TestPlatformLayer),
Effect.provideService(HostProcessEnvironment, {
...process.env,
[SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
Expand Down Expand Up @@ -214,7 +217,7 @@ describe("t3 pair", () => {

assert.include(output, "Pairing URL: http://localhost:5733/pair#token=");
}),
).pipe(Effect.provide(NodeServices.layer)),
).pipe(Effect.provide(TestPlatformLayer)),
);

it.effect("directs to t3 serve or t3 connect when no server is running", () =>
Expand All @@ -231,7 +234,7 @@ describe("t3 pair", () => {
assert.include(rendered, "No running T3 Code server found.");
assert.include(rendered, "npx t3 serve");
assert.include(rendered, "npx t3 connect");
}).pipe(Effect.provide(NodeServices.layer)),
}).pipe(Effect.provide(TestPlatformLayer)),
);

it.effect("ignores runtime state whose recorded pid is no longer alive", () =>
Expand Down Expand Up @@ -261,7 +264,7 @@ describe("t3 pair", () => {
);
assert.include(rendered, "No running T3 Code server found.");
}),
).pipe(Effect.provide(NodeServices.layer)),
).pipe(Effect.provide(TestPlatformLayer)),
);

it.effect("ignores stale runtime state pointing at a dead server", () =>
Expand All @@ -286,6 +289,6 @@ describe("t3 pair", () => {
typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
);
assert.include(rendered, "No running T3 Code server found.");
}).pipe(Effect.provide(NodeServices.layer)),
}).pipe(Effect.provide(TestPlatformLayer)),
);
});
Loading
Loading