@smthrs/artifacts 0.0.0-stage → 1.0.0-rc.3

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 (171) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -2
  4. package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
  5. package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
  6. package/dist/cjs/ArtifactBackupLease.js +175 -0
  7. package/dist/cjs/ArtifactBackupLease.js.map +7 -0
  8. package/dist/cjs/ArtifactStore.d.ts +129 -0
  9. package/dist/cjs/ArtifactStore.d.ts.map +1 -0
  10. package/dist/cjs/ArtifactStore.js +73 -0
  11. package/dist/cjs/ArtifactStore.js.map +7 -0
  12. package/dist/cjs/ArtifactStoreError.d.ts +82 -0
  13. package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
  14. package/dist/cjs/ArtifactStoreError.js +72 -0
  15. package/dist/cjs/ArtifactStoreError.js.map +7 -0
  16. package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
  17. package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
  18. package/dist/cjs/ArtifactStoreMetrics.js +51 -0
  19. package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
  20. package/dist/cjs/ArtifactSweep.d.ts +185 -0
  21. package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
  22. package/dist/cjs/ArtifactSweep.js +163 -0
  23. package/dist/cjs/ArtifactSweep.js.map +7 -0
  24. package/dist/cjs/CombinedArtifacts.d.ts +103 -0
  25. package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
  26. package/dist/cjs/CombinedArtifacts.js +189 -0
  27. package/dist/cjs/CombinedArtifacts.js.map +7 -0
  28. package/dist/cjs/FileLease.d.ts +113 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +201 -0
  31. package/dist/cjs/FileLease.js.map +7 -0
  32. package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
  33. package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
  34. package/dist/cjs/FileSystemArtifactStore.js +276 -0
  35. package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
  36. package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
  37. package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
  38. package/dist/cjs/MemoryArtifactStore.js +84 -0
  39. package/dist/cjs/MemoryArtifactStore.js.map +7 -0
  40. package/dist/cjs/NoopArtifactStore.d.ts +17 -0
  41. package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
  42. package/dist/cjs/NoopArtifactStore.js +46 -0
  43. package/dist/cjs/NoopArtifactStore.js.map +7 -0
  44. package/dist/cjs/RemoteArtifacts.d.ts +237 -0
  45. package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
  46. package/dist/cjs/RemoteArtifacts.js +405 -0
  47. package/dist/cjs/RemoteArtifacts.js.map +7 -0
  48. package/dist/cjs/index.d.ts +65 -0
  49. package/dist/cjs/index.d.ts.map +1 -0
  50. package/dist/cjs/index.js +47 -0
  51. package/dist/cjs/index.js.map +7 -0
  52. package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
  53. package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
  54. package/dist/cjs/internal/ArtifactLocks.js +93 -0
  55. package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
  56. package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
  57. package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
  58. package/dist/cjs/internal/ArtifactPath.js +74 -0
  59. package/dist/cjs/internal/ArtifactPath.js.map +7 -0
  60. package/dist/cjs/measureBytes.d.ts +18 -0
  61. package/dist/cjs/measureBytes.d.ts.map +1 -0
  62. package/dist/cjs/measureBytes.js +43 -0
  63. package/dist/cjs/measureBytes.js.map +7 -0
  64. package/dist/cjs/package.json +1 -0
  65. package/dist/cjs/snapshotBytes.d.ts +19 -0
  66. package/dist/cjs/snapshotBytes.d.ts.map +1 -0
  67. package/dist/cjs/snapshotBytes.js +41 -0
  68. package/dist/cjs/snapshotBytes.js.map +7 -0
  69. package/dist/cjs/validateDigest.d.ts +22 -0
  70. package/dist/cjs/validateDigest.d.ts.map +1 -0
  71. package/dist/cjs/validateDigest.js +42 -0
  72. package/dist/cjs/validateDigest.js.map +7 -0
  73. package/dist/esm/ArtifactBackupLease.d.ts +34 -0
  74. package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
  75. package/dist/esm/ArtifactBackupLease.js +117 -0
  76. package/dist/esm/ArtifactBackupLease.js.map +1 -0
  77. package/dist/esm/ArtifactStore.d.ts +129 -0
  78. package/dist/esm/ArtifactStore.d.ts.map +1 -0
  79. package/dist/esm/ArtifactStore.js +83 -0
  80. package/dist/esm/ArtifactStore.js.map +1 -0
  81. package/dist/esm/ArtifactStoreError.d.ts +82 -0
  82. package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
  83. package/dist/esm/ArtifactStoreError.js +77 -0
  84. package/dist/esm/ArtifactStoreError.js.map +1 -0
  85. package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
  86. package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/ArtifactStoreMetrics.js +77 -0
  88. package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
  89. package/dist/esm/ArtifactSweep.d.ts +185 -0
  90. package/dist/esm/ArtifactSweep.d.ts.map +1 -0
  91. package/dist/esm/ArtifactSweep.js +202 -0
  92. package/dist/esm/ArtifactSweep.js.map +1 -0
  93. package/dist/esm/CombinedArtifacts.d.ts +103 -0
  94. package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
  95. package/dist/esm/CombinedArtifacts.js +214 -0
  96. package/dist/esm/CombinedArtifacts.js.map +1 -0
  97. package/dist/esm/FileLease.d.ts +113 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +239 -0
  100. package/dist/esm/FileLease.js.map +1 -0
  101. package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
  102. package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
  103. package/dist/esm/FileSystemArtifactStore.js +336 -0
  104. package/dist/esm/FileSystemArtifactStore.js.map +1 -0
  105. package/dist/esm/MemoryArtifactStore.d.ts +25 -0
  106. package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
  107. package/dist/esm/MemoryArtifactStore.js +67 -0
  108. package/dist/esm/MemoryArtifactStore.js.map +1 -0
  109. package/dist/esm/NoopArtifactStore.d.ts +17 -0
  110. package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
  111. package/dist/esm/NoopArtifactStore.js +27 -0
  112. package/dist/esm/NoopArtifactStore.js.map +1 -0
  113. package/dist/esm/RemoteArtifacts.d.ts +237 -0
  114. package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
  115. package/dist/esm/RemoteArtifacts.js +554 -0
  116. package/dist/esm/RemoteArtifacts.js.map +1 -0
  117. package/dist/esm/index.d.ts +65 -0
  118. package/dist/esm/index.d.ts.map +1 -0
  119. package/dist/esm/index.js +65 -0
  120. package/dist/esm/index.js.map +1 -0
  121. package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
  122. package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
  123. package/dist/esm/internal/ArtifactLocks.js +86 -0
  124. package/dist/esm/internal/ArtifactLocks.js.map +1 -0
  125. package/dist/esm/internal/ArtifactPath.d.ts +19 -0
  126. package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
  127. package/dist/esm/internal/ArtifactPath.js +59 -0
  128. package/dist/esm/internal/ArtifactPath.js.map +1 -0
  129. package/dist/esm/measureBytes.d.ts +18 -0
  130. package/dist/esm/measureBytes.d.ts.map +1 -0
  131. package/dist/esm/measureBytes.js +22 -0
  132. package/dist/esm/measureBytes.js.map +1 -0
  133. package/dist/esm/snapshotBytes.d.ts +19 -0
  134. package/dist/esm/snapshotBytes.d.ts.map +1 -0
  135. package/dist/esm/snapshotBytes.js +30 -0
  136. package/dist/esm/snapshotBytes.js.map +1 -0
  137. package/dist/esm/validateDigest.d.ts +22 -0
  138. package/dist/esm/validateDigest.d.ts.map +1 -0
  139. package/dist/esm/validateDigest.js +26 -0
  140. package/dist/esm/validateDigest.js.map +1 -0
  141. package/docs/README.md +200 -0
  142. package/docs/api.md +474 -0
  143. package/docs/concepts/content-addressing.md +174 -0
  144. package/docs/concepts/coordination.md +141 -0
  145. package/docs/concepts/tiers.md +147 -0
  146. package/docs/guides/fence-a-backup.md +123 -0
  147. package/docs/guides/reclaim-disk-space.md +143 -0
  148. package/docs/guides/serve-the-artifact-protocol.md +140 -0
  149. package/docs/guides/share-artifacts-across-machines.md +180 -0
  150. package/docs/guides/test-against-an-artifact-store.md +158 -0
  151. package/docs/installation.md +98 -0
  152. package/docs/quickstart.md +182 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +213 -0
  156. package/src/ArtifactStore.ts +152 -0
  157. package/src/ArtifactStoreError.ts +96 -0
  158. package/src/ArtifactStoreMetrics.ts +83 -0
  159. package/src/ArtifactSweep.ts +336 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +380 -0
  162. package/src/FileSystemArtifactStore.ts +459 -0
  163. package/src/MemoryArtifactStore.ts +83 -0
  164. package/src/NoopArtifactStore.ts +30 -0
  165. package/src/RemoteArtifacts.ts +809 -0
  166. package/src/index.ts +71 -0
  167. package/src/internal/ArtifactLocks.ts +110 -0
  168. package/src/internal/ArtifactPath.ts +72 -0
  169. package/src/measureBytes.ts +28 -0
  170. package/src/snapshotBytes.ts +34 -0
  171. package/src/validateDigest.ts +31 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * A cross-process lease on one lock file.
