@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.
- package/dist/attachments.js +47 -12
- package/dist/computer-cli.js +72 -12
- package/dist/computer-profile-lock.js +395 -0
- package/dist/computer-profile.js +189 -20
- package/dist/config.js +2 -1
- package/dist/console.js +175 -9
- package/dist/execution-journal-lock.js +199 -40
- package/dist/execution-journal.js +42 -4
- package/dist/execution-protocol.js +2 -0
- package/dist/execution-recovery.js +71 -0
- package/dist/execution-runner.js +49 -91
- package/dist/execution-supervisor.js +79 -31
- package/dist/external-output.js +28 -0
- package/dist/i18n.js +5 -5
- package/dist/local-executor.js +66 -15
- package/dist/machine-info.js +1 -0
- package/dist/main.js +23 -8
- package/dist/runner.js +11 -6
- package/dist/runtime-cancellation.js +74 -0
- package/dist/runtime-path.js +8 -4
- package/dist/runtimes/claude.js +8 -4
- package/dist/runtimes/codex.js +8 -4
- package/dist/serve-lifecycle.js +82 -0
- package/dist/serve.js +186 -218
- package/dist/shared-execution-slots.js +68 -0
- package/dist/shutdown-deadline.js +32 -0
- package/dist/slog.js +34 -20
- package/dist/supervised-runtime.js +104 -0
- package/dist/websocket-shutdown.js +53 -0
- package/package.json +2 -2
package/dist/attachments.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/computer-cli.js
CHANGED
|
@@ -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
|
-
|
|
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"]
|
|
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
|
-
}
|
|
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
|
|
156
|
+
const restartProfile = profileName
|
|
157
|
+
? await loadProfile(profileName, home, profileStorage)
|
|
158
|
+
: null;
|
|
159
|
+
const spec = restartProfile
|
|
140
160
|
? buildServiceSpec({
|
|
141
161
|
...deps,
|
|
142
|
-
profile:
|
|
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 (
|
|
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:
|
|
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
|
|
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({
|
|
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: ${
|
|
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
|
+
}
|