@evolu/common 8.6.2 → 8.8.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 (303) hide show
  1. package/dist/src/Array.d.ts +21 -21
  2. package/dist/src/Array.js +5 -5
  3. package/dist/src/Assert.d.ts +478 -141
  4. package/dist/src/Assert.d.ts.map +1 -1
  5. package/dist/src/Assert.js +644 -99
  6. package/dist/src/Binary.d.ts +254 -0
  7. package/dist/src/Binary.d.ts.map +1 -0
  8. package/dist/src/Binary.js +958 -0
  9. package/dist/src/Console.d.ts +2 -10
  10. package/dist/src/Console.d.ts.map +1 -1
  11. package/dist/src/Console.js +6 -12
  12. package/dist/src/Crypto.d.ts +5 -5
  13. package/dist/src/Crypto.js +1 -1
  14. package/dist/src/Eq.d.ts +35 -40
  15. package/dist/src/Eq.d.ts.map +1 -1
  16. package/dist/src/Eq.js +51 -87
  17. package/dist/src/Function.d.ts +9 -14
  18. package/dist/src/Function.d.ts.map +1 -1
  19. package/dist/src/Function.js +7 -9
  20. package/dist/src/Http.d.ts +6 -7
  21. package/dist/src/Http.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.d.ts.map +1 -1
  23. package/dist/src/LeakDetector.js +1 -0
  24. package/dist/src/LockManager.d.ts +1 -1
  25. package/dist/src/LockManager.js +1 -1
  26. package/dist/src/Lookup.d.ts +3 -2
  27. package/dist/src/Lookup.d.ts.map +1 -1
  28. package/dist/src/Lookup.js +4 -3
  29. package/dist/src/Number.d.ts +1 -1
  30. package/dist/src/Number.d.ts.map +1 -1
  31. package/dist/src/Number.js +1 -1
  32. package/dist/src/Object.d.ts +4 -4
  33. package/dist/src/Object.d.ts.map +1 -1
  34. package/dist/src/Object.js +5 -4
  35. package/dist/src/Option.d.ts +1 -2
  36. package/dist/src/Option.d.ts.map +1 -1
  37. package/dist/src/Option.js +1 -2
  38. package/dist/src/Platform.js +2 -0
  39. package/dist/src/Polyfills.js +4 -1
  40. package/dist/src/Random.d.ts +1 -1
  41. package/dist/src/Result.d.ts +49 -54
  42. package/dist/src/Result.d.ts.map +1 -1
  43. package/dist/src/Result.js +17 -22
  44. package/dist/src/Set.d.ts +6 -6
  45. package/dist/src/Set.js +2 -2
  46. package/dist/src/Sqlite.js +1 -1
  47. package/dist/src/String.js +3 -2
  48. package/dist/src/Task.d.ts +32 -32
  49. package/dist/src/Task.d.ts.map +1 -1
  50. package/dist/src/Task.js +20 -22
  51. package/dist/src/Test.d.ts +31 -3
  52. package/dist/src/Test.d.ts.map +1 -1
  53. package/dist/src/Test.js +46 -3
  54. package/dist/src/Time.d.ts +3 -11
  55. package/dist/src/Time.d.ts.map +1 -1
  56. package/dist/src/Time.js +10 -18
  57. package/dist/src/Type.d.ts +87 -85
  58. package/dist/src/Type.d.ts.map +1 -1
  59. package/dist/src/Type.js +70 -63
  60. package/dist/src/Types.d.ts +12 -12
  61. package/dist/src/Types.js +1 -1
  62. package/dist/src/WebSocket.d.ts +4 -7
  63. package/dist/src/WebSocket.d.ts.map +1 -1
  64. package/dist/src/Worker.d.ts +1 -1
  65. package/dist/src/Worker.js +7 -7
  66. package/dist/src/index.d.ts +1 -1
  67. package/dist/src/index.js +1 -1
  68. package/dist/src/intl/_en.d.ts.map +1 -1
  69. package/dist/src/intl/_en.js +1 -1
  70. package/dist/src/intl/ar.d.ts.map +1 -1
  71. package/dist/src/intl/ar.js +1 -1
  72. package/dist/src/intl/bn.d.ts.map +1 -1
  73. package/dist/src/intl/bn.js +1 -1
  74. package/dist/src/intl/ca.d.ts.map +1 -1
  75. package/dist/src/intl/ca.js +1 -1
  76. package/dist/src/intl/cs.d.ts.map +1 -1
  77. package/dist/src/intl/cs.js +1 -1
  78. package/dist/src/intl/da.d.ts.map +1 -1
  79. package/dist/src/intl/da.js +1 -1
  80. package/dist/src/intl/de.d.ts.map +1 -1
  81. package/dist/src/intl/de.js +1 -1
  82. package/dist/src/intl/el.d.ts.map +1 -1
  83. package/dist/src/intl/el.js +1 -1
  84. package/dist/src/intl/es.d.ts.map +1 -1
  85. package/dist/src/intl/es.js +1 -1
  86. package/dist/src/intl/fa.d.ts.map +1 -1
  87. package/dist/src/intl/fa.js +1 -1
  88. package/dist/src/intl/fi.d.ts.map +1 -1
  89. package/dist/src/intl/fi.js +1 -1
  90. package/dist/src/intl/fil.d.ts.map +1 -1
  91. package/dist/src/intl/fil.js +1 -1
  92. package/dist/src/intl/fr.d.ts.map +1 -1
  93. package/dist/src/intl/fr.js +1 -1
  94. package/dist/src/intl/he.d.ts.map +1 -1
  95. package/dist/src/intl/he.js +1 -1
  96. package/dist/src/intl/hi.d.ts.map +1 -1
  97. package/dist/src/intl/hi.js +1 -1
  98. package/dist/src/intl/hr.d.ts.map +1 -1
  99. package/dist/src/intl/hr.js +1 -1
  100. package/dist/src/intl/hu.d.ts.map +1 -1
  101. package/dist/src/intl/hu.js +1 -1
  102. package/dist/src/intl/id.d.ts.map +1 -1
  103. package/dist/src/intl/id.js +1 -1
  104. package/dist/src/intl/it.d.ts.map +1 -1
  105. package/dist/src/intl/it.js +1 -1
  106. package/dist/src/intl/ja.d.ts.map +1 -1
  107. package/dist/src/intl/ja.js +1 -1
  108. package/dist/src/intl/ko.d.ts.map +1 -1
  109. package/dist/src/intl/ko.js +1 -1
  110. package/dist/src/intl/ml.d.ts.map +1 -1
  111. package/dist/src/intl/ml.js +1 -1
  112. package/dist/src/intl/mr.d.ts.map +1 -1
  113. package/dist/src/intl/mr.js +1 -1
  114. package/dist/src/intl/ms.d.ts.map +1 -1
  115. package/dist/src/intl/ms.js +1 -1
  116. package/dist/src/intl/nb.d.ts.map +1 -1
  117. package/dist/src/intl/nb.js +1 -1
  118. package/dist/src/intl/nl.d.ts.map +1 -1
  119. package/dist/src/intl/nl.js +1 -1
  120. package/dist/src/intl/pa.d.ts.map +1 -1
  121. package/dist/src/intl/pa.js +1 -1
  122. package/dist/src/intl/pl.d.ts.map +1 -1
  123. package/dist/src/intl/pl.js +1 -1
  124. package/dist/src/intl/pt-BR.d.ts.map +1 -1
  125. package/dist/src/intl/pt-BR.js +1 -1
  126. package/dist/src/intl/pt.d.ts.map +1 -1
  127. package/dist/src/intl/pt.js +1 -1
  128. package/dist/src/intl/ro.d.ts.map +1 -1
  129. package/dist/src/intl/ro.js +1 -1
  130. package/dist/src/intl/sk.d.ts.map +1 -1
  131. package/dist/src/intl/sk.js +1 -1
  132. package/dist/src/intl/sl.d.ts.map +1 -1
  133. package/dist/src/intl/sl.js +1 -1
  134. package/dist/src/intl/sv.d.ts.map +1 -1
  135. package/dist/src/intl/sv.js +1 -1
  136. package/dist/src/intl/sw.d.ts.map +1 -1
  137. package/dist/src/intl/sw.js +1 -1
  138. package/dist/src/intl/ta.d.ts.map +1 -1
  139. package/dist/src/intl/ta.js +1 -1
  140. package/dist/src/intl/te.d.ts.map +1 -1
  141. package/dist/src/intl/te.js +1 -1
  142. package/dist/src/intl/th.d.ts.map +1 -1
  143. package/dist/src/intl/th.js +1 -1
  144. package/dist/src/intl/tr.d.ts.map +1 -1
  145. package/dist/src/intl/tr.js +1 -1
  146. package/dist/src/intl/uk.d.ts.map +1 -1
  147. package/dist/src/intl/uk.js +1 -1
  148. package/dist/src/intl/ur.d.ts.map +1 -1
  149. package/dist/src/intl/ur.js +1 -1
  150. package/dist/src/intl/vi.d.ts.map +1 -1
  151. package/dist/src/intl/vi.js +1 -1
  152. package/dist/src/intl/zh-CN.d.ts.map +1 -1
  153. package/dist/src/intl/zh-CN.js +1 -1
  154. package/dist/src/intl/zh-TW.d.ts.map +1 -1
  155. package/dist/src/intl/zh-TW.js +1 -1
  156. package/dist/src/local-first/Evolu.d.ts +12 -12
  157. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  158. package/dist/src/local-first/Evolu.js +2 -2
  159. package/dist/src/local-first/Protocol.d.ts +28 -76
  160. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  161. package/dist/src/local-first/Protocol.js +45 -229
  162. package/dist/src/local-first/Query.d.ts +11 -11
  163. package/dist/src/local-first/Query.js +6 -6
  164. package/dist/src/local-first/Relay.d.ts +2 -2
  165. package/dist/src/local-first/Schema.d.ts +3 -5
  166. package/dist/src/local-first/Schema.d.ts.map +1 -1
  167. package/dist/src/local-first/Schema.js +2 -4
  168. package/dist/src/local-first/Shared.d.ts.map +1 -1
  169. package/dist/src/local-first/Shared.js +5 -5
  170. package/dist/src/local-first/Storage.js +4 -4
  171. package/dist/src/local-first/Timestamp.d.ts +9 -0
  172. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  173. package/dist/src/local-first/Timestamp.js +13 -10
  174. package/package.json +7 -5
  175. package/src/Array.test.ts +809 -0
  176. package/src/Array.ts +21 -21
  177. package/src/Assert.test.ts +1181 -0
  178. package/src/Assert.ts +971 -227
  179. package/src/BigInt.test.ts +28 -0
  180. package/src/Binary.test.ts +1128 -0
  181. package/src/Binary.ts +1377 -0
  182. package/src/Brand.test.ts +90 -0
  183. package/src/Cache.test.ts +183 -0
  184. package/src/Callbacks.test.ts +66 -0
  185. package/src/Console.test.ts +809 -0
  186. package/src/Console.ts +9 -17
  187. package/src/Crypto.test.ts +138 -0
  188. package/src/Crypto.ts +5 -5
  189. package/src/Eq.test.ts +333 -0
  190. package/src/Eq.ts +110 -176
  191. package/src/Error.test.ts +118 -0
  192. package/src/Function.test.ts +204 -0
  193. package/src/Function.ts +10 -15
  194. package/src/Http.test.ts +816 -0
  195. package/src/Http.ts +6 -7
  196. package/src/LeakDetector.test.ts +210 -0
  197. package/src/LeakDetector.ts +1 -0
  198. package/src/LockManager.ts +2 -2
  199. package/src/Lookup.test.ts +418 -0
  200. package/src/Lookup.ts +4 -3
  201. package/src/Microtask.test.ts +140 -0
  202. package/src/Number.test.ts +218 -0
  203. package/src/Number.ts +2 -2
  204. package/src/Object.test.ts +293 -0
  205. package/src/Object.ts +5 -4
  206. package/src/Option.test.ts +104 -0
  207. package/src/Option.ts +1 -2
  208. package/src/Order.test.ts +56 -0
  209. package/src/Platform.ts +2 -0
  210. package/src/Polyfills.ts +3 -4
  211. package/src/Random.test.ts +89 -0
  212. package/src/Random.ts +1 -1
  213. package/src/Ref.test.ts +139 -0
  214. package/src/RefCount.test.ts +184 -0
  215. package/src/Relation.test.ts +505 -0
  216. package/src/Resource.test.ts +4623 -0
  217. package/src/Result.test.ts +1829 -0
  218. package/src/Result.ts +49 -54
  219. package/src/Schedule.test.ts +1554 -0
  220. package/src/Set.test.ts +279 -0
  221. package/src/Set.ts +6 -6
  222. package/src/Sqlite.ts +1 -1
  223. package/src/Store.test.ts +288 -0
  224. package/src/String.test.ts +54 -0
  225. package/src/String.ts +3 -3
  226. package/src/Task.test.ts +10820 -0
  227. package/src/Task.ts +38 -43
  228. package/src/Test.test.ts +88 -0
  229. package/src/Test.ts +49 -3
  230. package/src/Time.test.ts +926 -0
  231. package/src/Time.ts +13 -21
  232. package/src/Type.base64.test.ts +88 -0
  233. package/src/Type.test.ts +20465 -0
  234. package/src/Type.ts +114 -111
  235. package/src/Types.test.ts +324 -0
  236. package/src/Types.ts +13 -13
  237. package/src/WebSocket.ts +4 -7
  238. package/src/Worker.test.ts +755 -0
  239. package/src/Worker.ts +13 -18
  240. package/src/index.ts +1 -1
  241. package/src/intl/_en.ts +1 -1
  242. package/src/intl/ar.ts +1 -1
  243. package/src/intl/bn.ts +1 -1
  244. package/src/intl/ca.ts +1 -1
  245. package/src/intl/cs.ts +1 -1
  246. package/src/intl/da.ts +1 -1
  247. package/src/intl/de.ts +1 -1
  248. package/src/intl/el.ts +1 -1
  249. package/src/intl/es.ts +1 -1
  250. package/src/intl/fa.ts +1 -1
  251. package/src/intl/fi.ts +1 -1
  252. package/src/intl/fil.ts +1 -1
  253. package/src/intl/fr.ts +1 -1
  254. package/src/intl/he.ts +1 -1
  255. package/src/intl/hi.ts +1 -1
  256. package/src/intl/hr.ts +1 -1
  257. package/src/intl/hu.ts +1 -1
  258. package/src/intl/id.ts +1 -1
  259. package/src/intl/intl.test.ts +3106 -0
  260. package/src/intl/it.ts +1 -1
  261. package/src/intl/ja.ts +1 -1
  262. package/src/intl/ko.ts +1 -1
  263. package/src/intl/ml.ts +1 -1
  264. package/src/intl/mr.ts +1 -1
  265. package/src/intl/ms.ts +1 -1
  266. package/src/intl/nb.ts +1 -1
  267. package/src/intl/nl.ts +1 -1
  268. package/src/intl/pa.ts +1 -1
  269. package/src/intl/pl.ts +1 -1
  270. package/src/intl/pt-BR.ts +1 -1
  271. package/src/intl/pt.ts +1 -1
  272. package/src/intl/ro.ts +1 -1
  273. package/src/intl/sk.ts +1 -1
  274. package/src/intl/sl.ts +1 -1
  275. package/src/intl/sv.ts +1 -1
  276. package/src/intl/sw.ts +1 -1
  277. package/src/intl/ta.ts +1 -1
  278. package/src/intl/te.ts +1 -1
  279. package/src/intl/th.ts +1 -1
  280. package/src/intl/tr.ts +1 -1
  281. package/src/intl/uk.ts +1 -1
  282. package/src/intl/ur.ts +1 -1
  283. package/src/intl/vi.ts +1 -1
  284. package/src/intl/zh-CN.ts +1 -1
  285. package/src/intl/zh-TW.ts +1 -1
  286. package/src/local-first/Evolu.test.ts +1921 -0
  287. package/src/local-first/Evolu.ts +18 -14
  288. package/src/local-first/Owner.test.ts +83 -0
  289. package/src/local-first/Protocol.test.ts +1037 -0
  290. package/src/local-first/Protocol.ts +75 -290
  291. package/src/local-first/Query.test.ts +349 -0
  292. package/src/local-first/Query.ts +11 -11
  293. package/src/local-first/Relay.ts +2 -2
  294. package/src/local-first/Schema.ts +3 -5
  295. package/src/local-first/Shared.test.ts +1828 -0
  296. package/src/local-first/Shared.ts +9 -8
  297. package/src/local-first/Storage.ts +4 -4
  298. package/src/local-first/Timestamp.test.ts +374 -0
  299. package/src/local-first/Timestamp.ts +22 -10
  300. package/dist/src/Buffer.d.ts +0 -122
  301. package/dist/src/Buffer.d.ts.map +0 -1
  302. package/dist/src/Buffer.js +0 -69
  303. package/src/Buffer.ts +0 -206
