From 3870052fb4fc927d90523ec34c431925d47fab6a 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: Wed, 27 May 2026 18:07:03 +0800 Subject: [PATCH] Simplify Hub channel management Refactor Hub to treat each UIN as a single active Channel: channelsByUin_ now maps UIN -> Channel (no Set of IDs), primary selection logic and primaryByUin_/pickPrimary were removed, and addChannel replaces any existing channel for a UIN (disposing the previous one). Updated add/remove flows to update channelsById_ and channelsByUin_ accordingly and to bring cores online/offline in the simplified model. Also: removed extensive JSDoc/comment blocks from channel/adapter/channel-ctx/core/hub files to slim the public API surface, reordered/cleaned exports in packages/channel/src/index.ts, added a new hub-events.ts placeholder, and cleaned up a test helper comment. Disposal of replaced channels is wrapped with a try/catch and logged at debug level. --- packages/channel/src/adapter.ts | 50 ------ packages/channel/src/channel-ctx.ts | 42 ----- packages/channel/src/channel.ts | 24 --- packages/channel/src/hub-events.ts | 0 packages/channel/src/index.ts | 16 +- packages/core/src/core.ts | 12 -- packages/core/src/hub.ts | 158 ++++-------------- .../tests/account-member-identity.test.ts | 7 - 8 files changed, 30 insertions(+), 279 deletions(-) create mode 100644 packages/channel/src/hub-events.ts diff --git a/packages/channel/src/adapter.ts b/packages/channel/src/adapter.ts index e916abf9..c5ba67b0 100644 --- a/packages/channel/src/adapter.ts +++ b/packages/channel/src/adapter.ts @@ -2,64 +2,14 @@ import type { Logger } from '@snowluma/common/logger'; import type { Channel } from './channel'; import type { ChannelKind } from './channel-interface'; -/** - * Sink the `Hub` (in `@snowluma/core`) exposes to a `ChannelAdapter` - * so the adapter can publish discovered channels and tear them down - * when the underlying runtime says they're gone. - * - * The host is intentionally narrow: adapters cannot reach into the - * hub's bookkeeping, only emit `addChannel` / `removeChannel` events. - * Anything else (Core construction, lifecycle event emission, primary - * selection) is the hub's concern. - */ export interface ChannelAdapterHost { - /** Publish a freshly-online channel. The hub dedupes by - * `channel.id`; calling this with an already-registered id is a - * no-op (and logs a warning in dev mode). */ addChannel(channel: Channel): void; - /** Tear down the channel with the given `id`. The hub calls - * `channel.dispose()` on its way out and disposes any `Core` - * bound to this channel. */ removeChannel(channelId: string): void; - /** Adapter-scoped logger. Adapters should prefer this over a - * module-level logger so the hub can label log lines per - * adapter kind. */ readonly log: Logger; } -/** - * A `ChannelAdapter` bridges an external login source (NTQQ hook, - * future pure-protocol client, …) and the `Hub`'s pool of live - * `Channel`s. Each adapter encapsulates: - * - * - how its runtime is started / stopped, - * - how it discovers new logins (events from a watcher, polling, - * manual `start` from WebUI, …), - * - how it materialises a concrete `Channel` subclass for each login, - * - how it cleans up when a login disappears. - * - * The hub itself remains transport-agnostic: it only knows about - * `Channel` instances coming and going through `ChannelAdapterHost`. - * - * Lifecycle expectations: - * - * - `start(host)` is called once when the hub initialises. The - * adapter retains the `host` and is free to call into it from any - * point until `dispose()` is invoked. - * - `dispose()` MUST be idempotent and MUST tear down every channel - * the adapter previously published (the hub calls - * `removeChannel` for them in turn). - */ export interface ChannelAdapter { - /** Discriminator that mirrors the `kind` of the channels this - * adapter produces. Used by the hub for diagnostics and to - * refuse duplicate registrations of the same kind. */ readonly kind: ChannelKind; - - /** Hook the adapter into a freshly-built hub. Called exactly - * once per adapter instance. */ start(host: ChannelAdapterHost): Promise | void; - - /** Stop the adapter and tear down every channel it produced. */ dispose(): Promise | void; } diff --git a/packages/channel/src/channel-ctx.ts b/packages/channel/src/channel-ctx.ts index dc25b931..b06f1176 100644 --- a/packages/channel/src/channel-ctx.ts +++ b/packages/channel/src/channel-ctx.ts @@ -3,60 +3,18 @@ import type { PacketInfo } from '@snowluma/common/protocol-types'; import type { Channel } from './channel'; import type { ChannelKind } from './channel-interface'; -/** - * `ChannelCtx` — the capability POJO that crosses the `Channel → Core` - * boundary in the v3 hexagonal layout. - * - * `Core` (in `@snowluma/core`) is constructed with one of these and - * **never** holds a `Channel` instance directly. That keeps `@snowluma/core` - * free of any adapter implementation detail (and even of the abstract - * `Channel` class — only this capability surface is consumed). - * - * The ctx is intentionally a plain object, not an interface backed by a - * class: - * - `Hub` builds it once per online channel via `makeChannelCtx(ch)` - * and emits it on its lifecycle events, - * - `Core` captures the closure and treats it as immutable for the - * channel's lifetime, - * - `dispose` is the channel's tear-down trigger; `Hub` invokes it - * when the underlying transport reports gone. - */ export interface ChannelCtx { - /** QQ number this channel carries. Equivalent to `Channel.uin`. */ readonly uin: string; - /** Transport family (`'inject'` for hook, `'socket'` for future - * pure-socket). Equivalent to `Channel.kind`. */ readonly kind: ChannelKind; - - /** Wire-level send. Core's `sendRawPacket` forwards every outbound - * request through this single function. */ readonly sendRawPacket: ( serviceCmd: string, body: Uint8Array, timeoutMs?: number, ) => Promise; - - /** Subscribe (or detach with `null`) the single inbound packet - * handler. Called once by `Core` in its constructor so its - * `IncomingPacketPipeline` receives every frame the channel - * delivers. */ readonly onPacket: (handler: ((pkt: PacketInfo) => void) | null) => void; - - /** Release channel-side resources. Invoked by `Hub` when the - * channel goes offline; idempotent on the channel side. */ readonly dispose: () => void; } -/** - * Project a concrete `Channel` instance into its capability POJO. The - * returned object captures the channel via closure so consumers see - * only the four functions plus `uin` / `kind` — never the class - * itself. - * - * Used exclusively by `Hub` (in `@snowluma/core`); adapters publish - * `Channel` instances and `Hub` is the one place that turns each into - * a ctx before handing it to `Core`. - */ export function makeChannelCtx(channel: Channel): ChannelCtx { return { uin: channel.uin, diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 75713a40..20009c5c 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -2,30 +2,6 @@ import type { SendPacketResult } from '@snowluma/common/packet-sender'; import type { PacketInfo } from '@snowluma/common/protocol-types'; import type { ChannelInterface, ChannelKind } from './channel-interface'; -/** - * Abstract base for every concrete channel transport (`HookChannel`, - * `SocketChannel`, …). Strictly transport: knows about wire-level - * send, packet delivery to a single subscriber, and lifecycle. - * - * Subclasses provide: - * - * 1. `kind` / `id` / `uin` — discriminators used by the `Hub` (in - * `@snowluma/core`) to index live transports. - * 2. `sendRawPacket` — the actual outbound wire path. `HookChannel` - * forwards through a `PacketSender` (named-pipe to QQ.exe); - * `SocketChannel` will eventually drive a long-lived TCP login - * session. - * - * Inbound packets are routed via the `packetHandler_` slot: `Hub` - * wires `ChannelCtx.onPacket` (which calls `setPacketHandler` on - * this channel) so each `Core` instance subscribes exactly once, and - * the owning `ChannelAdapter` calls `deliverPacket(pkt)` for every - * frame the underlying runtime hands it. - * - * `Channel` knows NOTHING about identity caches, the event bus, the - * api hub, or the cmd-dispatch pipeline. That entire account-layer - * machinery lives on `Core` (`@snowluma/core`). - */ export abstract class Channel implements ChannelInterface { abstract readonly kind: ChannelKind; abstract readonly id: string; diff --git a/packages/channel/src/hub-events.ts b/packages/channel/src/hub-events.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/channel/src/index.ts b/packages/channel/src/index.ts index 4358439f..680a3846 100644 --- a/packages/channel/src/index.ts +++ b/packages/channel/src/index.ts @@ -1,17 +1,5 @@ -// `@snowluma/channel` — the abstract transport layer + capability POJO -// + adapter port. Concrete transports (HookChannel / SocketChannel) -// and their adapters live in dedicated packages -// (`@snowluma/channel-hook`, `@snowluma/channel-socket`); the -// multi-account host lives in `@snowluma/core` as `Hub`. Nothing in -// this package depends on a runtime — it's pure interfaces and the -// `Channel` abstract base. - -// ─── Transport abstract base ─────────────────────────────────────── +export { type ChannelAdapter, type ChannelAdapterHost } from './adapter'; export { Channel } from './channel'; +export { makeChannelCtx, type ChannelCtx } from './channel-ctx'; export { type ChannelInterface, type ChannelKind } from './channel-interface'; -// ─── Capability POJO (handed to Core in @snowluma/core) ──────────── -export { makeChannelCtx, type ChannelCtx } from './channel-ctx'; - -// ─── Adapter port ────────────────────────────────────────────────── -export { type ChannelAdapter, type ChannelAdapterHost } from './adapter'; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 13b3d3ea..9174dec9 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -50,18 +50,6 @@ export class Core implements CoreCtx { private clientSeq_ = 100000000 + (Date.now() % 1000000000); private msgRandom_ = (Date.now() & 0xFFFFFFFF) >>> 0; private disposed_ = false; - - /** - * @param channelCtx The capability POJO this core rides on top of. - * `Core` calls `channelCtx.sendRawPacket` and - * subscribes via `channelCtx.onPacket`. Built by - * `Hub` from a concrete `Channel` via `makeChannelCtx`. - * @param identity Optional pre-built `IdentityService`. The default - * opens the on-disk SQLite store at - * `data//snowluma_identity.db`; tests usually - * pass `IdentityService.memory(uin)` to avoid touching - * the filesystem. - */ constructor(channelCtx: ChannelCtx, identity: IdentityService = IdentityService.openForUin(channelCtx.uin)) { this.channelCtx_ = channelCtx; this.kind = channelCtx.kind; diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index 90d5d13c..e871be54 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -11,31 +11,15 @@ import type { CoreCtx } from './core-ctx'; const log = createLogger('Hub'); -/** - * Top-level lifecycle events fired by `Hub`. Inbound consumers (OneBot, - * WebUI backend, future MCP) subscribe via `hub.events.on(...)`. - */ export type HubEventMap = { - /** Fired whenever a fresh `Core` is online for a UIN. Either the - * account just came up, OR the primary channel was swapped under it - * (in which case `'core-offline'` for the same UIN fired first). */ - 'core-online': CoreCtx; - /** Fired when an account goes offline OR right before a primary- - * channel swap rebuilds its `Core`. */ + 'core-online': CoreCtx; 'core-offline': { uin: string }; }; export type HubEventHandler = (payload: HubEventMap[K]) => void; export type HubEventDisposer = () => void; -/** - * Tiny typed event emitter scoped to `Hub`'s lifecycle events. Kept - * inline rather than reaching for `@snowluma/protocol/event-bus` - * (which is hard-typed to QQ event variants) or a generic dependency. - * - * Subscriber errors are swallowed with a warning so one bad consumer - * never blocks the rest. - */ + class HubEvents { private readonly listeners_ = new Map void>>(); @@ -67,39 +51,10 @@ class HubEvents { } } -/** - * `Hub` — the single multi-account entry point and **owner of all - * channels and cores**. Replaces the v2 split between - * `ChannelManager` (transport pool, lived in `@snowluma/channel`) and - * `AccountManager` (account wrapper, lived in `@snowluma/core`). - * - * Responsibilities (folded from both predecessors): - * - * 1. Register one `ChannelAdapter` per transport kind (hook, - * socket, …). Adapters publish/withdraw `Channel`s through the - * `ChannelAdapterHost` interface that Hub implements per-adapter. - * 2. Track every live channel (`channelsById_` / `channelsByUin_`) - * and pick a primary per UIN (`primaryByUin_`) by adapter rank - * (`primaryPriority`: inject > socket today). - * 3. Build a `ChannelCtx` capability POJO from each primary channel - * and materialise a `Core` on top. - * 4. Emit two top-level lifecycle events: `'core-online'` (with the - * `CoreCtx` projection) and `'core-offline'` (with `{ uin }`). - * Inbound consumers (OneBot, WebUI) only see these two events; - * they never touch the underlying `Channel` / `Core` classes. - * - * Single-owner invariant: every `Channel` and every `Core` instance - * is referenced exactly once — from inside `Hub`. External consumers - * hold only `CoreCtx` projections, which become stale when `Core` - * is disposed (handlers should unsubscribe in their `'core-offline'` - * handler). - */ export class Hub { - // ─── adapter / channel pool (was ChannelManager) ────────────────── private readonly adapters_ = new Map(); private readonly channelsById_ = new Map(); - private readonly channelsByUin_ = new Map>(); - private readonly primaryByUin_ = new Map(); + private readonly channelsByUin_ = new Map(); // ─── core pool (was AccountManager) ────────────────────────────── private readonly cores_ = new Map(); @@ -170,7 +125,6 @@ export class Hub { this.cores_.clear(); this.channelsById_.clear(); this.channelsByUin_.clear(); - this.primaryByUin_.clear(); this.events.clear(); } @@ -187,11 +141,9 @@ export class Hub { return [...this.cores_.values()]; } - /** Primary channel for a UIN (the one driving the current `Core`), - * or `null` if the UIN is offline. */ + /** Channel for a UIN, or `null` if the UIN is offline. */ getChannel(uin: string): Channel | null { - const id = this.primaryByUin_.get(uin); - return id ? this.channelsById_.get(id) ?? null : null; + return this.channelsByUin_.get(uin) ?? null; } getOnlineUins(): readonly string[] { @@ -215,37 +167,28 @@ export class Hub { adapterLog.warn('addChannel: id=%s already registered, ignoring', channel.id); return; } - this.channelsById_.set(channel.id, channel); + const existing = this.channelsByUin_.get(channel.uin); + if (existing) { + adapterLog.warn('addChannel: UIN=%s already has channel=%s, replacing with %s', + channel.uin, existing.id, channel.id); + this.channelsById_.delete(existing.id); + this.channelsById_.set(channel.id, channel); + this.channelsByUin_.set(channel.uin, channel); - let ids = this.channelsByUin_.get(channel.uin); - if (!ids) { - ids = new Set(); - this.channelsByUin_.set(channel.uin, ids); - } - ids.add(channel.id); - - const prevPrimaryId = this.primaryByUin_.get(channel.uin); - const newPrimary = this.pickPrimary(channel.uin); - if (!newPrimary) return; - this.primaryByUin_.set(channel.uin, newPrimary.id); - - const isFirst = prevPrimaryId === undefined; - const primaryChanged = !isFirst && prevPrimaryId !== newPrimary.id; - - if (isFirst) { - log.debug('account online: UIN=%s via %s', channel.uin, newPrimary.id); - this.bringCoreOnline(channel.uin, newPrimary); - } else if (primaryChanged) { - // A higher-priority transport (e.g. socket overtakes inject) - // came online. Tear down the old core and rebuild around the - // new primary so apis/identity stay consistent with the active - // transport. OneBot already tears down + rebuilds its instance - // on offline→online; we faithfully drive that. - log.debug('primary switched: UIN=%s %s → %s', - channel.uin, prevPrimaryId, newPrimary.id); this.bringCoreOffline(channel.uin); - this.bringCoreOnline(channel.uin, newPrimary); + this.bringCoreOnline(channel.uin, channel); + + try { existing.dispose(); } catch (err) { + adapterLog.debug('channel.dispose() threw: %s', + err instanceof Error ? err.message : String(err)); + } + return; } + + this.channelsById_.set(channel.id, channel); + this.channelsByUin_.set(channel.uin, channel); + log.debug('account online: UIN=%s via %s', channel.uin, channel.id); + this.bringCoreOnline(channel.uin, channel); } private removeChannel(id: string, adapterLog: Logger): void { @@ -253,25 +196,10 @@ export class Hub { if (!channel) return; this.channelsById_.delete(id); - const ids = this.channelsByUin_.get(channel.uin); - if (ids) { - ids.delete(id); - if (ids.size === 0) this.channelsByUin_.delete(channel.uin); - } - - const wasPrimary = this.primaryByUin_.get(channel.uin) === id; - if (wasPrimary) this.primaryByUin_.delete(channel.uin); - - const replacement = this.pickPrimary(channel.uin); - if (replacement) { - this.primaryByUin_.set(channel.uin, replacement.id); - if (wasPrimary) { - adapterLog.debug('primary failover: UIN=%s %s → %s', - channel.uin, id, replacement.id); - this.bringCoreOffline(channel.uin); - this.bringCoreOnline(channel.uin, replacement); - } - } else if (wasPrimary) { + const current = this.channelsByUin_.get(channel.uin); + const wasCurrent = current?.id === id; + if (wasCurrent) { + this.channelsByUin_.delete(channel.uin); log.debug('account offline: UIN=%s', channel.uin); this.bringCoreOffline(channel.uin); } @@ -307,34 +235,4 @@ export class Hub { } } - private pickPrimary(uin: string): Channel | null { - const ids = this.channelsByUin_.get(uin); - if (!ids || ids.size === 0) return null; - - let best: Channel | null = null; - let bestRank = -Infinity; - for (const id of ids) { - const channel = this.channelsById_.get(id); - if (!channel) continue; - const rank = primaryPriority(channel.kind); - if (rank > bestRank) { - best = channel; - bestRank = rank; - } - } - return best; - } -} - -/** - * Higher wins. Today we prefer the in-process hook over the future - * pure-socket runtime — operators with QQ.exe running trust the hook - * path more, and the socket runtime is unproven. Single knob to flip - * (or surface as config) once the socket runtime is hardened. - */ -function primaryPriority(kind: ChannelKind): number { - switch (kind) { - case 'inject': return 100; - case 'socket': return 50; - } } diff --git a/packages/core/tests/account-member-identity.test.ts b/packages/core/tests/account-member-identity.test.ts index 2dc5b93b..7b343768 100644 --- a/packages/core/tests/account-member-identity.test.ts +++ b/packages/core/tests/account-member-identity.test.ts @@ -36,13 +36,6 @@ function makeGroup(members: GroupMemberInfo[] = []): QQGroupInfo { }; } -/** - * `Core` wired up with a member-list interceptor. The interceptor - * lives on `core.apis.contacts.fetchGroupMemberList`; direct property - * assignment is enough because the ApiHub holds own-property methods, - * not prototype ones. The underlying `HookChannel` is exposed so tests - * can drive `deliverPacket` directly. - */ function buildCore(identity: IdentityService, refreshedMembers: GroupMemberInfo[]) { const channel = new HookChannel(identity.uin); const core = new Core(makeChannelCtx(channel), identity);