From 95a2d0d2575fe14ca4b24dfa5207bc68ac17ea28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 18 Oct 2026 13:31:10 +0800 Subject: [PATCH] fix(protocol): isolate catalog lookups and event subscriptions --- .../bridge-manager-subscriptions.test.ts | 6 +- .../tests/bridge-session-listeners.test.ts | 36 --- packages/core/tests/sys-face-catalog.test.ts | 18 +- packages/protocol/src/event-bus.ts | 63 ++--- packages/protocol/src/sys-face-store.ts | 259 +++++++++--------- .../tests/event-bus.test.ts} | 75 ++++- packages/protocol/tests/sys-faces.test.ts | 133 +++++++++ 7 files changed, 384 insertions(+), 206 deletions(-) delete mode 100644 packages/core/tests/bridge-session-listeners.test.ts rename packages/{core/tests/bridge-event-bus.test.ts => protocol/tests/event-bus.test.ts} (61%) diff --git a/packages/core/tests/bridge-manager-subscriptions.test.ts b/packages/core/tests/bridge-manager-subscriptions.test.ts index 2a4edf6c..576ef49a 100644 --- a/packages/core/tests/bridge-manager-subscriptions.test.ts +++ b/packages/core/tests/bridge-manager-subscriptions.test.ts @@ -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', () => { diff --git a/packages/core/tests/bridge-session-listeners.test.ts b/packages/core/tests/bridge-session-listeners.test.ts deleted file mode 100644 index 191d15fb..00000000 --- a/packages/core/tests/bridge-session-listeners.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/core/tests/sys-face-catalog.test.ts b/packages/core/tests/sys-face-catalog.test.ts index 71840710..517f22f5 100644 --- a/packages/core/tests/sys-face-catalog.test.ts +++ b/packages/core/tests/sys-face-catalog.test.ts @@ -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; diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts index dc7311e6..cbceec48 100644 --- a/packages/protocol/src/event-bus.ts +++ b/packages/protocol/src/event-bus.ts @@ -17,13 +17,8 @@ export interface EventBusOptions { const DEFAULT_LOGGER = createLogger('Bridge.Events'); export class BridgeEventBus { - // Per-kind subscribers are stored as `Set` 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` events, which is what the single - // narrowing cast at the dispatch site below relies on. - private readonly handlers = new Map>(); - private readonly anyHandlers = new Set(); + private readonly handlers = new Map>(); + 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(kind: K, handler: EventHandler): () => 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(kind: K, handler: EventHandler): 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(event: EventOf): Promise { - const kindHandlers = this.handlers.get(event.kind); + const kindHandlers = [...(this.handlers.get(event.kind) ?? [])]; + const anyHandlers = [...this.anyHandlers]; + const tasks: Promise[] = []; - 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; + tasks.push(invoke(this.onError, event.kind, () => typed(event))); } - const tasks: Promise[] = []; - - if (kindHandlers) { - for (const handler of kindHandlers) { - // Single narrowing cast — the only place we re-assert that handlers - // stored under `event.kind` accept `EventOf` events. - const typed = handler as EventHandler; - 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 { try { - const ret = fn(); - - if (ret && typeof (ret as Promise).then === 'function') { - await ret; - } + await fn(); } catch (err) { onError(kind, err); } diff --git a/packages/protocol/src/sys-face-store.ts b/packages/protocol/src/sys-face-store.ts index 7c9b26bc..34d4765b 100644 --- a/packages/protocol/src/sys-face-store.ts +++ b/packages/protocol/src/sys-face-store.ts @@ -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 } | { entry: SysFaceEntry | null }; + interface IndexedCatalog { packs: SysFacePackEntry[]; byId: Map; entryCount: number; overlapCount: number; + lookups: Map; } /** 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; private readonly now: () => number; private readonly fetchFace: NonNullable; - private readonly singleFaces = new Map(); - private readonly singleInflight = new Map>(); - private cacheLoadCompleted = false; - private cacheLoadInflight: Promise | null = null; + private cacheLoad: Promise | null = null; private refreshInflight: Promise | null = null; private refreshedInProcess = false; - private prewarmInflight: Promise | null = null; - private prewarmed = false; + private prewarmTask: Promise | 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 { - 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 { - 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 { + 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 { @@ -349,61 +352,54 @@ export class SysFaceStore { return this.catalog.packs; } - private loadCache(): Promise { + private loadCache(): Promise { 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 { 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(), }; } diff --git a/packages/core/tests/bridge-event-bus.test.ts b/packages/protocol/tests/event-bus.test.ts similarity index 61% rename from packages/core/tests/bridge-event-bus.test.ts rename to packages/protocol/tests/event-bus.test.ts index 6d180e73..ddfe36f8 100644 --- a/packages/core/tests/bridge-event-bus.test.ts +++ b/packages/protocol/tests/event-bus.test.ts @@ -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(); diff --git a/packages/protocol/tests/sys-faces.test.ts b/packages/protocol/tests/sys-faces.test.ts index 7a8ad1da..8ae266f9 100644 --- a/packages/protocol/tests/sys-faces.test.ts +++ b/packages/protocol/tests/sys-faces.test.ts @@ -26,6 +26,16 @@ import { import { buildSendElems } from '../src/element-builder'; import { FetchSysFace } from '../src/oidb-services/sys-faces/fetch-sys-face'; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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(); + 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(); + const remote = deferred(); + 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(); + const started = deferred(); + 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(); + const started = deferred(); + 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 });