334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
|
|
const DEV_RESTART_REQUEST_LOCK_STALE_MS = 30_000;
|
|
const DEV_RESTART_REQUEST_LOCK_RETRY_COUNT = 50;
|
|
const DEV_RESTART_REQUEST_LOCK_RETRY_MS = 2;
|
|
const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
|
|
export type PersistedDevServerStatus = {
|
|
dirty: boolean;
|
|
lastChangedAt: string | null;
|
|
changedPathCount: number;
|
|
changedPathsSample: string[];
|
|
pendingMigrations: string[];
|
|
lastRestartAt: string | null;
|
|
};
|
|
|
|
export type DevServerHealthStatus = {
|
|
enabled: true;
|
|
restartRequired: boolean;
|
|
reason:
|
|
| "backend_changes"
|
|
| "pending_migrations"
|
|
| "backend_changes_and_pending_migrations"
|
|
| null;
|
|
lastChangedAt: string | null;
|
|
changedPathCount: number;
|
|
changedPathsSample: string[];
|
|
pendingMigrations: string[];
|
|
autoRestartEnabled: boolean;
|
|
activeRunCount: number;
|
|
waitingForIdle: boolean;
|
|
lastRestartAt: string | null;
|
|
};
|
|
|
|
export type DevServerRestartRequest = {
|
|
requestedAt: string;
|
|
reason: "manual_restart_now";
|
|
requestId?: string;
|
|
mode?: "hot";
|
|
previousServerIdentity?: string;
|
|
};
|
|
|
|
function processIsAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function tryRecoverDevRestartRequestLock(lockPath: string): boolean {
|
|
let stale = false;
|
|
try {
|
|
const lockAgeMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
const owner = JSON.parse(
|
|
readFileSync(path.join(lockPath, "owner.json"), "utf8"),
|
|
) as Record<string, unknown>;
|
|
stale =
|
|
lockAgeMs >= DEV_RESTART_REQUEST_LOCK_STALE_MS ||
|
|
(typeof owner.pid === "number" && !processIsAlive(owner.pid));
|
|
} catch {
|
|
// Canonical locks are published only after owner.json is durable in a
|
|
// private candidate directory. A visible lock without valid ownership is
|
|
// therefore abandoned and can be reclaimed immediately.
|
|
stale = true;
|
|
}
|
|
if (!stale) return false;
|
|
|
|
const stalePath = `${lockPath}.${process.pid}.${randomUUID()}.stale`;
|
|
try {
|
|
renameSync(lockPath, stalePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
rmSync(stalePath, { recursive: true, force: true });
|
|
return true;
|
|
}
|
|
|
|
function withDevRestartRequestLock<T>(filePath: string, action: () => T): T {
|
|
const lockPath = `${filePath}.lock`;
|
|
for (
|
|
let attempt = 0;
|
|
attempt <= DEV_RESTART_REQUEST_LOCK_RETRY_COUNT;
|
|
attempt += 1
|
|
) {
|
|
const candidateLockPath = `${lockPath}.${process.pid}.${randomUUID()}.candidate`;
|
|
mkdirSync(candidateLockPath);
|
|
try {
|
|
writeFileSync(
|
|
path.join(candidateLockPath, "owner.json"),
|
|
`${JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
try {
|
|
// Publishing a populated directory makes lock ownership visible in one
|
|
// rename; there is no canonical owner-less crash window.
|
|
renameSync(candidateLockPath, lockPath);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
if (code !== "EEXIST" && code !== "ENOTEMPTY" && code !== "EPERM") {
|
|
throw error;
|
|
}
|
|
if (tryRecoverDevRestartRequestLock(lockPath)) continue;
|
|
if (attempt === DEV_RESTART_REQUEST_LOCK_RETRY_COUNT) {
|
|
throw new Error("dev_server_restart_request_lock_busy");
|
|
}
|
|
Atomics.wait(
|
|
lockWaitBuffer,
|
|
0,
|
|
0,
|
|
DEV_RESTART_REQUEST_LOCK_RETRY_MS,
|
|
);
|
|
continue;
|
|
}
|
|
} finally {
|
|
if (existsSync(candidateLockPath)) {
|
|
rmSync(candidateLockPath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
try {
|
|
return action();
|
|
} finally {
|
|
rmSync(lockPath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
throw new Error("dev_server_restart_request_lock_busy");
|
|
}
|
|
|
|
export function getDevServerRestartRequestFilePath(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string | null {
|
|
const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
|
|
if (!statusFilePath) return null;
|
|
return path.join(
|
|
path.dirname(statusFilePath),
|
|
"dev-server-restart-request.json",
|
|
);
|
|
}
|
|
|
|
export function writeDevServerRestartRequest(
|
|
request: DevServerRestartRequest,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): boolean {
|
|
const filePath = getDevServerRestartRequestFilePath(env);
|
|
if (!filePath) return false;
|
|
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
withDevRestartRequestLock(filePath, () => {
|
|
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
try {
|
|
writeFileSync(tempPath, `${JSON.stringify(request, null, 2)}\n`, "utf8");
|
|
renameSync(tempPath, filePath);
|
|
} finally {
|
|
try {
|
|
unlinkSync(tempPath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function readDevServerRestartRequestAtPath(
|
|
filePath: string,
|
|
): DevServerRestartRequest | null {
|
|
try {
|
|
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES)
|
|
return null;
|
|
const value = JSON.parse(readFileSync(filePath, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
if (
|
|
typeof value.requestedAt !== "string" ||
|
|
value.reason !== "manual_restart_now"
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
requestedAt: value.requestedAt,
|
|
reason: "manual_restart_now",
|
|
...(typeof value.requestId === "string"
|
|
? { requestId: value.requestId }
|
|
: {}),
|
|
...(value.mode === "hot" ? { mode: "hot" as const } : {}),
|
|
...(typeof value.previousServerIdentity === "string"
|
|
? { previousServerIdentity: value.previousServerIdentity }
|
|
: {}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readDevServerRestartRequest(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): DevServerRestartRequest | null {
|
|
const filePath = getDevServerRestartRequestFilePath(env);
|
|
if (!filePath || !existsSync(filePath)) return null;
|
|
return readDevServerRestartRequestAtPath(filePath);
|
|
}
|
|
|
|
export function removeDevServerRestartRequest(
|
|
expected?: Pick<DevServerRestartRequest, "requestId">,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): boolean {
|
|
const filePath = getDevServerRestartRequestFilePath(env);
|
|
if (!filePath) return false;
|
|
try {
|
|
withDevRestartRequestLock(filePath, () => {
|
|
const current = readDevServerRestartRequestAtPath(filePath);
|
|
if (expected?.requestId && current?.requestId !== expected.requestId) return;
|
|
rmSync(filePath, { force: true });
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
error.message === "dev_server_restart_request_lock_busy"
|
|
) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeStringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
function normalizeTimestamp(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
export function readPersistedDevServerStatus(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): PersistedDevServerStatus | null {
|
|
const filePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
|
|
if (!filePath || !existsSync(filePath)) return null;
|
|
|
|
try {
|
|
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) {
|
|
return null;
|
|
}
|
|
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const changedPathsSample = normalizeStringArray(
|
|
raw.changedPathsSample,
|
|
).slice(0, 5);
|
|
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
|
const changedPathCountRaw = raw.changedPathCount;
|
|
const changedPathCount =
|
|
typeof changedPathCountRaw === "number" &&
|
|
Number.isFinite(changedPathCountRaw)
|
|
? Math.max(0, Math.trunc(changedPathCountRaw))
|
|
: changedPathsSample.length;
|
|
const dirtyRaw = raw.dirty;
|
|
const dirty =
|
|
typeof dirtyRaw === "boolean"
|
|
? dirtyRaw
|
|
: changedPathCount > 0 || pendingMigrations.length > 0;
|
|
|
|
return {
|
|
dirty,
|
|
lastChangedAt: normalizeTimestamp(raw.lastChangedAt),
|
|
changedPathCount,
|
|
changedPathsSample,
|
|
pendingMigrations,
|
|
lastRestartAt: normalizeTimestamp(raw.lastRestartAt),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function toDevServerHealthStatus(
|
|
persisted: PersistedDevServerStatus,
|
|
opts: { autoRestartEnabled: boolean; activeRunCount: number },
|
|
): DevServerHealthStatus {
|
|
const hasPathChanges = persisted.changedPathCount > 0;
|
|
const hasPendingMigrations = persisted.pendingMigrations.length > 0;
|
|
const reason =
|
|
hasPathChanges && hasPendingMigrations
|
|
? "backend_changes_and_pending_migrations"
|
|
: hasPendingMigrations
|
|
? "pending_migrations"
|
|
: hasPathChanges
|
|
? "backend_changes"
|
|
: null;
|
|
const restartRequired = persisted.dirty || reason !== null;
|
|
|
|
return {
|
|
enabled: true,
|
|
restartRequired,
|
|
reason,
|
|
lastChangedAt: persisted.lastChangedAt,
|
|
changedPathCount: persisted.changedPathCount,
|
|
changedPathsSample: persisted.changedPathsSample,
|
|
pendingMigrations: persisted.pendingMigrations,
|
|
autoRestartEnabled: opts.autoRestartEnabled,
|
|
activeRunCount: opts.activeRunCount,
|
|
waitingForIdle:
|
|
restartRequired && opts.autoRestartEnabled && opts.activeRunCount > 0,
|
|
lastRestartAt: persisted.lastRestartAt,
|
|
};
|
|
}
|