@namzu/sdk 60.0.0 → 60.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +1 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  6. package/dist/bridge/a2a/mapper.js +19 -0
  7. package/dist/bridge/a2a/mapper.js.map +1 -1
  8. package/dist/bridge/acp/update.d.ts.map +1 -1
  9. package/dist/bridge/acp/update.js +1 -0
  10. package/dist/bridge/acp/update.js.map +1 -1
  11. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  12. package/dist/bridge/sse/mapper.js +28 -0
  13. package/dist/bridge/sse/mapper.js.map +1 -1
  14. package/dist/constants/continuation.d.ts +9 -0
  15. package/dist/constants/continuation.d.ts.map +1 -1
  16. package/dist/constants/continuation.js +9 -0
  17. package/dist/constants/continuation.js.map +1 -1
  18. package/dist/contracts/session/api.d.ts +8 -2
  19. package/dist/contracts/session/api.d.ts.map +1 -1
  20. package/dist/manager/session/turn-recorder.d.ts +7 -1
  21. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  22. package/dist/manager/session/turn-recorder.js +20 -0
  23. package/dist/manager/session/turn-recorder.js.map +1 -1
  24. package/dist/peers/dir.d.ts.map +1 -1
  25. package/dist/peers/dir.js +3 -0
  26. package/dist/peers/dir.js.map +1 -1
  27. package/dist/provider/collect-chat-completion.d.ts.map +1 -1
  28. package/dist/provider/collect-chat-completion.js +4 -0
  29. package/dist/provider/collect-chat-completion.js.map +1 -1
  30. package/dist/provider/fallback.d.ts.map +1 -1
  31. package/dist/provider/fallback.js +11 -0
  32. package/dist/provider/fallback.js.map +1 -1
  33. package/dist/provider/idle-timeout.d.ts.map +1 -1
  34. package/dist/provider/idle-timeout.js +5 -0
  35. package/dist/provider/idle-timeout.js.map +1 -1
  36. package/dist/provider/mock.d.ts +2 -0
  37. package/dist/provider/mock.d.ts.map +1 -1
  38. package/dist/provider/mock.js +16 -8
  39. package/dist/provider/mock.js.map +1 -1
  40. package/dist/provider/model-window-suffix.d.ts.map +1 -1
  41. package/dist/provider/model-window-suffix.js +1 -0
  42. package/dist/provider/model-window-suffix.js.map +1 -1
  43. package/dist/provider/retry.d.ts.map +1 -1
  44. package/dist/provider/retry.js +5 -0
  45. package/dist/provider/retry.js.map +1 -1
  46. package/dist/public-runtime.d.ts +1 -0
  47. package/dist/public-runtime.d.ts.map +1 -1
  48. package/dist/public-runtime.js +1 -0
  49. package/dist/public-runtime.js.map +1 -1
  50. package/dist/runtime/query/events.d.ts.map +1 -1
  51. package/dist/runtime/query/events.js +17 -0
  52. package/dist/runtime/query/events.js.map +1 -1
  53. package/dist/runtime/query/executor.d.ts +23 -0
  54. package/dist/runtime/query/executor.d.ts.map +1 -1
  55. package/dist/runtime/query/executor.js +88 -9
  56. package/dist/runtime/query/executor.js.map +1 -1
  57. package/dist/runtime/query/index.d.ts +14 -0
  58. package/dist/runtime/query/index.d.ts.map +1 -1
  59. package/dist/runtime/query/index.js +14 -0
  60. package/dist/runtime/query/index.js.map +1 -1
  61. package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
  62. package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
  63. package/dist/runtime/query/iteration/effort-clamp.js +42 -0
  64. package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
  65. package/dist/runtime/query/iteration/index.d.ts +34 -0
  66. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/index.js +199 -6
  68. package/dist/runtime/query/iteration/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  70. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  72. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  73. package/dist/runtime/query/iteration/phases/tool-review.js +6 -0
  74. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  75. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/stream-turn.js +6 -0
  77. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  78. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  79. package/dist/runtime/query/review-policy.js +12 -2
  80. package/dist/runtime/query/review-policy.js.map +1 -1
  81. package/dist/runtime/query/tooling.d.ts +2 -0
  82. package/dist/runtime/query/tooling.d.ts.map +1 -1
  83. package/dist/runtime/query/tooling.js +1 -0
  84. package/dist/runtime/query/tooling.js.map +1 -1
  85. package/dist/sandbox/provider/local.d.ts.map +1 -1
  86. package/dist/sandbox/provider/local.js +6 -5
  87. package/dist/sandbox/provider/local.js.map +1 -1
  88. package/dist/session/paths.d.ts +15 -22
  89. package/dist/session/paths.d.ts.map +1 -1
  90. package/dist/session/paths.js +19 -46
  91. package/dist/session/paths.js.map +1 -1
  92. package/dist/store/memory/disk.d.ts.map +1 -1
  93. package/dist/store/memory/disk.js +1 -1
  94. package/dist/store/memory/disk.js.map +1 -1
  95. package/dist/store/memory/markdown.d.ts.map +1 -1
  96. package/dist/store/memory/markdown.js +1 -1
  97. package/dist/store/memory/markdown.js.map +1 -1
  98. package/dist/store/memory/operation-lock.d.ts +20 -4
  99. package/dist/store/memory/operation-lock.d.ts.map +1 -1
  100. package/dist/store/memory/operation-lock.js +235 -58
  101. package/dist/store/memory/operation-lock.js.map +1 -1
  102. package/dist/store/session-log/core.d.ts +43 -0
  103. package/dist/store/session-log/core.d.ts.map +1 -1
  104. package/dist/store/session-log/core.js +190 -3
  105. package/dist/store/session-log/core.js.map +1 -1
  106. package/dist/store/session-log/disk.d.ts +1 -1
  107. package/dist/store/session-log/disk.d.ts.map +1 -1
  108. package/dist/store/session-log/disk.js +1 -0
  109. package/dist/store/session-log/disk.js.map +1 -1
  110. package/dist/store/session-log/index.d.ts +1 -1
  111. package/dist/store/session-log/index.d.ts.map +1 -1
  112. package/dist/store/session-log/index.js +1 -1
  113. package/dist/store/session-log/index.js.map +1 -1
  114. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -1
  115. package/dist/tools/builtins/bash-cwd.js +2 -3
  116. package/dist/tools/builtins/bash-cwd.js.map +1 -1
  117. package/dist/tools/builtins/read-media.js +3 -3
  118. package/dist/tools/builtins/read-media.js.map +1 -1
  119. package/dist/turn/reporter.d.ts.map +1 -1
  120. package/dist/turn/reporter.js +13 -0
  121. package/dist/turn/reporter.js.map +1 -1
  122. package/dist/types/agent/query.d.ts +2 -0
  123. package/dist/types/agent/query.d.ts.map +1 -1
  124. package/dist/types/hitl/index.d.ts +8 -0
  125. package/dist/types/hitl/index.d.ts.map +1 -1
  126. package/dist/types/hitl/index.js.map +1 -1
  127. package/dist/types/message/index.d.ts +1 -1
  128. package/dist/types/message/index.d.ts.map +1 -1
  129. package/dist/types/message/index.js +1 -0
  130. package/dist/types/message/index.js.map +1 -1
  131. package/dist/types/provider/chat.d.ts +19 -0
  132. package/dist/types/provider/chat.d.ts.map +1 -1
  133. package/dist/types/provider/config.d.ts +7 -0
  134. package/dist/types/provider/config.d.ts.map +1 -1
  135. package/dist/types/provider/index.d.ts +1 -1
  136. package/dist/types/provider/index.d.ts.map +1 -1
  137. package/dist/types/provider/interface.d.ts +15 -0
  138. package/dist/types/provider/interface.d.ts.map +1 -1
  139. package/dist/types/provider/stream.d.ts +6 -0
  140. package/dist/types/provider/stream.d.ts.map +1 -1
  141. package/dist/types/session/events.d.ts +72 -2
  142. package/dist/types/session/events.d.ts.map +1 -1
  143. package/dist/types/session/events.js +4 -0
  144. package/dist/types/session/events.js.map +1 -1
  145. package/dist/types/session/records.d.ts +34 -34
  146. package/dist/types/session/records.d.ts.map +1 -1
  147. package/dist/types/session/records.js +5 -0
  148. package/dist/types/session/records.js.map +1 -1
  149. package/dist/types/session/stop-reason.d.ts +11 -0
  150. package/dist/types/session/stop-reason.d.ts.map +1 -1
  151. package/dist/utils/temp.d.ts +82 -0
  152. package/dist/utils/temp.d.ts.map +1 -0
  153. package/dist/utils/temp.js +196 -0
  154. package/dist/utils/temp.js.map +1 -0
  155. package/package.json +1 -1
  156. package/src/agents/QueryAgent.ts +1 -0
  157. package/src/bridge/a2a/mapper.ts +22 -0
  158. package/src/bridge/acp/update.ts +6 -1
  159. package/src/bridge/sse/mapper.ts +29 -0
  160. package/src/constants/continuation.ts +13 -0
  161. package/src/contracts/session/api.ts +8 -0
  162. package/src/manager/session/turn-recorder.ts +22 -0
  163. package/src/peers/dir.ts +3 -0
  164. package/src/provider/collect-chat-completion.ts +3 -0
  165. package/src/provider/fallback.ts +17 -0
  166. package/src/provider/idle-timeout.ts +9 -0
  167. package/src/provider/mock.ts +19 -9
  168. package/src/provider/model-window-suffix.ts +1 -0
  169. package/src/provider/retry.ts +9 -0
  170. package/src/public-runtime.ts +13 -0
  171. package/src/runtime/query/events.ts +19 -0
  172. package/src/runtime/query/executor.ts +92 -8
  173. package/src/runtime/query/index.ts +27 -0
  174. package/src/runtime/query/iteration/effort-clamp.ts +46 -0
  175. package/src/runtime/query/iteration/index.ts +260 -6
  176. package/src/runtime/query/iteration/phases/context.ts +2 -0
  177. package/src/runtime/query/iteration/phases/tool-review.ts +6 -0
  178. package/src/runtime/query/iteration/stream-turn.ts +5 -0
  179. package/src/runtime/query/review-policy.ts +13 -2
  180. package/src/runtime/query/tooling.ts +3 -0
  181. package/src/sandbox/provider/local.ts +8 -5
  182. package/src/session/paths.ts +32 -57
  183. package/src/store/memory/disk.ts +1 -0
  184. package/src/store/memory/markdown.ts +1 -0
  185. package/src/store/memory/operation-lock.ts +288 -57
  186. package/src/store/session-log/core.ts +235 -3
  187. package/src/store/session-log/disk.ts +7 -1
  188. package/src/store/session-log/index.ts +4 -0
  189. package/src/tools/builtins/bash-cwd.ts +2 -3
  190. package/src/tools/builtins/read-media.ts +3 -3
  191. package/src/turn/reporter.ts +14 -0
  192. package/src/types/agent/query.ts +2 -0
  193. package/src/types/hitl/index.ts +8 -0
  194. package/src/types/message/index.ts +5 -3
  195. package/src/types/provider/chat.ts +20 -0
  196. package/src/types/provider/config.ts +7 -0
  197. package/src/types/provider/index.ts +1 -0
  198. package/src/types/provider/interface.ts +20 -0
  199. package/src/types/provider/stream.ts +6 -0
  200. package/src/types/session/events.ts +106 -6
  201. package/src/types/session/records.ts +5 -0
  202. package/src/types/session/stop-reason.ts +11 -0
  203. package/src/utils/temp.ts +250 -0
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { type FileHandle, lstat, open, readFile, unlink } from 'node:fs/promises'
2
+ import type { Stats } from 'node:fs'
3
+ import { lstat, open, readFile, unlink } from 'node:fs/promises'
4
+ import { hostname as osHostname } from 'node:os'
3
5
  import { setTimeout as delay } from 'node:timers/promises'
