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
132 changes: 132 additions & 0 deletions packages/core/src/commands/registry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -367,6 +367,138 @@ test("registerLazy's Disposable removes the command, matching register()'s dispo
expect(errors.at(-1)?.message).toBe("Command not found: demo.run");
});

// --- activateExtension hook (design.md §4.2, Task 1.12) --------------------

test("execute() awaits the activateExtension hook and re-dispatches once it resolves the handler", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const activateCalls: string[] = [];
const registry = createCommandRegistry({
log,
sink,
activateExtension: async (extensionId) => {
activateCalls.push(extensionId);
// Simulate the extension host's activate(ctx) replacing the lazy
// entry with a real handler via ctx.api.commands.register.
registry.register("demo.run", () => "activated!");
},
});
registry.registerLazy("demo.run", { extensionId: "demo.ext" });

const result = await registry.execute("demo.run");

expect(result).toBe("activated!");
expect(activateCalls).toEqual(["demo.ext"]);
});

test("execute() calls activateExtension only once across repeated executes once the handler resolves", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
const activateCalls: string[] = [];
const registry = createCommandRegistry({
log,
sink,
activateExtension: async (extensionId) => {
activateCalls.push(extensionId);
registry.register("demo.run", () => "activated!");
},
});
registry.registerLazy("demo.run", { extensionId: "demo.ext" });

await registry.execute("demo.run");
await registry.execute("demo.run");

expect(activateCalls).toEqual(["demo.ext"]);
});

test("execute() falls back to the not-activated-yet error when activateExtension does not resolve a handler", async () => {
const log = createHostLog();
const { errors, sink } = createRecordingSink();
const activateCalls: string[] = [];
const registry = createCommandRegistry({
log,
sink,
activateExtension: async (extensionId) => {
// The extension "activates" but never registers a real handler for
// this command (e.g. it failed, or the manifest was wrong).
activateCalls.push(extensionId);
},
});
registry.registerLazy("demo.run", { extensionId: "demo.ext" });

const result = await registry.execute("demo.run");

expect(result).toBeUndefined();
expect(activateCalls).toEqual(["demo.ext"]);
expect(errors.at(-1)?.message.toLowerCase()).toContain("not activated yet");
expect(errors.at(-1)?.extensionId).toBe("demo.ext");
});

test("execute() never calls activateExtension for a command with no extensionId (plain register)", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
let activateCalls = 0;
const registry = createCommandRegistry({
log,
sink,
activateExtension: async () => {
activateCalls += 1;
},
});
registry.register("editor.action.foo", () => "ok");

expect(await registry.execute("editor.action.foo")).toBe("ok");
expect(activateCalls).toBe(0);
});

test("execute() never calls activateExtension for an unknown command ID", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
let activateCalls = 0;
const registry = createCommandRegistry({
log,
sink,
activateExtension: async () => {
activateCalls += 1;
},
});

expect(await registry.execute("no.such.command")).toBeUndefined();
expect(activateCalls).toBe(0);
});

test("without an activateExtension hook, execute() keeps Task 1.11's not-activated-yet behavior unchanged", async () => {
const log = createHostLog();
const { errors, sink } = createRecordingSink();
const registry = createCommandRegistry({ log, sink });
registry.registerLazy("demo.run", { extensionId: "demo.ext" });

const result = await registry.execute("demo.run");

expect(result).toBeUndefined();
expect(errors.at(-1)?.message.toLowerCase()).toContain("not activated yet");
});

test("execute() keeps its never-throwing contract when activateExtension itself throws", async () => {
const log = createHostLog();
const { errors, sink } = createRecordingSink();
const registry = createCommandRegistry({
log,
sink,
activateExtension: async () => {
throw new Error("activation exploded");
},
});
registry.registerLazy("demo.run", { extensionId: "demo.ext" });

const result = await registry.execute("demo.run");

expect(result).toBeUndefined();
expect(errors.at(-1)?.extensionId).toBe("demo.ext");
const logged = log.entries().filter((e) => e.level === "error");
expect(logged.some((e) => e.error.message.includes("activation exploded"))).toBe(true);
});

