/** * Whether a T3 Code server is currently running against a data directory. * * A maintained copy of the parts of `apps/server/src/serverRuntimeState.ts` that * host scripts need: that package exposes no importable surface to `scripts/`, * the same reason `./projection-tables.ts` exists. Only the fields read here are * modelled, so a server that adds a field stays readable. */// @effect-diagnostics nodeBuiltinImport:off - the sync probe runs inside a// synchronous SQLite transaction, where the platform layer is not available.import * as NodeFS from "node:fs";import * as NodePath from "node:path";import * as Effect from "effect/Effect";import * as Option from "effect/Option";/** The two state directories `deriveServerPaths` can select. */const STATE_DIRS = ["dev", "userdata"] as const;/** * The runtime-state file is removed by a release finalizer, so a server killed * with SIGKILL — or one that crashed — leaves it behind. Signal 0 runs the * permission and existence checks without delivering a signal, which is what * distinguishes "still running" from "left this here on the way out". * * PIDs are reused eventually, so a false positive is possible in principle. For * a guard on a destructive local-dev command that errs the safe way: the worst * case is being told to stop a server that is already stopped. */const isPidLive = (pid: number): boolean => { // `process.kill` reads non-positive pids as signal-group targets rather than // process lookups — 0 signals the caller's own group, -1 every permitted // process — so neither throws and both would report "live" unconditionally. // The schema rejects those, and this repeats the check so the invariant // travels with the code that depends on it. if (!Number.isSafeInteger(pid) || pid <= 0) { return false; } try { process.kill(pid, 0); return true; } catch (cause) { // EPERM means the process exists but belongs to another user. An // out-of-range pid throws a TypeError instead, which has no errno. return typeof cause === "object" && cause !== null && "code" in cause ? cause.code === "EPERM" : false; }};/** * The pid of a live server whose state file sits in one directory. * * Synchronous because its main caller runs inside `seedDevDatabase`'s open * SQLite transaction, where nothing can await. Applies one rule everywhere: a * positive live pid counts, anything unparseable does not — a malformed or * half-written file says nothing about liveness, and treating it as "a server * is up" would block seeding with no way to clear it. */function findPidInStateDir(baseDir: string, stateDir: string): number | undefined { let raw: string; try { raw = NodeFS.readFileSync(NodePath.join(baseDir, stateDir, "server-runtime.json"), "utf8"); } catch { return undefined; } try { const parsed: unknown = JSON.parse(raw); const pid: unknown = (parsed as { pid?: unknown } | null)?.pid; return typeof pid === "number" && isPidLive(pid) ? pid : undefined; } catch { return undefined; }}/** * The pid of a live server under `baseDir`, if any. * * `stateDir` scopes the probe to one state directory when the caller has * already resolved which database it is about to touch — a server using only * the *other* directory's database is not a conflict and must not block it. * Omitted, both directories count, for callers guarding the base as a whole. */export function findRunningServerPidSync(baseDir: string, stateDir?: string): number | undefined { for (const candidate of stateDir === undefined ? STATE_DIRS : [stateDir]) { const pid = findPidInStateDir(baseDir, candidate); if (pid !== undefined) { return pid; } } return undefined;}/** {@link findRunningServerPidSync} for Effect callers. */export const findRunningServerPid = ( baseDir: string, stateDir?: string,): Effect.Effect<Option.Option<number>> => Effect.sync(() => Option.fromNullishOr(findRunningServerPidSync(baseDir, stateDir)));
/**
* Whether a T3 Code server is currently running against a data directory.
*
* A maintained copy of the parts of `apps/server/src/serverRuntimeState.ts` that
* host scripts need: that package exposes no importable surface to `scripts/`,
* the same reason `./projection-tables.ts` exists. Only the fields read here are
* modelled, so a server that adds a field stays readable.
*/
// @effect-diagnostics nodeBuiltinImport:off - the sync probe runs inside a
// synchronous SQLite transaction, where the platform layer is not available.
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
/** The two state directories `deriveServerPaths` can select. */
const STATE_DIRS = ["dev", "userdata"] as const;
/**
* The runtime-state file is removed by a release finalizer, so a server killed
* with SIGKILL — or one that crashed — leaves it behind. Signal 0 runs the
* permission and existence checks without delivering a signal, which is what
* distinguishes "still running" from "left this here on the way out".
*
* PIDs are reused eventually, so a false positive is possible in principle. For
* a guard on a destructive local-dev command that errs the safe way: the worst
* case is being told to stop a server that is already stopped.
*/
const isPidLive = (pid: number): boolean => {
// `process.kill` reads non-positive pids as signal-group targets rather than
// process lookups — 0 signals the caller's own group, -1 every permitted
// process — so neither throws and both would report "live" unconditionally.
// The schema rejects those, and this repeats the check so the invariant
// travels with the code that depends on it.
if (!Number.isSafeInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (cause) {
// EPERM means the process exists but belongs to another user. An
// out-of-range pid throws a TypeError instead, which has no errno.
return typeof cause === "object" && cause !== null && "code" in cause
? cause.code === "EPERM"
: false;
}
};
/**
* The pid of a live server whose state file sits in one directory.
*
* Synchronous because its main caller runs inside `seedDevDatabase`'s open
* SQLite transaction, where nothing can await. Applies one rule everywhere: a
* positive live pid counts, anything unparseable does not — a malformed or
* half-written file says nothing about liveness, and treating it as "a server
* is up" would block seeding with no way to clear it.
*/
function findPidInStateDir(baseDir: string, stateDir: string): number | undefined {
let raw: string;
try {
raw = NodeFS.readFileSync(NodePath.join(baseDir, stateDir, "server-runtime.json"), "utf8");
} catch {
return undefined;
}
try {
const parsed: unknown = JSON.parse(raw);
const pid: unknown = (parsed as { pid?: unknown } | null)?.pid;
return typeof pid === "number" && isPidLive(pid) ? pid : undefined;
} catch {
return undefined;
}
}
/**
* The pid of a live server under `baseDir`, if any.
*
* `stateDir` scopes the probe to one state directory when the caller has
* already resolved which database it is about to touch — a server using only
* the *other* directory's database is not a conflict and must not block it.
* Omitted, both directories count, for callers guarding the base as a whole.
*/
export function findRunningServerPidSync(baseDir: string, stateDir?: string): number | undefined {
for (const candidate of stateDir === undefined ? STATE_DIRS : [stateDir]) {
const pid = findPidInStateDir(baseDir, candidate);
if (pid !== undefined) {
return pid;
}
}
return undefined;
}
/** {@link findRunningServerPidSync} for Effect callers. */
export const findRunningServerPid = (
baseDir: string,
stateDir?: string,
): Effect.Effect<Option.Option<number>> =>
Effect.sync(() => Option.fromNullishOr(findRunningServerPidSync(baseDir, stateDir)));