fix(protocol): isolate catalog lookups and event subscriptions
This commit is contained in:
@@ -37,7 +37,7 @@ describe('BridgeManager subscriptions', () => {
|
||||
});
|
||||
|
||||
it('keeps subscription handles independent when an observer is reused', () => {
|
||||
const listener = { started: vi.fn() };
|
||||
const listener = { started: vi.fn(), closed: vi.fn() };
|
||||
const unsubscribeFirst = manager.subscribeSessions(listener);
|
||||
const unsubscribeSecond = manager.subscribeSessions(listener);
|
||||
unsubscribeFirst();
|
||||
@@ -45,9 +45,13 @@ describe('BridgeManager subscriptions', () => {
|
||||
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
expect(listener.started).toHaveBeenCalledOnce();
|
||||
manager.onPidDisconnected(101);
|
||||
expect(listener.closed).toHaveBeenCalledOnce();
|
||||
unsubscribeSecond();
|
||||
manager.onHookLogin(202, '20002', sender);
|
||||
manager.onPidDisconnected(202);
|
||||
expect(listener.started).toHaveBeenCalledOnce();
|
||||
expect(listener.closed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies listener changes to the next event while preserving dispatch order', () => {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BridgeManager } from '../src/bridge/manager';
|
||||
|
||||
describe('BridgeManager session subscriptions', () => {
|
||||
it('unsubscribes repeated registrations independently', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const started = vi.fn();
|
||||
const closed = vi.fn();
|
||||
const offStarted = manager.subscribeSessions({ started });
|
||||
manager.subscribeSessions({ started });
|
||||
const offClosed = manager.subscribeSessions({ closed });
|
||||
manager.subscribeSessions({ closed });
|
||||
offStarted();
|
||||
offStarted();
|
||||
offClosed();
|
||||
offClosed();
|
||||
|
||||
manager.onHookLogin(4001, '12345', {} as never);
|
||||
manager.onPidDisconnected(4001);
|
||||
expect(started).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
await manager.dispose();
|
||||
});
|
||||
|
||||
it('keeps the current fan-out stable when a listener unsubscribes a peer', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const peer = vi.fn();
|
||||
manager.subscribeSessions({ started: () => offPeer() });
|
||||
const offPeer = manager.subscribeSessions({ started: peer });
|
||||
|
||||
manager.onHookLogin(4002, '12345', {} as never);
|
||||
manager.onHookLogin(4003, '23456', {} as never);
|
||||
expect(peer).toHaveBeenCalledTimes(1);
|
||||
await manager.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SysFaceStore, type SysFaceCatalogStorage } from '@snowluma/protocol/sys-face-store';
|
||||
import { BridgeManager } from '../src/bridge/manager';
|
||||
import { bindSystemFaceCatalog } from '../src/sys-face-catalog';
|
||||
@@ -9,6 +10,20 @@ const storage: SysFaceCatalogStorage = {
|
||||
};
|
||||
|
||||
describe('bindSystemFaceCatalog', () => {
|
||||
let manager: BridgeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(IdentityService, 'openForUin').mockImplementation((uin) =>
|
||||
IdentityService.memory(uin),
|
||||
);
|
||||
manager = new BridgeManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.dispose();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts one shared catalog prewarm from the first logged-in account', async () => {
|
||||
let fetches = 0;
|
||||
const store = new SysFaceStore({
|
||||
@@ -36,7 +51,6 @@ describe('bindSystemFaceCatalog', () => {
|
||||
];
|
||||
},
|
||||
});
|
||||
const manager = new BridgeManager();
|
||||
bindSystemFaceCatalog(manager, { store, storage });
|
||||
|
||||
const packetSender = { sendPacket: vi.fn() } as never;
|
||||
|
||||
@@ -17,13 +17,8 @@ export interface EventBusOptions {
|
||||
const DEFAULT_LOGGER = createLogger('Bridge.Events');
|
||||
|
||||
export class BridgeEventBus {
|
||||
// Per-kind subscribers are stored as `Set<unknown>` so the container
|
||||
// doesn't carry a contravariant function type that fights TS variance
|
||||
// rules. The runtime invariant is straightforward: handlers stored under
|
||||
// key `K` only ever see `EventOf<K>` events, which is what the single
|
||||
// narrowing cast at the dispatch site below relies on.
|
||||
private readonly handlers = new Map<EventKind, Set<unknown>>();
|
||||
private readonly anyHandlers = new Set<AnyEventHandler>();
|
||||
private readonly handlers = new Map<EventKind, Set<{ handler: unknown }>>();
|
||||
private readonly anyHandlers = new Set<{ handler: AnyEventHandler }>();
|
||||
private readonly onError: (kind: EventKind | '*', err: unknown) => void;
|
||||
|
||||
constructor(opts: EventBusOptions = {}) {
|
||||
@@ -42,7 +37,7 @@ export class BridgeEventBus {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to one kind. Returns an unsubscribe function. */
|
||||
/** Each subscription owns its unsubscribe function. */
|
||||
on<K extends EventKind>(kind: K, handler: EventHandler<K>): () => void {
|
||||
let set = this.handlers.get(kind);
|
||||
|
||||
@@ -51,24 +46,35 @@ export class BridgeEventBus {
|
||||
this.handlers.set(kind, set);
|
||||
}
|
||||
|
||||
set.add(handler);
|
||||
const subscription = { handler };
|
||||
set.add(subscription);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(kind)?.delete(handler);
|
||||
this.handlers.get(kind)?.delete(subscription);
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to every kind (low-volume callers like loggers). */
|
||||
onAny(handler: AnyEventHandler): () => void {
|
||||
this.anyHandlers.add(handler);
|
||||
const subscription = { handler };
|
||||
this.anyHandlers.add(subscription);
|
||||
|
||||
return () => {
|
||||
this.anyHandlers.delete(handler);
|
||||
this.anyHandlers.delete(subscription);
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove every registration of this handler for the given kind. */
|
||||
off<K extends EventKind>(kind: K, handler: EventHandler<K>): void {
|
||||
this.handlers.get(kind)?.delete(handler);
|
||||
const subscriptions = this.handlers.get(kind);
|
||||
|
||||
if (subscriptions) {
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.handler === handler) {
|
||||
subscriptions.delete(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has(kind: EventKind): boolean {
|
||||
@@ -85,27 +91,20 @@ export class BridgeEventBus {
|
||||
* Dispatch an event in parallel to every subscriber for its kind plus every
|
||||
* onAny handler. Rejections are isolated per-handler — one bad subscriber
|
||||
* never blocks the others, and the returned promise resolves once every
|
||||
* subscriber settles.
|
||||
* subscriber settles. Subscription changes apply to the next event.
|
||||
*/
|
||||
async emit<K extends EventKind>(event: EventOf<K>): Promise<void> {
|
||||
const kindHandlers = this.handlers.get(event.kind);
|
||||
const kindHandlers = [...(this.handlers.get(event.kind) ?? [])];
|
||||
const anyHandlers = [...this.anyHandlers];
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if ((!kindHandlers || kindHandlers.size === 0) && this.anyHandlers.size === 0) {
|
||||
return;
|
||||
for (const { handler } of kindHandlers) {
|
||||
// Registration keys guarantee this handler accepts the event's kind.
|
||||
const typed = handler as EventHandler<K>;
|
||||
tasks.push(invoke(this.onError, event.kind, () => typed(event)));
|
||||
}
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (kindHandlers) {
|
||||
for (const handler of kindHandlers) {
|
||||
// Single narrowing cast — the only place we re-assert that handlers
|
||||
// stored under `event.kind` accept `EventOf<event.kind>` events.
|
||||
const typed = handler as EventHandler<K>;
|
||||
tasks.push(invoke(this.onError, event.kind, () => typed(event)));
|
||||
}
|
||||
}
|
||||
|
||||
for (const handler of this.anyHandlers) {
|
||||
for (const { handler } of anyHandlers) {
|
||||
tasks.push(invoke(this.onError, '*', () => handler(event)));
|
||||
}
|
||||
|
||||
@@ -119,11 +118,7 @@ async function invoke(
|
||||
fn: () => unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ret = fn();
|
||||
|
||||
if (ret && typeof (ret as Promise<unknown>).then === 'function') {
|
||||
await ret;
|
||||
}
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(kind, err);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 0x9154_1; this store makes that catalog durable, queryable, and safe to use
|
||||
// from the asynchronous send path.
|
||||
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import {
|
||||
FetchSysFaces,
|
||||
@@ -41,11 +42,14 @@ export interface SysFaceStoreOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type FaceLookup = { promise: Promise<SysFaceEntry | null> } | { entry: SysFaceEntry | null };
|
||||
|
||||
interface IndexedCatalog {
|
||||
packs: SysFacePackEntry[];
|
||||
byId: Map<string, SysFaceEntry>;
|
||||
entryCount: number;
|
||||
overlapCount: number;
|
||||
lookups: Map<number, FaceLookup>;
|
||||
}
|
||||
|
||||
/** Use animation metadata when available; any numeric id has a small-face encoding. */
|
||||
@@ -76,14 +80,10 @@ export class SysFaceStore {
|
||||
private readonly fetchCatalog: (sender: OidbSender) => Promise<SysFacePackEntry[]>;
|
||||
private readonly now: () => number;
|
||||
private readonly fetchFace: NonNullable<SysFaceStoreOptions['fetchFace']>;
|
||||
private readonly singleFaces = new Map<number, SysFaceEntry | null>();
|
||||
private readonly singleInflight = new Map<number, Promise<SysFaceEntry | null>>();
|
||||
private cacheLoadCompleted = false;
|
||||
private cacheLoadInflight: Promise<boolean> | null = null;
|
||||
private cacheLoad: Promise<void> | null = null;
|
||||
private refreshInflight: Promise<SysFacePackEntry[]> | null = null;
|
||||
private refreshedInProcess = false;
|
||||
private prewarmInflight: Promise<void> | null = null;
|
||||
private prewarmed = false;
|
||||
private prewarmTask: Promise<void> | null = null;
|
||||
|
||||
constructor(options: SysFaceStoreOptions = {}) {
|
||||
this.storage = options.storage ?? null;
|
||||
@@ -95,12 +95,7 @@ export class SysFaceStore {
|
||||
|
||||
/** Configure persistence before the shared store has been used. */
|
||||
configureStorage(storage: SysFaceCatalogStorage): void {
|
||||
if (
|
||||
this.cacheLoadCompleted ||
|
||||
this.cacheLoadInflight ||
|
||||
this.catalog ||
|
||||
this.refreshInflight
|
||||
) {
|
||||
if (this.cacheLoad || this.catalog || this.refreshInflight) {
|
||||
throw new Error('system face catalog storage must be configured before first use');
|
||||
}
|
||||
|
||||
@@ -111,11 +106,14 @@ export class SysFaceStore {
|
||||
* obtained a full catalog and want to reuse the same query/send semantics. */
|
||||
load(packs: SysFacePackEntry[]): void {
|
||||
this.catalog = indexCatalog(validateCatalog(packs));
|
||||
this.singleFaces.clear();
|
||||
}
|
||||
|
||||
lookup(faceId: number): SysFaceEntry | null {
|
||||
return this.catalog?.byId.get(String(faceId)) ?? this.singleFaces.get(faceId) ?? null;
|
||||
const lookup = this.catalog?.lookups.get(faceId);
|
||||
return (
|
||||
this.catalog?.byId.get(String(faceId)) ??
|
||||
(lookup && 'entry' in lookup ? lookup.entry : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** Case-insensitive query over id, description, aliases, and pack name. */
|
||||
@@ -166,47 +164,27 @@ export class SysFaceStore {
|
||||
|
||||
/** Load durable state, fetching only when no usable catalog exists. */
|
||||
async ensureReady(sender: OidbSender): Promise<void> {
|
||||
if (this.refreshInflight) {
|
||||
await this.refreshInflight;
|
||||
return;
|
||||
}
|
||||
await (this.refreshInflight ?? this.loadCache());
|
||||
|
||||
if (this.catalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = await this.loadCache();
|
||||
|
||||
if (!loaded) {
|
||||
if (this.refreshInflight || !this.catalog) {
|
||||
await this.refresh(sender);
|
||||
}
|
||||
}
|
||||
|
||||
/** Login hook: restore the durable snapshot for immediate reads, then refresh
|
||||
* from QQ exactly once per process. Multiple accounts share the same job. */
|
||||
/** Restore once, then refresh from the first logged-in account. */
|
||||
prewarm(sender: OidbSender): Promise<void> {
|
||||
if (this.prewarmed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.prewarmInflight) {
|
||||
return this.prewarmInflight;
|
||||
}
|
||||
|
||||
this.prewarmInflight = (async () => {
|
||||
return (this.prewarmTask ??= (async () => {
|
||||
try {
|
||||
await this.loadCache();
|
||||
} catch {
|
||||
// loadCache already emitted the path and failure. A fresh server fetch
|
||||
// is the authoritative repair for a corrupt rebuildable snapshot.
|
||||
// The failed restore is logged; the server refresh repairs the cache.
|
||||
}
|
||||
|
||||
await this.refresh(sender);
|
||||
this.prewarmed = true;
|
||||
})().finally(() => {
|
||||
this.prewarmInflight = null;
|
||||
});
|
||||
return this.prewarmInflight;
|
||||
})().catch((error) => {
|
||||
this.prewarmTask = null;
|
||||
throw error;
|
||||
}));
|
||||
}
|
||||
|
||||
/** Fetch the current authoritative catalog. Concurrent callers share one
|
||||
@@ -225,9 +203,9 @@ export class SysFaceStore {
|
||||
fetchedAt: this.now(),
|
||||
packs: validated,
|
||||
};
|
||||
this.catalog = indexCatalog(validated);
|
||||
const catalog = indexCatalog(validated);
|
||||
this.catalog = catalog;
|
||||
this.refreshedInProcess = true;
|
||||
this.singleFaces.clear();
|
||||
|
||||
if (this.storage) {
|
||||
await this.storage.save(snapshot);
|
||||
@@ -235,9 +213,9 @@ export class SysFaceStore {
|
||||
|
||||
log.info(
|
||||
'system face catalog refreshed: entries=%d uniqueFaces=%d overlaps=%d packs=%d elapsedMs=%d',
|
||||
this.catalog.entryCount,
|
||||
this.catalog.byId.size,
|
||||
this.catalog.overlapCount,
|
||||
catalog.entryCount,
|
||||
catalog.byId.size,
|
||||
catalog.overlapCount,
|
||||
validated.length,
|
||||
this.now() - startedAt,
|
||||
);
|
||||
@@ -247,7 +225,7 @@ export class SysFaceStore {
|
||||
log.error(
|
||||
'system face catalog refresh failed: elapsedMs=%d error=%s',
|
||||
this.now() - startedAt,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
errorMessage(error),
|
||||
);
|
||||
throw error;
|
||||
})
|
||||
@@ -275,51 +253,76 @@ export class SysFaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.singleFaces.has(faceId)) {
|
||||
return this.singleFaces.get(faceId)!;
|
||||
const catalog = this.catalog;
|
||||
|
||||
if (!catalog) {
|
||||
throw new Error('system face catalog is unavailable after readiness completed');
|
||||
}
|
||||
|
||||
const pending = this.singleInflight.get(faceId);
|
||||
const lookup = catalog.lookups.get(faceId);
|
||||
|
||||
if (pending) {
|
||||
return pending;
|
||||
if (lookup && 'entry' in lookup) {
|
||||
return lookup.entry;
|
||||
}
|
||||
|
||||
const request = this.fetchFace(sender, faceId)
|
||||
.then((entry) => {
|
||||
if (entry) {
|
||||
if (entry.qSid !== String(faceId)) {
|
||||
throw new Error(
|
||||
`system face lookup id mismatch: requested=${faceId} received=${entry.qSid}`,
|
||||
);
|
||||
}
|
||||
const request =
|
||||
lookup?.promise ??
|
||||
this.fetchSingleFace(sender, faceId)
|
||||
.then((entry) => {
|
||||
catalog.lookups.set(faceId, { entry });
|
||||
return entry;
|
||||
})
|
||||
.catch((error) => {
|
||||
catalog.lookups.delete(faceId);
|
||||
throw error;
|
||||
});
|
||||
catalog.lookups.set(faceId, { promise: request });
|
||||
|
||||
validateCatalog([{ packName: '', emojis: [entry] }]);
|
||||
faceWireFor(entry, faceId);
|
||||
try {
|
||||
const entry = await request;
|
||||
|
||||
if (this.catalog === catalog) {
|
||||
return entry;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.catalog === catalog) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// A replacement catalog owns its own lookups; callers follow the current one.
|
||||
return this.resolve(sender, faceId);
|
||||
}
|
||||
|
||||
private async fetchSingleFace(
|
||||
sender: OidbSender,
|
||||
faceId: number,
|
||||
): Promise<SysFaceEntry | null> {
|
||||
try {
|
||||
const entry = await this.fetchFace(sender, faceId);
|
||||
|
||||
if (entry) {
|
||||
if (entry.qSid !== String(faceId)) {
|
||||
throw new Error(
|
||||
`system face lookup id mismatch: requested=${faceId} received=${entry.qSid}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.singleFaces.set(faceId, entry);
|
||||
log.debug(
|
||||
'system face lookup completed: id=%d found=%s animated=%s',
|
||||
faceId,
|
||||
entry !== null,
|
||||
entry !== null && isSuperFaceEntry(entry),
|
||||
);
|
||||
return entry;
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(
|
||||
'system face lookup failed: id=%d error=%s',
|
||||
faceId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.singleInflight.delete(faceId);
|
||||
});
|
||||
this.singleInflight.set(faceId, request);
|
||||
return request;
|
||||
validateCatalog([{ packName: '', emojis: [entry] }]);
|
||||
faceWireFor(entry, faceId);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'system face lookup completed: id=%d found=%s animated=%s',
|
||||
faceId,
|
||||
entry !== null,
|
||||
entry !== null && isSuperFaceEntry(entry),
|
||||
);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
log.error('system face lookup failed: id=%d error=%s', faceId, errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveWire(sender: OidbSender, faceId: number): Promise<FaceWire> {
|
||||
@@ -349,61 +352,54 @@ export class SysFaceStore {
|
||||
return this.catalog.packs;
|
||||
}
|
||||
|
||||
private loadCache(): Promise<boolean> {
|
||||
private loadCache(): Promise<void> {
|
||||
if (this.catalog) {
|
||||
return Promise.resolve(true);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.cacheLoadCompleted) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (this.cacheLoadInflight) {
|
||||
return this.cacheLoadInflight;
|
||||
}
|
||||
return (this.cacheLoad ??= this.restoreCache());
|
||||
}
|
||||
|
||||
private async restoreCache(): Promise<void> {
|
||||
if (!this.storage) {
|
||||
this.cacheLoadCompleted = true;
|
||||
return Promise.resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = this.now();
|
||||
this.cacheLoadInflight = this.storage
|
||||
.load()
|
||||
.then((snapshot) => {
|
||||
this.cacheLoadCompleted = true;
|
||||
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const snapshot = await this.storage.load();
|
||||
|
||||
validateSnapshot(snapshot);
|
||||
this.catalog = indexCatalog(snapshot.packs);
|
||||
log.info(
|
||||
'system face catalog restored: entries=%d uniqueFaces=%d overlaps=%d packs=%d ' +
|
||||
'fetchedAt=%d elapsedMs=%d',
|
||||
this.catalog.entryCount,
|
||||
this.catalog.byId.size,
|
||||
this.catalog.overlapCount,
|
||||
snapshot.packs.length,
|
||||
snapshot.fetchedAt,
|
||||
this.now() - startedAt,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.cacheLoadCompleted = true;
|
||||
log.error(
|
||||
'system face catalog restore failed: elapsedMs=%d error=%s',
|
||||
this.now() - startedAt,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.cacheLoadInflight = null;
|
||||
});
|
||||
return this.cacheLoadInflight;
|
||||
// A newer load or refresh supersedes this disk read.
|
||||
if (this.catalog || !snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateSnapshot(snapshot);
|
||||
const catalog = indexCatalog(snapshot.packs);
|
||||
this.catalog = catalog;
|
||||
log.info(
|
||||
'system face catalog restored: entries=%d uniqueFaces=%d overlaps=%d packs=%d ' +
|
||||
'fetchedAt=%d elapsedMs=%d',
|
||||
catalog.entryCount,
|
||||
catalog.byId.size,
|
||||
catalog.overlapCount,
|
||||
snapshot.packs.length,
|
||||
snapshot.fetchedAt,
|
||||
this.now() - startedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.catalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'system face catalog restore failed: elapsedMs=%d error=%s',
|
||||
this.now() - startedAt,
|
||||
errorMessage(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +570,7 @@ function indexCatalog(packs: SysFacePackEntry[]): IndexedCatalog {
|
||||
byId,
|
||||
entryCount,
|
||||
overlapCount: entryCount - byId.size,
|
||||
lookups: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+73
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
|
||||
import type { FriendMessage, GroupMessage } from '@snowluma/protocol/events';
|
||||
import { BridgeEventBus } from '../src/event-bus';
|
||||
import type { FriendMessage, GroupMessage } from '../src/events';
|
||||
|
||||
function makeFriendMessage(): FriendMessage {
|
||||
return {
|
||||
@@ -33,6 +33,77 @@ function makeGroupMessage(): GroupMessage {
|
||||
}
|
||||
|
||||
describe('BridgeEventBus', () => {
|
||||
it('keeps the current kind and wildcard subscribers stable while dispatching', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const peer = vi.fn();
|
||||
const tap = vi.fn();
|
||||
const later = vi.fn();
|
||||
const laterTap = vi.fn();
|
||||
bus.on('friend_message', () => {
|
||||
offPeer();
|
||||
offTap();
|
||||
bus.on('friend_message', later);
|
||||
bus.onAny(laterTap);
|
||||
});
|
||||
const offPeer = bus.on('friend_message', peer);
|
||||
const offTap = bus.onAny(tap);
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
expect(laterTap).not.toHaveBeenCalled();
|
||||
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
expect(later).toHaveBeenCalledOnce();
|
||||
expect(laterTap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('finishes the current dispatch when a handler clears subscriptions', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const peer = vi.fn();
|
||||
const tap = vi.fn();
|
||||
bus.on('friend_message', () => bus.clear());
|
||||
bus.on('friend_message', peer);
|
||||
bus.onAny(tap);
|
||||
await bus.emit(makeFriendMessage());
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(peer).toHaveBeenCalledOnce();
|
||||
expect(tap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['kind', 'any'] as const)('owns each %s subscription independently', async (type) => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
const subscribe = () =>
|
||||
type === 'kind' ? bus.on('friend_message', handler) : bus.onAny(handler);
|
||||
const off = subscribe();
|
||||
subscribe();
|
||||
off();
|
||||
off();
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
bus.clear();
|
||||
subscribe();
|
||||
off();
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('off removes every registration of the named handler', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('friend_message', handler);
|
||||
bus.on('friend_message', handler);
|
||||
bus.off('friend_message', handler);
|
||||
expect(bus.has('friend_message')).toBe(false);
|
||||
await bus.emit(makeFriendMessage());
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes events only to subscribers of the matching kind', async () => {
|
||||
const bus = new BridgeEventBus();
|
||||
const friend = vi.fn();
|
||||
@@ -26,6 +26,16 @@ import {
|
||||
import { buildSendElems } from '../src/element-builder';
|
||||
import { FetchSysFace } from '../src/oidb-services/sys-faces/fetch-sys-face';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function entry(
|
||||
qSid: string,
|
||||
aniStickerType: number | null,
|
||||
@@ -296,6 +306,79 @@ describe('FetchSysFaces.deserialize', () => {
|
||||
describe('SysFaceStore — persistent catalog and query seam', () => {
|
||||
const sender = {} as never;
|
||||
|
||||
it.each(['snapshot', 'read error'] as const)(
|
||||
'does not replace a refreshed catalog with a late cache %s',
|
||||
async (result) => {
|
||||
const cache = deferred<SysFaceCatalogSnapshot | null>();
|
||||
const store = new SysFaceStore({
|
||||
storage: { load: () => cache.promise, save: async () => {} },
|
||||
fetchCatalog: async () => CATALOG,
|
||||
});
|
||||
const ready = store.ensureReady(sender);
|
||||
await store.refresh(sender);
|
||||
|
||||
if (result === 'snapshot') {
|
||||
cache.resolve({ schemaVersion: 1, fetchedAt: 1, packs: CATALOG.slice(0, 1) });
|
||||
} else {
|
||||
cache.reject(new Error('obsolete cache read failed'));
|
||||
}
|
||||
|
||||
await ready;
|
||||
await expect(store.getCatalog(sender)).resolves.toEqual(CATALOG);
|
||||
expect(store.lookup(392)?.qSid).toBe('392');
|
||||
},
|
||||
);
|
||||
|
||||
it('waits for a refresh that started during the disk read', async () => {
|
||||
const cache = deferred<SysFaceCatalogSnapshot | null>();
|
||||
const remote = deferred<SysFacePackEntry[]>();
|
||||
const store = new SysFaceStore({
|
||||
storage: { load: () => cache.promise, save: async () => {} },
|
||||
fetchCatalog: () => remote.promise,
|
||||
});
|
||||
let ready = false;
|
||||
const reading = store.ensureReady(sender).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
const refreshing = store.refresh(sender);
|
||||
cache.resolve({ schemaVersion: 1, fetchedAt: 1, packs: CATALOG.slice(0, 1) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(ready).toBe(false);
|
||||
remote.resolve(CATALOG);
|
||||
await Promise.all([reading, refreshing]);
|
||||
expect(store.lookup(392)?.qSid).toBe('392');
|
||||
});
|
||||
|
||||
it('retains a failed cache read until an explicit refresh repairs the catalog', async () => {
|
||||
const load = vi.fn(async () => {
|
||||
throw new Error('corrupt cache');
|
||||
});
|
||||
const fetchCatalog = vi.fn(async () => CATALOG);
|
||||
const store = new SysFaceStore({ storage: { load, save: async () => {} }, fetchCatalog });
|
||||
await expect(store.ensureReady(sender)).rejects.toThrow('corrupt cache');
|
||||
await expect(store.ensureReady(sender)).rejects.toThrow('corrupt cache');
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(fetchCatalog).not.toHaveBeenCalled();
|
||||
await store.refresh(sender);
|
||||
await expect(store.getCatalog(sender)).resolves.toEqual(CATALOG);
|
||||
});
|
||||
|
||||
it('retries a failed login prewarm, then shares the completed job', async () => {
|
||||
const load = vi.fn(async () => null);
|
||||
const fetchCatalog = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('network failed'))
|
||||
.mockResolvedValueOnce(CATALOG);
|
||||
const store = new SysFaceStore({ storage: { load, save: async () => {} }, fetchCatalog });
|
||||
await expect(store.prewarm(sender)).rejects.toThrow('network failed');
|
||||
const retry = store.prewarm(sender);
|
||||
expect(store.prewarm(sender)).toBe(retry);
|
||||
await retry;
|
||||
await store.prewarm(sender);
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(fetchCatalog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts face id 0 from the authoritative catalog', async () => {
|
||||
const zero = entry('0', null, null, null, '/惊讶');
|
||||
const store = new SysFaceStore({
|
||||
@@ -738,6 +821,56 @@ describe('makeFaceElem (via buildSendElems) — three-way wire encoding', () =>
|
||||
});
|
||||
|
||||
describe('system faces outside visible panels', () => {
|
||||
it.each(['result', 'error'] as const)(
|
||||
'ignores an older catalog’s lookup %s after refresh',
|
||||
async (outcome) => {
|
||||
const stale = deferred<SysFaceEntry | null>();
|
||||
const started = deferred<void>();
|
||||
const latest = entry('507', 1, 4, 108);
|
||||
const fetchFace = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
started.resolve();
|
||||
return stale.promise;
|
||||
})
|
||||
.mockResolvedValue(latest);
|
||||
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
|
||||
const first = store.resolveWire({} as never, 507);
|
||||
await started.promise;
|
||||
await store.refresh({} as never);
|
||||
const second = store.resolveWire({} as never, 507);
|
||||
|
||||
if (outcome === 'result') {
|
||||
stale.resolve(entry('507', 1, 4, 107));
|
||||
} else {
|
||||
stale.reject(new Error('outdated lookup failed'));
|
||||
}
|
||||
|
||||
const expected = { kind: 'super', packId: '4', stickerId: '108', stickerType: 1 };
|
||||
await expect(first).resolves.toEqual(expected);
|
||||
await expect(second).resolves.toEqual(expected);
|
||||
expect(fetchFace).toHaveBeenCalledTimes(2);
|
||||
expect(store.lookup(507)).toEqual(latest);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses an explicitly loaded catalog when an older single-face lookup completes', async () => {
|
||||
const stale = deferred<SysFaceEntry | null>();
|
||||
const started = deferred<void>();
|
||||
const fetchFace = vi.fn(() => {
|
||||
started.resolve();
|
||||
return stale.promise;
|
||||
});
|
||||
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
|
||||
const result = store.resolveWire({} as never, 507);
|
||||
await started.promise;
|
||||
const latest = entry('507', 1, 4, 108);
|
||||
store.load([{ packName: '最新', emojis: [latest] }]);
|
||||
stale.resolve(entry('507', 1, 4, 107));
|
||||
await expect(result).resolves.toMatchObject({ stickerId: '108' });
|
||||
expect(store.lookup(507)).toEqual(latest);
|
||||
});
|
||||
|
||||
it('resolves missing animation metadata once and shares concurrent lookups', async () => {
|
||||
const fetchFace = vi.fn(async () => entry('507', 1, 4, 107));
|
||||
const store = new SysFaceStore({ fetchCatalog: async () => CATALOG, fetchFace });
|
||||
|
||||
Reference in New Issue
Block a user