test("registerLazy twice for the same ID logs a re-registration warning (last-wins)", async () => {
const log = createHostLog();
const { sink } = createRecordingSink();
Expand Down
71 changes: 62 additions & 9 deletions packages/core/src/commands/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,13 @@
* Lazy (manifest-declared, not-yet-activated) commands (design.md §4.1's
* "lazy commands", §5's `CommandEntry = { handler?, meta, extensionId?,
* lazy }`) are registered via {@link CommandRegistry.registerLazy} by the
* extension host (`host/registration.ts`) with no `handler` yet — real
* activation (calling the owning extension's `activate(ctx)` on first
* execution) is Task 1.12; until then, executing a lazy command reports a
* "not activated yet" `HostError` through `log`/`sink` rather than
* throwing or silently no-op'ing.
* extension host (`host/registration.ts`) with no `handler` yet. Real
* activation (Task 1.12, `host/activation.ts`) is wired in via
* {@link CommandRegistryDeps.activateExtension}: `execute()` on an
* unresolved lazy command awaits that hook (activating the owning
* extension) and re-dispatches before falling back to the "not activated
* yet" `HostError` reported through `log`/`sink` — never throwing or
* silently no-op'ing either way.
*/

import type {
Expand DownExpand Up@@ -49,6 +51,29 @@ export interface CommandRegistryDeps {
log: HostLog;
/** Where user-facing command errors are surfaced (Req 3.4, 3.5). */
sink: StatusSink;
/**
* Activate the extension owning an unresolved lazy command before
* re-dispatching (Req 2.5, design.md §4.2's "executing a lazy command
* activates the extension first, then re-dispatches"). Supplied by
* `host/activation.ts`'s `createExtensionHost(...).activateExtension` at
* the assembly layer — see that module's TSDoc for the construction
* order. Optional so `registry.ts` has no hard dependency on the
* extension host: omitted (as in every registry.test.ts case with no
* host in the picture), `execute()` falls straight to the existing "not
* activated yet" error path, unchanged from Task 1.11's behavior.
* Documented to never throw/reject (matching `activateExtension`'s own
* contract); `execute()` guards the call anyway so a misbehaving
* implementation can't break its own never-throwing contract.
*
* Re-entrancy contract: the implementation must resolve immediately for
* a call re-entering an activation already in progress on the current
* async path — an extension executing its own still-lazy command from
* inside `activate(ctx)`, or a mutual activation cycle. `execute()`
* keeps no re-entrancy state of its own, so an implementation that
* hands back its own in-flight activation promise here deadlocks
* (`createExtensionHost` satisfies this via its activation context).
*/
activateExtension?: (extensionId: string) => Promise<void>;
}

/** The public shape of the command registry — the implementation behind
Expand DownExpand Up@@ -99,7 +124,7 @@ export function isValidCommandId(id: string): boolean {
* the registry owning them.
*/
export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistry {
const { log, sink } = deps;
const { log, sink, activateExtension } = deps;
const commands = new Map<string, CommandEntry>();

/** Guarded `log.append` — an injected log must not be able to break the
Expand DownExpand Up@@ -177,15 +202,43 @@ export function createCommandRegistry(deps: CommandRegistryDeps): CommandRegistr
}

async function execute(id: string, ...args: unknown[]): Promise<unknown> {
const entry = commands.get(id);
let entry = commands.get(id);

if (entry && !entry.handler && entry.extensionId && activateExtension) {
// Lazy, not-yet-activated command (design.md §4.1, §4.2) — activate
// its owning extension, then re-look-up: activation is expected to
// replace this entry with a real handler via register() (Task 1.12).
// Concurrent execute() calls all await here and share the host's
// in-flight activation; the one case that must NOT wait — the
// extension executing its own still-lazy command from inside its own
// activate(ctx), which would deadlock on its own activation promise —
// is detected host-side (host/activation.ts's activation context) and
// resolves immediately, landing on the not-activated error path below.
try {
await activateExtension(entry.extensionId);
} catch (cause) {
// activateExtension is documented to never throw/reject; guard
// anyway so a misbehaving host implementation can't break
// execute()'s own never-throwing contract.
logSafely("error", {
extensionId: entry.extensionId,
message: `activateExtension("${entry.extensionId}") threw: ${describeError(cause)}`,
});
}
entry = commands.get(id);
}

if (!entry) {
const err: HostError = { message: `Command not found: ${id}` };
notifySafely(err);
return undefined;
}
if (!entry.handler) {
// Lazy, not-yet-activated command (design.md §4.1) — real activation
// is Task 1.12; for now report and stop, never throw.
// Still lazy after the activation attempt above (no hook wired, the
// entry carried no extensionId, or activation ran but the extension
// never registered a real handler for this ID — including a
// "failed" activation, design.md §4.2) — report and stop, never
// throw.
const err: HostError = {
message:
`Command "${id}" belongs to extension "${entry.extensionId ?? "unknown"}", ` +
Expand Down
Loading