@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.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- 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
|
+
})
|