4
6
 
5
7
  import { NamzuError } from '../../types/errors/index.js'
8
+ import type { Logger } from '../../utils/logger.js'
6
9
 
7
- /** Wait bound, not a lease: a slow or crashed owner is never silently replaced. */
10
+ /** Wait bound, not a lease: a slow owner that is still running is never replaced. */
8
11
  export const DEFAULT_MEMORY_LOCK_TIMEOUT_MS = 10_000
9
12
 
10
13
  export function validateMemoryLockTimeout(timeoutMs: number): void {
@@ -17,83 +20,311 @@ export function validateMemoryLockTimeout(timeoutMs: number): void {
17
20
  }
18
21
  }
19
22
 
20
- function lockError(path: string, reason: string, timeoutMs: number): NamzuError {
23
+ /**
24
+ * An empty or unparsable lock file is only believed abandoned once it is older
25
+ * than this. A live acquirer writes its record immediately after creating the
26
+ * file, so a gap of seconds means the writer died in between (disk full, kill).
27
+ */
28
+ export const MEMORY_LOCK_RECORD_GRACE_MS = 5_000
29
+
30
+ export interface MemoryOperationLockOptions {
31
+ readonly logger?: Logger
32
+ /** Clock compared against file mtimes. */
33
+ readonly now?: () => number
34
+ /** True when a process with this pid exists on this machine. */
35
+ readonly isPidAlive?: (pid: number) => boolean
36
+ readonly hostname?: string
37
+ }
38
+
39
+ interface OwnerRecord {
40
+ readonly pid: number
41
+ readonly token: string
42
+ readonly acquiredAt: number
43
+ readonly hostname?: string
44
+ }
45
+
46
+ interface Inspection {
47
+ readonly stats: Stats
48
+ readonly content: string
49
+ readonly pid: number | undefined
50
+ /** Set only when the owner is provably gone. */
51
+ readonly staleReason: string | undefined
52
+ }
53
+
54
+ interface Resolved {
55
+ readonly logger: Logger | undefined
56
+ readonly now: () => number
57
+ readonly isPidAlive: (pid: number) => boolean
58
+ readonly hostname: string
59
+ }
60
+
61
+ /**
62
+ * Residual risk, deliberately accepted: a pid that is alive is treated as the
63
+ * owner even when it is an unrelated process that reused the number after the
64
+ * real owner died. That cannot be disproved from here, so such a lock stays and
65
+ * the caller gets the plain "another process is using it" error. It is not
66
+ * bounded by `acquiredAt` age: a lock's age is not a lease.
67
+ */
68
+ function defaultIsPidAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0)
71
+ return true
72
+ } catch (error) {
73
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH'
74
+ }
75
+ }
76
+
77
+ function lockError(path: string, message: string, timeoutMs: number): NamzuError {
21
78
  return new NamzuError({
22
79
  code: 'storage_error',
23
- message: `Memory operation lock ${path}: ${reason}. Stop all processes using this memory store and inspect the lock owner before removing a stale lock; it is never broken automatically.`,
80
+ message,
24
81
  details: { lockPath: path, timeoutMs },
25
82
  retryable: false,
26
83
  })
27
84
  }
