agents-can-communicate 0.8.4 → 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.
- package/README.md +11 -7
- package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
- package/bin/entrypoints/acc-hook.mjs +33 -19
- package/bin/entrypoints/hook-output.mjs +14 -5
- package/docs/ADAPTER_AUTHORING.md +32 -0
- package/docs/ARCHITECTURE.md +7 -4
- package/docs/CAPABILITIES.md +7 -6
- package/docs/CLI.md +5 -0
- package/docs/CONFIGURATION.md +15 -10
- package/docs/DESIGN_DECISIONS.md +0 -2
- package/docs/GETTING_STARTED.md +39 -6
- package/docs/HOW_IT_WORKS.md +13 -8
- package/docs/SECURITY_MODEL.md +26 -5
- package/docs/TROUBLESHOOTING.md +74 -12
- package/docs/UPGRADING.md +50 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
- package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +87 -13
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -9
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +66 -26
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +11 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +82 -17
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
- package/node_modules/@agents-can-communicate/cli/package.json +2 -1
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
- package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
- package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +15 -4
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
- package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +7 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
- package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +167 -34
- package/node_modules/@agents-can-communicate/installer/package.json +3 -2
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
- package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
- package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
- package/package.json +1 -5
|
@@ -7,11 +7,12 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
7
7
|
// path is validated segment by segment against the canonical root, so a
|
|
8
8
|
// symlinked ancestor cannot redirect a read or a publication - neither outside
|
|
9
9
|
// the store, nor to another directory inside it.
|
|
10
|
-
function invalidDirectory(message, directory, root, cause) {
|
|
10
|
+
function invalidDirectory(message, directory, root, cause, resolved) {
|
|
11
11
|
return new AccError(EXIT.DATA, message, {
|
|
12
12
|
directory,
|
|
13
13
|
root,
|
|
14
14
|
...(cause === undefined ? {} : { cause }),
|
|
15
|
+
...(resolved === undefined ? {} : { resolved }),
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -40,7 +41,7 @@ async function inspectRealDirectory(directory, root, create) {
|
|
|
40
41
|
for (let attempt = 0; ; attempt += 1) {
|
|
41
42
|
let details;
|
|
42
43
|
try {
|
|
43
|
-
details = await lstat(directory);
|
|
44
|
+
details = await lstat(directory, { bigint: true });
|
|
44
45
|
} catch (error) {
|
|
45
46
|
if (error.code !== "ENOENT") {
|
|
46
47
|
throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
|
|
@@ -64,10 +65,46 @@ async function inspectRealDirectory(directory, root, create) {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
// A directory validated once is known by its identity: volume and file id, as
|
|
69
|
+
// bigint because an NTFS file id does not fit a double. The same name is
|
|
70
|
+
// checked again with one lstat, which follows every ancestor, so an ancestor
|
|
71
|
+
// replaced by a link lands on another directory and anything but the identity
|
|
72
|
+
// that was validated takes the whole walk again. What is not repeated is
|
|
73
|
+
// finding the same directory under the same name: every record read walked
|
|
74
|
+
// its path from the root twice, about a thousand calls a hook and 130 ms of one
|
|
75
|
+
// on windows-latest. The one case the walk would still refuse is the store's
|
|
76
|
+
// own directory moved elsewhere with a link left at its name - the same data in
|
|
77
|
+
// another place (decided 2026-09-30). The seams bypass it: the race tests
|
|
78
|
+
// exercise the walk itself.
|
|
79
|
+
const VALIDATED = new Map();
|
|
80
|
+
const VALIDATED_LIMIT = 512;
|
|
81
|
+
|
|
82
|
+
async function validatedEarlier(key, directory) {
|
|
83
|
+
const known = VALIDATED.get(key);
|
|
84
|
+
if (known === undefined) return null;
|
|
85
|
+
const current = await lstat(directory, { bigint: true }).catch(() => null);
|
|
86
|
+
if (current !== null && current.isDirectory() && !current.isSymbolicLink()
|
|
87
|
+
&& current.dev === known.dev && current.ino === known.ino) return current;
|
|
88
|
+
VALIDATED.delete(key);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function remember(key, details) {
|
|
93
|
+
if (VALIDATED.size >= VALIDATED_LIMIT) VALIDATED.clear();
|
|
94
|
+
VALIDATED.set(key, { dev: details.dev, ino: details.ino });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function inspectManagedDirectory(rootPath, directoryPath, create, options = {}) {
|
|
98
|
+
const { afterInspect, realpath: resolve = realpath, platform = process.platform } = options;
|
|
68
99
|
const root = absolutePath(rootPath, "managed root");
|
|
69
100
|
const directory = absolutePath(directoryPath, "managed directory", root);
|
|
70
101
|
const relative = relativeWithin(root, directory);
|
|
102
|
+
const key = options.afterInspect === undefined && options.realpath === undefined
|
|
103
|
+
&& options.platform === undefined ? `${root}\u0000${directory}` : null;
|
|
104
|
+
if (key !== null) {
|
|
105
|
+
const known = await validatedEarlier(key, directory);
|
|
106
|
+
if (known !== null) return { directory, stat: known };
|
|
107
|
+
}
|
|
71
108
|
let details = await inspectRealDirectory(root, root, create);
|
|
72
109
|
const canonicalRoot = await realpath(root);
|
|
73
110
|
let current = root;
|
|
@@ -78,15 +115,33 @@ async function inspectManagedDirectory(rootPath, directoryPath, create, { afterI
|
|
|
78
115
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
79
116
|
current = path.join(current, segment);
|
|
80
117
|
let resolved;
|
|
81
|
-
for (let
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
for (let look = 1; ; look += 1) {
|
|
119
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
120
|
+
details = await inspectRealDirectory(current, root, create);
|
|
121
|
+
await afterInspect?.(current);
|
|
122
|
+
// A create-mode caller uses this name next. A directory renamed away
|
|
123
|
+
// between the check and the resolution leaves realpath nothing to
|
|
124
|
+
// resolve (ENOENT), so it is checked and created again, then resolved
|
|
125
|
+
// again; every check below applies to whichever attempt settles.
|
|
126
|
+
resolved = await resolveSegment(current, create && attempt < VANISHED_RETRIES,
|
|
127
|
+
{ resolve, platform, root });
|
|
128
|
+
if (resolved !== null) break;
|
|
129
|
+
}
|
|
130
|
+
if (path.dirname(resolved) === canonicalParent) break;
|
|
131
|
+
// An answer outside the parent says where the directory lives only when
|
|
132
|
+
// that place holds this directory. On windows-latest, a lock renamed
|
|
133
|
+
// aside and back without a pause was once answered with its name under
|
|
134
|
+
// another store's root, which an earlier test had already removed (PR
|
|
135
|
+
// #243). A place holding nothing, or another directory, is asked again;
|
|
136
|
+
// a name that keeps answering that way has changed while it resolved.
|
|
137
|
+
if (await sameDirectoryAt(details, resolved, current, root)) {
|
|
138
|
+
throw invalidDirectory("managed directory escapes the canonical store root", current, root,
|
|
139
|
+
undefined, resolved);
|
|
140
|
+
}
|
|
141
|
+
if (look >= ELSEWHERE_RETRIES) {
|
|
142
|
+
throw invalidDirectory("managed parent directory changed while it resolved", current, root,
|
|
143
|
+
undefined, resolved);
|
|
144
|
+
}
|
|
90
145
|
}
|
|
91
146
|
// realpath answers with the name the directory carries *now*, which is not
|
|
92
147
|
// always the name that was asked for: on Darwin it resolves by opening the
|
|
@@ -106,28 +161,86 @@ async function inspectManagedDirectory(rootPath, directoryPath, create, { afterI
|
|
|
106
161
|
// swapped in under this name is not. ENOENT on the re-stat means it moved
|
|
107
162
|
// again between the two calls, which cannot redirect anything: whatever
|
|
108
163
|
// the caller opens through this path next fails the same way.
|
|
109
|
-
if (path.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
164
|
+
if (path.basename(resolved) !== segment
|
|
165
|
+
&& !await stillTheSameDirectory(details, resolved, current, root)) {
|
|
166
|
+
throw invalidDirectory("managed directory escapes the canonical store root", current, root,
|
|
167
|
+
undefined, resolved);
|
|
113
168
|
}
|
|
114
169
|
canonicalParent = resolved;
|
|
115
170
|
}
|
|
171
|
+
if (key !== null) remember(key, details);
|
|
116
172
|
return { directory, stat: details };
|
|
117
173
|
}
|
|
118
174
|
|
|
175
|
+
// Where NTFS keeps a directory deleted while something still has it open.
|
|
176
|
+
const DELETED = /^(?:\\\\\?\\)?[A-Za-z]:\\\$Extend\\\$Deleted\\/i;
|
|
177
|
+
|
|
178
|
+
// How many times a name answered with a directory leaving it is resolved again.
|
|
179
|
+
const LEAVING_RETRIES = 3;
|
|
180
|
+
|
|
181
|
+
// How many times a name answered with a place that does not hold it is looked
|
|
182
|
+
// at again.
|
|
183
|
+
const ELSEWHERE_RETRIES = 3;
|
|
184
|
+
|
|
119
185
|
// null when the name went away and the caller may take it again.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
186
|
+
//
|
|
187
|
+
// Linux realpath fails ENOENT on a name removed or renamed away while it
|
|
188
|
+
// resolves. Windows, measured on windows-latest, answers that window with
|
|
189
|
+
// EBADF, with EPERM, or with the directory's place in $Extend\$Deleted (above):
|
|
190
|
+
// the handle it took was to a directory leaving the name. The name itself may
|
|
191
|
+
// already hold a new one - `stage` does, right after a sweep - so it is resolved
|
|
192
|
+
// again. A directory there is the answer, no directory there is gone, and a
|
|
193
|
+
// name that keeps answering that way while present fails as it always did:
|
|
194
|
+
// read as gone, a directory would read as holding no records.
|
|
195
|
+
async function resolveSegment(current, retry, { resolve, platform, root }) {
|
|
196
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
197
|
+
let resolved = null;
|
|
198
|
+
let failure = null;
|
|
199
|
+
try {
|
|
200
|
+
resolved = await resolve(current);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
failure = error;
|
|
203
|
+
}
|
|
204
|
+
const leaving = platform === "win32" && (failure === null ? DELETED.test(resolved)
|
|
205
|
+
: ["EBADF", "EPERM"].includes(failure.code));
|
|
206
|
+
if (failure === null && !leaving) return resolved;
|
|
207
|
+
if (failure?.code === "ENOENT") {
|
|
208
|
+
if (retry) return null;
|
|
209
|
+
throw failure;
|
|
210
|
+
}
|
|
211
|
+
if (!leaving) throw failure;
|
|
212
|
+
if (attempt < LEAVING_RETRIES) continue;
|
|
213
|
+
const present = await lstat(current).then(() => true, missing => missing.code !== "ENOENT");
|
|
214
|
+
// Present: refused, never read as gone - its records would read as none.
|
|
215
|
+
// Deleted directories' places, or EBADF - a handle taken to a directory
|
|
216
|
+
// leaving the name - while the name stays, is a directory moving in and out
|
|
217
|
+
// of the name; the writer lock does so for a living. It is refused as
|
|
218
|
+
// changed, which a reader takes as a reason to look again, and never as an
|
|
219
|
+
// escape: nothing left the store. EPERM can be a refusal in earnest and
|
|
220
|
+
// stays what it says.
|
|
221
|
+
if (present) {
|
|
222
|
+
if (failure !== null && failure.code !== "EBADF") throw failure;
|
|
223
|
+
throw invalidDirectory("managed parent directory changed while it resolved", current, root,
|
|
224
|
+
undefined, resolved);
|
|
225
|
+
}
|
|
226
|
+
if (retry) return null;
|
|
227
|
+
throw Object.assign(new Error(`ENOENT: ${current} left its name while it resolved`,
|
|
228
|
+
failure === null ? {} : { cause: failure }), { code: "ENOENT", syscall: "realpath", path: current });
|
|
126
229
|
}
|
|
127
230
|
}
|
|
128
231
|
|
|
232
|
+
// Whether `resolved` holds the directory `details` describes. A place that
|
|
233
|
+
// holds nothing is not where it lives.
|
|
234
|
+
async function sameDirectoryAt(details, resolved, current, root) {
|
|
235
|
+
const served = await stat(resolved, { bigint: true }).then(found => found, error => {
|
|
236
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code)) return null;
|
|
237
|
+
throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
|
|
238
|
+
});
|
|
239
|
+
return served !== null && served.dev === details.dev && served.ino === details.ino;
|
|
240
|
+
}
|
|
241
|
+
|
|
129
242
|
async function stillTheSameDirectory(details, resolved, current, root) {
|
|
130
|
-
const served = await stat(resolved).then(found => found, error => {
|
|
243
|
+
const served = await stat(resolved, { bigint: true }).then(found => found, error => {
|
|
131
244
|
if (error.code === "ENOENT") return null;
|
|
132
245
|
throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
|
|
133
246
|
});
|
|
@@ -135,7 +248,8 @@ async function stillTheSameDirectory(details, resolved, current, root) {
|
|
|
135
248
|
}
|
|
136
249
|
|
|
137
250
|
// `afterInspect` is the seam the race tests use: it runs between a segment's
|
|
138
|
-
// check and its resolution, the window a concurrent rename lands in.
|
|
251
|
+
// check and its resolution, the window a concurrent rename lands in. `realpath`
|
|
252
|
+
// and `platform` let them put a platform's answer in that window.
|
|
139
253
|
export async function assertManagedDirectory(rootPath, directoryPath, options) {
|
|
140
254
|
return inspectManagedDirectory(rootPath, directoryPath, false, options);
|
|
141
255
|
}
|
|
@@ -4,13 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
6
|
|
|
7
|
+
import { openNoFollow } from "./portable-fs.mjs";
|
|
7
8
|
import { assertManagedDirectory } from "./safe-directory.mjs";
|
|
8
9
|
|
|
9
10
|
function sameDirectory(left, right) {
|
|
10
11
|
return left.dev === right.dev && left.ino === right.ino;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
// The operation runs through the opened handle only after
|
|
14
|
+
// The operation runs through the opened handle only after the no-follow open and the
|
|
14
15
|
// before/after parent identity check agree. Replacing the pathname after that
|
|
15
16
|
// point cannot redirect a read or append through the already-open descriptor.
|
|
16
17
|
export async function withRegularNoFollow(filePath, root, flags, operation, openFile = open) {
|
|
@@ -18,7 +19,7 @@ export async function withRegularNoFollow(filePath, root, flags, operation, open
|
|
|
18
19
|
const before = await assertManagedDirectory(root, parent);
|
|
19
20
|
let handle;
|
|
20
21
|
try {
|
|
21
|
-
handle = await
|
|
22
|
+
handle = await openNoFollow(filePath, flags, { open: openFile });
|
|
22
23
|
} catch (error) {
|
|
23
24
|
if (error.code === "ENOENT") throw error;
|
|
24
25
|
throw new AccError(EXIT.DATA, "cannot safely open regular file",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { rename } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
|
|
4
3
|
import { EXIT } from "@agents-can-communicate/protocol";
|
|
5
4
|
|
|
6
5
|
import { encode, listDirectoryEntries, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
7
|
-
import { discard, discardLeftovers, doomedName, expired
|
|
6
|
+
import { discard, discardLeftovers, doomedName, expired }
|
|
8
7
|
from "./doomed-directory.mjs";
|
|
8
|
+
import { renameEntry, syncEntry } from "./portable-fs.mjs";
|
|
9
9
|
import { reclaimRetired } from "./reclaim.mjs";
|
|
10
10
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
11
11
|
|
|
@@ -30,7 +30,7 @@ async function reclaimLegacy(paths, root, detached, budget, deadlineAt) {
|
|
|
30
30
|
for (const entry of await listDirectoryEntries(paths.tmp, { root })) {
|
|
31
31
|
if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
|
|
32
32
|
if (!entry.isFile() || !entry.name.endsWith(".published")) continue;
|
|
33
|
-
await
|
|
33
|
+
await renameEntry(path.join(paths.tmp, entry.name), path.join(detached, entry.name));
|
|
34
34
|
spent += 1;
|
|
35
35
|
}
|
|
36
36
|
return { spent, drained: true };
|
|
@@ -69,9 +69,9 @@ export async function sweepAcceptedStages(paths,
|
|
|
69
69
|
// the ordinary case rather than an error, and such a store is exactly the one
|
|
70
70
|
// whose accepted stages are all still in tmp for the reclamation below.
|
|
71
71
|
await ensureManagedDirectory(root, paths.stage);
|
|
72
|
-
await
|
|
72
|
+
await renameEntry(paths.stage, detached);
|
|
73
73
|
await ensureManagedDirectory(root, paths.stage);
|
|
74
|
-
await
|
|
74
|
+
await syncEntry(root, detached);
|
|
75
75
|
|
|
76
76
|
const reclaimed = await reclaimLegacy(paths, root, detached, limit - spent, deadlineAt);
|
|
77
77
|
spent += reclaimed.spent;
|
|
@@ -167,8 +167,11 @@ export async function sweepIfDue(paths,
|
|
|
167
167
|
if (result.remaining || expired(deadlineAt)) {
|
|
168
168
|
return { swept: result.swept, remaining: true };
|
|
169
169
|
}
|
|
170
|
+
// Without a flush: sweptAt reads a damaged or missing marker as a pass
|
|
171
|
+
// that is due, so a crash costs one more sweep.
|
|
170
172
|
await publishAtomic(markerPath(paths), encode({ sweptAt: clock.now() }),
|
|
171
|
-
{ root, tmpDir: paths.tmp, stageDir: paths.stage, replace: true,
|
|
173
|
+
{ root, tmpDir: paths.tmp, stageDir: paths.stage, replace: true, durability: "none",
|
|
174
|
+
deadlineAt });
|
|
172
175
|
return result;
|
|
173
176
|
});
|
|
174
177
|
}
|
|
@@ -6,7 +6,8 @@ import { AccError, EXIT, assertPortableId, validateRecord }
|
|
|
6
6
|
|
|
7
7
|
import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
|
|
8
8
|
retainFile } from "./atomic-json.mjs";
|
|
9
|
-
import { initialiseActiveJournal } from "./active-journal.mjs";
|
|
9
|
+
import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
|
|
10
|
+
import { publicationPath } from "./publication-path.mjs";
|
|
10
11
|
import { assertPublicationDeadline } from "./deadline.mjs";
|
|
11
12
|
import { requireStoreIdentity } from "./identity.mjs";
|
|
12
13
|
import { journalEntry, readJournalCeiling, readOpenJournals, rollForward, writeJournalEntry }
|
|
@@ -26,6 +27,11 @@ import { withWriterMutex } from "./writer-mutex.mjs";
|
|
|
26
27
|
// make it easy to reintroduce separate locks and resurrect replaced sessions.
|
|
27
28
|
|
|
28
29
|
const SEQUENCE_WIDTH = 16;
|
|
30
|
+
// An ephemeral record describes a live session. A crash of the machine ends
|
|
31
|
+
// every session it describes, and a session whose record comes back as its
|
|
32
|
+
// previous version, or not at all, is recovered by its next hook. Its bytes are
|
|
33
|
+
// still flushed, so no reader finds a torn record.
|
|
34
|
+
const EPHEMERAL = "bytes";
|
|
29
35
|
export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
|
|
30
36
|
// No quarantine area. One was created in every workspace, named in the path
|
|
31
37
|
// typedef, and written to by nothing: repair deliberately refuses to move a
|
|
@@ -100,7 +106,7 @@ async function nextSequence(paths, root) {
|
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
export async function openFilesystemStore({ root, clock, ids, workspaceId, failAt,
|
|
103
|
-
deadlineAt: storeDeadline }) {
|
|
109
|
+
deadlineAt: storeDeadline, platform = process.platform }) {
|
|
104
110
|
assertPublicationDeadline(storeDeadline);
|
|
105
111
|
const paths = storePaths(root);
|
|
106
112
|
// The caller owns the root path, so its ancestors are created here. Inside the
|
|
@@ -109,9 +115,9 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
109
115
|
await mkdir(root, { recursive: true });
|
|
110
116
|
// Identity is settled before any read or write. Adopting a directory that
|
|
111
117
|
// already belongs to another workspace is the failure this fails closed on.
|
|
112
|
-
await requireStoreIdentity(paths, { workspaceId, clock });
|
|
118
|
+
await requireStoreIdentity(paths, { workspaceId, clock, platform });
|
|
113
119
|
for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
|
|
114
|
-
const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
|
|
120
|
+
const publishOptions = { root, tmpDir: paths.tmp, clock, failAt, platform };
|
|
115
121
|
await initialiseActiveJournal(paths, publishOptions);
|
|
116
122
|
|
|
117
123
|
// Any journal left behind by a crashed writer is completed before the store
|
|
@@ -251,7 +257,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
251
257
|
// ceiling in eventsSince has to hide.
|
|
252
258
|
const publications = [
|
|
253
259
|
...events.map(event => ({
|
|
254
|
-
path:
|
|
260
|
+
path: publicationPath(root, eventPath(paths, event.sequence)),
|
|
255
261
|
bytes: encode(event),
|
|
256
262
|
replace: false,
|
|
257
263
|
})),
|
|
@@ -259,13 +265,29 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
259
265
|
? stateDeletionPublication(paths, root, entry.kind, entry.id, entry.generation,
|
|
260
266
|
statePath(paths, entry.kind, entry.id))
|
|
261
267
|
: {
|
|
262
|
-
path:
|
|
268
|
+
path: publicationPath(root, statePath(paths, entry.kind, entry.id)),
|
|
263
269
|
bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
|
|
264
270
|
replace: true,
|
|
265
271
|
})),
|
|
266
272
|
];
|
|
267
273
|
if (publications.length === 0) return result;
|
|
268
274
|
|
|
275
|
+
// One record replaced and nothing appended - a session's heartbeat, every
|
|
276
|
+
// turn - needs no journal to appear at once: the rename replaces it
|
|
277
|
+
// atomically, and the bytes are flushed before and after it. The journal
|
|
278
|
+
// cost five atomic writes for it, ten flushes on Windows at 8 to 23 ms
|
|
279
|
+
// each (windows-latest). Only while no other transaction is open: an open
|
|
280
|
+
// one would later roll its own bytes over this record, and it refuses a
|
|
281
|
+
// journalled write too, which the path below keeps.
|
|
282
|
+
const [only] = staged.values();
|
|
283
|
+
if (events.length === 0 && staged.size === 1 && only.removed !== true
|
|
284
|
+
&& (await readActiveJournal(paths, root)).state === "idle") {
|
|
285
|
+
assertPublicationDeadline(deadlineAt);
|
|
286
|
+
await publishAtomic(statePath(paths, only.kind, only.id), publications[0].bytes,
|
|
287
|
+
{ ...publishOptions, replace: true, deadlineAt });
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
269
291
|
// Preparation is still cancellable. The active journal's atomic
|
|
270
292
|
// publication decides the write; roll-forward must then finish even if
|
|
271
293
|
// this invocation expires, so its publication options carry no deadline.
|
|
@@ -372,7 +394,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
372
394
|
validateRecord(kind, record);
|
|
373
395
|
return withWriterMutex(paths, { ...publishOptions, deadlineAt: storeDeadline }, async () => {
|
|
374
396
|
await publishAtomic(ephemeralPath(kind, id), encode(record),
|
|
375
|
-
{ root, tmpDir: paths.tmp, replace: true, deadlineAt: storeDeadline });
|
|
397
|
+
{ root, tmpDir: paths.tmp, replace: true, durability: EPHEMERAL, deadlineAt: storeDeadline });
|
|
376
398
|
// Once record bytes are accepted, finish the same logical publication.
|
|
377
399
|
await markEphemeral(paths, publishOptions, kind, id, "present");
|
|
378
400
|
return record;
|
|
@@ -386,7 +408,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
386
408
|
validateRecord(kind, next);
|
|
387
409
|
assertPublicationDeadline(deadlineAt);
|
|
388
410
|
await publishAtomic(ephemeralPath(kind, id), encode(next),
|
|
389
|
-
{ root, tmpDir: paths.tmp, replace: true, deadlineAt });
|
|
411
|
+
{ root, tmpDir: paths.tmp, replace: true, durability: EPHEMERAL, deadlineAt });
|
|
390
412
|
await markEphemeral(paths, publishOptions, kind, id, "present");
|
|
391
413
|
return next;
|
|
392
414
|
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, mkdir, rmdir } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
6
|
|
|
7
7
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
8
8
|
|
|
9
9
|
import { encode, readJsonIfPresent } from "./atomic-json.mjs";
|
|
10
|
+
import { isWindows, removeTree, renameEntry } from "./portable-fs.mjs";
|
|
10
11
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
11
12
|
import { withRegularNoFollow } from "./safe-file.mjs";
|
|
12
13
|
|
|
@@ -16,8 +17,15 @@ const OWNER = "owner.json";
|
|
|
16
17
|
const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
|
|
17
18
|
|
|
18
19
|
// A fully prepared directory is the atomic primitive. Publishing the owner
|
|
19
|
-
// with the directory means a
|
|
20
|
-
// ownerless canonical lock that blocks every later writer.
|
|
20
|
+
// with the directory means a process that dies can leave an unused candidate,
|
|
21
|
+
// but never an ownerless canonical lock that blocks every later writer.
|
|
22
|
+
//
|
|
23
|
+
// Nothing here is flushed. The lock guards live processes, and a crash of the
|
|
24
|
+
// machine leaves none: whatever it left in the lock guards nobody. A flush cost
|
|
25
|
+
// up to 5.8 s on windows-latest beside the suite (2026-10-01), three of them per
|
|
26
|
+
// acquisition on POSIX, and the only thing they bought was an owner record that
|
|
27
|
+
// stays readable through a crash. An unreadable one is reclaimed instead, once
|
|
28
|
+
// it is older than any write takes (takeStaleOwnership).
|
|
21
29
|
function defaultPidIsAlive(pid) {
|
|
22
30
|
try {
|
|
23
31
|
process.kill(pid, 0);
|
|
@@ -27,16 +35,7 @@ function defaultPidIsAlive(pid) {
|
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
async function
|
|
31
|
-
const handle = await open(directory, "r");
|
|
32
|
-
try {
|
|
33
|
-
await handle.sync();
|
|
34
|
-
} finally {
|
|
35
|
-
await handle.close();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function prepareCandidate(paths, root, owner) {
|
|
38
|
+
async function prepareCandidate(paths, root, owner, platform) {
|
|
40
39
|
const identity = createHash("sha256").update(owner.token).digest("hex");
|
|
41
40
|
const candidate = path.join(paths.locks, `writer.candidate-${identity}.lock`);
|
|
42
41
|
await mkdir(candidate);
|
|
@@ -44,17 +43,15 @@ async function prepareCandidate(paths, root, owner) {
|
|
|
44
43
|
await withRegularNoFollow(path.join(candidate, OWNER), root,
|
|
45
44
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, async handle => {
|
|
46
45
|
await handle.writeFile(encode(owner));
|
|
47
|
-
await handle.sync();
|
|
48
46
|
});
|
|
49
|
-
await syncDirectory(candidate);
|
|
50
47
|
return candidate;
|
|
51
48
|
} catch (error) {
|
|
52
|
-
await
|
|
49
|
+
await removeTree(candidate, { platform });
|
|
53
50
|
throw error;
|
|
54
51
|
}
|
|
55
52
|
}
|
|
56
53
|
|
|
57
|
-
async function releaseCanonical(directory, root, owner, openFile) {
|
|
54
|
+
async function releaseCanonical(directory, root, owner, openFile, { platform, rename }) {
|
|
58
55
|
const current = await readOwner(directory, root, openFile);
|
|
59
56
|
if (current?.token !== owner.token) return;
|
|
60
57
|
// Recursive removal would first unlink owner.json and briefly expose an
|
|
@@ -63,14 +60,51 @@ async function releaseCanonical(directory, root, owner, openFile) {
|
|
|
63
60
|
const identity = createHash("sha256")
|
|
64
61
|
.update(JSON.stringify([owner.pid, owner.token, owner.acquiredAt]))
|
|
65
62
|
.digest("hex");
|
|
63
|
+
if (isWindows(platform)) {
|
|
64
|
+
await releaseByOwnerRecord(directory,
|
|
65
|
+
path.join(path.dirname(directory), `writer.released-${identity}.json`), { platform, rename });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
66
68
|
const retired = path.join(path.dirname(directory), `writer.released-${identity}.lock`);
|
|
67
69
|
try {
|
|
68
|
-
|
|
70
|
+
// POSIX moves the whole lock aside; Windows released it above, by its
|
|
71
|
+
// owner record, because a reader inside holds the directory in place.
|
|
72
|
+
await renameEntry(directory, retired, { platform, rename });
|
|
69
73
|
} catch (error) {
|
|
70
74
|
if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return;
|
|
71
75
|
throw error;
|
|
72
76
|
}
|
|
73
|
-
await
|
|
77
|
+
await removeTree(retired, { platform });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Release on Windows, where a directory cannot be renamed while a file inside
|
|
82
|
+
* it is open - and every waiting writer opens owner.json to see who holds the
|
|
83
|
+
* lock. Eight writers polling one lock on windows-latest held the move back
|
|
84
|
+
* until one of them gave up at the acquire timeout.
|
|
85
|
+
*
|
|
86
|
+
* A file can be renamed while it is open (Node opens with FILE_SHARE_DELETE),
|
|
87
|
+
* so the owner record leaves first, in one rename, and the lock is an empty
|
|
88
|
+
* directory from then on. On Windows an empty lock is a released one: the
|
|
89
|
+
* acquiring loop removes it with rmdir, which cannot remove a lock that has an
|
|
90
|
+
* owner in it, and so does this owner. A reader that opened the record before
|
|
91
|
+
* it left sees this owner once more and looks again.
|
|
92
|
+
*
|
|
93
|
+
* Reclaiming a dead owner still moves the whole directory. Node's rename
|
|
94
|
+
* replaces an existing file on Windows, so a late reclaimer moving owner.json
|
|
95
|
+
* could take a successor's record; a directory never lands on another one.
|
|
96
|
+
*/
|
|
97
|
+
async function releaseByOwnerRecord(directory, retiredOwner, { platform, rename }) {
|
|
98
|
+
try {
|
|
99
|
+
await renameEntry(path.join(directory, OWNER), retiredOwner, { platform, rename });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code === "ENOENT") return;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
// ENOENT: a waiting writer removed it first. ENOTEMPTY: a successor is in.
|
|
105
|
+
// Busy: somebody is looking at it, and the next writer removes it instead.
|
|
106
|
+
await rmdir(directory).catch(() => null);
|
|
107
|
+
await removeTree(retiredOwner, { platform });
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
/**
|
|
@@ -93,14 +127,22 @@ async function releaseCanonical(directory, root, owner, openFile) {
|
|
|
93
127
|
* refuses everywhere a parent has no business changing.
|
|
94
128
|
*/
|
|
95
129
|
async function readOwner(directory, root, openFile) {
|
|
130
|
+
const file = path.join(directory, OWNER);
|
|
96
131
|
try {
|
|
97
|
-
const found = await readJsonIfPresent(
|
|
132
|
+
const found = await readJsonIfPresent(file, root, openFile);
|
|
98
133
|
return found?.value ?? null;
|
|
99
134
|
} catch (error) {
|
|
100
135
|
if (error instanceof AccError && error.code === EXIT.DATA
|
|
101
136
|
&& /parent directory changed/.test(error.message)) {
|
|
102
137
|
return null;
|
|
103
138
|
}
|
|
139
|
+
// Bytes a crash kept from the disk: an empty record, or zeros where NTFS
|
|
140
|
+
// kept its length. Its age is all that is left to judge it by.
|
|
141
|
+
if (error instanceof AccError && error.code === EXIT.DATA
|
|
142
|
+
&& /^invalid JSON record/.test(error.message)) {
|
|
143
|
+
const stat = await lstat(file).catch(() => null);
|
|
144
|
+
return stat === null ? null : { unreadable: true, modifiedMs: stat.mtimeMs };
|
|
145
|
+
}
|
|
104
146
|
throw error;
|
|
105
147
|
}
|
|
106
148
|
}
|
|
@@ -125,25 +167,40 @@ async function readOwner(directory, root, openFile) {
|
|
|
125
167
|
* unrelated, and then `pidIsAlive` says yes forever. Sixty seconds is far longer
|
|
126
168
|
* than any write here takes - the hook budget is five - so a holder that old is
|
|
127
169
|
* not a writer that is still going.
|
|
170
|
+
*
|
|
171
|
+
* An owner record nobody can read is judged by the same age, on the clock that
|
|
172
|
+
* stamped its file. Only a crash of the machine leaves one: a live writer's
|
|
173
|
+
* record is complete before the lock carries it, and an older ACC that wrote
|
|
174
|
+
* into the lock directly takes microseconds to finish.
|
|
128
175
|
*/
|
|
129
|
-
async function takeStaleOwnership(directory, root, owner, now, pidIsAlive
|
|
176
|
+
async function takeStaleOwnership(directory, root, owner, now, pidIsAlive,
|
|
177
|
+
{ platform, rename, deadlineAt, wallNow }) {
|
|
130
178
|
if (owner === null) return false;
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
if (owner.unreadable === true) {
|
|
180
|
+
if (!(wallNow() - owner.modifiedMs > STALE_MS)) return false;
|
|
181
|
+
} else {
|
|
182
|
+
const age = Date.parse(now) - Date.parse(owner.acquiredAt);
|
|
183
|
+
if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
|
|
184
|
+
}
|
|
133
185
|
// Every contender that observed this owner names the same retained target.
|
|
134
186
|
// rename() moves the whole lock atomically; exactly one contender can put it
|
|
135
187
|
// there. The target is deliberately left non-empty. A late contender cannot
|
|
136
188
|
// rename a successor over it, so a stale pathname observation never becomes
|
|
137
189
|
// permission to remove the writer that acquired the lock afterwards.
|
|
138
190
|
const identity = createHash("sha256")
|
|
139
|
-
.update(JSON.stringify(
|
|
191
|
+
.update(JSON.stringify(owner.unreadable === true ? ["unreadable", owner.modifiedMs]
|
|
192
|
+
: [owner.pid, owner.token, owner.acquiredAt]))
|
|
140
193
|
.digest("hex");
|
|
141
194
|
const reclaimed = path.join(path.dirname(directory), `writer.reclaimed-${identity}.lock`);
|
|
142
195
|
try {
|
|
143
|
-
await
|
|
196
|
+
await renameEntry(directory, reclaimed, { platform, rename, deadlineAt });
|
|
144
197
|
return true;
|
|
145
198
|
} catch (error) {
|
|
146
199
|
if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return false;
|
|
200
|
+
// Windows refuses the move while a waiting writer has the owner record
|
|
201
|
+
// open, and it was retried up to this writer's deadline: not reclaimed
|
|
202
|
+
// this time, which the loop reports as the conflict it is.
|
|
203
|
+
if (isWindows(platform) && ["EPERM", "EACCES", "EBUSY"].includes(error.code)) return false;
|
|
147
204
|
throw error;
|
|
148
205
|
}
|
|
149
206
|
}
|
|
@@ -152,7 +209,9 @@ export async function withWriterMutex(paths, options, operation) {
|
|
|
152
209
|
const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
|
|
153
210
|
attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
|
|
154
211
|
acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
|
|
155
|
-
wallNow = Date.now, deadlineAt, sleep = sleepFor
|
|
212
|
+
wallNow = Date.now, deadlineAt, sleep = sleepFor, platform = process.platform,
|
|
213
|
+
rename } = options;
|
|
214
|
+
const fsOptions = { platform, rename };
|
|
156
215
|
const directory = path.join(paths.locks, "writer.lock");
|
|
157
216
|
// Wall time can jump while a process waits. A monotonic absolute deadline
|
|
158
217
|
// bounds all owner reads and retries, leaving half the hook's five-second
|
|
@@ -164,7 +223,7 @@ export async function withWriterMutex(paths, options, operation) {
|
|
|
164
223
|
await ensureManagedDirectory(root, paths.locks);
|
|
165
224
|
const token = uuid();
|
|
166
225
|
const owner = { pid: process.pid, token, acquiredAt: clock.now() };
|
|
167
|
-
const candidate = await prepareCandidate(paths, root, owner);
|
|
226
|
+
const candidate = await prepareCandidate(paths, root, owner, platform);
|
|
168
227
|
let ownsCanonical = false;
|
|
169
228
|
|
|
170
229
|
try {
|
|
@@ -175,13 +234,24 @@ export async function withWriterMutex(paths, options, operation) {
|
|
|
175
234
|
// lock left by the old mkdir-then-publish sequence. A live publisher
|
|
176
235
|
// either fills it first (so rename fails) or loses its no-replace owner
|
|
177
236
|
// publication after this complete candidate wins.
|
|
178
|
-
|
|
237
|
+
// Windows reports the taken name as EPERM; renameEntry turns that into
|
|
238
|
+
// the EEXIST this loop waits on, and waits out a busy source itself.
|
|
239
|
+
await renameEntry(candidate, directory,
|
|
240
|
+
{ ...fsOptions, deadlineAt: wallNow() + Math.max(0, deadline - monotonicNow()) });
|
|
179
241
|
ownsCanonical = true;
|
|
180
|
-
await syncDirectory(paths.locks);
|
|
181
242
|
} catch (error) {
|
|
182
243
|
if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
|
|
183
244
|
const current = await readOwner(directory, root, openFile);
|
|
184
|
-
|
|
245
|
+
// POSIX rename replaces the empty lock an older ACC's mkdir-then-publish
|
|
246
|
+
// left; Windows cannot rename onto any directory, and an owner there
|
|
247
|
+
// releases by leaving an empty one. rmdir removes only an empty one, so
|
|
248
|
+
// the outcome is POSIX's: a publisher still filling it loses its
|
|
249
|
+
// no-replace owner write, and a complete lock is never touched.
|
|
250
|
+
if (current === null && isWindows(platform)
|
|
251
|
+
&& await rmdir(directory).then(() => true, () => false)) continue;
|
|
252
|
+
if (!await takeStaleOwnership(directory, root, current, clock.now(), pidIsAlive,
|
|
253
|
+
{ ...fsOptions, wallNow,
|
|
254
|
+
deadlineAt: wallNow() + Math.max(0, deadline - monotonicNow()) })) {
|
|
185
255
|
const remaining = deadline - monotonicNow();
|
|
186
256
|
if (remaining <= 0) break;
|
|
187
257
|
await sleep(Math.min(waitMs, remaining));
|
|
@@ -193,11 +263,11 @@ export async function withWriterMutex(paths, options, operation) {
|
|
|
193
263
|
}
|
|
194
264
|
} finally {
|
|
195
265
|
if (ownsCanonical) {
|
|
196
|
-
await releaseCanonical(directory, root, owner, openFile);
|
|
266
|
+
await releaseCanonical(directory, root, owner, openFile, fsOptions);
|
|
197
267
|
} else {
|
|
198
268
|
const current = await readOwner(candidate, root, openFile);
|
|
199
269
|
if (current?.token === token) {
|
|
200
|
-
await
|
|
270
|
+
await removeTree(candidate, { platform });
|
|
201
271
|
}
|
|
202
272
|
}
|
|
203
273
|
}
|