import { CallToPlayAsyncScope, type CallToPlayRetryCallback, type CallToPlayRetryScheduler, } from '../src/lib/callToPlayOwnership.ts'; import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts'; const assertEquals = (actual: T, expected: T, message: string) => { if (actual !== expected) { throw new Error(`${message}: expected ${expected}, got ${actual}`); } }; const assertArrayEquals = (actual: T[], expected: T[], message: string) => { assertEquals(JSON.stringify(actual), JSON.stringify(expected), message); }; const deferred = () => { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; }; interface ScheduledRetry { callback: CallToPlayRetryCallback; cancelled: boolean; } class ManualRetryScheduler { private readonly scheduled: ScheduledRetry[] = []; public readonly schedule: CallToPlayRetryScheduler = callback => { const retry = { callback, cancelled: false }; this.scheduled.push(retry); return () => { retry.cancelled = true; }; }; public pendingCount(): number { return this.scheduled.filter(retry => !retry.cancelled).length; } public fireNext(): Promise { const index = this.scheduled.findIndex(retry => !retry.cancelled); if (index < 0) throw new Error('no retry is scheduled'); const [retry] = this.scheduled.splice(index, 1); return retry.callback(); } } const noRetry: CallToPlayRetryScheduler = () => () => {}; Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => { const scope = new CallToPlayAsyncScope(noRetry); const listener = deferred(); const cleanupDone = deferred(); const published: string[] = []; let callback: ((value: string) => void) | undefined; let unlistens = 0; const registration = scope.registerListener(() => { callback = scope.guard(value => published.push(value)); return listener.promise; }); const disposal = scope.dispose(); callback?.('queued-after-dispose'); listener.resolve(async () => { unlistens += 1; await cleanupDone.promise; }); await Promise.resolve(); assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves'); cleanupDone.resolve(); assertEquals(await registration, false, 'the late registration should report cancellation'); await disposal; assertArrayEquals(published, [], 'a queued listener must not publish after disposal'); }); Deno.test('retry attempts never overlap and stop after readiness', async () => { const scheduler = new ManualRetryScheduler(); const scope = new CallToPlayAsyncScope(scheduler.schedule); const first = deferred(); const second = deferred(); const firstStarted = deferred(); const secondStarted = deferred(); let attempts = 0; scope.startRetry(() => { attempts += 1; if (attempts === 1) { firstStarted.resolve(); return first.promise; } secondStarted.resolve(); return second.promise; }, 2_000); assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt'); const firstRun = scheduler.fireNext(); await firstStarted.promise; assertEquals(attempts, 1, 'the first retry should start'); assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight'); scope.startRetry(() => Promise.resolve(false), 2_000); assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry'); first.resolve(false); await firstRun; assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor'); const secondRun = scheduler.fireNext(); await secondStarted.promise; assertEquals(attempts, 2, 'the successor should start only after the first settled'); assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive'); second.resolve(true); await secondRun; assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop'); await scope.dispose(); }); Deno.test('disposal waits for an in-flight retry attempt', async () => { const scheduler = new ManualRetryScheduler(); const scope = new CallToPlayAsyncScope(scheduler.schedule); const attempt = deferred(); const started = deferred(); let disposalSettled = false; scope.startRetry(() => { started.resolve(); return attempt.promise; }, 2_000); const retry = scheduler.fireNext(); await started.promise; const disposal = scope.dispose().then(() => { disposalSettled = true; }); await Promise.resolve(); assertEquals(disposalSettled, false, 'disposal must join the running retry'); attempt.resolve(false); await retry; await disposal; assertEquals(disposalSettled, true, 'disposal should settle after the retry exits'); assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor'); }); Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => { const scope = new CallToPlayAsyncScope(noRetry); const snapshot = deferred(); const action = deferred(); const publications: string[] = []; let snapshotStarts = 0; let actionStarts = 0; const pendingSnapshot = scope.applyIfActive( () => { snapshotStarts += 1; return snapshot.promise; }, value => publications.push(`snapshot:${value}`), ); const pendingAction = scope.applyIfActive( () => { actionStarts += 1; return action.promise; }, value => publications.push(`action:${value}`), ); const disposal = scope.dispose(); snapshot.resolve('stale'); action.resolve(true); assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation'); assertEquals(await pendingAction, false, 'the late action should report cancellation'); await disposal; assertArrayEquals(publications, [], 'late work must not publish into a disposed hook'); const postDisposeAction = await scope.applyIfActive( () => { actionStarts += 1; return Promise.resolve(true); }, value => publications.push(`post-dispose:${value}`), ); assertEquals(postDisposeAction, false, 'an action must not start after disposal'); assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once'); assertEquals(actionStarts, 1, 'only the pre-disposal action should start'); assertArrayEquals(publications, [], 'post-disposal actions must not publish'); }); Deno.test('an older action completion cannot overwrite a newer action status', async () => { const scope = new CallToPlayAsyncScope(noRetry); const older = deferred(); const newer = deferred(); const statuses: string[] = []; const publishOlder = scope.guardLatestAction((accepted: boolean) => { statuses.push(`older:${accepted}`); }); const olderAction = scope.applyIfActive( () => older.promise, accepted => publishOlder(accepted), ); const publishNewer = scope.guardLatestAction((accepted: boolean) => { statuses.push(`newer:${accepted}`); }); const newerAction = scope.applyIfActive( () => newer.promise, accepted => publishNewer(accepted), ); newer.resolve(true); assertEquals(await newerAction, true, 'the newer action should complete normally'); older.resolve(false); assertEquals(await olderAction, true, 'the older action result should still complete'); assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status'); await scope.dispose(); }); Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => { const scope = new CallToPlayAsyncScope(noRetry); const first = deferred(); const second = deferred(); const started: string[] = []; const applied: string[] = []; const firstMutation = scope.applyMutationIfActive( () => { started.push('display-name:older'); return first.promise; }, value => applied.push(value), ); const secondMutation = scope.applyMutationIfActive( () => { started.push('action:newer'); return second.promise; }, value => applied.push(value), ); await Promise.resolve(); assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued'); first.resolve('older-applied'); assertEquals(await firstMutation, true, 'the first mutation should publish while active'); await Promise.resolve(); assertArrayEquals( started, ['display-name:older', 'action:newer'], 'the second backend invoke must start after the first settles', ); second.resolve('newer-applied'); assertEquals(await secondMutation, true, 'the second mutation should publish while active'); assertArrayEquals( applied, ['older-applied', 'newer-applied'], 'mutation results should publish in backend order', ); await scope.dispose(); }); Deno.test('disposal drains admitted mutations and rejects later admission', async () => { const scope = new CallToPlayAsyncScope(noRetry); const first = deferred(); const second = deferred(); const started: string[] = []; const publications: string[] = []; const firstMutation = scope.applyMutationIfActive( () => { started.push('first'); return first.promise; }, () => publications.push('first'), ); const secondMutation = scope.applyMutationIfActive( () => { started.push('second'); return second.promise; }, () => publications.push('second'), ); await Promise.resolve(); let disposed = false; const disposal = scope.dispose().then(() => { disposed = true; }); const rejected = await scope.applyMutationIfActive( () => { started.push('rejected'); return Promise.resolve(); }, () => publications.push('rejected'), ); assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected'); assertEquals(disposed, false, 'disposal must wait for admitted mutations'); first.resolve(); assertEquals(await firstMutation, false, 'disposed scopes suppress the first result'); await Promise.resolve(); assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run'); assertEquals(disposed, false, 'disposal must also wait for the admitted successor'); second.resolve(); assertEquals(await secondMutation, false, 'disposed scopes suppress the second result'); await disposal; assertEquals(disposed, true, 'disposal should settle after the admitted queue drains'); assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start'); assertArrayEquals(publications, [], 'disposed mutation results must not publish'); });