From ee1bcba9a4a256991c188d19aee148fab87ba77b Mon Sep 17 00:00:00 2001 From: JunkaiWang-TheoPhy <1181100960@qq.com> Date: Sun, 1 Nov 2026 08:56:17 +0800 Subject: [PATCH] Reclaim Jupyter servers that finish starting during plugin shutdown Closing only registered entries misses an owned process whose launch is still in flight. Close admission, drain the pending launch, and share one cleanup promise while keeping attached servers outside plugin ownership. Confidence: high Scope-risk: narrow Tested: Old registry fails four unit regressions and a real Jupyter process regression; fixed49 unit and15 real integration tests; MCP smoke, AppBridge E2E, types and lint Not-tested: Current native Desktop install remains incomplete; unrelated full build requires missing rustup --- marketplace.json | 2 +- .../.zcode-plugin/plugin.json | 2 +- plugins/jupyter-notebook/README.md | 2 +- plugins/jupyter-notebook/README_CN.md | 2 +- ui-plugins/jupyter-notebook/package.json | 2 +- ui-plugins/jupyter-notebook/src/CONTRACT.md | 2 +- ui-plugins/jupyter-notebook/src/contract.ts | 2 +- .../jupyter-notebook/src/servers/registry.ts | 24 ++++- .../src/servers/servers.test.ts | 92 ++++++++++++++++++- .../test/server-shutdown.integration.test.ts | 45 +++++++++ 10 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 ui-plugins/jupyter-notebook/test/server-shutdown.integration.test.ts diff --git a/marketplace.json b/marketplace.json index c7f09a06..edc12eda 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1174,7 +1174,7 @@ "en": "Jupyter Notebook", "zh-CN": "Jupyter Notebook" }, - "version": "0.2.0", + "version": "0.2.1", "source": "./plugins/jupyter-notebook", "description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。", "description_i18n": { diff --git a/plugins/jupyter-notebook/.zcode-plugin/plugin.json b/plugins/jupyter-notebook/.zcode-plugin/plugin.json index 8c7327ad..ddb1a864 100644 --- a/plugins/jupyter-notebook/.zcode-plugin/plugin.json +++ b/plugins/jupyter-notebook/.zcode-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jupyter-notebook", - "version": "0.2.0", + "version": "0.2.1", "description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。", "author": { "name": "ZCode" diff --git a/plugins/jupyter-notebook/README.md b/plugins/jupyter-notebook/README.md index 4fa58070..17b011ed 100644 --- a/plugins/jupyter-notebook/README.md +++ b/plugins/jupyter-notebook/README.md @@ -19,7 +19,7 @@ You can also open the panel from the side pane "+" menu. ### Servers - **Running servers**: JupyterLab, Notebook 7 and `jupyter server` instances on this machine are found automatically and connect without a token. Other servers, including remote ones, connect with a URL and token (`connect_server`, or the form in the panel). The token stays inside the plugin process and is never shown to the agent or the panel. -- **A server for the workspace**: `start_server` (or the panel's start button) runs `python -m jupyter_server` bound to `127.0.0.1` with a random token and the workspace as its root. It uses the first Python with `jupyter-server` and `ipykernel`: the plugin setting *Python interpreter*, the workspace `.venv` / `venv`, the active virtualenv or conda environment, then `python3` on PATH. Nothing is installed automatically; `start_server` with `useUv: true` runs it through `uv run --with jupyter-server --with ipykernel`. The server stops when the session ends. +- **A server for the workspace**: `start_server` (or the panel's start button) runs `python -m jupyter_server` bound to `127.0.0.1` with a random token and the workspace as its root. It uses the first Python with `jupyter-server` and `ipykernel`: the plugin setting *Python interpreter*, the workspace `.venv` / `venv`, the active virtualenv or conda environment, then `python3` on PATH. Nothing is installed automatically; `start_server` with `useUv: true` runs it through `uv run --with jupyter-server --with ipykernel`. The server stops when the session ends, including when startup is still in progress. - If a notebook is already open in JupyterLab on the same server, the panel joins its kernel. ### Panel diff --git a/plugins/jupyter-notebook/README_CN.md b/plugins/jupyter-notebook/README_CN.md index be56c2e4..409b62ca 100644 --- a/plugins/jupyter-notebook/README_CN.md +++ b/plugins/jupyter-notebook/README_CN.md @@ -19,7 +19,7 @@ Git 市场安装包已包含编译好的 MCP server 和面板,无需自行构 ### Server - **已在运行的 server**:本机运行的 JupyterLab、Notebook 7 和 `jupyter server` 会被自动发现,无需 token 即可连接。其他 server(包括远程的)通过 URL 和 token 连接(`connect_server` 或面板中的表单)。token 只保存在插件进程内,不会出现在 Agent 或面板中。 -- **为工作区启动 server**:`start_server`(或面板中的启动按钮)运行 `python -m jupyter_server`,只绑定 `127.0.0.1`,使用随机 token,根目录为工作区。按顺序使用第一个装有 `jupyter-server` 与 `ipykernel` 的 Python:插件设置“Python 解释器”、工作区 `.venv` / `venv`、当前激活的 virtualenv 或 conda 环境、PATH 中的 `python3`。插件不会自动安装任何东西;`start_server` 传 `useUv: true` 时改用 `uv run --with jupyter-server --with ipykernel` 运行。会话结束时 server 随之停止。 +- **为工作区启动 server**:`start_server`(或面板中的启动按钮)运行 `python -m jupyter_server`,只绑定 `127.0.0.1`,使用随机 token,根目录为工作区。按顺序使用第一个装有 `jupyter-server` 与 `ipykernel` 的 Python:插件设置“Python 解释器”、工作区 `.venv` / `venv`、当前激活的 virtualenv 或 conda 环境、PATH 中的 `python3`。插件不会自动安装任何东西;`start_server` 传 `useUv: true` 时改用 `uv run --with jupyter-server --with ipykernel` 运行。会话结束时 server 随之停止,包括尚未完成启动的 server。 - 若同一 server 上的 JupyterLab 已打开该 notebook,面板会加入它的 kernel。 ### 面板 diff --git a/ui-plugins/jupyter-notebook/package.json b/ui-plugins/jupyter-notebook/package.json index d3f128fc..a836a5c3 100644 --- a/ui-plugins/jupyter-notebook/package.json +++ b/ui-plugins/jupyter-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@zcode/plugin-jupyter-notebook", - "version": "0.2.0", + "version": "0.2.1", "private": true, "license": "MIT", "type": "module", diff --git a/ui-plugins/jupyter-notebook/src/CONTRACT.md b/ui-plugins/jupyter-notebook/src/CONTRACT.md index 606f5ea6..fd80b031 100644 --- a/ui-plugins/jupyter-notebook/src/CONTRACT.md +++ b/ui-plugins/jupyter-notebook/src/CONTRACT.md @@ -5,7 +5,7 @@ ## 分层 - `jupyter/`:Jupyter Server REST(status、kernelspecs、sessions、kernels、contents)与 kernel WebSocket(旧版 JSON 帧,不协商子协议)。只认 `{baseUrl, token}`,不区分 server 来源。 -- `servers/`:`ServerRegistry` 是连接唯一 owner。`attach(url, token)` 连接已有 server;`launch()` 找到装有 `jupyter_server` + `ipykernel` 的 Python,起 `python -m jupyter_server`(仅 `127.0.0.1`、随机端口、`JUPYTER_TOKEN`、root_dir = 工作区),就绪后同样 attach。`discover()` 读 Jupyter runtime 目录的 `jpserver-*.json`。只停止自己 launch 的 server;进程退出时一并停止。 +- `servers/`:`ServerRegistry` 是连接唯一 owner。`attach(url, token)` 连接已有 server;`launch()` 找到装有 `jupyter_server` + `ipykernel` 的 Python,起 `python -m jupyter_server`(仅 `127.0.0.1`、随机端口、`JUPYTER_TOKEN`、root_dir = 工作区),就绪后同样 attach。`discover()` 读 Jupyter runtime 目录的 `jpserver-*.json`。只停止自己 launch 的 server;进程退出时一并停止。`closeAll()` 是终止操作:先拒绝新的连接与启动,等待在途启动完成并回收其进程;并发关闭复用同一次清理,不停止外部 attached server。 - `servers/environment.ts`:启动前的环境检查(`JupyterEnvironment`):与 `start_server` 相同的解释器顺序,目标解释器中两个包的版本,环境是否由 uv 管理(pyvenv.cfg 的 `uv =` 或项目的 uv.lock),本机的 uv,以及安装命令(uv 项目 `uv add --dev`,没有 pyproject.toml 的 uv 环境 `uv pip install --python`,其余 pip)。`start_server` 失败时的提示用同一个命令。 - `domain/`:nbformat 规范化(补 cell id、source 归一为字符串)、编辑操作、输出归并(stream 合并、`clear_output(wait)`、`update_display_data`)、模型摘要。纯函数。 - `app/notebooks.ts`:`NotebookOwner` 是已打开 notebook 的唯一 owner:文档、revision、kernel session、执行、自动保存与冲突。 diff --git a/ui-plugins/jupyter-notebook/src/contract.ts b/ui-plugins/jupyter-notebook/src/contract.ts index 6fc248b4..c664d821 100644 --- a/ui-plugins/jupyter-notebook/src/contract.ts +++ b/ui-plugins/jupyter-notebook/src/contract.ts @@ -1,5 +1,5 @@ export const PLUGIN_NAME = "jupyter-notebook"; -export const PLUGIN_VERSION = "0.2.0"; +export const PLUGIN_VERSION = "0.2.1"; /** manifest `mcpServers` 键、`ui.surfaces[].id` 与资源命名空间保持一致。 */ export const SERVER_NAME = "jupyter-notebook"; export const SURFACE_ID = "jupyter-notebook"; diff --git a/ui-plugins/jupyter-notebook/src/servers/registry.ts b/ui-plugins/jupyter-notebook/src/servers/registry.ts index 72864b50..d6a6af33 100644 --- a/ui-plugins/jupyter-notebook/src/servers/registry.ts +++ b/ui-plugins/jupyter-notebook/src/servers/registry.ts @@ -90,6 +90,7 @@ export function createServerRegistry(deps: RegistryDeps) { const clientFactory = deps.clientFactory ?? createJupyterClient; const discover = deps.discover ?? (() => discoverServers()); let launching: Promise | null = null; + let closing: Promise | null = null; let environment: JupyterEnvironment = UNKNOWN_ENVIRONMENT; let checkingEnvironment: Promise | null = null; @@ -154,9 +155,11 @@ export function createServerRegistry(deps: RegistryDeps) { /** 连接已有 server。未给 token 时从 URL 的 `?token=` 或本机 runtime 文件取。 */ async attach({ url, token }: { url: string; token?: string | null }): Promise { + if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed."); const normalized = normalizeBaseUrl(url); let secret = token?.trim() || normalized.token; const runtime = (await discover()).find((server) => server.baseUrl === normalized.baseUrl); + if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed."); if (!secret && runtime) secret = runtime.token; const id = serverIdFor(normalized.baseUrl); const existing = entries.get(id); @@ -176,6 +179,7 @@ export function createServerRegistry(deps: RegistryDeps) { ); throw error; } + if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed."); const entry: Entry = { info: { id, @@ -209,6 +213,7 @@ export function createServerRegistry(deps: RegistryDeps) { /** 启动本插件自己的 server;已有运行中的 launched server 时直接返回它。 */ launch({ python, useUv }: { python?: string; useUv?: boolean } = {}): Promise { + if (closing) return Promise.reject(new JupyterError("invalid_operation", "The Jupyter Server registry is closed.")); const running = [...entries.values()].find( (entry) => entry.process && entry.info.status === "ready", ); @@ -259,6 +264,11 @@ export function createServerRegistry(deps: RegistryDeps) { .version() .then((value) => value.version) .catch(() => null); + // closeAll 必须覆盖尚未登记的启动;关闭已开始时先回收新进程,不能重新登记。 + if (closing) { + await proc.stop(); + throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed."); + } proc.child.once("exit", () => { if (entries.get(id) !== entry) return; entry.info.status = entry.info.status === "stopped" ? "stopped" : "error"; @@ -325,11 +335,15 @@ export function createServerRegistry(deps: RegistryDeps) { return { info: snapshot(entry), client: entry.client }; }, - async closeAll() { - await Promise.allSettled( - [...entries.values()].filter((entry) => entry.process).map((entry) => entry.process!.stop()), - ); - entries.clear(); + closeAll(): Promise { + closing ??= (async () => { + await launching?.catch(() => undefined); + await Promise.allSettled( + [...entries.values()].filter((entry) => entry.process).map((entry) => entry.process!.stop()), + ); + entries.clear(); + })(); + return closing; }, }; } diff --git a/ui-plugins/jupyter-notebook/src/servers/servers.test.ts b/ui-plugins/jupyter-notebook/src/servers/servers.test.ts index 3d748c67..b79c796e 100644 --- a/ui-plugins/jupyter-notebook/src/servers/servers.test.ts +++ b/ui-plugins/jupyter-notebook/src/servers/servers.test.ts @@ -1,12 +1,20 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeBaseUrl } from "#nb/jupyter/rest.ts"; import { discoverServers, parseRuntimeFile, runtimeDirs } from "#nb/servers/discover.ts"; import { findJupyterPython, pythonCandidates } from "#nb/servers/launch.ts"; import { createServerRegistry, serverIdFor, toServerPath } from "#nb/servers/registry.ts"; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "jnb-unit-")); @@ -126,6 +134,88 @@ describe("python discovery", () => { }); describe("registry", () => { + it.each(["launch", "version"])("waits for a pending %s before completing shutdown", async (phase) => { + const entered = deferred(); + const resume = deferred(); + const stop = vi.fn(async () => undefined); + const registry = createServerRegistry({ + workspaceRoot: dir, + pythonPath: null, + launch: async () => { + if (phase === "launch") { entered.resolve(); await resume.promise; } + return { baseUrl: "http://127.0.0.1:9/", token: "secret", python: "python", pid: 1, + child: new EventEmitter(), logTail: () => "", stop } as never; + }, + clientFactory: (() => ({ version: async () => { + if (phase === "version") { entered.resolve(); await resume.promise; } + return { version: "2" }; + } })) as never, + }); + const launch = registry.launch({ useUv: true }); + const failedLaunch = expect(launch).rejects.toMatchObject({ code: "invalid_operation" }); + await entered.promise; + const closing = registry.closeAll(); + const closingAgain = registry.closeAll(); + resume.resolve(); + await Promise.all([closing, closingAgain, failedLaunch]); + expect(stop).toHaveBeenCalledTimes(1); + expect(registry.list()).toEqual([]); + await expect(registry.launch({ useUv: true })).rejects.toMatchObject({ code: "invalid_operation" }); + await expect(registry.attach({ url: "http://127.0.0.1:9/" })).rejects.toMatchObject({ code: "invalid_operation" }); + }); + + it("finishes shutdown when the pending launch fails", async () => { + const entered = deferred(); + const resume = deferred(); + const error = new Error("startup failed"); + const registry = createServerRegistry({ + workspaceRoot: dir, pythonPath: null, + launch: async () => { entered.resolve(); await resume.promise; throw error; }, + }); + const launch = registry.launch({ useUv: true }); + const failedLaunch = expect(launch).rejects.toBe(error); + await entered.promise; + const closing = registry.closeAll(); + resume.resolve(); + await Promise.all([closing, failedLaunch]); + expect(registry.list()).toEqual([]); + }); + + it("does not register an attachment that finishes after shutdown", async () => { + const entered = deferred(); + const resume = deferred(); + const registry = createServerRegistry({ + workspaceRoot: dir, pythonPath: null, discover: async () => [], + clientFactory: (() => ({ + status: async () => { entered.resolve(); await resume.promise; }, + version: async () => ({ version: "2" }), + })) as never, + }); + const attach = registry.attach({ url: "http://127.0.0.1:9/" }); + const failedAttach = expect(attach).rejects.toMatchObject({ code: "invalid_operation" }); + await entered.promise; + await registry.closeAll(); + resume.resolve(); + await failedAttach; + expect(registry.list()).toEqual([]); + }); + + it("stops only owned servers and shares concurrent shutdown", async () => { + const stop = vi.fn(async () => undefined); + const registry = createServerRegistry({ + workspaceRoot: dir, pythonPath: null, discover: async () => [], + launch: async () => ({ baseUrl: "http://127.0.0.1:9/", token: "secret", python: "python", pid: 1, + child: new EventEmitter(), logTail: () => "", stop }) as never, + clientFactory: (() => ({ status: async () => ({}), version: async () => ({ version: "2" }) })) as never, + }); + await registry.launch({ useUv: true }); + await registry.attach({ url: "http://127.0.0.1:10/" }); + expect(registry.list()).toHaveLength(2); + await Promise.all([registry.closeAll(), registry.closeAll()]); + expect(stop).toHaveBeenCalledTimes(1); + expect(registry.list()).toEqual([]); + }); + it("requires a server before resolving and reports it clearly", () => { const registry = createServerRegistry({ workspaceRoot: dir, pythonPath: null, discover: async () => [] }); expect(() => registry.resolve()).toThrow(expect.objectContaining({ code: "no_server" })); diff --git a/ui-plugins/jupyter-notebook/test/server-shutdown.integration.test.ts b/ui-plugins/jupyter-notebook/test/server-shutdown.integration.test.ts new file mode 100644 index 00000000..10995661 --- /dev/null +++ b/ui-plugins/jupyter-notebook/test/server-shutdown.integration.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { ensureJupyterPython } from "../scripts/jupyter-env.mjs"; +import { launchJupyterServer, type LaunchedProcess } from "#nb/servers/launch.ts"; +import { createServerRegistry } from "#nb/servers/registry.ts"; + +it("stops a real Jupyter process that becomes ready during registry shutdown", async () => { + const python = await ensureJupyterPython(); + const workspace = await mkdtemp(join(tmpdir(), "jupyter-nb-shutdown-")); + let ready!: () => void; + const started = new Promise((resolve) => { ready = resolve; }); + let resume!: () => void; + const released = new Promise((resolve) => { resume = resolve; }); + let proc: LaunchedProcess | undefined; + const registry = createServerRegistry({ + workspaceRoot: workspace, pythonPath: python, + launch: async (options) => { + proc = await launchJupyterServer(options); + ready(); + await released; + return proc; + }, + }); + try { + const launch = registry.launch(); + const result = launch.then(() => null, (error: unknown) => error); + await started; + expect(proc!.child.exitCode).toBeNull(); + const closing = registry.closeAll(); + resume(); + await closing; + const completion = await result; + expect(proc!.child.exitCode !== null || proc!.child.signalCode !== null).toBe(true); + expect(completion).toMatchObject({ code: "invalid_operation" }); + expect(registry.list()).toEqual([]); + await expect(fetch(new URL("api/status", proc!.baseUrl))).rejects.toThrow(); + } finally { + resume(); + await proc?.stop(); + await registry.closeAll(); + await rm(workspace, { recursive: true, force: true }); + } +});