@openclaw/crabline 0.1.10 → 0.1.12
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 +99 -12
- package/dist/src/bin/crabline.js +3 -1
- package/dist/src/bin/crabline.js.map +1 -1
- package/dist/src/cli/program.d.ts +5 -1
- package/dist/src/cli/program.js +264 -50
- package/dist/src/cli/program.js.map +1 -1
- package/dist/src/config/load.js +39 -3
- package/dist/src/config/load.js.map +1 -1
- package/dist/src/config/schema.d.ts +20 -0
- package/dist/src/config/schema.js +163 -35
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/core/errors.d.ts +1 -0
- package/dist/src/core/errors.js +6 -3
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/http-path.d.ts +1 -0
- package/dist/src/core/http-path.js +14 -0
- package/dist/src/core/http-path.js.map +1 -0
- package/dist/src/core/matcher.d.ts +3 -1
- package/dist/src/core/matcher.js +58 -2
- package/dist/src/core/matcher.js.map +1 -1
- package/dist/src/core/nonces.d.ts +2 -0
- package/dist/src/core/nonces.js +11 -1
- package/dist/src/core/nonces.js.map +1 -1
- package/dist/src/core/reporters.d.ts +5 -0
- package/dist/src/core/reporters.js +26 -2
- package/dist/src/core/reporters.js.map +1 -1
- package/dist/src/core/run.d.ts +3 -1
- package/dist/src/core/run.js +178 -66
- package/dist/src/core/run.js.map +1 -1
- package/dist/src/matrix-ids.d.ts +5 -0
- package/dist/src/matrix-ids.js +83 -0
- package/dist/src/matrix-ids.js.map +1 -0
- package/dist/src/openclaw/artifact-generation.d.ts +12 -2
- package/dist/src/openclaw/artifact-generation.js +436 -15
- package/dist/src/openclaw/artifact-generation.js.map +1 -1
- package/dist/src/openclaw/bridges/matrix.js +21 -4
- package/dist/src/openclaw/bridges/matrix.js.map +1 -1
- package/dist/src/openclaw/bridges/mattermost.js +13 -8
- package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
- package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
- package/dist/src/openclaw/bridges/probe-response.js +5 -0
- package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
- package/dist/src/openclaw/bridges/signal.js +15 -6
- package/dist/src/openclaw/bridges/signal.js.map +1 -1
- package/dist/src/openclaw/bridges/slack.js +116 -23
- package/dist/src/openclaw/bridges/slack.js.map +1 -1
- package/dist/src/openclaw/bridges/telegram.js +45 -19
- package/dist/src/openclaw/bridges/telegram.js.map +1 -1
- package/dist/src/openclaw/bridges/whatsapp.js +32 -16
- package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
- package/dist/src/openclaw/bridges/zalo.js +8 -3
- package/dist/src/openclaw/bridges/zalo.js.map +1 -1
- package/dist/src/openclaw/private-file.d.ts +40 -3
- package/dist/src/openclaw/private-file.js +2070 -95
- package/dist/src/openclaw/private-file.js.map +1 -1
- package/dist/src/openclaw/shared.d.ts +1 -0
- package/dist/src/openclaw/shared.js +48 -13
- package/dist/src/openclaw/shared.js.map +1 -1
- package/dist/src/openclaw/smoke-lock.d.ts +9 -0
- package/dist/src/openclaw/smoke-lock.js +696 -187
- package/dist/src/openclaw/smoke-lock.js.map +1 -1
- package/dist/src/openclaw.js +352 -29
- package/dist/src/openclaw.js.map +1 -1
- package/dist/src/platform/process-owned-lock.d.ts +11 -0
- package/dist/src/platform/process-owned-lock.js +1698 -0
- package/dist/src/platform/process-owned-lock.js.map +1 -0
- package/dist/src/platform/recorder-directory.d.ts +1 -0
- package/dist/src/platform/recorder-directory.js +14 -0
- package/dist/src/platform/recorder-directory.js.map +1 -0
- package/dist/src/platform/windows-acl.d.ts +18 -0
- package/dist/src/platform/windows-acl.js +1367 -0
- package/dist/src/platform/windows-acl.js.map +1 -0
- package/dist/src/platform/windows-lock-root.d.ts +13 -0
- package/dist/src/platform/windows-lock-root.js +98 -0
- package/dist/src/platform/windows-lock-root.js.map +1 -0
- package/dist/src/providers/builtin/discord.d.ts +4 -0
- package/dist/src/providers/builtin/discord.js +52 -9
- package/dist/src/providers/builtin/discord.js.map +1 -1
- package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
- package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
- package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
- package/dist/src/providers/builtin/feishu.d.ts +1 -0
- package/dist/src/providers/builtin/feishu.js +96 -26
- package/dist/src/providers/builtin/feishu.js.map +1 -1
- package/dist/src/providers/builtin/googlechat.d.ts +1 -0
- package/dist/src/providers/builtin/googlechat.js +95 -26
- package/dist/src/providers/builtin/googlechat.js.map +1 -1
- package/dist/src/providers/builtin/imessage.js +60 -17
- package/dist/src/providers/builtin/imessage.js.map +1 -1
- package/dist/src/providers/builtin/loopback.js +88 -35
- package/dist/src/providers/builtin/loopback.js.map +1 -1
- package/dist/src/providers/builtin/matrix.d.ts +2 -2
- package/dist/src/providers/builtin/matrix.js +78 -21
- package/dist/src/providers/builtin/matrix.js.map +1 -1
- package/dist/src/providers/builtin/mattermost.d.ts +11 -7
- package/dist/src/providers/builtin/mattermost.js +138 -24
- package/dist/src/providers/builtin/mattermost.js.map +1 -1
- package/dist/src/providers/builtin/msteams.d.ts +1 -0
- package/dist/src/providers/builtin/msteams.js +118 -38
- package/dist/src/providers/builtin/msteams.js.map +1 -1
- package/dist/src/providers/builtin/native-local-mock.js +4 -4
- package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
- package/dist/src/providers/builtin/script.js +1057 -146
- package/dist/src/providers/builtin/script.js.map +1 -1
- package/dist/src/providers/builtin/slack.d.ts +8 -1
- package/dist/src/providers/builtin/slack.js +327 -24
- package/dist/src/providers/builtin/slack.js.map +1 -1
- package/dist/src/providers/builtin/telegram.d.ts +1 -1
- package/dist/src/providers/builtin/telegram.js +55 -14
- package/dist/src/providers/builtin/telegram.js.map +1 -1
- package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
- package/dist/src/providers/builtin/whatsapp.js +157 -29
- package/dist/src/providers/builtin/whatsapp.js.map +1 -1
- package/dist/src/providers/builtin/zalo.d.ts +5 -2
- package/dist/src/providers/builtin/zalo.js +31 -7
- package/dist/src/providers/builtin/zalo.js.map +1 -1
- package/dist/src/providers/catalog.d.ts +1 -1
- package/dist/src/providers/catalog.js +1 -1
- package/dist/src/providers/catalog.js.map +1 -1
- package/dist/src/providers/local-mock.d.ts +4 -0
- package/dist/src/providers/local-mock.js +162 -35
- package/dist/src/providers/local-mock.js.map +1 -1
- package/dist/src/providers/native-ids.d.ts +2 -0
- package/dist/src/providers/native-ids.js +3 -0
- package/dist/src/providers/native-ids.js.map +1 -1
- package/dist/src/providers/recorder.d.ts +14 -3
- package/dist/src/providers/recorder.js +532 -42
- package/dist/src/providers/recorder.js.map +1 -1
- package/dist/src/providers/registry.d.ts +1 -0
- package/dist/src/providers/registry.js +65 -20
- package/dist/src/providers/registry.js.map +1 -1
- package/dist/src/providers/signed-jwt.d.ts +6 -1
- package/dist/src/providers/signed-jwt.js +241 -50
- package/dist/src/providers/signed-jwt.js.map +1 -1
- package/dist/src/providers/slack-ids.d.ts +1 -0
- package/dist/src/providers/slack-ids.js +5 -0
- package/dist/src/providers/slack-ids.js.map +1 -1
- package/dist/src/providers/target-normalizers.d.ts +9 -1
- package/dist/src/providers/target-normalizers.js +177 -58
- package/dist/src/providers/target-normalizers.js.map +1 -1
- package/dist/src/providers/webhook-server.d.ts +3 -0
- package/dist/src/providers/webhook-server.js +268 -47
- package/dist/src/providers/webhook-server.js.map +1 -1
- package/dist/src/servers/http.d.ts +14 -2
- package/dist/src/servers/http.js +229 -58
- package/dist/src/servers/http.js.map +1 -1
- package/dist/src/servers/matrix.d.ts +3 -0
- package/dist/src/servers/matrix.js +268 -110
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +5 -0
- package/dist/src/servers/mattermost.js +389 -91
- package/dist/src/servers/mattermost.js.map +1 -1
- package/dist/src/servers/recorder.d.ts +11 -0
- package/dist/src/servers/recorder.js +1241 -22
- package/dist/src/servers/recorder.js.map +1 -1
- package/dist/src/servers/signal.d.ts +1 -0
- package/dist/src/servers/signal.js +279 -44
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +15 -0
- package/dist/src/servers/slack.js +259 -51
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram-identity.d.ts +6 -0
- package/dist/src/servers/telegram-identity.js +27 -0
- package/dist/src/servers/telegram-identity.js.map +1 -0
- package/dist/src/servers/telegram.d.ts +17 -6
- package/dist/src/servers/telegram.js +1193 -118
- package/dist/src/servers/telegram.js.map +1 -1
- package/dist/src/servers/webhook-target.d.ts +12 -4
- package/dist/src/servers/webhook-target.js +226 -28
- package/dist/src/servers/webhook-target.js.map +1 -1
- package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
- package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
- package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
- package/dist/src/servers/whatsapp-jid.d.ts +1 -0
- package/dist/src/servers/whatsapp-jid.js +9 -5
- package/dist/src/servers/whatsapp-jid.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
- package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
- package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
- package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
- package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
- package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
- package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/signal.js +3 -3
- package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
- package/dist/src/servers/whatsapp.d.ts +7 -0
- package/dist/src/servers/whatsapp.js +149 -24
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +1 -0
- package/dist/src/servers/zalo.js +193 -82
- package/dist/src/servers/zalo.js.map +1 -1
- package/docs/channel-setup.md +185 -43
- package/fixtures/examples/crabline.example.yaml +3 -2
- package/package.json +18 -10
- package/fixtures/examples/openclaw-bridge.yaml +0 -125
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { lock } from "proper-lockfile";
|
|
6
|
+
import { createProcessOwnedLockFileSystem } from "../platform/process-owned-lock.js";
|
|
7
|
+
import { isManagedRecorderDirectory } from "../platform/recorder-directory.js";
|
|
8
|
+
import { createOwnerOnlyWindowsDirectory, readWindowsDirectoryNamespaceSecuritySnapshot, readWindowsDirectorySecuritySnapshot, } from "../platform/windows-acl.js";
|
|
9
|
+
import { secureCachedWindowsLockRoot, } from "../platform/windows-lock-root.js";
|
|
4
10
|
function sleep(ms, signal) {
|
|
5
11
|
if (signal?.aborted) {
|
|
6
12
|
return Promise.resolve();
|
|
@@ -17,6 +23,8 @@ function sleep(ms, signal) {
|
|
|
17
23
|
}
|
|
18
24
|
const pendingAppends = new Map();
|
|
19
25
|
const recordIdentityIndexes = new Map();
|
|
26
|
+
const securedWindowsLockRoots = new Map();
|
|
27
|
+
const securedWindowsRecorderDirectories = new Map();
|
|
20
28
|
const MAX_RECORD_IDENTITY_INDEXES = 128;
|
|
21
29
|
const MAX_RECENT_RECORD_KEYS = 4096;
|
|
22
30
|
const RECORDER_BATCH_VERSION = 1;
|
|
@@ -25,6 +33,14 @@ const RECORDER_LOCK_UPDATE_MS = 10_000;
|
|
|
25
33
|
const RECORDER_ROTATION_ATTEMPTS = 3;
|
|
26
34
|
class RecorderRotatedError extends Error {
|
|
27
35
|
}
|
|
36
|
+
export class ProviderRecorderCommittedError extends AggregateError {
|
|
37
|
+
committed = true;
|
|
38
|
+
indeterminate = true;
|
|
39
|
+
constructor(filePath, cause, relatedErrors = []) {
|
|
40
|
+
super([cause, ...relatedErrors], `Provider recorder append was published for "${filePath}", but durability or identity confirmation failed.`, { cause });
|
|
41
|
+
this.name = "ProviderRecorderCommittedError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
28
44
|
const CONTINUITY_BYTES = 4096;
|
|
29
45
|
const MAX_INCREMENTAL_READ_BYTES = 256 * 1024;
|
|
30
46
|
const MAX_PENDING_RECORD_BYTES = 4 * 1024 * 1024;
|
|
@@ -38,6 +54,20 @@ function createIncrementalReadState() {
|
|
|
38
54
|
pending: Buffer.alloc(0),
|
|
39
55
|
};
|
|
40
56
|
}
|
|
57
|
+
function snapshotIncrementalReadState(state) {
|
|
58
|
+
return {
|
|
59
|
+
...state,
|
|
60
|
+
identity: state.identity ? { ...state.identity } : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function restoreIncrementalReadState(state, snapshot) {
|
|
64
|
+
state.caughtUp = snapshot.caughtUp;
|
|
65
|
+
state.continuity = snapshot.continuity;
|
|
66
|
+
state.generation = snapshot.generation;
|
|
67
|
+
state.identity = snapshot.identity;
|
|
68
|
+
state.offset = snapshot.offset;
|
|
69
|
+
state.pending = snapshot.pending;
|
|
70
|
+
}
|
|
41
71
|
export function createRecordedInboundCursor() {
|
|
42
72
|
return {
|
|
43
73
|
buffered: [],
|
|
@@ -188,10 +218,20 @@ function consumeRecordedChunk(state, chunk) {
|
|
|
188
218
|
}
|
|
189
219
|
return events;
|
|
190
220
|
}
|
|
191
|
-
async function appendJsonLine(filePath, line) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
221
|
+
async function appendJsonLine(filePath, line, firstCreatedDirectory) {
|
|
222
|
+
for (let attempt = 0;; attempt++) {
|
|
223
|
+
try {
|
|
224
|
+
await serializeAppend(filePath, async (publicationPath, logicalPath, _lockCreatedFile, lockedIdentity) => {
|
|
225
|
+
await appendCommittedLine(publicationPath, logicalPath, line, true, lockedIdentity, firstCreatedDirectory);
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!(error instanceof RecorderRotatedError) || attempt + 1 >= RECORDER_ROTATION_ATTEMPTS) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
195
235
|
}
|
|
196
236
|
async function readRecorderFileIdentity(filePath) {
|
|
197
237
|
try {
|
|
@@ -202,6 +242,7 @@ async function readRecorderFileIdentity(filePath) {
|
|
|
202
242
|
return {
|
|
203
243
|
dev: stats.dev,
|
|
204
244
|
ino: stats.ino,
|
|
245
|
+
nlink: stats.nlink,
|
|
205
246
|
};
|
|
206
247
|
}
|
|
207
248
|
catch (error) {
|
|
@@ -214,6 +255,19 @@ async function readRecorderFileIdentity(filePath) {
|
|
|
214
255
|
function sameRecorderFileIdentity(left, right) {
|
|
215
256
|
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
216
257
|
}
|
|
258
|
+
function sameRecorderLockIdentity(left, right) {
|
|
259
|
+
return sameRecorderFileIdentity(left, right) && left?.nlink === right?.nlink;
|
|
260
|
+
}
|
|
261
|
+
function requireRecorderHandleIdentity(stats) {
|
|
262
|
+
if (stats.isFile?.() === false) {
|
|
263
|
+
throw new Error("Recorder path is not a regular file.");
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
dev: stats.dev,
|
|
267
|
+
ino: stats.ino,
|
|
268
|
+
nlink: stats.nlink,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
217
271
|
async function resolveRecorderPublicationPath(filePath) {
|
|
218
272
|
try {
|
|
219
273
|
return await realpath(filePath);
|
|
@@ -237,6 +291,94 @@ async function resolveRecorderPublicationPath(filePath) {
|
|
|
237
291
|
}
|
|
238
292
|
return path.join(await realpath(path.dirname(filePath)), path.basename(filePath));
|
|
239
293
|
}
|
|
294
|
+
async function openRecorderForAppend(filePath) {
|
|
295
|
+
const createFlags = process.platform === "win32"
|
|
296
|
+
? "wx+"
|
|
297
|
+
: fsConstants.O_RDWR |
|
|
298
|
+
fsConstants.O_APPEND |
|
|
299
|
+
fsConstants.O_CREAT |
|
|
300
|
+
fsConstants.O_EXCL |
|
|
301
|
+
fsConstants.O_NONBLOCK |
|
|
302
|
+
fsConstants.O_NOFOLLOW;
|
|
303
|
+
const existingFlags = process.platform === "win32"
|
|
304
|
+
? "r+"
|
|
305
|
+
: fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW;
|
|
306
|
+
try {
|
|
307
|
+
return {
|
|
308
|
+
created: true,
|
|
309
|
+
handle: await open(filePath, createFlags, 0o600),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error.code !== "EEXIST") {
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
created: false,
|
|
318
|
+
handle: await open(filePath, existingFlags, 0o600),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function appendRecorderData(handle, data) {
|
|
323
|
+
if (process.platform !== "win32") {
|
|
324
|
+
await handle.writeFile(data, "utf8");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const bytes = Buffer.from(data, "utf8");
|
|
328
|
+
const position = (await handle.stat()).size;
|
|
329
|
+
let offset = 0;
|
|
330
|
+
while (offset < bytes.length) {
|
|
331
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, position + offset);
|
|
332
|
+
if (bytesWritten === 0) {
|
|
333
|
+
throw new Error("Could not append recorder data.");
|
|
334
|
+
}
|
|
335
|
+
offset += bytesWritten;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function syncParentDirectory(filePath) {
|
|
339
|
+
if (process.platform === "win32") {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const directory = await open(path.dirname(filePath), "r");
|
|
343
|
+
try {
|
|
344
|
+
await directory.sync();
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await directory.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function syncRecorderPathAncestry(filePath, firstCreatedDirectory) {
|
|
351
|
+
if (process.platform === "win32") {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
355
|
+
let currentPath = resolvedFilePath;
|
|
356
|
+
const syncThroughPath = firstCreatedDirectory === undefined ? undefined : path.resolve(firstCreatedDirectory);
|
|
357
|
+
for (;;) {
|
|
358
|
+
const directoryPath = path.dirname(currentPath);
|
|
359
|
+
const mandatory = syncThroughPath !== undefined || currentPath === resolvedFilePath;
|
|
360
|
+
try {
|
|
361
|
+
await syncParentDirectory(currentPath);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
const code = error.code;
|
|
365
|
+
if (!mandatory && (code === "EACCES" || code === "EPERM")) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
if (syncThroughPath === undefined) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (currentPath === syncThroughPath) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (path.dirname(directoryPath) === directoryPath) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
currentPath = directoryPath;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
240
382
|
async function prepareRecorderTailForAppend(handle) {
|
|
241
383
|
const stats = await handle.stat();
|
|
242
384
|
if (stats.size === 0) {
|
|
@@ -255,8 +397,8 @@ async function prepareRecorderTailForAppend(handle) {
|
|
|
255
397
|
const tailStart = stats.size - windowSize + lastNewline + 1;
|
|
256
398
|
const tail = tailWindow.subarray(lastNewline + 1).toString("utf8");
|
|
257
399
|
try {
|
|
258
|
-
|
|
259
|
-
await handle
|
|
400
|
+
parseRecordedLine(tail);
|
|
401
|
+
await appendRecorderData(handle, "\n");
|
|
260
402
|
return true;
|
|
261
403
|
}
|
|
262
404
|
catch (error) {
|
|
@@ -267,11 +409,15 @@ async function prepareRecorderTailForAppend(handle) {
|
|
|
267
409
|
return true;
|
|
268
410
|
}
|
|
269
411
|
}
|
|
270
|
-
async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable) {
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
const recorderIdentity =
|
|
412
|
+
async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable, expectedIdentity) {
|
|
413
|
+
const opened = await openRecorderForAppend(publicationPath);
|
|
414
|
+
const { handle } = opened;
|
|
415
|
+
const recorderIdentity = requireRecorderHandleIdentity(await handle.stat({ bigint: true }));
|
|
274
416
|
try {
|
|
417
|
+
if (expectedIdentity !== undefined &&
|
|
418
|
+
!sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
|
|
419
|
+
throw new RecorderRotatedError("Recorder rotated before preparing a committed line.");
|
|
420
|
+
}
|
|
275
421
|
const changed = await prepareRecorderTailForAppend(handle);
|
|
276
422
|
if (changed && durable) {
|
|
277
423
|
await handle.sync();
|
|
@@ -283,35 +429,299 @@ async function prepareRecorderPathForAppend(publicationPath, logicalPath, durabl
|
|
|
283
429
|
if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
|
|
284
430
|
throw new RecorderRotatedError("Recorder rotated while preparing a committed line.");
|
|
285
431
|
}
|
|
286
|
-
return recorderIdentity;
|
|
432
|
+
return { created: opened.created, identity: recorderIdentity };
|
|
433
|
+
}
|
|
434
|
+
function providerRecorderCloseError(filePath, operationError, closeError) {
|
|
435
|
+
return new AggregateError([operationError, closeError], `Provider recorder operation and file close both failed for "${filePath}".`, { cause: closeError });
|
|
287
436
|
}
|
|
288
|
-
async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity) {
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
const recorderIdentity =
|
|
437
|
+
async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity, firstCreatedDirectory) {
|
|
438
|
+
const opened = await openRecorderForAppend(publicationPath);
|
|
439
|
+
const { handle } = opened;
|
|
440
|
+
const recorderIdentity = requireRecorderHandleIdentity(await handle.stat({ bigint: true }));
|
|
441
|
+
let published = false;
|
|
442
|
+
let operationError;
|
|
292
443
|
try {
|
|
293
444
|
if (expectedIdentity !== undefined &&
|
|
294
445
|
!sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
|
|
295
446
|
throw new RecorderRotatedError("Recorder rotated before appending a committed line.");
|
|
296
447
|
}
|
|
297
448
|
await prepareRecorderTailForAppend(handle);
|
|
298
|
-
|
|
449
|
+
published = true;
|
|
450
|
+
await appendRecorderData(handle, line);
|
|
299
451
|
if (durable) {
|
|
300
452
|
await handle.sync();
|
|
301
453
|
}
|
|
454
|
+
if (durable) {
|
|
455
|
+
const firstCreatedPath = firstCreatedDirectory ?? (opened.created ? publicationPath : undefined);
|
|
456
|
+
await syncRecorderPathAncestry(publicationPath, firstCreatedPath);
|
|
457
|
+
}
|
|
302
458
|
}
|
|
303
|
-
|
|
459
|
+
catch (error) {
|
|
460
|
+
operationError = published ? new ProviderRecorderCommittedError(logicalPath, error) : error;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
304
463
|
await handle.close();
|
|
305
464
|
}
|
|
306
|
-
|
|
465
|
+
catch (closeError) {
|
|
466
|
+
if (operationError !== undefined) {
|
|
467
|
+
if (published) {
|
|
468
|
+
throw new ProviderRecorderCommittedError(logicalPath, operationError, [closeError]);
|
|
469
|
+
}
|
|
470
|
+
throw providerRecorderCloseError(logicalPath, operationError, closeError);
|
|
471
|
+
}
|
|
472
|
+
throw published ? new ProviderRecorderCommittedError(logicalPath, closeError) : closeError;
|
|
473
|
+
}
|
|
474
|
+
if (operationError !== undefined) {
|
|
475
|
+
throw operationError;
|
|
476
|
+
}
|
|
477
|
+
let publishedIdentity;
|
|
478
|
+
try {
|
|
479
|
+
publishedIdentity = await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath));
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
throw new ProviderRecorderCommittedError(logicalPath, error);
|
|
483
|
+
}
|
|
484
|
+
if (!sameRecorderFileIdentity(recorderIdentity, publishedIdentity)) {
|
|
307
485
|
throw new RecorderRotatedError("Recorder rotated while appending a committed line.");
|
|
308
486
|
}
|
|
309
487
|
}
|
|
310
|
-
function recorderLockReleaseError(filePath, operationError,
|
|
311
|
-
return new AggregateError([operationError,
|
|
488
|
+
function recorderLockReleaseError(filePath, operationError, releaseErrors) {
|
|
489
|
+
return new AggregateError([operationError, ...releaseErrors], `Recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
|
|
312
490
|
}
|
|
313
|
-
|
|
314
|
-
|
|
491
|
+
function recorderErrorDetail(error) {
|
|
492
|
+
if (error instanceof AggregateError) {
|
|
493
|
+
return error.errors.map(recorderErrorDetail).join("; ");
|
|
494
|
+
}
|
|
495
|
+
return error instanceof Error ? error.message : String(error);
|
|
496
|
+
}
|
|
497
|
+
function reportRecorderLockReleaseFailure(filePath, releaseError) {
|
|
498
|
+
const detail = recorderErrorDetail(releaseError);
|
|
499
|
+
try {
|
|
500
|
+
process.emitWarning(`Provider recorder append committed but lock cleanup failed for "${filePath}": ${detail}`, {
|
|
501
|
+
code: "CRABLINE_RECORDER_LOCK_CLEANUP",
|
|
502
|
+
type: "ProviderRecorderWarning",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// Cleanup reporting must not change the result of a committed append.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function verifyProviderRecorderLockAncestry(directory, currentUserId) {
|
|
510
|
+
let currentPath = path.resolve(directory);
|
|
511
|
+
for (;;) {
|
|
512
|
+
const current = await lstat(currentPath, { bigint: true });
|
|
513
|
+
const ownerIsTrusted = current.uid === BigInt(currentUserId) || current.uid === 0n;
|
|
514
|
+
if (current.isSymbolicLink()) {
|
|
515
|
+
if (!ownerIsTrusted) {
|
|
516
|
+
throw new Error("Provider recorder lock directory parent namespace is not trusted.");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const peerWritable = (current.mode & 18n) !== 0n;
|
|
521
|
+
const sticky = (current.mode & 512n) !== 0n;
|
|
522
|
+
if (!current.isDirectory() || !ownerIsTrusted || (peerWritable && !sticky)) {
|
|
523
|
+
throw new Error("Provider recorder lock directory parent namespace is not trusted.");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const parent = path.dirname(currentPath);
|
|
527
|
+
if (parent === currentPath) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
currentPath = parent;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function prepareProviderRecorderLockRoot(root, currentUserId) {
|
|
534
|
+
let existingPath = path.resolve(root);
|
|
535
|
+
const missingComponents = [];
|
|
536
|
+
for (;;) {
|
|
537
|
+
try {
|
|
538
|
+
await lstat(existingPath);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error.code !== "ENOENT") {
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
const parent = path.dirname(existingPath);
|
|
546
|
+
if (parent === existingPath) {
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
missingComponents.unshift(path.basename(existingPath));
|
|
550
|
+
existingPath = parent;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
await verifyProviderRecorderLockAncestry(existingPath, currentUserId);
|
|
554
|
+
let canonicalPath = await realpath(existingPath);
|
|
555
|
+
if (canonicalPath !== existingPath) {
|
|
556
|
+
await verifyProviderRecorderLockAncestry(canonicalPath, currentUserId);
|
|
557
|
+
}
|
|
558
|
+
for (const component of missingComponents) {
|
|
559
|
+
canonicalPath = path.join(canonicalPath, component);
|
|
560
|
+
try {
|
|
561
|
+
await mkdir(canonicalPath, { mode: 0o700 });
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (error.code !== "EEXIST") {
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const identity = await lstat(canonicalPath, { bigint: true });
|
|
569
|
+
if (!identity.isDirectory() ||
|
|
570
|
+
identity.isSymbolicLink() ||
|
|
571
|
+
identity.uid !== BigInt(currentUserId)) {
|
|
572
|
+
throw new Error("Provider recorder lock directory is not privately owned.");
|
|
573
|
+
}
|
|
574
|
+
await chmod(canonicalPath, 0o700);
|
|
575
|
+
}
|
|
576
|
+
return canonicalPath;
|
|
577
|
+
}
|
|
578
|
+
export async function secureProviderRecorderLockRoot(root, currentUserId, options = {}) {
|
|
579
|
+
if ((options.platform ?? process.platform) === "win32") {
|
|
580
|
+
const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
|
|
581
|
+
return secureCachedWindowsLockRoot({
|
|
582
|
+
cache: securedWindowsLockRoots,
|
|
583
|
+
cacheKey,
|
|
584
|
+
createDirectory: () => (options.createWindowsDirectory ?? createOwnerOnlyWindowsDirectory)(root),
|
|
585
|
+
errorPrefix: "Provider recorder lock directory",
|
|
586
|
+
readSecuritySnapshot: () => (options.readWindowsDirectorySecuritySnapshot ?? readWindowsDirectorySecuritySnapshot)(root),
|
|
587
|
+
root,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (currentUserId === undefined) {
|
|
591
|
+
throw new Error("Provider recorder identity locking requires a current user id.");
|
|
592
|
+
}
|
|
593
|
+
const canonicalRoot = await prepareProviderRecorderLockRoot(root, currentUserId);
|
|
594
|
+
const handle = await open(canonicalRoot, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
595
|
+
try {
|
|
596
|
+
const identity = await handle.stat({ bigint: true });
|
|
597
|
+
const current = await lstat(canonicalRoot, { bigint: true });
|
|
598
|
+
if (!identity.isDirectory() ||
|
|
599
|
+
!current.isDirectory() ||
|
|
600
|
+
identity.dev !== current.dev ||
|
|
601
|
+
identity.ino !== current.ino ||
|
|
602
|
+
identity.uid !== BigInt(currentUserId) ||
|
|
603
|
+
current.uid !== BigInt(currentUserId)) {
|
|
604
|
+
throw new Error("Provider recorder lock directory is not privately owned.");
|
|
605
|
+
}
|
|
606
|
+
if ((identity.mode & 511n) !== 448n) {
|
|
607
|
+
await handle.chmod(0o700);
|
|
608
|
+
const secured = await handle.stat({ bigint: true });
|
|
609
|
+
if ((secured.mode & 511n) !== 448n) {
|
|
610
|
+
throw new Error("Provider recorder lock directory permissions are not private.");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
await handle.close();
|
|
616
|
+
}
|
|
617
|
+
return canonicalRoot;
|
|
618
|
+
}
|
|
619
|
+
async function secureProviderRecorderWindowsDirectory(root) {
|
|
620
|
+
const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
|
|
621
|
+
return secureCachedWindowsLockRoot({
|
|
622
|
+
cache: securedWindowsRecorderDirectories,
|
|
623
|
+
cacheKey,
|
|
624
|
+
createDirectory: async () => {
|
|
625
|
+
try {
|
|
626
|
+
const identity = await lstat(root);
|
|
627
|
+
if (!identity.isDirectory() || identity.isSymbolicLink()) {
|
|
628
|
+
throw new Error("Provider recorder Windows directory is not a private directory.");
|
|
629
|
+
}
|
|
630
|
+
if (isManagedRecorderDirectory(root)) {
|
|
631
|
+
await createOwnerOnlyWindowsDirectory(root);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
if (error.code !== "ENOENT") {
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
await createOwnerOnlyWindowsDirectory(root);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
errorPrefix: "Provider recorder Windows directory",
|
|
642
|
+
readSecuritySnapshot: () => readWindowsDirectoryNamespaceSecuritySnapshot(root),
|
|
643
|
+
root,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async function prepareRecorderDirectory(filePath) {
|
|
647
|
+
const directory = path.dirname(filePath);
|
|
648
|
+
if (process.platform === "win32") {
|
|
649
|
+
await secureProviderRecorderWindowsDirectory(directory);
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
const createdDirectory = await mkdir(directory, { recursive: true });
|
|
653
|
+
return createdDirectory === undefined ? undefined : await realpath(createdDirectory);
|
|
654
|
+
}
|
|
655
|
+
function recorderLockRootUnavailable(error) {
|
|
656
|
+
const code = error.code;
|
|
657
|
+
return (code === "EACCES" ||
|
|
658
|
+
code === "EDQUOT" ||
|
|
659
|
+
code === "ENOSPC" ||
|
|
660
|
+
code === "EPERM" ||
|
|
661
|
+
code === "EROFS");
|
|
662
|
+
}
|
|
663
|
+
function adjacentRecorderLockRoot(filePath, currentUserId) {
|
|
664
|
+
return path.join(path.dirname(filePath), currentUserId === undefined
|
|
665
|
+
? ".crabline-provider-recorder-locks"
|
|
666
|
+
: `.crabline-provider-recorder-locks-${currentUserId}`);
|
|
667
|
+
}
|
|
668
|
+
function recorderIdentityLockPath(root, identity) {
|
|
669
|
+
return path.join(root, `recorder-${identity.dev}-${identity.ino}`);
|
|
670
|
+
}
|
|
671
|
+
async function recorderIdentityLockTargets(filePath, identity) {
|
|
672
|
+
const currentUserId = process.platform === "win32" ? undefined : process.geteuid?.();
|
|
673
|
+
let sharedRoot;
|
|
674
|
+
try {
|
|
675
|
+
const account = userInfo();
|
|
676
|
+
sharedRoot =
|
|
677
|
+
process.platform === "win32"
|
|
678
|
+
? path.join(account.homedir, "AppData", "Local", "Crabline", "locks", "provider-recorder")
|
|
679
|
+
: path.join(account.homedir, ".cache", "crabline", "locks", "provider-recorder");
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
// Arbitrary container UIDs may not have an OS account entry.
|
|
683
|
+
}
|
|
684
|
+
if (sharedRoot) {
|
|
685
|
+
try {
|
|
686
|
+
return {
|
|
687
|
+
...(identity.nlink === 1n
|
|
688
|
+
? { fallbackRoot: adjacentRecorderLockRoot(filePath, currentUserId) }
|
|
689
|
+
: {}),
|
|
690
|
+
preferred: recorderIdentityLockPath(await secureProviderRecorderLockRoot(sharedRoot, currentUserId), identity),
|
|
691
|
+
userId: currentUserId,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
if (!recorderLockRootUnavailable(error)) {
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (identity.nlink > 1n) {
|
|
701
|
+
throw new Error("Provider recorder hardlinks require a writable shared per-user lock directory.");
|
|
702
|
+
}
|
|
703
|
+
const adjacentRoot = await secureProviderRecorderLockRoot(adjacentRecorderLockRoot(filePath, currentUserId), currentUserId);
|
|
704
|
+
return {
|
|
705
|
+
preferred: recorderIdentityLockPath(adjacentRoot, identity),
|
|
706
|
+
userId: currentUserId,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
async function acquireRecorderIdentityLock(filePath, identity) {
|
|
710
|
+
const targets = await recorderIdentityLockTargets(filePath, identity);
|
|
711
|
+
try {
|
|
712
|
+
return await acquireRecorderLock(targets.preferred);
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
if (!targets.fallbackRoot || !recorderLockRootUnavailable(error)) {
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
const fallbackRoot = await secureProviderRecorderLockRoot(targets.fallbackRoot, targets.userId);
|
|
719
|
+
return await acquireRecorderLock(recorderIdentityLockPath(fallbackRoot, identity));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function acquireRecorderLock(filePath) {
|
|
723
|
+
return await lock(filePath, {
|
|
724
|
+
fs: createProcessOwnedLockFileSystem(),
|
|
315
725
|
realpath: false,
|
|
316
726
|
retries: {
|
|
317
727
|
factor: 1,
|
|
@@ -322,28 +732,97 @@ async function withRecorderLock(filePath, operation) {
|
|
|
322
732
|
stale: RECORDER_LOCK_STALE_MS,
|
|
323
733
|
update: RECORDER_LOCK_UPDATE_MS,
|
|
324
734
|
});
|
|
735
|
+
}
|
|
736
|
+
async function ensureRecorderExistsForLock(filePath) {
|
|
737
|
+
if (await readRecorderFileIdentity(filePath)) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
let handle;
|
|
741
|
+
try {
|
|
742
|
+
handle = await open(filePath, "ax", 0o600);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error.code === "EEXIST") {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
await handle.close();
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
async function releaseRecorderLocks(releases) {
|
|
754
|
+
const errors = [];
|
|
755
|
+
for (const release of releases.reverse()) {
|
|
756
|
+
try {
|
|
757
|
+
await release();
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
errors.push(error);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return errors;
|
|
764
|
+
}
|
|
765
|
+
async function withRecorderLock(filePath, operation) {
|
|
766
|
+
const releases = [];
|
|
767
|
+
let lockCreatedFile = false;
|
|
768
|
+
let lockedIdentity;
|
|
769
|
+
try {
|
|
770
|
+
releases.push(await acquireRecorderLock(filePath));
|
|
771
|
+
for (let attempt = 0; attempt < RECORDER_ROTATION_ATTEMPTS; attempt += 1) {
|
|
772
|
+
lockCreatedFile = await ensureRecorderExistsForLock(filePath);
|
|
773
|
+
const identity = await readRecorderFileIdentity(filePath);
|
|
774
|
+
if (!identity) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const releaseIdentityLock = await acquireRecorderIdentityLock(filePath, identity);
|
|
778
|
+
releases.push(releaseIdentityLock);
|
|
779
|
+
const currentIdentity = await readRecorderFileIdentity(filePath);
|
|
780
|
+
if (sameRecorderLockIdentity(identity, currentIdentity)) {
|
|
781
|
+
lockedIdentity = identity;
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
const registeredIdentityRelease = releases.pop();
|
|
785
|
+
const releaseErrors = await releaseRecorderLocks(registeredIdentityRelease ? [registeredIdentityRelease] : []);
|
|
786
|
+
if (releaseErrors.length > 0) {
|
|
787
|
+
throw recorderLockReleaseError(filePath, new RecorderRotatedError("Recorder rotated while acquiring its identity lock."), releaseErrors);
|
|
788
|
+
}
|
|
789
|
+
if (attempt + 1 >= RECORDER_ROTATION_ATTEMPTS) {
|
|
790
|
+
throw new RecorderRotatedError("Recorder kept rotating while acquiring its identity lock.");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (!lockedIdentity) {
|
|
794
|
+
throw new RecorderRotatedError("Recorder disappeared while acquiring its identity lock.");
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
const releaseErrors = await releaseRecorderLocks(releases);
|
|
799
|
+
if (releaseErrors.length > 0) {
|
|
800
|
+
throw recorderLockReleaseError(filePath, error, releaseErrors);
|
|
801
|
+
}
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
325
804
|
let operationFailed = false;
|
|
326
805
|
let operationError;
|
|
327
806
|
let result;
|
|
328
807
|
try {
|
|
329
|
-
result = await operation();
|
|
808
|
+
result = await operation(lockCreatedFile, lockedIdentity);
|
|
330
809
|
}
|
|
331
810
|
catch (error) {
|
|
332
811
|
operationFailed = true;
|
|
333
812
|
operationError = error;
|
|
334
813
|
}
|
|
335
|
-
|
|
336
|
-
await release();
|
|
337
|
-
}
|
|
338
|
-
catch (releaseError) {
|
|
339
|
-
if (operationFailed) {
|
|
340
|
-
throw recorderLockReleaseError(filePath, operationError, releaseError);
|
|
341
|
-
}
|
|
342
|
-
throw releaseError;
|
|
343
|
-
}
|
|
814
|
+
const releaseErrors = await releaseRecorderLocks(releases);
|
|
344
815
|
if (operationFailed) {
|
|
816
|
+
if (releaseErrors.length > 0) {
|
|
817
|
+
throw recorderLockReleaseError(filePath, operationError, releaseErrors);
|
|
818
|
+
}
|
|
345
819
|
throw operationError;
|
|
346
820
|
}
|
|
821
|
+
if (releaseErrors.length > 0) {
|
|
822
|
+
reportRecorderLockReleaseFailure(filePath, releaseErrors.length === 1
|
|
823
|
+
? releaseErrors[0]
|
|
824
|
+
: new AggregateError(releaseErrors, "Multiple recorder lock cleanup operations failed."));
|
|
825
|
+
}
|
|
347
826
|
return result;
|
|
348
827
|
}
|
|
349
828
|
async function serializeAppend(filePath, operation) {
|
|
@@ -354,7 +833,7 @@ async function serializeAppend(filePath, operation) {
|
|
|
354
833
|
const current = previous
|
|
355
834
|
.catch(() => { })
|
|
356
835
|
.then(async () => {
|
|
357
|
-
result = await withRecorderLock(key, async () => await operation(key, logicalPath));
|
|
836
|
+
result = await withRecorderLock(key, async (lockCreatedFile, lockedIdentity) => await operation(key, logicalPath, lockCreatedFile, lockedIdentity));
|
|
358
837
|
});
|
|
359
838
|
pendingAppends.set(key, current);
|
|
360
839
|
try {
|
|
@@ -368,6 +847,7 @@ async function serializeAppend(filePath, operation) {
|
|
|
368
847
|
}
|
|
369
848
|
}
|
|
370
849
|
async function readRecordedInboundAppend(filePath, state) {
|
|
850
|
+
const snapshot = snapshotIncrementalReadState(state);
|
|
371
851
|
let handle;
|
|
372
852
|
try {
|
|
373
853
|
handle = await open(filePath, "r");
|
|
@@ -383,7 +863,8 @@ async function readRecordedInboundAppend(filePath, state) {
|
|
|
383
863
|
}
|
|
384
864
|
try {
|
|
385
865
|
const stats = await handle.stat();
|
|
386
|
-
const
|
|
866
|
+
const identityStats = await handle.stat({ bigint: true });
|
|
867
|
+
const identity = { dev: identityStats.dev, ino: identityStats.ino };
|
|
387
868
|
const sameFile = state.identity?.dev === identity.dev && state.identity.ino === identity.ino;
|
|
388
869
|
const rotated = state.identity !== undefined && !sameFile;
|
|
389
870
|
let hasContinuity = stats.size >= state.offset;
|
|
@@ -420,12 +901,16 @@ async function readRecordedInboundAppend(filePath, state) {
|
|
|
420
901
|
state.caughtUp = reachedUnexpectedEof || position >= stats.size;
|
|
421
902
|
return events;
|
|
422
903
|
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
restoreIncrementalReadState(state, snapshot);
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
423
908
|
finally {
|
|
424
909
|
await handle.close();
|
|
425
910
|
}
|
|
426
911
|
}
|
|
427
912
|
export async function appendRecordedInbound(filePath, event) {
|
|
428
|
-
await
|
|
913
|
+
const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
|
|
429
914
|
const recorded = {
|
|
430
915
|
...event,
|
|
431
916
|
recordedAt: new Date().toISOString(),
|
|
@@ -434,18 +919,18 @@ export async function appendRecordedInbound(filePath, event) {
|
|
|
434
919
|
if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
|
|
435
920
|
throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
|
|
436
921
|
}
|
|
437
|
-
await appendJsonLine(filePath, line);
|
|
922
|
+
await appendJsonLine(filePath, line, firstCreatedDirectory);
|
|
438
923
|
return recorded;
|
|
439
924
|
}
|
|
440
925
|
export async function appendRecordedInboundBatch(filePath, events) {
|
|
441
926
|
if (events.length === 0) {
|
|
442
927
|
return [];
|
|
443
928
|
}
|
|
444
|
-
await
|
|
929
|
+
const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
|
|
445
930
|
for (let attempt = 0;; attempt++) {
|
|
446
931
|
try {
|
|
447
|
-
return await serializeAppend(filePath, async (publicationPath, logicalPath) => {
|
|
448
|
-
const generation = await prepareRecorderPathForAppend(publicationPath, logicalPath, true);
|
|
932
|
+
return await serializeAppend(filePath, async (publicationPath, logicalPath, _lockCreatedFile, lockedIdentity) => {
|
|
933
|
+
const generation = await prepareRecorderPathForAppend(publicationPath, logicalPath, true, lockedIdentity);
|
|
449
934
|
const seen = await syncRecordIdentityIndex(publicationPath);
|
|
450
935
|
const pendingIdentities = new Set();
|
|
451
936
|
const recorded = [];
|
|
@@ -470,10 +955,10 @@ export async function appendRecordedInboundBatch(filePath, events) {
|
|
|
470
955
|
if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
|
|
471
956
|
throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
|
|
472
957
|
}
|
|
473
|
-
await appendCommittedLine(publicationPath, logicalPath, line, true, generation);
|
|
958
|
+
await appendCommittedLine(publicationPath, logicalPath, line, true, generation.identity, firstCreatedDirectory);
|
|
474
959
|
await syncRecordIdentityIndex(publicationPath);
|
|
475
960
|
}
|
|
476
|
-
else if (!sameRecorderFileIdentity(generation, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
|
|
961
|
+
else if (!sameRecorderFileIdentity(generation.identity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
|
|
477
962
|
throw new RecorderRotatedError("Recorder rotated before confirming a duplicate batch.");
|
|
478
963
|
}
|
|
479
964
|
return recorded;
|
|
@@ -509,6 +994,11 @@ async function syncRecordIdentityIndex(filePath) {
|
|
|
509
994
|
if (index.readState.generation !== generation) {
|
|
510
995
|
index.seen.clear();
|
|
511
996
|
generation = index.readState.generation;
|
|
997
|
+
index.readState.caughtUp = false;
|
|
998
|
+
index.readState.continuity = Buffer.alloc(0);
|
|
999
|
+
index.readState.offset = 0;
|
|
1000
|
+
index.readState.pending = Buffer.alloc(0);
|
|
1001
|
+
continue;
|
|
512
1002
|
}
|
|
513
1003
|
for (const event of appended) {
|
|
514
1004
|
rememberRecentRecord(index.seen, event);
|