fix(discord): load the thread map once, so alerts that race at startup keep their threads

This commit is contained in:
Devvyn committed 2026-10-29 23:04:20 +08:00
1 parent eb0553a645
commit e95d38a944
2 files changed
+20 -8

No files matched your search

+17 -5
View File
@@ -653,6 +653,9 @@ export class DiscordBridge {
private pending = new Map<string, Promise<string | null>>();
/** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */
private persistChain: Promise<void> = Promise.resolve();
private mapLoad?: Promise<void>;
/** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
private firstLoad?: Promise<void>;
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<void> {
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<void> {
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<DiscordResult | null> {
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<void> {
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);
+3 -3
View File
@@ -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<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
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');
});