Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import {
|
|
CallToPlayAsyncScope,
|
|
type CallToPlayRetryCallback,
|
|
type CallToPlayRetryScheduler,
|
|
} from '../src/lib/callToPlayOwnership.ts';
|
|
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
|
}
|
|
};
|
|
|
|
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
|
|
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
|
|
};
|
|
|
|
const deferred = <T>() => {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((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<void> {
|
|
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<AsyncCleanup>();
|
|
const cleanupDone = deferred<void>();
|
|
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<boolean>();
|
|
const second = deferred<boolean>();
|
|
const firstStarted = deferred<void>();
|
|
const secondStarted = deferred<void>();
|
|
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<boolean>();
|
|
const started = deferred<void>();
|
|
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<string>();
|
|
const action = deferred<boolean>();
|
|
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<boolean>();
|
|
const newer = deferred<boolean>();
|
|
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<string>();
|
|
const second = deferred<string>();
|
|
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<void>();
|
|
const second = deferred<void>();
|
|
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');
|
|
});
|