fix(vscode): boot webview shell without blocking resolve; guard stale boots

This commit is contained in:
Kayshen-X 2026-07-18 09:56:23 +08:00
parent 2ff78e7d44
commit cd76503369
5 changed files with 123 additions and 10 deletions

1
.gitignore vendored
View file

@ -42,3 +42,4 @@ Cargo.lock.merge-conflict
tools/__pycache__/
openpencil-docs
output

15
packages/op-vscode/.vscode/launch.json vendored Normal file
View file

@ -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"]
}
]
}

View file

@ -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,
});

View file

@ -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(

View file

@ -49,6 +49,14 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
// listeners/watcher first so nothing accumulates across restarts.
private readonly mounts = new Map<string, MountContext>();
// 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<string, object>();
constructor(
private readonly context: vscode.ExtensionContext,
private readonly pool: DaemonPool,
@ -81,7 +89,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
// restarts): whenever the panel closes, tear down the live mount if any and
// ALWAYS release the daemon — the single guarantee that closing an editor
// (even mid-restart, even after a failed re-mount) never orphans a daemon.
const token = {};
this.bootTokens.set(key, token);
panel.onDidDispose(() => {
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<PenDocumen
void this.pool.release(key);
});
// Fire-and-forget: the workbench only attaches the webview (loading its
// HTML and running its scripts) AFTER this resolve completes — observed in
// Cursor, and the official custom-editor samples likewise never await
// webview messages here. Awaiting the shell-ready handshake inside resolve
// would deadlock into the ready timeout. The boot HTML is still assigned
// synchronously below (inside bootShellAndGetOrigin) before we return.
void this.bootAndMount(document, panel, watcherState, token);
}
private async bootAndMount(
document: PenDocument,
panel: vscode.WebviewPanel,
watcherState: { suppressUntil: number },
token: object,
): Promise<void> {
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<string> {
@ -213,6 +280,11 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
private async handleRestart(filePath: string, client: DaemonClient | undefined): Promise<void> {
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<PenDocumen
// mount. On success the fresh mount owns it (released at panel close). The
// panel-scope onDidDispose in resolveCustomEditor is the final backstop;
// pool.release is idempotent so a later close is a harmless no-op.
let remounted = false;
try {
if (!client) {
panel.webview.html = errorPage("The design daemon crashed repeatedly.");
if (!this.staleCleanup(filePath, token)) {
panel.webview.html = errorPage("The design daemon crashed repeatedly.");
void this.pool.release(filePath);
}
return;
}
const source = pickRestartSource(document.latestDurable, wasDirty);
@ -236,13 +310,19 @@ export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocumen
: decode(await vscode.workspace.fs.readFile(document.uri));
(document as { bootJson: string }).bootJson = bootJson;
const shellOrigin = await this.bootShellAndGetOrigin(panel);
if (this.staleCleanup(filePath, token)) return;
const fresh = await this.spawnAndAwaitReady(document.uri, shellOrigin);
if (this.staleCleanup(filePath, token)) return;
await this.mountEditor(document, panel, fresh, { suppressUntil: 0 });
remounted = true;
if (this.staleAfterMount(filePath, token, panel)) return;
} catch (err) {
panel.webview.html = errorPage(`restart failed: ${String(err)}`);
} finally {
if (!remounted) void this.pool.release(filePath);
if (this.staleCleanup(filePath, token)) return;
try {
panel.webview.html = errorPage(`restart failed: ${String(err)}`);
} catch {
/* panel already disposed */
}
void this.pool.release(filePath);
}
}