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
41 changes: 41 additions & 0 deletions scripts/dev-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () =>
Expand Down
48 changes: 48 additions & 0 deletions scripts/dev-runner.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -182,6 +183,15 @@ export class DevRunnerProcessExitError extends Schema.TaggedError<DevRunnerProce
}
}

export class DevRunnerStateDirOwnedError extends Schema.TaggedError<DevRunnerStateDirOwnedError>()(
"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>()(
"DevRunnerHostNotProxiableError",
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading