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

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