@@ -0,0 +1,4623 @@
1
+ import { describe, it } from "node:test";
2
+ import {
3
+ assertConditionAfterMicrotasks,
4
+ assertEqual,
5
+ assertErr,
6
+ assertFalse,
7
+ assertInstanceOf,
8
+ assertLength,
9
+ assertNotUndefined,
10
+ assertOk,
11
+ assertRejects,
12
+ assertSame,
13
+ assertTrue,
14
+ } from "./Assert.ts";
15
+
16
+ import type { NonEmptyArray, NonEmptyReadonlyArray } from "./Array.ts";
17
+ import { constVoid } from "./Function.ts";
18
+ import { installPolyfills } from "./Polyfills.ts";
19
+ import {
20
+ createSharedResourceByKeyWithClaims,
21
+ createSharedResource,
22
+ createSharedResourceByKey,
23
+ type BorrowedResource,
24
+ type ClaimLease,
25
+ type Lease,
26
+ type SharedResource,
27
+ type SharedResourceByKey,
28
+ type SharedResourceByKeyWithClaims,
29
+ } from "./Resource.ts";
30
+ import { err, ok } from "./Result.ts";
31
+ import {
32
+ AbortError,
33
+ createGate,
34
+ runDisposedAbortReason,
35
+ testAbortReason,
36
+ testCreateRun,
37
+ type Task,
38
+ } from "./Task.ts";
39
+ import { assertType } from "./Type.ts";
40
+
41
+ installPolyfills();
42
+
43
+ /** Creates a fresh Disposable per create call and records lifecycle counts. */
44
+ const createTestResources = () => {
45
+ let createCount = 0;
46
+ let disposeCount = 0;
47
+ const disposedWaiters: Array<() => void> = [];
48
+
49
+ const create: Task<Disposable> = () => {
50
+ createCount++;
51
+ return ok({
52
+ [Symbol.dispose]: () => {
53
+ disposeCount++;
54
+ for (const resolve of disposedWaiters.splice(0)) resolve();
55
+ },
56
+ });
57
+ };
58
+
59
+ return {
60
+ create,
61
+ getCreateCount: () => createCount,
62
+ getDisposeCount: () => disposeCount,
63
+ nextDisposed: () =>
64
+ new Promise<void>((resolve) => {
65
+ disposedWaiters.push(resolve);
66
+ }),
67
+ };
68
+ };
69
+
70
+ const idleMutexSnapshot = {
71
+ policy: "fifo",
72
+ permits: 1,
73
+ taken: 0,
74
+ waiters: [],
75
+ available: 1,
76
+ isIdle: true,
77
+ };
78
+
79
+ describe("BorrowedResource", () => {
80
+ it("preserves resource unions", () => {
81
+ interface SyncResource extends Disposable {
82
+ readonly type: "sync";
83
+ readonly sync: () => void;
84
+ }
85
+ interface AsyncResource extends AsyncDisposable {
86
+ readonly type: "async";
87
+ readonly async: () => void;
88
+ }
89
+
90
+ assertType<
91
+ BorrowedResource<SyncResource | AsyncResource>,
92
+ | { readonly type: "sync"; readonly sync: () => void }
93
+ | { readonly type: "async"; readonly async: () => void }
94
+ >();
95
+ });
96
+ });
97
+
98
+ describe("SharedResource", () => {
99
+ describe("acquire", () => {
100
+ it("lazily creates the resource", async () => {
101
+ await using run = testCreateRun();
102
+
103
+ let createCount = 0;
104
+ const resource: Disposable = { [Symbol.dispose]: constVoid };
105
+
106
+ await using sharedResource = await run.ok(
107
+ createSharedResource(() => {
108
+ createCount++;
109
+ return ok(resource);
110
+ }),
111
+ );
112
+
113
+ assertEqual(createCount, 0);
114
+
115
+ const lease = await run.ok(sharedResource.acquire);
116
+
117
+ assertEqual(createCount, 1);
118
+ assertSame(lease.resource, resource);
119
+ });
120
+
121
+ it("reuses the current resource while a lease is held", async () => {
122
+ await using run = testCreateRun();
123
+ const resources = createTestResources();
124
+
125
+ await using sharedResource = await run.ok(
126
+ createSharedResource(resources.create),
127
+ );
128
+
129
+ const first = await run.ok(sharedResource.acquire);
130
+ const second = await run.ok(sharedResource.acquire);
131
+
132
+ assertEqual(resources.getCreateCount(), 1);
133
+ assertSame(second.resource, first.resource);
134
+ });
135
+
136
+ it("reports whether acquire created the resource", async () => {
137
+ await using run = testCreateRun();
138
+ const resources = createTestResources();
139
+
140
+ await using sharedResource = await run.ok(
141
+ createSharedResource(resources.create),
142
+ );
143
+
144
+ const first = await run.ok(sharedResource.acquire);
145
+ const second = await run.ok(sharedResource.acquire);
146
+
147
+ assertTrue(first.created);
148
+ assertFalse(second.created);
149
+ });
150
+
151
+ it("creates a fresh resource after disposal", async () => {
152
+ await using run = testCreateRun();
153
+ const resources = createTestResources();
154
+
155
+ await using sharedResource = await run.ok(
156
+ createSharedResource(resources.create),
157
+ );
158
+
159
+ const first = await run.ok(sharedResource.acquire);
160
+ const firstResource = first.resource;
161
+ const disposed = resources.nextDisposed();
162
+ first.release();
163
+ await disposed;
164
+
165
+ const second = await run.ok(sharedResource.acquire);
166
+
167
+ assertEqual(resources.getCreateCount(), 2);
168
+ assertFalse(Object.is(second.resource, firstResource));
169
+ });
170
+
171
+ it("waits for async resource disposal", async () => {
172
+ await using run = testCreateRun();
173
+ const disposalStarted = Promise.withResolvers<void>();
174
+ const continueDisposal = Promise.withResolvers<void>();
175
+
176
+ let createCount = 0;
177
+ const create: Task<AsyncDisposable> = () => {
178
+ createCount++;
179
+ return ok({
180
+ [Symbol.asyncDispose]: async () => {
181
+ disposalStarted.resolve();
182
+ await continueDisposal.promise;
183
+ },
184
+ });
185
+ };
186
+
187
+ await using sharedResource = await run.ok(createSharedResource(create));
188
+
189
+ const first = await run.ok(sharedResource.acquire);
190
+ first.release();
191
+ await disposalStarted.promise;
192
+
193
+ const second = run.ok(sharedResource.acquire);
194
+
195
+ assertEqual(createCount, 1);
196
+
197
+ continueDisposal.resolve();
198
+ await second;
199
+
200
+ assertEqual(createCount, 2);
201
+ });
202
+
203
+ it("serializes concurrent first calls so create runs once", async () => {
204
+ await using run = testCreateRun();
205
+ const gate = createGate();
206
+
207
+ let createCount = 0;
208
+ const resource: Disposable = { [Symbol.dispose]: constVoid };
209
+ const create: Task<Disposable> = async (run) => {
210
+ createCount++;
211
+ await run.ok(gate.wait);
212
+ return ok(resource);
213
+ };
214
+
215
+ await using sharedResource = await run.ok(createSharedResource(create));
216
+
217
+ const first = run.ok(sharedResource.acquire);
218
+ const second = run.ok(sharedResource.acquire);
219
+ gate.open();
220
+ const [firstLease, secondLease] = await Promise.all([first, second]);
221
+
222
+ assertEqual(createCount, 1);
223
+ assertSame(firstLease.resource, resource);
224
+ assertSame(secondLease.resource, resource);
225
+ });
226
+
227
+ it("runs to completion when the caller aborts its Fiber", async () => {
228
+ await using run = testCreateRun();
229
+ const gate = createGate();
230
+
231
+ const resource: Disposable = { [Symbol.dispose]: constVoid };
232
+ const create: Task<Disposable> = async (run) => {
233
+ await run.ok(gate.wait);
234
+ return ok(resource);
235
+ };
236
+
237
+ await using sharedResource = await run.ok(createSharedResource(create));
238
+
239
+ const fiber = run.abortable(sharedResource.acquire);
240
+ fiber.abort(testAbortReason);
241
+ gate.open();
242
+
243
+ const result = await fiber;
244
+
245
+ assertOk(result);
246
+ assertSame(result.value.resource, resource);
247
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
248
+ });
249
+
250
+ it("overprovided acquire deps do not replace deps captured for create", async () => {
251
+ await using run = testCreateRun();
252
+
253
+ interface TestResource extends Disposable {
254
+ readonly value: string;
255
+ }
256
+ interface TestDep {
257
+ readonly value: string;
258
+ }
259
+
260
+ await using sharedResource = await run.ok(
261
+ createSharedResource<TestResource, TestDep>(({ deps }) =>
262
+ ok({ value: deps.value, [Symbol.dispose]: constVoid }),
263
+ ),
264
+ { value: "captured" },
265
+ );
266
+
267
+ const lease = await run.ok(sharedResource.acquire, {
268
+ value: "replacement",
269
+ });
270
+
271
+ assertEqual(lease.resource.value, "captured");
272
+ });
273
+ });
274
+
275
+ describe("acquireCurrent", () => {
276
+ it("returns undefined without creating a resource", async () => {
277
+ await using run = testCreateRun();
278
+ const resources = createTestResources();
279
+
280
+ await using sharedResource = await run.ok(
281
+ createSharedResource(resources.create),
282
+ );
283
+
284
+ const lease = await run.ok(sharedResource.acquireCurrent);
285
+
286
+ assertSame(lease, undefined);
287
+ assertEqual(resources.getCreateCount(), 0);
288
+ });
289
+
290
+ it("waits for in-flight creation and acquires the new resource", async () => {
291
+ await using run = testCreateRun();
292
+ const resources = createTestResources();
293
+ const gate = createGate();
294
+
295
+ await using sharedResource = await run.ok(
296
+ createSharedResource(async (run) => {
297
+ await run.ok(gate.wait);
298
+ return run(resources.create);
299
+ }),
300
+ );
301
+
302
+ const first = run.ok(sharedResource.acquire);
303
+ const current = run.ok(sharedResource.acquireCurrent);
304
+ gate.open();
305
+
306
+ const firstLease = await first;
307
+ const currentLease = await current;
308
+
309
+ assertSame(currentLease?.resource, firstLease.resource);
310
+ assertFalse(currentLease?.created);
311
+ assertEqual(resources.getCreateCount(), 1);
312
+ });
313
+
314
+ it("waits for in-flight disposal and returns undefined", async () => {
315
+ await using run = testCreateRun();
316
+ const disposalStarted = Promise.withResolvers<void>();
317
+ const continueDisposal = Promise.withResolvers<void>();
318
+
319
+ await using sharedResource = await run.ok(
320
+ createSharedResource(() =>
321
+ ok({
322
+ [Symbol.asyncDispose]: async () => {
323
+ disposalStarted.resolve();
324
+ await continueDisposal.promise;
325
+ },
326
+ }),
327
+ ),
328
+ );
329
+
330
+ const lease = await run.ok(sharedResource.acquire);
331
+ lease.release();
332
+ await disposalStarted.promise;
333
+
334
+ const current = run.ok(sharedResource.acquireCurrent);
335
+ assertEqual(sharedResource.snapshot().mutex.waiters, [{ permits: 1 }]);
336
+ continueDisposal.resolve();
337
+
338
+ assertSame(await current, undefined);
339
+ });
340
+
341
+ it("acquires the current resource without creating another", async () => {
342
+ await using run = testCreateRun();
343
+ const resources = createTestResources();
344
+
345
+ await using sharedResource = await run.ok(
346
+ createSharedResource(resources.create),
347
+ );
348
+
349
+ const first = await run.ok(sharedResource.acquire);
350
+ const current = await run.ok(sharedResource.acquireCurrent);
351
+
352
+ assertSame(current?.resource, first.resource);
353
+ assertFalse(current?.created);
354
+ assertEqual(resources.getCreateCount(), 1);
355
+ });
356
+
357
+ it("cancels pending idle disposal and acquires the current resource", async () => {
358
+ await using run = testCreateRun();
359
+ const resources = createTestResources();
360
+
361
+ await using sharedResource = await run.ok(
362
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
363
+ );
364
+
365
+ const first = await run.ok(sharedResource.acquire);
366
+ first.release();
367
+
368
+ const current = await run.ok(sharedResource.acquireCurrent);
369
+
370
+ assertSame(current?.resource, first.resource);
371
+ assertFalse(sharedResource.snapshot().idleDisposePending);
372
+
373
+ run.deps.time.advance("3s");
374
+ assertEqual(resources.getDisposeCount(), 0);
375
+ });
376
+ });
377
+
378
+ describe("use", () => {
379
+ it("creates the resource and releases its lease after the Task settles", async () => {
380
+ await using run = testCreateRun();
381
+ const resources = createTestResources();
382
+ let leaseCountDuringUse: number | undefined;
383
+ let createdDuringUse: boolean | undefined;
384
+
385
+ await using sharedResource = await run.ok(
386
+ createSharedResource(resources.create),
387
+ );
388
+
389
+ const value = await run.ok(
390
+ sharedResource.use((_resource, created) => () => {
391
+ leaseCountDuringUse = sharedResource.snapshot().leaseCount;
392
+ createdDuringUse = created;
393
+ return ok("used" as const);
394
+ }),
395
+ );
396
+
397
+ assertEqual(value, "used");
398
+ assertTrue(createdDuringUse);
399
+ assertEqual(leaseCountDuringUse, 1);
400
+ assertEqual(sharedResource.snapshot().leaseCount, 0);
401
+ });
402
+
403
+ it("reports created false while reusing a held resource", async () => {
404
+ await using run = testCreateRun();
405
+ const resources = createTestResources();
406
+ let createdDuringUse: boolean | undefined;
407
+
408
+ await using sharedResource = await run.ok(
409
+ createSharedResource(resources.create),
410
+ );
411
+ using _lease = await run.ok(sharedResource.acquire);
412
+
413
+ await run.ok(
414
+ sharedResource.use((_resource, created) => () => {
415
+ createdDuringUse = created;
416
+ return ok();
417
+ }),
418
+ );
419
+
420
+ assertFalse(createdDuringUse);
421
+ assertEqual(resources.getCreateCount(), 1);
422
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
423
+ });
424
+
425
+ it("releases its lease when the callback Task aborts", async () => {
426
+ await using run = testCreateRun();
427
+ const resources = createTestResources();
428
+ const callbackStarted = Promise.withResolvers<void>();
429
+ const gate = createGate();
430
+
431
+ await using sharedResource = await run.ok(
432
+ createSharedResource(resources.create),
433
+ );
434
+ const disposed = resources.nextDisposed();
435
+
436
+ const fiber = run.abortable(
437
+ sharedResource.use(() => async (run) => {
438
+ callbackStarted.resolve();
439
+ await run.ok(gate.wait);
440
+ return ok();
441
+ }),
442
+ );
443
+ await callbackStarted.promise;
444
+
445
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
446
+
447
+ fiber.abort(testAbortReason);
448
+ const result = await fiber;
449
+
450
+ assertErr(result);
451
+ assertType(AbortError, result.error);
452
+ assertSame(result.error.reason, testAbortReason);
453
+
454
+ await disposed;
455
+ assertEqual(sharedResource.snapshot().leaseCount, 0);
456
+ assertEqual(resources.getDisposeCount(), 1);
457
+ });
458
+ });
459
+
460
+ describe("Lease", () => {
461
+ it("release disposes the resource after the last lease", async () => {
462
+ await using run = testCreateRun();
463
+ const resources = createTestResources();
464
+
465
+ await using sharedResource = await run.ok(
466
+ createSharedResource(resources.create),
467
+ );
468
+
469
+ const lease = await run.ok(sharedResource.acquire);
470
+ const disposed = resources.nextDisposed();
471
+
472
+ assertTrue(lease.release());
473
+ await disposed;
474
+
475
+ assertEqual(resources.getDisposeCount(), 1);
476
+ assertFalse(sharedResource.snapshot().hasResource);
477
+ assertFalse(sharedResource.snapshot().idleDisposePending);
478
+ });
479
+
480
+ it("release keeps the resource while other leases remain", async () => {
481
+ await using run = testCreateRun();
482
+ const resources = createTestResources();
483
+
484
+ await using sharedResource = await run.ok(
485
+ createSharedResource(resources.create),
486
+ );
487
+
488
+ const first = await run.ok(sharedResource.acquire);
489
+ await run.ok(sharedResource.acquire);
490
+
491
+ assertTrue(first.release());
492
+
493
+ assertEqual(resources.getDisposeCount(), 0);
494
+ assertTrue(sharedResource.snapshot().hasResource);
495
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
496
+ });
497
+
498
+ it("release is idempotent and returns false after the first call", async () => {
499
+ await using run = testCreateRun();
500
+ const resources = createTestResources();
501
+
502
+ await using sharedResource = await run.ok(
503
+ createSharedResource(resources.create),
504
+ );
505
+
506
+ const first = await run.ok(sharedResource.acquire);
507
+ await run.ok(sharedResource.acquire);
508
+
509
+ assertTrue(first.release());
510
+ assertFalse(first.release());
511
+
512
+ // Double release must not release the remaining lease's hold.
513
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
514
+ assertEqual(resources.getDisposeCount(), 0);
515
+ });
516
+
517
+ it("release remains safe after root Run disposal", async () => {
518
+ const run = testCreateRun();
519
+ const resources = createTestResources();
520
+
521
+ await using sharedResource = await run.ok(
522
+ createSharedResource(resources.create),
523
+ );
524
+ const lease = await run.ok(sharedResource.acquire);
525
+
526
+ await run[Symbol.asyncDispose]();
527
+
528
+ assertFalse(lease.release());
529
+ assertFalse(lease.release());
530
+ });
531
+
532
+ it("Symbol.dispose releases it", async () => {
533
+ await using run = testCreateRun();
534
+ const resources = createTestResources();
535
+
536
+ await using sharedResource = await run.ok(
537
+ createSharedResource(resources.create),
538
+ );
539
+
540
+ const disposed = resources.nextDisposed();
541
+
542
+ {
543
+ using _ = await run.ok(sharedResource.acquire);
544
+ assertEqual(sharedResource.snapshot().leaseCount, 1);
545
+ }
546
+
547
+ await disposed;
548
+ assertEqual(resources.getDisposeCount(), 1);
549
+ });
550
+ });
551
+
552
+ describe("idleDisposeAfter", () => {
553
+ it("delays disposal after the last release", async () => {
554
+ await using run = testCreateRun();
555
+ const resources = createTestResources();
556
+
557
+ await using sharedResource = await run.ok(
558
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
559
+ );
560
+
561
+ const lease = await run.ok(sharedResource.acquire);
562
+
563
+ assertTrue(lease.release());
564
+ assertEqual(resources.getDisposeCount(), 0);
565
+ assertTrue(sharedResource.snapshot().idleDisposePending);
566
+
567
+ const disposed = resources.nextDisposed();
568
+ run.deps.time.advance("3s");
569
+ await disposed;
570
+
571
+ assertEqual(resources.getDisposeCount(), 1);
572
+ assertFalse(sharedResource.snapshot().hasResource);
573
+ });
574
+
575
+ it("reuses the current resource when acquired before disposal", async () => {
576
+ await using run = testCreateRun();
577
+ const resources = createTestResources();
578
+
579
+ await using sharedResource = await run.ok(
580
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
581
+ );
582
+
583
+ const first = await run.ok(sharedResource.acquire);
584
+ first.release();
585
+ assertTrue(sharedResource.snapshot().idleDisposePending);
586
+
587
+ const second = await run.ok(sharedResource.acquire);
588
+
589
+ assertFalse(sharedResource.snapshot().idleDisposePending);
590
+ assertEqual(resources.getCreateCount(), 1);
591
+ assertSame(second.resource, first.resource);
592
+
593
+ run.deps.time.advance("3s");
594
+ assertEqual(resources.getDisposeCount(), 0);
595
+ });
596
+
597
+ it("restarts the idle delay after reacquisition and release", async () => {
598
+ await using run = testCreateRun();
599
+ const resources = createTestResources();
600
+
601
+ await using sharedResource = await run.ok(
602
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
603
+ );
604
+
605
+ const first = await run.ok(sharedResource.acquire);
606
+ first.release();
607
+ run.deps.time.advance("2s");
608
+
609
+ const second = await run.ok(sharedResource.acquire);
610
+ second.release();
611
+ run.deps.time.advance("1s");
612
+
613
+ assertEqual(resources.getDisposeCount(), 0);
614
+ assertTrue(sharedResource.snapshot().idleDisposePending);
615
+
616
+ const disposed = resources.nextDisposed();
617
+ run.deps.time.advance("2s");
618
+ await disposed;
619
+
620
+ assertEqual(resources.getDisposeCount(), 1);
621
+ });
622
+
623
+ it("creates a fresh resource when acquired after the timer elapses", async () => {
624
+ await using run = testCreateRun();
625
+ const disposalStarted = Promise.withResolvers<void>();
626
+ const continueDisposal = Promise.withResolvers<void>();
627
+
628
+ let createCount = 0;
629
+ const create: Task<AsyncDisposable & { readonly id: number }> = () => {
630
+ const id = ++createCount;
631
+ return ok({
632
+ id,
633
+ [Symbol.asyncDispose]: async () => {
634
+ if (id !== 1) return;
635
+ disposalStarted.resolve();
636
+ await continueDisposal.promise;
637
+ },
638
+ });
639
+ };
640
+
641
+ await using sharedResource = await run.ok(
642
+ createSharedResource(create, { idleDisposeAfter: "3s" }),
643
+ );
644
+
645
+ const first = await run.ok(sharedResource.acquire);
646
+ first.release();
647
+ run.deps.time.advance("3s");
648
+ await disposalStarted.promise;
649
+
650
+ const second = run.ok(sharedResource.acquire);
651
+ assertEqual(createCount, 1);
652
+
653
+ continueDisposal.resolve();
654
+ const secondLease = await second;
655
+
656
+ assertEqual(createCount, 2);
657
+ assertFalse(Object.is(secondLease.resource, first.resource));
658
+ });
659
+ });
660
+
661
+ describe("snapshot", () => {
662
+ it("reports shared resource state", async () => {
663
+ await using run = testCreateRun();
664
+ const resources = createTestResources();
665
+
666
+ await using sharedResource = await run.ok(
667
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
668
+ );
669
+
670
+ assertEqual(sharedResource.snapshot(), {
671
+ isIdle: true,
672
+ leaseCount: 0,
673
+ hasResource: false,
674
+ idleDisposePending: false,
675
+ mutex: idleMutexSnapshot,
676
+ });
677
+
678
+ const lease = await run.ok(sharedResource.acquire);
679
+
680
+ assertEqual(sharedResource.snapshot(), {
681
+ isIdle: false,
682
+ leaseCount: 1,
683
+ hasResource: true,
684
+ idleDisposePending: false,
685
+ mutex: idleMutexSnapshot,
686
+ });
687
+
688
+ lease.release();
689
+
690
+ assertEqual(sharedResource.snapshot(), {
691
+ isIdle: false,
692
+ leaseCount: 0,
693
+ hasResource: true,
694
+ idleDisposePending: true,
695
+ mutex: idleMutexSnapshot,
696
+ });
697
+ });
698
+
699
+ it("reports immediate disposal in progress", async () => {
700
+ await using run = testCreateRun();
701
+ const resources = createTestResources();
702
+
703
+ await using sharedResource = await run.ok(
704
+ createSharedResource(resources.create),
705
+ );
706
+
707
+ const lease = await run.ok(sharedResource.acquire);
708
+ const disposed = resources.nextDisposed();
709
+ lease.release();
710
+
711
+ assertEqual(sharedResource.snapshot(), {
712
+ isIdle: false,
713
+ leaseCount: 0,
714
+ hasResource: true,
715
+ idleDisposePending: false,
716
+ mutex: {
717
+ policy: "fifo",
718
+ permits: 1,
719
+ taken: 1,
720
+ waiters: [],
721
+ available: 0,
722
+ isIdle: false,
723
+ },
724
+ });
725
+
726
+ await disposed;
727
+ });
728
+
729
+ it("reports not idle while creating the first resource", async () => {
730
+ await using run = testCreateRun();
731
+ const gate = createGate();
732
+
733
+ await using sharedResource = await run.ok(
734
+ createSharedResource(async (run) => {
735
+ await run.ok(gate.wait);
736
+ return ok({ [Symbol.dispose]: constVoid });
737
+ }),
738
+ );
739
+
740
+ const acquire = run.ok(sharedResource.acquire);
741
+ const isIdleWhileCreating = sharedResource.snapshot().isIdle;
742
+ gate.open();
743
+ await acquire;
744
+
745
+ assertFalse(isIdleWhileCreating);
746
+ });
747
+
748
+ it("reports mutex waiters during a contended acquire", async () => {
749
+ await using run = testCreateRun();
750
+ const gate = createGate();
751
+
752
+ await using sharedResource = await run.ok(
753
+ createSharedResource(async (run) => {
754
+ await run.ok(gate.wait);
755
+ return ok({ [Symbol.dispose]: constVoid });
756
+ }),
757
+ );
758
+
759
+ const first = run.ok(sharedResource.acquire);
760
+ const second = run.ok(sharedResource.acquire);
761
+ const mutexWhileContended = sharedResource.snapshot().mutex;
762
+ gate.open();
763
+ await Promise.all([first, second]);
764
+
765
+ assertEqual(mutexWhileContended.waiters, [{ permits: 1 }]);
766
+ });
767
+ });
768
+
769
+ describe("onDisposed", () => {
770
+ it("does not call onDisposed when disposed before resource creation", async () => {
771
+ await using run = testCreateRun();
772
+ let onDisposedCallCount = 0;
773
+
774
+ const sharedResource = await run.ok(
775
+ createSharedResource(createTestResources().create, {
776
+ onDisposed: () => {
777
+ onDisposedCallCount++;
778
+ },
779
+ }),
780
+ );
781
+
782
+ await sharedResource[Symbol.asyncDispose]();
783
+
784
+ assertEqual(onDisposedCallCount, 0);
785
+ });
786
+
787
+ it("calls onDisposed during owner disposal", async () => {
788
+ await using run = testCreateRun();
789
+ const resources = createTestResources();
790
+ let disposeCountAtCallback: number | undefined;
791
+
792
+ const sharedResource = await run.ok(
793
+ createSharedResource(resources.create, {
794
+ onDisposed: () => {
795
+ disposeCountAtCallback = resources.getDisposeCount();
796
+ },
797
+ }),
798
+ );
799
+ await run.ok(sharedResource.acquire);
800
+
801
+ await sharedResource[Symbol.asyncDispose]();
802
+
803
+ assertEqual(disposeCountAtCallback, 1);
804
+ });
805
+
806
+ it("runs after the resource is disposed and cleared", async () => {
807
+ await using run = testCreateRun();
808
+ const resources = createTestResources();
809
+ const onDisposedCalled = Promise.withResolvers<void>();
810
+
811
+ let stateAtCallback:
812
+ { disposeCount: number; hasResource: boolean } | undefined;
813
+
814
+ await using sharedResource = await run.ok(
815
+ createSharedResource(resources.create, {
816
+ onDisposed: () => {
817
+ stateAtCallback = {
818
+ disposeCount: resources.getDisposeCount(),
819
+ hasResource: sharedResource.snapshot().hasResource,
820
+ };
821
+ onDisposedCalled.resolve();
822
+ },
823
+ }),
824
+ );
825
+
826
+ const lease = await run.ok(sharedResource.acquire);
827
+ lease.release();
828
+ await onDisposedCalled.promise;
829
+
830
+ assertEqual(stateAtCallback, { disposeCount: 1, hasResource: false });
831
+ });
832
+
833
+ it("runs after delayed idle disposal", async () => {
834
+ await using run = testCreateRun();
835
+ const resources = createTestResources();
836
+ const onDisposedCalled = Promise.withResolvers<void>();
837
+
838
+ await using sharedResource = await run.ok(
839
+ createSharedResource(resources.create, {
840
+ idleDisposeAfter: "3s",
841
+ onDisposed: onDisposedCalled.resolve,
842
+ }),
843
+ );
844
+
845
+ const lease = await run.ok(sharedResource.acquire);
846
+ lease.release();
847
+
848
+ assertEqual(resources.getDisposeCount(), 0);
849
+
850
+ run.deps.time.advance("3s");
851
+ await onDisposedCalled.promise;
852
+
853
+ assertEqual(resources.getDisposeCount(), 1);
854
+ assertFalse(sharedResource.snapshot().hasResource);
855
+ });
856
+ });
857
+
858
+ describe("disposal", () => {
859
+ it("aborts acquire queued on the mutex when the owner is disposed", async () => {
860
+ await using run = testCreateRun();
861
+ const createStarted = Promise.withResolvers<void>();
862
+ const gate = createGate();
863
+
864
+ const sharedResource = await run.ok(
865
+ createSharedResource(async (run) => {
866
+ createStarted.resolve();
867
+ await run.ok(gate.wait);
868
+ return ok({ [Symbol.dispose]: constVoid });
869
+ }),
870
+ );
871
+
872
+ const first = run.abortable(sharedResource.acquire);
873
+ await createStarted.promise;
874
+
875
+ const queued = run.abortable(sharedResource.acquire);
876
+ assertEqual(sharedResource.snapshot().mutex.waiters, [{ permits: 1 }]);
877
+
878
+ const disposal = sharedResource[Symbol.asyncDispose]();
879
+ const result = await queued;
880
+
881
+ assertErr(result);
882
+ assertType(AbortError, result.error);
883
+ assertSame(result.error.reason, runDisposedAbortReason);
884
+
885
+ gate.open();
886
+ await first;
887
+ await disposal;
888
+ });
889
+
890
+ it("aborts acquire when the owner is disposed during creation", async () => {
891
+ await using run = testCreateRun();
892
+ const resources = createTestResources();
893
+ const createStarted = Promise.withResolvers<void>();
894
+ const continueCreate = Promise.withResolvers<void>();
895
+
896
+ const sharedResource = await run.ok(
897
+ createSharedResource(async (run) => {
898
+ const resource = await run.ok(resources.create);
899
+ createStarted.resolve();
900
+ await continueCreate.promise;
901
+ return ok(resource);
902
+ }),
903
+ );
904
+
905
+ const acquire = run.abortable(sharedResource.acquire);
906
+ await createStarted.promise;
907
+
908
+ const disposal = sharedResource[Symbol.asyncDispose]();
909
+ continueCreate.resolve();
910
+
911
+ const result = await acquire;
912
+ await disposal;
913
+
914
+ assertErr(result);
915
+ assertType(AbortError, result.error);
916
+ assertSame(result.error.reason, runDisposedAbortReason);
917
+ assertEqual(resources.getCreateCount(), 1);
918
+ assertEqual(resources.getDisposeCount(), 1);
919
+ });
920
+
921
+ it("disposes the current resource and releases held leases", async () => {
922
+ await using run = testCreateRun();
923
+ const resources = createTestResources();
924
+
925
+ const sharedResource = await run.ok(
926
+ createSharedResource(resources.create),
927
+ );
928
+ const lease = await run.ok(sharedResource.acquire);
929
+
930
+ await sharedResource[Symbol.asyncDispose]();
931
+
932
+ assertEqual(resources.getDisposeCount(), 1);
933
+ assertFalse(lease.release());
934
+ assertFalse(sharedResource.snapshot().hasResource);
935
+ });
936
+
937
+ it("clears pending idle disposal state", async () => {
938
+ await using run = testCreateRun();
939
+ const resources = createTestResources();
940
+
941
+ const sharedResource = await run.ok(
942
+ createSharedResource(resources.create, { idleDisposeAfter: "3s" }),
943
+ );
944
+ const lease = await run.ok(sharedResource.acquire);
945
+ lease.release();
946
+
947
+ assertTrue(sharedResource.snapshot().idleDisposePending);
948
+
949
+ await sharedResource[Symbol.asyncDispose]();
950
+
951
+ assertEqual(resources.getDisposeCount(), 1);
952
+ const snapshot = sharedResource.snapshot();
953
+ assertTrue(snapshot.isIdle);
954
+ assertFalse(snapshot.idleDisposePending);
955
+ });
956
+
957
+ it("awaits async resource disposal", async () => {
958
+ await using run = testCreateRun();
959
+ const disposalStarted = Promise.withResolvers<void>();
960
+ const continueDisposal = Promise.withResolvers<void>();
961
+
962
+ const sharedResource = await run.ok(
963
+ createSharedResource(() =>
964
+ ok({
965
+ [Symbol.asyncDispose]: async () => {
966
+ disposalStarted.resolve();
967
+ await continueDisposal.promise;
968
+ },
969
+ }),
970
+ ),
971
+ );
972
+ await run.ok(sharedResource.acquire);
973
+
974
+ let disposalSettled = false;
975
+ const disposal = sharedResource[Symbol.asyncDispose]().then(() => {
976
+ disposalSettled = true;
977
+ });
978
+ await disposalStarted.promise;
979
+
980
+ assertFalse(disposalSettled);
981
+
982
+ continueDisposal.resolve();
983
+ await disposal;
984
+
985
+ assertTrue(disposalSettled);
986
+ });
987
+
988
+ it("root Run disposal awaits current resource disposal", async () => {
989
+ const run = testCreateRun();
990
+ const disposalStarted = Promise.withResolvers<void>();
991
+ const continueDisposal = Promise.withResolvers<void>();
992
+
993
+ const sharedResource = await run.ok(
994
+ createSharedResource(() =>
995
+ ok({
996
+ [Symbol.asyncDispose]: async () => {
997
+ disposalStarted.resolve();
998
+ await continueDisposal.promise;
999
+ },
1000
+ }),
1001
+ ),
1002
+ );
1003
+ await run.ok(sharedResource.acquire);
1004
+
1005
+ let rootDisposalSettled = false;
1006
+ const rootDisposal = run[Symbol.asyncDispose]().then(() => {
1007
+ rootDisposalSettled = true;
1008
+ });
1009
+
1010
+ try {
1011
+ const firstSettled = await Promise.race([
1012
+ disposalStarted.promise.then(() => "resourceDisposalStarted"),
1013
+ rootDisposal.then(() => "rootDisposalSettled"),
1014
+ ]);
1015
+
1016
+ assertEqual(firstSettled, "resourceDisposalStarted");
1017
+ assertFalse(rootDisposalSettled);
1018
+
1019
+ continueDisposal.resolve();
1020
+ await rootDisposal;
1021
+
1022
+ assertFalse(sharedResource.snapshot().hasResource);
1023
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), []);
1024
+ } finally {
1025
+ continueDisposal.resolve();
1026
+ await sharedResource[Symbol.asyncDispose]();
1027
+ await rootDisposal;
1028
+ }
1029
+ });
1030
+
1031
+ it("allows late lease release as a safe no-op", async () => {
1032
+ await using run = testCreateRun();
1033
+ const resources = createTestResources();
1034
+
1035
+ const sharedResource = await run.ok(
1036
+ createSharedResource(resources.create),
1037
+ );
1038
+ const lease = await run.ok(sharedResource.acquire);
1039
+
1040
+ const disposal = sharedResource[Symbol.asyncDispose]();
1041
+ assertTrue(lease.release());
1042
+ await disposal;
1043
+
1044
+ assertEqual(resources.getDisposeCount(), 1);
1045
+ assertFalse(lease.release());
1046
+ });
1047
+
1048
+ it("owner disposal can drain a transferred lease before the caller awaits it", async () => {
1049
+ await using run = testCreateRun();
1050
+ const resources = createTestResources();
1051
+
1052
+ const sharedResource = await run.ok(
1053
+ createSharedResource(resources.create),
1054
+ );
1055
+ const acquire = run.ok(sharedResource.acquire);
1056
+
1057
+ await assertConditionAfterMicrotasks(
1058
+ () => sharedResource.snapshot().leaseCount === 1,
1059
+ 10,
1060
+ );
1061
+ await sharedResource[Symbol.asyncDispose]();
1062
+
1063
+ const lease = await acquire;
1064
+
1065
+ assertEqual(sharedResource.snapshot().leaseCount, 0);
1066
+ assertEqual(resources.getDisposeCount(), 1);
1067
+ assertFalse(lease.release());
1068
+ });
1069
+
1070
+ it("skips scheduled disposal when a new lease arrives", async () => {
1071
+ await using run = testCreateRun();
1072
+ const resources = createTestResources();
1073
+
1074
+ await using sharedResource = await run.ok(
1075
+ createSharedResource(resources.create),
1076
+ );
1077
+
1078
+ const first = await run.ok(sharedResource.acquire);
1079
+
1080
+ // Take the mutex before release so the scheduled disposal queues behind
1081
+ // the second acquire.
1082
+ const second = run.ok(sharedResource.acquire);
1083
+ first.release();
1084
+ const secondLease = await second;
1085
+
1086
+ // The mutex is FIFO, so a third acquire completes only after the queued
1087
+ // disposal task ran.
1088
+ await run.ok(sharedResource.acquire);
1089
+
1090
+ assertEqual(resources.getDisposeCount(), 0);
1091
+ assertSame(secondLease.resource, first.resource);
1092
+ });
1093
+ });
1094
+
1095
+ describe("leak detection", () => {
1096
+ it("warns when an undisposed SharedResource is garbage-collected", async () => {
1097
+ await using run = testCreateRun();
1098
+
1099
+ await run.ok(createSharedResource(createTestResources().create));
1100
+
1101
+ assertEqual(run.deps.leakDetector.collect(), 1);
1102
+
1103
+ const entries = run.deps.console.getEntriesSnapshot();
1104
+ assertLength(entries, 1);
1105
+ assertEqual(entries[0].method, "warn");
1106
+ assertEqual(
1107
+ entries[0].args[0],
1108
+ "SharedResource was garbage-collected without cleanup. Tracked at:",
1109
+ );
1110
+ });
1111
+
1112
+ it("warns when a leaked lease is garbage-collected", async () => {
1113
+ await using run = testCreateRun();
1114
+ const resources = createTestResources();
1115
+
1116
+ await using sharedResource = await run.ok(
1117
+ createSharedResource(resources.create),
1118
+ );
1119
+
1120
+ // Acquired but never released — a leak.
1121
+ await run.ok(sharedResource.acquire);
1122
+
1123
+ assertEqual(run.deps.leakDetector.collect(), 2);
1124
+
1125
+ const entries = run.deps.console.getEntriesSnapshot();
1126
+ assertLength(entries, 2);
1127
+ assertTrue(
1128
+ entries.some(
1129
+ (entry) =>
1130
+ entry.method === "warn" &&
1131
+ entry.args.includes(
1132
+ "Lease was garbage-collected without cleanup. Tracked at:",
1133
+ ),
1134
+ ),
1135
+ );
1136
+ });
1137
+
1138
+ it("release untracks the lease", async () => {
1139
+ await using run = testCreateRun();
1140
+ const resources = createTestResources();
1141
+
1142
+ await using sharedResource = await run.ok(
1143
+ createSharedResource(resources.create),
1144
+ );
1145
+
1146
+ const lease = await run.ok(sharedResource.acquire);
1147
+
1148
+ assertEqual(run.deps.leakDetector.getTrackedCount({ name: "Lease" }), 1);
1149
+
1150
+ lease.release();
1151
+
1152
+ assertEqual(run.deps.leakDetector.getTrackedCount({ name: "Lease" }), 0);
1153
+ });
1154
+
1155
+ it("disposal untracks held leases", async () => {
1156
+ await using run = testCreateRun();
1157
+ const resources = createTestResources();
1158
+
1159
+ const sharedResource = await run.ok(
1160
+ createSharedResource(resources.create),
1161
+ );
1162
+
1163
+ await run.ok(sharedResource.acquire);
1164
+ assertEqual(run.deps.leakDetector.getTrackedCount({ name: "Lease" }), 1);
1165
+
1166
+ await sharedResource[Symbol.asyncDispose]();
1167
+
1168
+ assertEqual(run.deps.leakDetector.getTrackedCount({ name: "Lease" }), 0);
1169
+ assertEqual(run.deps.console.getEntriesSnapshot(), []);
1170
+ });
1171
+ });
1172
+
1173
+ describe("defects", () => {
1174
+ it("acquire after disposal is a programmer error", async () => {
1175
+ await using run = testCreateRun();
1176
+ const resources = createTestResources();
1177
+
1178
+ const sharedResource = await run.ok(
1179
+ createSharedResource(resources.create),
1180
+ );
1181
+ await sharedResource[Symbol.asyncDispose]();
1182
+
1183
+ const result = await run.abortable(sharedResource.acquire);
1184
+
1185
+ assertErr(result);
1186
+ assertType(AbortError, result.error);
1187
+ assertSame(result.error.reason.type, "PanicAbortReason");
1188
+ assertInstanceOf(result.error.reason.defect, Error);
1189
+ assertEqual(
1190
+ result.error.reason.defect.message,
1191
+ "Cannot use a disposed object.",
1192
+ );
1193
+ assertSame(await run.deps.reportDefect.next(), result.error);
1194
+ });
1195
+
1196
+ it("throwing create Task panics the Run tree", async () => {
1197
+ await using run = testCreateRun();
1198
+ const defect = new Error("create failed");
1199
+
1200
+ await using sharedResource = await run.ok(
1201
+ createSharedResource(() => {
1202
+ throw defect;
1203
+ }),
1204
+ );
1205
+
1206
+ const result = await run.abortable(sharedResource.acquire);
1207
+
1208
+ assertErr(result);
1209
+ assertType(AbortError, result.error);
1210
+ assertSame(result.error.reason.type, "PanicAbortReason");
1211
+ assertSame(result.error.reason.defect, defect);
1212
+ assertSame(await run.deps.reportDefect.next(), result.error);
1213
+ });
1214
+
1215
+ it("throwing onDisposed after the last release panics the Run tree", async () => {
1216
+ await using run = testCreateRun();
1217
+ const resources = createTestResources();
1218
+ const defect = new Error("onDisposed failed");
1219
+
1220
+ await using sharedResource = await run.ok(
1221
+ createSharedResource(resources.create, {
1222
+ onDisposed: () => {
1223
+ throw defect;
1224
+ },
1225
+ }),
1226
+ );
1227
+
1228
+ const lease = await run.ok(sharedResource.acquire);
1229
+ lease.release();
1230
+
1231
+ // Disposal runs on a fire-and-forget Fiber, so the defect is observable
1232
+ // only via panic reporting.
1233
+ const abortError = await run.deps.reportDefect.next();
1234
+ assertType(AbortError, abortError);
1235
+ assertSame(abortError.reason.type, "PanicAbortReason");
1236
+ assertSame(abortError.reason.defect, defect);
1237
+ });
1238
+
1239
+ it("throwing onDisposed during disposal reports once and rejects with AbortError", async () => {
1240
+ await using run = testCreateRun();
1241
+ const resources = createTestResources();
1242
+ const defect = new Error("onDisposed failed");
1243
+
1244
+ const sharedResource = await run.ok(
1245
+ createSharedResource(resources.create, {
1246
+ onDisposed: () => {
1247
+ throw defect;
1248
+ },
1249
+ }),
1250
+ );
1251
+
1252
+ await run.ok(sharedResource.acquire);
1253
+
1254
+ await assertRejects(
1255
+ sharedResource[Symbol.asyncDispose](),
1256
+ (disposalError) => {
1257
+ assertType(AbortError, disposalError);
1258
+ assertSame(disposalError.reason.type, "PanicAbortReason");
1259
+ assertSame(disposalError.reason.defect, defect);
1260
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), [
1261
+ disposalError,
1262
+ ]);
1263
+ },
1264
+ );
1265
+ });
1266
+
1267
+ it("throwing resource disposer panics the Run tree", async () => {
1268
+ await using run = testCreateRun();
1269
+ const defect = new Error("dispose failed");
1270
+
1271
+ await using sharedResource = await run.ok(
1272
+ createSharedResource(() =>
1273
+ ok({
1274
+ [Symbol.dispose]: () => {
1275
+ throw defect;
1276
+ },
1277
+ }),
1278
+ ),
1279
+ );
1280
+
1281
+ const lease = await run.ok(sharedResource.acquire);
1282
+ lease.release();
1283
+
1284
+ const abortError = await run.deps.reportDefect.next();
1285
+ assertType(AbortError, abortError);
1286
+ assertSame(abortError.reason.type, "PanicAbortReason");
1287
+ assertSame(abortError.reason.defect, defect);
1288
+ });
1289
+ });
1290
+
1291
+ describe("types", () => {
1292
+ it("keyed acquire does not require captured deps", () => {
1293
+ interface TestDb extends Disposable {
1294
+ readonly query: () => string;
1295
+ }
1296
+ interface TestDep {
1297
+ readonly value: string;
1298
+ }
1299
+
1300
+ assertType<SharedResource<TestDb>["acquire"], Task<Lease<TestDb>>>();
1301
+
1302
+ const _sharedResource = createSharedResource((() =>
1303
+ ok({ [Symbol.dispose]: constVoid })) as Task<
1304
+ Disposable,
1305
+ never,
1306
+ TestDep
1307
+ >);
1308
+ assertType<
1309
+ typeof _sharedResource,
1310
+ Task<SharedResource<Disposable>, never, TestDep>
1311
+ >();
1312
+
1313
+ // @ts-expect-error - create Task must not fail.
1314
+ createSharedResource(() => err({ type: "CreateError" }));
1315
+ });
1316
+
1317
+ it("acquireCurrent and use preserve their Task types", async () => {
1318
+ await using run = testCreateRun();
1319
+
1320
+ interface TestDb extends Disposable {
1321
+ readonly query: () => string;
1322
+ }
1323
+ interface TestError {
1324
+ readonly type: "TestError";
1325
+ }
1326
+ interface TestDep {
1327
+ readonly value: string;
1328
+ }
1329
+
1330
+ const create: Task<TestDb> = () =>
1331
+ ok({ query: () => "result", [Symbol.dispose]: constVoid });
1332
+
1333
+ await using sharedResource = await run.ok(createSharedResource(create));
1334
+
1335
+ assertType<
1336
+ typeof sharedResource.acquireCurrent,
1337
+ Task<Lease<TestDb> | undefined>
1338
+ >();
1339
+
1340
+ const use = sharedResource.use(
1341
+ (): Task<"used", TestError, TestDep> => () =>
1342
+ err({ type: "TestError" }),
1343
+ );
1344
+ assertType<typeof use, Task<"used", TestError, TestDep>>();
1345
+ });
1346
+ });
1347
+ });
1348
+
1349
+ describe("SharedResourceByKey", () => {
1350
+ describe("acquire", () => {
1351
+ it("lazily creates a resource per key", async () => {
1352
+ await using run = testCreateRun();
1353
+
1354
+ const createCountsByKey = new Map<string, number>();
1355
+ const create =
1356
+ (key: string): Task<Disposable> =>
1357
+ () => {
1358
+ createCountsByKey.set(key, (createCountsByKey.get(key) ?? 0) + 1);
1359
+ return ok({ [Symbol.dispose]: constVoid });
1360
+ };
1361
+
1362
+ await using sharedResourceByKey = await run.ok(
1363
+ createSharedResourceByKey(create),
1364
+ );
1365
+
1366
+ assertEqual(createCountsByKey.size, 0);
1367
+
1368
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1369
+ const second = await run.ok(sharedResourceByKey.acquire("a"));
1370
+
1371
+ assertEqual(createCountsByKey.get("a"), 1);
1372
+ assertSame(second.resource, first.resource);
1373
+ });
1374
+
1375
+ it("serializes concurrent first acquires for the same key", async () => {
1376
+ await using run = testCreateRun();
1377
+ const gate = createGate();
1378
+
1379
+ let createCount = 0;
1380
+ const create = (): Task<Disposable> => async (run) => {
1381
+ createCount++;
1382
+ await run.ok(gate.wait);
1383
+ return ok({ [Symbol.dispose]: constVoid });
1384
+ };
1385
+
1386
+ await using sharedResourceByKey = await run.ok(
1387
+ createSharedResourceByKey(create),
1388
+ );
1389
+
1390
+ const first = run.ok(sharedResourceByKey.acquire("a"));
1391
+ const second = run.ok(sharedResourceByKey.acquire("a"));
1392
+ gate.open();
1393
+ const [firstLease, secondLease] = await Promise.all([first, second]);
1394
+
1395
+ assertEqual(createCount, 1);
1396
+ assertSame(secondLease.resource, firstLease.resource);
1397
+ });
1398
+
1399
+ it("creates independent resources for different keys", async () => {
1400
+ await using run = testCreateRun();
1401
+
1402
+ const resourcesByKey = new Map<
1403
+ string,
1404
+ ReturnType<typeof createTestResources>
1405
+ >();
1406
+ const create = (key: string): Task<Disposable> => {
1407
+ const resources = createTestResources();
1408
+ resourcesByKey.set(key, resources);
1409
+ return resources.create;
1410
+ };
1411
+
1412
+ await using sharedResourceByKey = await run.ok(
1413
+ createSharedResourceByKey(create),
1414
+ );
1415
+
1416
+ const a = await run.ok(sharedResourceByKey.acquire("a"));
1417
+ const b = await run.ok(sharedResourceByKey.acquire("b"));
1418
+
1419
+ assertFalse(Object.is(b.resource, a.resource));
1420
+
1421
+ // Releasing "a" disposes only "a".
1422
+ const aDisposed = resourcesByKey.get("a")!.nextDisposed();
1423
+ a.release();
1424
+ await aDisposed;
1425
+
1426
+ assertEqual(resourcesByKey.get("a")!.getDisposeCount(), 1);
1427
+ assertEqual(resourcesByKey.get("b")!.getDisposeCount(), 0);
1428
+ assertTrue(
1429
+ sharedResourceByKey.snapshot().resourcesByKey.get("b")?.hasResource,
1430
+ );
1431
+ });
1432
+
1433
+ it("allows different keys to progress concurrently", async () => {
1434
+ await using run = testCreateRun();
1435
+ const gate = createGate();
1436
+
1437
+ const create =
1438
+ (key: string): Task<Disposable> =>
1439
+ async (run) => {
1440
+ if (key === "a") await run.ok(gate.wait);
1441
+ return ok({ [Symbol.dispose]: constVoid });
1442
+ };
1443
+
1444
+ await using sharedResourceByKey = await run.ok(
1445
+ createSharedResourceByKey(create),
1446
+ );
1447
+
1448
+ const a = run.ok(sharedResourceByKey.acquire("a"));
1449
+ await run.ok(sharedResourceByKey.acquire("b"));
1450
+
1451
+ assertTrue(
1452
+ sharedResourceByKey.snapshot().resourcesByKey.get("b")?.hasResource,
1453
+ );
1454
+ assertFalse(
1455
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.hasResource,
1456
+ );
1457
+
1458
+ gate.open();
1459
+ await a;
1460
+ });
1461
+
1462
+ it("runs to completion after caller abort while waiting for a key", async () => {
1463
+ await using run = testCreateRun();
1464
+ const gate = createGate();
1465
+ const resources = createTestResources();
1466
+
1467
+ const create: Task<Disposable> = async (run) => {
1468
+ await run.ok(gate.wait);
1469
+ return run(resources.create);
1470
+ };
1471
+
1472
+ await using sharedResourceByKey = await run.ok(
1473
+ createSharedResourceByKey(() => create),
1474
+ );
1475
+
1476
+ const first = run.ok(sharedResourceByKey.acquire("a"));
1477
+ const second = run.abortable(sharedResourceByKey.acquire("a"));
1478
+ second.abort(testAbortReason);
1479
+ gate.open();
1480
+
1481
+ const firstLease = await first;
1482
+ const result = await second;
1483
+
1484
+ assertOk(result);
1485
+ assertEqual(
1486
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1487
+ 2,
1488
+ );
1489
+
1490
+ assertTrue(firstLease.release());
1491
+ assertEqual(resources.getDisposeCount(), 0);
1492
+
1493
+ const disposed = resources.nextDisposed();
1494
+ assertTrue(result.value.release());
1495
+ await disposed;
1496
+
1497
+ assertEqual(resources.getDisposeCount(), 1);
1498
+ });
1499
+ });
1500
+
1501
+ describe("key removal", () => {
1502
+ it("disposes the resource and removes the key after the last lease", async () => {
1503
+ await using run = testCreateRun();
1504
+ const resources = createTestResources();
1505
+ const onDisposedCalled = Promise.withResolvers<void>();
1506
+ let hasKeyWhenDisposed: boolean | undefined;
1507
+
1508
+ await using sharedResourceByKey = await run.ok(
1509
+ createSharedResourceByKey(() => resources.create, {
1510
+ onDisposed: () => {
1511
+ hasKeyWhenDisposed = sharedResourceByKey
1512
+ .snapshot()
1513
+ .resourcesByKey.has("a");
1514
+ onDisposedCalled.resolve();
1515
+ },
1516
+ }),
1517
+ );
1518
+
1519
+ const lease = await run.ok(sharedResourceByKey.acquire("a"));
1520
+ lease.release();
1521
+ await onDisposedCalled.promise;
1522
+
1523
+ assertFalse(hasKeyWhenDisposed);
1524
+ assertEqual(resources.getDisposeCount(), 1);
1525
+
1526
+ // The key was removed before onDisposed, so this acquire creates a fresh
1527
+ // resource.
1528
+ const again = await run.ok(sharedResourceByKey.acquire("a"));
1529
+
1530
+ assertEqual(resources.getCreateCount(), 2);
1531
+ assertFalse(Object.is(again.resource, lease.resource));
1532
+ assertEqual(sharedResourceByKey.snapshot().resourcesByKey.size, 1);
1533
+ });
1534
+
1535
+ it("removes the key when acquireCurrent waits for its resource disposal", async () => {
1536
+ await using run = testCreateRun();
1537
+ const disposalStarted = Promise.withResolvers<void>();
1538
+ const continueDisposal = Promise.withResolvers<void>();
1539
+
1540
+ await using sharedResourceByKey = await run.ok(
1541
+ createSharedResourceByKey(
1542
+ () => () =>
1543
+ ok({
1544
+ [Symbol.asyncDispose]: async () => {
1545
+ disposalStarted.resolve();
1546
+ await continueDisposal.promise;
1547
+ },
1548
+ }),
1549
+ ),
1550
+ );
1551
+
1552
+ const lease = await run.ok(sharedResourceByKey.acquire("a"));
1553
+ lease.release();
1554
+ await disposalStarted.promise;
1555
+
1556
+ const current = run.ok(sharedResourceByKey.acquireCurrent("a"));
1557
+ await assertConditionAfterMicrotasks(
1558
+ () =>
1559
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.mutex.waiters
1560
+ .length === 1,
1561
+ 5,
1562
+ );
1563
+ continueDisposal.resolve();
1564
+
1565
+ assertSame(await current, undefined);
1566
+ assertFalse(sharedResourceByKey.snapshot().resourcesByKey.has("a"));
1567
+ });
1568
+
1569
+ it("keeps the key when its resource is reacquired before removal", async () => {
1570
+ await using run = testCreateRun();
1571
+ const resources = createTestResources();
1572
+
1573
+ await using sharedResourceByKey = await run.ok(
1574
+ createSharedResourceByKey(() => resources.create),
1575
+ );
1576
+
1577
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1578
+
1579
+ // Take the outer key mutex before release so synchronous key removal
1580
+ // leaves the SharedResource registered for this acquire.
1581
+ const second = run.ok(sharedResourceByKey.acquire("a"));
1582
+ first.release();
1583
+ const secondLease = await second;
1584
+
1585
+ // Later acquires reuse the same keyed SharedResource.
1586
+ const thirdLease = await run.ok(sharedResourceByKey.acquire("a"));
1587
+
1588
+ assertEqual(resources.getCreateCount(), 2);
1589
+ assertEqual(resources.getDisposeCount(), 1);
1590
+ assertSame(thirdLease.resource, secondLease.resource);
1591
+ });
1592
+ });
1593
+
1594
+ describe("lookup", () => {
1595
+ it("derives logical key identity", async () => {
1596
+ await using run = testCreateRun();
1597
+ const resources = createTestResources();
1598
+
1599
+ await using sharedResourceByKey = await run.ok(
1600
+ createSharedResourceByKey(() => resources.create, {
1601
+ lookup: (key: { readonly id: string }) => key.id,
1602
+ }),
1603
+ );
1604
+
1605
+ const first = await run.ok(sharedResourceByKey.acquire({ id: "a" }));
1606
+ const second = await run.ok(sharedResourceByKey.acquire({ id: "a" }));
1607
+ await run.ok(sharedResourceByKey.acquire({ id: "b" }));
1608
+
1609
+ assertEqual(resources.getCreateCount(), 2);
1610
+ assertSame(second.resource, first.resource);
1611
+ });
1612
+ });
1613
+
1614
+ describe("acquireCurrent", () => {
1615
+ it("returns undefined for an absent key without creating or registering it", async () => {
1616
+ await using run = testCreateRun();
1617
+ const resources = createTestResources();
1618
+
1619
+ await using sharedResourceByKey = await run.ok(
1620
+ createSharedResourceByKey(() => resources.create),
1621
+ );
1622
+
1623
+ const lease = await run.ok(sharedResourceByKey.acquireCurrent("a"));
1624
+
1625
+ assertSame(lease, undefined);
1626
+ assertEqual(resources.getCreateCount(), 0);
1627
+ assertEqual(sharedResourceByKey.snapshot().resourcesByKey.size, 0);
1628
+ });
1629
+
1630
+ it("acquires the current resource for an existing key without creating another", async () => {
1631
+ await using run = testCreateRun();
1632
+ const resources = createTestResources();
1633
+
1634
+ await using sharedResourceByKey = await run.ok(
1635
+ createSharedResourceByKey(() => resources.create),
1636
+ );
1637
+
1638
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1639
+ const current = await run.ok(sharedResourceByKey.acquireCurrent("a"));
1640
+
1641
+ assertSame(current?.resource, first.resource);
1642
+ assertFalse(current?.created);
1643
+ assertEqual(resources.getCreateCount(), 1);
1644
+ });
1645
+ });
1646
+
1647
+ describe("use", () => {
1648
+ it("creates the keyed resource and releases its lease after the Task settles", async () => {
1649
+ await using run = testCreateRun();
1650
+ const resources = createTestResources();
1651
+ let leaseCountDuringUse: number | undefined;
1652
+ let createdDuringUse: boolean | undefined;
1653
+
1654
+ await using sharedResourceByKey = await run.ok(
1655
+ createSharedResourceByKey(() => resources.create),
1656
+ );
1657
+ const disposed = resources.nextDisposed();
1658
+
1659
+ const value = await run.ok(
1660
+ sharedResourceByKey.use("a", (_resource, created) => () => {
1661
+ leaseCountDuringUse = sharedResourceByKey
1662
+ .snapshot()
1663
+ .resourcesByKey.get("a")?.leaseCount;
1664
+ createdDuringUse = created;
1665
+ return ok("used" as const);
1666
+ }),
1667
+ );
1668
+
1669
+ assertEqual(value, "used");
1670
+ assertTrue(createdDuringUse);
1671
+ assertEqual(leaseCountDuringUse, 1);
1672
+ assertEqual(
1673
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1674
+ 0,
1675
+ );
1676
+ await disposed;
1677
+ assertEqual(resources.getDisposeCount(), 1);
1678
+ });
1679
+
1680
+ it("reports created false while reusing a held keyed resource", async () => {
1681
+ await using run = testCreateRun();
1682
+ const resources = createTestResources();
1683
+ let createdDuringUse: boolean | undefined;
1684
+
1685
+ await using sharedResourceByKey = await run.ok(
1686
+ createSharedResourceByKey(() => resources.create),
1687
+ );
1688
+ using _lease = await run.ok(sharedResourceByKey.acquire("a"));
1689
+
1690
+ await run.ok(
1691
+ sharedResourceByKey.use("a", (_resource, created) => () => {
1692
+ createdDuringUse = created;
1693
+ return ok();
1694
+ }),
1695
+ );
1696
+
1697
+ assertFalse(createdDuringUse);
1698
+ assertEqual(resources.getCreateCount(), 1);
1699
+ assertEqual(
1700
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1701
+ 1,
1702
+ );
1703
+ });
1704
+
1705
+ it("releases its keyed lease when the callback Task aborts", async () => {
1706
+ await using run = testCreateRun();
1707
+ const resources = createTestResources();
1708
+ const callbackStarted = Promise.withResolvers<void>();
1709
+ const gate = createGate();
1710
+
1711
+ await using sharedResourceByKey = await run.ok(
1712
+ createSharedResourceByKey(() => resources.create),
1713
+ );
1714
+ const disposed = resources.nextDisposed();
1715
+
1716
+ const fiber = run.abortable(
1717
+ sharedResourceByKey.use("a", () => async (run) => {
1718
+ callbackStarted.resolve();
1719
+ await run.ok(gate.wait);
1720
+ return ok();
1721
+ }),
1722
+ );
1723
+ await callbackStarted.promise;
1724
+
1725
+ assertEqual(
1726
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1727
+ 1,
1728
+ );
1729
+
1730
+ fiber.abort(testAbortReason);
1731
+ const result = await fiber;
1732
+
1733
+ assertErr(result);
1734
+ assertType(AbortError, result.error);
1735
+ assertSame(result.error.reason, testAbortReason);
1736
+ assertEqual(
1737
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount ?? 0,
1738
+ 0,
1739
+ );
1740
+
1741
+ await disposed;
1742
+ assertEqual(resources.getDisposeCount(), 1);
1743
+ });
1744
+ });
1745
+
1746
+ describe("forEachCurrent", () => {
1747
+ it("delegates to every current resource while holding temporary leases", async () => {
1748
+ await using run = testCreateRun();
1749
+ const resources = createTestResources();
1750
+
1751
+ await using sharedResourceByKey = await run.ok(
1752
+ createSharedResourceByKey((_key: string) => resources.create),
1753
+ );
1754
+
1755
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1756
+ const second = await run.ok(sharedResourceByKey.acquire("b"));
1757
+ const entries: Array<readonly [typeof first.resource, string]> = [];
1758
+ const leaseCountsDuringUse = new Map<string, number>();
1759
+
1760
+ await run.ok(
1761
+ sharedResourceByKey.forEachCurrent((resource, key) => {
1762
+ entries.push([resource, key]);
1763
+ leaseCountsDuringUse.set(
1764
+ key,
1765
+ sharedResourceByKey.snapshot().resourcesByKey.get(key)!.leaseCount,
1766
+ );
1767
+ }),
1768
+ );
1769
+
1770
+ assertEqual(entries, [
1771
+ [first.resource, "a"],
1772
+ [second.resource, "b"],
1773
+ ]);
1774
+ assertEqual(
1775
+ leaseCountsDuringUse,
1776
+ new Map([
1777
+ ["a", 2],
1778
+ ["b", 2],
1779
+ ]),
1780
+ );
1781
+ assertEqual(
1782
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1783
+ 1,
1784
+ );
1785
+ assertEqual(
1786
+ sharedResourceByKey.snapshot().resourcesByKey.get("b")?.leaseCount,
1787
+ 1,
1788
+ );
1789
+ });
1790
+
1791
+ it("releases collected leases when the caller aborts while acquiring a later key", async () => {
1792
+ await using run = testCreateRun();
1793
+ const resources = createTestResources();
1794
+ const bCreateStarted = Promise.withResolvers<void>();
1795
+ const continueBCreate = Promise.withResolvers<void>();
1796
+
1797
+ await using sharedResourceByKey = await run.ok(
1798
+ createSharedResourceByKey((key: string) => async (run) => {
1799
+ if (key === "b") {
1800
+ bCreateStarted.resolve();
1801
+ await continueBCreate.promise;
1802
+ }
1803
+ return run(resources.create);
1804
+ }),
1805
+ );
1806
+
1807
+ using _a = await run.ok(sharedResourceByKey.acquire("a"));
1808
+ const b = run.ok(sharedResourceByKey.acquire("b"));
1809
+ await bCreateStarted.promise;
1810
+
1811
+ const keys: Array<string> = [];
1812
+ const forEach = run.abortable(
1813
+ sharedResourceByKey.forEachCurrent((_resource, key) => {
1814
+ keys.push(key);
1815
+ }),
1816
+ );
1817
+ const firstAcquireRunSnapshot = forEach.run.snapshot().children.at(0);
1818
+ assertNotUndefined(firstAcquireRunSnapshot);
1819
+ // forEachCurrent acquires keys sequentially. Replacing this child means
1820
+ // the first lease is held and acquisition of the blocked second key has
1821
+ // started. The exact count intentionally pins that settlement pipeline.
1822
+ await assertConditionAfterMicrotasks(() => {
1823
+ const { children } = forEach.run.snapshot();
1824
+ return (
1825
+ children.length === 1 &&
1826
+ !Object.is(children[0].id, firstAcquireRunSnapshot.id)
1827
+ );
1828
+ }, 34);
1829
+
1830
+ forEach.abort(testAbortReason);
1831
+ continueBCreate.resolve();
1832
+ using _b = await b;
1833
+
1834
+ const result = await forEach;
1835
+
1836
+ assertErr(result);
1837
+ assertType(AbortError, result.error);
1838
+ assertSame(result.error.reason, testAbortReason);
1839
+ assertEqual(keys, []);
1840
+ assertEqual(
1841
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.leaseCount,
1842
+ 1,
1843
+ );
1844
+ assertEqual(
1845
+ sharedResourceByKey.snapshot().resourcesByKey.get("b")?.leaseCount,
1846
+ 1,
1847
+ );
1848
+ });
1849
+
1850
+ it("skips a resource disposed before its temporary lease is acquired", async () => {
1851
+ await using run = testCreateRun();
1852
+ const disposalStarted = Promise.withResolvers<void>();
1853
+ const continueDisposal = Promise.withResolvers<void>();
1854
+ const keys: Array<string> = [];
1855
+
1856
+ const create: Task<AsyncDisposable> = () =>
1857
+ ok({
1858
+ [Symbol.asyncDispose]: async () => {
1859
+ disposalStarted.resolve();
1860
+ await continueDisposal.promise;
1861
+ },
1862
+ });
1863
+
1864
+ await using sharedResourceByKey = await run.ok(
1865
+ createSharedResourceByKey((_key: string) => create),
1866
+ );
1867
+
1868
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1869
+ await run.ok(sharedResourceByKey.acquire("b"));
1870
+
1871
+ first.release();
1872
+ await disposalStarted.promise;
1873
+
1874
+ const forEach = run.ok(
1875
+ sharedResourceByKey.forEachCurrent((_resource, key) => {
1876
+ keys.push(key);
1877
+ }),
1878
+ );
1879
+ continueDisposal.resolve();
1880
+ await forEach;
1881
+
1882
+ assertEqual(keys, ["b"]);
1883
+ });
1884
+ });
1885
+
1886
+ describe("idleDisposeAfter", () => {
1887
+ it("keeps a resource across release/acquire churn", async () => {
1888
+ await using run = testCreateRun();
1889
+ const resources = createTestResources();
1890
+
1891
+ await using sharedResourceByKey = await run.ok(
1892
+ createSharedResourceByKey(() => resources.create, {
1893
+ idleDisposeAfter: "3s",
1894
+ }),
1895
+ );
1896
+
1897
+ const first = await run.ok(sharedResourceByKey.acquire("a"));
1898
+ first.release();
1899
+
1900
+ const second = await run.ok(sharedResourceByKey.acquire("a"));
1901
+
1902
+ assertEqual(resources.getCreateCount(), 1);
1903
+ assertSame(second.resource, first.resource);
1904
+
1905
+ // After the last release, the idle delay elapses and the key is disposed.
1906
+ const disposed = resources.nextDisposed();
1907
+ second.release();
1908
+ run.deps.time.advance("3s");
1909
+ await disposed;
1910
+
1911
+ assertEqual(resources.getDisposeCount(), 1);
1912
+ assertFalse(
1913
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.hasResource,
1914
+ );
1915
+ });
1916
+ });
1917
+
1918
+ describe("snapshot", () => {
1919
+ it("returns current states by key", async () => {
1920
+ await using run = testCreateRun();
1921
+ const resources = createTestResources();
1922
+
1923
+ await using sharedResourceByKey = await run.ok(
1924
+ createSharedResourceByKey(() => resources.create),
1925
+ );
1926
+
1927
+ assertEqual(sharedResourceByKey.snapshot(), {
1928
+ resourcesByKey: new Map(),
1929
+ });
1930
+
1931
+ await run.ok(sharedResourceByKey.acquire("a"));
1932
+ await run.ok(sharedResourceByKey.acquire("a"));
1933
+ await run.ok(sharedResourceByKey.acquire("b"));
1934
+
1935
+ assertEqual(sharedResourceByKey.snapshot(), {
1936
+ resourcesByKey: new Map([
1937
+ [
1938
+ "a",
1939
+ {
1940
+ isIdle: false,
1941
+ leaseCount: 2,
1942
+ hasResource: true,
1943
+ idleDisposePending: false,
1944
+ mutex: idleMutexSnapshot,
1945
+ },
1946
+ ],
1947
+ [
1948
+ "b",
1949
+ {
1950
+ isIdle: false,
1951
+ leaseCount: 1,
1952
+ hasResource: true,
1953
+ idleDisposePending: false,
1954
+ mutex: idleMutexSnapshot,
1955
+ },
1956
+ ],
1957
+ ]),
1958
+ });
1959
+ });
1960
+ });
1961
+
1962
+ describe("onDisposed", () => {
1963
+ it("does not call onDisposed when registry is disposed before resource creation", async () => {
1964
+ await using run = testCreateRun();
1965
+ const disposedKeys: Array<string> = [];
1966
+
1967
+ const sharedResourceByKey = await run.ok(
1968
+ createSharedResourceByKey(
1969
+ (_key: string) => createTestResources().create,
1970
+ {
1971
+ onDisposed: (key) => {
1972
+ disposedKeys.push(key);
1973
+ },
1974
+ },
1975
+ ),
1976
+ );
1977
+
1978
+ await sharedResourceByKey[Symbol.asyncDispose]();
1979
+
1980
+ assertEqual(disposedKeys, []);
1981
+ });
1982
+
1983
+ it("calls onDisposed for every key during owner disposal", async () => {
1984
+ await using run = testCreateRun();
1985
+ const resources = createTestResources();
1986
+ const disposedKeys: Array<string> = [];
1987
+
1988
+ const sharedResourceByKey = await run.ok(
1989
+ createSharedResourceByKey((_key: string) => resources.create, {
1990
+ onDisposed: (key) => {
1991
+ disposedKeys.push(key);
1992
+ },
1993
+ }),
1994
+ );
1995
+ await run.ok(sharedResourceByKey.acquire("a"));
1996
+ await run.ok(sharedResourceByKey.acquire("b"));
1997
+
1998
+ await sharedResourceByKey[Symbol.asyncDispose]();
1999
+
2000
+ assertLength(disposedKeys, 2);
2001
+ assertEqual(new Set(disposedKeys), new Set(["a", "b"]));
2002
+ });
2003
+
2004
+ it("receives the key", async () => {
2005
+ await using run = testCreateRun();
2006
+ const resources = createTestResources();
2007
+ const disposedKeys: Array<string> = [];
2008
+ const onDisposedCalled = Promise.withResolvers<void>();
2009
+
2010
+ await using sharedResourceByKey = await run.ok(
2011
+ createSharedResourceByKey((_key: string) => resources.create, {
2012
+ onDisposed: (key) => {
2013
+ disposedKeys.push(key);
2014
+ onDisposedCalled.resolve();
2015
+ },
2016
+ }),
2017
+ );
2018
+
2019
+ const lease = await run.ok(sharedResourceByKey.acquire("a"));
2020
+
2021
+ assertEqual(disposedKeys, []);
2022
+
2023
+ lease.release();
2024
+ await onDisposedCalled.promise;
2025
+
2026
+ assertEqual(disposedKeys, ["a"]);
2027
+ });
2028
+ });
2029
+
2030
+ describe("disposal", () => {
2031
+ it("aborts acquire when the registry is disposed during creation", async () => {
2032
+ await using run = testCreateRun();
2033
+ const resources = createTestResources();
2034
+ const createStarted = Promise.withResolvers<void>();
2035
+ const continueCreate = Promise.withResolvers<void>();
2036
+
2037
+ const sharedResourceByKey = await run.ok(
2038
+ createSharedResourceByKey(() => async (run) => {
2039
+ const resource = await run.ok(resources.create);
2040
+ createStarted.resolve();
2041
+ await continueCreate.promise;
2042
+ return ok(resource);
2043
+ }),
2044
+ );
2045
+
2046
+ const acquire = run.abortable(sharedResourceByKey.acquire("a"));
2047
+ await createStarted.promise;
2048
+
2049
+ const disposal = sharedResourceByKey[Symbol.asyncDispose]();
2050
+ continueCreate.resolve();
2051
+
2052
+ const result = await acquire;
2053
+ await disposal;
2054
+
2055
+ assertErr(result);
2056
+ assertType(AbortError, result.error);
2057
+ assertSame(result.error.reason, runDisposedAbortReason);
2058
+ assertEqual(resources.getCreateCount(), 1);
2059
+ assertEqual(resources.getDisposeCount(), 1);
2060
+ });
2061
+
2062
+ it("aborts acquireCurrent when the registry is disposed while it waits", async () => {
2063
+ await using run = testCreateRun();
2064
+ const disposalStarted = Promise.withResolvers<void>();
2065
+ const continueDisposal = Promise.withResolvers<void>();
2066
+
2067
+ const sharedResourceByKey = await run.ok(
2068
+ createSharedResourceByKey(
2069
+ () => () =>
2070
+ ok({
2071
+ [Symbol.asyncDispose]: async () => {
2072
+ disposalStarted.resolve();
2073
+ await continueDisposal.promise;
2074
+ },
2075
+ }),
2076
+ ),
2077
+ );
2078
+
2079
+ const lease = await run.ok(sharedResourceByKey.acquire("a"));
2080
+ lease.release();
2081
+ await disposalStarted.promise;
2082
+
2083
+ const acquireCurrent = run.abortable(
2084
+ sharedResourceByKey.acquireCurrent("a"),
2085
+ );
2086
+ await assertConditionAfterMicrotasks(
2087
+ () =>
2088
+ sharedResourceByKey.snapshot().resourcesByKey.get("a")?.mutex.waiters
2089
+ .length === 1,
2090
+ 5,
2091
+ );
2092
+
2093
+ const disposal = sharedResourceByKey[Symbol.asyncDispose]();
2094
+ continueDisposal.resolve();
2095
+
2096
+ const result = await acquireCurrent;
2097
+ await disposal;
2098
+
2099
+ assertErr(result);
2100
+ assertType(AbortError, result.error);
2101
+ assertSame(result.error.reason, runDisposedAbortReason);
2102
+ });
2103
+
2104
+ it("disposes all keyed resources", async () => {
2105
+ await using run = testCreateRun();
2106
+ const resources = createTestResources();
2107
+
2108
+ const sharedResourceByKey = await run.ok(
2109
+ createSharedResourceByKey(() => resources.create),
2110
+ );
2111
+
2112
+ const leaseA = await run.ok(sharedResourceByKey.acquire("a"));
2113
+ const leaseB = await run.ok(sharedResourceByKey.acquire("b"));
2114
+
2115
+ await sharedResourceByKey[Symbol.asyncDispose]();
2116
+
2117
+ assertEqual(resources.getDisposeCount(), 2);
2118
+ assertFalse(leaseA.release());
2119
+ assertFalse(leaseB.release());
2120
+ assertEqual(sharedResourceByKey.snapshot().resourcesByKey.size, 0);
2121
+ });
2122
+
2123
+ it("registry disposal owns concurrently removed keys", async () => {
2124
+ await using run = testCreateRun();
2125
+ const aDisposalStarted = Promise.withResolvers<void>();
2126
+ const continueADisposal = Promise.withResolvers<void>();
2127
+ const aDisposed = Promise.withResolvers<void>();
2128
+ const bCreateStarted = Promise.withResolvers<void>();
2129
+ const bCreateGate = createGate();
2130
+
2131
+ const create =
2132
+ (key: string): Task<AsyncDisposable> =>
2133
+ async (run) => {
2134
+ if (key === "b") {
2135
+ bCreateStarted.resolve();
2136
+ await run.ok(bCreateGate.wait);
2137
+ }
2138
+
2139
+ return ok({
2140
+ [Symbol.asyncDispose]: async () => {
2141
+ if (key !== "a") return;
2142
+ aDisposalStarted.resolve();
2143
+ await continueADisposal.promise;
2144
+ },
2145
+ });
2146
+ };
2147
+
2148
+ const sharedResourceByKey = await run.ok(
2149
+ createSharedResourceByKey(create, {
2150
+ onDisposed: (key) => {
2151
+ if (key === "a") aDisposed.resolve();
2152
+ },
2153
+ }),
2154
+ );
2155
+
2156
+ const a = await run.ok(sharedResourceByKey.acquire("a"));
2157
+ a.release();
2158
+ await aDisposalStarted.promise;
2159
+
2160
+ const b = run.abortable(sharedResourceByKey.acquire("b"));
2161
+ await bCreateStarted.promise;
2162
+
2163
+ const registryDisposal = sharedResourceByKey[Symbol.asyncDispose]();
2164
+ continueADisposal.resolve();
2165
+ await aDisposed.promise;
2166
+
2167
+ bCreateGate.open();
2168
+ const bResult = await b;
2169
+ await registryDisposal;
2170
+
2171
+ assertErr(bResult);
2172
+ assertType(AbortError, bResult.error);
2173
+ assertSame(bResult.error.reason, runDisposedAbortReason);
2174
+ assertEqual(run.snapshot().children, []);
2175
+ });
2176
+
2177
+ it("root Run disposal awaits keyed resource disposal", async () => {
2178
+ const run = testCreateRun();
2179
+ const disposalStarted = Promise.withResolvers<void>();
2180
+ const continueDisposal = Promise.withResolvers<void>();
2181
+
2182
+ const sharedResourceByKey = await run.ok(
2183
+ createSharedResourceByKey(
2184
+ (_key: string) => () =>
2185
+ ok({
2186
+ [Symbol.asyncDispose]: async () => {
2187
+ disposalStarted.resolve();
2188
+ await continueDisposal.promise;
2189
+ },
2190
+ }),
2191
+ ),
2192
+ );
2193
+ await run.ok(sharedResourceByKey.acquire("a"));
2194
+
2195
+ let rootDisposalSettled = false;
2196
+ const rootDisposal = run[Symbol.asyncDispose]().then(() => {
2197
+ rootDisposalSettled = true;
2198
+ });
2199
+
2200
+ try {
2201
+ const firstSettled = await Promise.race([
2202
+ disposalStarted.promise.then(() => "resourceDisposalStarted"),
2203
+ rootDisposal.then(() => "rootDisposalSettled"),
2204
+ ]);
2205
+
2206
+ assertEqual(firstSettled, "resourceDisposalStarted");
2207
+ assertFalse(rootDisposalSettled);
2208
+
2209
+ continueDisposal.resolve();
2210
+ await rootDisposal;
2211
+
2212
+ assertEqual(sharedResourceByKey.snapshot().resourcesByKey.size, 0);
2213
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), []);
2214
+ } finally {
2215
+ continueDisposal.resolve();
2216
+ await sharedResourceByKey[Symbol.asyncDispose]();
2217
+ await rootDisposal;
2218
+ }
2219
+ });
2220
+ });
2221
+
2222
+ describe("leak detection", () => {
2223
+ it("warns when an undisposed SharedResourceByKey is garbage-collected", async () => {
2224
+ await using run = testCreateRun();
2225
+
2226
+ await run.ok(
2227
+ createSharedResourceByKey(
2228
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
2229
+ ),
2230
+ );
2231
+
2232
+ assertEqual(run.deps.leakDetector.collect(), 1);
2233
+
2234
+ const entries = run.deps.console.getEntriesSnapshot();
2235
+ assertLength(entries, 1);
2236
+ assertEqual(entries[0].method, "warn");
2237
+ assertEqual(
2238
+ entries[0].args[0],
2239
+ "SharedResourceByKey was garbage-collected without cleanup. Tracked at:",
2240
+ );
2241
+ });
2242
+ });
2243
+
2244
+ describe("defects", () => {
2245
+ it("acquire after registry disposal is a programmer error", async () => {
2246
+ await using run = testCreateRun();
2247
+
2248
+ const sharedResourceByKey = await run.ok(
2249
+ createSharedResourceByKey(
2250
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
2251
+ ),
2252
+ );
2253
+ await sharedResourceByKey[Symbol.asyncDispose]();
2254
+
2255
+ const result = await run.abortable(sharedResourceByKey.acquire("a"));
2256
+
2257
+ assertErr(result);
2258
+ assertType(AbortError, result.error);
2259
+ assertSame(result.error.reason.type, "PanicAbortReason");
2260
+ assertInstanceOf(result.error.reason.defect, Error);
2261
+ assertEqual(
2262
+ result.error.reason.defect.message,
2263
+ "Cannot use a disposed object.",
2264
+ );
2265
+ assertSame(await run.deps.reportDefect.next(), result.error);
2266
+ });
2267
+
2268
+ it("throwing create factory panics the Run tree", async () => {
2269
+ await using run = testCreateRun();
2270
+ const defect = new Error("create failed");
2271
+
2272
+ await using sharedResourceByKey = await run.ok(
2273
+ createSharedResourceByKey((_key: string) => {
2274
+ throw defect;
2275
+ }),
2276
+ );
2277
+
2278
+ const result = await run.abortable(sharedResourceByKey.acquire("a"));
2279
+
2280
+ assertErr(result);
2281
+ assertType(AbortError, result.error);
2282
+ assertSame(result.error.reason.type, "PanicAbortReason");
2283
+ assertSame(result.error.reason.defect, defect);
2284
+ assertSame(await run.deps.reportDefect.next(), result.error);
2285
+ });
2286
+
2287
+ it("throwing lookup panics the Run tree", async () => {
2288
+ await using run = testCreateRun();
2289
+ const resources = createTestResources();
2290
+ const defect = new Error("lookup failed");
2291
+
2292
+ await using sharedResourceByKey = await run.ok(
2293
+ createSharedResourceByKey(() => resources.create, {
2294
+ lookup: (_key: string): string => {
2295
+ throw defect;
2296
+ },
2297
+ }),
2298
+ );
2299
+
2300
+ const result = await run.abortable(sharedResourceByKey.acquire("a"));
2301
+
2302
+ assertErr(result);
2303
+ assertType(AbortError, result.error);
2304
+ assertSame(result.error.reason.type, "PanicAbortReason");
2305
+ assertSame(result.error.reason.defect, defect);
2306
+ assertSame(await run.deps.reportDefect.next(), result.error);
2307
+ });
2308
+
2309
+ it("throwing onDisposed panics the Run tree", async () => {
2310
+ await using run = testCreateRun();
2311
+ const resources = createTestResources();
2312
+ const defect = new Error("onDisposed failed");
2313
+
2314
+ await using sharedResourceByKey = await run.ok(
2315
+ createSharedResourceByKey(() => resources.create, {
2316
+ onDisposed: () => {
2317
+ throw defect;
2318
+ },
2319
+ }),
2320
+ );
2321
+
2322
+ const lease = await run.ok(sharedResourceByKey.acquire("a"));
2323
+ lease.release();
2324
+
2325
+ // Disposal runs on a fire-and-forget Fiber, so the defect is observable
2326
+ // only via panic reporting.
2327
+ const abortError = await run.deps.reportDefect.next();
2328
+ assertType(AbortError, abortError);
2329
+ assertSame(abortError.reason.type, "PanicAbortReason");
2330
+ assertSame(abortError.reason.defect, defect);
2331
+ assertFalse(sharedResourceByKey.snapshot().resourcesByKey.has("a"));
2332
+ });
2333
+ });
2334
+
2335
+ describe("types", () => {
2336
+ it("acquire does not require captured deps", () => {
2337
+ interface TestDb extends Disposable {
2338
+ readonly query: () => string;
2339
+ }
2340
+ interface TestDep {
2341
+ readonly value: string;
2342
+ }
2343
+
2344
+ assertType<
2345
+ SharedResourceByKey<string, TestDb>["acquire"],
2346
+ (key: string) => Task<Lease<TestDb>>
2347
+ >();
2348
+
2349
+ const create = ((_key: string) => () =>
2350
+ ok({ [Symbol.dispose]: constVoid })) as (
2351
+ key: string,
2352
+ ) => Task<Disposable, never, TestDep>;
2353
+
2354
+ {
2355
+ const actual = createSharedResourceByKey(create);
2356
+ assertType<
2357
+ typeof actual,
2358
+ Task<SharedResourceByKey<string, Disposable>, never, TestDep>
2359
+ >();
2360
+ }
2361
+ {
2362
+ const actual = createSharedResourceByKey(create, {
2363
+ lookup: (key) => key,
2364
+ });
2365
+ assertType<
2366
+ typeof actual,
2367
+ Task<SharedResourceByKey<string, Disposable>, never, TestDep>
2368
+ >();
2369
+ }
2370
+
2371
+ const failingCreate = (_key: string) => () =>
2372
+ err({ type: "CreateError" });
2373
+
2374
+ // @ts-expect-error - create Task must not fail.
2375
+ createSharedResourceByKey(failingCreate);
2376
+ });
2377
+
2378
+ it("keyed acquireCurrent and use preserve their Task types", async () => {
2379
+ await using run = testCreateRun();
2380
+
2381
+ interface TestDb extends Disposable {
2382
+ readonly query: () => string;
2383
+ }
2384
+ interface TestError {
2385
+ readonly type: "TestError";
2386
+ }
2387
+ interface TestDep {
2388
+ readonly value: string;
2389
+ }
2390
+
2391
+ const create =
2392
+ (_key: string): Task<TestDb> =>
2393
+ () =>
2394
+ ok({ query: () => "result", [Symbol.dispose]: constVoid });
2395
+
2396
+ await using sharedResourceByKey = await run.ok(
2397
+ createSharedResourceByKey(create),
2398
+ );
2399
+
2400
+ assertType<
2401
+ typeof sharedResourceByKey.acquireCurrent,
2402
+ (key: string) => Task<Lease<TestDb> | undefined>
2403
+ >();
2404
+
2405
+ const use = sharedResourceByKey.use(
2406
+ "a",
2407
+ (): Task<"used", TestError, TestDep> => () =>
2408
+ err({ type: "TestError" }),
2409
+ );
2410
+ assertType<typeof use, Task<"used", TestError, TestDep>>();
2411
+ });
2412
+ });
2413
+ });
2414
+
2415
+ describe("SharedResourceByKeyWithClaims", () => {
2416
+ describe("types", () => {
2417
+ it("types expose claim as a non-failing Task returning ClaimLease", () => {
2418
+ assertType<ClaimLease extends Disposable ? true : false, true>();
2419
+ assertType<
2420
+ SharedResourceByKeyWithClaims<string, number, Disposable>["claim"],
2421
+ (
2422
+ claim: number,
2423
+ resourceKeys: NonEmptyReadonlyArray<string>,
2424
+ ) => Task<ClaimLease>
2425
+ >();
2426
+
2427
+ createSharedResourceByKeyWithClaims(
2428
+ (_key: string): Task<Disposable> =>
2429
+ () =>
2430
+ ok({ [Symbol.dispose]: constVoid }),
2431
+ );
2432
+ });
2433
+
2434
+ it("types expose scoped use with borrowed resources and callback Task errors and dependencies", () => {
2435
+ assertType<
2436
+ SharedResourceByKeyWithClaims<string, number, Disposable>["use"],
2437
+ <R, E, D>(
2438
+ claim: number,
2439
+ resourceKeys: NonEmptyReadonlyArray<string>,
2440
+ callback: (
2441
+ resources: NonEmptyReadonlyArray<
2442
+ readonly [BorrowedResource<Disposable>, string]
2443
+ >,
2444
+ ) => Task<R, E, D>,
2445
+ ) => Task<R, E, D>
2446
+ >();
2447
+ });
2448
+
2449
+ it("types reject empty claim and use resource keys", () => {
2450
+ type Claims = SharedResourceByKeyWithClaims<string, number, Disposable>;
2451
+
2452
+ // @ts-expect-error - a claim must retain at least one resource.
2453
+ const _invalidClaimResourceKeys: Parameters<Claims["claim"]>[1] = [];
2454
+ // @ts-expect-error - scoped use must retain at least one resource.
2455
+ const _invalidUseResourceKeys: Parameters<Claims["use"]>[1] = [];
2456
+ });
2457
+
2458
+ it("types do not expose command-based removeClaim", () => {
2459
+ assertType<
2460
+ "removeClaim" extends keyof SharedResourceByKeyWithClaims<
2461
+ string,
2462
+ number,
2463
+ Disposable
2464
+ >
2465
+ ? true
2466
+ : false,
2467
+ false
2468
+ >();
2469
+ });
2470
+
2471
+ it("types accept interface-shaped keys and claims with lookup functions", () => {
2472
+ interface ResourceKey {
2473
+ readonly id: string;
2474
+ }
2475
+
2476
+ interface Claim {
2477
+ readonly id: number;
2478
+ }
2479
+
2480
+ const task = createSharedResourceByKeyWithClaims(
2481
+ (_key: ResourceKey): Task<Disposable> =>
2482
+ () =>
2483
+ ok({ [Symbol.dispose]: constVoid }),
2484
+ {
2485
+ resourceLookup: (key) => key.id,
2486
+ claimLookup: (claim: Claim) => claim.id,
2487
+ },
2488
+ );
2489
+
2490
+ assertType<
2491
+ typeof task,
2492
+ Task<SharedResourceByKeyWithClaims<ResourceKey, Claim, Disposable>>
2493
+ >();
2494
+ });
2495
+ });
2496
+
2497
+ describe("use", () => {
2498
+ it("provides only its resources in input order and releases them after the callback settles", async () => {
2499
+ await using run = testCreateRun();
2500
+ const callbackStarted = Promise.withResolvers<void>();
2501
+ const continueCallback = createGate();
2502
+ const scopedResourcesDisposed = Promise.withResolvers<void>();
2503
+ const disposedKeys = new Set<string>();
2504
+
2505
+ interface TestResource extends Disposable {
2506
+ readonly key: string;
2507
+ readonly isDisposed: () => boolean;
2508
+ }
2509
+
2510
+ await using sharedResourceByKeyWithClaims = await run.ok(
2511
+ createSharedResourceByKeyWithClaims(
2512
+ (key: string): Task<TestResource> =>
2513
+ () =>
2514
+ ok({
2515
+ key,
2516
+ isDisposed: () => disposedKeys.has(key),
2517
+ [Symbol.dispose]: () => {
2518
+ disposedKeys.add(key);
2519
+ if (disposedKeys.has("a") && disposedKeys.has("b")) {
2520
+ scopedResourcesDisposed.resolve();
2521
+ }
2522
+ },
2523
+ }),
2524
+ ),
2525
+ );
2526
+ using _existingClaimLease = await run.ok(
2527
+ sharedResourceByKeyWithClaims.claim("claim", ["existing"]),
2528
+ );
2529
+
2530
+ const use = run.ok(
2531
+ sharedResourceByKeyWithClaims.use(
2532
+ "claim",
2533
+ ["b", "a"],
2534
+ (resources) => async (run) => {
2535
+ callbackStarted.resolve();
2536
+ await run.ok(continueCallback.wait);
2537
+ return ok(
2538
+ resources.map(([resource, resourceKey]) => {
2539
+ assertFalse(resource.isDisposed());
2540
+ return [resource.key, resourceKey] as const;
2541
+ }),
2542
+ );
2543
+ },
2544
+ ),
2545
+ );
2546
+ await callbackStarted.promise;
2547
+
2548
+ assertEqual(disposedKeys, new Set());
2549
+ assertEqual(
2550
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
2551
+ new Set(["existing", "b", "a"]),
2552
+ );
2553
+
2554
+ continueCallback.open();
2555
+
2556
+ assertEqual(await use, [
2557
+ ["b", "b"],
2558
+ ["a", "a"],
2559
+ ]);
2560
+ await scopedResourcesDisposed.promise;
2561
+ assertEqual(
2562
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
2563
+ new Set(["existing"]),
2564
+ );
2565
+ });
2566
+
2567
+ it("releases its ClaimLease when the callback Task aborts", async () => {
2568
+ await using run = testCreateRun();
2569
+ const resources = createTestResources();
2570
+ const callbackStarted = Promise.withResolvers<void>();
2571
+ const gate = createGate();
2572
+
2573
+ await using sharedResourceByKeyWithClaims = await run.ok(
2574
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
2575
+ );
2576
+ const disposed = resources.nextDisposed();
2577
+
2578
+ const fiber = run.abortable(
2579
+ sharedResourceByKeyWithClaims.use(
2580
+ "claim",
2581
+ ["resource"],
2582
+ () => async (run) => {
2583
+ callbackStarted.resolve();
2584
+ await run.ok(gate.wait);
2585
+ return ok();
2586
+ },
2587
+ ),
2588
+ );
2589
+ await callbackStarted.promise;
2590
+
2591
+ assertEqual(
2592
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
2593
+ new Set(["resource"]),
2594
+ );
2595
+
2596
+ fiber.abort(testAbortReason);
2597
+ const result = await fiber;
2598
+
2599
+ assertErr(result);
2600
+ assertType(AbortError, result.error);
2601
+ assertSame(result.error.reason, testAbortReason);
2602
+ assertEqual(
2603
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
2604
+ new Set(),
2605
+ );
2606
+
2607
+ await disposed;
2608
+ assertEqual(resources.getDisposeCount(), 1);
2609
+ });
2610
+ });
2611
+
2612
+ describe("claim lifecycle", () => {
2613
+ it("claim lazily creates and retains every keyed resource until its claim lease is released", async () => {
2614
+ await using run = testCreateRun();
2615
+ const resources = createTestResources();
2616
+
2617
+ await using sharedResourceByKeyWithClaims = await run.ok(
2618
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
2619
+ );
2620
+
2621
+ assertEqual(resources.getCreateCount(), 0);
2622
+
2623
+ const claimLease = await run.ok(
2624
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
2625
+ );
2626
+
2627
+ assertEqual(resources.getCreateCount(), 1);
2628
+ assertEqual(resources.getDisposeCount(), 0);
2629
+
2630
+ const disposed = resources.nextDisposed();
2631
+ assertTrue(claimLease.release());
2632
+ await disposed;
2633
+
2634
+ assertEqual(resources.getDisposeCount(), 1);
2635
+ });
2636
+
2637
+ it("claim lease release is idempotent", async () => {
2638
+ await using run = testCreateRun();
2639
+ const resources = createTestResources();
2640
+
2641
+ await using sharedResourceByKeyWithClaims = await run.ok(
2642
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
2643
+ );
2644
+ const claimLease = await run.ok(
2645
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
2646
+ );
2647
+
2648
+ const disposed = resources.nextDisposed();
2649
+ assertTrue(claimLease.release());
2650
+ assertFalse(claimLease.release());
2651
+ await disposed;
2652
+
2653
+ assertEqual(resources.getDisposeCount(), 1);
2654
+ });
2655
+
2656
+ it("claim lease Symbol.dispose releases it", async () => {
2657
+ await using run = testCreateRun();
2658
+ const resources = createTestResources();
2659
+
2660
+ await using sharedResourceByKeyWithClaims = await run.ok(
2661
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
2662
+ );
2663
+ const claimLease = await run.ok(
2664
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
2665
+ );
2666
+
2667
+ const disposed = resources.nextDisposed();
2668
+ claimLease[Symbol.dispose]();
2669
+ await disposed;
2670
+
2671
+ assertEqual(resources.getDisposeCount(), 1);
2672
+ });
2673
+
2674
+ it("repeated claims for the same logical pair retain independently without duplicating the relation", async () => {
2675
+ await using run = testCreateRun();
2676
+ const resources = createTestResources();
2677
+
2678
+ await using sharedResourceByKeyWithClaims = await run.ok(
2679
+ createSharedResourceByKeyWithClaims(
2680
+ (_key: { readonly id: string }) => resources.create,
2681
+ {
2682
+ resourceLookup: (key) => key.id,
2683
+ claimLookup: (claim: { readonly id: string }) => claim.id,
2684
+ },
2685
+ ),
2686
+ );
2687
+ const first = await run.ok(
2688
+ sharedResourceByKeyWithClaims.claim({ id: "claim" }, [
2689
+ { id: "resource" },
2690
+ ]),
2691
+ );
2692
+ const second = await run.ok(
2693
+ sharedResourceByKeyWithClaims.claim({ id: "claim" }, [
2694
+ { id: "resource" },
2695
+ ]),
2696
+ );
2697
+
2698
+ assertEqual(resources.getCreateCount(), 1);
2699
+
2700
+ const disposed = resources.nextDisposed();
2701
+ assertTrue(first.release());
2702
+ assertEqual(resources.getDisposeCount(), 0);
2703
+ assertTrue(second.release());
2704
+ await disposed;
2705
+
2706
+ assertEqual(resources.getDisposeCount(), 1);
2707
+ });
2708
+
2709
+ it("multiple claims share one keyed resource until the last claim lease is released", async () => {
2710
+ await using run = testCreateRun();
2711
+ const resources = createTestResources();
2712
+
2713
+ await using sharedResourceByKeyWithClaims = await run.ok(
2714
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
2715
+ );
2716
+ const first = await run.ok(
2717
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
2718
+ );
2719
+ const second = await run.ok(
2720
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
2721
+ );
2722
+
2723
+ assertEqual(resources.getCreateCount(), 1);
2724
+
2725
+ const disposed = resources.nextDisposed();
2726
+ assertTrue(first.release());
2727
+ assertEqual(resources.getDisposeCount(), 0);
2728
+ assertTrue(second.release());
2729
+ await disposed;
2730
+
2731
+ assertEqual(resources.getDisposeCount(), 1);
2732
+ });
2733
+
2734
+ it("one claim lease retains and releases multiple resource keys together", async () => {
2735
+ await using run = testCreateRun();
2736
+ const createdKeys: Array<string> = [];
2737
+ const disposedKeys: Array<string> = [];
2738
+ const allDisposed = Promise.withResolvers<void>();
2739
+
2740
+ await using sharedResourceByKeyWithClaims = await run.ok(
2741
+ createSharedResourceByKeyWithClaims(
2742
+ (key: string): Task<Disposable> =>
2743
+ () => {
2744
+ createdKeys.push(key);
2745
+ return ok({
2746
+ [Symbol.dispose]: () => {
2747
+ disposedKeys.push(key);
2748
+ if (disposedKeys.length === 2) allDisposed.resolve();
2749
+ },
2750
+ });
2751
+ },
2752
+ ),
2753
+ );
2754
+ const claimLease = await run.ok(
2755
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
2756
+ );
2757
+
2758
+ assertEqual(createdKeys, ["a", "b"]);
2759
+
2760
+ assertTrue(claimLease.release());
2761
+ await allDisposed.promise;
2762
+
2763
+ assertEqual(new Set(disposedKeys), new Set(["a", "b"]));
2764
+ });
2765
+
2766
+ it("claim rejects lookup-duplicate resource keys as a programmer defect", async () => {
2767
+ await using run = testCreateRun();
2768
+ const resources = createTestResources();
2769
+
2770
+ await using sharedResourceByKeyWithClaims = await run.ok(
2771
+ createSharedResourceByKeyWithClaims(
2772
+ (_key: { readonly id: string }) => resources.create,
2773
+ { resourceLookup: (key) => key.id },
2774
+ ),
2775
+ );
2776
+
2777
+ const result = await run.abortable(
2778
+ sharedResourceByKeyWithClaims.claim("claim", [
2779
+ { id: "resource" },
2780
+ { id: "resource" },
2781
+ ]),
2782
+ );
2783
+
2784
+ assertErr(result);
2785
+ assertType(AbortError, result.error);
2786
+ assertSame(result.error.reason.type, "PanicAbortReason");
2787
+ assertInstanceOf(result.error.reason.defect, Error);
2788
+ assertEqual(
2789
+ result.error.reason.defect.message,
2790
+ "resourceKeys must not contain lookup duplicates.",
2791
+ );
2792
+ assertEqual(resources.getCreateCount(), 0);
2793
+ assertSame(await run.deps.reportDefect.next(), result.error);
2794
+ });
2795
+
2796
+ it("claim snapshots resource keys before awaiting acquisition", async () => {
2797
+ await using run = testCreateRun();
2798
+ const createStarted = Promise.withResolvers<void>();
2799
+ const continueCreate = Promise.withResolvers<void>();
2800
+ const createdKeys: Array<string> = [];
2801
+ const resourceKeys: NonEmptyArray<string> = ["a"];
2802
+
2803
+ await using sharedResourceByKeyWithClaims = await run.ok(
2804
+ createSharedResourceByKeyWithClaims(
2805
+ (key: string): Task<Disposable> =>
2806
+ async () => {
2807
+ createdKeys.push(key);
2808
+ if (key === "a") {
2809
+ createStarted.resolve();
2810
+ await continueCreate.promise;
2811
+ }
2812
+ return ok({ [Symbol.dispose]: constVoid });
2813
+ },
2814
+ ),
2815
+ );
2816
+
2817
+ const claim = run.ok(
2818
+ sharedResourceByKeyWithClaims.claim("claim", resourceKeys),
2819
+ );
2820
+ await createStarted.promise;
2821
+ resourceKeys.push("b");
2822
+ continueCreate.resolve();
2823
+ using _claimLease = await claim;
2824
+
2825
+ assertEqual(createdKeys, ["a"]);
2826
+ assertEqual(
2827
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
2828
+ new Set(["a"]),
2829
+ );
2830
+ });
2831
+
2832
+ it("later claim deps do not replace deps captured for resource creation", async () => {
2833
+ await using run = testCreateRun();
2834
+
2835
+ interface TestDep {
2836
+ readonly value: string;
2837
+ }
2838
+
2839
+ const createValues: Array<string> = [];
2840
+
2841
+ await using sharedResourceByKeyWithClaims = await run.ok(
2842
+ createSharedResourceByKeyWithClaims(
2843
+ (_key: string): Task<Disposable, never, TestDep> =>
2844
+ ({ deps }) => {
2845
+ createValues.push(deps.value);
2846
+ return ok({ [Symbol.dispose]: constVoid });
2847
+ },
2848
+ ),
2849
+ { value: "captured" },
2850
+ );
2851
+
2852
+ using _claimLease = await run.ok(
2853
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
2854
+ { value: "replacement" },
2855
+ );
2856
+
2857
+ assertEqual(createValues, ["captured"]);
2858
+ });
2859
+ });
2860
+
2861
+ describe("relations", () => {
2862
+ it("gets unique canonical claims retaining a resource key", async () => {
2863
+ await using run = testCreateRun();
2864
+
2865
+ interface ResourceKey {
2866
+ readonly id: string;
2867
+ readonly label: string;
2868
+ }
2869
+
2870
+ interface Claim {
2871
+ readonly id: string;
2872
+ readonly label: string;
2873
+ }
2874
+
2875
+ const resourceKey = { id: "resource", label: "first" };
2876
+ const firstClaim = { id: "first", label: "first" };
2877
+ const equivalentFirstClaim = { id: "first", label: "equivalent" };
2878
+ const secondClaim = { id: "second", label: "second" };
2879
+
2880
+ await using sharedResourceByKeyWithClaims = await run.ok(
2881
+ createSharedResourceByKeyWithClaims(
2882
+ (_key: ResourceKey) => () => ok({ [Symbol.dispose]: constVoid }),
2883
+ {
2884
+ resourceLookup: (key) => key.id,
2885
+ claimLookup: (claim: Claim) => claim.id,
2886
+ },
2887
+ ),
2888
+ );
2889
+ using _first = await run.ok(
2890
+ sharedResourceByKeyWithClaims.claim(firstClaim, [resourceKey]),
2891
+ );
2892
+ using _equivalentFirst = await run.ok(
2893
+ sharedResourceByKeyWithClaims.claim(equivalentFirstClaim, [
2894
+ { id: "resource", label: "equivalent" },
2895
+ ]),
2896
+ );
2897
+ using _second = await run.ok(
2898
+ sharedResourceByKeyWithClaims.claim(secondClaim, [resourceKey]),
2899
+ );
2900
+
2901
+ assertEqual(
2902
+ sharedResourceByKeyWithClaims.getClaimsForResource({
2903
+ id: "resource",
2904
+ label: "query",
2905
+ }),
2906
+ new Set([firstClaim, secondClaim]),
2907
+ );
2908
+
2909
+ const snapshot = sharedResourceByKeyWithClaims.snapshot();
2910
+ assertSame(
2911
+ [...snapshot.retainCountsByResourceKeyByClaim.keys()][0],
2912
+ firstClaim,
2913
+ );
2914
+ const firstClaimRetainCounts =
2915
+ snapshot.retainCountsByResourceKeyByClaim.get(firstClaim);
2916
+ assertNotUndefined(firstClaimRetainCounts);
2917
+ assertSame([...firstClaimRetainCounts.keys()][0], resourceKey);
2918
+ assertSame([...snapshot.resourcesByKey.keys()][0], resourceKey);
2919
+ });
2920
+
2921
+ it("gets unique canonical resource keys retained by a claim", async () => {
2922
+ await using run = testCreateRun();
2923
+
2924
+ interface ResourceKey {
2925
+ readonly id: string;
2926
+ readonly label: string;
2927
+ }
2928
+
2929
+ interface Claim {
2930
+ readonly id: string;
2931
+ readonly label: string;
2932
+ }
2933
+
2934
+ const claim = { id: "claim", label: "first" };
2935
+ const firstResourceKey = { id: "first", label: "first" };
2936
+ const secondResourceKey = { id: "second", label: "second" };
2937
+
2938
+ await using sharedResourceByKeyWithClaims = await run.ok(
2939
+ createSharedResourceByKeyWithClaims(
2940
+ (_key: ResourceKey) => () => ok({ [Symbol.dispose]: constVoid }),
2941
+ {
2942
+ resourceLookup: (key) => key.id,
2943
+ claimLookup: (claim: Claim) => claim.id,
2944
+ },
2945
+ ),
2946
+ );
2947
+ using _first = await run.ok(
2948
+ sharedResourceByKeyWithClaims.claim(claim, [firstResourceKey]),
2949
+ );
2950
+ using _second = await run.ok(
2951
+ sharedResourceByKeyWithClaims.claim(
2952
+ { id: "claim", label: "equivalent" },
2953
+ [{ id: "first", label: "equivalent" }, secondResourceKey],
2954
+ ),
2955
+ );
2956
+
2957
+ assertEqual(
2958
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim({
2959
+ id: "claim",
2960
+ label: "query",
2961
+ }),
2962
+ new Set([firstResourceKey, secondResourceKey]),
2963
+ );
2964
+ });
2965
+
2966
+ it("keeps canonical representatives until their logical relations end", async () => {
2967
+ await using run = testCreateRun();
2968
+
2969
+ interface ResourceKey {
2970
+ readonly id: string;
2971
+ readonly label: string;
2972
+ }
2973
+
2974
+ interface Claim {
2975
+ readonly id: string;
2976
+ readonly label: string;
2977
+ }
2978
+
2979
+ const firstClaim = { id: "claim", label: "first" };
2980
+ const equivalentClaim = { id: "claim", label: "equivalent" };
2981
+ const firstResourceKey = { id: "resource", label: "first" };
2982
+ const equivalentResourceKey = {
2983
+ id: "resource",
2984
+ label: "equivalent",
2985
+ };
2986
+ const lastClaimRemoved: Array<readonly [Claim, ResourceKey]> = [];
2987
+
2988
+ await using sharedResourceByKeyWithClaims = await run.ok(
2989
+ createSharedResourceByKeyWithClaims(
2990
+ (_key: ResourceKey) => () => ok({ [Symbol.dispose]: constVoid }),
2991
+ {
2992
+ resourceLookup: (key) => key.id,
2993
+ claimLookup: (claim: Claim) => claim.id,
2994
+ onLastClaimRemoved: (claim, _resource, resourceKey) => {
2995
+ lastClaimRemoved.push([claim, resourceKey]);
2996
+ },
2997
+ },
2998
+ ),
2999
+ );
3000
+ const firstClaimLease = await run.ok(
3001
+ sharedResourceByKeyWithClaims.claim(firstClaim, [firstResourceKey]),
3002
+ );
3003
+ const equivalentClaimLease = await run.ok(
3004
+ sharedResourceByKeyWithClaims.claim(equivalentClaim, [
3005
+ equivalentResourceKey,
3006
+ ]),
3007
+ );
3008
+
3009
+ firstClaimLease.release();
3010
+
3011
+ assertEqual(
3012
+ sharedResourceByKeyWithClaims.getClaimsForResource(
3013
+ equivalentResourceKey,
3014
+ ),
3015
+ new Set([firstClaim]),
3016
+ );
3017
+ assertEqual(
3018
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim(equivalentClaim),
3019
+ new Set([firstResourceKey]),
3020
+ );
3021
+
3022
+ const snapshot = sharedResourceByKeyWithClaims.snapshot();
3023
+ assertEqual(
3024
+ [...snapshot.retainCountsByResourceKeyByClaim.keys()],
3025
+ [firstClaim],
3026
+ );
3027
+ assertEqual(
3028
+ [...snapshot.retainCountsByResourceKeyByClaim.get(firstClaim)!.keys()],
3029
+ [firstResourceKey],
3030
+ );
3031
+ assertEqual([...snapshot.resourcesByKey.keys()], [firstResourceKey]);
3032
+
3033
+ equivalentClaimLease.release();
3034
+
3035
+ assertEqual(lastClaimRemoved, [[firstClaim, firstResourceKey]]);
3036
+ });
3037
+
3038
+ it("iterates current resources for a claim while pair leases keep them alive", async () => {
3039
+ await using run = testCreateRun();
3040
+
3041
+ interface TestResource extends Disposable {
3042
+ readonly id: string;
3043
+ readonly isDisposed: () => boolean;
3044
+ }
3045
+
3046
+ const disposedIds = new Set<string>();
3047
+ const visited: Array<readonly [string, string]> = [];
3048
+
3049
+ await using sharedResourceByKeyWithClaims = await run.ok(
3050
+ createSharedResourceByKeyWithClaims(
3051
+ (key: { readonly id: string }): Task<TestResource> =>
3052
+ () =>
3053
+ ok({
3054
+ id: key.id,
3055
+ isDisposed: () => disposedIds.has(key.id),
3056
+ [Symbol.dispose]: () => {
3057
+ disposedIds.add(key.id);
3058
+ },
3059
+ }),
3060
+ {
3061
+ resourceLookup: (key) => key.id,
3062
+ claimLookup: (claim: { readonly id: string }) => claim.id,
3063
+ },
3064
+ ),
3065
+ );
3066
+ const claimLease = await run.ok(
3067
+ sharedResourceByKeyWithClaims.claim({ id: "claim" }, [
3068
+ { id: "a" },
3069
+ { id: "b" },
3070
+ ]),
3071
+ );
3072
+
3073
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(
3074
+ { id: "claim" },
3075
+ (resource, resourceKey) => {
3076
+ assertFalse(resource.isDisposed());
3077
+ visited.push([resource.id, resourceKey.id]);
3078
+ },
3079
+ );
3080
+
3081
+ assertEqual(visited, [
3082
+ ["a", "a"],
3083
+ ["b", "b"],
3084
+ ]);
3085
+ assertEqual(disposedIds.size, 0);
3086
+
3087
+ claimLease.release();
3088
+ });
3089
+
3090
+ it("relation queries are empty after the final claim lease is released", async () => {
3091
+ await using run = testCreateRun();
3092
+
3093
+ await using sharedResourceByKeyWithClaims = await run.ok(
3094
+ createSharedResourceByKeyWithClaims(
3095
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3096
+ ),
3097
+ );
3098
+ const claimLease = await run.ok(
3099
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3100
+ );
3101
+
3102
+ assertEqual(
3103
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3104
+ new Set(["claim"]),
3105
+ );
3106
+ assertEqual(
3107
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
3108
+ new Set(["resource"]),
3109
+ );
3110
+
3111
+ claimLease.release();
3112
+
3113
+ assertEqual(
3114
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3115
+ new Set(),
3116
+ );
3117
+ assertEqual(
3118
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
3119
+ new Set(),
3120
+ );
3121
+
3122
+ const visited: Array<string> = [];
3123
+ sharedResourceByKeyWithClaims.forEachResourceForClaim("claim", () => {
3124
+ visited.push("visited");
3125
+ });
3126
+ assertEqual(visited, []);
3127
+ });
3128
+
3129
+ it("iteration is stable when a callback releases the claim lease", async () => {
3130
+ await using run = testCreateRun();
3131
+ const disposedKeys = new Set<string>();
3132
+ const allDisposed = Promise.withResolvers<void>();
3133
+
3134
+ interface TestResource extends Disposable {
3135
+ readonly isDisposed: () => boolean;
3136
+ }
3137
+
3138
+ await using sharedResourceByKeyWithClaims = await run.ok(
3139
+ createSharedResourceByKeyWithClaims(
3140
+ (key: string): Task<TestResource> =>
3141
+ () =>
3142
+ ok({
3143
+ isDisposed: () => disposedKeys.has(key),
3144
+ [Symbol.dispose]: () => {
3145
+ disposedKeys.add(key);
3146
+ if (disposedKeys.size === 2) allDisposed.resolve();
3147
+ },
3148
+ }),
3149
+ ),
3150
+ );
3151
+ const claimLease = await run.ok(
3152
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
3153
+ );
3154
+ const visited: Array<string> = [];
3155
+
3156
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(
3157
+ "claim",
3158
+ (resource, resourceKey) => {
3159
+ assertFalse(resource.isDisposed());
3160
+ visited.push(resourceKey);
3161
+ if (resourceKey === "a") assertTrue(claimLease.release());
3162
+ },
3163
+ );
3164
+
3165
+ assertEqual(visited, ["a", "b"]);
3166
+ assertEqual(disposedKeys.size, 0);
3167
+ await allDisposed.promise;
3168
+ assertEqual(disposedKeys, new Set(["a", "b"]));
3169
+ assertFalse(claimLease.release());
3170
+ });
3171
+ });
3172
+
3173
+ describe("transitions", () => {
3174
+ it("calls onFirstClaimAdded for each claim-resource pair's first retain", async () => {
3175
+ await using run = testCreateRun();
3176
+ const calls: Array<readonly [string, string, string]> = [];
3177
+
3178
+ interface TestResource extends Disposable {
3179
+ readonly id: string;
3180
+ }
3181
+
3182
+ await using sharedResourceByKeyWithClaims = await run.ok(
3183
+ createSharedResourceByKeyWithClaims(
3184
+ (key: string): Task<TestResource> =>
3185
+ () =>
3186
+ ok({ id: key, [Symbol.dispose]: constVoid }),
3187
+ {
3188
+ onFirstClaimAdded: (claim: string, resource, resourceKey) => {
3189
+ calls.push([claim, resource.id, resourceKey]);
3190
+ },
3191
+ },
3192
+ ),
3193
+ );
3194
+
3195
+ using _first = await run.ok(
3196
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3197
+ );
3198
+ using _second = await run.ok(
3199
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3200
+ );
3201
+
3202
+ assertEqual(calls, [
3203
+ ["first", "resource", "resource"],
3204
+ ["second", "resource", "resource"],
3205
+ ]);
3206
+ });
3207
+
3208
+ it("onFirstClaimAdded can iterate all resources retained by the claim", async () => {
3209
+ await using run = testCreateRun();
3210
+ const visitedKeys: Array<string> = [];
3211
+ let forEachResourceForClaim = (
3212
+ _claim: string,
3213
+ _callback: (
3214
+ resource: BorrowedResource<Disposable>,
3215
+ resourceKey: string,
3216
+ ) => void,
3217
+ ): void => {
3218
+ throw new Error("forEachResourceForClaim is not initialized.");
3219
+ };
3220
+
3221
+ await using sharedResourceByKeyWithClaims = await run.ok(
3222
+ createSharedResourceByKeyWithClaims(
3223
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3224
+ {
3225
+ onFirstClaimAdded: (claim: string, _resource, resourceKey) => {
3226
+ if (resourceKey !== "a") return;
3227
+ forEachResourceForClaim(claim, (_resource, resourceKey) => {
3228
+ visitedKeys.push(resourceKey);
3229
+ });
3230
+ },
3231
+ },
3232
+ ),
3233
+ );
3234
+ forEachResourceForClaim = (claim, callback) => {
3235
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(claim, callback);
3236
+ };
3237
+
3238
+ using _claimLease = await run.ok(
3239
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
3240
+ );
3241
+
3242
+ assertEqual(visitedKeys, ["a", "b"]);
3243
+ });
3244
+
3245
+ it("calls onLastClaimRemoved before a key loses its final lease", async () => {
3246
+ await using run = testCreateRun();
3247
+ let resourceDisposed = false;
3248
+ let callbackState:
3249
+ | {
3250
+ readonly claim: string;
3251
+ readonly key: string;
3252
+ readonly disposed: boolean;
3253
+ }
3254
+ | undefined;
3255
+
3256
+ await using sharedResourceByKeyWithClaims = await run.ok(
3257
+ createSharedResourceByKeyWithClaims(
3258
+ (_key: string) => () =>
3259
+ ok({
3260
+ isDisposed: () => resourceDisposed,
3261
+ [Symbol.dispose]: () => {
3262
+ resourceDisposed = true;
3263
+ },
3264
+ }),
3265
+ {
3266
+ onLastClaimRemoved: (claim: string, resource, resourceKey) => {
3267
+ callbackState = {
3268
+ claim,
3269
+ key: resourceKey,
3270
+ disposed: resource.isDisposed(),
3271
+ };
3272
+ },
3273
+ },
3274
+ ),
3275
+ );
3276
+ const claimLease = await run.ok(
3277
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3278
+ );
3279
+
3280
+ claimLease.release();
3281
+
3282
+ assertEqual(callbackState, {
3283
+ claim: "claim",
3284
+ key: "resource",
3285
+ disposed: false,
3286
+ });
3287
+ });
3288
+
3289
+ it("onLastClaimRemoved observes the current pair removed and later lease keys retained", async () => {
3290
+ await using run = testCreateRun();
3291
+ const relationStates: Array<
3292
+ readonly [string, ReadonlySet<string>, ReadonlySet<string>]
3293
+ > = [];
3294
+ let getClaimsForResource = (_resourceKey: string): ReadonlySet<string> =>
3295
+ new Set();
3296
+ let getResourceKeysForClaim = (_claim: string): ReadonlySet<string> =>
3297
+ new Set();
3298
+
3299
+ await using sharedResourceByKeyWithClaims = await run.ok(
3300
+ createSharedResourceByKeyWithClaims(
3301
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3302
+ {
3303
+ onLastClaimRemoved: (claim: string, _resource, resourceKey) => {
3304
+ if (claim !== "claim") return;
3305
+ relationStates.push([
3306
+ resourceKey,
3307
+ getClaimsForResource(resourceKey),
3308
+ getResourceKeysForClaim(claim),
3309
+ ]);
3310
+ },
3311
+ },
3312
+ ),
3313
+ );
3314
+ getClaimsForResource = (resourceKey) =>
3315
+ sharedResourceByKeyWithClaims.getClaimsForResource(resourceKey);
3316
+ getResourceKeysForClaim = (claim) =>
3317
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim(claim);
3318
+
3319
+ const claimLease = await run.ok(
3320
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
3321
+ );
3322
+ using _otherClaimLease = await run.ok(
3323
+ sharedResourceByKeyWithClaims.claim("other", ["a"]),
3324
+ );
3325
+
3326
+ claimLease.release();
3327
+
3328
+ assertEqual(relationStates, [
3329
+ ["a", new Set(["other"]), new Set(["b"])],
3330
+ ["b", new Set(), new Set()],
3331
+ ]);
3332
+ });
3333
+
3334
+ it("calls onLastClaimRemoved for each claim-resource pair's final release", async () => {
3335
+ await using run = testCreateRun();
3336
+ const calls: Array<string> = [];
3337
+
3338
+ await using sharedResourceByKeyWithClaims = await run.ok(
3339
+ createSharedResourceByKeyWithClaims(
3340
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3341
+ {
3342
+ onLastClaimRemoved: (claim: string) => {
3343
+ calls.push(claim);
3344
+ },
3345
+ },
3346
+ ),
3347
+ );
3348
+ const first = await run.ok(
3349
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3350
+ );
3351
+ const second = await run.ok(
3352
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3353
+ );
3354
+
3355
+ first.release();
3356
+ assertEqual(calls, ["first"]);
3357
+
3358
+ second.release();
3359
+ assertEqual(calls, ["first", "second"]);
3360
+ });
3361
+
3362
+ it("pair transitions ignore intermediate retains and releases", async () => {
3363
+ await using run = testCreateRun();
3364
+ const firstCalls: Array<string> = [];
3365
+ const lastCalls: Array<string> = [];
3366
+
3367
+ await using sharedResourceByKeyWithClaims = await run.ok(
3368
+ createSharedResourceByKeyWithClaims(
3369
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3370
+ {
3371
+ onFirstClaimAdded: (claim: string) => {
3372
+ firstCalls.push(claim);
3373
+ },
3374
+ onLastClaimRemoved: (claim: string) => {
3375
+ lastCalls.push(claim);
3376
+ },
3377
+ },
3378
+ ),
3379
+ );
3380
+ const first = await run.ok(
3381
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3382
+ );
3383
+ const second = await run.ok(
3384
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3385
+ );
3386
+
3387
+ assertEqual(firstCalls, ["claim"]);
3388
+
3389
+ first.release();
3390
+ assertEqual(lastCalls, []);
3391
+
3392
+ second.release();
3393
+ assertEqual(lastCalls, ["claim"]);
3394
+ });
3395
+
3396
+ it("calls onFirstClaimAdded again after zero claims is reached while the resource idles", async () => {
3397
+ await using run = testCreateRun();
3398
+ const resources = createTestResources();
3399
+ const resourcesAtFirstClaim: Array<object> = [];
3400
+
3401
+ await using sharedResourceByKeyWithClaims = await run.ok(
3402
+ createSharedResourceByKeyWithClaims(
3403
+ (_key: string) => resources.create,
3404
+ {
3405
+ idleDisposeAfter: "3s",
3406
+ onFirstClaimAdded: (_claim, resource) => {
3407
+ resourcesAtFirstClaim.push(resource);
3408
+ },
3409
+ },
3410
+ ),
3411
+ );
3412
+ const first = await run.ok(
3413
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3414
+ );
3415
+ first.release();
3416
+
3417
+ using _second = await run.ok(
3418
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3419
+ );
3420
+
3421
+ assertEqual(resources.getCreateCount(), 1);
3422
+ assertLength(resourcesAtFirstClaim, 2);
3423
+ assertSame(resourcesAtFirstClaim[1], resourcesAtFirstClaim[0]);
3424
+ });
3425
+
3426
+ it("registry disposal drains claims without calling transition callbacks because disposal is not semantic claim removal", async () => {
3427
+ await using run = testCreateRun();
3428
+ const lastClaimRemoved: Array<string> = [];
3429
+
3430
+ const sharedResourceByKeyWithClaims = await run.ok(
3431
+ createSharedResourceByKeyWithClaims(
3432
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
3433
+ {
3434
+ onLastClaimRemoved: (claim: string) => {
3435
+ lastClaimRemoved.push(claim);
3436
+ },
3437
+ },
3438
+ ),
3439
+ );
3440
+ await run.ok(sharedResourceByKeyWithClaims.claim("claim", ["resource"]));
3441
+
3442
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
3443
+
3444
+ assertEqual(lastClaimRemoved, []);
3445
+ });
3446
+
3447
+ it("onFirstClaimAdded defects compensate completed transitions in reverse order and clean up resources", async () => {
3448
+ await using run = testCreateRun();
3449
+ const defect = new Error("onFirstClaimAdded failed");
3450
+ const firstTransitions: Array<string> = [];
3451
+ const lastTransitions: Array<string> = [];
3452
+ const resourceKeysDuringTransitions: Array<ReadonlySet<string>> = [];
3453
+ const allDisposed = Promise.withResolvers<void>();
3454
+ let disposeCount = 0;
3455
+ let getResourceKeysForClaim = (): ReadonlySet<string> => new Set();
3456
+
3457
+ await using sharedResourceByKeyWithClaims = await run.ok(
3458
+ createSharedResourceByKeyWithClaims(
3459
+ (_key: string) => () =>
3460
+ ok({
3461
+ [Symbol.dispose]: () => {
3462
+ disposeCount++;
3463
+ if (disposeCount === 3) allDisposed.resolve();
3464
+ },
3465
+ }),
3466
+ {
3467
+ onFirstClaimAdded: (_claim, _resource, resourceKey) => {
3468
+ resourceKeysDuringTransitions.push(getResourceKeysForClaim());
3469
+ if (resourceKey === "c") throw defect;
3470
+ firstTransitions.push(resourceKey);
3471
+ },
3472
+ onLastClaimRemoved: (_claim, _resource, resourceKey) => {
3473
+ resourceKeysDuringTransitions.push(getResourceKeysForClaim());
3474
+ lastTransitions.push(resourceKey);
3475
+ },
3476
+ },
3477
+ ),
3478
+ );
3479
+ getResourceKeysForClaim = () =>
3480
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim");
3481
+
3482
+ const result = await run.abortable(
3483
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b", "c"]),
3484
+ );
3485
+
3486
+ assertErr(result);
3487
+ assertType(AbortError, result.error);
3488
+ assertSame(result.error.reason.type, "PanicAbortReason");
3489
+ assertSame(result.error.reason.defect, defect);
3490
+ await allDisposed.promise;
3491
+ assertEqual(firstTransitions, ["a", "b"]);
3492
+ assertEqual(lastTransitions, ["b", "a"]);
3493
+ assertEqual(resourceKeysDuringTransitions, [
3494
+ new Set(["a", "b", "c"]),
3495
+ new Set(["a", "b", "c"]),
3496
+ new Set(["a", "b", "c"]),
3497
+ new Set(["a", "b", "c"]),
3498
+ new Set(["a", "b", "c"]),
3499
+ ]);
3500
+ assertEqual(
3501
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
3502
+ new Set(),
3503
+ );
3504
+ assertSame(await run.deps.reportDefect.next(), result.error);
3505
+ });
3506
+
3507
+ it("compensation callback defects also panic while cleanup completes", async () => {
3508
+ await using run = testCreateRun();
3509
+ const firstDefect = new Error("onFirstClaimAdded failed");
3510
+ const compensationDefect = new Error("compensation failed");
3511
+ const allDisposed = Promise.withResolvers<void>();
3512
+ let disposeCount = 0;
3513
+
3514
+ await using sharedResourceByKeyWithClaims = await run.ok(
3515
+ createSharedResourceByKeyWithClaims(
3516
+ (_key: string) => () =>
3517
+ ok({
3518
+ [Symbol.dispose]: () => {
3519
+ disposeCount++;
3520
+ if (disposeCount === 2) allDisposed.resolve();
3521
+ },
3522
+ }),
3523
+ {
3524
+ onFirstClaimAdded: (_claim, _resource, resourceKey) => {
3525
+ if (resourceKey === "b") throw firstDefect;
3526
+ },
3527
+ onLastClaimRemoved: () => {
3528
+ throw compensationDefect;
3529
+ },
3530
+ },
3531
+ ),
3532
+ );
3533
+
3534
+ const result = await run.abortable(
3535
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
3536
+ );
3537
+
3538
+ assertErr(result);
3539
+ assertType(AbortError, result.error);
3540
+ assertSame(result.error.reason.type, "PanicAbortReason");
3541
+ assertSame(result.error.reason.defect, firstDefect);
3542
+ await allDisposed.promise;
3543
+ assertEqual(
3544
+ run.deps.reportDefect.getDefectsSnapshot().map((reported) => {
3545
+ assertType(AbortError, reported);
3546
+ assertSame(reported.reason.type, "PanicAbortReason");
3547
+ return reported.reason.defect;
3548
+ }),
3549
+ [compensationDefect, firstDefect],
3550
+ );
3551
+ assertEqual(
3552
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
3553
+ new Set(),
3554
+ );
3555
+ });
3556
+
3557
+ it("onLastClaimRemoved defects panic the owner Run and resources are cleaned up", async () => {
3558
+ await using run = testCreateRun();
3559
+ const resources = createTestResources();
3560
+ const defect = new Error("onLastClaimRemoved failed");
3561
+
3562
+ await using sharedResourceByKeyWithClaims = await run.ok(
3563
+ createSharedResourceByKeyWithClaims(
3564
+ (_key: string) => resources.create,
3565
+ {
3566
+ onLastClaimRemoved: () => {
3567
+ throw defect;
3568
+ },
3569
+ },
3570
+ ),
3571
+ );
3572
+ const claimLease = await run.ok(
3573
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3574
+ );
3575
+ const disposed = resources.nextDisposed();
3576
+
3577
+ assertTrue(claimLease.release());
3578
+
3579
+ const abortError = await run.deps.reportDefect.next();
3580
+ assertType(AbortError, abortError);
3581
+ assertSame(abortError.reason.type, "PanicAbortReason");
3582
+ assertSame(abortError.reason.defect, defect);
3583
+ await disposed;
3584
+ assertEqual(resources.getDisposeCount(), 1);
3585
+ assertEqual(
3586
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3587
+ new Set(),
3588
+ );
3589
+ });
3590
+ });
3591
+
3592
+ describe("idle disposal", () => {
3593
+ it("the final claim release keeps the resource alive until idleDisposeAfter elapses", async () => {
3594
+ await using run = testCreateRun();
3595
+ const resources = createTestResources();
3596
+
3597
+ await using sharedResourceByKeyWithClaims = await run.ok(
3598
+ createSharedResourceByKeyWithClaims(
3599
+ (_key: string) => resources.create,
3600
+ { idleDisposeAfter: "3s" },
3601
+ ),
3602
+ );
3603
+ const claimLease = await run.ok(
3604
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3605
+ );
3606
+
3607
+ assertTrue(claimLease.release());
3608
+ run.deps.time.advance("2s");
3609
+
3610
+ assertEqual(resources.getDisposeCount(), 0);
3611
+ assertEqual(
3612
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3613
+ new Set(),
3614
+ );
3615
+
3616
+ const disposed = resources.nextDisposed();
3617
+ run.deps.time.advance("1s");
3618
+ await disposed;
3619
+
3620
+ assertEqual(resources.getDisposeCount(), 1);
3621
+ });
3622
+
3623
+ it("a claim acquired during idle delay reuses the current resource", async () => {
3624
+ await using run = testCreateRun();
3625
+ const resources = createTestResources();
3626
+ const resourcesAtFirstClaim: Array<object> = [];
3627
+
3628
+ await using sharedResourceByKeyWithClaims = await run.ok(
3629
+ createSharedResourceByKeyWithClaims(
3630
+ (_key: string) => resources.create,
3631
+ {
3632
+ idleDisposeAfter: "3s",
3633
+ onFirstClaimAdded: (_claim, resource) => {
3634
+ resourcesAtFirstClaim.push(resource);
3635
+ },
3636
+ },
3637
+ ),
3638
+ );
3639
+ const first = await run.ok(
3640
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3641
+ );
3642
+ first.release();
3643
+ run.deps.time.advance("2s");
3644
+
3645
+ using _second = await run.ok(
3646
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3647
+ );
3648
+
3649
+ assertEqual(resources.getCreateCount(), 1);
3650
+ assertLength(resourcesAtFirstClaim, 2);
3651
+ assertSame(resourcesAtFirstClaim[1], resourcesAtFirstClaim[0]);
3652
+
3653
+ run.deps.time.advance("1s");
3654
+ assertEqual(resources.getDisposeCount(), 0);
3655
+ });
3656
+
3657
+ it("a claim acquired after disposal starts creates a fresh resource generation", async () => {
3658
+ await using run = testCreateRun();
3659
+ const firstDisposalStarted = Promise.withResolvers<void>();
3660
+ const continueFirstDisposal = Promise.withResolvers<void>();
3661
+ const resourcesAtFirstClaim: Array<{ readonly id: number }> = [];
3662
+ let createCount = 0;
3663
+
3664
+ await using sharedResourceByKeyWithClaims = await run.ok(
3665
+ createSharedResourceByKeyWithClaims(
3666
+ (_key: string): Task<AsyncDisposable & { readonly id: number }> =>
3667
+ () => {
3668
+ const id = ++createCount;
3669
+ return ok({
3670
+ id,
3671
+ [Symbol.asyncDispose]: async () => {
3672
+ if (id !== 1) return;
3673
+ firstDisposalStarted.resolve();
3674
+ await continueFirstDisposal.promise;
3675
+ },
3676
+ });
3677
+ },
3678
+ {
3679
+ idleDisposeAfter: "3s",
3680
+ onFirstClaimAdded: (_claim, resource) => {
3681
+ resourcesAtFirstClaim.push(resource);
3682
+ },
3683
+ },
3684
+ ),
3685
+ );
3686
+ const first = await run.ok(
3687
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3688
+ );
3689
+ first.release();
3690
+ run.deps.time.advance("3s");
3691
+ await firstDisposalStarted.promise;
3692
+
3693
+ const second = run.ok(
3694
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3695
+ );
3696
+ const createCountWhileDisposing = createCount;
3697
+ continueFirstDisposal.resolve();
3698
+ using _second = await second;
3699
+
3700
+ assertEqual(createCountWhileDisposing, 1);
3701
+ assertEqual(createCount, 2);
3702
+ assertEqual(
3703
+ resourcesAtFirstClaim.map((resource) => resource.id),
3704
+ [1, 2],
3705
+ );
3706
+ });
3707
+
3708
+ it("a stale disposal callback does not hide a reacquired resource", async () => {
3709
+ await using run = testCreateRun();
3710
+ const firstDisposalStarted = Promise.withResolvers<void>();
3711
+ const continueFirstDisposal = Promise.withResolvers<void>();
3712
+ let createCount = 0;
3713
+
3714
+ await using sharedResourceByKeyWithClaims = await run.ok(
3715
+ createSharedResourceByKeyWithClaims(
3716
+ (_key: string): Task<AsyncDisposable> =>
3717
+ () => {
3718
+ const id = ++createCount;
3719
+ return ok({
3720
+ [Symbol.asyncDispose]: async () => {
3721
+ if (id !== 1) return;
3722
+ firstDisposalStarted.resolve();
3723
+ await continueFirstDisposal.promise;
3724
+ },
3725
+ });
3726
+ },
3727
+ { idleDisposeAfter: "3s" },
3728
+ ),
3729
+ );
3730
+ const first = await run.ok(
3731
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3732
+ );
3733
+ first.release();
3734
+ run.deps.time.advance("3s");
3735
+ await firstDisposalStarted.promise;
3736
+
3737
+ const second = run.ok(
3738
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3739
+ );
3740
+ continueFirstDisposal.resolve();
3741
+ using _second = await second;
3742
+ using _third = await run.ok(
3743
+ sharedResourceByKeyWithClaims.claim("third", ["resource"]),
3744
+ );
3745
+
3746
+ assertEqual(createCount, 2);
3747
+ assertEqual(
3748
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3749
+ new Set(["second", "third"]),
3750
+ );
3751
+ });
3752
+
3753
+ it("reacquisition and release restart the idle disposal delay", async () => {
3754
+ await using run = testCreateRun();
3755
+ const resources = createTestResources();
3756
+
3757
+ await using sharedResourceByKeyWithClaims = await run.ok(
3758
+ createSharedResourceByKeyWithClaims(
3759
+ (_key: string) => resources.create,
3760
+ { idleDisposeAfter: "3s" },
3761
+ ),
3762
+ );
3763
+ const first = await run.ok(
3764
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3765
+ );
3766
+ first.release();
3767
+ run.deps.time.advance("2s");
3768
+
3769
+ const second = await run.ok(
3770
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3771
+ );
3772
+ second.release();
3773
+ run.deps.time.advance("1s");
3774
+
3775
+ assertEqual(resources.getDisposeCount(), 0);
3776
+
3777
+ const disposed = resources.nextDisposed();
3778
+ run.deps.time.advance("2s");
3779
+ await disposed;
3780
+
3781
+ assertEqual(resources.getDisposeCount(), 1);
3782
+ });
3783
+ });
3784
+
3785
+ describe("concurrency and abort", () => {
3786
+ it("concurrent first claims for one key share one resource creation", async () => {
3787
+ await using run = testCreateRun();
3788
+ const createStarted = Promise.withResolvers<void>();
3789
+ const continueCreate = createGate();
3790
+ let createCount = 0;
3791
+
3792
+ await using sharedResourceByKeyWithClaims = await run.ok(
3793
+ createSharedResourceByKeyWithClaims(
3794
+ (_key: string): Task<Disposable> =>
3795
+ async (run) => {
3796
+ createCount++;
3797
+ createStarted.resolve();
3798
+ await run.ok(continueCreate.wait);
3799
+ return ok({ [Symbol.dispose]: constVoid });
3800
+ },
3801
+ ),
3802
+ );
3803
+
3804
+ const first = run.ok(
3805
+ sharedResourceByKeyWithClaims.claim("first", ["resource"]),
3806
+ );
3807
+ await createStarted.promise;
3808
+ const second = run.ok(
3809
+ sharedResourceByKeyWithClaims.claim("second", ["resource"]),
3810
+ );
3811
+ continueCreate.open();
3812
+
3813
+ using _first = await first;
3814
+ using _second = await second;
3815
+
3816
+ assertEqual(createCount, 1);
3817
+ assertEqual(
3818
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
3819
+ new Set(["first", "second"]),
3820
+ );
3821
+ });
3822
+
3823
+ it("claim completes despite caller abort once acquisition starts", async () => {
3824
+ await using run = testCreateRun();
3825
+ const createStarted = Promise.withResolvers<void>();
3826
+ const continueCreate = createGate();
3827
+
3828
+ await using sharedResourceByKeyWithClaims = await run.ok(
3829
+ createSharedResourceByKeyWithClaims(
3830
+ (_key: string): Task<Disposable> =>
3831
+ async (run) => {
3832
+ createStarted.resolve();
3833
+ await run.ok(continueCreate.wait);
3834
+ return ok({ [Symbol.dispose]: constVoid });
3835
+ },
3836
+ ),
3837
+ );
3838
+ const claim = run.abortable(
3839
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
3840
+ );
3841
+ await createStarted.promise;
3842
+
3843
+ claim.abort(testAbortReason);
3844
+ continueCreate.open();
3845
+
3846
+ const result = await claim;
3847
+
3848
+ assertOk(result);
3849
+ using _claimLease = result.value;
3850
+ assertEqual(
3851
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
3852
+ new Set(["resource"]),
3853
+ );
3854
+ });
3855
+
3856
+ it("overlapping claims acquired in different key orders complete without deadlock", async () => {
3857
+ await using run = testCreateRun();
3858
+ const bothCreatesStarted = createGate();
3859
+ const startedKeys = new Set<string>();
3860
+
3861
+ await using sharedResourceByKeyWithClaims = await run.ok(
3862
+ createSharedResourceByKeyWithClaims(
3863
+ (key: string): Task<Disposable> =>
3864
+ async (run) => {
3865
+ startedKeys.add(key);
3866
+ if (startedKeys.size === 2) bothCreatesStarted.open();
3867
+ await run.ok(bothCreatesStarted.wait);
3868
+ return ok({ [Symbol.dispose]: constVoid });
3869
+ },
3870
+ ),
3871
+ );
3872
+
3873
+ const first = run.ok(
3874
+ sharedResourceByKeyWithClaims.claim("first", ["a", "b"]),
3875
+ );
3876
+ const second = run.ok(
3877
+ sharedResourceByKeyWithClaims.claim("second", ["b", "a"]),
3878
+ );
3879
+
3880
+ using _first = await first;
3881
+ using _second = await second;
3882
+
3883
+ assertEqual(startedKeys, new Set(["a", "b"]));
3884
+ });
3885
+
3886
+ it("a claim waiting for one key does not block claims for disjoint keys", async () => {
3887
+ await using run = testCreateRun();
3888
+ const blockedCreateStarted = Promise.withResolvers<void>();
3889
+ const continueBlockedCreate = createGate();
3890
+
3891
+ await using sharedResourceByKeyWithClaims = await run.ok(
3892
+ createSharedResourceByKeyWithClaims(
3893
+ (key: string): Task<Disposable> =>
3894
+ async (run) => {
3895
+ if (key === "blocked") {
3896
+ blockedCreateStarted.resolve();
3897
+ await run.ok(continueBlockedCreate.wait);
3898
+ }
3899
+ return ok({ [Symbol.dispose]: constVoid });
3900
+ },
3901
+ ),
3902
+ );
3903
+ const blocked = run.ok(
3904
+ sharedResourceByKeyWithClaims.claim("blocked", ["blocked"]),
3905
+ );
3906
+ await blockedCreateStarted.promise;
3907
+
3908
+ using _disjoint = await run.ok(
3909
+ sharedResourceByKeyWithClaims.claim("disjoint", ["disjoint"]),
3910
+ );
3911
+
3912
+ assertEqual(
3913
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("disjoint"),
3914
+ new Set(["disjoint"]),
3915
+ );
3916
+
3917
+ continueBlockedCreate.open();
3918
+ using _blocked = await blocked;
3919
+ });
3920
+
3921
+ it("claim re-inserts a resource generation pinned before another claim removes its relation", async () => {
3922
+ await using run = testCreateRun();
3923
+ const trailingCreateStarted = Promise.withResolvers<void>();
3924
+ const continueTrailingCreate = createGate();
3925
+ const createCountsByKey = new Map<string, number>();
3926
+
3927
+ interface TestResource extends Disposable {
3928
+ readonly key: string;
3929
+ }
3930
+
3931
+ await using sharedResourceByKeyWithClaims = await run.ok(
3932
+ createSharedResourceByKeyWithClaims(
3933
+ (key: string): Task<TestResource> =>
3934
+ async (run) => {
3935
+ if (key === "trailing") {
3936
+ trailingCreateStarted.resolve();
3937
+ await run.ok(continueTrailingCreate.wait);
3938
+ }
3939
+ createCountsByKey.set(key, (createCountsByKey.get(key) ?? 0) + 1);
3940
+ return ok({ key, [Symbol.dispose]: constVoid });
3941
+ },
3942
+ ),
3943
+ );
3944
+ const firstClaimLease = await run.ok(
3945
+ sharedResourceByKeyWithClaims.claim("first", ["shared"]),
3946
+ );
3947
+ let firstResource: BorrowedResource<TestResource> | undefined;
3948
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(
3949
+ "first",
3950
+ (resource) => {
3951
+ firstResource = resource;
3952
+ },
3953
+ );
3954
+
3955
+ const secondClaim = run.ok(
3956
+ sharedResourceByKeyWithClaims.claim("second", ["shared", "trailing"]),
3957
+ );
3958
+ await trailingCreateStarted.promise;
3959
+
3960
+ firstClaimLease.release();
3961
+ assertEqual(
3962
+ sharedResourceByKeyWithClaims.getClaimsForResource("shared"),
3963
+ new Set(),
3964
+ );
3965
+
3966
+ continueTrailingCreate.open();
3967
+ using _secondClaimLease = await secondClaim;
3968
+
3969
+ let secondResource: BorrowedResource<TestResource> | undefined;
3970
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(
3971
+ "second",
3972
+ (resource, resourceKey) => {
3973
+ if (resourceKey === "shared") secondResource = resource;
3974
+ },
3975
+ );
3976
+ assertSame(secondResource, firstResource);
3977
+ assertEqual(createCountsByKey.get("shared"), 1);
3978
+ });
3979
+
3980
+ it("registry disposal before claim transfer aborts acquisition and releases partial leases", async () => {
3981
+ await using run = testCreateRun();
3982
+ const resources = createTestResources();
3983
+ const secondCreateStarted = Promise.withResolvers<void>();
3984
+ const secondCreateGate = createGate();
3985
+
3986
+ const sharedResourceByKeyWithClaims = await run.ok(
3987
+ createSharedResourceByKeyWithClaims(
3988
+ (key: string): Task<Disposable> =>
3989
+ async (run) => {
3990
+ if (key === "b") {
3991
+ secondCreateStarted.resolve();
3992
+ await run.ok(secondCreateGate.wait);
3993
+ }
3994
+ return run(resources.create);
3995
+ },
3996
+ ),
3997
+ );
3998
+ const claim = run.abortable(
3999
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
4000
+ );
4001
+ await secondCreateStarted.promise;
4002
+
4003
+ const disposal = sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4004
+ secondCreateGate.open();
4005
+ const result = await claim;
4006
+ await disposal;
4007
+
4008
+ assertErr(result);
4009
+ assertType(AbortError, result.error);
4010
+ assertSame(result.error.reason, runDisposedAbortReason);
4011
+ assertEqual(resources.getCreateCount(), 2);
4012
+ assertEqual(resources.getDisposeCount(), 2);
4013
+ assertEqual(
4014
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
4015
+ new Set(),
4016
+ );
4017
+ });
4018
+
4019
+ it("registry disposal after claim transfer can drain the lease before the caller awaits it", async () => {
4020
+ await using run = testCreateRun();
4021
+ const resources = createTestResources();
4022
+ const claimTransferred = Promise.withResolvers<void>();
4023
+
4024
+ const sharedResourceByKeyWithClaims = await run.ok(
4025
+ createSharedResourceByKeyWithClaims(
4026
+ (_key: string) => resources.create,
4027
+ { onFirstClaimAdded: () => claimTransferred.resolve() },
4028
+ ),
4029
+ );
4030
+ const claim = run.ok(
4031
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4032
+ );
4033
+
4034
+ await claimTransferred.promise;
4035
+ assertEqual(
4036
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
4037
+ new Set(["resource"]),
4038
+ );
4039
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4040
+
4041
+ const claimLease = await claim;
4042
+
4043
+ assertFalse(claimLease.release());
4044
+ assertEqual(resources.getDisposeCount(), 1);
4045
+ assertEqual(
4046
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
4047
+ new Set(),
4048
+ );
4049
+ });
4050
+ });
4051
+
4052
+ describe("disposal", () => {
4053
+ it("disposal drains claim leases, claim indexes, and keyed resources", async () => {
4054
+ await using run = testCreateRun();
4055
+ const resources = createTestResources();
4056
+
4057
+ const sharedResourceByKeyWithClaims = await run.ok(
4058
+ createSharedResourceByKeyWithClaims((_key: string) => resources.create),
4059
+ );
4060
+ const first = await run.ok(
4061
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
4062
+ );
4063
+ const second = await run.ok(
4064
+ sharedResourceByKeyWithClaims.claim("claim", ["a"]),
4065
+ );
4066
+
4067
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4068
+
4069
+ assertFalse(first.release());
4070
+ assertFalse(second.release());
4071
+ assertEqual(resources.getDisposeCount(), 2);
4072
+ assertEqual(
4073
+ sharedResourceByKeyWithClaims.getClaimsForResource("a"),
4074
+ new Set(),
4075
+ );
4076
+ assertEqual(
4077
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
4078
+ new Set(),
4079
+ );
4080
+ assertEqual(
4081
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4082
+ 0,
4083
+ );
4084
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), []);
4085
+ });
4086
+
4087
+ it("disposal continues draining claim leases after one release defects", async () => {
4088
+ await using run = testCreateRun();
4089
+ const resources = createTestResources();
4090
+ const defect = new Error("claim lookup failed during drain");
4091
+ let throwForFirst = false;
4092
+
4093
+ const sharedResourceByKeyWithClaims = await run.ok(
4094
+ createSharedResourceByKeyWithClaims(
4095
+ (_key: string) => resources.create,
4096
+ {
4097
+ claimLookup: (claim: string) => {
4098
+ if (throwForFirst && claim === "first") throw defect;
4099
+ return claim;
4100
+ },
4101
+ },
4102
+ ),
4103
+ );
4104
+ const first = await run.ok(
4105
+ sharedResourceByKeyWithClaims.claim("first", ["a"]),
4106
+ );
4107
+ const second = await run.ok(
4108
+ sharedResourceByKeyWithClaims.claim("second", ["b"]),
4109
+ );
4110
+
4111
+ throwForFirst = true;
4112
+ await assertRejects(
4113
+ sharedResourceByKeyWithClaims[Symbol.asyncDispose](),
4114
+ (disposalError) => {
4115
+ assertType(AbortError, disposalError);
4116
+ assertSame(disposalError.reason.type, "PanicAbortReason");
4117
+ assertSame(disposalError.reason.defect, defect);
4118
+ },
4119
+ );
4120
+ throwForFirst = false;
4121
+ assertFalse(first.release());
4122
+ assertFalse(second.release());
4123
+ assertEqual(resources.getDisposeCount(), 2);
4124
+ assertEqual(
4125
+ sharedResourceByKeyWithClaims.getClaimsForResource("a"),
4126
+ new Set(),
4127
+ );
4128
+ assertEqual(
4129
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("second"),
4130
+ new Set(),
4131
+ );
4132
+ assertEqual(
4133
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4134
+ 0,
4135
+ );
4136
+ const reportedDefects = run.deps.reportDefect.getDefectsSnapshot();
4137
+ assertLength(reportedDefects, 1);
4138
+ const reportedDefect = reportedDefects[0];
4139
+ assertType(AbortError, reportedDefect);
4140
+ assertSame(reportedDefect.reason.type, "PanicAbortReason");
4141
+ assertSame(reportedDefect.reason.defect, defect);
4142
+ });
4143
+
4144
+ it("a claim lease reports false after the registry drains it", async () => {
4145
+ await using run = testCreateRun();
4146
+
4147
+ const sharedResourceByKeyWithClaims = await run.ok(
4148
+ createSharedResourceByKeyWithClaims(
4149
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4150
+ ),
4151
+ );
4152
+ const claimLease = await run.ok(
4153
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4154
+ );
4155
+
4156
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4157
+
4158
+ assertFalse(claimLease.release());
4159
+ });
4160
+
4161
+ it("claim lease release remains safe after root Run disposal", async () => {
4162
+ const run = testCreateRun();
4163
+
4164
+ const sharedResourceByKeyWithClaims = await run.ok(
4165
+ createSharedResourceByKeyWithClaims(
4166
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4167
+ ),
4168
+ );
4169
+ const claimLease = await run.ok(
4170
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4171
+ );
4172
+
4173
+ await run[Symbol.asyncDispose]();
4174
+
4175
+ assertFalse(claimLease.release());
4176
+ assertFalse(claimLease.release());
4177
+ run.deps.leakDetector.collect();
4178
+ assertEqual(run.deps.console.getEntriesSnapshot(), []);
4179
+ });
4180
+
4181
+ it("relation queries are empty after root Run disposal", async () => {
4182
+ const run = testCreateRun();
4183
+
4184
+ const sharedResourceByKeyWithClaims = await run.ok(
4185
+ createSharedResourceByKeyWithClaims(
4186
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4187
+ ),
4188
+ );
4189
+ await run.ok(sharedResourceByKeyWithClaims.claim("claim", ["resource"]));
4190
+
4191
+ await run[Symbol.asyncDispose]();
4192
+
4193
+ assertEqual(
4194
+ sharedResourceByKeyWithClaims.getClaimsForResource("resource"),
4195
+ new Set(),
4196
+ );
4197
+ assertEqual(
4198
+ sharedResourceByKeyWithClaims.getResourceKeysForClaim("claim"),
4199
+ new Set(),
4200
+ );
4201
+ const visited: Array<string> = [];
4202
+ sharedResourceByKeyWithClaims.forEachResourceForClaim(
4203
+ "claim",
4204
+ (_resource, resourceKey) => {
4205
+ visited.push(resourceKey);
4206
+ },
4207
+ );
4208
+ assertEqual(visited, []);
4209
+ });
4210
+
4211
+ it("late claim lease release during registry disposal is a safe no-op", async () => {
4212
+ await using run = testCreateRun();
4213
+ const disposalStarted = Promise.withResolvers<void>();
4214
+ const continueDisposal = Promise.withResolvers<void>();
4215
+
4216
+ const sharedResourceByKeyWithClaims = await run.ok(
4217
+ createSharedResourceByKeyWithClaims(
4218
+ (_key: string) => () =>
4219
+ ok({
4220
+ [Symbol.asyncDispose]: async () => {
4221
+ disposalStarted.resolve();
4222
+ await continueDisposal.promise;
4223
+ },
4224
+ }),
4225
+ ),
4226
+ );
4227
+ const claimLease = await run.ok(
4228
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4229
+ );
4230
+
4231
+ const disposal = sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4232
+
4233
+ try {
4234
+ await disposalStarted.promise;
4235
+
4236
+ // Resource disposal starts only after the metadata drain releases the
4237
+ // final inner lease, so the ClaimLease is already drained here.
4238
+ assertFalse(claimLease.release());
4239
+
4240
+ continueDisposal.resolve();
4241
+ await disposal;
4242
+
4243
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), []);
4244
+ } finally {
4245
+ continueDisposal.resolve();
4246
+ await disposal;
4247
+ }
4248
+ });
4249
+
4250
+ it("root Run disposal awaits keyed resource disposal", async () => {
4251
+ const run = testCreateRun();
4252
+ const disposalStarted = Promise.withResolvers<void>();
4253
+ const continueDisposal = Promise.withResolvers<void>();
4254
+
4255
+ const sharedResourceByKeyWithClaims = await run.ok(
4256
+ createSharedResourceByKeyWithClaims(
4257
+ (_key: string) => () =>
4258
+ ok({
4259
+ [Symbol.asyncDispose]: async () => {
4260
+ disposalStarted.resolve();
4261
+ await continueDisposal.promise;
4262
+ },
4263
+ }),
4264
+ ),
4265
+ );
4266
+ const claimLease = await run.ok(
4267
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4268
+ );
4269
+
4270
+ let rootDisposalSettled = false;
4271
+ const rootDisposal = run[Symbol.asyncDispose]().then(() => {
4272
+ rootDisposalSettled = true;
4273
+ });
4274
+
4275
+ try {
4276
+ const firstSettled = await Promise.race([
4277
+ disposalStarted.promise.then(() => "resourceDisposalStarted"),
4278
+ rootDisposal.then(() => "rootDisposalSettled"),
4279
+ ]);
4280
+
4281
+ assertEqual(firstSettled, "resourceDisposalStarted");
4282
+ assertFalse(rootDisposalSettled);
4283
+
4284
+ continueDisposal.resolve();
4285
+ await rootDisposal;
4286
+
4287
+ assertEqual(run.deps.reportDefect.getDefectsSnapshot(), []);
4288
+ } finally {
4289
+ continueDisposal.resolve();
4290
+ claimLease.release();
4291
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4292
+ await rootDisposal;
4293
+ }
4294
+ });
4295
+
4296
+ it("claiming after registry disposal is a programmer error", async () => {
4297
+ await using run = testCreateRun();
4298
+
4299
+ const sharedResourceByKeyWithClaims = await run.ok(
4300
+ createSharedResourceByKeyWithClaims(
4301
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4302
+ ),
4303
+ );
4304
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4305
+
4306
+ const result = await run.abortable(
4307
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4308
+ );
4309
+
4310
+ assertErr(result);
4311
+ assertType(AbortError, result.error);
4312
+ assertSame(result.error.reason.type, "PanicAbortReason");
4313
+ assertInstanceOf(result.error.reason.defect, Error);
4314
+ assertEqual(
4315
+ result.error.reason.defect.message,
4316
+ "Cannot use a disposed object.",
4317
+ );
4318
+ assertSame(await run.deps.reportDefect.next(), result.error);
4319
+ });
4320
+
4321
+ it("claiming after root Run disposal is a programmer error", async () => {
4322
+ const ownerRun = testCreateRun();
4323
+ const sharedResourceByKeyWithClaims = await ownerRun.ok(
4324
+ createSharedResourceByKeyWithClaims(
4325
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4326
+ ),
4327
+ );
4328
+ await ownerRun[Symbol.asyncDispose]();
4329
+ await using callerRun = testCreateRun();
4330
+
4331
+ const result = await callerRun.abortable(
4332
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4333
+ );
4334
+
4335
+ assertErr(result);
4336
+ assertType(AbortError, result.error);
4337
+ assertSame(result.error.reason.type, "PanicAbortReason");
4338
+ assertInstanceOf(result.error.reason.defect, Error);
4339
+ assertEqual(
4340
+ result.error.reason.defect.message,
4341
+ "Cannot use a disposed object.",
4342
+ );
4343
+ assertSame(await callerRun.deps.reportDefect.next(), result.error);
4344
+ });
4345
+ });
4346
+
4347
+ describe("leak detection", () => {
4348
+ it("warns when an undisposed claims registry is garbage-collected", async () => {
4349
+ await using run = testCreateRun();
4350
+
4351
+ await run.ok(
4352
+ createSharedResourceByKeyWithClaims(
4353
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4354
+ ),
4355
+ );
4356
+
4357
+ assertEqual(
4358
+ run.deps.leakDetector.getTrackedCount({
4359
+ name: "SharedResourceByKeyWithClaims",
4360
+ }),
4361
+ 1,
4362
+ );
4363
+
4364
+ run.deps.leakDetector.collect();
4365
+
4366
+ assertTrue(
4367
+ run.deps.console
4368
+ .getEntriesSnapshot()
4369
+ .some(
4370
+ (entry) =>
4371
+ entry.method === "warn" &&
4372
+ entry.args.includes(
4373
+ "SharedResourceByKeyWithClaims was garbage-collected without cleanup. Tracked at:",
4374
+ ),
4375
+ ),
4376
+ );
4377
+ });
4378
+
4379
+ it("warns when a leaked claim lease is garbage-collected", async () => {
4380
+ await using run = testCreateRun();
4381
+
4382
+ await using sharedResourceByKeyWithClaims = await run.ok(
4383
+ createSharedResourceByKeyWithClaims(
4384
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4385
+ ),
4386
+ );
4387
+
4388
+ await run.ok(sharedResourceByKeyWithClaims.claim("claim", ["resource"]));
4389
+
4390
+ assertEqual(
4391
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4392
+ 1,
4393
+ );
4394
+
4395
+ run.deps.leakDetector.collect();
4396
+
4397
+ assertTrue(
4398
+ run.deps.console
4399
+ .getEntriesSnapshot()
4400
+ .some(
4401
+ (entry) =>
4402
+ entry.method === "warn" &&
4403
+ entry.args.includes(
4404
+ "ClaimLease was garbage-collected without cleanup. Tracked at:",
4405
+ ),
4406
+ ),
4407
+ );
4408
+ });
4409
+
4410
+ it("claim lease release untracks it", async () => {
4411
+ await using run = testCreateRun();
4412
+
4413
+ await using sharedResourceByKeyWithClaims = await run.ok(
4414
+ createSharedResourceByKeyWithClaims(
4415
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4416
+ ),
4417
+ );
4418
+ const claimLease = await run.ok(
4419
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4420
+ );
4421
+
4422
+ assertEqual(
4423
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4424
+ 1,
4425
+ );
4426
+
4427
+ claimLease.release();
4428
+
4429
+ assertEqual(
4430
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4431
+ 0,
4432
+ );
4433
+ });
4434
+
4435
+ it("registry disposal untracks held claim leases", async () => {
4436
+ await using run = testCreateRun();
4437
+
4438
+ const sharedResourceByKeyWithClaims = await run.ok(
4439
+ createSharedResourceByKeyWithClaims(
4440
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4441
+ ),
4442
+ );
4443
+ await run.ok(sharedResourceByKeyWithClaims.claim("claim", ["resource"]));
4444
+
4445
+ assertEqual(
4446
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4447
+ 1,
4448
+ );
4449
+
4450
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4451
+
4452
+ assertEqual(
4453
+ run.deps.leakDetector.getTrackedCount({ name: "ClaimLease" }),
4454
+ 0,
4455
+ );
4456
+ assertEqual(run.deps.console.getEntriesSnapshot(), []);
4457
+ });
4458
+ });
4459
+
4460
+ describe("defects", () => {
4461
+ it("throwing create Task panics the owner Run", async () => {
4462
+ await using run = testCreateRun();
4463
+ const defect = new Error("create failed");
4464
+
4465
+ await using sharedResourceByKeyWithClaims = await run.ok(
4466
+ createSharedResourceByKeyWithClaims((_key: string) => () => {
4467
+ throw defect;
4468
+ }),
4469
+ );
4470
+
4471
+ const result = await run.abortable(
4472
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4473
+ );
4474
+
4475
+ assertErr(result);
4476
+ assertType(AbortError, result.error);
4477
+ assertSame(result.error.reason.type, "PanicAbortReason");
4478
+ assertSame(result.error.reason.defect, defect);
4479
+ assertSame(await run.deps.reportDefect.next(), result.error);
4480
+ });
4481
+
4482
+ it("throwing resource lookup panics the owner Run", async () => {
4483
+ await using run = testCreateRun();
4484
+ const defect = new Error("resource lookup failed");
4485
+
4486
+ await using sharedResourceByKeyWithClaims = await run.ok(
4487
+ createSharedResourceByKeyWithClaims(
4488
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4489
+ {
4490
+ resourceLookup: (_key): string => {
4491
+ throw defect;
4492
+ },
4493
+ },
4494
+ ),
4495
+ );
4496
+
4497
+ const result = await run.abortable(
4498
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4499
+ );
4500
+
4501
+ assertErr(result);
4502
+ assertType(AbortError, result.error);
4503
+ assertSame(result.error.reason.type, "PanicAbortReason");
4504
+ assertSame(result.error.reason.defect, defect);
4505
+ assertSame(await run.deps.reportDefect.next(), result.error);
4506
+ });
4507
+
4508
+ it("throwing claim lookup panics the owner Run", async () => {
4509
+ await using run = testCreateRun();
4510
+ const defect = new Error("claim lookup failed");
4511
+
4512
+ await using sharedResourceByKeyWithClaims = await run.ok(
4513
+ createSharedResourceByKeyWithClaims(
4514
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4515
+ {
4516
+ claimLookup: (_claim): string => {
4517
+ throw defect;
4518
+ },
4519
+ },
4520
+ ),
4521
+ );
4522
+
4523
+ const result = await run.abortable(
4524
+ sharedResourceByKeyWithClaims.claim("claim", ["resource"]),
4525
+ );
4526
+
4527
+ assertErr(result);
4528
+ assertType(AbortError, result.error);
4529
+ assertSame(result.error.reason.type, "PanicAbortReason");
4530
+ assertSame(result.error.reason.defect, defect);
4531
+ assertSame(await run.deps.reportDefect.next(), result.error);
4532
+ });
4533
+ });
4534
+
4535
+ describe("snapshot", () => {
4536
+ it("snapshot exposes pair retain counts and keyed resource states", async () => {
4537
+ await using run = testCreateRun();
4538
+
4539
+ const sharedResourceByKeyWithClaims = await run.ok(
4540
+ createSharedResourceByKeyWithClaims(
4541
+ (_key: string) => () => ok({ [Symbol.dispose]: constVoid }),
4542
+ ),
4543
+ );
4544
+
4545
+ assertEqual(sharedResourceByKeyWithClaims.snapshot(), {
4546
+ claimLeaseCount: 0,
4547
+ retainCountsByResourceKeyByClaim: new Map(),
4548
+ resourcesByKey: new Map(),
4549
+ });
4550
+
4551
+ const first = await run.ok(
4552
+ sharedResourceByKeyWithClaims.claim("claim", ["a", "b"]),
4553
+ );
4554
+ const second = await run.ok(
4555
+ sharedResourceByKeyWithClaims.claim("claim", ["a"]),
4556
+ );
4557
+ const other = await run.ok(
4558
+ sharedResourceByKeyWithClaims.claim("other", ["a"]),
4559
+ );
4560
+
4561
+ const snapshot = sharedResourceByKeyWithClaims.snapshot();
4562
+ assertEqual(snapshot, {
4563
+ claimLeaseCount: 3,
4564
+ retainCountsByResourceKeyByClaim: new Map([
4565
+ [
4566
+ "claim",
4567
+ new Map([
4568
+ ["a", 2],
4569
+ ["b", 1],
4570
+ ]),
4571
+ ],
4572
+ ["other", new Map([["a", 1]])],
4573
+ ]),
4574
+ resourcesByKey: new Map([
4575
+ [
4576
+ "a",
4577
+ {
4578
+ isIdle: false,
4579
+ leaseCount: 3,
4580
+ hasResource: true,
4581
+ idleDisposePending: false,
4582
+ mutex: idleMutexSnapshot,
4583
+ },
4584
+ ],
4585
+ [
4586
+ "b",
4587
+ {
4588
+ isIdle: false,
4589
+ leaseCount: 1,
4590
+ hasResource: true,
4591
+ idleDisposePending: false,
4592
+ mutex: idleMutexSnapshot,
4593
+ },
4594
+ ],
4595
+ ]),
4596
+ });
4597
+
4598
+ assertTrue(second.release());
4599
+ assertEqual(
4600
+ snapshot.retainCountsByResourceKeyByClaim.get("claim")?.get("a"),
4601
+ 2,
4602
+ );
4603
+ assertEqual(
4604
+ sharedResourceByKeyWithClaims
4605
+ .snapshot()
4606
+ .retainCountsByResourceKeyByClaim.get("claim")
4607
+ ?.get("a"),
4608
+ 1,
4609
+ );
4610
+
4611
+ await sharedResourceByKeyWithClaims[Symbol.asyncDispose]();
4612
+
4613
+ assertEqual(sharedResourceByKeyWithClaims.snapshot(), {
4614
+ claimLeaseCount: 0,
4615
+ retainCountsByResourceKeyByClaim: new Map(),
4616
+ resourcesByKey: new Map(),
4617
+ });
4618
+ assertFalse(first.release());
4619
+ assertFalse(second.release());
4620
+ assertFalse(other.release());
4621
+ });
4622
+ });
4623
+ });