@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,48 +1,1267 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readlink, realpath, stat as statPath } from "node:fs/promises";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
2
5
|
import path from "node:path";
|
|
6
|
+
import { lock } from "proper-lockfile";
|
|
7
|
+
import { createProcessOwnedLockFileSystem } from "../platform/process-owned-lock.js";
|
|
8
|
+
import { createOwnerOnlyWindowsDirectory, readWindowsDirectoryNamespaceSecuritySnapshot, readWindowsDirectorySecuritySnapshot, } from "../platform/windows-acl.js";
|
|
9
|
+
import { isManagedRecorderDirectory } from "../platform/recorder-directory.js";
|
|
10
|
+
import { secureCachedWindowsLockRoot, } from "../platform/windows-lock-root.js";
|
|
11
|
+
export class ServerRecorderCommittedError extends AggregateError {
|
|
12
|
+
committed = true;
|
|
13
|
+
indeterminate;
|
|
14
|
+
constructor(filePath, operationError, relatedErrors = [], indeterminate = false) {
|
|
15
|
+
super([operationError, ...relatedErrors], indeterminate
|
|
16
|
+
? `Server recorder append may have been published for "${filePath}", but completion is indeterminate.`
|
|
17
|
+
: `Server recorder append committed for "${filePath}", but subsequent work failed.`, { cause: operationError });
|
|
18
|
+
this.name = "ServerRecorderCommittedError";
|
|
19
|
+
this.indeterminate = indeterminate;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
class ServerRecorderRotationError extends Error {
|
|
23
|
+
}
|
|
24
|
+
class ServerRecorderDirectorySyncError extends AggregateError {
|
|
25
|
+
syncError;
|
|
26
|
+
closeError;
|
|
27
|
+
constructor(syncError, closeError) {
|
|
28
|
+
super([syncError, closeError], "Server recorder directory sync and handle cleanup both failed.", { cause: syncError });
|
|
29
|
+
this.syncError = syncError;
|
|
30
|
+
this.closeError = closeError;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
3
33
|
const pendingAppends = new Map();
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
34
|
+
const pendingAdmissions = new Map();
|
|
35
|
+
const pendingLogicalObservers = new Map();
|
|
36
|
+
const pendingPublicationObservers = new Map();
|
|
37
|
+
const securedWindowsLockRoots = new Map();
|
|
38
|
+
const securedWindowsRecorderDirectories = new Map();
|
|
39
|
+
const MAX_RECOVERY_VALIDATION_BYTES = 64 * 1024 * 1024;
|
|
40
|
+
const MAX_RECOVERY_SCAN_BYTES = MAX_RECOVERY_VALIDATION_BYTES + 1;
|
|
41
|
+
const RECORDER_LOCK_RETRY_MS = 100;
|
|
42
|
+
const RECORDER_LOCK_STALE_MS = 30_000;
|
|
43
|
+
const RECORDER_LOCK_UPDATE_MS = 10_000;
|
|
44
|
+
const RECORDER_LOCK_WAIT_MARGIN_MS = 5_000;
|
|
45
|
+
const RECORDER_LOCK_DIRECTORY_ENV = "CRABLINE_RECORDER_LOCK_DIR";
|
|
46
|
+
const RECORDER_PATH_ATTEMPTS = 3;
|
|
47
|
+
const RECORDER_ROTATION_ATTEMPTS = 3;
|
|
48
|
+
const TAIL_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
49
|
+
async function readBufferAt(file, length, position) {
|
|
50
|
+
const buffer = Buffer.alloc(length);
|
|
51
|
+
let bytesRead = 0;
|
|
52
|
+
while (bytesRead < length) {
|
|
53
|
+
const result = await file.read(buffer, bytesRead, length - bytesRead, position + bytesRead);
|
|
54
|
+
if (result.bytesRead === 0) {
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
bytesRead += result.bytesRead;
|
|
58
|
+
}
|
|
59
|
+
return buffer.subarray(0, bytesRead);
|
|
60
|
+
}
|
|
61
|
+
async function appendRecorderText(file, contents, position) {
|
|
62
|
+
if (process.platform !== "win32") {
|
|
63
|
+
await file.appendFile(contents, { encoding: "utf8" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const buffer = Buffer.from(contents, "utf8");
|
|
67
|
+
let written = 0;
|
|
68
|
+
while (written < buffer.length) {
|
|
69
|
+
const result = await file.write(buffer, written, buffer.length - written, position + written);
|
|
70
|
+
if (result.bytesWritten === 0) {
|
|
71
|
+
throw new Error("Server recorder append made no progress.");
|
|
72
|
+
}
|
|
73
|
+
written += result.bytesWritten;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function findIncompleteTailStart(file, fileSize) {
|
|
77
|
+
let position = fileSize;
|
|
78
|
+
while (position > 0) {
|
|
79
|
+
const scannedBytes = fileSize - position;
|
|
80
|
+
const remainingScanBytes = MAX_RECOVERY_SCAN_BYTES - scannedBytes;
|
|
81
|
+
if (remainingScanBytes <= 0) {
|
|
82
|
+
throw recoveryValidationLimitError();
|
|
83
|
+
}
|
|
84
|
+
const chunkStart = Math.max(0, position - Math.min(TAIL_SCAN_CHUNK_BYTES, remainingScanBytes));
|
|
85
|
+
const chunk = await readBufferAt(file, position - chunkStart, chunkStart);
|
|
86
|
+
const lastNewline = chunk.lastIndexOf(0x0a);
|
|
87
|
+
if (lastNewline >= 0) {
|
|
88
|
+
return chunkStart + lastNewline + 1;
|
|
89
|
+
}
|
|
90
|
+
if (chunk.length === 0) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
position = chunkStart;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
function recoveryValidationLimitError() {
|
|
98
|
+
return new Error("Server recorder final record is too large to validate safely; refusing to modify it.");
|
|
99
|
+
}
|
|
100
|
+
function recorderFileSize(size) {
|
|
101
|
+
const normalized = typeof size === "bigint" ? Number(size) : size;
|
|
102
|
+
if (!Number.isSafeInteger(normalized) || normalized < 0) {
|
|
103
|
+
throw new Error("Server recorder file size exceeds the supported range.");
|
|
104
|
+
}
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
async function openRecorderFile(filePath) {
|
|
108
|
+
try {
|
|
109
|
+
return {
|
|
110
|
+
created: true,
|
|
111
|
+
file: await open(filePath, "ax+", 0o600),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST") {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
created: false,
|
|
120
|
+
file: await open(filePath,
|
|
121
|
+
// Windows callers secure the publication directory before reaching this open.
|
|
122
|
+
process.platform === "win32"
|
|
123
|
+
? "r+"
|
|
124
|
+
: fsConstants.O_RDWR |
|
|
125
|
+
fsConstants.O_APPEND |
|
|
126
|
+
fsConstants.O_NONBLOCK |
|
|
127
|
+
fsConstants.O_NOFOLLOW, 0o600),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function truncateRecorderFile(file, filePath, expectedIdentity, length) {
|
|
132
|
+
if (process.platform !== "win32") {
|
|
133
|
+
await file.truncate(length);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
let repairFile;
|
|
137
|
+
try {
|
|
138
|
+
repairFile = await open(filePath, "r+");
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
const code = error.code;
|
|
142
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR" || code === "ELOOP") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
let result = false;
|
|
148
|
+
let operationFailed = false;
|
|
149
|
+
let operationError;
|
|
150
|
+
try {
|
|
151
|
+
if (requireRecorderIdentity(await repairFile.stat({ bigint: true })) === expectedIdentity) {
|
|
152
|
+
await repairFile.truncate(length);
|
|
153
|
+
await repairFile.sync();
|
|
154
|
+
result = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
operationFailed = true;
|
|
159
|
+
operationError = error;
|
|
160
|
+
}
|
|
161
|
+
let closeFailed = false;
|
|
162
|
+
let closeError;
|
|
163
|
+
try {
|
|
164
|
+
await repairFile.close();
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
closeFailed = true;
|
|
168
|
+
closeError = error;
|
|
169
|
+
}
|
|
170
|
+
if (closeFailed) {
|
|
171
|
+
if (operationFailed) {
|
|
172
|
+
throw new AggregateError([operationError, closeError], "Server recorder tail repair and repair-handle close both failed.", { cause: closeError });
|
|
173
|
+
}
|
|
174
|
+
throw closeError;
|
|
175
|
+
}
|
|
176
|
+
if (operationFailed) {
|
|
177
|
+
throw operationError;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
async function syncDirectory(directoryPath) {
|
|
182
|
+
if (process.platform === "win32") {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const directory = await open(directoryPath, "r");
|
|
186
|
+
let syncError;
|
|
187
|
+
try {
|
|
188
|
+
await directory.sync();
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
syncError = error;
|
|
192
|
+
}
|
|
193
|
+
let closeError;
|
|
194
|
+
try {
|
|
195
|
+
await directory.close();
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
closeError = error;
|
|
199
|
+
}
|
|
200
|
+
if (syncError !== undefined && closeError !== undefined) {
|
|
201
|
+
throw new ServerRecorderDirectorySyncError(syncError, closeError);
|
|
202
|
+
}
|
|
203
|
+
if (syncError !== undefined) {
|
|
204
|
+
throw syncError;
|
|
205
|
+
}
|
|
206
|
+
if (closeError !== undefined) {
|
|
207
|
+
throw closeError;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function syncRecorderPathAncestry(filePath, firstCreatedDirectory) {
|
|
211
|
+
if (process.platform === "win32") {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
215
|
+
let currentPath = resolvedFilePath;
|
|
216
|
+
const syncThroughPath = firstCreatedDirectory === undefined ? undefined : path.resolve(firstCreatedDirectory);
|
|
217
|
+
for (;;) {
|
|
218
|
+
const directoryPath = path.dirname(currentPath);
|
|
219
|
+
const mandatory = syncThroughPath !== undefined || currentPath === resolvedFilePath;
|
|
220
|
+
try {
|
|
221
|
+
await syncDirectory(directoryPath);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
const code = error.code;
|
|
225
|
+
if (!mandatory && (code === "EACCES" || code === "EPERM")) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
if (syncThroughPath === undefined) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (currentPath === syncThroughPath) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (path.dirname(directoryPath) === directoryPath) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
currentPath = directoryPath;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function recorderIdentity(stats) {
|
|
243
|
+
if (stats.dev === undefined || stats.ino === undefined) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return `${stats.dev}:${stats.ino}`;
|
|
247
|
+
}
|
|
248
|
+
function requireRecorderFileIdentity(stats) {
|
|
249
|
+
if (stats.isFile?.() === false) {
|
|
250
|
+
throw new Error("Server recorder path is not a regular file.");
|
|
251
|
+
}
|
|
252
|
+
if (stats.dev === undefined || stats.ino === undefined) {
|
|
253
|
+
throw new Error("Server recorder file identity is unavailable.");
|
|
254
|
+
}
|
|
255
|
+
if (stats.nlink === undefined) {
|
|
256
|
+
throw new Error("Server recorder file link count is unavailable.");
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
dev: stats.dev,
|
|
260
|
+
ino: stats.ino,
|
|
261
|
+
nlink: stats.nlink,
|
|
262
|
+
};
|
|
7
263
|
}
|
|
8
|
-
async function
|
|
9
|
-
|
|
264
|
+
async function recorderPathHasIdentity(filePath, expectedIdentity) {
|
|
265
|
+
try {
|
|
266
|
+
const stats = await statPath(filePath, { bigint: true });
|
|
267
|
+
if (stats.isFile?.() === false) {
|
|
268
|
+
throw new Error(`Server recorder path is not a regular file: ${filePath}`);
|
|
269
|
+
}
|
|
270
|
+
return recorderIdentity(stats) === expectedIdentity;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code === "ENOENT") {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function recorderWindowsPathIsRegular(filePath) {
|
|
280
|
+
try {
|
|
281
|
+
const stats = await lstat(filePath, { bigint: true });
|
|
282
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
283
|
+
throw new Error(`Server recorder path is not a regular file: ${filePath}`);
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error.code === "ENOENT") {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function requireRecorderIdentity(stats) {
|
|
295
|
+
const identity = recorderIdentity(stats);
|
|
296
|
+
if (identity === undefined) {
|
|
297
|
+
throw new Error("Server recorder file identity is unavailable.");
|
|
298
|
+
}
|
|
299
|
+
return identity;
|
|
300
|
+
}
|
|
301
|
+
function recorderLockReleaseError(filePath, operationError, releaseError) {
|
|
302
|
+
if (operationError instanceof ServerRecorderCommittedError) {
|
|
303
|
+
return new ServerRecorderCommittedError(filePath, operationError, [releaseError], operationError.indeterminate);
|
|
304
|
+
}
|
|
305
|
+
return new AggregateError([operationError, releaseError], `Server recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
|
|
306
|
+
}
|
|
307
|
+
function recorderLockAcquisitionReleaseError(filePath, acquisitionError, releaseError) {
|
|
308
|
+
return new AggregateError([acquisitionError, releaseError], `Server recorder shared lock acquisition and local lock release both failed for "${filePath}".`, { cause: acquisitionError });
|
|
309
|
+
}
|
|
310
|
+
function recorderIdentityLockAcquisitionReleaseError(acquisitionError, releaseErrors) {
|
|
311
|
+
return new AggregateError([acquisitionError, ...releaseErrors], "Server recorder identity lock acquisition and cleanup both failed.", { cause: acquisitionError });
|
|
312
|
+
}
|
|
313
|
+
function isRecorderLockContention(error) {
|
|
314
|
+
return (typeof error === "object" &&
|
|
315
|
+
error !== null &&
|
|
316
|
+
error.code === "ELOCKED");
|
|
317
|
+
}
|
|
318
|
+
async function verifyPrivateServerRecorderDirectory(directory, currentUserId, requirePrivateMode) {
|
|
319
|
+
const handle = await open(directory, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
320
|
+
try {
|
|
321
|
+
const identity = await handle.stat({ bigint: true });
|
|
322
|
+
const current = await lstat(directory, { bigint: true });
|
|
323
|
+
if (!identity.isDirectory() ||
|
|
324
|
+
!current.isDirectory() ||
|
|
325
|
+
current.isSymbolicLink() ||
|
|
326
|
+
identity.dev !== current.dev ||
|
|
327
|
+
identity.ino !== current.ino ||
|
|
328
|
+
identity.uid !== BigInt(currentUserId) ||
|
|
329
|
+
current.uid !== BigInt(currentUserId) ||
|
|
330
|
+
(!requirePrivateMode && (identity.mode & 18n) !== 0n)) {
|
|
331
|
+
throw new Error("Server recorder lock directory is not privately owned.");
|
|
332
|
+
}
|
|
333
|
+
if (requirePrivateMode && (identity.mode & 511n) !== 448n) {
|
|
334
|
+
await handle.chmod(0o700);
|
|
335
|
+
const secured = await handle.stat({ bigint: true });
|
|
336
|
+
if ((secured.mode & 511n) !== 448n) {
|
|
337
|
+
throw new Error("Server recorder lock directory permissions are not private.");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await handle.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function securePrivateServerRecorderLockRoot(baseDirectory, components, currentUserId) {
|
|
346
|
+
await verifyPrivateServerRecorderLockAncestry(baseDirectory, currentUserId);
|
|
347
|
+
let currentPath = await realpath(baseDirectory);
|
|
348
|
+
await verifyPrivateServerRecorderLockAncestry(currentPath, currentUserId);
|
|
349
|
+
await verifyPrivateServerRecorderDirectory(currentPath, currentUserId, false);
|
|
350
|
+
for (const component of components) {
|
|
351
|
+
currentPath = path.join(currentPath, component);
|
|
352
|
+
let created = false;
|
|
353
|
+
try {
|
|
354
|
+
await mkdir(currentPath, { mode: 0o700 });
|
|
355
|
+
created = true;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error.code !== "EEXIST") {
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (created) {
|
|
363
|
+
await chmod(currentPath, 0o700);
|
|
364
|
+
}
|
|
365
|
+
await verifyPrivateServerRecorderDirectory(currentPath, currentUserId, true);
|
|
366
|
+
}
|
|
367
|
+
return currentPath;
|
|
368
|
+
}
|
|
369
|
+
async function verifyPrivateServerRecorderLockAncestry(directory, currentUserId) {
|
|
370
|
+
let currentPath = path.resolve(directory);
|
|
371
|
+
for (;;) {
|
|
372
|
+
const current = await lstat(currentPath, { bigint: true });
|
|
373
|
+
const ownerIsTrusted = current.uid === BigInt(currentUserId) || current.uid === 0n;
|
|
374
|
+
if (current.isSymbolicLink()) {
|
|
375
|
+
if (!ownerIsTrusted) {
|
|
376
|
+
throw new Error("Server recorder lock directory parent namespace is not trusted.");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
const peerWritable = (current.mode & 18n) !== 0n;
|
|
381
|
+
const sticky = (current.mode & 512n) !== 0n;
|
|
382
|
+
if (!current.isDirectory() || !ownerIsTrusted || (peerWritable && !sticky)) {
|
|
383
|
+
throw new Error("Server recorder lock directory parent namespace is not trusted.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const parent = path.dirname(currentPath);
|
|
387
|
+
if (parent === currentPath) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
currentPath = parent;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function serverRecorderUnixLockRoot(currentUserId) {
|
|
394
|
+
let homeDirectory;
|
|
395
|
+
try {
|
|
396
|
+
homeDirectory = userInfo().homedir;
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
throw new Error(`Server recorder identity locking requires an OS account home or ${RECORDER_LOCK_DIRECTORY_ENV}.`, { cause: error });
|
|
400
|
+
}
|
|
401
|
+
await verifyPrivateServerRecorderLockAncestry(homeDirectory, currentUserId);
|
|
402
|
+
const cacheDirectory = path.join(homeDirectory, ".cache");
|
|
403
|
+
let cacheCreated = false;
|
|
404
|
+
try {
|
|
405
|
+
await mkdir(cacheDirectory, { mode: 0o700 });
|
|
406
|
+
cacheCreated = true;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (error.code !== "EEXIST") {
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (cacheCreated) {
|
|
414
|
+
await chmod(cacheDirectory, 0o700);
|
|
415
|
+
}
|
|
416
|
+
return await securePrivateServerRecorderLockRoot(cacheDirectory, ["crabline", "locks", "server-recorder"], currentUserId);
|
|
417
|
+
}
|
|
418
|
+
async function secureRecorderLockArtifact(lockTarget, expectedIdentity) {
|
|
419
|
+
const lockDirectory = `${lockTarget}.lock`;
|
|
420
|
+
let handle;
|
|
421
|
+
try {
|
|
422
|
+
handle = await open(lockDirectory, process.platform === "win32"
|
|
423
|
+
? "r"
|
|
424
|
+
: fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
throw new Error("Server recorder shared lock artifact changed during acquisition.", {
|
|
428
|
+
cause: error,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const identity = await handle.stat({ bigint: true });
|
|
433
|
+
const current = await lstat(lockDirectory, { bigint: true });
|
|
434
|
+
if (!identity.isDirectory() ||
|
|
435
|
+
!current.isDirectory() ||
|
|
436
|
+
current.isSymbolicLink() ||
|
|
437
|
+
identity.dev !== expectedIdentity.dev ||
|
|
438
|
+
identity.ino !== expectedIdentity.ino ||
|
|
439
|
+
identity.dev !== current.dev ||
|
|
440
|
+
identity.ino !== current.ino) {
|
|
441
|
+
throw new Error("Server recorder shared lock artifact changed during acquisition.");
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
async assertIdentity() {
|
|
445
|
+
const held = await handle.stat({ bigint: true });
|
|
446
|
+
const candidate = await lstat(lockDirectory, { bigint: true });
|
|
447
|
+
if (!held.isDirectory() ||
|
|
448
|
+
!candidate.isDirectory() ||
|
|
449
|
+
candidate.isSymbolicLink() ||
|
|
450
|
+
held.dev !== identity.dev ||
|
|
451
|
+
held.ino !== identity.ino ||
|
|
452
|
+
candidate.dev !== identity.dev ||
|
|
453
|
+
candidate.ino !== identity.ino) {
|
|
454
|
+
throw new Error("Server recorder shared lock artifact changed after acquisition.");
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
async close() {
|
|
458
|
+
await handle.close();
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
try {
|
|
464
|
+
await handle.close();
|
|
465
|
+
}
|
|
466
|
+
catch (closeError) {
|
|
467
|
+
const aggregateError = new AggregateError([error, closeError], "Server recorder shared lock validation and handle cleanup both failed.");
|
|
468
|
+
aggregateError.cause = closeError;
|
|
469
|
+
throw aggregateError;
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function secureRecorderLockRoot(root) {
|
|
475
|
+
const configuredRoot = path.resolve(root);
|
|
476
|
+
let windowsNamespace;
|
|
477
|
+
let currentUserId;
|
|
478
|
+
if (process.platform === "win32") {
|
|
479
|
+
const namespacePath = path.dirname(configuredRoot);
|
|
480
|
+
// Writers may share the root ACL, but its parent namespace must not permit
|
|
481
|
+
// an untrusted principal to replace that root.
|
|
482
|
+
windowsNamespace = {
|
|
483
|
+
path: namespacePath,
|
|
484
|
+
snapshot: await readWindowsDirectoryNamespaceSecuritySnapshot(namespacePath),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
currentUserId = process.geteuid?.();
|
|
489
|
+
if (currentUserId === undefined) {
|
|
490
|
+
throw new Error("Server recorder identity locking requires a current user id.");
|
|
491
|
+
}
|
|
492
|
+
await verifyPrivateServerRecorderLockAncestry(path.dirname(configuredRoot), currentUserId);
|
|
493
|
+
}
|
|
494
|
+
const canonicalRoot = await realpath(configuredRoot);
|
|
495
|
+
if (path.relative(configuredRoot, canonicalRoot) !== "") {
|
|
496
|
+
throw new Error(`${RECORDER_LOCK_DIRECTORY_ENV} must name a canonical directory without symlink components.`);
|
|
497
|
+
}
|
|
498
|
+
if (currentUserId !== undefined) {
|
|
499
|
+
await verifyPrivateServerRecorderLockAncestry(path.dirname(canonicalRoot), currentUserId);
|
|
500
|
+
}
|
|
501
|
+
const handle = await open(canonicalRoot, process.platform === "win32"
|
|
502
|
+
? "r"
|
|
503
|
+
: fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
504
|
+
try {
|
|
505
|
+
const identity = await handle.stat({ bigint: true });
|
|
506
|
+
const current = await lstat(canonicalRoot, { bigint: true });
|
|
507
|
+
if (!identity.isDirectory() ||
|
|
508
|
+
!current.isDirectory() ||
|
|
509
|
+
current.isSymbolicLink() ||
|
|
510
|
+
identity.dev !== current.dev ||
|
|
511
|
+
identity.ino !== current.ino) {
|
|
512
|
+
throw new Error("Server recorder lock directory changed while opening it.");
|
|
513
|
+
}
|
|
514
|
+
if (currentUserId !== undefined &&
|
|
515
|
+
((identity.mode & 2n) !== 0n || (current.mode & 2n) !== 0n)) {
|
|
516
|
+
throw new Error("Server recorder shared lock directory is writable by every local user.");
|
|
517
|
+
}
|
|
518
|
+
if (windowsNamespace) {
|
|
519
|
+
const currentNamespace = await readWindowsDirectoryNamespaceSecuritySnapshot(windowsNamespace.path);
|
|
520
|
+
if (currentNamespace.identity !== windowsNamespace.snapshot.identity ||
|
|
521
|
+
currentNamespace.securityDescriptor !== windowsNamespace.snapshot.securityDescriptor) {
|
|
522
|
+
throw new Error("Server recorder shared lock directory changed while opening it.");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
async assertIdentity() {
|
|
527
|
+
if (currentUserId !== undefined) {
|
|
528
|
+
await verifyPrivateServerRecorderLockAncestry(path.dirname(canonicalRoot), currentUserId);
|
|
529
|
+
}
|
|
530
|
+
const held = await handle.stat({ bigint: true });
|
|
531
|
+
const candidate = await lstat(canonicalRoot, { bigint: true });
|
|
532
|
+
if (!held.isDirectory() ||
|
|
533
|
+
!candidate.isDirectory() ||
|
|
534
|
+
candidate.isSymbolicLink() ||
|
|
535
|
+
held.dev !== identity.dev ||
|
|
536
|
+
held.ino !== identity.ino ||
|
|
537
|
+
candidate.dev !== identity.dev ||
|
|
538
|
+
candidate.ino !== identity.ino ||
|
|
539
|
+
(currentUserId !== undefined &&
|
|
540
|
+
((held.mode & 2n) !== 0n || (candidate.mode & 2n) !== 0n))) {
|
|
541
|
+
throw new Error("Server recorder shared lock directory changed after validation.");
|
|
542
|
+
}
|
|
543
|
+
if (windowsNamespace) {
|
|
544
|
+
const currentNamespace = await readWindowsDirectoryNamespaceSecuritySnapshot(windowsNamespace.path);
|
|
545
|
+
if (currentNamespace.identity !== windowsNamespace.snapshot.identity ||
|
|
546
|
+
currentNamespace.securityDescriptor !== windowsNamespace.snapshot.securityDescriptor) {
|
|
547
|
+
throw new Error("Server recorder shared lock directory changed after validation.");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
async close() {
|
|
552
|
+
await handle.close();
|
|
553
|
+
},
|
|
554
|
+
root: canonicalRoot,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
try {
|
|
559
|
+
await handle.close();
|
|
560
|
+
}
|
|
561
|
+
catch (closeError) {
|
|
562
|
+
const aggregateError = new AggregateError([error, closeError], "Server recorder lock-root validation and handle cleanup both failed.");
|
|
563
|
+
aggregateError.cause = closeError;
|
|
564
|
+
throw aggregateError;
|
|
565
|
+
}
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function recorderIdentityLockPath(root, identity) {
|
|
570
|
+
return path.join(root, `recorder-${identity.dev}-${identity.ino}`);
|
|
571
|
+
}
|
|
572
|
+
function recorderSharedIdentityLockPath(root, identity) {
|
|
573
|
+
// A shared inode can report different device numbers across mount namespaces.
|
|
574
|
+
return path.join(root, `recorder-${identity.ino}`);
|
|
575
|
+
}
|
|
576
|
+
function deduplicateRecorderLockTargets(targets) {
|
|
577
|
+
const retained = new Map();
|
|
578
|
+
for (const target of targets) {
|
|
579
|
+
const resolved = path.resolve(target.path);
|
|
580
|
+
const key = process.platform === "win32" ? path.win32.normalize(resolved).toLowerCase() : resolved;
|
|
581
|
+
const existing = retained.get(key);
|
|
582
|
+
if (existing) {
|
|
583
|
+
for (const namespace of target.namespaces) {
|
|
584
|
+
if (!existing.namespaces.includes(namespace)) {
|
|
585
|
+
existing.namespaces.push(namespace);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
retained.set(key, { namespaces: [...target.namespaces], path: target.path });
|
|
591
|
+
}
|
|
592
|
+
return [...retained.values()];
|
|
593
|
+
}
|
|
594
|
+
export async function secureServerRecorderWindowsLockRoot(root, options = {}) {
|
|
595
|
+
const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
|
|
596
|
+
return secureCachedWindowsLockRoot({
|
|
597
|
+
cache: securedWindowsLockRoots,
|
|
598
|
+
cacheKey,
|
|
599
|
+
createDirectory: () => (options.createWindowsDirectory ?? createOwnerOnlyWindowsDirectory)(root),
|
|
600
|
+
errorPrefix: "Server recorder Windows lock root",
|
|
601
|
+
readSecuritySnapshot: () => (options.readWindowsDirectorySecuritySnapshot ?? readWindowsDirectorySecuritySnapshot)(root),
|
|
602
|
+
root,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
async function secureServerRecorderWindowsDirectory(root) {
|
|
606
|
+
const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
|
|
607
|
+
return secureCachedWindowsLockRoot({
|
|
608
|
+
cache: securedWindowsRecorderDirectories,
|
|
609
|
+
cacheKey,
|
|
610
|
+
createDirectory: async () => {
|
|
611
|
+
try {
|
|
612
|
+
const identity = await lstat(root);
|
|
613
|
+
if (!identity.isDirectory() || identity.isSymbolicLink()) {
|
|
614
|
+
throw new Error("Server recorder Windows directory is not a private directory.");
|
|
615
|
+
}
|
|
616
|
+
if (isManagedRecorderDirectory(root)) {
|
|
617
|
+
await createOwnerOnlyWindowsDirectory(root);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
if (error.code !== "ENOENT") {
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
await createOwnerOnlyWindowsDirectory(root);
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
errorPrefix: "Server recorder Windows directory",
|
|
628
|
+
readSecuritySnapshot: () => readWindowsDirectoryNamespaceSecuritySnapshot(root),
|
|
629
|
+
root,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
function serverRecorderWindowsLockRoot() {
|
|
633
|
+
const configuredRoot = process.env.LOCALAPPDATA?.trim();
|
|
634
|
+
const localAppData = configuredRoot && path.isAbsolute(configuredRoot)
|
|
635
|
+
? configuredRoot
|
|
636
|
+
: path.join(userInfo().homedir, "AppData", "Local");
|
|
637
|
+
return path.join(localAppData, "Crabline", "locks", "server-recorder");
|
|
638
|
+
}
|
|
639
|
+
export function serverRecorderWindowsLockPath(root, filePath) {
|
|
640
|
+
const canonicalPath = path.win32.normalize(filePath).toLowerCase();
|
|
641
|
+
const fingerprint = createHash("sha256").update(canonicalPath).digest("hex");
|
|
642
|
+
return path.join(root, `recorder-${fingerprint}`);
|
|
643
|
+
}
|
|
644
|
+
async function recorderProcessLockTarget(filePath) {
|
|
645
|
+
if (process.platform !== "win32") {
|
|
646
|
+
return filePath;
|
|
647
|
+
}
|
|
648
|
+
const root = await secureServerRecorderWindowsLockRoot(serverRecorderWindowsLockRoot());
|
|
649
|
+
return serverRecorderWindowsLockPath(root, filePath);
|
|
650
|
+
}
|
|
651
|
+
async function recorderLocalIdentityLockTarget(fileIdentity) {
|
|
652
|
+
if (process.platform === "win32") {
|
|
653
|
+
const root = await secureServerRecorderWindowsLockRoot(serverRecorderWindowsLockRoot());
|
|
654
|
+
return recorderIdentityLockPath(root, fileIdentity);
|
|
655
|
+
}
|
|
656
|
+
const currentUserId = process.geteuid?.();
|
|
657
|
+
if (currentUserId === undefined) {
|
|
658
|
+
throw new Error("Server recorder identity locking requires a current user id.");
|
|
659
|
+
}
|
|
660
|
+
return recorderIdentityLockPath(await serverRecorderUnixLockRoot(currentUserId), fileIdentity);
|
|
661
|
+
}
|
|
662
|
+
async function recorderIdentityLockTargets(fileIdentity) {
|
|
663
|
+
const configuredRoot = process.env[RECORDER_LOCK_DIRECTORY_ENV]?.trim();
|
|
664
|
+
if (configuredRoot) {
|
|
665
|
+
if (!path.isAbsolute(configuredRoot)) {
|
|
666
|
+
throw new Error(`${RECORDER_LOCK_DIRECTORY_ENV} must be an absolute path.`);
|
|
667
|
+
}
|
|
668
|
+
const sharedRoot = await secureRecorderLockRoot(configuredRoot);
|
|
669
|
+
try {
|
|
670
|
+
// Lock every configured writer in the shared namespace before a first hardlink can appear.
|
|
671
|
+
return deduplicateRecorderLockTargets([
|
|
672
|
+
{ namespaces: [], path: await recorderLocalIdentityLockTarget(fileIdentity) },
|
|
673
|
+
{
|
|
674
|
+
namespaces: [sharedRoot],
|
|
675
|
+
path: recorderSharedIdentityLockPath(sharedRoot.root, fileIdentity),
|
|
676
|
+
},
|
|
677
|
+
]);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
await sharedRoot.close();
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (fileIdentity.nlink > 1n) {
|
|
685
|
+
throw new Error(`Server recorder hardlinks require ${RECORDER_LOCK_DIRECTORY_ENV} to name one shared writable lock directory for every writer.`);
|
|
686
|
+
}
|
|
687
|
+
return deduplicateRecorderLockTargets([
|
|
688
|
+
{ namespaces: [], path: await recorderLocalIdentityLockTarget(fileIdentity) },
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
async function acquireSingleRecorderLock(filePath) {
|
|
692
|
+
const deadline = performance.now() + RECORDER_LOCK_STALE_MS + RECORDER_LOCK_WAIT_MARGIN_MS;
|
|
693
|
+
const lockDirectory = path.resolve(`${filePath}.lock`);
|
|
694
|
+
let artifactIdentity;
|
|
695
|
+
const lockFileSystem = createProcessOwnedLockFileSystem({
|
|
696
|
+
onDirectoryOwned(directoryPath, identity) {
|
|
697
|
+
if (path.resolve(directoryPath) === lockDirectory) {
|
|
698
|
+
artifactIdentity = identity;
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
for (;;) {
|
|
703
|
+
try {
|
|
704
|
+
const release = await lock(filePath, {
|
|
705
|
+
fs: lockFileSystem,
|
|
706
|
+
realpath: false,
|
|
707
|
+
retries: 0,
|
|
708
|
+
stale: RECORDER_LOCK_STALE_MS,
|
|
709
|
+
update: RECORDER_LOCK_UPDATE_MS,
|
|
710
|
+
});
|
|
711
|
+
return { artifactIdentity, release };
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
if (!isRecorderLockContention(error)) {
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
const remainingMs = deadline - performance.now();
|
|
718
|
+
if (remainingMs <= 0) {
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(RECORDER_LOCK_RETRY_MS, remainingMs)));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function releaseRecorderLockSet(releases) {
|
|
726
|
+
const errors = [];
|
|
727
|
+
for (const release of releases.reverse()) {
|
|
728
|
+
try {
|
|
729
|
+
await release();
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
errors.push(error);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (errors.length === 1) {
|
|
736
|
+
throw errors[0];
|
|
737
|
+
}
|
|
738
|
+
if (errors.length > 1) {
|
|
739
|
+
throw new AggregateError(errors, "Multiple server recorder locks failed to release.");
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
async function acquireRecorderLock(filePath, useProcessLocalWindowsTarget = true) {
|
|
743
|
+
if (process.platform !== "win32" || !useProcessLocalWindowsTarget) {
|
|
744
|
+
return await acquireSingleRecorderLock(filePath);
|
|
745
|
+
}
|
|
746
|
+
const localLock = await acquireSingleRecorderLock(await recorderProcessLockTarget(filePath));
|
|
747
|
+
try {
|
|
748
|
+
const sharedLock = await acquireSingleRecorderLock(filePath);
|
|
749
|
+
return {
|
|
750
|
+
artifactIdentity: sharedLock.artifactIdentity,
|
|
751
|
+
async release() {
|
|
752
|
+
await releaseRecorderLockSet([localLock.release, sharedLock.release]);
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
const [releaseResult] = await Promise.allSettled([localLock.release()]);
|
|
758
|
+
if (releaseResult?.status === "rejected") {
|
|
759
|
+
throw recorderLockAcquisitionReleaseError(filePath, error, releaseResult.reason);
|
|
760
|
+
}
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function acquireRecorderIdentityLock(identity) {
|
|
765
|
+
const releases = [];
|
|
766
|
+
const artifacts = new Set();
|
|
767
|
+
const namespaces = new Set();
|
|
768
|
+
const closeBindings = async () => {
|
|
769
|
+
const closeResults = await Promise.allSettled([...artifacts, ...namespaces].map(async (binding) => binding.close()));
|
|
770
|
+
return closeResults
|
|
771
|
+
.filter((result) => result.status === "rejected")
|
|
772
|
+
.map((result) => result.reason);
|
|
773
|
+
};
|
|
774
|
+
const assertNamespaces = async () => {
|
|
775
|
+
for (const namespace of namespaces) {
|
|
776
|
+
await namespace.assertIdentity();
|
|
777
|
+
}
|
|
778
|
+
for (const artifact of artifacts) {
|
|
779
|
+
await artifact.assertIdentity();
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
const targets = await recorderIdentityLockTargets(identity);
|
|
784
|
+
for (const target of targets) {
|
|
785
|
+
for (const namespace of target.namespaces) {
|
|
786
|
+
namespaces.add(namespace);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const target of targets) {
|
|
790
|
+
for (const namespace of target.namespaces) {
|
|
791
|
+
await namespace.assertIdentity();
|
|
792
|
+
}
|
|
793
|
+
const acquired = await acquireRecorderLock(target.path, false);
|
|
794
|
+
try {
|
|
795
|
+
if (target.namespaces.length > 0) {
|
|
796
|
+
if (!acquired.artifactIdentity) {
|
|
797
|
+
throw new Error("Server recorder shared lock identity is unavailable after acquisition.");
|
|
798
|
+
}
|
|
799
|
+
artifacts.add(await secureRecorderLockArtifact(target.path, acquired.artifactIdentity));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
try {
|
|
804
|
+
await acquired.release();
|
|
805
|
+
}
|
|
806
|
+
catch (releaseError) {
|
|
807
|
+
throw recorderLockAcquisitionReleaseError(target.path, error, releaseError);
|
|
808
|
+
}
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
releases.push(acquired.release);
|
|
812
|
+
for (const namespace of target.namespaces) {
|
|
813
|
+
await namespace.assertIdentity();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
const releaseResults = await Promise.allSettled(releases.reverse().map(async (release) => release()));
|
|
819
|
+
const releaseErrors = releaseResults
|
|
820
|
+
.filter((result) => result.status === "rejected")
|
|
821
|
+
.map((result) => result.reason);
|
|
822
|
+
const cleanupErrors = [...releaseErrors, ...(await closeBindings())];
|
|
823
|
+
if (cleanupErrors.length > 0) {
|
|
824
|
+
throw recorderIdentityLockAcquisitionReleaseError(error, cleanupErrors);
|
|
825
|
+
}
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
assertNamespace: assertNamespaces,
|
|
830
|
+
async release() {
|
|
831
|
+
let releaseError;
|
|
832
|
+
try {
|
|
833
|
+
await releaseRecorderLockSet(releases);
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
releaseError = error;
|
|
837
|
+
}
|
|
838
|
+
const closeErrors = await closeBindings();
|
|
839
|
+
if (releaseError !== undefined) {
|
|
840
|
+
if (closeErrors.length > 0) {
|
|
841
|
+
throw new AggregateError([releaseError, ...closeErrors], "Server recorder identity lock and namespace cleanup both failed.", { cause: releaseError });
|
|
842
|
+
}
|
|
843
|
+
throw releaseError;
|
|
844
|
+
}
|
|
845
|
+
if (closeErrors.length === 1) {
|
|
846
|
+
throw closeErrors[0];
|
|
847
|
+
}
|
|
848
|
+
if (closeErrors.length > 1) {
|
|
849
|
+
throw new AggregateError(closeErrors, "Multiple server recorder lock namespaces failed to close.");
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async function withRecorderLock(lockPath, logicalPath, operation) {
|
|
855
|
+
const { release } = await acquireRecorderLock(lockPath);
|
|
856
|
+
let operationFailed = false;
|
|
857
|
+
let operationError;
|
|
858
|
+
let result;
|
|
859
|
+
try {
|
|
860
|
+
result = await operation();
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
operationFailed = true;
|
|
864
|
+
operationError = error;
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
await release();
|
|
868
|
+
}
|
|
869
|
+
catch (releaseError) {
|
|
870
|
+
if (operationFailed) {
|
|
871
|
+
throw recorderLockReleaseError(logicalPath, operationError, releaseError);
|
|
872
|
+
}
|
|
873
|
+
if (result === true) {
|
|
874
|
+
throw new ServerRecorderCommittedError(logicalPath, releaseError);
|
|
875
|
+
}
|
|
876
|
+
throw releaseError;
|
|
877
|
+
}
|
|
878
|
+
if (operationFailed) {
|
|
879
|
+
throw operationError;
|
|
880
|
+
}
|
|
881
|
+
return result;
|
|
882
|
+
}
|
|
883
|
+
async function closeRecorderAttempt(params) {
|
|
884
|
+
let closeFailed = false;
|
|
885
|
+
let closeError;
|
|
886
|
+
try {
|
|
887
|
+
await params.file.close();
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
closeFailed = true;
|
|
891
|
+
closeError = error;
|
|
892
|
+
}
|
|
893
|
+
if (closeFailed) {
|
|
894
|
+
if (params.operationFailed) {
|
|
895
|
+
if (params.operationError instanceof ServerRecorderCommittedError) {
|
|
896
|
+
throw new ServerRecorderCommittedError(params.filePath, params.operationError, [closeError], params.operationError.indeterminate);
|
|
897
|
+
}
|
|
898
|
+
throw new AggregateError([params.operationError, closeError], "Server recorder operation and file close both failed.", { cause: closeError });
|
|
899
|
+
}
|
|
900
|
+
if (params.committed) {
|
|
901
|
+
throw new ServerRecorderCommittedError(params.filePath, closeError);
|
|
902
|
+
}
|
|
903
|
+
throw closeError;
|
|
904
|
+
}
|
|
905
|
+
if (params.operationFailed) {
|
|
906
|
+
throw params.operationError;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async function appendRecorderAttempt(params) {
|
|
910
|
+
const opened = await openRecorderFile(params.publicationPath);
|
|
911
|
+
const { file } = opened;
|
|
912
|
+
let committed = false;
|
|
913
|
+
let operationFailed = false;
|
|
914
|
+
let operationError;
|
|
915
|
+
let identityLock;
|
|
916
|
+
let result;
|
|
917
|
+
try {
|
|
918
|
+
const openedStats = await file.stat({ bigint: true });
|
|
919
|
+
const lockedIdentity = requireRecorderFileIdentity(openedStats);
|
|
920
|
+
let identity = `${lockedIdentity.dev}:${lockedIdentity.ino}`;
|
|
921
|
+
if (opened.created) {
|
|
922
|
+
await file.chmod(0o600);
|
|
923
|
+
}
|
|
924
|
+
if (process.platform === "win32" &&
|
|
925
|
+
!(await recorderWindowsPathIsRegular(params.publicationPath))) {
|
|
926
|
+
result = "retry";
|
|
927
|
+
}
|
|
928
|
+
else if (!(await recorderPathHasIdentity(params.publicationPath, identity))) {
|
|
929
|
+
result = "retry";
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
identityLock = await acquireRecorderIdentityLock(lockedIdentity);
|
|
933
|
+
await identityLock.assertNamespace();
|
|
934
|
+
// Lock acquisition may have blocked while another writer repaired or appended.
|
|
935
|
+
let stats = await file.stat({ bigint: true });
|
|
936
|
+
const currentIdentity = requireRecorderFileIdentity(stats);
|
|
937
|
+
identity = requireRecorderIdentity(stats);
|
|
938
|
+
if (currentIdentity.dev !== lockedIdentity.dev ||
|
|
939
|
+
currentIdentity.ino !== lockedIdentity.ino ||
|
|
940
|
+
currentIdentity.nlink !== lockedIdentity.nlink ||
|
|
941
|
+
!(await recorderPathHasIdentity(params.publicationPath, identity))) {
|
|
942
|
+
result = "retry";
|
|
943
|
+
}
|
|
944
|
+
else if ((await resolveRecorderPath(params.logicalPath)) !== params.publicationPath) {
|
|
945
|
+
result = "retargeted";
|
|
946
|
+
}
|
|
947
|
+
if (result === undefined) {
|
|
948
|
+
const fileSize = recorderFileSize(stats.size);
|
|
949
|
+
if (fileSize > 0) {
|
|
950
|
+
const finalByte = await readBufferAt(file, 1, fileSize - 1);
|
|
951
|
+
if (finalByte[0] !== 0x0a) {
|
|
952
|
+
const tailStart = await findIncompleteTailStart(file, fileSize);
|
|
953
|
+
const tailLength = fileSize - tailStart;
|
|
954
|
+
if (tailLength > MAX_RECOVERY_VALIDATION_BYTES) {
|
|
955
|
+
throw recoveryValidationLimitError();
|
|
956
|
+
}
|
|
957
|
+
const tail = await readBufferAt(file, tailLength, tailStart);
|
|
958
|
+
if (tail.length !== tailLength) {
|
|
959
|
+
throw new Error("Server recorder changed while repairing its final record.");
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
JSON.parse(tail.toString("utf8"));
|
|
963
|
+
await identityLock.assertNamespace();
|
|
964
|
+
await appendRecorderText(file, "\n", fileSize);
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
if (!(error instanceof SyntaxError)) {
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
await identityLock.assertNamespace();
|
|
971
|
+
if (!(await truncateRecorderFile(file, params.publicationPath, identity, tailStart))) {
|
|
972
|
+
result = "retry";
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (result === undefined) {
|
|
978
|
+
// Repair and truncation change EOF; refresh it before a positional Windows append.
|
|
979
|
+
stats = await file.stat({ bigint: true });
|
|
980
|
+
identity = requireRecorderIdentity(stats);
|
|
981
|
+
}
|
|
982
|
+
if (result === undefined &&
|
|
983
|
+
!(await recorderPathHasIdentity(params.publicationPath, identity))) {
|
|
984
|
+
result = "retry";
|
|
985
|
+
}
|
|
986
|
+
else if (result === undefined) {
|
|
987
|
+
try {
|
|
988
|
+
await identityLock.assertNamespace();
|
|
989
|
+
await appendRecorderText(file, params.line, recorderFileSize(stats.size));
|
|
990
|
+
await file.sync();
|
|
991
|
+
if (!(await recorderPathHasIdentity(params.publicationPath, identity)) ||
|
|
992
|
+
(await resolveRecorderPath(params.logicalPath)) !== params.publicationPath) {
|
|
993
|
+
throw new ServerRecorderCommittedError(params.logicalPath, new ServerRecorderRotationError("Server recorder rotated during append."));
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
const firstCreatedPath = params.createdDirectory ?? (opened.created ? params.publicationPath : undefined);
|
|
997
|
+
await syncRecorderPathAncestry(params.publicationPath, firstCreatedPath);
|
|
998
|
+
if (!(await recorderPathHasIdentity(params.publicationPath, identity)) ||
|
|
999
|
+
(await resolveRecorderPath(params.logicalPath)) !== params.publicationPath) {
|
|
1000
|
+
throw new ServerRecorderCommittedError(params.logicalPath, new ServerRecorderRotationError("Server recorder rotated while syncing path ancestry."));
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
committed = true;
|
|
1004
|
+
result = "committed";
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
if (error instanceof ServerRecorderCommittedError) {
|
|
1010
|
+
throw error;
|
|
1011
|
+
}
|
|
1012
|
+
if (error instanceof ServerRecorderDirectorySyncError) {
|
|
1013
|
+
throw new ServerRecorderCommittedError(params.logicalPath, error.syncError, [error.closeError], true);
|
|
1014
|
+
}
|
|
1015
|
+
throw new ServerRecorderCommittedError(params.logicalPath, error, [], true);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
operationFailed = true;
|
|
1023
|
+
operationError = error;
|
|
1024
|
+
}
|
|
1025
|
+
if (identityLock) {
|
|
1026
|
+
try {
|
|
1027
|
+
await identityLock.release();
|
|
1028
|
+
}
|
|
1029
|
+
catch (releaseError) {
|
|
1030
|
+
if (operationFailed) {
|
|
1031
|
+
operationError = recorderLockReleaseError(params.logicalPath, operationError, releaseError);
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
operationFailed = true;
|
|
1035
|
+
operationError = committed
|
|
1036
|
+
? new ServerRecorderCommittedError(params.logicalPath, releaseError)
|
|
1037
|
+
: releaseError;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
await closeRecorderAttempt({
|
|
1042
|
+
committed,
|
|
1043
|
+
file,
|
|
1044
|
+
filePath: params.logicalPath,
|
|
1045
|
+
operationError,
|
|
1046
|
+
operationFailed,
|
|
1047
|
+
});
|
|
1048
|
+
return result ?? "retry";
|
|
1049
|
+
}
|
|
1050
|
+
async function resolveRecorderPath(filePath) {
|
|
1051
|
+
try {
|
|
1052
|
+
return await realpath(filePath);
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
if (error.code !== "ENOENT") {
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
if ((await lstat(filePath)).isSymbolicLink()) {
|
|
1061
|
+
const target = await readlink(filePath);
|
|
1062
|
+
return await resolveRecorderPath(path.resolve(path.dirname(filePath), target));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
if (error.code !== "ENOENT") {
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return path.join(await resolveRecorderPath(path.dirname(filePath)), path.basename(filePath));
|
|
1071
|
+
}
|
|
1072
|
+
function planObserver(params) {
|
|
1073
|
+
if (params.onEvent === undefined) {
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const dependencies = new Set();
|
|
1077
|
+
const previousLogical = pendingLogicalObservers.get(params.logicalPath);
|
|
1078
|
+
const previousPublication = pendingPublicationObservers.get(params.key);
|
|
1079
|
+
if (previousLogical !== undefined) {
|
|
1080
|
+
dependencies.add(previousLogical);
|
|
1081
|
+
}
|
|
1082
|
+
if (previousPublication !== undefined) {
|
|
1083
|
+
dependencies.add(previousPublication);
|
|
1084
|
+
}
|
|
1085
|
+
let markStarted;
|
|
1086
|
+
const started = new Promise((resolve) => {
|
|
1087
|
+
markStarted = resolve;
|
|
1088
|
+
});
|
|
1089
|
+
const task = {
|
|
1090
|
+
markStarted,
|
|
1091
|
+
started,
|
|
1092
|
+
};
|
|
1093
|
+
return { dependencies, task };
|
|
1094
|
+
}
|
|
1095
|
+
function activateObserver(params) {
|
|
1096
|
+
if (params.onEvent === undefined || params.plan === undefined) {
|
|
1097
|
+
return Promise.resolve();
|
|
1098
|
+
}
|
|
1099
|
+
const { dependencies, task } = params.plan;
|
|
1100
|
+
const current = Promise.all([...dependencies].map((dependency) => dependency.started.catch(() => { }))).then(async () => {
|
|
1101
|
+
try {
|
|
1102
|
+
const observation = params.onEvent?.(params.event);
|
|
1103
|
+
task.markStarted();
|
|
1104
|
+
await observation;
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
task.markStarted();
|
|
1108
|
+
throw new ServerRecorderCommittedError(params.logicalPath, error);
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
pendingLogicalObservers.set(params.logicalPath, task);
|
|
1112
|
+
pendingPublicationObservers.set(params.key, task);
|
|
1113
|
+
void current.then(() => {
|
|
1114
|
+
if (pendingLogicalObservers.get(params.logicalPath) === task) {
|
|
1115
|
+
pendingLogicalObservers.delete(params.logicalPath);
|
|
1116
|
+
}
|
|
1117
|
+
if (pendingPublicationObservers.get(params.key) === task) {
|
|
1118
|
+
pendingPublicationObservers.delete(params.key);
|
|
1119
|
+
}
|
|
1120
|
+
}, () => {
|
|
1121
|
+
if (pendingLogicalObservers.get(params.logicalPath) === task) {
|
|
1122
|
+
pendingLogicalObservers.delete(params.logicalPath);
|
|
1123
|
+
}
|
|
1124
|
+
if (pendingPublicationObservers.get(params.key) === task) {
|
|
1125
|
+
pendingPublicationObservers.delete(params.key);
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
return current;
|
|
1129
|
+
}
|
|
1130
|
+
async function appendResolvedJsonLine(params) {
|
|
1131
|
+
const { logicalPath } = params;
|
|
1132
|
+
const key = await resolveRecorderPath(logicalPath);
|
|
10
1133
|
const previous = pendingAppends.get(key) ?? Promise.resolve();
|
|
11
1134
|
const current = previous
|
|
12
1135
|
.catch(() => { })
|
|
13
1136
|
.then(async () => {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
1137
|
+
const observerPlan = planObserver({
|
|
1138
|
+
key,
|
|
1139
|
+
logicalPath,
|
|
1140
|
+
onEvent: params.onEvent,
|
|
1141
|
+
});
|
|
1142
|
+
let observerActivated = false;
|
|
20
1143
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
1144
|
+
const directory = path.dirname(key);
|
|
1145
|
+
let createdDirectory;
|
|
1146
|
+
if (process.platform === "win32") {
|
|
1147
|
+
await secureServerRecorderWindowsDirectory(directory);
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
createdDirectory = await mkdir(directory, { mode: 0o700, recursive: true });
|
|
1151
|
+
if (createdDirectory !== undefined || isManagedRecorderDirectory(directory)) {
|
|
1152
|
+
await chmod(directory, 0o700);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Keep the cross-process lock through append verification.
|
|
1156
|
+
const committed = await withRecorderLock(key, logicalPath, async () => {
|
|
1157
|
+
if ((await resolveRecorderPath(logicalPath)) !== key) {
|
|
1158
|
+
return undefined;
|
|
1159
|
+
}
|
|
1160
|
+
for (let attempt = 0; attempt < RECORDER_ROTATION_ATTEMPTS; attempt++) {
|
|
1161
|
+
const result = await appendRecorderAttempt({
|
|
1162
|
+
createdDirectory,
|
|
1163
|
+
logicalPath,
|
|
1164
|
+
line: params.line,
|
|
1165
|
+
publicationPath: key,
|
|
1166
|
+
});
|
|
1167
|
+
if (result === "committed") {
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
if (result === "retargeted") {
|
|
1171
|
+
return undefined;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
throw new ServerRecorderRotationError(`Server recorder rotation retries exhausted for "${logicalPath}".`);
|
|
1175
|
+
});
|
|
1176
|
+
if (committed !== true) {
|
|
1177
|
+
return undefined;
|
|
1178
|
+
}
|
|
1179
|
+
observerActivated = true;
|
|
1180
|
+
return {
|
|
1181
|
+
observation: activateObserver({
|
|
1182
|
+
event: params.event,
|
|
1183
|
+
key,
|
|
1184
|
+
logicalPath,
|
|
1185
|
+
onEvent: params.onEvent,
|
|
1186
|
+
plan: observerPlan,
|
|
1187
|
+
}),
|
|
1188
|
+
};
|
|
23
1189
|
}
|
|
24
1190
|
finally {
|
|
25
|
-
|
|
1191
|
+
if (!observerActivated) {
|
|
1192
|
+
observerPlan?.task.markStarted();
|
|
1193
|
+
}
|
|
26
1194
|
}
|
|
27
1195
|
});
|
|
28
|
-
|
|
1196
|
+
const tail = current.then(() => undefined, () => undefined);
|
|
1197
|
+
pendingAppends.set(key, tail);
|
|
29
1198
|
try {
|
|
30
|
-
await current;
|
|
1199
|
+
return await current;
|
|
31
1200
|
}
|
|
32
1201
|
finally {
|
|
33
|
-
if (pendingAppends.get(key) ===
|
|
1202
|
+
if (pendingAppends.get(key) === tail) {
|
|
34
1203
|
pendingAppends.delete(key);
|
|
35
1204
|
}
|
|
36
1205
|
}
|
|
37
1206
|
}
|
|
1207
|
+
async function appendJsonLine(params) {
|
|
1208
|
+
const logicalPath = path.resolve(params.recorderPath);
|
|
1209
|
+
const previous = pendingAdmissions.get(logicalPath) ?? Promise.resolve();
|
|
1210
|
+
const current = previous
|
|
1211
|
+
.catch(() => { })
|
|
1212
|
+
.then(async () => {
|
|
1213
|
+
for (let attempt = 0; attempt < RECORDER_PATH_ATTEMPTS; attempt++) {
|
|
1214
|
+
const result = await appendResolvedJsonLine({
|
|
1215
|
+
event: params.event,
|
|
1216
|
+
line: params.line,
|
|
1217
|
+
logicalPath,
|
|
1218
|
+
onEvent: params.onEvent,
|
|
1219
|
+
});
|
|
1220
|
+
if (result !== undefined) {
|
|
1221
|
+
return result;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
throw new ServerRecorderRotationError(`Server recorder path retries exhausted for "${logicalPath}".`);
|
|
1225
|
+
});
|
|
1226
|
+
const tail = current.then(() => undefined, () => undefined);
|
|
1227
|
+
pendingAdmissions.set(logicalPath, tail);
|
|
1228
|
+
let result;
|
|
1229
|
+
try {
|
|
1230
|
+
result = await current;
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
if (pendingAdmissions.get(logicalPath) === tail) {
|
|
1234
|
+
pendingAdmissions.delete(logicalPath);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
await result.observation;
|
|
1238
|
+
}
|
|
1239
|
+
function snapshotServerEvent(event) {
|
|
1240
|
+
const serialized = JSON.stringify(event);
|
|
1241
|
+
if (serialized === undefined) {
|
|
1242
|
+
throw new TypeError("Server recorder event is not JSON-serializable.");
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
event: JSON.parse(serialized),
|
|
1246
|
+
line: `${serialized}\n`,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
38
1249
|
export async function recordServerEvent(params) {
|
|
39
|
-
|
|
40
|
-
await appendJsonLine(
|
|
41
|
-
|
|
1250
|
+
const snapshot = snapshotServerEvent(params.event);
|
|
1251
|
+
await appendJsonLine({
|
|
1252
|
+
...snapshot,
|
|
1253
|
+
onEvent: params.onEvent,
|
|
1254
|
+
recorderPath: params.recorderPath,
|
|
1255
|
+
});
|
|
42
1256
|
}
|
|
43
1257
|
export async function recordCommittedServerEvent(params) {
|
|
44
1258
|
try {
|
|
45
|
-
|
|
1259
|
+
const snapshot = snapshotServerEvent(params.event);
|
|
1260
|
+
await appendJsonLine({
|
|
1261
|
+
...snapshot,
|
|
1262
|
+
onEvent: params.onEvent,
|
|
1263
|
+
recorderPath: params.recorderPath,
|
|
1264
|
+
});
|
|
46
1265
|
}
|
|
47
1266
|
catch {
|
|
48
1267
|
// The provider mutation already committed, so telemetry failure cannot change its response.
|