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 }); + } +});