diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 11b702e33705..9383d2c3abe7 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import * as NetService from "@t3tools/shared/Net"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; @@ -395,6 +396,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); + // The server's `node --watch` parent outlives a refused start, and its next + // restart races the running owner for the lock, so refusal must happen + // before anything is spawned. + it.effect("refuses to start when another server owns the state directory", () => { + let spawned = false; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawned = true; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + const t3Home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-dev-owner-")); + const stateDir = NodePath.join(t3Home, "userdata"); + NodeFS.mkdirSync(stateDir, { recursive: true }); + const owner = new NodeSqlite.DatabaseSync(NodePath.join(stateDir, "server-owner.sqlite")); + owner.exec("BEGIN EXCLUSIVE;"); + + const error = yield* runDevRunnerWithInput({ ...devServerInput, t3Home }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.flip, + Effect.ensuring( + Effect.sync(() => { + owner.close(); + NodeFS.rmSync(t3Home, { recursive: true, force: true }); + }), + ), + ); + + if (error._tag !== "DevRunnerStateDirOwnedError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.stateDir, stateDir); + assert.isFalse(spawned); + }); + }); + // `tailscale serve` config outlives the process, so a dry run that shared // would replace and then tear down whatever mapping the port already had. it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 256064d85553..6bf9957aabee 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -182,6 +183,15 @@ export class DevRunnerProcessExitError extends Schema.TaggedError()( + "DevRunnerStateDirOwnedError", + { stateDir: Schema.String }, +) { + override get message(): string { + return `A T3 Code server already owns ${this.stateDir}. Stop that server, or pass --home-dir to use a separate T3 home, then retry. Nothing was started.`; + } +} + export class DevRunnerHostNotProxiableError extends Schema.TaggedError()( "DevRunnerHostNotProxiableError", { @@ -680,6 +690,31 @@ const prepareFirstStart = Effect.fn("dev-runner.prepareFirstStart")(function* (i } }); +/** + * Mirrors the server's ownership lock (apps/server/src/serverOwnership.ts). + * A refused server under `node --watch` leaves its watcher alive, and the + * next file change restarts it into a race with the running owner. + */ +const isStateDirOwned = Effect.fn("dev-runner.isStateDirOwned")(function* (stateDir: string) { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const lockPath = path.join(stateDir, "server-owner.sqlite"); + if (!(yield* fs.exists(lockPath).pipe(Effect.orElseSucceed(() => false)))) return false; + return yield* Effect.sync(() => { + const db = new NodeSqlite.DatabaseSync(lockPath); + try { + db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + db.exec("ROLLBACK;"); + return false; + } catch (cause) { + if (cause instanceof Error && "errcode" in cause && cause.errcode === 5) return true; + throw cause; + } finally { + db.close(); + } + }); +}); + interface DevRunnerCliInput { readonly mode: DevMode; readonly t3Home: string | undefined; @@ -772,6 +807,19 @@ export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); + // Mirrors the server's deriveServerPaths: an explicit home uses userdata. + const stateDir = (yield* Path.Path).join( + baseDir, + env.T3CODE_HOME === undefined ? "dev" : "userdata", + ); + if ( + !input.dryRun && + (input.mode === "dev" || input.mode === "dev:server") && + (yield* isStateDirOwned(stateDir)) + ) { + return yield* new DevRunnerStateDirOwnedError({ stateDir }); + } + // Before the share block: --dry-run only resolves and prints. Sharing would // replace, then tear down, whatever mapping the port already had — a // surprising side effect from a command documented as inert.