diff --git a/.gitignore b/.gitignore index 3dfcd2e09..245108a04 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ Cargo.lock.merge-conflict tools/__pycache__/ openpencil-docs +output diff --git a/packages/op-vscode/.vscode/launch.json b/packages/op-vscode/.vscode/launch.json new file mode 100644 index 000000000..3296282f0 --- /dev/null +++ b/packages/op-vscode/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run OpenPencil Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "/private/tmp/claude-501/-Users-kayshen-Workspace-ZSeven-W-openpencil/9ad30769-086a-43a5-ab3c-81ea1ac1e9b2/scratchpad/op-vscode-testws" + ], + "outFiles": ["${workspaceFolder}/dist/**/*.js"] + } + ] +} diff --git a/packages/op-vscode/build.mjs b/packages/op-vscode/build.mjs index 9bd7d4238..7a3c6eb21 100644 --- a/packages/op-vscode/build.mjs +++ b/packages/op-vscode/build.mjs @@ -33,6 +33,12 @@ const ctx = await esbuild.context({ format: "cjs", platform: "node", target: "node18", + // Prefer ESM entry points. jsonc-parser's UMD `main` wraps its submodule + // requires (`require("./impl/format")`) in a factory esbuild can't statically + // link, so they survive as runtime requires that fail at load ("Cannot find + // module './impl/format'") — crashing extension activation. Its ESM `module` + // entry uses static imports esbuild bundles cleanly. + mainFields: ["module", "main"], sourcemap: true, }); diff --git a/packages/op-vscode/src/extension.ts b/packages/op-vscode/src/extension.ts index 711c37a35..162617e0b 100644 --- a/packages/op-vscode/src/extension.ts +++ b/packages/op-vscode/src/extension.ts @@ -39,12 +39,22 @@ const placeholderUris: vscode.Uri[] = []; export function activate(context: vscode.ExtensionContext): void { output = vscode.window.createOutputChannel("OpenPencil"); + // Mirror to the dev-tools console (console.log shows in the Extension + // Development Host debug console) AND the "OpenPencil" Output channel, so the + // manual test matrix is observable from either surface. const logger: DaemonLogger = { - info: (l) => output?.appendLine(l), - error: (l) => output?.appendLine(`[error] ${l}`), + info: (l) => { + output?.appendLine(l); + console.log(`[OpenPencil] ${l}`); + }, + error: (l) => { + output?.appendLine(`[error] ${l}`); + console.error(`[OpenPencil] ${l}`); + }, }; const state: AppState = { trusted: vscode.workspace.isTrusted }; currentState = state; + logger.info(`activate: trusted=${state.trusted}`); // Commands are registered ONCE; handlers dispatch through the mutable state. registerCommands(context, state); @@ -108,6 +118,7 @@ async function assemble( ); } await context.workspaceState.update(PROXY_PORT_KEY, proxyPort); + logger.info(`assembled: MCP proxy listening on 127.0.0.1:${proxyPort}`); state.assembled = { pool, proxy, registry, proxyPort }; context.subscriptions.push( diff --git a/packages/op-vscode/src/vscode/pen-editor-provider.ts b/packages/op-vscode/src/vscode/pen-editor-provider.ts index 1ce8c3edd..743196dd0 100644 --- a/packages/op-vscode/src/vscode/pen-editor-provider.ts +++ b/packages/op-vscode/src/vscode/pen-editor-provider.ts @@ -49,6 +49,14 @@ export class PenEditorProvider implements vscode.CustomEditorProvider(); + // Current boot per file. bootAndMount is fire-and-forget, so a boot from a + // panel closed mid-flight can survive into a reopened editor for the same + // file; its token no longer being current marks it stale. A stale boot must + // never release the key's daemon while a successor token exists — the pool + // coalesces acquires onto one daemon, so that release would tear down the + // successor's live editor. + private readonly bootTokens = new Map(); + constructor( private readonly context: vscode.ExtensionContext, private readonly pool: DaemonPool, @@ -81,7 +89,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { + if (this.bootTokens.get(key) === token) this.bootTokens.delete(key); const live = this.mounts.get(key); if (live && live.panel === panel) { this.disposeMount(live); @@ -90,17 +102,72 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { + const key = document.uri.fsPath; try { + this.logger.info(`bootAndMount: ${key} — booting shell`); const shellOrigin = await this.bootShellAndGetOrigin(panel); + if (this.staleCleanup(key, token)) return; + this.logger.info(`bootAndMount: shell origin=${shellOrigin} — spawning daemon`); const client = await this.spawnAndAwaitReady(document.uri, shellOrigin); + if (this.staleCleanup(key, token)) return; + this.logger.info(`bootAndMount: daemon ready at ${client.baseUrl} — mounting editor`); await this.mountEditor(document, panel, client, watcherState); + if (this.staleAfterMount(key, token, panel)) return; + this.logger.info(`bootAndMount: mounted ${key}`); } catch (err) { - this.logger.error(`resolveCustomEditor failed: ${String(err)}`); - panel.webview.html = errorPage(String(err)); + if (this.staleCleanup(key, token)) return; // stale failure: never disturb a successor + this.logger.error(`bootAndMount failed: ${String(err)}`); + try { + panel.webview.html = errorPage(String(err)); + } catch { + /* panel already disposed */ + } void this.pool.release(key); // failed initial mount → don't orphan } } + /** True when `token` is no longer the current boot for `key` (its panel was + * closed, possibly with a successor editor reopened since). On a stale exit: + * with a successor boot live, touch nothing — the pool coalesces acquires, + * so releasing here would tear down the successor's daemon. With none, + * release: the panel-close release may have raced ahead of a still-inflight + * spawn that then completed into an ownerless daemon. release is a no-op + * when nothing lives. */ + private staleCleanup(key: string, token: object | undefined): boolean { + if (this.bootTokens.get(key) === token) return false; + this.logger.info(`stale boot for ${key} abandoned`); + if (!this.bootTokens.has(key)) void this.pool.release(key); + return true; + } + + /** staleCleanup for the window where the panel closed DURING mountEditor: + * onDidDispose ran before the mount was recorded, so also tear down the + * just-created mount (session, watcher, registry entry) it couldn't see. */ + private staleAfterMount(key: string, token: object | undefined, panel: vscode.WebviewPanel): boolean { + if (this.bootTokens.get(key) === token) return false; + const ctx = this.mounts.get(key); + if (ctx && ctx.panel === panel) { + this.disposeMount(ctx); + this.mounts.delete(key); + } + return this.staleCleanup(key, token); + } + /** Phase 1: install the message listener + timeout BEFORE assigning boot HTML * (the shell script may run synchronously on html assignment). */ private bootShellAndGetOrigin(panel: vscode.WebviewPanel): Promise { @@ -213,6 +280,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider { const ctx = this.mounts.get(filePath); if (!ctx) return; // no live editor for this file + // The live mount's boot token stays current for the panel's lifetime, so + // reuse it: the panel closing mid-restart (possibly with the file reopened + // since) makes it stale, and the guards below keep this restart from + // releasing a successor's daemon or writing to the dead panel. + const token = this.bootTokens.get(filePath); const { document, panel } = ctx; const wasDirty = ctx.session.isRustDirty; this.disposeMount(ctx); @@ -223,10 +295,12 @@ export class PenEditorProvider implements vscode.CustomEditorProvider