From e95d38a9442796e740f2dc2ce15ab79b58139d1f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:04:20 +0800 Subject: [PATCH] fix(discord): load the thread map once, so alerts that race at startup keep their threads --- src/discord-bridge.ts | 22 +++++++++++++++++----- test/discord-push-glue.test.ts | 6 +++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 571931c7..db2d2a94 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -653,6 +653,9 @@ export class DiscordBridge { private pending = new Map>(); /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ private persistChain: Promise = Promise.resolve(); + private mapLoad?: Promise; + /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */ + private firstLoad?: Promise; private last: DiscordResult | null = null; private readonly api: DiscordApi; private readonly now: () => number; @@ -672,13 +675,22 @@ export class DiscordBridge { return this.gateway?.lastError ?? null; } + private async ensureLoaded(): Promise { + if (this.loaded) return; + this.firstLoad ??= this.reload(); + await this.firstLoad; + } + /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ async reload(): Promise { this.cfg = await readDiscordConfig(this.deps.configDir); - if (!this.loaded) { - this.threads = await readThreadMap(this.deps.configDir); + // The thread map is read once, by whoever gets here first; everyone else waits for that read. A second + // read finishing later would replace the map and drop threads made in between. + this.mapLoad ??= readThreadMap(this.deps.configDir).then((saved) => { + this.threads = { ...saved, ...this.threads }; this.loaded = true; - } + }); + await this.mapLoad; this.gateway?.stop(); this.gateway = null; activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; @@ -758,7 +770,7 @@ export class DiscordBridge { opts: { urgent?: boolean; deliberate?: boolean } = {} ): Promise { const explicitUrgent = opts.urgent === true; - if (!this.loaded) await this.reload(); + await this.ensureLoaded(); if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; const key = `${msg.event}:${msg.sessionId ?? ''}`; const t = this.now(); @@ -833,7 +845,7 @@ export class DiscordBridge { createdAt?: number, resumed = false ): Promise { - if (!this.loaded) await this.reload(); + await this.ensureLoaded(); // Only a tab made just now: the same event also fires for sessions restored at server start. // A resumed tab is created now too, but its conversation is not new, so it is never hinted. if (!resumed && createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 04c18ba5..74220a84 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -18,7 +18,7 @@ import { WebServer } from '../src/web/server.js'; import { SseEvent } from '../src/web/sse-events.js'; type Internals = { - pushStore: { getAll: () => never[] }; + pushStore: { getAll: () => never[]; dispose: () => void }; discordBridge: Record>; sessions: Map; mux: { capturePaneText?: (name: string) => string | null }; @@ -29,7 +29,7 @@ type Internals = { function server(): Internals { const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; - s.pushStore = { getAll: () => [] }; + s.pushStore = { getAll: () => [], dispose: () => undefined }; s.discordBridge = { notify: vi.fn(async () => null), sessionCreated: vi.fn(async () => undefined), @@ -92,7 +92,7 @@ describe('broadcast -> Discord tab lifecycle', () => { it('routes created, updated and deleted by kind, reading both payload shapes', () => { const s = server(); s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } }); - expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123, false); s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); });