3
+ *
4
+ * The lock is a file created with `wx` (exclusive create) that holds a unique
5
+ * owner token. The holder refreshes its mtime every `heartbeatEvery`; a lock
6
+ * whose mtime is older than `staleAfterMs` belongs to a crashed or hard-killed
7
+ * owner and is reclaimed. Reclaimers of one lock generation race for a `wx`
8
+ * claim file first, so a stale generation is moved away at most once and a
9
+ * fresh lock is never displaced. Acquisition is bounded by `acquireWithin`,
10
+ * so a waiter fails with a typed error instead of waiting forever on a lock
11
+ * nothing will release.
12
+ *
13
+ * | Setting | Default |
14
+ * | ---------------- | ----------- |
15
+ * | `heartbeatEvery` | 10 seconds |
16
+ * | `staleAfterMs` | 60 000 ms |
17
+ * | `acquireWithin` | 2 minutes |
18
+ * | `retryEvery` | 25 millis |
19
+ *
20
+ * The fence is bounded, not absolute: a holder stalled past `staleAfterMs`
21
+ * (a suspended laptop, a stopped debugger) is reaped while it still runs. Its
22
+ * heartbeat then logs `<label> was reclaimed while its holder was still
23
+ * running`, and the protected effect continues unfenced.
24
+ *
25
+ * A directory at the lock path, left by a release that used an exclusive
26
+ * `makeDirectory` as its lock, is treated as a lock with no readable owner:
27
+ * it is reclaimed once its mtime is past `staleAfterMs`, and waited on
28
+ * before that.
29
+ *
30
+ * The host needs exclusive `wx` file creation, `readFileString`, `stat` with
31
+ * an mtime, `utimes`, `rename`, and `remove`. The claim file and the tombstone
32
+ * a reclaim renames the stale lock to are siblings of the lock:
33
+ * `<lockPath>.reclaim-<token>` and `<lockPath>.stale-<token>`.
34
+ *
35
+ * @since 1.0.0-rc.1
36
+ */
37
+
38
+ import * as Clock from "effect/Clock"
39
+ import * as Deferred from "effect/Deferred"
40
+ import type * as Duration from "effect/Duration"
41
+ import * as Effect from "effect/Effect"
42
+ import type * as FileSystem from "effect/FileSystem"
43
+ import * as Option from "effect/Option"
44
+ import * as PlatformError from "effect/PlatformError"
45
+ import * as Random from "effect/Random"
46
+
47
+ /**
48
+ * Tuning for one lease.
49
+ *
50
+ * @category models
51
+ * @since 1.0.0-rc.1
52
+ */
53
+ export interface Options {
54
+ /** Names the lock in the reclaimed-holder warning. Defaults to `"File lease"`. */
55
+ readonly label?: string | undefined
56
+ /** Extra fields logged with the reclaimed-holder warning. */
57
+ readonly annotations?: Readonly<Record<string, unknown>> | undefined
58
+ /** How often the holder refreshes the lock's mtime. Defaults to 10 seconds. */
59
+ readonly heartbeatEvery?: Duration.Input | undefined
60
+ /** Age past which a lock or claim is stale. Defaults to 60 000 ms. */
61
+ readonly staleAfterMs?: number | undefined
62
+ /** How long acquisition may wait. Defaults to 2 minutes. */
63
+ readonly acquireWithin?: Duration.Input | undefined
64
+ /** How long a waiter sleeps between attempts. Defaults to 25 millis. */
65
+ readonly retryEvery?: Duration.Input | undefined
66
+ }
67
+
68
+ /**
69
+ * {@link Options} for {@link hold}, which leaves the deadline to its caller.
70
+ *
71
+ * @category models
72
+ * @since 1.0.0-rc.1
73
+ */
74
+ export interface HoldOptions extends Omit<Options, "acquireWithin"> {
75
+ /** Runs once, the moment the lease is held and before `effect` starts. */
76
+ readonly onAcquired?: Effect.Effect<void> | undefined
77
+ /**
78
+ * Runs before every create, heartbeat, rename, and remove of the lock, its
79
+ * claim, or its tombstone. A failure refuses that step through `failure`,
80
+ * so a caller can re-check that the lock directory was not replaced (for
81
+ * example by a symlink) after it was created.
82
+ */
83
+ readonly guard?: Effect.Effect<void, unknown> | undefined
84
+ }
85
+
86
+ /**
87
+ * The default acquisition deadline.
88
+ *
89
+ * @category constants
90
+ * @since 1.0.0-rc.1
91
+ */
92
+ export const defaultAcquireWithin: Duration.Input = "2 minutes"
93
+
94
+ /**
95
+ * The default stale bound, in milliseconds.
96
+ *
97
+ * @category constants
98
+ * @since 1.0.0-rc.1
99
+ */
100
+ export const defaultStaleAfterMs = 60_000
101
+
102
+ const isReason = (cause: unknown, tag: PlatformError.SystemErrorTag): boolean =>
103
+ cause instanceof PlatformError.PlatformError && cause.reason._tag === tag
104
+
105
+ /** The generation name of a lock with no readable owner: a directory. */
106
+ const directoryGeneration = "legacy-directory"
107
+
108
+ const token = Effect.gen(function*() {
109
+ const first = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
110
+ const second = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
111
+ return `${first.toString(36)}-${second.toString(36)}-${yield* Clock.currentTimeMillis}`
112
+ })
113
+
114
+ /**
115
+ * Runs `effect` while holding the lease at `lockPath`, with no acquisition
116
+ * deadline. Use it when the caller already bounds a wider wait (an
117
+ * in-process semaphore plus this lease) with its own deadline; otherwise use
118
+ * {@link withLease}.
119
+ *
120
+ * @category combinators
121
+ * @since 1.0.0-rc.1
122
+ */
123
+ export const hold = <A, E, R, E2>(
124
+ fs: FileSystem.FileSystem,
125
+ lockPath: string,
126
+ effect: Effect.Effect<A, E, R>,
127
+ failure: (cause: unknown) => E2,
128
+ options: HoldOptions = {}
129
+ ): Effect.Effect<A, E | E2, R> =>
130
+ Effect.gen(function*() {
131
+ const label = options.label ?? "File lease"
132
+ const heartbeatEvery = options.heartbeatEvery ?? "10 seconds"
133
+ const staleAfterMs = options.staleAfterMs ?? defaultStaleAfterMs
134
+ const retryEvery = options.retryEvery ?? "25 millis"
135
+ const owner = yield* token
136
+ /**
137
+ * Whether this call created the lock file, and therefore owes a release.
138
+ * It is set in the same uninterruptible step that creates the file, so
139
+ * interruption striking between the two cannot leak a lock nothing
140
+ * releases — the only party that would ever reclaim it is a later
141
+ * acquirer of the same path, which may never come.
142
+ */
143
+ let acquired = false
144
+
145
+ const guard = options.guard === undefined ? Effect.void : options.guard.pipe(Effect.mapError(failure))
146
+ const guarded = <B, F, S>(mutation: Effect.Effect<B, F, S>): Effect.Effect<B, F | E2, S> =>
147
+ Effect.andThen(guard, mutation)
148
+
149
+ const statIfPresent = (path: string): Effect.Effect<Option.Option<FileSystem.File.Info>, E2> =>
150
+ fs.stat(path).pipe(
151
+ Effect.map(Option.some),
152
+ Effect.catch((cause): Effect.Effect<Option.Option<FileSystem.File.Info>, E2> =>
153
+ isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : Effect.fail(failure(cause))
154
+ )
155
+ )
156
+
157
+ const refuse = (cause: unknown): Effect.Effect<never, E2> => Effect.fail(failure(cause))
158
+
159
+ const readOwner = (
160
+ path: string,
161
+ onRefused: (cause: unknown) => Effect.Effect<Option.Option<string>, E2> = refuse
162
+ ): Effect.Effect<Option.Option<string>, E2> =>
163
+ fs.readFileString(path).pipe(
164
+ Effect.map(Option.some),
165
+ Effect.catch((cause) => isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : onRefused(cause))
166
+ )
167
+
168
+ /**
169
+ * Names the lock generation at `path`: its owner token, or
170
+ * `directoryGeneration` for a directory, which has no readable owner.
171
+ * A read refused for any other reason is propagated, not guessed at.
172
+ */
173
+ const observe = (path: string): Effect.Effect<Option.Option<string>, E2> =>
174
+ readOwner(path, (cause) =>
175
+ fs.stat(path).pipe(
176
+ Effect.matchEffect({
177
+ onFailure: () => refuse(cause),
178
+ onSuccess: (info) =>
179
+ info.type === "Directory" ? Effect.succeed(Option.some(directoryGeneration)) : refuse(cause)
180
+ })
181
+ ))
182
+
183
+ const isStale = (path: string): Effect.Effect<boolean, E2> =>
184
+ Effect.gen(function*() {
185
+ const info = yield* statIfPresent(path)
186
+ const modified = Option.isSome(info) ? Option.getOrUndefined(info.value.mtime) : undefined
187
+ return modified !== undefined && (yield* Clock.currentTimeMillis) - modified.getTime() > staleAfterMs
188
+ })
189
+
190
+ const removeIfOwnedBy = (path: string, expected: string): Effect.Effect<void> =>
191
+ fs.readFileString(path).pipe(
192
+ Effect.flatMap((found) => found === expected ? guarded(fs.remove(path)) : Effect.void),
193
+ Effect.ignore
194
+ )
195
+
196
+ const acquire = Effect.gen(function*() {
197
+ while (true) {
198
+ const created = yield* Effect.uninterruptible(
199
+ guarded(fs.writeFileString(lockPath, owner, { flag: "wx", mode: 0o600 })).pipe(
200
+ Effect.andThen(Effect.sync(() => {
201
+ acquired = true
202
+ return true
203
+ })),
204
+ Effect.catch((cause): Effect.Effect<boolean, E2> =>
205
+ isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
206
+ )
207
+ )
208
+ )
209
+ if (created) return
210
+
211
+ // The owner is read before the age, so a stale verdict always
212
+ // belongs to the lock generation this read named.
213
+ const observed = yield* observe(lockPath)
214
+ if (Option.isNone(observed)) continue
215
+ if (yield* isStale(lockPath)) {
216
+ const reclaimed = yield* reclaim(observed.value)
217
+ if (reclaimed) continue
218
+ }
219
+ yield* Effect.sleep(retryEvery)
220
+ }
221
+ })
222
+
223
+ /**
224
+ * Moves away the stale lock generation `observed` names, at most once.
225
+ *
226
+ * Measuring a lock and renaming it are two steps, so two contenders
227
+ * that both measure the same generation as stale would otherwise both
228
+ * rename, and the second would move away the fresh lock the first just
229
+ * took. Reclaimers of one generation therefore race for a `wx` claim
230
+ * named after its owner token first. Only the winner renames, and only
231
+ * after re-reading that the path still holds that stale generation. A
232
+ * loser, or a winner whose generation is already gone, moves nothing.
233
+ * Owner tokens are unique per acquisition, so a claim never outlives
234
+ * the generation it names in any way that matters.
235
+ */
236
+ const reclaim = (observed: string): Effect.Effect<boolean, E2> => {
237
+ const claimPath = `${lockPath}.reclaim-${observed.replace(/[^0-9A-Za-z-]/g, "_").slice(0, 96)}`
238
+ return Effect.acquireUseRelease(
239
+ guarded(fs.writeFileString(claimPath, owner, { flag: "wx", mode: 0o600 })).pipe(
240
+ Effect.as(true),
241
+ Effect.catch((cause): Effect.Effect<boolean, E2> =>
242
+ isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
243
+ )
244
+ ),
245
+ (claimed) =>
246
+ Effect.gen(function*() {
247
+ if (!claimed) {
248
+ // A claimant that died mid-reclaim leaves its claim behind.
249
+ // It ages out on the same bound as a lock.
250
+ const holder = yield* readOwner(claimPath)
251
+ if (Option.isSome(holder) && (yield* isStale(claimPath))) {
252
+ yield* removeIfOwnedBy(claimPath, holder.value)
253
+ }
254
+ return false
255
+ }
256
+ const current = yield* observe(lockPath)
257
+ if (Option.isNone(current) || current.value !== observed || !(yield* isStale(lockPath))) return true
258
+ const tombstone = `${lockPath}.stale-${owner}`
259
+ const moved = yield* guarded(fs.rename(lockPath, tombstone)).pipe(
260
+ Effect.as(true),
261
+ Effect.catch((cause): Effect.Effect<boolean, E2> =>
262
+ isReason(cause, "NotFound") ? Effect.succeed(false) : Effect.fail(failure(cause))
263
+ )
264
+ )
265
+ if (!moved) return true
266
+ // Only a stalled owner releasing between the re-read and the
267
+ // rename, and a new owner acquiring in that gap, puts a live
268
+ // lock here. Hand it back with an atomic create, which cannot
269
+ // displace anyone who took the path since.
270
+ const displaced = yield* observe(tombstone)
271
+ if (
272
+ Option.isSome(displaced) && displaced.value !== observed && displaced.value !== directoryGeneration
273
+ ) {
274
+ yield* guarded(fs.writeFileString(lockPath, displaced.value, { flag: "wx", mode: 0o600 })).pipe(
275
+ Effect.ignore
276
+ )
277
+ }
278
+ yield* guarded(fs.remove(tombstone, { recursive: true })).pipe(Effect.ignore)
279
+ return true
280
+ }),
281
+ (claimed) => claimed ? removeIfOwnedBy(claimPath, owner) : Effect.void
282
+ )
283
+ }
284
+
285
+ const release = fs.readFileString(lockPath).pipe(
286
+ Effect.flatMap((found) => found === owner ? guarded(fs.remove(lockPath)) : Effect.void),
287
+ // A concurrent stale-lock reaper can win release. `NotFound`
288
+ // means no lock remains for this owner to release or leak.
289
+ Effect.catch((cause): Effect.Effect<void, E2> =>
290
+ isReason(cause, "NotFound") ? Effect.void : Effect.fail(failure(cause))
291
+ )
292
+ )
293
+
294
+ // The finalizer is attached around acquisition itself, not just around
295
+ // the protected effect, so a lock created moments before an interruption
296
+ // is still released. A call that never created one owes nothing and must
297
+ // not touch a file another owner holds.
298
+ return yield* acquire.pipe(
299
+ Effect.andThen(options.onAcquired ?? Effect.void),
300
+ Effect.andThen(Effect.scoped(Effect.gen(function*() {
301
+ yield* Effect.forkScoped(
302
+ Effect.gen(function*() {
303
+ while (true) {
304
+ yield* Effect.sleep(heartbeatEvery)
305
+ // Freshen only a lock this call still owns, the same comparison
306
+ // the release below makes. A holder stalled past the stale
307
+ // bound has already been reaped and replaced, and touching that
308
+ // pathname anyway would hold a stranger's lock fresh forever: a
309
+ // replacement that is then hard-killed could never be judged
310
+ // stale while this zombie ran, so every later acquirer would
311
+ // burn the full acquisition deadline and fail.
312
+ const beat = yield* fs.readFileString(lockPath).pipe(
313
+ Effect.map((found) => found === owner ? "own" as const : "foreign" as const),
314
+ Effect.catch((cause) =>
315
+ Effect.succeed(isReason(cause, "NotFound") ? "gone" as const : "unreadable" as const)
316
+ )
317
+ )
318
+ if (beat === "own") {
319
+ const timestamp = new Date(yield* Clock.currentTimeMillis)
320
+ yield* Effect.ignore(guarded(fs.utimes(lockPath, timestamp, timestamp)))
321
+ continue
322
+ }
323
+ // A read the host refused for any other reason is no evidence
324
+ // either way, so this beat is skipped and the next one retries.
325
+ // Ending the heartbeat on it would retire a lock this call
326
+ // still holds: the file goes stale within the minute, another
327
+ // process reaps it, and this holder keeps working unfenced.
328
+ if (beat === "unreadable") continue
329
+ // `gone` and `foreign` are the two states worth an operator
330
+ // signal: this call's lock was reaped, and possibly already
331
+ // replaced, while the effect it fences is still running, so
332
+ // the rest of that effect proceeds unfenced against every other
333
+ // process. The heartbeat is the only party that ever observes
334
+ // it — the protected effect is not interrupted and the caller
335
+ // is not failed — so retiring the fiber silently would leave no
336
+ // trace at all.
337
+ yield* Effect.logWarning(
338
+ `${label} was reclaimed while its holder was still running`,
339
+ { ...options.annotations, state: beat }
340
+ )
341
+ return
342
+ }
343
+ })
344
+ )
345
+ return yield* effect
346
+ }))),
347
+ Effect.onExit(() => acquired ? release : Effect.void)
348
+ )
349
+ })
350
+
351
+ /**
352
+ * Runs `effect` while holding the lease at `lockPath`.
353
+ *
354
+ * Acquisition fails with `failure(TimeoutError)` once `acquireWithin` passes
355
+ * without the lease; every other host refusal reaches `failure` with the
356
+ * refusing `PlatformError`. The deadline ends when the lease is held: the
357
+ * protected effect and its release are never timed out.
358
+ *
359
+ * @category combinators
360
+ * @since 1.0.0-rc.1
361
+ */
362
+ export const withLease = <A, E, R, E2>(
363
+ fs: FileSystem.FileSystem,
364
+ lockPath: string,
365
+ effect: Effect.Effect<A, E, R>,
366
+ failure: (cause: unknown) => E2,
367
+ options: Options = {}
368
+ ): Effect.Effect<A, E | E2, R> =>
369
+ Effect.suspend(() => {
370
+ const ready = Deferred.makeUnsafe<void>()
371
+ const deadline = Deferred.await(ready).pipe(
372
+ Effect.timeout(options.acquireWithin ?? defaultAcquireWithin),
373
+ Effect.catchTag("TimeoutError", (cause) => Effect.fail(failure(cause))),
374
+ Effect.andThen(Effect.never)
375
+ )
376
+ return hold(fs, lockPath, effect, failure, {
377
+ ...options,
378
+ onAcquired: Deferred.succeed(ready, undefined).pipe(Effect.asVoid)
379
+ }).pipe(Effect.raceFirst(deadline))
380
+ })