@filen/sync 0.2.1 → 0.3.2

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 (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. package/jest.config.js +0 -5
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import pathModule from "path"
3
+ import { createWorld } from "../harness/world"
4
+ import { IGNORER_VERSION } from "../../src/ignorer"
5
+
6
+ /**
7
+ * Unit coverage for the `Ignorer` public methods the scenario suite (Category F) drives only
8
+ * indirectly: `clearFile` (empties BOTH the dbPath copy and the physical `.filenignore`), `clear`
9
+ * (resets the in-memory matcher), and the `ignores` root/empty-path short-circuit. The matcher
10
+ * itself (gitignore semantics) is covered behaviorally in F; here we pin the management surface.
11
+ */
12
+ function dbIgnorePath(dbPath: string, uuid: string): string {
13
+ return pathModule.posix.join(dbPath, "ignorer", `v${IGNORER_VERSION}`, uuid, "filenIgnore")
14
+ }
15
+
16
+ describe("Ignorer — management surface", () => {
17
+ it("clearFile empties both the dbPath copy and the physical .filenignore when both exist", async () => {
18
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "secret.txt" })
19
+ // posix joins: these paths are read back through the RAW memfs `ifs` (which is posix-only), so on a
20
+ // Windows runner a platform `pathModule.join` would normalize to backslashes and miss the key.
21
+ const physicalPath = pathModule.posix.join(world.syncPair.localPath, ".filenignore")
22
+ const dbPath = dbIgnorePath(world.sync.dbPath, world.syncPair.uuid)
23
+
24
+ // The physical `.filenignore` is written by the `filenIgnore` option. The dbPath copy is the
25
+ // engine's merged-on-disk mirror, seeded out-of-band in the harness (as Category F does); seed it
26
+ // so clearFile sees BOTH files present and exercises both blank-out branches.
27
+ world.vfs.ifs.mkdirSync(pathModule.posix.dirname(dbPath), { recursive: true })
28
+ world.vfs.ifs.writeFileSync(dbPath, "db-secret.txt")
29
+
30
+ expect(world.vfs.ifs.readFileSync(physicalPath, "utf-8")).toBe("secret.txt")
31
+ expect(world.vfs.ifs.readFileSync(dbPath, "utf-8").length).toBeGreaterThan(0)
32
+
33
+ await world.sync.ignorer.clearFile()
34
+
35
+ // Both copies are now blanked (clearFile preserves the files but empties them).
36
+ expect(world.vfs.ifs.readFileSync(physicalPath, "utf-8")).toBe("")
37
+ expect(world.vfs.ifs.readFileSync(dbPath, "utf-8")).toBe("")
38
+ })
39
+
40
+ it("clear resets the in-memory matcher so a previously-ignored path is no longer ignored", async () => {
41
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*.log" })
42
+
43
+ await world.sync.ignorer.initialize()
44
+
45
+ expect(world.sync.ignorer.ignores("a.log")).toBe(true)
46
+
47
+ world.sync.ignorer.clear()
48
+
49
+ expect(world.sync.ignorer.ignores("a.log")).toBe(false)
50
+ })
51
+
52
+ it("ignores treats the root/empty path as not ignored even under a catch-all pattern", async () => {
53
+ const world = await createWorld({ mode: "twoWay", filenIgnore: "*" })
54
+
55
+ await world.sync.ignorer.initialize()
56
+
57
+ // A normal path under "*" is ignored…
58
+ expect(world.sync.ignorer.ignores("anything.txt")).toBe(true)
59
+ // …but the root, the leading-slash root, and the empty string short-circuit to false.
60
+ expect(world.sync.ignorer.ignores("/")).toBe(false)
61
+ expect(world.sync.ignorer.ignores("")).toBe(false)
62
+ })
63
+ })
@@ -0,0 +1,438 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { isMainThread } from "worker_threads"
3
+ import { postMessageToMain } from "../../src/lib/ipc"
4
+ import { Lock } from "../../src/lib/lock"
5
+ import { serializeError } from "../../src/utils"
6
+ import { type SyncMessage } from "../../src/types"
7
+ import { createFakeCloud, type FakeCloudControls } from "../fakes/fake-cloud"
8
+ import { createVirtualFS } from "../fakes/virtual-fs"
9
+ import type Sync from "../../src/lib/sync"
10
+
11
+ /**
12
+ * Unit coverage for the two IPC/locking seams the scenario suite never exercises directly:
13
+ *
14
+ * - `src/lib/ipc.ts` `postMessageToMain` — its three mutually exclusive routing branches
15
+ * (`ipcProcess.onMessage`, the main-thread `process.send` fallback, and the worker-thread
16
+ * `parentPort.postMessage`). The worker-thread branch is unreachable from vitest's main thread,
17
+ * so it is driven by re-importing the module under a scoped `vi.doMock("worker_threads", ...)`.
18
+ * - `src/lib/lock.ts` `Lock` — acquire/release, re-entrant counting, the 5s refresh interval and its
19
+ * swallow-on-error contract, plus the acquire-contention and release-error rollback paths. The lock
20
+ * is the REAL class, driven through a minimal `Sync` stand-in whose sdk wraps the fake cloud's
21
+ * actual `user()` lock methods in spies (so `controls.contendLock` / `setError` semantics are real).
22
+ */
23
+
24
+ /**
25
+ * `process` augmented with the worker IPC hook. The hook is declared as a global `NodeJS.Process`
26
+ * member in `index.d.ts`; re-stating the shape locally lets this file type-check in single-file mode
27
+ * (where that ambient augmentation is not in scope) as well as in the full `tsconfig.test.json` program.
28
+ */
29
+ const ipcProcess = process as NodeJS.Process & { onMessage?: (message: SyncMessage) => void }
30
+
31
+ /** A fixed clock so the fake cloud's timestamps and the refresh interval are deterministic. */
32
+ const FIXED_TIME = new Date("2024-06-01T00:00:00.000Z").getTime()
33
+
34
+ /** Faked time primitives — mirrors the scenario suite so the refresh `setInterval` is controllable. */
35
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
36
+
37
+ /** A concrete, opaque message; `postMessageToMain` forwards it verbatim on every branch. */
38
+ const MESSAGE: SyncMessage = {
39
+ type: "error",
40
+ data: {
41
+ error: serializeError(new Error("ipc boom")),
42
+ uuid: "ipc-test"
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Restore an optional `process` property. Under `exactOptionalPropertyTypes` a bare
48
+ * `ipcProcess.onMessage = undefined` is a type error — the absent state must be expressed by `delete`,
49
+ * not by assigning `undefined`. These helpers route through `delete` when the saved value was absent.
50
+ */
51
+ function restoreOnMessage(value: ((message: SyncMessage) => void) | undefined): void {
52
+ if (value === undefined) {
53
+ delete ipcProcess.onMessage
54
+ } else {
55
+ ipcProcess.onMessage = value
56
+ }
57
+ }
58
+
59
+ function restoreSend(value: typeof process.send): void {
60
+ if (value === undefined) {
61
+ delete process.send
62
+ } else {
63
+ process.send = value
64
+ }
65
+ }
66
+
67
+ describe("ipc.ts — postMessageToMain", () => {
68
+ it("(a) routes to ipcProcess.onMessage when it is set", () => {
69
+ const original = ipcProcess.onMessage
70
+ const onMessage = vi.fn()
71
+
72
+ ipcProcess.onMessage = onMessage
73
+
74
+ try {
75
+ postMessageToMain(MESSAGE)
76
+
77
+ expect(onMessage).toHaveBeenCalledTimes(1)
78
+ expect(onMessage).toHaveBeenCalledWith(MESSAGE)
79
+ } finally {
80
+ restoreOnMessage(original)
81
+ }
82
+ })
83
+
84
+ it("(b) falls back to process.send on the main thread when onMessage is unset", () => {
85
+ const originalOnMessage = ipcProcess.onMessage
86
+ const originalSend = process.send
87
+ const send = vi.fn()
88
+
89
+ delete ipcProcess.onMessage
90
+ process.send = send as unknown as NonNullable<typeof process.send>
91
+
92
+ try {
93
+ // vitest runs this file on a child process's main thread, so this branch is genuinely taken.
94
+ expect(isMainThread).toBe(true)
95
+
96
+ postMessageToMain(MESSAGE)
97
+
98
+ expect(send).toHaveBeenCalledTimes(1)
99
+ expect(send).toHaveBeenCalledWith(MESSAGE)
100
+ } finally {
101
+ restoreOnMessage(originalOnMessage)
102
+ restoreSend(originalSend)
103
+ }
104
+ })
105
+
106
+ it("(b') is a no-op on the main thread when neither onMessage nor send is available", () => {
107
+ const originalOnMessage = ipcProcess.onMessage
108
+ const originalSend = process.send
109
+
110
+ delete ipcProcess.onMessage
111
+ delete process.send
112
+
113
+ try {
114
+ // isMainThread is true and process.send is falsy, so the only correct behavior is to do
115
+ // nothing — and crucially not throw.
116
+ expect(() => postMessageToMain(MESSAGE)).not.toThrow()
117
+ } finally {
118
+ restoreOnMessage(originalOnMessage)
119
+ restoreSend(originalSend)
120
+ }
121
+ })
122
+
123
+ it("(c) posts to parentPort.postMessage when running inside a worker thread", async () => {
124
+ const originalOnMessage = ipcProcess.onMessage
125
+ const postMessage = vi.fn()
126
+
127
+ delete ipcProcess.onMessage
128
+
129
+ // The real module sees isMainThread === true on the main thread, so the parentPort branch is
130
+ // only reachable by re-evaluating ipc.ts against a worker-thread-shaped worker_threads.
131
+ vi.resetModules()
132
+ vi.doMock("worker_threads", () => ({
133
+ isMainThread: false,
134
+ parentPort: { postMessage }
135
+ }))
136
+
137
+ try {
138
+ const { postMessageToMain: scopedPostMessageToMain } = await import("../../src/lib/ipc")
139
+
140
+ scopedPostMessageToMain(MESSAGE)
141
+
142
+ expect(postMessage).toHaveBeenCalledTimes(1)
143
+ expect(postMessage).toHaveBeenCalledWith(MESSAGE)
144
+ } finally {
145
+ vi.doUnmock("worker_threads")
146
+ vi.resetModules()
147
+
148
+ restoreOnMessage(originalOnMessage)
149
+ }
150
+ })
151
+ })
152
+
153
+ type LockFixture = {
154
+ lock: Lock
155
+ controls: FakeCloudControls
156
+ resource: string
157
+ acquireResourceLock: ReturnType<typeof vi.fn>
158
+ refreshResourceLock: ReturnType<typeof vi.fn>
159
+ releaseResourceLock: ReturnType<typeof vi.fn>
160
+ }
161
+
162
+ /**
163
+ * Build a real {@link Lock} over a minimal `Sync` stand-in. The stand-in's `sdk.user()` returns spies
164
+ * that wrap the fake cloud's ACTUAL lock methods, so `controls.contendLock` / `controls.setError`
165
+ * drive genuine acquire/refresh/release behavior while the calls remain assertable.
166
+ *
167
+ * Runs the body under fake timers (the 5s refresh interval is a `setInterval`) and always restores
168
+ * real timers afterwards, which also discards any interval still registered at body exit.
169
+ */
170
+ async function withLock(body: (fixture: LockFixture) => Promise<void>): Promise<void> {
171
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
172
+ vi.setSystemTime(FIXED_TIME)
173
+
174
+ try {
175
+ const resource = "sync-remoteParentUUID-test"
176
+ const vfs = createVirtualFS()
177
+ const cloud = createFakeCloud({}, { localFs: vfs.fs })
178
+ const user = cloud.sdk.user()
179
+ // The fake's `user()` methods are stable closures over the same lock maps, so capturing them
180
+ // once and wrapping each in a spy preserves real semantics (contention, injected errors).
181
+ const acquireResourceLock = vi.fn(user.acquireResourceLock)
182
+ const refreshResourceLock = vi.fn(user.refreshResourceLock)
183
+ const releaseResourceLock = vi.fn(user.releaseResourceLock)
184
+ const sync = {
185
+ sdk: {
186
+ user: () => ({ acquireResourceLock, refreshResourceLock, releaseResourceLock })
187
+ }
188
+ } as unknown as Sync
189
+ const lock = new Lock({ sync, resource })
190
+
191
+ await body({ lock, controls: cloud.controls, resource, acquireResourceLock, refreshResourceLock, releaseResourceLock })
192
+ } finally {
193
+ vi.useRealTimers()
194
+ }
195
+ }
196
+
197
+ describe("lock.ts — Lock", () => {
198
+ it("acquires then releases, forwarding the resource/lockUUID and freeing the lock", async () => {
199
+ await withLock(async ({ lock, resource, acquireResourceLock, releaseResourceLock }) => {
200
+ await lock.acquire()
201
+
202
+ expect(acquireResourceLock).toHaveBeenCalledTimes(1)
203
+ expect(acquireResourceLock.mock.calls[0]![0]).toMatchObject({ resource, maxTries: Infinity, tryTimeout: 1000 })
204
+
205
+ const firstLockUUID = acquireResourceLock.mock.calls[0]![0].lockUUID
206
+
207
+ expect(typeof firstLockUUID).toBe("string")
208
+
209
+ await lock.release()
210
+
211
+ expect(releaseResourceLock).toHaveBeenCalledTimes(1)
212
+ expect(releaseResourceLock).toHaveBeenCalledWith({ resource, lockUUID: firstLockUUID })
213
+
214
+ // A successful release nulls the uuid and frees the resource: the next acquire mints a fresh
215
+ // uuid and the fake (which rejects a mismatched holder) still accepts it — proving it is free.
216
+ await lock.acquire()
217
+
218
+ const secondLockUUID = acquireResourceLock.mock.calls[1]![0].lockUUID
219
+
220
+ expect(acquireResourceLock).toHaveBeenCalledTimes(2)
221
+ expect(secondLockUUID).not.toBe(firstLockUUID)
222
+
223
+ await lock.release()
224
+ })
225
+ })
226
+
227
+ it("counts re-entrant acquire/release and only hits the sdk on the outermost pair", async () => {
228
+ await withLock(async ({ lock, acquireResourceLock, releaseResourceLock }) => {
229
+ await lock.acquire()
230
+ await lock.acquire()
231
+
232
+ // The second acquire just increments the counter (acquiredCount > 1) and returns early.
233
+ expect(acquireResourceLock).toHaveBeenCalledTimes(1)
234
+
235
+ await lock.release()
236
+
237
+ // First release decrements to 1 (acquiredCount > 0) and returns without touching the sdk.
238
+ expect(releaseResourceLock).not.toHaveBeenCalled()
239
+
240
+ await lock.release()
241
+
242
+ // Only the release that drops the counter to 0 actually releases the remote lock.
243
+ expect(releaseResourceLock).toHaveBeenCalledTimes(1)
244
+ })
245
+ })
246
+
247
+ it("refreshes every 5s while held and stops refreshing after release", async () => {
248
+ await withLock(async ({ lock, resource, acquireResourceLock, refreshResourceLock }) => {
249
+ await lock.acquire()
250
+
251
+ const lockUUID = acquireResourceLock.mock.calls[0]![0].lockUUID
252
+
253
+ expect(refreshResourceLock).not.toHaveBeenCalled()
254
+
255
+ await vi.advanceTimersByTimeAsync(5000)
256
+
257
+ expect(refreshResourceLock).toHaveBeenCalledTimes(1)
258
+ expect(refreshResourceLock).toHaveBeenLastCalledWith({ resource, lockUUID })
259
+
260
+ await vi.advanceTimersByTimeAsync(5000)
261
+
262
+ expect(refreshResourceLock).toHaveBeenCalledTimes(2)
263
+
264
+ await lock.release()
265
+
266
+ // release() clears the interval, so further time advancement triggers no more refreshes.
267
+ await vi.advanceTimersByTimeAsync(15000)
268
+
269
+ expect(refreshResourceLock).toHaveBeenCalledTimes(2)
270
+ })
271
+ })
272
+
273
+ it("swallows a refresh error and keeps refreshing on the next tick", async () => {
274
+ await withLock(async ({ lock, controls, refreshResourceLock }) => {
275
+ await lock.acquire()
276
+
277
+ controls.setError("refreshResourceLock", new Error("refresh boom"))
278
+
279
+ // The refresh callback's try/catch swallows the rejection — the lock must survive the tick.
280
+ await vi.advanceTimersByTimeAsync(5000)
281
+
282
+ expect(refreshResourceLock).toHaveBeenCalledTimes(1)
283
+
284
+ controls.clearError("refreshResourceLock")
285
+
286
+ await vi.advanceTimersByTimeAsync(5000)
287
+
288
+ // Having swallowed the error, the interval is intact and the next tick refreshes normally.
289
+ expect(refreshResourceLock).toHaveBeenCalledTimes(2)
290
+
291
+ await lock.release()
292
+ })
293
+ })
294
+
295
+ it("propagates a hard acquire failure, rolls back, then recovers reusing the same uuid", async () => {
296
+ await withLock(async ({ lock, controls, acquireResourceLock, refreshResourceLock }) => {
297
+ // A hard (non-contention) acquire error is the only acquire failure the engine can actually hit:
298
+ // it always passes maxTries:Infinity, so CONTENTION blocks rather than throws (covered below).
299
+ controls.setError("acquireResourceLock", new Error("acquire boom"))
300
+
301
+ await expect(lock.acquire()).rejects.toThrow(/acquire boom/)
302
+
303
+ // The failed acquire rolled the counter back but kept the minted uuid for reuse, and never
304
+ // armed the refresh interval.
305
+ expect(acquireResourceLock).toHaveBeenCalledTimes(1)
306
+ expect(refreshResourceLock).not.toHaveBeenCalled()
307
+
308
+ controls.clearError("acquireResourceLock")
309
+
310
+ await lock.acquire()
311
+
312
+ expect(acquireResourceLock).toHaveBeenCalledTimes(2)
313
+ // The retained uuid is reused (the `!this.lockUUID` guard is false on the recovery acquire).
314
+ expect(acquireResourceLock.mock.calls[1]![0].lockUUID).toBe(acquireResourceLock.mock.calls[0]![0].lockUUID)
315
+
316
+ await lock.release()
317
+ })
318
+ })
319
+
320
+ it("blocks on a contended lock and acquires once the contention clears (maxTries: Infinity)", async () => {
321
+ await withLock(async ({ lock, resource, controls, acquireResourceLock, refreshResourceLock }) => {
322
+ // The engine acquires with maxTries:Infinity, so a lock held elsewhere must make acquire WAIT
323
+ // (retrying every tryTimeout) rather than fail — the real SDK's behavior. The previous fake threw
324
+ // immediately on contention, which the engine could never actually observe.
325
+ controls.contendLock(resource)
326
+
327
+ let settled: "acquired" | "rejected" | "pending" = "pending"
328
+ const acquiring = lock
329
+ .acquire()
330
+ .then(() => {
331
+ settled = "acquired"
332
+ })
333
+ .catch(() => {
334
+ settled = "rejected"
335
+ })
336
+
337
+ // Many retry windows pass while contended: the acquire neither resolves nor rejects, and it has
338
+ // not armed the refresh interval (which only starts once the lock is actually held).
339
+ await vi.advanceTimersByTimeAsync(10000)
340
+
341
+ expect(settled, "acquire must keep blocking while the lock is contended").toBe("pending")
342
+ expect(acquireResourceLock).toHaveBeenCalledTimes(1)
343
+ expect(refreshResourceLock).not.toHaveBeenCalled()
344
+
345
+ // Once the contention clears, the next retry tick acquires.
346
+ controls.releaseLockContention(resource)
347
+
348
+ await vi.advanceTimersByTimeAsync(1000)
349
+ await acquiring
350
+
351
+ expect(settled).toBe("acquired")
352
+
353
+ await lock.release()
354
+ })
355
+ })
356
+
357
+ it("honors a finite maxTries: a permanently contended acquire throws after that many attempts", async () => {
358
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
359
+ vi.setSystemTime(FIXED_TIME)
360
+
361
+ try {
362
+ const vfs = createVirtualFS()
363
+ const cloud = createFakeCloud({}, { localFs: vfs.fs })
364
+ const resource = "sync-remoteParentUUID-finite"
365
+
366
+ // An external client holds the lock and never releases it.
367
+ cloud.controls.contendLock(resource)
368
+
369
+ let settled: "resolved" | "rejected" | "pending" = "pending"
370
+ const acquiring = cloud.sdk
371
+ .user()
372
+ .acquireResourceLock({ resource, lockUUID: "me", maxTries: 3, tryTimeout: 1000 })
373
+ .then(() => {
374
+ settled = "resolved"
375
+ })
376
+ .catch(() => {
377
+ settled = "rejected"
378
+ })
379
+
380
+ // After only one retry window it must STILL be retrying — the old fake ignored maxTries and threw
381
+ // on the very first attempt, so it would already be "rejected" here.
382
+ await vi.advanceTimersByTimeAsync(1000)
383
+ expect(settled, "a finite-maxTries acquire must retry, not fail on the first attempt").toBe("pending")
384
+
385
+ // Once the bounded retries are exhausted it rejects.
386
+ await vi.advanceTimersByTimeAsync(2000)
387
+ await acquiring
388
+ expect(settled).toBe("rejected")
389
+ } finally {
390
+ vi.useRealTimers()
391
+ }
392
+ })
393
+
394
+ it("is a no-op when releasing while nothing is held", async () => {
395
+ await withLock(async ({ lock, releaseResourceLock }) => {
396
+ await lock.release()
397
+
398
+ expect(releaseResourceLock).not.toHaveBeenCalled()
399
+ })
400
+ })
401
+
402
+ it("relinquishes the lock (stops refreshing, resets the held state) when releaseResourceLock fails", async () => {
403
+ await withLock(async ({ lock, controls, acquireResourceLock, refreshResourceLock, releaseResourceLock }) => {
404
+ await lock.acquire()
405
+
406
+ const firstLockUUID = acquireResourceLock.mock.calls[0]![0].lockUUID
407
+
408
+ controls.setError("releaseResourceLock", new Error("release boom"))
409
+
410
+ // The last holder releases but the server call fails: the failure surfaces (the fake's guard
411
+ // throws before deleting, so the fake still holds the lock — a faithful "release didn't land").
412
+ await expect(lock.release()).rejects.toThrow(/release boom/)
413
+ expect(releaseResourceLock).toHaveBeenCalledTimes(1)
414
+
415
+ // The refresh timer is torn down — a lock that kept refreshing here would be held forever, so the
416
+ // server-side TTL could never lapse it and no other device could acquire it (cross-device starvation).
417
+ const refreshesAfterFailure = refreshResourceLock.mock.calls.length
418
+ await vi.advanceTimersByTimeAsync(20000)
419
+ expect(refreshResourceLock.mock.calls.length, "no refreshes after a relinquished lock").toBe(refreshesAfterFailure)
420
+
421
+ // The held state is reset (count back to 0), so a bare release is now a no-op — the lock is NOT
422
+ // stuck "held" waiting to be retried.
423
+ await lock.release()
424
+ expect(releaseResourceLock, "a relinquished lock is not re-released by a bare release").toHaveBeenCalledTimes(1)
425
+
426
+ // The next acquire re-acquires for real, reusing the retained uuid (re-taking our own not-yet-
427
+ // expired hold), and a following release frees it.
428
+ controls.clearError("releaseResourceLock")
429
+
430
+ await lock.acquire()
431
+ expect(acquireResourceLock).toHaveBeenCalledTimes(2)
432
+ expect(acquireResourceLock.mock.calls[1]![0].lockUUID).toBe(firstLockUUID)
433
+
434
+ await lock.release()
435
+ expect(releaseResourceLock).toHaveBeenCalledTimes(2)
436
+ })
437
+ })
438
+ })
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2
+ import Lock from "../../src/lib/lock"
3
+ import type Sync from "../../src/lib/sync"
4
+
5
+ /**
6
+ * Lock teardown on release (H1). The lock auto-refreshes its server-side hold on a 5s interval. When the
7
+ * LAST holder releases, the refresh interval AND the held state must be torn down UNCONDITIONALLY — even if
8
+ * the server releaseResourceLock call fails. The implementation keeps the invariant "refresh timer alive iff
9
+ * acquiredCount > 0", which closes both failure modes at once:
10
+ *
11
+ * - Keeping the timer alive after the holder is gone (the earlier "restore the count so it can retry"
12
+ * behavior) renews a lock NOBODY holds forever: the count never returns to 0, the release is never
13
+ * retried, and no other device can ever acquire it (cross-device starvation).
14
+ * - Leaving acquiredCount >= 1 with a DEAD timer is the opposite hazard — the engine believes it holds a
15
+ * lock the server has let lapse (split-brain).
16
+ *
17
+ * A failed release therefore STOPS refreshing and resets to the unheld state; the server-side lock TTL
18
+ * lapses the now-un-refreshed hold, and the retained uuid lets the next acquire re-acquire cleanly.
19
+ */
20
+
21
+ type LockHandlers = {
22
+ acquireResourceLock: ReturnType<typeof vi.fn>
23
+ refreshResourceLock: ReturnType<typeof vi.fn>
24
+ releaseResourceLock: ReturnType<typeof vi.fn>
25
+ }
26
+
27
+ function makeLock(handlers: LockHandlers): Lock {
28
+ const sync = {
29
+ sdk: {
30
+ user: () => handlers
31
+ }
32
+ } as unknown as Sync
33
+
34
+ return new Lock({ sync, resource: "sync-test-resource" })
35
+ }
36
+
37
+ describe("Lock — teardown on release survives a server release failure (H1)", () => {
38
+ beforeEach(() => vi.useFakeTimers())
39
+ afterEach(() => vi.useRealTimers())
40
+
41
+ it("stops refreshing and resets to the unheld state after a failed release (no held-forever)", async () => {
42
+ let releaseShouldFail = true
43
+ const handlers: LockHandlers = {
44
+ acquireResourceLock: vi.fn(async () => {}),
45
+ refreshResourceLock: vi.fn(async () => {}),
46
+ releaseResourceLock: vi.fn(async () => {
47
+ if (releaseShouldFail) {
48
+ throw new Error("release boom")
49
+ }
50
+ })
51
+ }
52
+
53
+ const lock = makeLock(handlers)
54
+
55
+ await lock.acquire()
56
+ expect(handlers.acquireResourceLock).toHaveBeenCalledTimes(1)
57
+
58
+ // The refresh interval is live while held.
59
+ await vi.advanceTimersByTimeAsync(5000)
60
+ expect(handlers.refreshResourceLock.mock.calls.length).toBeGreaterThanOrEqual(1)
61
+
62
+ // The (last) release fails at the server, but the holder is gone: the failure surfaces...
63
+ await expect(lock.release()).rejects.toThrow("release boom")
64
+ expect(handlers.releaseResourceLock).toHaveBeenCalledTimes(1)
65
+
66
+ // ...and refreshing STOPS. A lock that kept refreshing here would be held forever (the H1 bug);
67
+ // with the timer torn down the server-side TTL lapses the un-refreshed hold.
68
+ const refreshesAfterFailure = handlers.refreshResourceLock.mock.calls.length
69
+ await vi.advanceTimersByTimeAsync(20000)
70
+ expect(
71
+ handlers.refreshResourceLock.mock.calls.length,
72
+ "a relinquished lock must not keep refreshing after a failed release"
73
+ ).toBe(refreshesAfterFailure)
74
+
75
+ // The held state was reset (acquiredCount back to 0), so the next acquire is a REAL server acquire —
76
+ // not a re-entrant no-op, which is what a count stuck at >= 1 (believing it still holds) would yield.
77
+ releaseShouldFail = false
78
+ await lock.acquire()
79
+ expect(handlers.acquireResourceLock, "a reset lock must re-acquire from the server").toHaveBeenCalledTimes(2)
80
+
81
+ await lock.release()
82
+ expect(handlers.releaseResourceLock).toHaveBeenCalledTimes(2)
83
+ })
84
+
85
+ it("keeps refreshing while a re-entrant holder remains, and only tears down on the final release", async () => {
86
+ const handlers: LockHandlers = {
87
+ acquireResourceLock: vi.fn(async () => {}),
88
+ refreshResourceLock: vi.fn(async () => {}),
89
+ releaseResourceLock: vi.fn(async () => {})
90
+ }
91
+
92
+ const lock = makeLock(handlers)
93
+
94
+ await lock.acquire()
95
+ await lock.acquire()
96
+
97
+ // Inner release: an outer holder remains, so the timer must stay alive (count > 0) and the sdk is
98
+ // untouched.
99
+ await lock.release()
100
+ expect(handlers.releaseResourceLock).not.toHaveBeenCalled()
101
+
102
+ const refreshesBefore = handlers.refreshResourceLock.mock.calls.length
103
+ await vi.advanceTimersByTimeAsync(5000)
104
+ expect(
105
+ handlers.refreshResourceLock.mock.calls.length,
106
+ "the timer lives while any holder remains"
107
+ ).toBeGreaterThan(refreshesBefore)
108
+
109
+ // Outer release: last holder gone → release the server lock and stop refreshing.
110
+ await lock.release()
111
+ expect(handlers.releaseResourceLock).toHaveBeenCalledTimes(1)
112
+
113
+ const refreshesAfter = handlers.refreshResourceLock.mock.calls.length
114
+ await vi.advanceTimersByTimeAsync(15000)
115
+ expect(handlers.refreshResourceLock.mock.calls.length).toBe(refreshesAfter)
116
+ })
117
+
118
+ it("a clean acquire/release leaves no refresh timer running", async () => {
119
+ const handlers: LockHandlers = {
120
+ acquireResourceLock: vi.fn(async () => {}),
121
+ refreshResourceLock: vi.fn(async () => {}),
122
+ releaseResourceLock: vi.fn(async () => {})
123
+ }
124
+
125
+ const lock = makeLock(handlers)
126
+
127
+ await lock.acquire()
128
+ await lock.release()
129
+ expect(handlers.releaseResourceLock).toHaveBeenCalledTimes(1)
130
+
131
+ const refreshes = handlers.refreshResourceLock.mock.calls.length
132
+ await vi.advanceTimersByTimeAsync(20000)
133
+ expect(handlers.refreshResourceLock.mock.calls.length).toBe(refreshes)
134
+ })
135
+ })