fix(vscode): boot webview shell without blocking resolve; guard stale boots
This commit is contained in:
parent
2ff78e7d44
commit
cd76503369
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -42,3 +42,4 @@ Cargo.lock.merge-conflict
|
|||
|
||||
tools/__pycache__/
|
||||
openpencil-docs
|
||||
output
|
||||
|
|
|
|||
15
packages/op-vscode/.vscode/launch.json
vendored
Normal file
15
packages/op-vscode/.vscode/launch.json
vendored
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue