Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
35 changes: 35 additions & 0 deletions src/vscode/foundation/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<TApi = void> {
/**
Expand Down Expand Up @@ -172,6 +182,31 @@ export function defineExtension(
commands: createCommandExecutor(capability),

activate: async (context: vscode.ExtensionContext): Promise<unknown> => {
// 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
Expand Down
104 changes: 104 additions & 0 deletions tests/vscode/foundation/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
});