diff --git a/packages/cli/src/ui/opentui/commands-dispatch.ts b/packages/cli/src/ui/opentui/commands-dispatch.ts index ab771054756..41690bb7ac0 100644 --- a/packages/cli/src/ui/opentui/commands-dispatch.ts +++ b/packages/cli/src/ui/opentui/commands-dispatch.ts @@ -340,14 +340,14 @@ export class OpenTuiSlashDispatcher { } /** - * Startup-window self-heal: the first dispatcher can attach a registry - * built while config.initialize() was still in flight — the second - * initialize() call throws "already initialized", the catch proceeds, and - * the skill loaders run before the skill manager exists, so builtin - * commands resolve but every skill (e.g. /qc-helper) reports "Unknown - * command" until the config-ready dispatcher replaces this one. One - * bounded retry per dispatcher lifetime: wait for the skill manager, then - * reload the registry so the re-parse sees the complete list. + * Startup-window self-heal: the dispatcher can be attached with a registry + * snapshot taken before config.initialize() finished — the skill manager + * does not exist yet, so builtin commands resolve but every skill (e.g. + * /qc-helper) reports "Unknown command". A concurrent initialize() call + * now joins the in-flight run instead of throwing, so only a failed first + * flight still lands the loader in its partial-commands catch. One bounded + * retry per dispatcher lifetime: wait for the skill manager, then reload + * the registry so the re-parse sees the complete list. */ private async ensureCommandsLoaded(): Promise { if (this.startupRetryUsed || !this.services.config) { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ec5ec9e67b8..119c5a773f1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -5157,6 +5157,37 @@ describe('Server Config (config.ts)', () => { ); }); + it('rejects a joining caller whose signal is already aborted', async () => { + const config = new Config({ + ...baseParams, + }); + + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn( + config as unknown as { + initializeInternal: () => Promise; + }, + 'initializeInternal', + ).mockImplementation(() => gate); + + const first = config.initialize(); + const controller = new AbortController(); + const abortReason = new Error('joining caller already aborted'); + controller.abort(abortReason); + // A joining caller cannot have its options honored, so an + // already-aborted signal fails fast instead of blocking on the first + // flight. Assert the rejection while the gate is still held: settling + // the first flight first would let a guard placed after the `await` + // reject with the same reason and pass. + const joining = config.initialize({ signal: controller.signal }); + await expect(joining).rejects.toBe(abortReason); + release(); + await expect(first).resolves.toBeUndefined(); + }); + it('shares a failed in-flight initialization with concurrent callers', async () => { const config = new Config({ ...baseParams, @@ -5177,6 +5208,12 @@ describe('Server Config (config.ts)', () => { ]); expect(firstError).toBeInstanceOf(Error); expect(secondError).toBe(firstError); + + // A failed-and-settled first flight still flips `initializationSettled`, + // so a later call must throw rather than re-join the stale rejection. + await expect(config.initialize()).rejects.toThrow( + 'Config was already initialized', + ); }); it('should skip implicit startup discovery in bare mode', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9531c5551d0..2ea1850a670 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2997,7 +2997,8 @@ export class Config { /** * Must only be called once, throws if called again after the first call * settled. Callers arriving while the first call is still in flight join - * that flight instead of throwing. + * that flight instead of throwing; a joining caller's options are ignored + * — the first caller's options win. * @param options Optional initialization options including sendSdkMcpMessage callback */ async initialize(options?: ConfigInitializeOptions): Promise { @@ -3010,6 +3011,12 @@ export class Config { // a config whose chat had not started yet, and the first prompt died // with "Chat not initialized" (#11002). if (!this.initializationSettled) { + // A joining caller's options cannot be honored, so an already-aborted + // signal must fail fast instead of blocking on the foreign flight. + options?.signal?.throwIfAborted(); + this.debugLogger.debug( + 'Config.initialize() called while initialization is in flight; joining the existing run', + ); await this.initializationPromise; return; }