@nowcrew/daemon 0.5.19 → 0.5.20

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.
@@ -2,6 +2,10 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, extname, resolve, sep } from "node:path";
3
3
  export const ATTACHMENT_MAX_FILE_BYTES = 25 * 1024 * 1024;
4
4
  export const ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
5
+ const throwIfAborted = (signal) => {
6
+ if (signal?.aborted)
7
+ throw signal.reason ?? new Error("Attachment materialization aborted");
8
+ };
5
9
  function truncateUtf8(value, maxBytes) {
6
10
  let bytes = 0;
7
11
  let output = "";
@@ -74,7 +78,15 @@ async function downloadAttachment(input) {
74
78
  }
75
79
  let current = initial;
76
80
  const controller = new AbortController();
77
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
81
+ let timedOut = false;
82
+ const onAbort = () => controller.abort(input.signal?.reason ?? new Error("Attachment materialization aborted"));
83
+ input.signal?.addEventListener("abort", onAbort, { once: true });
84
+ if (input.signal?.aborted)
85
+ onAbort();
86
+ const timer = setTimeout(() => {
87
+ timedOut = true;
88
+ controller.abort(new Error(`Attachment download timed out after ${input.timeoutMs}ms`));
89
+ }, input.timeoutMs);
78
90
  try {
79
91
  let response = await input.fetchImpl(current, {
80
92
  redirect: "manual",
@@ -93,22 +105,37 @@ async function downloadAttachment(input) {
93
105
  if (!response.ok)
94
106
  throw new Error(`Attachment download failed with status ${response.status}`);
95
107
  const data = await readBounded(response, input.maxFileBytes);
108
+ throwIfAborted(input.signal);
96
109
  if (data.length !== input.attachment.sizeBytes) {
97
110
  throw new Error(`Attachment size mismatch: expected ${input.attachment.sizeBytes}, received ${data.length}`);
98
111
  }
99
112
  return data;
100
113
  }
101
114
  catch (error) {
102
- if (controller.signal.aborted) {
115
+ if (input.signal?.aborted) {
116
+ throw input.signal.reason ?? new Error("Attachment materialization aborted");
117
+ }
118
+ if (timedOut) {
103
119
  throw new Error(`Attachment download timed out after ${input.timeoutMs}ms`);
104
120
  }
105
121
  throw error;
106
122
  }
107
123
  finally {
108
124
  clearTimeout(timer);
125
+ input.signal?.removeEventListener("abort", onAbort);
126
+ }
127
+ }
128
+ export function executionAttachmentDirectory(runDir, executionId) {
129
+ const attachmentsRoot = resolve(runDir, "attachments");
130
+ const executionKey = executionId.replace(/[^A-Za-z0-9._-]/gu, "_");
131
+ const directory = resolve(attachmentsRoot, executionKey);
132
+ if (!directory.startsWith(`${attachmentsRoot}${sep}`)) {
133
+ throw new Error("Attachment directory escapes the run directory");
109
134
  }
135
+ return directory;
110
136
  }
111
137
  export async function materializeAttachments(input) {
138
+ throwIfAborted(input.signal);
112
139
  const maxFileBytes = input.maxFileBytes ?? ATTACHMENT_MAX_FILE_BYTES;
113
140
  const maxTotalBytes = input.maxTotalBytes ?? ATTACHMENT_MAX_TOTAL_BYTES;
114
141
  const total = input.attachments.reduce((sum, attachment) => sum + attachment.sizeBytes, 0);
@@ -116,17 +143,14 @@ export async function materializeAttachments(input) {
116
143
  || total > maxTotalBytes) {
117
144
  throw new Error("Attachment metadata exceeds the configured byte limit");
118
145
  }
119
- const attachmentsRoot = resolve(input.runDir, "attachments");
120
- const executionKey = input.executionId.replace(/[^A-Za-z0-9._-]/gu, "_");
121
- const directory = resolve(attachmentsRoot, executionKey);
122
- if (!directory.startsWith(`${attachmentsRoot}${sep}`)) {
123
- throw new Error("Attachment directory escapes the run directory");
124
- }
125
- await rm(directory, { recursive: true, force: true });
126
- await mkdir(directory, { recursive: true, mode: 0o700 });
146
+ const directory = executionAttachmentDirectory(input.runDir, input.executionId);
127
147
  const used = new Set();
128
148
  const materialized = [];
129
149
  try {
150
+ await rm(directory, { recursive: true, force: true });
151
+ throwIfAborted(input.signal);
152
+ await mkdir(directory, { recursive: true, mode: 0o700 });
153
+ throwIfAborted(input.signal);
130
154
  for (const attachment of input.attachments) {
131
155
  const base = safeFilename(attachment.filename);
132
156
  let collision = 1;
@@ -145,14 +169,25 @@ export async function materializeAttachments(input) {
145
169
  maxFileBytes,
146
170
  maxRedirects: input.maxRedirects ?? 3,
147
171
  timeoutMs: input.timeoutMs ?? 10_000,
172
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
173
+ });
174
+ throwIfAborted(input.signal);
175
+ await writeFile(path, data, {
176
+ flag: "wx",
177
+ mode: 0o600,
178
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
148
179
  });
149
- await writeFile(path, data, { flag: "wx", mode: 0o600 });
150
180
  materialized.push({ ...attachment, filename, path });
151
181
  }
152
182
  return { directory, attachments: materialized };
153
183
  }
154
184
  catch (error) {
155
- await rm(directory, { recursive: true, force: true });
185
+ try {
186
+ await rm(directory, { recursive: true, force: true });
187
+ }
188
+ catch (cleanupError) {
189
+ throw new AggregateError([error, cleanupError], "Attachment materialization failed and its execution directory could not be removed");
190
+ }
156
191
  throw error;
157
192
  }
158
193
  }
@@ -2,8 +2,10 @@ import { parseArgs } from "node:util";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { daemonHome, listProfiles, loadProfile, profileIsPrivate, profilePath, publicProfile, removeProfile, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
5
+ import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
6
6
  import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
7
+ import { defaultProcessController } from "./execution-journal.js";
8
+ import { inspectJournalLock } from "./execution-journal-lock.js";
7
9
  import { detectDaemonLang, formatDaemonText } from "./i18n.js";
8
10
  const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
9
11
  export function builtDaemonEntry(moduleUrl = import.meta.url) {
@@ -60,6 +62,17 @@ function requireValue(value, name, td) {
60
62
  throw new Error(td("Missing {{name}}", { name }));
61
63
  return value;
62
64
  }
65
+ function translatedError(error, td) {
66
+ if (error instanceof ProfileAgentsRootConflictError) {
67
+ return td(PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, {
68
+ profile: error.profile,
69
+ conflict: error.conflict,
70
+ agentsRoot: error.agentsRoot,
71
+ command: error.command,
72
+ });
73
+ }
74
+ return td(error.message);
75
+ }
63
76
  export async function runComputerCommand(argv, overrides = {}) {
64
77
  const command = argv[0];
65
78
  if (!command || !COMPUTER_COMMANDS.has(command))
@@ -88,6 +101,7 @@ export async function runComputerCommand(argv, overrides = {}) {
88
101
  const home = daemonHome(deps.env);
89
102
  const profileStorage = {
90
103
  platform: deps.platform,
104
+ userHome: deps.userHome,
91
105
  ...(deps.profileProtector ? { protector: deps.profileProtector } : {}),
92
106
  ...(deps.platform === "win32"
93
107
  ? { harden: (path) => hardenWindowsProfile(path, deps.runner) }
@@ -124,22 +138,28 @@ export async function runComputerCommand(argv, overrides = {}) {
124
138
  const machineToken = (fromStdin ? await deps.readStdin() : deps.env.CREW_MACHINE_TOKEN)?.trim();
125
139
  if (!machineToken)
126
140
  throw new Error(td("Missing machine token; use CREW_MACHINE_TOKEN or --token-stdin"));
127
- await saveProfile({
141
+ const profile = {
128
142
  name,
129
143
  serverUrl: requireValue(parsed.values["server-url"], "--server-url", td),
130
144
  machineToken,
131
- ...(parsed.values["agents-root"] ? { agentsRoot: resolve(parsed.values["agents-root"]) } : {}),
145
+ ...(parsed.values["agents-root"]
146
+ ? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
147
+ : {}),
132
148
  ...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
133
- }, home, profileStorage);
149
+ };
150
+ await saveProfile(profile, home, profileStorage);
134
151
  deps.stdout(`${td("Saved profile '{{name}}' with private credentials.", { name })}\n`);
135
152
  return 0;
136
153
  }
137
154
  const profileName = parsed.values.profile;
138
155
  if (command === "upgrade") {
139
- const spec = profileName
156
+ const restartProfile = profileName
157
+ ? await loadProfile(profileName, home, profileStorage)
158
+ : null;
159
+ const spec = restartProfile
140
160
  ? buildServiceSpec({
141
161
  ...deps,
142
- profile: (await loadProfile(profileName, home, profileStorage)).name,
162
+ profile: restartProfile.name,
143
163
  profileHome: home,
144
164
  })
145
165
  : null;
@@ -152,9 +172,18 @@ export async function runComputerCommand(argv, overrides = {}) {
152
172
  throw new Error(td("Service '{{id}}' is not installed", { id: spec.id }));
153
173
  }
154
174
  await upgradeDaemon(deps.platform, deps.runner);
155
- if (profileName) {
175
+ if (restartProfile) {
176
+ try {
177
+ await assertProfileAgentsRootUnique(restartProfile, home, deps.userHome, profileStorage);
178
+ }
179
+ catch (error) {
180
+ if (!(error instanceof ProfileAgentsRootConflictError))
181
+ throw error;
182
+ deps.stdout(`${td("Upgraded daemon but skipped restart for '{{name}}': {{reason}}", { name: restartProfile.name, reason: translatedError(error, td) })}\n`);
183
+ return 0;
184
+ }
156
185
  await serviceAction(spec, "restart", deps.runner);
157
- deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: profileName })}\n`);
186
+ deps.stdout(`${td("Upgraded daemon and restart request accepted for '{{name}}'. Verify with status.", { name: restartProfile.name })}\n`);
158
187
  }
159
188
  else {
160
189
  deps.stdout(`${td("Upgraded daemon. Installed services were not restarted; pass --profile to restart one.")}\n`);
@@ -162,7 +191,10 @@ export async function runComputerCommand(argv, overrides = {}) {
162
191
  return 0;
163
192
  }
164
193
  const name = requireValue(profileName, "--profile", td);
165
- await loadProfile(name, home, profileStorage);
194
+ const profile = await loadProfile(name, home, profileStorage);
195
+ if (command === "install" || command === "start" || command === "restart") {
196
+ await assertProfileAgentsRootUnique(profile, home, deps.userHome, profileStorage);
197
+ }
166
198
  if (!deps.entryPath.endsWith(".js")) {
167
199
  throw new Error(td("Service lifecycle requires the built daemon entry (.js), not a TypeScript development entry"));
168
200
  }
@@ -199,16 +231,44 @@ export async function runComputerCommand(argv, overrides = {}) {
199
231
  const privateFile = deps.platform === "win32"
200
232
  ? await windowsProfileIsPrivate(profilePath(name, home), deps.runner)
201
233
  : await profileIsPrivate(name, home, deps.platform);
202
- const checks = await doctorService(spec, privateFile, deps.runner);
234
+ const serviceChecks = await doctorService(spec, privateFile, deps.runner);
235
+ const rootInspection = await inspectProfileAgentsRoot(profile, home, deps.userHome, profileStorage);
236
+ const journalPath = resolve(rootInspection.agentsRoot, ".crew", "executions");
237
+ const journalLock = await inspectJournalLock({
238
+ directory: journalPath,
239
+ inspectIdentity: defaultProcessController.inspectIdentity,
240
+ });
241
+ const checks = [
242
+ ...serviceChecks,
243
+ {
244
+ name: "agents-root-unique",
245
+ ok: rootInspection.duplicateProfiles.length === 0,
246
+ detail: rootInspection.duplicateProfiles.length === 0
247
+ ? "unique"
248
+ : `also used by: ${rootInspection.duplicateProfiles.join(", ")}`,
249
+ },
250
+ {
251
+ name: "journal-lock-health",
252
+ ok: journalLock.status === "unlocked" || journalLock.status === "owned",
253
+ detail: journalLock.detail,
254
+ },
255
+ ];
203
256
  const registered = (await serviceStatus(spec, deps.runner)).installed;
204
- deps.stdout(`${JSON.stringify({ checks, registered }, null, 2)}\n`);
257
+ deps.stdout(`${JSON.stringify({
258
+ agentsRoot: rootInspection.agentsRoot,
259
+ journalPath,
260
+ duplicateProfiles: rootInspection.duplicateProfiles,
261
+ journalLock,
262
+ checks,
263
+ registered,
264
+ }, null, 2)}\n`);
205
265
  return checks.every((check) => check.ok) ? 0 : 4;
206
266
  }
207
267
  deps.stderr(usage(td));
208
268
  return 2;
209
269
  }
210
270
  catch (error) {
211
- deps.stderr(`crew-daemon: ${td(error.message)}\n`);
271
+ deps.stderr(`crew-daemon: ${translatedError(error, td)}\n`);
212
272
  return 1;
213
273
  }
214
274
  }
@@ -0,0 +1,395 @@
1
+ import { mkdir, open, opendir, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { defaultProcessController } from "./execution-journal.js";
6
+ const OwnerSchema = z.object({
7
+ pid: z.number().int().positive(),
8
+ processIdentity: z.string().min(1),
9
+ token: z.string().uuid(),
10
+ createdAt: z.number().int().nonnegative(),
11
+ }).strict();
12
+ const DEFAULT_TIMEOUT_MS = 30_000;
13
+ const DEFAULT_RETRY_MS = 25;
14
+ const DEFAULT_TEMP_STALE_AGE_MS = 24 * 60 * 60 * 1_000;
15
+ const DEFAULT_TEMP_SCAN_LIMIT = 64;
16
+ const DEFAULT_TEMP_DELETE_LIMIT = 16;
17
+ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
18
+ const OWNER_NAME_PATTERN = new RegExp(`^owner\\.${UUID_PATTERN}\\.json$`, "i");
19
+ const TEMP_NAME_PATTERN = new RegExp(`^\\.save\\.lock\\.(${UUID_PATTERN})\\.tmp$`, "i");
20
+ const codeOf = (error) => error instanceof Error && "code" in error ? error.code : undefined;
21
+ const ownerName = (token) => `owner.${token}.json`;
22
+ export const defaultProfileSaveLockFileSystem = {
23
+ mkdir,
24
+ readFile: (path) => readFile(path, "utf8"),
25
+ readDirectoryEntries: async (path, limit) => {
26
+ const directory = await opendir(path);
27
+ const names = [];
28
+ try {
29
+ while (names.length < limit) {
30
+ const entry = await directory.read();
31
+ if (entry === null)
32
+ break;
33
+ names.push(entry.name);
34
+ }
35
+ }
36
+ finally {
37
+ await directory.close();
38
+ }
39
+ return names;
40
+ },
41
+ readdir: async (path) => readdir(path),
42
+ rename,
43
+ rm,
44
+ rmdir,
45
+ stat,
46
+ unlink,
47
+ writeDurableFile: async (path, contents) => {
48
+ const handle = await open(path, "wx", 0o600);
49
+ try {
50
+ await handle.writeFile(contents, "utf8");
51
+ await handle.sync();
52
+ }
53
+ finally {
54
+ await handle.close();
55
+ }
56
+ },
57
+ };
58
+ async function readGeneration(lockDirectory, fileSystem) {
59
+ let names;
60
+ try {
61
+ names = (await fileSystem.readdir(lockDirectory)).sort();
62
+ }
63
+ catch (error) {
64
+ if (codeOf(error) === "ENOENT")
65
+ return { status: "absent" };
66
+ throw error;
67
+ }
68
+ if (names.length === 0)
69
+ return { status: "absent" };
70
+ if (names.length !== 1 || names[0] !== "generation") {
71
+ throw new Error(`Invalid profile save lock: ${lockDirectory}`);
72
+ }
73
+ const generationDirectory = join(lockDirectory, "generation");
74
+ try {
75
+ names = (await fileSystem.readdir(generationDirectory)).sort();
76
+ }
77
+ catch (error) {
78
+ if (codeOf(error) === "ENOENT")
79
+ return { status: "absent" };
80
+ throw error;
81
+ }
82
+ if (names.length === 0)
83
+ return { status: "empty" };
84
+ if (names.length !== 1 || !OWNER_NAME_PATTERN.test(names[0])) {
85
+ throw new Error(`Invalid profile save lock generation: ${generationDirectory}`);
86
+ }
87
+ const fileName = names[0];
88
+ const ownerPath = join(generationDirectory, fileName);
89
+ try {
90
+ const owner = OwnerSchema.parse(JSON.parse(await fileSystem.readFile(ownerPath)));
91
+ if (fileName !== ownerName(owner.token))
92
+ throw new Error("owner token does not match filename");
93
+ return { status: "owned", owner, fileName };
94
+ }
95
+ catch (error) {
96
+ if (codeOf(error) === "ENOENT")
97
+ return { status: "absent" };
98
+ throw new Error(`Invalid profile save lock owner: ${ownerPath}`, { cause: error });
99
+ }
100
+ }
101
+ function sameOwner(left, right) {
102
+ return left.pid === right.pid
103
+ && left.processIdentity === right.processIdentity
104
+ && left.token === right.token
105
+ && left.createdAt === right.createdAt;
106
+ }
107
+ async function requireOwnGeneration(lockDirectory, expected, fileSystem) {
108
+ const observed = await readGeneration(lockDirectory, fileSystem);
109
+ if (observed.status !== "owned" || !sameOwner(observed.owner, expected)) {
110
+ throw new Error(`Profile save lock generation changed during install: ${lockDirectory}`);
111
+ }
112
+ return { fileName: observed.fileName };
113
+ }
114
+ async function removeOwnGeneration(lockDirectory, expected, fileSystem) {
115
+ const observed = await readGeneration(lockDirectory, fileSystem);
116
+ if (observed.status !== "owned" || !sameOwner(observed.owner, expected))
117
+ return false;
118
+ const generationDirectory = join(lockDirectory, "generation");
119
+ try {
120
+ await fileSystem.unlink(join(generationDirectory, observed.fileName));
121
+ }
122
+ catch (error) {
123
+ if (codeOf(error) === "ENOENT")
124
+ return false;
125
+ throw error;
126
+ }
127
+ try {
128
+ await fileSystem.rmdir(generationDirectory);
129
+ }
130
+ catch (error) {
131
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
132
+ throw error;
133
+ }
134
+ try {
135
+ await fileSystem.rmdir(lockDirectory);
136
+ }
137
+ catch (error) {
138
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
139
+ throw error;
140
+ }
141
+ return true;
142
+ }
143
+ function positiveInteger(value, name) {
144
+ if (!Number.isSafeInteger(value) || value <= 0)
145
+ throw new RangeError(`${name} must be a positive integer`);
146
+ return value;
147
+ }
148
+ async function inspectTemporaryGeneration(temporaryDirectory, token, fileSystem) {
149
+ let entries;
150
+ try {
151
+ entries = (await fileSystem.readdir(temporaryDirectory)).sort();
152
+ }
153
+ catch (error) {
154
+ return codeOf(error) === "ENOENT" ? null : { status: "unavailable" };
155
+ }
156
+ if (entries.length !== 1 || entries[0] !== ownerName(token)) {
157
+ return { status: "missing-or-corrupt" };
158
+ }
159
+ let contents;
160
+ try {
161
+ contents = await fileSystem.readFile(join(temporaryDirectory, entries[0]));
162
+ }
163
+ catch (error) {
164
+ return codeOf(error) === "ENOENT" ? null : { status: "unavailable" };
165
+ }
166
+ try {
167
+ const owner = OwnerSchema.parse(JSON.parse(contents));
168
+ return owner.token.toLowerCase() === token
169
+ ? { status: "valid", owner }
170
+ : { status: "missing-or-corrupt" };
171
+ }
172
+ catch {
173
+ return { status: "missing-or-corrupt" };
174
+ }
175
+ }
176
+ async function cleanupStaleTemporaryGenerations(options) {
177
+ const names = await options.fileSystem.readDirectoryEntries(options.profilesDirectory, options.scanLimit);
178
+ let deleted = 0;
179
+ for (const name of names) {
180
+ if (deleted >= options.deleteLimit)
181
+ break;
182
+ const match = TEMP_NAME_PATTERN.exec(name);
183
+ const token = match?.[1]?.toLowerCase();
184
+ if (token === undefined)
185
+ continue;
186
+ const temporaryDirectory = join(options.profilesDirectory, name);
187
+ let directoryMtimeMs;
188
+ try {
189
+ directoryMtimeMs = (await options.fileSystem.stat(temporaryDirectory)).mtimeMs;
190
+ }
191
+ catch (error) {
192
+ if (codeOf(error) === "ENOENT")
193
+ continue;
194
+ continue;
195
+ }
196
+ const inspection = await inspectTemporaryGeneration(temporaryDirectory, token, options.fileSystem);
197
+ if (inspection === null || inspection.status === "unavailable")
198
+ continue;
199
+ let removable = false;
200
+ if (inspection.status === "valid") {
201
+ try {
202
+ removable = await options.processController.inspectIdentity(inspection.owner.pid)
203
+ !== inspection.owner.processIdentity;
204
+ }
205
+ catch {
206
+ removable = false;
207
+ }
208
+ }
209
+ else {
210
+ removable = options.now - directoryMtimeMs >= options.staleAgeMs;
211
+ }
212
+ if (!removable)
213
+ continue;
214
+ const confirmed = await inspectTemporaryGeneration(temporaryDirectory, token, options.fileSystem);
215
+ if (confirmed === null || confirmed.status !== inspection.status)
216
+ continue;
217
+ if (inspection.status === "valid") {
218
+ if (confirmed.status !== "valid" || !sameOwner(confirmed.owner, inspection.owner))
219
+ continue;
220
+ }
221
+ else {
222
+ try {
223
+ if ((await options.fileSystem.stat(temporaryDirectory)).mtimeMs !== directoryMtimeMs)
224
+ continue;
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ }
230
+ try {
231
+ await options.fileSystem.rm(temporaryDirectory, { recursive: true, force: true });
232
+ deleted += 1;
233
+ }
234
+ catch {
235
+ // Stale-temp cleanup is bounded best effort and must not block a profile save.
236
+ }
237
+ }
238
+ }
239
+ export async function acquireProfileSaveLock(profilesDirectory, options = {}) {
240
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
241
+ const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
242
+ const now = options.now ?? Date.now;
243
+ const wait = options.wait ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
244
+ const processController = options.processController ?? defaultProcessController;
245
+ const fileSystem = options.fileSystem ?? defaultProfileSaveLockFileSystem;
246
+ const tempStaleAgeMs = options.tempStaleAgeMs ?? DEFAULT_TEMP_STALE_AGE_MS;
247
+ const tempScanLimit = positiveInteger(options.tempScanLimit ?? DEFAULT_TEMP_SCAN_LIMIT, "tempScanLimit");
248
+ const tempDeleteLimit = positiveInteger(options.tempDeleteLimit ?? DEFAULT_TEMP_DELETE_LIMIT, "tempDeleteLimit");
249
+ if (!Number.isFinite(tempStaleAgeMs) || tempStaleAgeMs < 0) {
250
+ throw new RangeError("tempStaleAgeMs must be a nonnegative finite number");
251
+ }
252
+ const deadline = now() + timeoutMs;
253
+ const lockDirectory = join(profilesDirectory, ".save.lock");
254
+ const generationDirectory = join(lockDirectory, "generation");
255
+ const token = randomUUID();
256
+ const fileName = ownerName(token);
257
+ const temporaryDirectory = join(profilesDirectory, `.save.lock.${token}.tmp`);
258
+ await fileSystem.mkdir(profilesDirectory, { recursive: true, mode: 0o700 });
259
+ await cleanupStaleTemporaryGenerations({
260
+ profilesDirectory,
261
+ now: now(),
262
+ staleAgeMs: tempStaleAgeMs,
263
+ scanLimit: tempScanLimit,
264
+ deleteLimit: tempDeleteLimit,
265
+ processController,
266
+ fileSystem,
267
+ });
268
+ const processIdentity = await processController.inspectIdentity(process.pid);
269
+ if (processIdentity === null || processIdentity.trim().length === 0) {
270
+ throw new Error("Failed to capture profile save lock owner process identity");
271
+ }
272
+ const owner = OwnerSchema.parse({ pid: process.pid, processIdentity, token, createdAt: now() });
273
+ let temporaryDirectoryCreated = false;
274
+ let installed = false;
275
+ let acquired = false;
276
+ const pause = async () => {
277
+ const remaining = deadline - now();
278
+ if (remaining <= 0)
279
+ throw new Error(`Timed out waiting for profile save lock: ${lockDirectory}`);
280
+ await wait(Math.min(retryMs, remaining));
281
+ };
282
+ try {
283
+ await fileSystem.mkdir(temporaryDirectory, { mode: 0o700 });
284
+ temporaryDirectoryCreated = true;
285
+ await fileSystem.writeDurableFile(join(temporaryDirectory, fileName), `${JSON.stringify(owner)}\n`);
286
+ await options.hooks?.beforeInstall?.(owner);
287
+ for (;;) {
288
+ try {
289
+ await fileSystem.mkdir(lockDirectory, { mode: 0o700 });
290
+ }
291
+ catch (error) {
292
+ if (codeOf(error) !== "EEXIST")
293
+ throw error;
294
+ }
295
+ let renameFailure;
296
+ try {
297
+ await fileSystem.rename(temporaryDirectory, generationDirectory);
298
+ installed = true;
299
+ await requireOwnGeneration(lockDirectory, owner, fileSystem);
300
+ acquired = true;
301
+ let released = false;
302
+ return async () => {
303
+ if (released)
304
+ return;
305
+ if (!await removeOwnGeneration(lockDirectory, owner, fileSystem)) {
306
+ throw new Error(`Profile save lock generation changed before release: ${lockDirectory}`);
307
+ }
308
+ released = true;
309
+ };
310
+ }
311
+ catch (error) {
312
+ if (installed)
313
+ throw error;
314
+ if (codeOf(error) === "ENOENT") {
315
+ await pause();
316
+ continue;
317
+ }
318
+ if (codeOf(error) !== "EEXIST"
319
+ && codeOf(error) !== "ENOTEMPTY"
320
+ && codeOf(error) !== "EPERM"
321
+ && codeOf(error) !== "EACCES")
322
+ throw error;
323
+ renameFailure = error;
324
+ }
325
+ const permissionFailure = codeOf(renameFailure) === "EPERM"
326
+ || codeOf(renameFailure) === "EACCES"
327
+ ? renameFailure
328
+ : null;
329
+ const observed = await readGeneration(lockDirectory, fileSystem);
330
+ if (observed.status === "absent") {
331
+ if (permissionFailure !== null)
332
+ throw permissionFailure;
333
+ await pause();
334
+ continue;
335
+ }
336
+ if (observed.status === "empty") {
337
+ try {
338
+ await fileSystem.rmdir(generationDirectory);
339
+ }
340
+ catch (error) {
341
+ if (permissionFailure !== null)
342
+ throw permissionFailure;
343
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
344
+ throw error;
345
+ await pause();
346
+ continue;
347
+ }
348
+ try {
349
+ await fileSystem.rmdir(lockDirectory);
350
+ }
351
+ catch (error) {
352
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
353
+ throw error;
354
+ }
355
+ await pause();
356
+ continue;
357
+ }
358
+ const identity = await processController.inspectIdentity(observed.owner.pid);
359
+ if (identity === observed.owner.processIdentity) {
360
+ await pause();
361
+ continue;
362
+ }
363
+ try {
364
+ await fileSystem.unlink(join(generationDirectory, observed.fileName));
365
+ }
366
+ catch (error) {
367
+ if (codeOf(error) === "ENOENT")
368
+ continue;
369
+ throw error;
370
+ }
371
+ try {
372
+ await fileSystem.rmdir(generationDirectory);
373
+ }
374
+ catch (error) {
375
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
376
+ throw error;
377
+ }
378
+ try {
379
+ await fileSystem.rmdir(lockDirectory);
380
+ }
381
+ catch (error) {
382
+ if (codeOf(error) !== "ENOENT" && codeOf(error) !== "ENOTEMPTY")
383
+ throw error;
384
+ }
385
+ }
386
+ }
387
+ finally {
388
+ if (!installed && temporaryDirectoryCreated) {
389
+ await fileSystem.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
390
+ }
391
+ else if (!acquired) {
392
+ await removeOwnGeneration(lockDirectory, owner, fileSystem).catch(() => undefined);
393
+ }
394
+ }
395
+ }