@openclaw/crabline 0.1.11 → 0.1.13

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.
Files changed (214) hide show
  1. package/README.md +137 -23
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +281 -52
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/index.d.ts +12 -10
  31. package/dist/src/index.js +6 -5
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/matrix-ids.d.ts +5 -0
  34. package/dist/src/matrix-ids.js +83 -0
  35. package/dist/src/matrix-ids.js.map +1 -0
  36. package/dist/src/openclaw/artifact-generation.d.ts +7 -6
  37. package/dist/src/openclaw/artifact-generation.js +385 -41
  38. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  39. package/dist/src/openclaw/bridges/discord.d.ts +1 -0
  40. package/dist/src/openclaw/bridges/discord.js +149 -0
  41. package/dist/src/openclaw/bridges/discord.js.map +1 -0
  42. package/dist/src/openclaw/bridges/matrix.js +22 -5
  43. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  44. package/dist/src/openclaw/bridges/mattermost.js +14 -9
  45. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  46. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  47. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  48. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  49. package/dist/src/openclaw/bridges/signal.js +16 -7
  50. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  51. package/dist/src/openclaw/bridges/slack.js +117 -24
  52. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  53. package/dist/src/openclaw/bridges/telegram.js +46 -20
  54. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  55. package/dist/src/openclaw/bridges/whatsapp.js +33 -17
  56. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  57. package/dist/src/openclaw/bridges/zalo.js +9 -4
  58. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  59. package/dist/src/openclaw/outbound-contract.js +3 -0
  60. package/dist/src/openclaw/outbound-contract.js.map +1 -1
  61. package/dist/src/openclaw/private-file.d.ts +38 -3
  62. package/dist/src/openclaw/private-file.js +2008 -87
  63. package/dist/src/openclaw/private-file.js.map +1 -1
  64. package/dist/src/openclaw/{smoke-lock.d.ts → provider-readiness-lock.d.ts} +14 -5
  65. package/dist/src/openclaw/provider-readiness-lock.js +1320 -0
  66. package/dist/src/openclaw/provider-readiness-lock.js.map +1 -0
  67. package/dist/src/openclaw/shared.d.ts +10 -17
  68. package/dist/src/openclaw/shared.js +53 -19
  69. package/dist/src/openclaw/shared.js.map +1 -1
  70. package/dist/src/openclaw.d.ts +3 -5
  71. package/dist/src/openclaw.js +245 -60
  72. package/dist/src/openclaw.js.map +1 -1
  73. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  74. package/dist/src/platform/process-owned-lock.js +1698 -0
  75. package/dist/src/platform/process-owned-lock.js.map +1 -0
  76. package/dist/src/platform/recorder-directory.d.ts +1 -0
  77. package/dist/src/platform/recorder-directory.js +14 -0
  78. package/dist/src/platform/recorder-directory.js.map +1 -0
  79. package/dist/src/platform/windows-acl.d.ts +18 -0
  80. package/dist/src/platform/windows-acl.js +1367 -0
  81. package/dist/src/platform/windows-acl.js.map +1 -0
  82. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  83. package/dist/src/platform/windows-lock-root.js +98 -0
  84. package/dist/src/platform/windows-lock-root.js.map +1 -0
  85. package/dist/src/providers/builtin/discord.d.ts +4 -0
  86. package/dist/src/providers/builtin/discord.js +52 -9
  87. package/dist/src/providers/builtin/discord.js.map +1 -1
  88. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  89. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  90. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  91. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  92. package/dist/src/providers/builtin/feishu.js +96 -26
  93. package/dist/src/providers/builtin/feishu.js.map +1 -1
  94. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  95. package/dist/src/providers/builtin/googlechat.js +95 -26
  96. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  97. package/dist/src/providers/builtin/imessage.js +60 -17
  98. package/dist/src/providers/builtin/imessage.js.map +1 -1
  99. package/dist/src/providers/builtin/loopback.js +88 -35
  100. package/dist/src/providers/builtin/loopback.js.map +1 -1
  101. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  102. package/dist/src/providers/builtin/matrix.js +78 -21
  103. package/dist/src/providers/builtin/matrix.js.map +1 -1
  104. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  105. package/dist/src/providers/builtin/mattermost.js +138 -24
  106. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  107. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  108. package/dist/src/providers/builtin/msteams.js +118 -38
  109. package/dist/src/providers/builtin/msteams.js.map +1 -1
  110. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  111. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  112. package/dist/src/providers/builtin/script.js +1057 -146
  113. package/dist/src/providers/builtin/script.js.map +1 -1
  114. package/dist/src/providers/builtin/slack.d.ts +8 -1
  115. package/dist/src/providers/builtin/slack.js +327 -24
  116. package/dist/src/providers/builtin/slack.js.map +1 -1
  117. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  118. package/dist/src/providers/builtin/telegram.js +55 -14
  119. package/dist/src/providers/builtin/telegram.js.map +1 -1
  120. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  121. package/dist/src/providers/builtin/whatsapp.js +157 -29
  122. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  123. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  124. package/dist/src/providers/builtin/zalo.js +31 -7
  125. package/dist/src/providers/builtin/zalo.js.map +1 -1
  126. package/dist/src/providers/catalog.d.ts +1 -1
  127. package/dist/src/providers/catalog.js +1 -1
  128. package/dist/src/providers/catalog.js.map +1 -1
  129. package/dist/src/providers/local-mock.d.ts +4 -0
  130. package/dist/src/providers/local-mock.js +162 -35
  131. package/dist/src/providers/local-mock.js.map +1 -1
  132. package/dist/src/providers/native-ids.d.ts +2 -0
  133. package/dist/src/providers/native-ids.js +3 -0
  134. package/dist/src/providers/native-ids.js.map +1 -1
  135. package/dist/src/providers/recorder.d.ts +14 -3
  136. package/dist/src/providers/recorder.js +521 -41
  137. package/dist/src/providers/recorder.js.map +1 -1
  138. package/dist/src/providers/registry.d.ts +1 -0
  139. package/dist/src/providers/registry.js +65 -20
  140. package/dist/src/providers/registry.js.map +1 -1
  141. package/dist/src/providers/signed-jwt.d.ts +6 -1
  142. package/dist/src/providers/signed-jwt.js +241 -50
  143. package/dist/src/providers/signed-jwt.js.map +1 -1
  144. package/dist/src/providers/slack-ids.d.ts +1 -0
  145. package/dist/src/providers/slack-ids.js +5 -0
  146. package/dist/src/providers/slack-ids.js.map +1 -1
  147. package/dist/src/providers/target-normalizers.d.ts +9 -1
  148. package/dist/src/providers/target-normalizers.js +177 -58
  149. package/dist/src/providers/target-normalizers.js.map +1 -1
  150. package/dist/src/providers/webhook-server.d.ts +3 -0
  151. package/dist/src/providers/webhook-server.js +268 -47
  152. package/dist/src/providers/webhook-server.js.map +1 -1
  153. package/dist/src/servers/discord.d.ts +40 -0
  154. package/dist/src/servers/discord.js +842 -0
  155. package/dist/src/servers/discord.js.map +1 -0
  156. package/dist/src/servers/http.d.ts +14 -2
  157. package/dist/src/servers/http.js +229 -58
  158. package/dist/src/servers/http.js.map +1 -1
  159. package/dist/src/servers/index.d.ts +10 -4
  160. package/dist/src/servers/index.js +4 -0
  161. package/dist/src/servers/index.js.map +1 -1
  162. package/dist/src/servers/matrix.d.ts +3 -0
  163. package/dist/src/servers/matrix.js +268 -110
  164. package/dist/src/servers/matrix.js.map +1 -1
  165. package/dist/src/servers/mattermost.d.ts +5 -0
  166. package/dist/src/servers/mattermost.js +389 -91
  167. package/dist/src/servers/mattermost.js.map +1 -1
  168. package/dist/src/servers/recorder.d.ts +11 -0
  169. package/dist/src/servers/recorder.js +1072 -69
  170. package/dist/src/servers/recorder.js.map +1 -1
  171. package/dist/src/servers/signal.d.ts +1 -0
  172. package/dist/src/servers/signal.js +279 -44
  173. package/dist/src/servers/signal.js.map +1 -1
  174. package/dist/src/servers/slack.d.ts +15 -0
  175. package/dist/src/servers/slack.js +270 -54
  176. package/dist/src/servers/slack.js.map +1 -1
  177. package/dist/src/servers/telegram-identity.d.ts +6 -0
  178. package/dist/src/servers/telegram-identity.js +27 -0
  179. package/dist/src/servers/telegram-identity.js.map +1 -0
  180. package/dist/src/servers/telegram.d.ts +17 -6
  181. package/dist/src/servers/telegram.js +1218 -107
  182. package/dist/src/servers/telegram.js.map +1 -1
  183. package/dist/src/servers/webhook-target.d.ts +12 -4
  184. package/dist/src/servers/webhook-target.js +226 -28
  185. package/dist/src/servers/webhook-target.js.map +1 -1
  186. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  187. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  188. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  189. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  190. package/dist/src/servers/whatsapp-jid.js +9 -5
  191. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  192. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  193. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  194. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  195. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  196. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  197. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  198. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  199. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  200. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  201. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  202. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  203. package/dist/src/servers/whatsapp.d.ts +7 -1
  204. package/dist/src/servers/whatsapp.js +149 -25
  205. package/dist/src/servers/whatsapp.js.map +1 -1
  206. package/dist/src/servers/zalo.d.ts +1 -0
  207. package/dist/src/servers/zalo.js +193 -82
  208. package/dist/src/servers/zalo.js.map +1 -1
  209. package/docs/channel-setup.md +251 -64
  210. package/fixtures/examples/crabline.example.yaml +3 -2
  211. package/package.json +18 -10
  212. package/dist/src/openclaw/smoke-lock.js +0 -1004
  213. package/dist/src/openclaw/smoke-lock.js.map +0 -1
  214. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,19 +1,51 @@
