{"version":3,"file":"session-lease.d.ts","sourceRoot":"","sources":["../../../../src/runs/shared/session-lease.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { createHash, randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { createAtomicJsonWriter } from \"../../shared/atomic-json.ts\";\nimport { TEMP_ROOT_DIR } from \"../../shared/types.ts\";\n\nexport const SESSION_LEASES_DIR = path.join(TEMP_ROOT_DIR, \"session-leases\");\n\nexport interface SessionLeaseRequest {\n\tsessionFile: string;\n\trunId: string;\n\tsourceRunId: string;\n\tparentSessionId?: string;\n}\n\nexport interface SessionLeaseOwner {\n\tversion: 1;\n\ttoken: string;\n\tcanonicalSessionFile: string;\n\trunId: string;\n\tsourceRunId: string;\n\tparentSessionId?: string;\n\tpid: number;\n\thostname: string;\n\tprocessStartIdentity?: string;\n\twriterState: \"none\" | \"spawning\" | \"running\";\n\twriterPid?: number;\n\twriterProcessStartIdentity?: string;\n\tacquiredAt: string;\n\tacquiredAtMs: number;\n\tupdatedAtMs: number;\n}\n\nexport interface SessionLeaseHandle {\n\tleaseDir: string;\n\towner: SessionLeaseOwner;\n\tupdateWriter(writer: { state: \"none\" | \"spawning\" } | { state: \"running\"; pid: number }): void;\n\trelease(): boolean;\n}\n\nexport type SessionLeaseState =\n\t| { state: \"free\"; canonicalSessionFile: string; canonicalSessionId: string }\n\t| { state: \"owned\"; canonicalSessionFile: string; canonicalSessionId: string; owner: SessionLeaseOwner }\n\t| { state: \"unreadable\"; canonicalSessionFile: string; canonicalSessionId: string };\n\ninterface SessionLeaseOptions {\n\trootDir?: string;\n\tnow?: () => number;\n\ttoken?: () => string;\n\tpid?: number;\n\thostname?: string;\n\tprocessStartIdentity?: string;\n\tisProcessAlive?: (pid: number) => boolean | undefined;\n\tgetProcessStartIdentity?: (pid: number) => string | undefined;\n}\n\nexport class SessionLeaseConflictError extends Error {\n\treadonly owner?: SessionLeaseOwner;\n\n\tconstructor(message: string, owner?: SessionLeaseOwner) {\n\t\tsuper(message);\n\t\tthis.name = \"SessionLeaseConflictError\";\n\t\tthis.owner = owner;\n\t}\n}\n\nfunction getProcessStartIdentity(pid: number): string | undefined {\n\tif (process.platform === \"linux\") {\n\t\ttry {\n\t\t\tconst stat = fs.readFileSync(`/proc/${pid}/stat`, \"utf-8\");\n\t\t\tconst commandEnd = stat.lastIndexOf(\")\");\n\t\t\tif (commandEnd === -1) return undefined;\n\t\t\tconst fields = stat\n\t\t\t\t.slice(commandEnd + 1)\n\t\t\t\t.trim()\n\t\t\t\t.split(/\\s+/);\n\t\t\tconst startTicks = fields[19];\n\t\t\treturn startTicks ? `linux:${startTicks}` : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\tif (process.platform === \"darwin\" || process.platform === \"freebsd\") {\n\t\tconst result = spawnSync(\"/bin/ps\", [\"-o\", \"lstart=\", \"-p\", String(pid)], { encoding: \"utf-8\" });\n\t\tconst started = result.status === 0 ? result.stdout.trim() : \"\";\n\t\treturn started ? `${process.platform}:${started}` : undefined;\n\t}\n\treturn undefined;\n}\n\nfunction processIsAlive(pid: number): boolean | undefined {\n\ttry {\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch (error) {\n\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\tif (code === \"ESRCH\") return false;\n\t\tif (code === \"EPERM\") return true;\n\t\treturn undefined;\n\t}\n}\n\nexport function canonicalSessionFilePath(sessionFile: string): string {\n\treturn fs.realpathSync.native(path.resolve(sessionFile));\n}\n\nexport function canonicalSessionId(sessionFile: string): string {\n\tconst canonical = canonicalSessionFilePath(sessionFile);\n\tconst key = process.platform === \"win32\" ? canonical.toLowerCase() : canonical;\n\treturn createHash(\"sha256\").update(key).digest(\"hex\");\n}\n\nexport function sessionLeaseDir(sessionFile: string, rootDir = SESSION_LEASES_DIR): string {\n\treturn path.join(rootDir, canonicalSessionId(sessionFile));\n}\n\nexport function inspectSessionLease(sessionFile: string, rootDir = SESSION_LEASES_DIR): SessionLeaseState {\n\tconst canonicalSessionFile = canonicalSessionFilePath(sessionFile);\n\tconst canonicalSessionIdValue = canonicalSessionId(canonicalSessionFile);\n\tconst leaseDir = path.join(rootDir, canonicalSessionIdValue);\n\tif (!fs.existsSync(leaseDir))\n\t\treturn { state: \"free\", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue };\n\tconst owner = readLeaseOwner(leaseDir);\n\treturn owner\n\t\t? { state: \"owned\", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue, owner }\n\t\t: { state: \"unreadable\", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue };\n}\n\nfunction parseOwner(value: unknown): SessionLeaseOwner | undefined {\n\tif (!value || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n\tconst owner = value as Partial<SessionLeaseOwner>;\n\tif (\n\t\towner.version !== 1 ||\n\t\ttypeof owner.token !== \"string\" ||\n\t\ttypeof owner.canonicalSessionFile !== \"string\" ||\n\t\ttypeof owner.runId !== \"string\" ||\n\t\ttypeof owner.sourceRunId !== \"string\" ||\n\t\ttypeof owner.pid !== \"number\" ||\n\t\t!Number.isInteger(owner.pid) ||\n\t\towner.pid <= 0 ||\n\t\ttypeof owner.hostname !== \"string\" ||\n\t\t(owner.writerState !== \"none\" && owner.writerState !== \"spawning\" && owner.writerState !== \"running\") ||\n\t\ttypeof owner.acquiredAt !== \"string\" ||\n\t\ttypeof owner.acquiredAtMs !== \"number\" ||\n\t\ttypeof owner.updatedAtMs !== \"number\"\n\t)\n\t\treturn undefined;\n\tif (owner.parentSessionId !== undefined && typeof owner.parentSessionId !== \"string\") return undefined;\n\tif (owner.processStartIdentity !== undefined && typeof owner.processStartIdentity !== \"string\") return undefined;\n\tif (\n\t\towner.writerPid !== undefined &&\n\t\t(typeof owner.writerPid !== \"number\" || !Number.isInteger(owner.writerPid) || owner.writerPid <= 0)\n\t)\n\t\treturn undefined;\n\tif (owner.writerProcessStartIdentity !== undefined && typeof owner.writerProcessStartIdentity !== \"string\")\n\t\treturn undefined;\n\tif (owner.writerState === \"running\" && owner.writerPid === undefined) return undefined;\n\tif (\n\t\towner.writerState !== \"running\" &&\n\t\t(owner.writerPid !== undefined || owner.writerProcessStartIdentity !== undefined)\n\t)\n\t\treturn undefined;\n\treturn owner as SessionLeaseOwner;\n}\n\nfunction readLeaseOwner(leaseDir: string): SessionLeaseOwner | undefined {\n\ttry {\n\t\treturn parseOwner(JSON.parse(fs.readFileSync(path.join(leaseDir, \"owner.json\"), \"utf-8\")));\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction conflictMessage(canonicalSessionFile: string, owner: SessionLeaseOwner | undefined): string {\n\tif (!owner) {\n\t\treturn `Direct revival of session '${canonicalSessionFile}' is blocked by an existing lease with unreadable owner metadata. Refusing to reclaim it without proof that the owner is stale.`;\n\t}\n\tconst parent = owner.parentSessionId ? `, parent session '${owner.parentSessionId}'` : \"\";\n\treturn `Direct revival of session '${canonicalSessionFile}' is already owned by run '${owner.runId}' (source run '${owner.sourceRunId}'${parent}, pid ${owner.pid} on ${owner.hostname}). Wait for that revival to finish or start a separate continuation without reusing this session file.`;\n}\n\nfunction processDemonstrablyGone(\n\tpid: number,\n\tstartIdentity: string | undefined,\n\toptions: Required<Pick<SessionLeaseOptions, \"isProcessAlive\" | \"getProcessStartIdentity\">>,\n): boolean {\n\tconst alive = options.isProcessAlive(pid);\n\tif (alive === false) return true;\n\tif (alive !== true || !startIdentity) return false;\n\tconst currentIdentity = options.getProcessStartIdentity(pid);\n\treturn currentIdentity !== undefined && currentIdentity !== startIdentity;\n}\n\nfunction demonstrablyStale(\n\towner: SessionLeaseOwner,\n\toptions: Required<Pick<SessionLeaseOptions, \"hostname\" | \"isProcessAlive\" | \"getProcessStartIdentity\">>,\n): boolean {\n\tif (owner.hostname !== options.hostname) return false;\n\tif (!processDemonstrablyGone(owner.pid, owner.processStartIdentity, options)) return false;\n\tif (owner.writerState === \"spawning\") return false;\n\tif (owner.writerState === \"none\") return true;\n\treturn (\n\t\towner.writerPid !== undefined &&\n\t\tprocessDemonstrablyGone(owner.writerPid, owner.writerProcessStartIdentity, options)\n\t);\n}\n\nfunction createLeaseDirectory(leaseDir: string, owner: SessionLeaseOwner): boolean {\n\tconst tempDir = `${leaseDir}.candidate-${owner.token}`;\n\tfs.mkdirSync(path.dirname(leaseDir), { recursive: true, mode: 0o700 });\n\tfs.rmSync(tempDir, { recursive: true, force: true });\n\tfs.mkdirSync(tempDir, { mode: 0o700 });\n\ttry {\n\t\tfs.writeFileSync(path.join(tempDir, \"owner.json\"), JSON.stringify(owner, null, 2), {\n\t\t\tencoding: \"utf-8\",\n\t\t\tmode: 0o600,\n\t\t});\n\t\ttry {\n\t\t\tfs.renameSync(tempDir, leaseDir);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tif (fs.existsSync(leaseDir)) return false;\n\t\t\tthrow error;\n\t\t}\n\t} finally {\n\t\tfs.rmSync(tempDir, { recursive: true, force: true });\n\t}\n}\n\nexport function acquireSessionLease(\n\trequest: SessionLeaseRequest,\n\toptions: SessionLeaseOptions = {},\n): SessionLeaseHandle {\n\tconst canonicalSessionFile = canonicalSessionFilePath(request.sessionFile);\n\tconst rootDir = options.rootDir ?? SESSION_LEASES_DIR;\n\tconst leaseDir = sessionLeaseDir(canonicalSessionFile, rootDir);\n\tconst now = options.now ?? Date.now;\n\tconst pid = options.pid ?? process.pid;\n\tconst hostname = options.hostname ?? os.hostname();\n\tconst getIdentity = options.getProcessStartIdentity ?? getProcessStartIdentity;\n\tconst processStartIdentity =\n\t\toptions.processStartIdentity ??\n\t\tgetIdentity(pid) ??\n\t\t(pid === process.pid ? `runtime:${Math.round(Date.now() - process.uptime() * 1000)}` : undefined);\n\tconst acquiredAtMs = now();\n\tconst owner: SessionLeaseOwner = {\n\t\tversion: 1,\n\t\ttoken: options.token?.() ?? randomUUID(),\n\t\tcanonicalSessionFile,\n\t\trunId: request.runId,\n\t\tsourceRunId: request.sourceRunId,\n\t\t...(request.parentSessionId ? { parentSessionId: request.parentSessionId } : {}),\n\t\tpid,\n\t\thostname,\n\t\t...(processStartIdentity ? { processStartIdentity } : {}),\n\t\twriterState: \"none\",\n\t\tacquiredAt: new Date(acquiredAtMs).toISOString(),\n\t\tacquiredAtMs,\n\t\tupdatedAtMs: acquiredAtMs,\n\t};\n\tconst staleOptions = {\n\t\thostname,\n\t\tisProcessAlive: options.isProcessAlive ?? processIsAlive,\n\t\tgetProcessStartIdentity: getIdentity,\n\t};\n\n\tfor (let attempt = 0; attempt < 4; attempt++) {\n\t\tif (createLeaseDirectory(leaseDir, owner)) {\n\t\t\tconst writeOwner = createAtomicJsonWriter();\n\t\t\treturn {\n\t\t\t\tleaseDir,\n\t\t\t\towner,\n\t\t\t\tupdateWriter(writer) {\n\t\t\t\t\tconst currentOwner = readLeaseOwner(leaseDir);\n\t\t\t\t\tif (!currentOwner || currentOwner.token !== owner.token) {\n\t\t\t\t\t\tthrow new Error(`Session revival lease ownership changed for run '${owner.runId}'.`);\n\t\t\t\t\t}\n\t\t\t\t\tconst writerProcessStartIdentity = writer.state === \"running\" ? getIdentity(writer.pid) : undefined;\n\t\t\t\t\tconst nextOwner: SessionLeaseOwner = {\n\t\t\t\t\t\t...owner,\n\t\t\t\t\t\twriterState: writer.state,\n\t\t\t\t\t\t...(writer.state === \"running\" ? { writerPid: writer.pid } : {}),\n\t\t\t\t\t\t...(writerProcessStartIdentity ? { writerProcessStartIdentity } : {}),\n\t\t\t\t\t\tupdatedAtMs: now(),\n\t\t\t\t\t};\n\t\t\t\t\tdelete nextOwner.writerPid;\n\t\t\t\t\tdelete nextOwner.writerProcessStartIdentity;\n\t\t\t\t\tif (writer.state === \"running\") {\n\t\t\t\t\t\tnextOwner.writerPid = writer.pid;\n\t\t\t\t\t\tif (writerProcessStartIdentity) nextOwner.writerProcessStartIdentity = writerProcessStartIdentity;\n\t\t\t\t\t}\n\t\t\t\t\twriteOwner(path.join(leaseDir, \"owner.json\"), nextOwner);\n\t\t\t\t\tdelete owner.writerPid;\n\t\t\t\t\tdelete owner.writerProcessStartIdentity;\n\t\t\t\t\tObject.assign(owner, nextOwner);\n\t\t\t\t},\n\t\t\t\trelease() {\n\t\t\t\t\tconst currentOwner = readLeaseOwner(leaseDir);\n\t\t\t\t\tif (!currentOwner || currentOwner.token !== owner.token) return false;\n\t\t\t\t\tfs.rmSync(leaseDir, { recursive: true, force: true });\n\t\t\t\t\treturn !fs.existsSync(leaseDir);\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst existingOwner = readLeaseOwner(leaseDir);\n\t\tif (!existingOwner || !demonstrablyStale(existingOwner, staleOptions)) {\n\t\t\tthrow new SessionLeaseConflictError(conflictMessage(canonicalSessionFile, existingOwner), existingOwner);\n\t\t}\n\t\t// The per-owner tombstone is retained. Every contender that observed this\n\t\t// stale token targets the same occupied destination, so only the first can\n\t\t// rename it and later contenders cannot move a successor lease by mistake.\n\t\tconst tombstone = `${leaseDir}.stale-${existingOwner.token.replace(/[^A-Za-z0-9._-]/g, \"-\")}`;\n\t\ttry {\n\t\t\tfs.renameSync(leaseDir, tombstone);\n\t\t} catch (error) {\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\tif (code === \"ENOENT\" || fs.existsSync(tombstone)) continue;\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tconst existingOwner = readLeaseOwner(leaseDir);\n\tthrow new SessionLeaseConflictError(conflictMessage(canonicalSessionFile, existingOwner), existingOwner);\n}\n"]}