28
85
 
86
+ function busyError(path: string, pid: number | undefined, timeoutMs: number): NamzuError {
87
+ const who =
88
+ pid === undefined ? 'Another Namzu process is' : `Another Namzu process (pid ${pid}) is`
89
+ return lockError(
90
+ path,
91
+ `${who} using the stored memory. Close it, or wait for it to finish.`,
92
+ timeoutMs,
93
+ )
94
+ }
95
+
96
+ function parseOwner(content: string): OwnerRecord | undefined {
97
+ try {
98
+ const value: unknown = JSON.parse(content)
99
+ if (!value || typeof value !== 'object') return undefined
100
+ const pid = (value as { pid?: unknown }).pid
101
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
102
+ const host = (value as { hostname?: unknown }).hostname
103
+ return {
104
+ pid,
105
+ token: String((value as { token?: unknown }).token ?? ''),
106
+ acquiredAt: Number((value as { acquiredAt?: unknown }).acquiredAt ?? 0),
107
+ ...(typeof host === 'string' && host.length > 0 ? { hostname: host } : {}),
108
+ }
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** Reads the file at `path`; undefined when it is gone or was swapped while reading. */
115
+ async function inspect(path: string, env: Resolved): Promise<Inspection | undefined> {
116
+ const before = await lstat(path).catch((error: NodeJS.ErrnoException) => {
117
+ if (error.code === 'ENOENT') return undefined
118
+ throw error
119
+ })
120
+ if (!before) return undefined
121
+ if (!before.isFile() || before.isSymbolicLink())
122
+ return { stats: before, content: '', pid: undefined, staleReason: undefined }
123
+ const content = await readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => {
124
+ if (error.code === 'ENOENT') return undefined
125
+ throw error
126
+ })
127
+ if (content === undefined) return undefined
128
+ const after = await lstat(path).catch(() => undefined)
129
+ if (!after || after.dev !== before.dev || after.ino !== before.ino) return undefined
130
+
131
+ const owner = parseOwner(content)
132
+ if (owner) {
133
+ // A record from another host on a shared filesystem cannot be checked from
134
+ // here; it is live. A record without a host predates the field: local.
135
+ if (owner.hostname !== undefined && owner.hostname !== env.hostname) {
136
+ return { stats: before, content, pid: owner.pid, staleReason: undefined }
137
+ }
138
+ return {
139
+ stats: before,
140
+ content,
141
+ pid: owner.pid,
142
+ staleReason: env.isPidAlive(owner.pid) ? undefined : `owner process ${owner.pid} is gone`,
143
+ }
144
+ }
145
+ const age = env.now() - before.mtimeMs
146
+ return {
147
+ stats: before,
148
+ content,
149
+ pid: undefined,
150
+ staleReason:
151
+ age > MEMORY_LOCK_RECORD_GRACE_MS
152
+ ? `lock holds no readable owner record and is ${Math.round(age / 1000)}s old`
153
+ : undefined,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Creates `path` exclusively and writes this process's owner record. Returns
159
+ * undefined when the path is occupied. A failed write (a full disk) leaves no
160
+ * file behind.
161
+ */
162
+ async function createOwned(
163
+ path: string,
164
+ env: Resolved,
165
+ ): Promise<{ identity: Stats; owner: string } | undefined> {
166
+ let handle: Awaited<ReturnType<typeof open>>
167
+ try {
168
+ handle = await open(path, 'wx', 0o600)
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined
171
+ throw error
172
+ }
173
+ const owner = `${JSON.stringify({
174
+ pid: process.pid,
175
+ token: randomUUID(),
176
+ acquiredAt: Date.now(),
177
+ hostname: env.hostname,
178
+ })}\n`
179
+ let identity: Stats | undefined
180
+ try {
181
+ identity = await handle.stat()
182
+ await handle.writeFile(owner, 'utf8')
183
+ await handle.sync()
184
+ } catch (error) {
185
+ await handle.close().catch(() => undefined)
186
+ // We created this path exclusively a moment ago; drop it so no empty or
187
+ // partial record is left to block the next start.
188
+ const current = await lstat(path).catch(() => undefined)
189
+ if (!identity || (current?.dev === identity.dev && current.ino === identity.ino)) {
190
+ await unlink(path).catch(() => undefined)
191
+ }
192
+ throw error
193
+ }
194
+ await handle.close()
195
+ return { identity, owner }
196
+ }
197
+
198
+ async function releaseOwned(
199
+ path: string,
200
+ identity: Stats,
201
+ owner: string,
202
+ timeoutMs: number,
203
+ ): Promise<void> {
204
+ const current = await lstat(path)
205
+ if (
206
+ !current.isFile() ||
207
+ current.isSymbolicLink() ||
208
+ current.dev !== identity.dev ||
209
+ current.ino !== identity.ino ||
210
+ (await readFile(path, 'utf8')) !== owner
211
+ ) {
212
+ throw lockError(
213
+ path,
214
+ `Memory operation lock ${path}: the lock owner changed before release.`,
215
+ timeoutMs,
216
+ )
217
+ }
218
+ await unlink(path)
219
+ }
220
+
221
+ function sameFile(a: Stats, b: Stats): boolean {
222
+ return a.dev === b.dev && a.ino === b.ino
223
+ }
224
+
225
+ /** Unlinks `path` only if it is still exactly the stale file that was inspected. */
226
+ async function unlinkIfStillStale(path: string, seen: Inspection, env: Resolved): Promise<boolean> {
227
+ const again = await inspect(path, env)
228
+ if (!again || !sameFile(again.stats, seen.stats) || again.content !== seen.content) return false
229
+ if (again.staleReason === undefined) return false
230
+ // Narrow the window further: the inode must still be the inspected one now.
231
+ const last = await lstat(path).catch(() => undefined)
232
+ if (!last || !sameFile(last, seen.stats)) return false
233
+ await unlink(path).catch((error: NodeJS.ErrnoException) => {
234
+ if (error.code !== 'ENOENT') throw error
235
+ })
236
+ return true
237
+ }
238
+
239
+ /**
240
+ * Removes a lock whose owner is provably gone. Two processes that see the same
241
+ * stale lock are serialised through `<lock>.break`; the lock is re-inspected
242
+ * under it and only the inspected inode is unlinked. Returns whether the lock
243
+ * was removed.
244
+ */
245
+ async function breakStaleLock(
246
+ path: string,
247
+ seen: Inspection,
248
+ timeoutMs: number,
249
+ env: Resolved,
250
+ ): Promise<boolean> {
251
+ const breakPath = `${path}.break`
252
+ let held: { identity: Stats; owner: string } | undefined
253
+ for (let attempt = 0; attempt < 2 && !held; attempt++) {
254
+ held = await createOwned(breakPath, env)
255
+ if (held) break
256
+ const breaker = await inspect(breakPath, env)
257
+ if (!breaker) continue
258
+ // A dead breaker's file follows the same rule, once.
259
+ if (breaker.staleReason === undefined || !(await unlinkIfStillStale(breakPath, breaker, env))) {
260
+ return false
261
+ }
262
+ }
263
+ if (!held) return false
264
+ try {
265
+ const removed = await unlinkIfStillStale(path, seen, env)
266
+ if (removed) {
267
+ env.logger?.info('Broke an abandoned memory operation lock', {
268
+ 'namzu.store.lock_path': path,
269
+ 'namzu.store.lock_reason': seen.staleReason,
270
+ })
271
+ }
272
+ return removed
273
+ } finally {
274
+ await releaseOwned(breakPath, held.identity, held.owner, timeoutMs).catch(() => undefined)
275
+ }
276
+ }
277
+
29
278
  /**
30
279
  * Local-filesystem exclusion for cooperating processes. Atomic rename of the
31
280
  * index alone cannot serialize the index/content read-modify-write transaction.
32
- * An abandoned lock fails closed; guessing a lease expired would permit two
33
- * live writers to overwrite each other again.
281
+ * A lock whose owner is provably gone (dead pid on this host, or an owner-less
282
+ * file older than the grace period) is broken; a live or unverifiable owner is
283
+ * waited for until the timeout, never replaced on a guessed lease.
34
284
  */
35
285
  export async function acquireMemoryOperationLock(
36
286
  path: string,
37
287
  timeoutMs: number,
288
+ options: MemoryOperationLockOptions = {},
38
289
  ): Promise<() => Promise<void>> {
290
+ const env: Resolved = {
291
+ logger: options.logger,
292
+ now: options.now ?? Date.now,
293
+ isPidAlive: options.isPidAlive ?? defaultIsPidAlive,
294
+ hostname: options.hostname ?? osHostname(),
295
+ }
39
296
  const deadline = performance.now() + timeoutMs
40
297
  let attempted = false
298
+ let lastPid: number | undefined
299
+ // The wait bound limits waiting for a live owner, not the attempt that
300
+ // follows a successful break: the lock is free then, and breaking it can
301
+ // itself outlast a short bound on a slow disk.
302
+ let justBroke = false
41
303
  for (;;) {
42
- if (attempted && performance.now() >= deadline) {
43
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
304
+ if (attempted && !justBroke && performance.now() >= deadline) {
305
+ throw busyError(path, lastPid, timeoutMs)
44
306
  }
307
+ justBroke = false
45
308
  attempted = true
46
- let handle: FileHandle
47
- try {
48
- handle = await open(path, 'wx', 0o600)
49
- } catch (error) {
50
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
51
- const existing = await lstat(path).catch((statError: NodeJS.ErrnoException) => {
52
- if (statError.code === 'ENOENT') return undefined
53
- throw statError
54
- })
55
- if (!existing) continue
56
- if (!existing.isFile() || existing.isSymbolicLink()) {
57
- throw lockError(path, 'the occupied lock is not a regular file', timeoutMs)
58
- }
59
- if (performance.now() >= deadline) {
60
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
61
- }
62
- await delay(Math.min(25, Math.max(1, deadline - performance.now())))
63
- continue
309
+ const created = await createOwned(path, env)
310
+ if (created) {
311
+ return () => releaseOwned(path, created.identity, created.owner, timeoutMs)
64
312
  }
65
-
66
- const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), acquiredAt: Date.now() })}\n`
67
- const identity = await handle.stat().catch(async (error: unknown) => {
68
- await handle.close()
69
- // Exclusive create happened, but ownership could not be established.
70
- // Leave the path for explicit recovery rather than unlink an unknown inode.
71
- throw error
72
- })
73
- try {
74
- await handle.writeFile(owner, 'utf8')
75
- await handle.sync()
76
- } catch (error) {
77
- await handle.close()
78
- const current = await lstat(path).catch(() => undefined)
79
- if (current?.dev === identity.dev && current.ino === identity.ino) {
80
- await unlink(path).catch(() => undefined)
81
- }
82
- throw error
313
+ const seen = await inspect(path, env)
314
+ if (!seen) continue
315
+ if (!seen.stats.isFile() || seen.stats.isSymbolicLink()) {
316
+ throw lockError(
317
+ path,
318
+ `The stored memory lock ${path} is not a regular file, so Namzu will not use it. Move it aside to continue.`,
319
+ timeoutMs,
320
+ )
83
321
  }
84
- await handle.close()
85
- return async () => {
86
- const current = await lstat(path)
87
- if (
88
- !current.isFile() ||
89
- current.isSymbolicLink() ||
90
- current.dev !== identity.dev ||
91
- current.ino !== identity.ino ||
92
- (await readFile(path, 'utf8')) !== owner
93
- ) {
94
- throw lockError(path, 'the lock owner changed before release', timeoutMs)
95
- }
96
- await unlink(path)
322
+ lastPid = seen.pid
323
+ if (seen.staleReason !== undefined && (await breakStaleLock(path, seen, timeoutMs, env))) {
324
+ justBroke = true
325
+ continue
97
326
  }
327
+ if (performance.now() >= deadline) throw busyError(path, lastPid, timeoutMs)
328
+ await delay(Math.min(25, Math.max(1, deadline - performance.now())))
98
329
  }
99
330
  }