From ef532553fb3c05c9ecdd9b19f8e0a5b40b32279a Mon Sep 17 00:00:00 2001 From: kkdev92 <112151103+kkdev92@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:07:01 +0900 Subject: [PATCH] feat(extension): make defineExtension single-use, like the host it serves A second `activate` on one `defineExtension` result used to build a second application from scratch: a new log channel VS Code never saw closed, new registrations the first application's failsafe knew nothing about, and the first application left behind, still owning what it had registered. The host underneath is single-flight and single-use -- a start in flight is joined, and a stopped or failed host refuses to start again -- but the facade was hiding that by starting over. Now the facade keeps one application. A second `activate` while the first is starting or running joins it and resolves to the same value; after `deactivate`, or after a start that failed, it rejects with a `FrameworkError` of kind `activation` (code `EXTENSION_NOT_RESTARTABLE`, `details.state` naming the host state) rather than rebuilding anything. The host's own `InvalidHostStateError` stays internal; the facade translates it into the vocabulary a consumer already handles. VS Code activates an extension once per session, so nothing changes in the editor. The change is visible only to a test that activates one definition twice, which the guide already steers away from: build one per test, or run the plan through `createTestHost`. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +++ README.md | 1 + src/vscode/foundation/extension.ts | 35 ++++++++ tests/vscode/foundation/extension.test.ts | 104 ++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ff2bc..63b1763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,18 @@ Pre-1.0 releases followed it in spirit; their breaking changes are marked **Brea `PreflightError` is exported from the root, so the error can be recognised with `instanceof` rather than by its name. +### Changed + +- **`defineExtension` is single-use, like the extension host it serves.** A + second `activate` while the first is starting or running now joins that + start and resolves to the same value; after `deactivate`, or after a start + that failed, it rejects with a `FrameworkError` of kind `activation` (code + `EXTENSION_NOT_RESTARTABLE`) instead of quietly building a second application + and a second log channel. VS Code activates an extension once per session, + so nothing changes in the editor; the change shows up only in a test that + activates one `defineExtension` result twice — build one per test, or run + the plan through `createTestHost`, which is what it is for. + ## [4.0.1] - 2026-08-29 **A patch to the tree-view adapter, plus four corrections to what the project diff --git a/README.md b/README.md index 7a4f9a3..5351fd5 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ generated API reference is not built yet. - A command's result and its rejection both reach the caller - The plan is immutable: mutating a definition after compilation cannot change what runs - Fakes and real adapters satisfy the same contract suite +- `activate` is single-use: a second call while starting or running joins the same start and resolves to the same value; after `deactivate` it rejects rather than rebuilding the application behind VS Code's back --- diff --git a/src/vscode/foundation/extension.ts b/src/vscode/foundation/extension.ts index a25c5f0..8a21a47 100644 --- a/src/vscode/foundation/extension.ts +++ b/src/vscode/foundation/extension.ts @@ -23,6 +23,8 @@ import type { ApplicationPlan } from '../../foundation/application/plan.js'; import { createCommandExecutor } from '../../foundation/commands/binder.js'; import type { CommandExecutor } from '../../foundation/commands/binder.js'; import type { HostDiagnostic } from '../../foundation/hosting/application-host.js'; +import { InvalidHostStateError } from '../../foundation/internal/errors.js'; +import { ErrorKind, FrameworkError } from '../../foundation/operations/errors.js'; import type { ModuleDefinition } from '../../foundation/modules/definition.js'; import type { Injected, ServiceMap } from '../../foundation/services/token.js'; import { createVSCodeCommandCapability } from './commands.js'; @@ -112,6 +114,14 @@ export interface DefineExtensionOptions { * * `deactivate` is the single cleanup path; `activate` registers only a * synchronous failsafe on `context.subscriptions`. + * + * One application per definition. A second `activate` while the first is + * starting or running joins that start and resolves to the same value; after + * `deactivate`, or after a start that failed, it rejects with a + * `FrameworkError` of kind `activation` (code `EXTENSION_NOT_RESTARTABLE`) + * rather than building a second application. VS Code activates an extension + * once per session, so this only shows up in a test that reuses one + * definition — build one per test, or run the plan through `createTestHost`. */ export interface ExtensionApplication { /** @@ -172,6 +182,31 @@ export function defineExtension( commands: createCommandExecutor(capability), activate: async (context: vscode.ExtensionContext): Promise => { + // One application per definition. A second call while the first is + // starting or running joins it: the host's start is single-flight, so + // this resolves to the same value and creates nothing. Once the host has + // stopped or failed there is nothing to join, and building a fresh + // application here would give VS Code an extension it never asked for, + // on a log channel it never saw closed. The host says no; the facade + // says it in the vocabulary a consumer already handles. + if (application !== undefined) { + try { + return await application.activate(context); + } catch (error) { + if (error instanceof InvalidHostStateError) { + const state = application.host.state; + throw new FrameworkError({ + kind: ErrorKind.Activation, + code: 'EXTENSION_NOT_RESTARTABLE', + message: `"${options.name}" cannot be activated again: its application is ${state}.`, + details: { state }, + cause: error, + }); + } + throw error; + } + } + // Created here, not at import time: creating a channel is a VS Code call. // // Deliberately NOT pushed onto context.subscriptions: VS Code may dispose diff --git a/tests/vscode/foundation/extension.test.ts b/tests/vscode/foundation/extension.test.ts index 15f59be..204ce6d 100644 --- a/tests/vscode/foundation/extension.test.ts +++ b/tests/vscode/foundation/extension.test.ts @@ -133,6 +133,7 @@ describe('defineExtension boundary', () => { */ const { defineModule } = await import('../../../src/foundation/modules/definition.js'); const { serviceToken } = await import('../../../src/foundation/services/token.js'); +const { FrameworkError } = await import('../../../src/foundation/operations/errors.js'); describe('defineExtension exports', () => { const Counter = serviceToken<{ next(): number }>('sample.counter'); @@ -220,3 +221,106 @@ describe('defineExtension exports', () => { expect(vscodeMock.channels[0]?.disposed).toBe(true); }); }); + +/** + * One application per definition. + * + * VS Code activates an extension once per session, so none of this happens in + * the editor. It happens in a test that reuses one `defineExtension` result, + * and the question is what that test sees: the same application, or a second + * one built behind the first — with a second log channel VS Code never saw + * closed and registrations the first one's failsafe knows nothing about. + */ +describe('defineExtension is single-use', () => { + const Handle = serviceToken<{ readonly id: string }>('sample.handle'); + const handleModule = defineModule('handle', (module): undefined => { + module.services.singleton(Handle, () => ({ id: 'one' })); + return undefined; + }); + const withExports = () => + defineExtension({ + name: 'Sample', + modules: [handleModule], + exports: { inject: { handle: Handle }, create: ({ handle }) => handle }, + }); + + it('joins a start already in flight, resolving both callers to the same value', async () => { + vscodeMock.channels.length = 0; + const app = withExports(); + const context = makeContext(); + + const [first, second] = await Promise.all([app.activate(context), app.activate(context)]); + + expect(second).toBe(first); + // The host's start is single-flight, so the second call created nothing. + expect(vscodeMock.channels).toHaveLength(1); + await app.deactivate(); + }); + + it('answers a second activate while running with the same value', async () => { + vscodeMock.channels.length = 0; + const app = withExports(); + + const first = await app.activate(makeContext()); + const second = await app.activate(makeContext()); + + expect(second).toBe(first); + expect(vscodeMock.channels).toHaveLength(1); + await app.deactivate(); + }); + + it('refuses to activate again after deactivate, and says why', async () => { + vscodeMock.channels.length = 0; + const app = defineExtension({ name: 'Sample', modules: [] }); + await app.activate(makeContext()); + await app.deactivate(); + + const failure: unknown = await app.activate(makeContext()).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(FrameworkError); + expect(failure).toMatchObject({ + kind: 'activation', + code: 'EXTENSION_NOT_RESTARTABLE', + details: { state: 'stopped' }, + }); + // Nothing was rebuilt: the one channel is the one deactivate closed. + expect(vscodeMock.channels).toHaveLength(1); + expect(vscodeMock.channels[0]?.disposed).toBe(true); + }); + + it('refuses while deactivate is still pending', async () => { + const app = defineExtension({ name: 'Sample', modules: [] }); + await app.activate(makeContext()); + + const stopping = app.deactivate(); + await expect(app.activate(makeContext())).rejects.toMatchObject({ + code: 'EXTENSION_NOT_RESTARTABLE', + details: { state: 'stopping' }, + }); + await stopping; + }); + + it('refuses after a failed activation instead of trying again', async () => { + vscodeMock.channels.length = 0; + const bad = defineModule('bad', (builder): undefined => { + builder.raw.register({ + id: 'bad.bind', + bind: () => { + throw new Error('bind failed'); + }, + }); + return undefined; + }); + const app = defineExtension({ name: 'Sample', modules: [bad] }); + + await expect(app.activate(makeContext())).rejects.toThrow('bind failed'); + await expect(app.activate(makeContext())).rejects.toMatchObject({ + code: 'EXTENSION_NOT_RESTARTABLE', + details: { state: 'failed' }, + }); + // The failed attempt's channel was disposed by the rollback, and the + // refusal opened no second one. + expect(vscodeMock.channels).toHaveLength(1); + expect(vscodeMock.channels[0]?.disposed).toBe(true); + }); +});