diff --git a/packages/db/tests/indexed-db/idb-driver.test.ts b/packages/db/tests/indexed-db/idb-driver.test.ts new file mode 100644 index 0000000000..cc17675a89 --- /dev/null +++ b/packages/db/tests/indexed-db/idb-driver.test.ts @@ -0,0 +1,88 @@ +// These tests check the native waiter's timing and cleanup mechanics, not +// product results. Retirement laws stay in retirement-oracle.test.ts. +import { expect, it, vi } from 'vitest' +import { completed, withHarness } from './harness' +import { observeTransactions } from './idb-driver' + +it('resolves an already established native cut without scheduling a timer', async () => { + await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await native.until(() => true) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) + +it.each(['admission', 'complete', 'abort'] as const)( + 'resumes at native %s and clears the timeout', + async (cut) => { + await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const waiting = native.until(() => + cut === 'admission' + ? native.entries.length === 1 + : native.entries[0]?.status === cut, + ) + const transaction = h.db.db.transaction('items') + const finished = completed(transaction).catch(() => undefined) + if (cut === 'abort') transaction.abort() + await waiting + expect(native.entries[0]?.status).toBe( + cut === 'admission' ? 'pending' : cut, + ) + expect(vi.getTimerCount()).toBe(0) + await finished + } finally { + vi.useRealTimers() + } + }) + }, +) + +it('rejects an unreachable cut, removes its waiter and lets the harness clean up', async () => { + const originalChannel = globalThis.BroadcastChannel + let disposed = false + let waiterRemoved = false + let timersRemaining: number | undefined + const failure = await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => { + native.restore() + disposed = true + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const ready = vi.fn(() => false) + const waiting = native.until(ready).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(1000) + // Record a broken, unbounded waiter without hanging the test itself. + const error = await Promise.race([waiting, Promise.resolve('pending')]) + if (error === 'pending') return + const calls = ready.mock.calls.length + await completed(h.db.db.transaction('items')) + waiterRemoved = ready.mock.calls.length === calls + timersRemaining = vi.getTimerCount() + throw error + } finally { + vi.useRealTimers() + } + }).catch((error: unknown) => error) + expect(failure, 'native cut rejects at its own deadline').toBeInstanceOf( + Error, + ) + expect(failure).toMatchObject({ + message: 'Timed out waiting for native transaction state', + }) + expect(waiterRemoved).toBe(true) + expect(timersRemaining).toBe(0) + expect(disposed).toBe(true) + expect(globalThis.BroadcastChannel).toBe(originalChannel) +}) diff --git a/packages/db/tests/indexed-db/idb-driver.ts b/packages/db/tests/indexed-db/idb-driver.ts index dd80525a70..ffc8e442fc 100644 --- a/packages/db/tests/indexed-db/idb-driver.ts +++ b/packages/db/tests/indexed-db/idb-driver.ts @@ -38,12 +38,27 @@ export function holdStore(db: IDBDatabase, stores = ['items', '_versions']) { } } +/** + * Records native transaction admission and outcome. `until` resumes a test at + * the native event that makes its condition true, so a schedule cut depends on + * provider events rather than on elapsed time or a polling interval. + * An unreachable cut rejects after one second so the harness can clean up. + */ export function observeTransactions(db: IDBDatabase) { const entries: Array<{ mode: IDBTransactionMode status: 'pending' | 'complete' | 'abort' transaction: IDBTransaction }> = [] + const waiters = new Set<{ ready: () => boolean; resolve: () => void }>() + function notify() { + for (const waiter of waiters) { + if (waiter.ready()) { + waiters.delete(waiter) + waiter.resolve() + } + } + } const original = db.transaction db.transaction = function (...args) { const transaction = original.apply(this, args) @@ -55,14 +70,36 @@ export function observeTransactions(db: IDBDatabase) { entries.push(entry) transaction.addEventListener('complete', () => { entry.status = 'complete' + notify() }) transaction.addEventListener('abort', () => { entry.status = 'abort' + notify() }) + notify() return transaction } return { entries, + until: (ready: () => boolean) => + ready() + ? Promise.resolve() + : new Promise((resolve, reject) => { + const waiter = { + ready, + resolve: () => { + clearTimeout(timer) + resolve() + }, + } + const timer = setTimeout(() => { + waiters.delete(waiter) + reject( + new Error('Timed out waiting for native transaction state'), + ) + }, 1000) + waiters.add(waiter) + }), restore: () => { db.transaction = original }, diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index f5f45dda43..8a5d12330b 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -16,6 +16,8 @@ * Additional histories put closure before admission, between native commit and * confirmation, after sync acceptance, and during initial/replacement/key reads. * Native gates issue real requests; no IndexedDB transaction awaits a test gate. + * Each schedule cut resumes at the native admission or outcome event that + * establishes it, not after a polling interval. * These finite schedules cover the semantic cuts, not every TLC interleaving. */ import { describe, expect, it, vi } from 'vitest' @@ -112,8 +114,8 @@ describe('admitted native writes retain their actual outcome after closure', () return sorted(subjects[slot]!.base.values()) }, ) - await vi.waitFor(() => - expect(native.entries.length).toBe(callers.filter(Boolean).length), + await native.until( + () => native.entries.length === callers.filter(Boolean).length, ) } expect( @@ -388,19 +390,28 @@ it.each(mixedCells)( h.disposers.push(() => gate.release()) await gate.started const beforeAdmission = native.entries.length + // At most one operation is active, so its native transaction is the first + // one admitted after the gate. Its caller settlement is checked against + // that native outcome at the settlement itself, independent of when the + // test resumes after admission. + const nativeAtCaller: Array = [] for (const slot of order) { const op = operations[slot]! if (op.prefix === 'active' || op.prefix === 'deciding') callers[slot] = observe( start(subjects[slot]!, op.kind, slot + 1), - capture(slot), + () => { + nativeAtCaller[slot] = + native.entries[beforeAdmission]?.status ?? 'notAdmitted' + return capture(slot)() + }, ) } const activeCount = operations.filter( (op) => op.prefix === 'active', ).length - await vi.waitFor(() => - expect(native.entries.length).toBe(beforeAdmission + activeCount), + await native.until( + () => native.entries.length === beforeAdmission + activeCount, ) for (const slot of [0, 1]) expect(callers[slot]!.result.state).toBe( @@ -437,6 +448,16 @@ it.each(mixedCells)( expect(callers[slot]!.result.state).toBe( success ? 'fulfilled' : 'rejected', ) + // As in the paired matrix, a rejection may precede the abort event. + if (op.prefix === 'active' && success) + expect(nativeAtCaller[slot], 'native commit at caller success').toBe( + 'complete', + ) + else if (op.prefix === 'active') + expect( + nativeAtCaller[slot], + 'rejected caller cannot report a commit', + ).not.toBe('complete') const rows = success ? apply(initial, op.kind, slot + 1) : initial assertRows( callers[slot]!.snapshot()!, diff --git a/packages/db/tests/indexed-db/retirement.test.ts b/packages/db/tests/indexed-db/retirement.test.ts index 624c338f38..0bd0aebc5b 100644 --- a/packages/db/tests/indexed-db/retirement.test.ts +++ b/packages/db/tests/indexed-db/retirement.test.ts @@ -93,19 +93,15 @@ for (const reason of reasons) { h.disposers.push(() => native.restore()) const loading = read === 'initial' ? observe(c.preload()) : undefined while (Channel.pending.length) Channel.dispatch() - await vi.waitFor(() => - expect( - native.entries.some((entry) => entry.mode === 'readonly'), - ).toBe(true), + await native.until(() => + native.entries.some((entry) => entry.mode === 'readonly'), ) await close(h, reason) closed(c, statuses) await gate.release() await Channel.drain() - await vi.waitFor(() => - expect( - native.entries.every((entry) => entry.status === 'complete'), - ).toBe(true), + await native.until(() => + native.entries.every((entry) => entry.status === 'complete'), ) await loading?.done if (loading) expect(loading.result.state).toBe('rejected') @@ -196,10 +192,8 @@ for (const reason of reasons) const caller = observe(c.insert(row).when('settled'), () => sorted(c.base.values()), ) - await vi.waitFor(() => - expect(native.entries.some((entry) => entry.mode === 'readwrite')).toBe( - true, - ), + await native.until(() => + native.entries.some((entry) => entry.mode === 'readwrite'), ) const inspector = await close(h, reason) await gate.release() @@ -303,8 +297,7 @@ for (const phase of ['idle', 'ready', 'loading', ...kinds] as const) { : phase === 'idle' || phase === 'ready' ? undefined : observe(start(c, phase, 1)) - if (pending) - await vi.waitFor(() => expect(native.entries.length).toBeGreaterThan(0)) + if (pending) await native.until(() => native.entries.length > 0) const terminated = new Promise((resolve) => h.db.db.addEventListener('close', () => resolve(), { once: true }), )