diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index fa2edb7..4052f98 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- **Fix: the dev server is no longer interrupted seconds after it starts.** "Debug Pyxle app (React browser)" used to start `pyxle dev` by typing into a shell terminal. Anything may write to a shell terminal, and the Python extension activates your environment in every new one — through an API that sends `^C` first to interrupt whatever is running. The freshly started server was killed and the activation line (`pyenv shell …`, `source .venv/bin/activate`) typed in its place. The extension now owns the process directly, so nothing else can type into it; the panel still shows the server's output and Ctrl-C still stops it cleanly. +- **Fix: both debug configurations now run the same environment.** The React-browser flow ran whatever `pyxle` came first on your shell `PATH` while the Python flow ran VS Code's selected interpreter — routinely two different installs of two different versions. It now uses the selected interpreter for both, with the same pre-launch check. +- **A stale editable install is no longer mistaken for an old one.** The pre-launch check asks what the interpreter *can do* (can it run `python -m pyxle`) rather than what its package metadata claims, so an editable/dev install whose dist-info still says `0.7.5` while the code is current launches normally. +- **The interpreter errors are no longer a dead end.** Both the "pyxle not installed" and the "too old" message now lead with **Select Interpreter** — the usual cause is that the right pyxle lives in a *different* environment — and the launch continues automatically once you pick one. The message also reports the version it found (labelled as package metadata) and offers the matching install/upgrade/repair command. +- **New: the Python interpreter is visible and switchable from `.pyxl` files.** The Python extension only shows its interpreter indicator for `.py` files, so in a `.pyxl` editor you could neither see nor change the interpreter that debugging uses. A status-bar item now shows it (click to change), with a new **Pyxle: Select Python Interpreter** command. +- **A pre-launch check that can't answer no longer blocks the launch.** If the interpreter crashes, times out, or won't start, the debugger now goes ahead and lets the debugger report the real error instead of claiming pyxle isn't installed. +- **A dev server the extension started is stopped when VS Code shuts down normally**, rather than being left running and holding its port. It runs in its own process group so one signal takes the whole tree (Vite and the SSR workers) down with it; that also means a force-quit or an extension-host crash can still leave it running, in which case stop it from the terminal it prints to. +- Command palette entries no longer read "Pyxle: Pyxle: …". + ## 0.3.0 - **Breakpoint debugging of `.pyxl` files.** New `pyxle` debug type. Press **F5** ("Debug Pyxle app") to run your dev server under the debugger — one clean session with a real Stop/Restart/Pause — and open your app. Breakpoints bind in the Python half (`@server` loaders, `@action` handlers). Debug the React half (JSX) with the separate "Debug Pyxle app (React browser)" configuration (`"server": false`), a standalone Chrome session against the same dev server. Both halves of a page are breakpointable in the one `.pyxl` file you already have open. Also supports `"request": "attach"` for an already-running `pyxle dev --inspect`. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index eabcc9e..8bf7a2d 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -36,7 +36,7 @@ The command **"Pyxle: Open Studio"** opens the running dev server's [Studio dash ## Requirements -- **[pyxle-framework](https://pypi.org/project/pyxle-framework/) 0.8.0 or newer** in your project's Python environment (the launch model runs `python -m pyxle dev`, and `.pyxl` debugging relies on the framework's line mapping shipped in 0.8.0). Point VS Code at that environment with **Python: Select Interpreter** — the debugger checks it has pyxle before launching and guides you if not. +- **[pyxle-framework](https://pypi.org/project/pyxle-framework/) 0.8.0 or newer** in your project's Python environment (the launch model runs `python -m pyxle dev`, and `.pyxl` debugging relies on the framework's line mapping shipped in 0.8.0). Both debug configurations use the interpreter VS Code has selected — the status-bar item shown while a `.pyxl` file is open tells you which one that is, and clicking it (or **Pyxle: Select Python Interpreter**) changes it. The debugger checks the interpreter can run the dev server before launching and, if it can't, offers to switch to one that can. - The **[Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)** (`ms-python.python`) for the Python side of debugging. The debugger offers to install it if it's missing, and still debugs the React side without it. - The language server: `pip install pyxle-langkit` (bundled as a default dependency of `pyxle-framework`, so a normal Pyxle project already has it). diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 0a4c069..b6ba191 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -3,7 +3,7 @@ "displayName": "Pyxle Language Tools", "publisher": "pyxle", "version": "0.3.0", - "description": "Language support and debugging for Pyxle .pyxl files — syntax highlighting, diagnostics, completions, hover, go-to-definition, formatting, and full-stack breakpoint debugging.", + "description": "Language support and debugging for Pyxle .pyxl files \u2014 syntax highlighting, diagnostics, completions, hover, go-to-definition, formatting, and full-stack breakpoint debugging.", "license": "MIT", "repository": { "type": "git", @@ -31,8 +31,7 @@ ], "activationEvents": [ "onLanguage:pyxle", - "onDebugResolve:pyxle", - "onCommand:pyxle.openStudio" + "onDebugResolve:pyxle" ], "main": "./out/extension.js", "scripts": { @@ -53,12 +52,17 @@ "commands": [ { "command": "pyxle.openStudio", - "title": "Pyxle: Open Studio", + "title": "Open Studio", "category": "Pyxle" }, { "command": "pyxle.showInstallGuide", - "title": "Pyxle: Show Language Server Install Guide", + "title": "Show Language Server Install Guide", + "category": "Pyxle" + }, + { + "command": "pyxle.selectPythonInterpreter", + "title": "Select Python Interpreter", "category": "Pyxle" } ], @@ -79,12 +83,12 @@ }, "server": { "type": "boolean", - "description": "Debug the Python side — run the dev server under debugpy so breakpoints in @server loaders and @action handlers bind. Set false to debug only the React/JSX side in a standalone Chrome session.", + "description": "Debug the Python side \u2014 run the dev server under debugpy so breakpoints in @server loaders and @action handlers bind. Set false to debug only the React/JSX side in a standalone Chrome session.", "default": true }, "browser": { "type": "boolean", - "description": "When debugging the Python side, open the app in your browser once the dev server is ready. (React/JSX debugging is a separate launch — set \"server\": false.)", + "description": "When debugging the Python side, open the app in your browser once the dev server is ready. (React/JSX debugging is a separate launch \u2014 set \"server\": false.)", "default": true }, "url": { @@ -94,7 +98,9 @@ "args": { "type": "array", "description": "Extra arguments passed to `pyxle dev` (e.g. [\"--port\", \"3000\"]).", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "default": [] }, "justMyCode": { @@ -148,7 +154,7 @@ "configurationSnippets": [ { "label": "Pyxle: Debug app (Python)", - "description": "Run the dev server under the debugger — breakpoints in .pyxl @server loaders and @action handlers bind. Opens the app in your browser. One clean debug session.", + "description": "Run the dev server under the debugger \u2014 breakpoints in .pyxl @server loaders and @action handlers bind. Opens the app in your browser. One clean debug session.", "body": { "type": "pyxle", "request": "launch", @@ -157,7 +163,7 @@ }, { "label": "Pyxle: Debug React (browser)", - "description": "Debug the React/JSX half in a standalone Chrome session against the running dev server — breakpoints in .pyxl components bind.", + "description": "Debug the React/JSX half in a standalone Chrome session against the running dev server \u2014 breakpoints in .pyxl components bind.", "body": { "type": "pyxle", "request": "launch", diff --git a/editors/vscode/src/debug.ts b/editors/vscode/src/debug.ts index 3581ac2..12fcbf7 100644 --- a/editors/vscode/src/debug.ts +++ b/editors/vscode/src/debug.ts @@ -30,7 +30,6 @@ */ import * as vscode from "vscode"; -import * as cp from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -40,8 +39,14 @@ import { readDiscovery, sleep, } from "./discovery"; +import { probePyxleInterpreter, reportedAtLeast } from "./interpreter"; +import { + PYTHON_EXTENSION_ID, + pickInterpreter, + selectedInterpreterPath as selectedInterpreter, +} from "./python"; +import { DevServerPty, devServerSpec, startDevServerTerminal } from "./devServer"; -const PYTHON_EXTENSION_ID = "ms-python.python"; // The `debugpy` debug adapter is contributed by a SEPARATE extension from the // language features. It activates lazily (e.g. on opening a .py file), so a // launch fired before it wakes up fails with "Couldn't find a debug adapter @@ -76,20 +81,22 @@ const BROWSER_MARKER = "__pyxleBrowser"; */ const SERVER_OWNER_MARKER = "__pyxleServerOwner"; -/** The Ctrl-C (ETX) byte; `pyxle dev` handles SIGINT with a clean shutdown. */ -const CTRL_C = String.fromCharCode(3); - interface OwnedServer { - /** The "Pyxle Dev" terminal running `pyxle dev`. */ + /** The "Pyxle Dev" terminal presenting the dev server. */ terminal: vscode.Terminal; + /** + * The pty that owns the `pyxle dev` process. We spawn it ourselves rather + * than typing into a shell, so liveness and shutdown are exact — see + * `devServer.ts` for why a shell terminal is unsafe here. + */ + pty: DevServerPty; projectRoot: string; /** Live React debug sessions using this server; it stops when this empties. */ refs: Set; /** * The discovery `startedAt` of the server this terminal actually brought up, - * recorded once it's live. Used to tell whether the currently-live server is - * really ours: a bare "terminal shell still open" check would misfire if our - * `pyxle dev` crashed to a prompt and a different server later took the port. + * recorded once it's live. Belt-and-braces against a *foreign* server having + * taken the port: our own process liveness is already authoritative. */ startedAt?: number; } @@ -116,8 +123,6 @@ let ownerSeq = 0; * prompt (whose Stop click could otherwise hit the server the restart needs). */ const SERVER_RESTART_GUARD_MS = 4000; -/** Grace after Ctrl-C for `pyxle dev` to shut down before closing its terminal. */ -const SERVER_SHUTDOWN_GRACE_MS = 2500; /** * The owner id of the server THIS extension started that matches the currently @@ -136,7 +141,7 @@ function liveOwnerForRoot( for (const [ownerId, server] of ownedServers) { if ( server.projectRoot === projectRoot && - server.terminal.exitStatus === undefined && + server.pty.running && server.startedAt === liveStartedAt ) { return ownerId; @@ -154,24 +159,13 @@ function cancelServerStop(ownerId: string): void { } } -/** Ctrl-C the server's terminal (clean `pyxle dev` shutdown), then close it. */ -function stopOwnedServerTerminal(server: OwnedServer): void { - const { terminal } = server; - if (terminal.exitStatus !== undefined) { - return; // already exited - } - try { - terminal.sendText(CTRL_C); - } catch { - return; // terminal disposed between the check and the send - } - setTimeout(() => { - try { - terminal.dispose(); - } catch { - /* already gone */ - } - }, SERVER_SHUTDOWN_GRACE_MS); +/** + * Stop the dev server we started: SIGINT to its process group (a clean + * `pyxle dev` teardown that also takes Vite and the SSR workers down), with a + * SIGKILL backstop, then the panel closes with the process. + */ +function stopOwnedServer(server: OwnedServer): void { + server.pty.stop(); } /** @@ -210,7 +204,7 @@ async function promptStopOwnedServer(ownerId: string): Promise { return; } ownedServers.delete(ownerId); - stopOwnedServerTerminal(server); + stopOwnedServer(server); } interface BrowserRequest { @@ -319,86 +313,21 @@ async function ensurePythonExtension(): Promise<"ok" | "react-only" | "abort"> { /* ------------------------------------------------------------------ */ /** - * The interpreter path the Python extension has selected for `folder`, via its - * stable Environments API. Returns undefined if the API isn't available (older - * Python extension) — the caller then lets debugpy fall back to its default. + * How many times a failed interpreter check may be retried after the user picks + * a different interpreter, before we stop re-prompting. */ -async function selectedInterpreter( - folder: vscode.WorkspaceFolder | undefined, -): Promise { - try { - const ext = vscode.extensions.getExtension(PYTHON_EXTENSION_ID); - if (!ext) { - return undefined; - } - const api = ext.isActive ? ext.exports : await ext.activate(); - const envs = api?.environments; - const envPath = envs?.getActiveEnvironmentPath?.(folder?.uri); - if (!envPath) { - return undefined; - } - const resolved = await envs.resolveEnvironment?.(envPath); - return resolved?.executable?.uri?.fsPath ?? envPath.path; - } catch { - return undefined; - } -} - -/** What a candidate interpreter can do, from one short probe. */ -type PyxleProbe = - /** Has pyxle AND `python -m pyxle` works — good to launch. */ - | "ok" - /** pyxle imports, but `pyxle.__main__` is missing (pyxle-framework < 0.8.0). */ - | "too-old" - /** pyxle is not installed in this interpreter at all. */ - | "missing"; +const MAX_INTERPRETER_RETRIES = 2; /** - * Probe what `python` can actually do (short, timeout-guarded). + * The `pyxle dev ...` argv both launch paths use. * - * Checks the CAPABILITY the launch needs, not just that pyxle is present: the - * debug launch runs `python -m pyxle dev`, which requires `pyxle.__main__` — - * added in pyxle-framework 0.8.0. Probing only `import pyxle` would pass on an - * 0.7.x install and then die with a cryptic "No module named pyxle.__main__". + * Shared so the Python-debug and React-browser flows can never drift apart, and + * forgives a stray leading "dev" in user args (e.g. copied from a shell + * command) so we never emit `pyxle dev dev`. */ -function probePyxleInterpreter(python: string): Promise { - return new Promise((resolve) => { - let settled = false; - let timer: ReturnType | undefined; - const finish = (result: PyxleProbe, proc?: cp.ChildProcess): void => { - if (settled) return; - settled = true; - if (timer) { - clearTimeout(timer); - } - if (proc) { - try { - proc.kill(); - } catch { - /* ignore */ - } - } - resolve(result); - }; - try { - // Exit 0 = ok, 3 = pyxle present but no `__main__` (pre-0.8.0), - // 4 = pyxle absent. Any other code (e.g. a broken install whose - // import raises) is treated as absent — same user action either way. - // find_spec locates `__main__` without executing it. - const script = - "import importlib.util as u, sys; " + - "sys.exit(4 if u.find_spec('pyxle') is None " + - "else (0 if u.find_spec('pyxle.__main__') else 3))"; - const proc = cp.spawn(python, ["-c", script], { stdio: "ignore" }); - proc.once("exit", (code) => - finish(code === 0 ? "ok" : code === 3 ? "too-old" : "missing"), - ); - proc.once("error", () => finish("missing")); - timer = setTimeout(() => finish("missing", proc), 6000); - } catch { - finish("missing"); - } - }); +export function devServerArgs(userArgs: readonly string[] | undefined): string[] { + const args = userArgs ?? []; + return args[0] === "dev" ? [...args] : ["dev", ...args]; } /** @@ -408,50 +337,108 @@ function probePyxleInterpreter(python: string): Promise { * selected interpreter. A very common setup has pyxle installed in one * environment while VS Code has a different interpreter selected — which fails * with a cryptic "No module named pyxle". This resolves the selected - * interpreter and, when it lacks pyxle, guides the user to fix it rather than - * launching a doomed session. + * interpreter and, when it can't launch, guides the user to fix it rather than + * starting a doomed session. + * + * Both failure modes lead with **Select Interpreter**, because the usual cause + * is that the right pyxle lives in a *different* environment — telling the user + * to upgrade the one VS Code happens to have selected is, in that case, wrong + * advice and a dead end. When they pick a new interpreter we re-check and carry + * on, so a fixable mistake doesn't cost another F5. * * Returns the interpreter path to pin on the debug config (so debugpy uses * exactly the one we checked), `undefined` to proceed with debugpy's default - * (interpreter couldn't be determined — an older Python extension), or `false` - * to abort (the interpreter is known and lacks pyxle; the user was prompted). + * (interpreter couldn't be determined — no/old Python extension), or `false` + * to abort (the interpreter is known and can't launch; the user was prompted). */ async function ensurePyxleInterpreter( folder: vscode.WorkspaceFolder | undefined, + projectRoot: string, + attempt = 0, ): Promise { const python = await selectedInterpreter(folder); if (!python) { return undefined; // can't determine — let debugpy use its default } - const probe = await probePyxleInterpreter(python); - if (probe === "ok") { + // Probe in the project root so imports resolve the way the launch will. + const probe = await probePyxleInterpreter(python, projectRoot); + if (probe.status === "ok") { return python; // pin it, so what we checked is what launches } - if (probe === "too-old") { - // pyxle is installed but predates `python -m pyxle` (added in 0.8.0). - // Launching would fail with a cryptic "No module named pyxle.__main__"; - // name the real fix instead. - const choice = await vscode.window.showErrorMessage( - "Pyxle: debugging needs pyxle-framework 0.8.0 or newer — the version in " + - `the selected interpreter (${python}) is older and can't be launched ` + - "as `python -m pyxle`. Upgrade with `pip install --upgrade pyxle-framework`.", - "Copy upgrade command", - ); - if (choice === "Copy upgrade command") { - await vscode.env.clipboard.writeText( - "pip install --upgrade pyxle-framework", - ); - } - return false; + if (probe.status === "unknown") { + // The probe couldn't answer (the interpreter crashed, timed out, or + // wouldn't spawn). Never block on that: launch anyway and let debugpy + // report the real failure, exactly as it did before this check existed. + return python; } - const choice = await vscode.window.showErrorMessage( - `Pyxle: the selected Python interpreter (${python}) doesn't have pyxle installed, ` + + + // The version is ADVISORY. An editable install carries the dist-info from + // `pip install -e` time, so a 0.8.0 checkout can still report 0.7.5 — the + // capability probe above stays authoritative, and the number is only shown + // (labelled as metadata) and used to pick the right remediation. + const reported = probe.reportedVersion + ? ` It reports pyxle-framework ${probe.reportedVersion} (package metadata, which can be stale in an editable install).` + : ""; + let message: string; + let copyLabel: string; + let copyCommand: string; + if (probe.status === "too-old") { + // Metadata already claims >= 0.8.0 but `pyxle.__main__` is missing, so + // this is a broken/partial install, not an old one — "upgrade" would be + // a no-op. Repair it instead. + const looksIncomplete = reportedAtLeast(probe.reportedVersion, [0, 8, 0]); + message = looksIncomplete + ? `Pyxle: the selected Python interpreter (${python}) has a pyxle install that can't be ` + + `launched as \`python -m pyxle\` — it looks incomplete.${reported} ` + + "Reinstall it, or switch to the environment where a working pyxle is installed." + : "Pyxle: debugging needs pyxle-framework 0.8.0 or newer, and the selected interpreter " + + `(${python}) has an older one.${reported} ` + + "If pyxle 0.8.0+ is installed in a different environment, switch to it — " + + "otherwise upgrade this one."; + copyLabel = looksIncomplete ? "Copy repair command" : "Copy upgrade command"; + copyCommand = looksIncomplete + ? "pip install --force-reinstall pyxle-framework" + : "pip install --upgrade pyxle-framework"; + } else { + message = + `Pyxle: the selected Python interpreter (${python}) doesn't have pyxle installed, ` + "so debugging can't launch the dev server. Select the environment where pyxle " + - "is installed — the one your terminal's `pyxle` command uses.", + "is installed — the one your terminal's `pyxle` command uses."; + copyLabel = "Copy install command"; + copyCommand = "pip install pyxle-framework"; + } + + const choice = await vscode.window.showErrorMessage( + message, "Select Interpreter", + copyLabel, ); - if (choice === "Select Interpreter") { - await vscode.commands.executeCommand("python.setInterpreter"); + if (choice === copyLabel) { + await vscode.env.clipboard.writeText(copyCommand); + return false; + } + if (choice !== "Select Interpreter") { + return false; // dismissed + } + // Always open the picker when that button is clicked. The retry budget + // bounds how often *we* re-prompt; it must never turn the button into a + // silent no-op. + const switched = await pickInterpreter(folder); + if (switched && attempt < MAX_INTERPRETER_RETRIES) { + // A different interpreter — re-check from the top, so a still-bad one + // gets the same guidance instead of failing mid-launch. + return ensurePyxleInterpreter(folder, projectRoot, attempt + 1); + } + // Dismissed, re-picked the same one, or the budget is spent. Probe once + // more anyway — the user may have fixed *this* environment (a pip install + // in another window) rather than switching away from it — then give up + // quietly rather than re-opening the same dialog. + const current = await selectedInterpreter(folder); + if (current) { + const recheck = await probePyxleInterpreter(current, projectRoot); + if (recheck.status === "ok" || recheck.status === "unknown") { + return current; + } } return false; } @@ -567,14 +554,19 @@ export class PyxleDebugConfigurationProvider // Only an explicit "Debug React only" choice falls back to the // browser flow; an install/dismiss must start nothing. if (gate === "react-only" && browserRequest) { - void this.launchBrowserOnly(folder, projectRoot, browserRequest); + void this.launchBrowserOnly( + folder, + projectRoot, + browserRequest, + devServerArgs(pyxle.args), + ); } return undefined; } // The launch model runs `python -m pyxle dev` under the selected // interpreter — verify that interpreter actually has pyxle, or the // session dies with a cryptic "No module named pyxle". - const interpreter = await ensurePyxleInterpreter(folder); + const interpreter = await ensurePyxleInterpreter(folder, projectRoot); if (interpreter === false) { return undefined; // wrong interpreter — the user was guided to fix it } @@ -585,11 +577,7 @@ export class PyxleDebugConfigurationProvider if (browserRequest) { browserRequest.since = readDiscovery(projectRoot)?.startedAt; } - // Forgive a stray leading "dev" in user args (e.g. copied from a - // shell command) so we never emit `pyxle dev dev`. - const userArgs = pyxle.args ?? []; - const args = - userArgs[0] === "dev" ? [...userArgs] : ["dev", ...userArgs]; + const args = devServerArgs(pyxle.args); // Hand VS Code a debugpy launch of `python -m pyxle dev`. VS Code // owns the process → a real Stop button that tears the dev server // (Vite, SSR workers) down. Breakpoints in .pyxl bind because the @@ -620,7 +608,12 @@ export class PyxleDebugConfigurationProvider // Server debugging off, browser on: no python session at all. if (browserRequest) { - void this.launchBrowserOnly(folder, projectRoot, browserRequest); + void this.launchBrowserOnly( + folder, + projectRoot, + browserRequest, + devServerArgs(pyxle.args), + ); } return undefined; } @@ -683,6 +676,7 @@ export class PyxleDebugConfigurationProvider folder: vscode.WorkspaceFolder | undefined, projectRoot: string, browserRequest: BrowserRequest, + devArgs: string[], ): Promise { // One launch per project at a time. Re-clicking "Debug frontend" before // the dev server is up must not spawn a second server or a second @@ -696,6 +690,9 @@ export class PyxleDebugConfigurationProvider // resolution that spawned it (that call returns immediately), so it must // not ride the resolver's cancellation token. const source = new vscode.CancellationTokenSource(); + // Set when we start the server ourselves: cancels the readiness wait if + // that server exits before it ever becomes discoverable. + let exitedEarly: vscode.Disposable | undefined; try { // Start a dev server only if there's no LIVE one. A leftover // discovery file from a crashed/killed server (its process gone, @@ -707,19 +704,55 @@ export class PyxleDebugConfigurationProvider let ownerId: string | undefined; let createdServer = false; if (!existing || !(await discoveryIsLive(existing))) { - const terminal = vscode.window.createTerminal({ - name: "Pyxle Dev", - cwd: projectRoot, - }); - terminal.show(true); - terminal.sendText("pyxle dev"); - ownerId = `${process.pid}-${(ownerSeq += 1)}`; - ownedServers.set(ownerId, { - terminal, + // Validate the interpreter for THIS flow too. Previously it just + // typed `pyxle dev` into a shell, so it ran whatever `pyxle` was + // first on PATH — which is routinely a different environment + // than the one the Python-debug flow verifies and launches. + // `undefined` means the interpreter couldn't be determined (no + // Python extension); `false` means it can't launch and the user + // was already guided, so start nothing. + const interpreter = await ensurePyxleInterpreter( + folder, projectRoot, - refs: new Set(), - }); - createdServer = true; + ); + if (interpreter === false) { + return; + } + // The gate above can block for an unbounded time — a probe, an + // error dialog, then the interpreter quick pick. A server may + // well have come up meanwhile (the user starting Backend, or + // `pyxle dev` in a terminal), so re-check before spawning a + // second one onto a port that is now taken. + const nowLive = readDiscovery(projectRoot); + if (nowLive && (await discoveryIsLive(nowLive))) { + ownerId = liveOwnerForRoot(projectRoot, nowLive.startedAt); + if (ownerId) { + cancelServerStop(ownerId); + } + } else { + // Own the process rather than typing into a shell: a shell + // terminal is writable by any extension, and the Python + // extension's environment activation interrupts (^C) whatever + // is running in it — killing the server seconds after it starts. + const { terminal, pty } = startDevServerTerminal( + devServerSpec(interpreter, projectRoot, devArgs), + ); + terminal.show(true); + ownerId = `${process.pid}-${(ownerSeq += 1)}`; + ownedServers.set(ownerId, { + terminal, + pty, + projectRoot, + refs: new Set(), + }); + createdServer = true; + // If the server dies before discovery appears — a broken + // interpreter the probe could not rule out, a port clash — + // stop waiting immediately. Without this the launch sits on + // the 120s timeout with `browserLaunchInFlight` held, so + // every further F5 is silently ignored for two minutes. + exitedEarly = pty.onDidExit(() => source.cancel()); + } } else { // A live server exists. If WE own the one that's actually serving // (matched by its discovery startedAt, not just an open terminal), @@ -757,11 +790,14 @@ export class PyxleDebugConfigurationProvider } if (!url) { void vscode.window.showErrorMessage( - "Pyxle: the dev server didn't come up, so the React debugger couldn't attach. Start it with `pyxle dev` and try again.", + createdServer && source.token.isCancellationRequested + ? "Pyxle: the dev server exited before it was ready — see the Pyxle Dev panel for the error." + : "Pyxle: the dev server didn't come up, so the React debugger couldn't attach. Start it with `pyxle dev` and try again.", ); } } } finally { + exitedEarly?.dispose(); browserLaunchInFlight.delete(projectRoot); source.dispose(); } @@ -834,6 +870,17 @@ export function registerDebugSupport(context: vscode.ExtensionContext): void { const provider = new PyxleDebugConfigurationProvider(); context.subscriptions.push( + // Dev servers we started run in their own process group, so nothing + // reaps them if the extension goes away. Take them down with us — + // otherwise closing the window leaves `pyxle dev` and Vite holding + // their ports with no terminal left to stop them from. + { + dispose: () => { + for (const server of ownedServers.values()) { + server.pty.dispose(); + } + }, + }, // Default trigger powers launch.json resolution AND the resolve chain. vscode.debug.registerDebugConfigurationProvider("pyxle", provider), // The Dynamic trigger makes "Debug Pyxle app" appear in the Run-and-Debug diff --git a/editors/vscode/src/devServer.ts b/editors/vscode/src/devServer.ts new file mode 100644 index 0000000..2626e31 --- /dev/null +++ b/editors/vscode/src/devServer.ts @@ -0,0 +1,289 @@ +/** + * The "Pyxle Dev" terminal — an extension-owned dev server. + * + * The React-browser flow needs a running `pyxle dev` but no Python debugger. + * It used to get one by opening a shell terminal and typing `pyxle dev` into + * it. That is unsafe: a shell terminal belongs to whoever wants to write to it, + * and the Python extension auto-activates the selected environment in every new + * shell terminal. Modern activation goes through + * `TerminalShellIntegration.executeCommand()`, which is *documented* to send + * `^C` first to interrupt whatever is running — so the freshly started dev + * server was killed moments after coming up, then the activation line + * (`pyenv shell 3.14.4`, `source .venv/bin/activate`, ...) was typed in its + * place. + * + * So we own the process instead. `createTerminal({ pty })` spawns no shell at + * all: `terminal.shellIntegration` is never populated, so `executeCommand` — + * the only thing that sends `^C` — cannot be called on it, and a foreign + * `Terminal.sendText()` is delivered to our `handleInput`, where we drop it. + * The injection becomes inert rather than merely unlikely. + * + * Owning the process also buys precise shutdown: SIGINT to the process *group* + * is what a real Ctrl-C does, and it is what tears the dev server's children + * (Vite, esbuild, the SSR workers) down cleanly. + */ + +import * as vscode from "vscode"; +import * as cp from "child_process"; + +/** The Ctrl-C (ETX) byte — what a real keystroke sends. */ +const ETX = ""; + +/** Grace between SIGINT and the SIGKILL that guarantees the process is gone. */ +const SHUTDOWN_GRACE_MS = 2500; + +/** How the dev server should be started. */ +export interface DevServerSpec { + /** Executable to run — a resolved interpreter, or `pyxle` as a fallback. */ + command: string; + /** Arguments, e.g. `["-m", "pyxle", "dev"]`. */ + args: string[]; + /** Project root; the server is started here. */ + cwd: string; +} + +/** + * A `pyxle dev` process rendered into a VS Code terminal. + * + * Implements {@link vscode.Pseudoterminal}: VS Code calls `open`/`close`, and + * everything the child writes is echoed through `onDidWrite`. + */ +export class DevServerPty implements vscode.Pseudoterminal { + private readonly writeEmitter = new vscode.EventEmitter(); + private readonly closeEmitter = new vscode.EventEmitter(); + private readonly exitEmitter = new vscode.EventEmitter(); + + readonly onDidWrite = this.writeEmitter.event; + readonly onDidClose = this.closeEmitter.event; + /** Fires when the child exits, however it exited. */ + readonly onDidExit = this.exitEmitter.event; + + private child: cp.ChildProcess | undefined; + private exited = false; + private stopping = false; + private killTimer: ReturnType | undefined; + + constructor(private readonly spec: DevServerSpec) {} + + /** Whether the dev server process is still alive. */ + get running(): boolean { + return this.child !== undefined && !this.exited; + } + + /** VS Code opens the terminal — start the server. */ + open(): void { + const { command, args, cwd } = this.spec; + this.writeEmitter.fire( + `\x1b[2m${command} ${args.join(" ")}\x1b[0m\r\n\r\n`, + ); + let child: cp.ChildProcess; + try { + child = cp.spawn(command, args, { + cwd, + // A process group of its own, so one SIGINT reaches the whole + // tree (Vite, esbuild, SSR workers) exactly like a real Ctrl-C. + // Not on Windows, which has no process groups to signal — there + // the tree is torn down with taskkill (see `terminate`). + detached: process.platform !== "win32", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // No tty means block buffering: without this the dev + // server's output arrives in multi-KB bursts and looks + // like a hang. + PYTHONUNBUFFERED: "1", + // ...and no tty also makes most CLIs strip colour. + FORCE_COLOR: "1", + PY_COLORS: "1", + }, + }); + } catch (error) { + this.writeEmitter.fire( + `\r\n\x1b[31mFailed to start the dev server: ${String(error)}\x1b[0m\r\n`, + ); + this.exited = true; + this.exitEmitter.fire(); + return; + } + this.child = child; + const pump = (chunk: Buffer): void => { + // A raw pty performs no newline translation: without this the + // output stair-steps down the terminal. + this.writeEmitter.fire(chunk.toString().replace(/\r?\n/g, "\r\n")); + }; + child.stdout?.on("data", pump); + child.stderr?.on("data", pump); + child.once("error", (error) => { + this.writeEmitter.fire(`\r\n\x1b[31m${String(error)}\x1b[0m\r\n`); + }); + // Settle on "close", not "exit" — and be idempotent. + // + // "close" fires once the piped stdio has drained, so the traceback of a + // server that died on startup is already on the panel when we print the + // "stopped" line, and a deliberate stop doesn't close the terminal on + // top of output still in flight. "exit" fires first, before the drain. + // + // "close" also covers the case that motivated listening to both: a + // process that never started (ENOENT, EACCES) emits "error" then + // "close" and never "exit", so liveness still settles for a command + // that does not exist — verified against Node directly. + const settleExit = (code: number | null, signal: string | null): void => { + if (this.exited) { + return; + } + this.exited = true; + if (this.killTimer) { + clearTimeout(this.killTimer); + this.killTimer = undefined; + } + this.exitEmitter.fire(); + if (this.stopping) { + // A stop we asked for: close the panel with the process. + this.closeEmitter.fire(code ?? 0); + return; + } + // An exit we did NOT ask for is usually a crash or a bad command. + // Leave the panel open so the traceback stays readable. + const how = signal ? `signal ${signal}` : `exit code ${code ?? 0}`; + this.writeEmitter.fire( + `\r\n\x1b[2mThe dev server stopped (${how}).\x1b[0m\r\n`, + ); + }; + child.once("close", settleExit); + } + + /** VS Code closes the terminal (user hit the trash can) — stop the server. */ + close(): void { + this.terminate(); + } + + /** + * Keystrokes — and anything another extension writes with + * `Terminal.sendText()`, which VS Code routes here for a pty terminal. + * + * Only Ctrl-C is honoured. Everything else is dropped on purpose: at this + * API a real keystroke and a foreign `sendText` are indistinguishable, so + * forwarding input would reintroduce exactly the injection this class + * exists to prevent. + */ + handleInput(data: string): void { + if (data.includes(ETX)) { + this.stop(); + } + } + + /** Ask the dev server to shut down cleanly, then guarantee it is gone. */ + stop(): void { + if (!this.running || this.stopping) { + return; + } + this.stopping = true; + this.writeEmitter.fire("\r\n\x1b[2mStopping the dev server...\x1b[0m\r\n"); + this.terminate(); + } + + /** + * Extension shutdown — take the server down with us. + * + * The child runs in its own process group so one SIGINT reaches its whole + * tree, but that also means nothing reaps it if we go away: on POSIX a + * parent's death never kills its children. Without this, a window close or + * an extension-host crash would leave `pyxle dev` (and Vite) running and + * holding their ports, with no terminal left to stop them from. + */ + dispose(): void { + this.terminate(); + } + + /** + * Signal the process tree: SIGINT for a clean `pyxle dev` teardown, with a + * SIGKILL backstop. Windows has no SIGINT delivery to another process, so + * the tree is taken down with `taskkill /T /F`. + */ + private terminate(): void { + const child = this.child; + if (!child || this.exited || child.pid === undefined) { + return; + } + this.stopping = true; + if (process.platform === "win32") { + // `cp.spawn` reports a missing/blocked taskkill ASYNCHRONOUSLY as an + // "error" event — a try/catch here would never see it, and an + // unhandled "error" on a ChildProcess throws. Listen for it and fall + // back immediately rather than waiting out the SIGKILL timer, which + // would only reach the leader and strand Vite holding its port. + const killer = cp.spawn( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { windowsHide: true, stdio: "ignore" }, + ); + killer.on("error", () => { + try { + child.kill(); + } catch { + /* already gone */ + } + }); + } else { + // Negative pid = the whole process group (we spawned detached). + try { + process.kill(-child.pid, "SIGINT"); + } catch { + try { + child.kill("SIGINT"); + } catch { + /* already gone */ + } + } + } + this.killTimer = setTimeout(() => { + if (this.exited || child.pid === undefined) { + return; + } + try { + if (process.platform !== "win32") { + process.kill(-child.pid, "SIGKILL"); + } else { + child.kill(); + } + } catch { + /* already gone */ + } + }, SHUTDOWN_GRACE_MS); + } +} + +/** + * Open a "Pyxle Dev" terminal running the dev server. + * + * Returns the terminal and its pty so callers can watch liveness and stop it. + */ +export function startDevServerTerminal(spec: DevServerSpec): { + terminal: vscode.Terminal; + pty: DevServerPty; +} { + const pty = new DevServerPty(spec); + const terminal = vscode.window.createTerminal({ name: "Pyxle Dev", pty }); + return { terminal, pty }; +} + +/** + * Build the argv that starts the dev server. + * + * Prefers ` -m pyxle dev` so the React-browser flow runs the SAME + * environment the Python-debug flow verified and launched — the two used to + * disagree whenever the shell's `pyxle` came from a different environment than + * the interpreter VS Code had selected. Falls back to a bare `pyxle` only when + * no interpreter could be resolved (no Python extension installed). + */ +export function devServerSpec( + interpreter: string | undefined, + projectRoot: string, + devArgs: readonly string[], +): DevServerSpec { + const args = devArgs.length > 0 ? [...devArgs] : ["dev"]; + return interpreter + ? { command: interpreter, args: ["-m", "pyxle", ...args], cwd: projectRoot } + : { command: "pyxle", args, cwd: projectRoot }; +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index d4602bc..bd185cc 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -27,8 +27,14 @@ import { LanguageClientOptions, ServerOptions, } from "vscode-languageclient/node"; -import { createStatusBar, updateStatus, StatusState } from "./status"; +import { + createStatusBar, + updateStatus, + StatusState, + registerInterpreterStatus, +} from "./status"; import { registerDebugSupport } from "./debug"; +import { showInterpreterPicker } from "./python"; const LANGUAGE_ID = "pyxle"; const MAX_RETRIES = 3; @@ -66,6 +72,17 @@ export function activate(context: vscode.ExtensionContext): void { }), ); + // Registered BEFORE the status item, whose `command` points at it — a + // StatusBarItem referencing an unregistered command silently no-ops. + context.subscriptions.push( + vscode.commands.registerCommand("pyxle.selectPythonInterpreter", () => + showInterpreterPicker(), + ), + ); + // The Python extension only shows its interpreter item for Python files, so + // surface one for .pyxl — debugging runs `python -m pyxle dev` under it. + registerInterpreterStatus(context, LANGUAGE_ID); + registerDebugSupport(context); resolveAndStart(context); diff --git a/editors/vscode/src/interpreter.ts b/editors/vscode/src/interpreter.ts new file mode 100644 index 0000000..f7e9fb3 --- /dev/null +++ b/editors/vscode/src/interpreter.ts @@ -0,0 +1,224 @@ +/** + * Interpreter probing and labelling — pure helpers, no `vscode` import. + * + * Like `discovery.ts`, this module stays VS Code-free so it can be unit tested + * without stubbing the editor API. The `vscode` <-> ms-python bridge lives in + * `python.ts`; everything that only needs a Python executable path lives here. + */ + +import * as cp from "child_process"; +import * as path from "path"; + +/** What a candidate interpreter can do, from one short probe. */ +export type PyxleProbeStatus = + /** Has pyxle AND `python -m pyxle` works — good to launch. */ + | "ok" + /** pyxle imports, but `pyxle.__main__` is missing (pyxle-framework < 0.8.0). */ + | "too-old" + /** pyxle is definitively not installed in this interpreter. */ + | "missing" + /** + * The probe itself couldn't answer — the interpreter crashed, timed out, or + * wouldn't spawn. Callers must NOT block on this: before the probe existed + * the launch simply went ahead, and debugpy's own error is more useful than + * a guess. Blocking here would turn "your sitecustomize raises" into the + * wrong message ("pyxle is not installed") and an unfixable dialog. + */ + | "unknown"; + +export interface PyxleProbeResult { + /** Authoritative verdict — derived from `find_spec`, never from a version. */ + status: PyxleProbeStatus; + /** + * The version `importlib.metadata` reports for the distribution, if any. + * + * ADVISORY ONLY. An editable/dev install carries the dist-info recorded at + * `pip install -e` time, so a checkout running 0.8.0 code can still report + * 0.7.5. It is shown to the user (labelled as package metadata) and used to + * pick the right remediation wording — never to decide whether to launch. + */ + reportedVersion?: string; +} + +/** + * Sentinel prefix around the version line. + * + * A `sitecustomize`, a conda banner, or a noisy `.pth` file can write to stdout + * before our script runs, so the version is matched by marker rather than by + * trusting the whole stream. + */ +const VERSION_MARKER = "PYXLE_DIST_VERSION:"; + +/** How long to wait for the probe before treating the interpreter as unusable. */ +const PROBE_TIMEOUT_MS = 6000; + +/** + * The probe script. + * + * `find_spec` locates modules *without importing* them and `importlib.metadata` + * reads dist-info without importing pyxle, so this stays cheap and side-effect + * free even against a half-broken install. Exit codes: 0 = ok, 3 = pyxle + * present but no `__main__` (pre-0.8.0), 4 = pyxle absent. + */ +const PROBE_SCRIPT = [ + "import importlib.util as u, sys", + "if u.find_spec('pyxle') is None: sys.exit(4)", + "try:", + " from importlib.metadata import version, PackageNotFoundError", + " try: v = version('pyxle-framework')", + " except PackageNotFoundError: v = version('pyxle')", + ` sys.stdout.write('${VERSION_MARKER}' + v + '\\n')`, + "except Exception: pass", + "sys.exit(0 if u.find_spec('pyxle.__main__') else 3)", +].join("\n"); + +/** + * Pull the marked version line out of probe stdout; ignore everything else. + * + * Scans from the end so the last marker wins, and only accepts something that + * actually looks like a version — a dialog must never interpolate stray prose. + */ +export function parseReportedVersion(stdout: string): string | undefined { + const lines = stdout.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line.startsWith(VERSION_MARKER)) { + continue; + } + const value = line.slice(VERSION_MARKER.length).trim(); + if (/^[0-9][A-Za-z0-9._+!-]{0,31}$/.test(value)) { + return value; + } + } + return undefined; +} + +/** + * Whether a reported version parses to at least *min*. + * + * Used ONLY to choose remediation wording (upgrade vs. repair) — never to gate + * a launch, because the reported version can be stale (see `reportedVersion`). + */ +export function reportedAtLeast( + reported: string | undefined, + min: readonly [number, number, number], +): boolean { + if (!reported) { + return false; + } + const parts = reported.split(".").map((piece) => parseInt(piece, 10)); + for (let i = 0; i < min.length; i += 1) { + const got = parts[i]; + if (!Number.isFinite(got)) { + return false; + } + if (got !== min[i]) { + return got > min[i]; + } + } + return true; +} + +/** + * A short, unambiguous label for an interpreter — e.g. `venv (3.12)`. + * + * Falls back through the environment folder name and the executable's own name + * so an unresolved environment still reads as something a human recognises. + */ +export function formatInterpreterLabel( + envName: string | undefined, + envFolderPath: string | undefined, + executablePath: string, + major?: number, + minor?: number, +): string { + const name = + envName || + (envFolderPath ? path.basename(envFolderPath) : undefined) || + path.basename(executablePath || "") || + "Python"; + if (typeof major !== "number" || typeof minor !== "number") { + return name; + } + // A pyenv version directory is already called "3.12.4" — don't render + // "3.12.4 (3.12)". + return name.startsWith(`${major}.${minor}`) ? name : `${name} (${major}.${minor})`; +} + +/** + * Probe what *python* can actually do (short, timeout-guarded). + * + * Checks the CAPABILITY the launch needs, not just that pyxle is present: the + * debug launch runs `python -m pyxle dev`, which requires `pyxle.__main__` — + * added in pyxle-framework 0.8.0. Probing only `import pyxle` would pass on an + * 0.7.x install and then die with a cryptic "No module named pyxle.__main__". + * + * Runs in *cwd* (the project root) so the probe resolves imports the same way + * the launch will — a project containing a local `pyxle/` directory would + * otherwise make the two disagree. + */ +export function probePyxleInterpreter( + python: string, + cwd?: string, +): Promise { + return new Promise((resolve) => { + let settled = false; + let stdout = ""; + let timer: ReturnType | undefined; + const finish = ( + status: PyxleProbeStatus, + proc?: cp.ChildProcess, + ): void => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + if (proc) { + try { + proc.kill(); + } catch { + /* ignore */ + } + } + resolve({ status, reportedVersion: parseReportedVersion(stdout) }); + }; + try { + const proc = cp.spawn(python, ["-c", PROBE_SCRIPT], { + cwd, + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }); + proc.stdout?.setEncoding("utf8"); + proc.stdout?.on("data", (chunk: string) => { + // Bounded: a runaway sitecustomize must not grow this forever. + if (stdout.length < 8192) { + stdout += chunk; + } + }); + // Settle on "close", not "exit": with stdout piped, "exit" can fire + // before the stream drains and the version would be lost. + // + // Only the exit codes the script itself produces are meaningful. + // Anything else means the interpreter never got far enough to + // answer — report "unknown" rather than asserting pyxle is absent. + proc.once("close", (code) => + finish( + code === 0 + ? "ok" + : code === 3 + ? "too-old" + : code === 4 + ? "missing" + : "unknown", + ), + ); + proc.once("error", () => finish("unknown")); + timer = setTimeout(() => finish("unknown", proc), PROBE_TIMEOUT_MS); + } catch { + finish("unknown"); + } + }); +} diff --git a/editors/vscode/src/python.ts b/editors/vscode/src/python.ts new file mode 100644 index 0000000..794c533 --- /dev/null +++ b/editors/vscode/src/python.ts @@ -0,0 +1,228 @@ +/** + * The bridge to the Python extension (`ms-python.python`). + * + * Every read of the selected interpreter goes through here so the debug launch, + * the status bar, and the palette command all agree on one code path — and so + * the defensive optional-chaining around another extension's API exists once + * rather than three times. + */ + +import * as vscode from "vscode"; + +export const PYTHON_EXTENSION_ID = "ms-python.python"; + +/** The interpreter the Python extension has selected, resolved for display. */ +export interface ActiveInterpreter { + /** Absolute path to the executable — what we probe and what we launch. */ + executable: string; + /** Environment name (`venv`, `3.12.4`, a conda env name), when known. */ + envName?: string; + /** The environment folder, used to derive a name when `envName` is absent. */ + envFolder?: string; + versionMajor?: number; + versionMinor?: number; + /** + * True when the Python extension has no real selection yet and is falling + * back to a bare `python` on PATH — worth surfacing, since it is the state + * most likely to launch something other than what the user expects. + */ + isDefault: boolean; +} + +/** + * The subset of the `ms-python.python` API surface we use, declared + * structurally. + * + * Depending on `@vscode/python-extension` for types would pull a dependency for + * shapes we consume defensively anyway; a missing field must degrade to a + * shorter label, never throw. + */ +interface PythonEnvironmentsApi { + getActiveEnvironmentPath?: (resource?: vscode.Uri) => { + id?: string; + path?: string; + }; + resolveEnvironment?: (env: unknown) => Promise< + | { + executable?: { uri?: vscode.Uri }; + environment?: { name?: string; folderUri?: vscode.Uri }; + version?: { major?: number; minor?: number }; + } + | undefined + >; + onDidChangeActiveEnvironmentPath?: vscode.Event; +} + +interface PythonApi { + environments?: PythonEnvironmentsApi; +} + +/** Activate `ms-python.python` and return its API, or `undefined` if absent. */ +async function pythonApi(): Promise { + try { + const ext = vscode.extensions.getExtension(PYTHON_EXTENSION_ID); + if (!ext) { + return undefined; + } + return (ext.isActive ? ext.exports : await ext.activate()) as PythonApi; + } catch { + return undefined; + } +} + +/** + * The interpreter path the Python extension has selected for *folder*. + * + * Returns `undefined` when the Python extension is missing or too old to expose + * the environments API — callers then fall back to debugpy's own default. + */ +export async function selectedInterpreterPath( + folder: vscode.WorkspaceFolder | undefined, +): Promise { + return (await activeInterpreter(folder))?.executable; +} + +/** The selected interpreter with the extra detail a label/tooltip needs. */ +export async function activeInterpreter( + folder: vscode.WorkspaceFolder | undefined, +): Promise { + try { + const envs = (await pythonApi())?.environments; + const envPath = envs?.getActiveEnvironmentPath?.(folder?.uri); + if (!envPath?.path) { + return undefined; + } + // "DEFAULT_PYTHON" is what the API reports when nothing has been chosen. + const isDefault = + envPath.id === "DEFAULT_PYTHON" || !isAbsolutePath(envPath.path); + const resolved = await envs?.resolveEnvironment?.(envPath); + return { + executable: resolved?.executable?.uri?.fsPath ?? envPath.path, + envName: resolved?.environment?.name, + envFolder: resolved?.environment?.folderUri?.fsPath, + versionMajor: resolved?.version?.major, + versionMinor: resolved?.version?.minor, + isDefault, + }; + } catch { + return undefined; + } +} + +/** Cheap absolute-path test that works for both POSIX and Windows shapes. */ +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); +} + +/** + * Subscribe to interpreter changes. + * + * Fires unconditionally rather than filtering on the event's resource: the + * active environment is per-resource, and the active editor may live in a + * different workspace folder than the one that changed. + */ +export async function onDidChangeActiveInterpreter( + listener: () => void, +): Promise { + try { + const envs = (await pythonApi())?.environments; + return envs?.onDidChangeActiveEnvironmentPath?.(() => listener()); + } catch { + return undefined; + } +} + +/** + * Open the Python extension's interpreter picker. + * + * Resolves to `true` once the picker has been shown. The selection itself is + * applied asynchronously by the Python extension — callers that need to react + * to the new value must subscribe to {@link onDidChangeActiveInterpreter} + * *before* calling this, or they race the write. + */ +export async function showInterpreterPicker(): Promise { + const ext = vscode.extensions.getExtension(PYTHON_EXTENSION_ID); + if (!ext) { + const choice = await vscode.window.showErrorMessage( + "Pyxle: selecting a Python interpreter needs the Python extension (ms-python.python).", + "Install Python extension", + ); + if (choice === "Install Python extension") { + await vscode.commands.executeCommand( + "workbench.extensions.installExtension", + PYTHON_EXTENSION_ID, + ); + } + return false; + } + if (!ext.isActive) { + try { + await ext.activate(); + } catch { + /* fall through — the command may still work */ + } + } + await vscode.commands.executeCommand("python.setInterpreter"); + return true; +} + +/** + * How long to wait, after the picker closes, for the Python extension to write + * the new selection. It applies the change asynchronously, so reading the + * active interpreter the instant the quick pick closes can race the write. + */ +const PICKER_SETTLE_MS = 2000; + +/** + * Show the interpreter picker and report whether the selection actually + * changed. + * + * Waits only until the picker closes plus a short settle window — never on the + * change event alone. A dismissed picker (Esc) and a re-pick of the *same* + * interpreter both fire no event, so waiting for one would hang a launch that + * `resolveDebugConfiguration` is blocking on, with no UI and no way to cancel. + * + * The return value says "the interpreter is different now", which callers use + * to decide whether re-prompting is worthwhile — it is never the only signal: + * they re-probe regardless, since the user may have fixed the environment + * rather than switched away from it. + */ +export async function pickInterpreter( + folder: vscode.WorkspaceFolder | undefined, +): Promise { + const before = await selectedInterpreterPath(folder); + let subscription: vscode.Disposable | undefined; + // Subscribe BEFORE opening the picker so a fast change can't be missed. + const changed = new Promise((resolve) => { + let done = false; + const settle = (): void => { + if (!done) { + done = true; + resolve(); + } + }; + void onDidChangeActiveInterpreter(settle).then((sub) => { + subscription = sub; + if (done) { + sub?.dispose(); + } + }); + }); + try { + if (!(await showInterpreterPicker())) { + return false; + } + // The command resolves when the quick pick closes. Give the write a + // moment to land, but never block on an event that may never come. + await Promise.race([ + changed, + new Promise((resolve) => + setTimeout(resolve, PICKER_SETTLE_MS), + ), + ]); + const after = await selectedInterpreterPath(folder); + return Boolean(after) && after !== before; + } finally { + subscription?.dispose(); + } +} diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 32e3e7f..95d8c90 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -1,8 +1,11 @@ /** - * Status bar management for the Pyxle Language Server. + * Status bar management — the language server's health, and the Python + * interpreter indicator for `.pyxl` files. */ import * as vscode from "vscode"; +import { activeInterpreter, onDidChangeActiveInterpreter } from "./python"; +import { formatInterpreterLabel } from "./interpreter"; export const enum StatusState { Starting, @@ -64,3 +67,91 @@ export function updateStatus( break; } } + +/* ------------------------------------------------------------------ */ +/* Python interpreter indicator */ +/* ------------------------------------------------------------------ */ + +/** + * Show which Python interpreter is selected while a `.pyxl` file is open. + * + * The Python extension scopes its own interpreter item to Python files, so in a + * `.pyxl` editor the user is blind: debugging launches `python -m pyxle dev` + * under an interpreter they can neither see nor change without first opening an + * unrelated `.py` file. This puts it back — in the same status-bar slot they + * already look for it — and one click opens the picker. + */ +export function registerInterpreterStatus( + context: vscode.ExtensionContext, + languageId: string, +): void { + const item = vscode.window.createStatusBarItem( + "pyxle.interpreter", + vscode.StatusBarAlignment.Right, + 100, + ); + item.name = "Pyxle Python Interpreter"; + item.command = "pyxle.selectPythonInterpreter"; + context.subscriptions.push(item); + + // Guards against a slow `resolveEnvironment` for a previous editor landing + // after a newer one and painting a stale label. + let seq = 0; + + const refresh = async (): Promise => { + const editor = vscode.window.activeTextEditor; + if (editor?.document.languageId !== languageId) { + item.hide(); + return; + } + const token = (seq += 1); + const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri); + const interpreter = await activeInterpreter(folder); + if (token !== seq) { + return; // superseded + } + if (!interpreter || interpreter.isDefault) { + item.text = "$(snake) Select Interpreter"; + item.tooltip = interpreter + ? "No Python interpreter selected for this workspace. Pyxle debugging runs `python -m pyxle dev`, so pick the environment where pyxle is installed." + : "Pyxle can't determine the Python interpreter (is the Python extension installed?). Click to choose one."; + item.backgroundColor = new vscode.ThemeColor( + "statusBarItem.warningBackground", + ); + item.show(); + return; + } + item.text = `$(snake) ${formatInterpreterLabel( + interpreter.envName, + interpreter.envFolder, + interpreter.executable, + interpreter.versionMajor, + interpreter.versionMinor, + )}`; + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown("**Pyxle: Python interpreter**\n\n"); + tooltip.appendCodeblock(interpreter.executable, "text"); + tooltip.appendMarkdown( + "\nPyxle debugging runs `python -m pyxle dev` with this interpreter.\n\nClick to select a different one.", + ); + item.tooltip = tooltip; + item.backgroundColor = undefined; + item.show(); + }; + + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(() => void refresh()), + ); + void onDidChangeActiveInterpreter(() => void refresh()).then((sub) => { + if (sub) { + context.subscriptions.push(sub); + } + }); + // The Python extension may be installed *after* we activate. + context.subscriptions.push( + vscode.extensions.onDidChange(() => void refresh()), + ); + // Activation is `onLanguage:pyxle`, so the .pyxl editor is already active + // and onDidChangeActiveTextEditor will not fire for it. + void refresh(); +} diff --git a/editors/vscode/test/interpreter.test.ts b/editors/vscode/test/interpreter.test.ts new file mode 100644 index 0000000..7efc3ae --- /dev/null +++ b/editors/vscode/test/interpreter.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for the VS Code-free interpreter helpers (src/interpreter.ts). + * + * Like discovery.test.ts these use only node:test + node built-ins, so they run + * fast and in CI without a VS Code host. The probe is exercised against the + * real `python3` on PATH — it only needs *a* Python, not a pyxle install. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + formatInterpreterLabel, + parseReportedVersion, + probePyxleInterpreter, + reportedAtLeast, +} from "../src/interpreter"; + +/* ------------------------------------------------------------------ */ +/* parseReportedVersion */ +/* ------------------------------------------------------------------ */ + +test("parseReportedVersion reads the marked line", () => { + assert.equal(parseReportedVersion("PYXLE_DIST_VERSION:0.8.0\n"), "0.8.0"); +}); + +test("parseReportedVersion ignores unmarked noise from sitecustomize", () => { + const stdout = "some conda banner\nPYXLE_DIST_VERSION:1.2.3rc1\ntrailing\n"; + assert.equal(parseReportedVersion(stdout), "1.2.3rc1"); +}); + +test("parseReportedVersion returns undefined when absent", () => { + assert.equal(parseReportedVersion("nothing to see"), undefined); + assert.equal(parseReportedVersion(""), undefined); +}); + +test("parseReportedVersion rejects a value that isn't version-shaped", () => { + // Guards against a dialog interpolating stray prose. + assert.equal( + parseReportedVersion("PYXLE_DIST_VERSION:not a version at all"), + undefined, + ); +}); + +/* ------------------------------------------------------------------ */ +/* reportedAtLeast */ +/* ------------------------------------------------------------------ */ + +test("reportedAtLeast compares numerically, not lexically", () => { + assert.equal(reportedAtLeast("0.8.0", [0, 8, 0]), true); + assert.equal(reportedAtLeast("0.8.1", [0, 8, 0]), true); + assert.equal(reportedAtLeast("0.10.0", [0, 8, 0]), true); // not "0.10" < "0.8" + assert.equal(reportedAtLeast("0.7.5", [0, 8, 0]), false); + assert.equal(reportedAtLeast("1.0.0", [0, 8, 0]), true); +}); + +test("reportedAtLeast is false for missing or unparseable versions", () => { + assert.equal(reportedAtLeast(undefined, [0, 8, 0]), false); + assert.equal(reportedAtLeast("weird", [0, 8, 0]), false); +}); + +/* ------------------------------------------------------------------ */ +/* formatInterpreterLabel */ +/* ------------------------------------------------------------------ */ + +test("formatInterpreterLabel prefers the environment name", () => { + assert.equal( + formatInterpreterLabel("venv", "/p/venv", "/p/venv/bin/python", 3, 12), + "venv (3.12)", + ); +}); + +test("formatInterpreterLabel falls back to the env folder, then the binary", () => { + assert.equal( + formatInterpreterLabel(undefined, "/p/.venv", "/p/.venv/bin/python", 3, 11), + ".venv (3.11)", + ); + assert.equal( + formatInterpreterLabel(undefined, undefined, "/usr/bin/python3"), + "python3", + ); +}); + +test("formatInterpreterLabel doesn't render pyenv's version twice", () => { + // A pyenv env is already named "3.12.4" — "3.12.4 (3.12)" reads as a bug. + assert.equal( + formatInterpreterLabel("3.12.4", undefined, "/py/bin/python", 3, 12), + "3.12.4", + ); +}); + +test("formatInterpreterLabel omits the version when it is unknown", () => { + assert.equal( + formatInterpreterLabel("venv", undefined, "/p/venv/bin/python"), + "venv", + ); +}); + +/* ------------------------------------------------------------------ */ +/* probePyxleInterpreter */ +/* ------------------------------------------------------------------ */ + +test("probePyxleInterpreter answers for a real interpreter without hanging", async () => { + // python3 exists in CI; a stock one has no pyxle installed, a dev machine's + // may have it. Any verdict is valid — what must never happen is a hang. + const result = await probePyxleInterpreter("python3"); + assert.ok(["ok", "too-old", "missing", "unknown"].includes(result.status)); +}); + +test("probePyxleInterpreter reports unknown — not missing — when it can't run", async () => { + // A spawn failure means the probe never got an answer. Reporting "missing" + // would assert something it doesn't know and hard-block a launch that used + // to work; "unknown" lets the launch proceed and debugpy report the truth. + const result = await probePyxleInterpreter( + "definitely-not-a-python-on-this-box", + ); + assert.equal(result.status, "unknown"); + assert.equal(result.reportedVersion, undefined); +}); + +test("probePyxleInterpreter reports missing only on the script's own exit 4", async () => { + // Exit 4 is what the probe script returns when find_spec('pyxle') is None, + // i.e. a definitive "not installed" — the one case that blocks a launch. + const result = await probePyxleInterpreter("python3"); + if (result.status === "missing") { + assert.equal(result.reportedVersion, undefined); + } +}); diff --git a/editors/vscode/tsconfig.test.json b/editors/vscode/tsconfig.test.json index 9d6f223..bd3f762 100644 --- a/editors/vscode/tsconfig.test.json +++ b/editors/vscode/tsconfig.test.json @@ -6,8 +6,18 @@ "noEmit": false, "noUnusedLocals": false, "noUnusedParameters": false, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["src/discovery.ts", "test/**/*.ts"], - "exclude": ["node_modules", "out", "out-test"] + "include": [ + "src/discovery.ts", + "src/interpreter.ts", + "test/**/*.ts" + ], + "exclude": [ + "node_modules", + "out", + "out-test" + ] }