1
- import { chmod, mkdir, open } from "node:fs/promises";
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 durableRecorderIdentities = new Map();
6
- const MAX_DURABLE_RECORDER_IDENTITIES = 128;
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, "a+", 0o600),
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
- finally {
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 rememberDurableRecorderIdentity(filePath, identity) {
118
- durableRecorderIdentities.delete(filePath);
119
- durableRecorderIdentities.set(filePath, identity);
120
- if (durableRecorderIdentities.size > MAX_DURABLE_RECORDER_IDENTITIES) {
121
- const oldestPath = durableRecorderIdentities.keys().next().value;
122
- if (oldestPath !== undefined) {
123
- durableRecorderIdentities.delete(oldestPath);
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 acquireRecorderLock(filePath) {
136
- while (true) {
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
- return await lock(filePath, {
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
- await new Promise((resolve) => setTimeout(resolve, RECORDER_LOCK_RETRY_MS));
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(filePath, operation) {
154
- const release = await acquireRecorderLock(filePath);
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(filePath, operationError, releaseError);
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 appendJsonLine(filePath, line, durable) {
180
- const key = path.resolve(filePath);
181
- const previous = pendingAppends.get(key) ?? Promise.resolve();
182
- const current = previous
183
- .catch(() => { })
184
- .then(async () => {
185
- const directory = path.dirname(filePath);
186
- const createdDirectory = await mkdir(directory, { mode: 0o700, recursive: true });
187
- if (createdDirectory !== undefined || isManagedRecorderDirectory(path.resolve(directory))) {
188
- await chmod(directory, 0o700);
189
- }
190
- await withRecorderLock(key, async () => {
191
- const opened = await openRecorderFile(filePath);
192
- const { file } = opened;
193
- try {
194
- await file.chmod(0o600);
195
- const stats = await file.stat();
196
- const identity = recorderIdentity(stats);
197
- const needsPathDurability = opened.created ||
198
- identity === undefined ||
199
- durableRecorderIdentities.get(key) !== identity;
200
- if (stats.size > 0) {
201
- const finalByte = await readBufferAt(file, 1, stats.size - 1);
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, stats.size);
204
- const tailLength = stats.size - tailStart;
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 file.appendFile("\n", { encoding: "utf8" });
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 file.truncate(tailStart);
970
+ await identityLock.assertNamespace();
971
+ if (!(await truncateRecorderFile(file, params.publicationPath, identity, tailStart))) {
972
+ result = "retry";
973
+ }
221
974
  }
222
975
  }
223
976
  }
224
- await file.appendFile(line, { encoding: "utf8" });
225
- if (durable || needsPathDurability) {
226
- await file.sync();
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 (needsPathDurability) {
229
- const firstCreatedPath = createdDirectory ?? (opened.created ? path.resolve(filePath) : undefined);
230
- await syncRecorderPathAncestry(filePath, firstCreatedPath);
231
- if (identity === undefined) {
232
- durableRecorderIdentities.delete(key);
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
- else {
235
- rememberDurableRecorderIdentity(key, identity);
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
- finally {
240
- await file.close();
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
- pendingAppends.set(key, current);
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) === current) {
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
- // Observers only see events after the recorder append is durable.
256
- await appendJsonLine(params.recorderPath, `${JSON.stringify(params.event)}\n`, params.onEvent !== undefined);
257
- await params.onEvent?.(params.event);
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
- await recordServerEvent(params);
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.