@openclaw/crabline 0.1.11 → 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 +7 -2
- package/dist/src/openclaw/artifact-generation.js +399 -27
- 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 +38 -3
- package/dist/src/openclaw/private-file.js +2008 -87
- 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 +626 -172
- package/dist/src/openclaw/smoke-lock.js.map +1 -1
- package/dist/src/openclaw.js +225 -42
- 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 +521 -41
- 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 +1072 -69
- 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 +1169 -107
- 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,19 +1,51 @@
|
|
|
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";
|
|
3
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
|
+
}
|
|
4
33
|
const pendingAppends = new Map();
|
|
5
|
-
const
|
|
6
|
-
const
|
|
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();
|
|
7
39
|
const MAX_RECOVERY_VALIDATION_BYTES = 64 * 1024 * 1024;
|
|
8
40
|
const MAX_RECOVERY_SCAN_BYTES = MAX_RECOVERY_VALIDATION_BYTES + 1;
|
|
9
41
|
const RECORDER_LOCK_RETRY_MS = 100;
|
|
10
42
|
const RECORDER_LOCK_STALE_MS = 30_000;
|
|
11
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;
|
|
12
48
|
const TAIL_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
13
|
-
function isManagedRecorderDirectory(directory) {
|
|
14
|
-
return (directory === path.resolve(".crabline", "servers") ||
|
|
15
|
-
directory === path.resolve("artifacts", "crabline"));
|
|
16
|
-
}
|
|
17
49
|
async function readBufferAt(file, length, position) {
|
|
18
50
|
const buffer = Buffer.alloc(length);
|
|
19
51
|
let bytesRead = 0;
|
|
@@ -26,6 +58,21 @@ async function readBufferAt(file, length, position) {
|
|
|
26
58
|
}
|
|
27
59
|
return buffer.subarray(0, bytesRead);
|
|
28
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
|
+
}
|
|
29
76
|
async function findIncompleteTailStart(file, fileSize) {
|
|
30
77
|
let position = fileSize;
|
|
31
78
|
while (position > 0) {
|
|
@@ -50,6 +97,13 @@ async function findIncompleteTailStart(file, fileSize) {
|
|
|
50
97
|
function recoveryValidationLimitError() {
|
|
51
98
|
return new Error("Server recorder final record is too large to validate safely; refusing to modify it.");
|
|
52
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
|
+
}
|
|
53
107
|
async function openRecorderFile(filePath) {
|
|
54
108
|
try {
|
|
55
109
|
return {
|
|
@@ -63,21 +117,95 @@ async function openRecorderFile(filePath) {
|
|
|
63
117
|
}
|
|
64
118
|
return {
|
|
65
119
|
created: false,
|
|
66
|
-
file: await open(filePath,
|
|
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),
|
|
67
128
|
};
|
|
68
129
|
}
|
|
69
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
|
+
}
|
|
70
181
|
async function syncDirectory(directoryPath) {
|
|
71
182
|
if (process.platform === "win32") {
|
|
72
183
|
return;
|
|
73
184
|
}
|
|
74
185
|
const directory = await open(directoryPath, "r");
|
|
186
|
+
let syncError;
|
|
75
187
|
try {
|
|
76
188
|
await directory.sync();
|
|
77
189
|
}
|
|
78
|
-
|
|
190
|
+
catch (error) {
|
|
191
|
+
syncError = error;
|
|
192
|
+
}
|
|
193
|
+
let closeError;
|
|
194
|
+
try {
|
|
79
195
|
await directory.close();
|
|
80
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
|
+
}
|
|
81
209
|
}
|
|
82
210
|
async function syncRecorderPathAncestry(filePath, firstCreatedDirectory) {
|
|
83
211
|
if (process.platform === "win32") {
|
|
@@ -99,6 +227,9 @@ async function syncRecorderPathAncestry(filePath, firstCreatedDirectory) {
|
|
|
99
227
|
}
|
|
100
228
|
throw error;
|
|
101
229
|
}
|
|
230
|
+
if (syncThroughPath === undefined) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
102
233
|
if (currentPath === syncThroughPath) {
|
|
103
234
|
return;
|
|
104
235
|
}
|
|
@@ -114,44 +245,614 @@ function recorderIdentity(stats) {
|
|
|
114
245
|
}
|
|
115
246
|
return `${stats.dev}:${stats.ino}`;
|
|
116
247
|
}
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
};
|
|
263
|
+
}
|
|
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;
|
|
124
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.");
|
|
125
298
|
}
|
|
299
|
+
return identity;
|
|
126
300
|
}
|
|
127
301
|
function recorderLockReleaseError(filePath, operationError, releaseError) {
|
|
302
|
+
if (operationError instanceof ServerRecorderCommittedError) {
|
|
303
|
+
return new ServerRecorderCommittedError(filePath, operationError, [releaseError], operationError.indeterminate);
|
|
304
|
+
}
|
|
128
305
|
return new AggregateError([operationError, releaseError], `Server recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
|
|
129
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
|
+
}
|
|
130
313
|
function isRecorderLockContention(error) {
|
|
131
314
|
return (typeof error === "object" &&
|
|
132
315
|
error !== null &&
|
|
133
316
|
error.code === "ELOCKED");
|
|
134
317
|
}
|
|
135
|
-
async function
|
|
136
|
-
|
|
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 (;;) {
|
|
137
703
|
try {
|
|
138
|
-
|
|
704
|
+
const release = await lock(filePath, {
|
|
705
|
+
fs: lockFileSystem,
|
|
139
706
|
realpath: false,
|
|
140
707
|
retries: 0,
|
|
141
708
|
stale: RECORDER_LOCK_STALE_MS,
|
|
142
709
|
update: RECORDER_LOCK_UPDATE_MS,
|
|
143
710
|
});
|
|
711
|
+
return { artifactIdentity, release };
|
|
144
712
|
}
|
|
145
713
|
catch (error) {
|
|
146
714
|
if (!isRecorderLockContention(error)) {
|
|
147
715
|
throw error;
|
|
148
716
|
}
|
|
149
|
-
|
|
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);
|
|
150
825
|
}
|
|
826
|
+
throw error;
|
|
151
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
|
+
};
|
|
152
853
|
}
|
|
153
|
-
async function withRecorderLock(
|
|
154
|
-
const release = await acquireRecorderLock(
|
|
854
|
+
async function withRecorderLock(lockPath, logicalPath, operation) {
|
|
855
|
+
const { release } = await acquireRecorderLock(lockPath);
|
|
155
856
|
let operationFailed = false;
|
|
156
857
|
let operationError;
|
|
157
858
|
let result;
|
|
@@ -167,7 +868,10 @@ async function withRecorderLock(filePath, operation) {
|
|
|
167
868
|
}
|
|
168
869
|
catch (releaseError) {
|
|
169
870
|
if (operationFailed) {
|
|
170
|
-
throw recorderLockReleaseError(
|
|
871
|
+
throw recorderLockReleaseError(logicalPath, operationError, releaseError);
|
|
872
|
+
}
|
|
873
|
+
if (result === true) {
|
|
874
|
+
throw new ServerRecorderCommittedError(logicalPath, releaseError);
|
|
171
875
|
}
|
|
172
876
|
throw releaseError;
|
|
173
877
|
}
|
|
@@ -176,32 +880,77 @@ async function withRecorderLock(filePath, operation) {
|
|
|
176
880
|
}
|
|
177
881
|
return result;
|
|
178
882
|
}
|
|
179
|
-
async function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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);
|
|
202
951
|
if (finalByte[0] !== 0x0a) {
|
|
203
|
-
const tailStart = await findIncompleteTailStart(file,
|
|
204
|
-
const tailLength =
|
|
952
|
+
const tailStart = await findIncompleteTailStart(file, fileSize);
|
|
953
|
+
const tailLength = fileSize - tailStart;
|
|
205
954
|
if (tailLength > MAX_RECOVERY_VALIDATION_BYTES) {
|
|
206
955
|
throw recoveryValidationLimitError();
|
|
207
956
|
}
|
|
@@ -211,54 +960,308 @@ async function appendJsonLine(filePath, line, durable) {
|
|
|
211
960
|
}
|
|
212
961
|
try {
|
|
213
962
|
JSON.parse(tail.toString("utf8"));
|
|
214
|
-
await
|
|
963
|
+
await identityLock.assertNamespace();
|
|
964
|
+
await appendRecorderText(file, "\n", fileSize);
|
|
215
965
|
}
|
|
216
966
|
catch (error) {
|
|
217
967
|
if (!(error instanceof SyntaxError)) {
|
|
218
968
|
throw error;
|
|
219
969
|
}
|
|
220
|
-
await
|
|
970
|
+
await identityLock.assertNamespace();
|
|
971
|
+
if (!(await truncateRecorderFile(file, params.publicationPath, identity, tailStart))) {
|
|
972
|
+
result = "retry";
|
|
973
|
+
}
|
|
221
974
|
}
|
|
222
975
|
}
|
|
223
976
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await file.
|
|
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);
|
|
227
981
|
}
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
}
|
|
233
1007
|
}
|
|
234
|
-
|
|
235
|
-
|
|
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);
|
|
236
1016
|
}
|
|
237
1017
|
}
|
|
238
1018
|
}
|
|
239
|
-
|
|
240
|
-
|
|
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;
|
|
241
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);
|
|
1133
|
+
const previous = pendingAppends.get(key) ?? Promise.resolve();
|
|
1134
|
+
const current = previous
|
|
1135
|
+
.catch(() => { })
|
|
1136
|
+
.then(async () => {
|
|
1137
|
+
const observerPlan = planObserver({
|
|
1138
|
+
key,
|
|
1139
|
+
logicalPath,
|
|
1140
|
+
onEvent: params.onEvent,
|
|
242
1141
|
});
|
|
1142
|
+
let observerActivated = false;
|
|
1143
|
+
try {
|
|
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
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
finally {
|
|
1191
|
+
if (!observerActivated) {
|
|
1192
|
+
observerPlan?.task.markStarted();
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
243
1195
|
});
|
|
244
|
-
|
|
1196
|
+
const tail = current.then(() => undefined, () => undefined);
|
|
1197
|
+
pendingAppends.set(key, tail);
|
|
245
1198
|
try {
|
|
246
|
-
await current;
|
|
1199
|
+
return await current;
|
|
247
1200
|
}
|
|
248
1201
|
finally {
|
|
249
|
-
if (pendingAppends.get(key) ===
|
|
1202
|
+
if (pendingAppends.get(key) === tail) {
|
|
250
1203
|
pendingAppends.delete(key);
|
|
251
1204
|
}
|
|
252
1205
|
}
|
|
253
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
|
+
}
|
|
254
1249
|
export async function recordServerEvent(params) {
|
|
255
|
-
|
|
256
|
-
await appendJsonLine(
|
|
257
|
-
|
|
1250
|
+
const snapshot = snapshotServerEvent(params.event);
|
|
1251
|
+
await appendJsonLine({
|
|
1252
|
+
...snapshot,
|
|
1253
|
+
onEvent: params.onEvent,
|
|
1254
|
+
recorderPath: params.recorderPath,
|
|
1255
|
+
});
|
|
258
1256
|
}
|
|
259
1257
|
export async function recordCommittedServerEvent(params) {
|
|
260
1258
|
try {
|
|
261
|
-
|
|
1259
|
+
const snapshot = snapshotServerEvent(params.event);
|
|
1260
|
+
await appendJsonLine({
|
|
1261
|
+
...snapshot,
|
|
1262
|
+
onEvent: params.onEvent,
|
|
1263
|
+
recorderPath: params.recorderPath,
|
|
1264
|
+
});
|
|
262
1265
|
}
|
|
263
1266
|
catch {
|
|
264
1267
|
// The provider mutation already committed, so telemetry failure cannot change its response.
|