@aefree/pi-unity 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { commandTargetsProject, type SupportedPlatform } from "./unity-core";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export type RunningUnityProcess = {
8
+ pid: number | null;
9
+ commandLine: string;
10
+ };
11
+
12
+ export type UnityProcessTerminationInfo = {
13
+ forced?: boolean;
14
+ };
15
+
16
+ const SENSITIVE_FLAG = "(?:-{1,2})?(?:access[-_]?token|auth(?:entication)?[-_]?token|api[-_]?key|client[-_]?secret|credential(?:s)?|password|passwd|secret|token)";
17
+ const SENSITIVE_VALUE = "(?:\"[^\"]*\"|'[^']*'|[^\\s]+)";
18
+ const SENSITIVE_ASSIGNMENT = new RegExp(`(${SENSITIVE_FLAG})(=|:)${SENSITIVE_VALUE}`, "gi");
19
+ const SENSITIVE_SEPARATE_ARGUMENT = new RegExp(`(${SENSITIVE_FLAG})\\s+${SENSITIVE_VALUE}`, "gi");
20
+
21
+ /** Redact values, not flag names, so diagnostics remain actionable without leaking credentials. */
22
+ export function redactUnityProcessCommandLine(commandLine: string): string {
23
+ return commandLine
24
+ .replace(SENSITIVE_ASSIGNMENT, "$1$2[REDACTED]")
25
+ .replace(SENSITIVE_SEPARATE_ARGUMENT, "$1 [REDACTED]");
26
+ }
27
+
28
+ export type UnityProcessTerminator = (process: RunningUnityProcess) => Promise<void | UnityProcessTerminationInfo>;
29
+ export type UnityProcessIdentityVerifier = (process: RunningUnityProcess) => Promise<boolean>;
30
+
31
+ export type TerminateUnityProcessesResult = {
32
+ terminated: RunningUnityProcess[];
33
+ forceTerminated: RunningUnityProcess[];
34
+ skipped: RunningUnityProcess[];
35
+ };
36
+
37
+ export function parseWindowsUnityProcessList(output: string, projectRoot: string): RunningUnityProcess[] {
38
+ const trimmed = output.trim();
39
+ if (!trimmed) {
40
+ return [];
41
+ }
42
+
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(trimmed);
46
+ } catch {
47
+ return [];
48
+ }
49
+
50
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
51
+ return entries
52
+ .map((entry) => {
53
+ if (!entry || typeof entry !== "object") return null;
54
+ const record = entry as Record<string, unknown>;
55
+ const commandLine = typeof record.CommandLine === "string" ? record.CommandLine : "";
56
+ const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
57
+ if (!commandLine || !commandTargetsProject(commandLine, projectRoot, "win32")) {
58
+ return null;
59
+ }
60
+ return { pid, commandLine: redactUnityProcessCommandLine(commandLine) } satisfies RunningUnityProcess;
61
+ })
62
+ .filter((entry): entry is RunningUnityProcess => entry !== null);
63
+ }
64
+
65
+ export function parsePosixUnityProcessList(output: string, projectRoot: string, platform: SupportedPlatform = process.platform): RunningUnityProcess[] {
66
+ return output
67
+ .split(/\r?\n/)
68
+ .map((line) => line.trim())
69
+ .filter(Boolean)
70
+ .map((line) => {
71
+ const match = line.match(/^(\d+)\s+(.*)$/);
72
+ if (!match) return null;
73
+ const pid = Number.parseInt(match[1], 10);
74
+ const commandLine = match[2] ?? "";
75
+ const looksLikeUnity = /(^|[\/\s"'])Unity(?:\.app\/Contents\/MacOS\/Unity)?(?=$|[\s"'])/.test(commandLine);
76
+ if (!commandLine || !looksLikeUnity || !commandTargetsProject(commandLine, projectRoot, platform)) {
77
+ return null;
78
+ }
79
+ return {
80
+ pid: Number.isFinite(pid) ? pid : null,
81
+ commandLine: redactUnityProcessCommandLine(commandLine),
82
+ } satisfies RunningUnityProcess;
83
+ })
84
+ .filter((entry): entry is RunningUnityProcess => entry !== null);
85
+ }
86
+
87
+ export function dedupeRunningUnityProcesses(processes: RunningUnityProcess[]): RunningUnityProcess[] {
88
+ const seenPids = new Set<number>();
89
+ const seenCommandLines = new Set<string>();
90
+ const unique: RunningUnityProcess[] = [];
91
+
92
+ for (const runningProcess of processes) {
93
+ if (typeof runningProcess.pid === "number" && Number.isInteger(runningProcess.pid) && runningProcess.pid > 0) {
94
+ if (seenPids.has(runningProcess.pid)) continue;
95
+ seenPids.add(runningProcess.pid);
96
+ unique.push(runningProcess);
97
+ continue;
98
+ }
99
+
100
+ if (seenCommandLines.has(runningProcess.commandLine)) continue;
101
+ seenCommandLines.add(runningProcess.commandLine);
102
+ unique.push(runningProcess);
103
+ }
104
+
105
+ return unique;
106
+ }
107
+
108
+ function getErrorText(error: unknown): string {
109
+ if (!error || typeof error !== "object") {
110
+ return String(error ?? "");
111
+ }
112
+
113
+ const record = error as { stdout?: unknown; stderr?: unknown; message?: unknown };
114
+ return [record.stdout, record.stderr, record.message]
115
+ .filter((value): value is string => typeof value === "string")
116
+ .join("\n");
117
+ }
118
+
119
+ export function shouldRetryWindowsTaskkillWithForce(error: unknown): boolean {
120
+ const text = getErrorText(error).toLowerCase();
121
+ return text.includes("/f") && (
122
+ text.includes("forcefully") ||
123
+ text.includes("terminated forcefully") ||
124
+ text.includes("child process") ||
125
+ text.includes("child processes")
126
+ );
127
+ }
128
+
129
+ export async function defaultUnityProcessTerminator(
130
+ runningProcess: RunningUnityProcess,
131
+ platform: SupportedPlatform = process.platform,
132
+ ): Promise<UnityProcessTerminationInfo> {
133
+ if (typeof runningProcess.pid !== "number" || !Number.isInteger(runningProcess.pid) || runningProcess.pid <= 0) {
134
+ throw new Error(`Cannot close Unity process because no valid PID was reported: ${redactUnityProcessCommandLine(runningProcess.commandLine)}`);
135
+ }
136
+
137
+ if (platform === "win32") {
138
+ try {
139
+ await execFileAsync("taskkill.exe", ["/PID", String(runningProcess.pid), "/T"], { timeout: 5000, windowsHide: true });
140
+ return { forced: false };
141
+ } catch (error) {
142
+ if (!shouldRetryWindowsTaskkillWithForce(error)) {
143
+ throw error;
144
+ }
145
+ await execFileAsync("taskkill.exe", ["/PID", String(runningProcess.pid), "/T", "/F"], { timeout: 5000, windowsHide: true });
146
+ return { forced: true };
147
+ }
148
+ }
149
+
150
+ process.kill(runningProcess.pid, "SIGTERM");
151
+ return { forced: false };
152
+ }
153
+
154
+ export function unityProcessIdentityMatchesCandidates(
155
+ runningProcess: RunningUnityProcess,
156
+ candidates: RunningUnityProcess[],
157
+ ): boolean {
158
+ return candidates.some((candidate) => candidate.pid === runningProcess.pid
159
+ && (runningProcess.commandLine.startsWith("Unity CLI status") || candidate.commandLine === runningProcess.commandLine));
160
+ }
161
+
162
+ export async function verifyUnityProcessIdentity(
163
+ runningProcess: RunningUnityProcess,
164
+ projectRoot: string,
165
+ platform: SupportedPlatform = process.platform,
166
+ ): Promise<boolean> {
167
+ if (typeof runningProcess.pid !== "number" || !Number.isInteger(runningProcess.pid) || runningProcess.pid <= 0) {
168
+ return false;
169
+ }
170
+
171
+ try {
172
+ if (platform === "win32") {
173
+ const script = [
174
+ "$ErrorActionPreference='Stop';",
175
+ `Get-CimInstance Win32_Process -Filter \"ProcessId = ${runningProcess.pid}\"`,
176
+ "| Select-Object ProcessId, CommandLine",
177
+ "| ConvertTo-Json -Compress",
178
+ ].join(" ");
179
+ const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-Command", script], { timeout: 5000, windowsHide: true });
180
+ return unityProcessIdentityMatchesCandidates(
181
+ runningProcess,
182
+ parseWindowsUnityProcessList(stdout, projectRoot),
183
+ );
184
+ }
185
+
186
+ const { stdout } = await execFileAsync("ps", ["-p", String(runningProcess.pid), "-o", "pid=,command="], { timeout: 5000 });
187
+ return unityProcessIdentityMatchesCandidates(
188
+ runningProcess,
189
+ parsePosixUnityProcessList(stdout, projectRoot, platform),
190
+ );
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ export async function terminateRunningUnityProcesses(
197
+ processes: RunningUnityProcess[],
198
+ options: {
199
+ terminator?: UnityProcessTerminator;
200
+ identityVerifier?: UnityProcessIdentityVerifier;
201
+ onTerminated?: (runningProcess: RunningUnityProcess, info: UnityProcessTerminationInfo) => void;
202
+ signal?: AbortSignal;
203
+ } = {},
204
+ ): Promise<TerminateUnityProcessesResult> {
205
+ const terminator = options.terminator ?? defaultUnityProcessTerminator;
206
+ const terminated: RunningUnityProcess[] = [];
207
+ const forceTerminated: RunningUnityProcess[] = [];
208
+ const skipped: RunningUnityProcess[] = [];
209
+
210
+ for (const runningProcess of dedupeRunningUnityProcesses(processes)) {
211
+ options.signal?.throwIfAborted();
212
+ if (typeof runningProcess.pid !== "number" || !Number.isInteger(runningProcess.pid) || runningProcess.pid <= 0) {
213
+ skipped.push(runningProcess);
214
+ continue;
215
+ }
216
+
217
+ if (options.identityVerifier && !(await options.identityVerifier(runningProcess))) {
218
+ skipped.push(runningProcess);
219
+ continue;
220
+ }
221
+
222
+ options.signal?.throwIfAborted();
223
+ const terminationInfo = await terminator(runningProcess);
224
+ terminated.push(runningProcess);
225
+ if (terminationInfo?.forced === true) {
226
+ forceTerminated.push(runningProcess);
227
+ }
228
+ options.onTerminated?.(runningProcess, terminationInfo ?? {});
229
+ }
230
+
231
+ return { terminated, forceTerminated, skipped };
232
+ }
233
+
234
+ export async function listRunningUnityProcessesForProject(
235
+ projectRoot: string,
236
+ platform: SupportedPlatform = process.platform,
237
+ ): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
238
+ try {
239
+ if (platform === "win32") {
240
+ const script = [
241
+ "$ErrorActionPreference='Stop';",
242
+ "Get-CimInstance Win32_Process",
243
+ "| Where-Object { $_.Name -eq 'Unity.exe' }",
244
+ "| Select-Object ProcessId, CommandLine",
245
+ "| ConvertTo-Json -Compress",
246
+ ].join(" ");
247
+ const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-Command", script], { timeout: 5000, windowsHide: true });
248
+ return { processes: parseWindowsUnityProcessList(stdout, projectRoot) };
249
+ }
250
+
251
+ const { stdout } = await execFileAsync("ps", ["-ax", "-o", "pid=,command="], { timeout: 5000 });
252
+ return { processes: parsePosixUnityProcessList(stdout, projectRoot, platform) };
253
+ } catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
255
+ return {
256
+ processes: [],
257
+ warning: `Could not verify whether Unity is already running for this project: ${message}`,
258
+ };
259
+ }
260
+ }
@@ -0,0 +1,381 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { type SupportedPlatform } from "./unity-core";
6
+ import { listRunningUnityProcessesForProject, redactUnityProcessCommandLine, type RunningUnityProcess } from "./unity-processes";
7
+
8
+ type ProcessListResult = { processes: RunningUnityProcess[]; warning?: string };
9
+ type UnityProcessLister = (projectRoot: string) => Promise<ProcessListResult>;
10
+ type PidAliveCheck = (pid: number) => boolean;
11
+
12
+ export type UnityProjectBusyState = {
13
+ projectRoot: string;
14
+ canonicalProjectRoot: string;
15
+ nativeLockfilePath: string;
16
+ nativeLockfileExists: boolean;
17
+ };
18
+
19
+ export type UnityProjectLaunchMutexMetadata = {
20
+ projectRoot: string;
21
+ canonicalProjectRoot: string;
22
+ ownerPid: number;
23
+ ownerToken: string;
24
+ createdAt: string;
25
+ mode: "batchmode" | "gui";
26
+ toolName: string;
27
+ };
28
+
29
+ export type UnityProjectLaunchMutex = {
30
+ mutexDir: string;
31
+ metadata: UnityProjectLaunchMutexMetadata;
32
+ release: () => Promise<void>;
33
+ };
34
+
35
+ export type UnityProjectBusyOptions = {
36
+ platform?: SupportedPlatform;
37
+ processLister?: UnityProcessLister;
38
+ };
39
+
40
+ export type UnityLaunchSafetyRoute = "unity-cli" | "editor-executable";
41
+ export type UnityLaunchSafetyDecision = { allowed: true; staleLockDelegated?: boolean } | { allowed: false; reason: "process_unknown" | "matching_process" | "native_lockfile" };
42
+
43
+ /** Pure route matrix used by launch paths: uncertainty and matching editors always block. */
44
+ export function evaluateUnityLaunchSafety(
45
+ route: UnityLaunchSafetyRoute,
46
+ state: Pick<UnityProjectBusyState, "nativeLockfileExists">,
47
+ processes: ProcessListResult,
48
+ ): UnityLaunchSafetyDecision {
49
+ if (processes.warning) return { allowed: false, reason: "process_unknown" };
50
+ if (processes.processes.length > 0) return { allowed: false, reason: "matching_process" };
51
+ if (state.nativeLockfileExists && route === "editor-executable") return { allowed: false, reason: "native_lockfile" };
52
+ return { allowed: true, ...(state.nativeLockfileExists ? { staleLockDelegated: true } : {}) };
53
+ }
54
+
55
+ export type UnityProjectLaunchMutexOptions = UnityProjectBusyOptions & {
56
+ mode?: "batchmode" | "gui";
57
+ toolName?: string;
58
+ mutexRoot?: string;
59
+ now?: () => Date;
60
+ randomToken?: () => string;
61
+ ownerPid?: number;
62
+ isPidAlive?: PidAliveCheck;
63
+ };
64
+
65
+ const METADATA_FILE = "metadata.json";
66
+
67
+ function defaultMutexRoot(): string {
68
+ return path.join(os.tmpdir(), "pi-unity-project-locks");
69
+ }
70
+
71
+ function isNodeErrorWithCode(error: unknown, code: string): boolean {
72
+ return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === code);
73
+ }
74
+
75
+ async function pathExists(filePath: string): Promise<boolean> {
76
+ try {
77
+ await fs.access(filePath);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ export function getUnityNativeLockfilePath(projectRoot: string): string {
85
+ return path.join(projectRoot, "Temp", "UnityLockfile");
86
+ }
87
+
88
+ export async function canonicalizeUnityProjectRoot(
89
+ projectRoot: string,
90
+ platform: SupportedPlatform = process.platform,
91
+ ): Promise<string> {
92
+ const absoluteProjectRoot = path.resolve(projectRoot);
93
+ let realProjectRoot: string;
94
+ try {
95
+ realProjectRoot = await fs.realpath(absoluteProjectRoot);
96
+ } catch {
97
+ realProjectRoot = absoluteProjectRoot;
98
+ }
99
+
100
+ const normalized = path.normalize(realProjectRoot);
101
+ return platform === "win32" ? normalized.toLowerCase() : normalized;
102
+ }
103
+
104
+ export function getUnityProjectMutexDir(canonicalProjectRoot: string, mutexRoot: string = defaultMutexRoot()): string {
105
+ const hash = crypto.createHash("sha256").update(canonicalProjectRoot).digest("hex").slice(0, 32);
106
+ return path.join(mutexRoot, hash);
107
+ }
108
+
109
+ function defaultIsPidAlive(pid: number): boolean {
110
+ if (!Number.isInteger(pid) || pid <= 0) {
111
+ return false;
112
+ }
113
+
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch (error) {
118
+ if (isNodeErrorWithCode(error, "ESRCH")) {
119
+ return false;
120
+ }
121
+ return true;
122
+ }
123
+ }
124
+
125
+ function buildProcessSummary(processes: RunningUnityProcess[]): string {
126
+ return processes
127
+ .map((process) => `${process.pid ?? "?"}: ${redactUnityProcessCommandLine(process.commandLine)}`)
128
+ .join("\n");
129
+ }
130
+
131
+ async function listProcessesSafely(processLister: UnityProcessLister, projectRoot: string): Promise<ProcessListResult> {
132
+ try {
133
+ return await processLister(projectRoot);
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
136
+ return { processes: [], warning: `Could not verify whether Unity is already running for this project: ${message}` };
137
+ }
138
+ }
139
+
140
+ export async function inspectUnityProjectBusyState(
141
+ projectRoot: string,
142
+ options: UnityProjectBusyOptions = {},
143
+ ): Promise<UnityProjectBusyState> {
144
+ const canonicalProjectRoot = await canonicalizeUnityProjectRoot(projectRoot, options.platform);
145
+ const nativeLockfilePath = getUnityNativeLockfilePath(projectRoot);
146
+ const nativeLockfileExists = await pathExists(nativeLockfilePath);
147
+ return {
148
+ projectRoot,
149
+ canonicalProjectRoot,
150
+ nativeLockfilePath,
151
+ nativeLockfileExists,
152
+ };
153
+ }
154
+
155
+ export async function assertUnityProjectNotBusy(
156
+ projectRoot: string,
157
+ options: UnityProjectBusyOptions = {},
158
+ ): Promise<UnityProjectBusyState> {
159
+ const state = await inspectUnityProjectBusyState(projectRoot, options);
160
+ if (!state.nativeLockfileExists) {
161
+ return state;
162
+ }
163
+
164
+ const processLister = options.processLister ?? listRunningUnityProcessesForProject;
165
+ const running = await listProcessesSafely(processLister, projectRoot);
166
+ if (running.warning) {
167
+ throw new Error(
168
+ [
169
+ `Refusing to launch Unity for ${projectRoot} because Unity's native project lockfile exists and running-process verification failed.`,
170
+ `Unity lockfile: ${state.nativeLockfilePath}`,
171
+ running.warning,
172
+ "Inspect the project manually before removing the lockfile or retrying.",
173
+ ].join("\n"),
174
+ );
175
+ }
176
+
177
+ if (running.processes.length > 0) {
178
+ throw new Error(
179
+ [
180
+ `Refusing to launch Unity for ${projectRoot} because Unity's native project lockfile exists and a Unity process targets this project.`,
181
+ `Unity lockfile: ${state.nativeLockfilePath}`,
182
+ buildProcessSummary(running.processes),
183
+ ].join("\n"),
184
+ );
185
+ }
186
+
187
+ throw new Error(
188
+ [
189
+ `Refusing to launch Unity for ${projectRoot} because Unity's native project lockfile exists.`,
190
+ `Unity lockfile: ${state.nativeLockfilePath}`,
191
+ "No running Unity process targeting this project was detected; this may be a stale Unity lockfile.",
192
+ "Remove the lockfile manually only after confirming no Unity process is using this project.",
193
+ ].join("\n"),
194
+ );
195
+ }
196
+
197
+ function metadataPath(mutexDir: string): string {
198
+ return path.join(mutexDir, METADATA_FILE);
199
+ }
200
+
201
+ function parseMutexMetadata(raw: string): UnityProjectLaunchMutexMetadata | null {
202
+ let parsed: unknown;
203
+ try {
204
+ parsed = JSON.parse(raw);
205
+ } catch {
206
+ return null;
207
+ }
208
+
209
+ if (!parsed || typeof parsed !== "object") {
210
+ return null;
211
+ }
212
+
213
+ const record = parsed as Record<string, unknown>;
214
+ if (
215
+ typeof record.projectRoot !== "string" ||
216
+ typeof record.canonicalProjectRoot !== "string" ||
217
+ typeof record.ownerPid !== "number" ||
218
+ typeof record.ownerToken !== "string" ||
219
+ typeof record.createdAt !== "string" ||
220
+ (record.mode !== "batchmode" && record.mode !== "gui") ||
221
+ typeof record.toolName !== "string"
222
+ ) {
223
+ return null;
224
+ }
225
+
226
+ return record as UnityProjectLaunchMutexMetadata;
227
+ }
228
+
229
+ async function readMutexMetadata(mutexDir: string): Promise<UnityProjectLaunchMutexMetadata | null> {
230
+ try {
231
+ const raw = await fs.readFile(metadataPath(mutexDir), "utf8");
232
+ return parseMutexMetadata(raw);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function buildMutexConflictMessage(projectRoot: string, metadata: UnityProjectLaunchMutexMetadata, mutexDir: string): string {
239
+ return [
240
+ `Refusing to launch Unity for ${projectRoot} because another Pi Unity launch already holds the project mutex.`,
241
+ `Mutex: ${mutexDir}`,
242
+ `Owner pid: ${metadata.ownerPid}`,
243
+ `Owner tool: ${metadata.toolName}`,
244
+ `Owner mode: ${metadata.mode}`,
245
+ `Created at: ${metadata.createdAt}`,
246
+ ].join("\n");
247
+ }
248
+
249
+ async function clearStaleMutexOrThrow(
250
+ projectRoot: string,
251
+ mutexDir: string,
252
+ metadata: UnityProjectLaunchMutexMetadata,
253
+ options: UnityProjectLaunchMutexOptions,
254
+ ): Promise<void> {
255
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
256
+ if (isPidAlive(metadata.ownerPid)) {
257
+ throw new Error(buildMutexConflictMessage(projectRoot, metadata, mutexDir));
258
+ }
259
+
260
+ const busyState = await inspectUnityProjectBusyState(projectRoot, options);
261
+ if (busyState.nativeLockfileExists) {
262
+ throw new Error(
263
+ [
264
+ `Refusing to clear a stale Pi Unity launch mutex for ${projectRoot} because Unity's native project lockfile exists.`,
265
+ `Mutex: ${mutexDir}`,
266
+ `Unity lockfile: ${busyState.nativeLockfilePath}`,
267
+ "Inspect the project manually before retrying.",
268
+ ].join("\n"),
269
+ );
270
+ }
271
+
272
+ const processLister = options.processLister ?? listRunningUnityProcessesForProject;
273
+ const running = await listProcessesSafely(processLister, projectRoot);
274
+ if (running.warning) {
275
+ throw new Error(
276
+ [
277
+ `Refusing to clear a stale Pi Unity launch mutex for ${projectRoot} because running-process verification failed.`,
278
+ `Mutex: ${mutexDir}`,
279
+ running.warning,
280
+ ].join("\n"),
281
+ );
282
+ }
283
+
284
+ if (running.processes.length > 0) {
285
+ throw new Error(
286
+ [
287
+ `Refusing to clear a stale Pi Unity launch mutex for ${projectRoot} because a Unity process still targets this project.`,
288
+ `Mutex: ${mutexDir}`,
289
+ buildProcessSummary(running.processes),
290
+ ].join("\n"),
291
+ );
292
+ }
293
+
294
+ await fs.rm(mutexDir, { recursive: true, force: true });
295
+ }
296
+
297
+ export async function releaseUnityProjectLaunchMutex(mutexDir: string, ownerToken: string): Promise<void> {
298
+ const metadata = await readMutexMetadata(mutexDir);
299
+ if (!metadata || metadata.ownerToken !== ownerToken) {
300
+ return;
301
+ }
302
+
303
+ await fs.rm(mutexDir, { recursive: true, force: true });
304
+ }
305
+
306
+ export async function acquireUnityProjectLaunchMutex(
307
+ projectRoot: string,
308
+ options: UnityProjectLaunchMutexOptions = {},
309
+ ): Promise<UnityProjectLaunchMutex> {
310
+ const canonicalProjectRoot = await canonicalizeUnityProjectRoot(projectRoot, options.platform);
311
+ const mutexRoot = options.mutexRoot ?? defaultMutexRoot();
312
+ const mutexDir = getUnityProjectMutexDir(canonicalProjectRoot, mutexRoot);
313
+ await fs.mkdir(mutexRoot, { recursive: true });
314
+
315
+ for (let attempt = 0; attempt < 2; attempt += 1) {
316
+ try {
317
+ await fs.mkdir(mutexDir);
318
+ const metadata: UnityProjectLaunchMutexMetadata = {
319
+ projectRoot,
320
+ canonicalProjectRoot,
321
+ ownerPid: options.ownerPid ?? process.pid,
322
+ ownerToken: options.randomToken?.() ?? crypto.randomUUID(),
323
+ createdAt: (options.now?.() ?? new Date()).toISOString(),
324
+ mode: options.mode ?? "batchmode",
325
+ toolName: options.toolName ?? "unity_launch_batchmode",
326
+ };
327
+
328
+ try {
329
+ await fs.writeFile(metadataPath(mutexDir), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
330
+ } catch (error) {
331
+ await fs.rm(mutexDir, { recursive: true, force: true });
332
+ throw error;
333
+ }
334
+
335
+ return {
336
+ mutexDir,
337
+ metadata,
338
+ release: () => releaseUnityProjectLaunchMutex(mutexDir, metadata.ownerToken),
339
+ };
340
+ } catch (error) {
341
+ if (!isNodeErrorWithCode(error, "EEXIST")) {
342
+ throw error;
343
+ }
344
+
345
+ const metadata = await readMutexMetadata(mutexDir);
346
+ if (!metadata) {
347
+ throw new Error(
348
+ [
349
+ `Refusing to launch Unity for ${projectRoot} because a Pi Unity launch mutex exists but its metadata could not be read.`,
350
+ `Mutex: ${mutexDir}`,
351
+ "Inspect the mutex manually before removing it or retrying.",
352
+ ].join("\n"),
353
+ );
354
+ }
355
+
356
+ await clearStaleMutexOrThrow(projectRoot, mutexDir, metadata, options);
357
+ }
358
+ }
359
+
360
+ throw new Error(`Refusing to launch Unity for ${projectRoot} because the Pi Unity launch mutex could not be acquired.`);
361
+ }
362
+
363
+ export async function withUnityProjectLaunchMutex<T>(
364
+ projectRoot: string,
365
+ options: UnityProjectLaunchMutexOptions,
366
+ callback: () => Promise<T>,
367
+ ): Promise<T> {
368
+ const mutex = await acquireUnityProjectLaunchMutex(projectRoot, options);
369
+ try {
370
+ return await callback();
371
+ } finally {
372
+ await mutex.release();
373
+ }
374
+ }
375
+
376
+ export const __unityProjectLockInternals = {
377
+ canonicalizeUnityProjectRoot,
378
+ getUnityNativeLockfilePath,
379
+ getUnityProjectMutexDir,
380
+ defaultIsPidAlive,
381
+ };