Development log
Findings from each step, newest last: what was built, what was checked against the reference implementations, what broke, and what was learned.
The design started as a single sketch.md, split on 2026-10-17 into Architecture, Replacing Python, Protocol and kernel host, User interfaces, The iPad use case, Roadmap, and Prior art. Older entries refer to the sketch by section number, and entries before the rename use the old jcore- crate names.
Adding an entry
Each pull request adds its own file to notes/, named YYYY-MM-DD-<topic>.md (for example 2026-10-18-request-body-limit.md), instead of appending to one shared file, so PRs open at the same time don't conflict. The file holds one entry that starts with ## YYYY-MM-DD, <topic> (#<issue>). Links are relative to notes/ (../architecture.md).
The docs site shows every entry on this page: first notes/archive.md, the log before it was split, then the dated files in name order (see .vitepress/notes.ts). On GitHub the entries are the files in notes/.
2026-10-17
Starting point: question of what a Rust reimplementation of the Jupyter core (kernel host, web UI, protocol) would take and what is easy to miss.
Wrote sketch.md: components, what already exists in Rust, commonly missed pieces, and an order of work.
Findings while writing it:
- Messaging spec is now v5.5:
subshell_idin headers, iopub moved to XPUB with aniopub_welcomemessage,supported_featuresinkernel_info_reply(JEP 92). - The runtimed crates (
jupyter-protocol,runtimelib,nbformat) are maintained and used by Zed's REPL. They cover protocol, ZMQ kernel client, and the file format. A new project should either build on them or use them as a conformance oracle. - No Rust equivalent of
jupyter_serverexists. That plus the frontend is the actual work. - The frontend cannot usefully be Rust-native if
text/htmloutputs and widgets matter. TypeScript UI over a Rust server is the pragmatic shape. (Superseded by the scope clarification at the end of these notes: the UI was never meant to be Rust, and is pluggable.)
Open questions:
- Is the goal a product, or understanding the system? Changes whether to reuse the runtimed crates.
- Are widgets in scope? Largest single cost after the UI itself.
- Windows support?
- Must existing clients (JupyterLab, VS Code, papermill) connect to it? If yes, the REST and websocket surface is fixed by
jupyter_server.
Nothing built.
2026-10-17, later
Goal clarified: a Jupyter server with no Python anywhere in the product, kernel-agnostic, with cdp-kernel (TypeScript on Node or Deno) as the first target. May become a project of its own.
Read cdp-kernel's install, architecture, and known-gaps pages. Relevant facts:
- Speaks ZMTP over TCP via
node:net; noipctransport. - Installs kernelspecs to the standard Jupyter data dirs (
$JUPYTER_DATA_DIR,~/Library/Jupyter, XDG). Uninstall docs still point at the Pythonjupyter kernelspecCLI, so the server should shipkernelspec list/install/remove. - Interrupts over CDP internally;
kernel.jsoncontents (includinginterrupt_mode) are not documented. Read the generated file. - anywidget-style widgets and comms only; no ipywidgets controls.
- Debugger on Node and Deno targets; needs a DAP client in the UI.
prompt()andconfirm()over stdin.- Kernel inherits the server's environment. Windows unsupported.
Changes to sketch.md: revised goal paragraph, new section 3 (table of Python assumptions in the reference stack and their replacements, what cdp-kernel needs from a server, frontends that work today), order of work now validates the server with VS Code plus cdp-kernel before any UI, and more prior art (Jupyverse, VS Code Jupyter extension, anywidget, Datalayer jupyter-react).
Main conclusion: step 4 (the server) is a usable product on its own. VS Code's Jupyter extension against a Rust server running cdp-kernel is a fully Python-free TypeScript notebook environment with no UI work.
Open questions, updated:
- Build on the runtimed crates or reimplement? Still open.
- Which
interrupt_modedoes cdp-kernel declare? - Serve JupyterLab's static bundle (needs
/lab/api/*) or target VS Code only for v1? - Widgets: anywidget-only manager in a custom UI, or rely on JupyterLab and VS Code's managers until a UI exists?
2026-10-17, ts-on-ipados as a use case
The ts-on-ipados plan is one intended use: this server is its remote tier on the Mac mini over the tailnet. Read the design note sections 04, 05, 09 and the review sections 3, 4, 6, 7. Added section 6 to sketch.md.
What the plan adds to the server's requirements:
- REST API fidelity, because Juno Connect and JupyterLab in Safari are the Tier 0 clients.
- Token auth baseline;
tailscale serveidentity headers as an option. - Kernels survive websocket drops (iPad suspension) and clients reattach by id; idle culling as the complement.
- Files are truth: iCloud Drive writes under the server. Needs mtime conflict check on PUT, atomic writes, checkpoint dir outside the synced folder.
- The websocket and channel-message shape become a public contract once the Swift client exists.
- Library-first workspace so the iPad app can compile the protocol, nbformat, and document crates for iOS and drive its in-process kernel through the same types. ZMQ is one transport behind a trait.
- An attach-to-existing-kernel provisioner, for driving the device kernel from a desktop JupyterLab during development.
- Pass kernelspec
metadataandkernel_info_replythrough verbatim so capability manifests reach the client.
Tension noted: a server-side CRDT and iCloud file sync are two writers of one file. For this use case, file-as-truth with the mtime check is enough for v1.
2026-10-17, modules and profiles
Goal restated: pluggable modules composing a Jupyter-like environment on iPad, desktop, or server; TypeScript kernels as the focus; Python kernels possible on desktop and server but never assumed. Added section 7 to sketch.md: the one rule (the host knows no language), the seams with their implementations, the three profiles, a crate layout, and a ranked definition of ecosystem compatibility.
Decisions implied:
- Kernel host boundary is "a stream of channel messages", with ZMQ, in-process, and websocket-client transports behind one trait.
- The REST and websocket handlers are functions over traits, not axum handlers, so the iPad bridge and the HTTP server share them.
- The widget manager is per profile: anywidget-only on iPad, the full ipywidgets manager where Python kernels are expected.
- Extension compatibility (JupyterLab, jupyter_server) is out, by design.
Found two existing seams to match: jupyter_server's gateway client (remote provisioner) and @jupyterlab/services ServerConnection with custom fetch and WebSocket (how JupyterLite runs without a server). JupyterLite is the closest precedent for the iPad profile.
Test rule to adopt: the same scenarios run against cdp-kernel and ipykernel; no python string in host crates outside tests.
2026-10-17, code sketch
Started the code. Cargo workspace at the project root, crates/*, and a TypeScript package under frontend/. Everything builds, tests pass, clippy is clean. Test oracle: a scratch venv with ipykernel 7 and nbformat 5.11 (cdp-kernel needs Node 24; this container has 22).
Crates:
jcore-protocol: envelope, typed content withextrapassthrough, ZMQ wire framing with HMAC-SHA256, connection file, websocket v1 and legacy codecs. No I/O. 12 tests, including an HMAC vector checked against Python and the v1 offset layout fromjupyter_server.jcore-nbformat(written by a subagent): nbformat 4.5 types, lenient reader, byte-exact writer matchingnbformat.write. 24 tests against fixtures generated by Python nbformat.jcore-kernel-host: kernelspec discovery over the Jupyter data dirs, the transport seam with an in-process pair, the ZMQ transport on the pure-Rustzeromqcrate, subprocess and attach provisioners, the session state machine, and a fake kernel. 9 tests.jcore-exec: headless executor over the two above. 2 tests.jcore-cli:jcore kernelspec list|paths,jcore run,jcore attach,jcore exec.
Verified against ipykernel over real ZMQ:
jcore run --kernel python3 ...: launch, nudge, five cells, shutdown, about 1.6 s total.--interrupt-after-ms: SIGINT to the process group interrupts a sleeping cell;interrupt_requeston control does the same on the fake.jcore attach <connection file>against a kernel started byjupyter_client.jcore exec: a notebook generated by nbformat with streams, adisplay_idupdate,clear_output(wait=True), and an empty cell. The output validates with nbformat and is byte-identical after an nbformat rewrite.
Things learned:
tokio::sync::watch::Sender::sendfails when no receiver exists; usesend_replacefor state that may have no watchers yet.- ipykernel 7 reports protocol 5.3 in
kernel_info_replywhile its kernelspec says 5.5. Do not gate on the version string. - ipykernel warns about unencrypted TCP and advertises
supported_encryption: ["curve"]in kernelspec metadata. CurveZMQ is a future item for the ZMQ transport. - Python
json.dumpsfloat layout (1e-05,2.5e-07) differs from serde_json; the nbformat writer has its own float formatter. nbformat.writeassigns ids to cells that lack them, so byte fidelity is only defined for notebooks with ids.
Frontend (written by a subagent): protocol types, the websocket v1 and legacy codecs, a ServerConnection with injectable fetch and WebSocket, a REST client, a KernelConnection with the two-part completion rule, a renderer registry, ANSI and stream handling, and widget manager interfaces. 45 tests on Node's test runner, typescript as the only dependency. No editor, no DOM tests, no app shell yet.
Not done, in the order the sketch suggests: the server crate (REST and websocket bridge over the session), the contents crate, the document model, an app shell for the frontend, cdp-kernel verification (needs Node 24), CurveZMQ, Windows.
2026-10-17, server crate
Added three crates. jcore-contents (subagent): the contents seam and a filesystem implementation with atomic writes, the mtime conflict check, checkpoints with a configurable directory outside the root, and path safety including symlink escapes; 30 tests. jcore-api: kernel and session managers, models, and the per-client bridge, with no HTTP; 6 tests. jcore-server: axum routes, token auth, websocket channels, TOML config, static assets; 5 integration tests including a real websocket round trip against the fake kernel.
Verified against ipykernel over ZMQ, through HTTP, from Node:
- Our
frontend/client: kernelspecs, start, kernel_info, execute with stdout and stderr, result, error reply, shutdown. @jupyterlab/services7.5, the real JupyterLab client library: specs, start, info, execute, complete, signal interrupt of a sleeping cell, restart (it reconnects its websocket on its own), contents newUntitled, save, get, list, sessions bound to the kernel, shutdown, and the server's close frame when the kernel is gone.
Things learned:
- JupyterLab's client POSTs JSON without
Content-Type; axum'sJsonextractor answers 415.jupyter_serverparses the body regardless. The server now has a lenient extractor for every body route. - A client holding an
Arcto the running kernel keeps the broadcast hub alive, so "hub closed" can never signal shutdown; an explicit alive flag the client selects on does. KernelSession::shutdownmust not wait on or kill a process it does not own; attached kernels have no pid.- A kernel started with no path must run in the contents root, not the server's cwd.
pkill -f <pattern>matches the shell running it. Use process names.- The contents crate's checkpoint layout with an external directory mirrors the root's tree instead of jupyter_server's flat folder, so same-named files in different directories do not collide.
Not done: cookie login for a browser frontend, serving JupyterLab's static bundle (needs /lab/api/*), CurveZMQ, kernel autorestart on crash, cdp-kernel verification.
Scope clarification: the UI is not Rust (2026-10-17)
Only the core server is Rust. The UI was never meant to be, and should itself be pluggable: any Jupyter frontend, a TypeScript web UI, or a native shell, for example a Swift app on iPadOS with a web view doing the rendering. Earlier drafts read as if the presentation layer was part of the Rust project (scope line, section 2.6 weighing Rust UI frameworks, frontend/ described as "the" frontend). The sketch, README, and section 7 now treat the UI as a client of the core's API, with the TS package as shared rendering code for any web-view UI. No code changes: the Rust crates never depended on a UI.
2026-10-17, rename to ganymede-
Renamed the crates from jcore-* to ganymede-*, the CLI binary from jcore to ganymede, the server binary from jcore-server to ganymede-server, and the npm package to @ganymede/frontend. Entries above this one keep the old names.
Things learned:
- The longer crate prefix pushes some
uselines and signatures past rustfmt's width, so a rename needscargo fmtbefore CI'sfmt --checkpasses.
2026-10-17, validation against JupyterLab and VS Code
Pointed JupyterLab 4.6.4 and the VS Code Jupyter extension's remote-server flow at the server, with ipykernel 7 as the kernel (cdp-kernel needs Node 24; this container has 22). For JupyterLab, a real jupyter lab (jupyter_server 2.21.1) was the oracle: the same browser script ran against both and the request sets were diffed. Scripts are in validation/.
No real VS Code instance can run in this container. Instead, validation/vscode.cjs replays the extension's flow from its source (vscode-jupyter e116e22, src/kernels/jupyter and src/standalone/userJupyterServer): the same URL parsing, password probe, @jupyterlab/services settings, Request and WebSocket overrides, and manager calls. It passes all 13 steps against both servers.
Added:
- JupyterLab mode.
--static-dirpointed at JupyterLab'sstatic/directory is recognised by itsindex.htmlandpackage.json; the server then serves the page at/lab,/lab/tree/*, and/lab/workspaces/*with a page config, the bundle at/static/lab,schemas/andthemes/from the sibling directories, and prebuilt extensions found inlabextensionsunder the Jupyter data dirs or--labextensions-path./redirects to/lab. /lab/api/settings(JSON5 user settings under<config dir>/lab/user-settings),/lab/api/workspaces,/lab/api/translations(English only),/lab/api/listings,/lab/api/plugins,/lab/api/licenses(JSON format only),/lab/api/themes.- Core
jupyter_serverendpoints JupyterLab calls:GET /api(public, reports the API version matched),/api/me,/api/config/{section}(GET, PATCH with recursive merge, stored in<config dir>/nbconfig),/api/nbconvert(empty: nbconvert is Python),/api/events/subscribe(accepted, silent), and kernelspecresourceswith/kernelspecs/{name}/{file}. --config-dirfor that frontend state; defaultJUPYTER_CONFIG_DIRor~/.jupyter, the same placejupyter_serverkeeps it.
Endpoints JupyterLab called, opening a notebook, running a cell, saving: /lab/workspaces/{ws}/tree/{path}, /static/lab/*, /lab/extensions/*, /lab/api/settings (plain and ?ids_only=true), /lab/api/settings/{id}, /lab/api/themes/{pkg}/index.css, /lab/api/translations, /lab/api/translations/default, /lab/api/workspaces (GET, PUT every few seconds), /api/me, /api/config/jupyterlabapputilsextensionannouncements, /api/nbconvert, /api/kernelspecs, /api/kernels, /api/sessions (GET, POST, PATCH twice), /api/contents (root listing, content=0&hash=0 polls, the notebook with hash=1, PUT), /api/contents/{path}/checkpoints (GET, POST), websockets /api/events/subscribe and /api/kernels/{id}/channels?session_id=, /lsp/status, and the kernelspec logo. Only against jupyter_server, because of its page config: /api/terminals and /lab/api/build. Every REST URL carries a cache-busting ?<timestamp> query.
VS Code's flow calls: GET / and GET /tree? without credentials (the password probe; 404 on /tree? means "no password"), /api/kernels, /api/kernelspecs, /api/sessions (GET, POST with a <name>-jvsc-<uuid>.ipynb path that never exists, DELETE), /api/kernels/{id}/interrupt, /api/kernels/{id}/restart, and the channels websocket with Authorization as a handshake header and token in the query. It never calls /api, /api/me, /api/status, or the contents API. It forces Content-Type: application/json on every request, GET and DELETE included, and pings the websocket every 30 s.
What broke:
- stdin never reached the client. A kernel sends
input_requeston its stdin ROUTER to the routing identity that sent theexecute_requeston shell. The host's shell and stdin DEALERs had separate random identities, so the kernel addressed a peer the stdin socket was not. Fixed: one identity for shell, control, and stdin, asjupyter_clientdoes, with a test. Neither earlier client test exercisedinput(). - JupyterLab's websockets get 403 without cookie login. In a browser at the server's own origin,
@jupyterlab/servicessetsappendTokento false, so the events and channels websockets carry no token and rely on the login cookiejupyter_serversets when the page is loaded with?token=. REST calls are fine (the page config token goes in theAuthorizationheader). The kernelspec logo<img>gets 403 for the same reason. With the token appended to websocket URLs by the script (WS_TOKEN=1), JupyterLab opens a notebook, starts ipykernel, runs a cell, and saves a notebook nbformat validates. This is step 3. /lsp/statusis 404, as with any jupyter_server without thejupyter-lspextension. JupyterLab logs it and carries on; the oracle hadjupyter-lspinstalled, which lists Python language servers.- On restart,
@jupyterlab/servicesreportsautorestartingand reconnects twice; against jupyter_server it reportsrestartingand reconnects once. The restart still succeeds. Belongs with kernel lifetime (step 5).
Things learned:
- JupyterLab's
index.htmlis a Jinja template. The server renders the few constructs it uses;tojsonmust escape<,>,&,'so the page config cannot close its<script>. jupyterlab_serverreturns settingsrawas the user's JSON5 text andsettingsparsed. Users write comments and trailing commas, so a JSON parser is not enough.- The page config's
extensionManagerset toread-onlywithcan_install: falsekeeps the extension manager from offering PyPI installs.buildAvailableandterminalsAvailablefalse stop the/lab/api/buildand/api/terminalscalls. jupyter_serverserves/static/lab, themes, and extension assets without auth; the page,/lab/api/*, and kernelspec resources need it;GET /apiis public.- Chromium does not apply Playwright's
extraHTTPHeadersto websocket handshakes, so it cannot stand in for cookie auth. @jupyterlab/services7.5 appendsAuthorization: tokeneven when the request already has one, givingtoken t, token t. jupyter_server rejects that; VS Code'sRequestsubclass blocks the second append.
Not done: Notebook 7's /tree and /notebooks pages, the terminal and LSP endpoints, language packs, overrides.json and page_config.json from the app's settings/ dir, and a real VS Code instance.
2026-10-17, engine, CLI, and e2e parity with Jupyter
ganymede-engine is the one library a host embeds: Engine finds kernelspecs and starts, attaches, connects, or adopts kernels; Kernel is one running kernel (execute with a Handler for streamed iopub and stdin, complete, inspect, is_complete, comms, interrupt, restart, shutdown, run a notebook). The ganymede CLI is a thin layer over it, and ganymede serve replaces the separate server binary as the usual entry point.
ganymede-exec now follows nbclient where its behavior is observable in the saved file, so ganymede execute and jupyter execute write the same notebook. Checked on ipykernel 7.4 and cdp-kernel 0.5 with nbclient 0.11 and jupyter_server 2.21:
- Python notebook: byte-identical after normalizing timestamps and ids.
- TypeScript notebook: identical except stream chunking (below).
- REST and websocket transcripts match
jupyter serverfor kernelspecs, contents, sessions, execute, stdin, interrupt, and restart on both kernels, after the exclusions below.
nbclient details that decide the bytes:
execution_countcounts cells executed in this run, not the kernel's counter; blank cells and cells taggedskip-executionare untouched.metadata.executiontimestamps are the message header dates reformatted to%Y-%m-%dT%H:%M:%S.%fZ.display_idupdates reach outputs in earlier cells;clear_output(wait)defers to the next output.coalesce_streamsdefaults to false, so\rsurvives in saved output. With it on, adjacent streams merge and\roverwrites the line; nbclient's backspace handling never runs (its loop condition is false on entry), so neither does ours.jupyter executewithout--outputor--inplacewrites nothing, and a failing run writes nothing. The oldganymede execoverwrote its input;executenow matches Jupyter.language_infofrom kernel_info goes into notebook metadata verbatim; widget state goes intometadata.widgets.
Findings:
- cdp-kernel's stdout and stderr order within one cell is not deterministic (nbclient sees the same), and stream chunk boundaries depend on timing for any kernel. The parity test compares exactly first, then with adjacent same-name streams merged.
- jupyter_client replaces the installed
python3kernelspec with ipykernel's own when ipykernel is importable, soargvandkernel_protocol_versiondiffer from the file on disk. The host reads only the file, by design. - jupyter_server marks notebook cells
metadata.trustedfrom its notary. Ganymede has no trust store, so JupyterLab may treat outputs as untrusted. Open. - Contents now returns
hashandhash_algorithm(null unless?hash=1, then SHA-256), and kernelspecs carrykernel_protocol_version, as jupyter_server 2.11+ does. - Restarting through the API relaunched the kernel in the server's cwd instead of the notebook's directory. Fixed.
Portability:
- Features
zmqandsubprocesson kernel-host and engine gate sockets and child processes. Without them the protocol, nbformat, kernel host, executor, and engine build forwasm32-unknown-unknown(with uuid'sjsand chrono'swasmbind) andwasm32-wasip1/p2. e2e/wasm-smokeruns a notebook on the in-process kernel inside wasm under Node's WASI: no sockets, processes, or threads.- With all features the engine, API, and contents build for
aarch64-apple-iosandaarch64-linux-android. The server needs a C cross toolchain there (psm, via its dependencies). - A browser page needs more than a build: tokio's timers and
spawnneed a runtime seam (wasm-bindgen-futuresand browser timers), and a kernel reaches the page over a websocket orpostMessagetransport behind the existingTransporttrait. The iPad kernel from the ts-on-ipados plan is the same shape: an in-processTransport.
2026-10-17, release workflow
.github/workflows/release.yml packages the ganymede binary.
- Trigger: a pushed
v*tag publishes; pull requests touching the workflow and manual runs build and package without publishing, so the packaging is exercised before a tag exists. - The tag must equal
v+[workspace.package] version, and each native build checksganymede --versionprints that version. Bump the version first, then tag. A prerelease tag (v0.1.0-rc.1) needs the same string inCargo.toml. - Targets build natively on their own runners, no cross toolchains: Linux on
ubuntu-22.04andubuntu-22.04-arm(older glibc thanubuntu-latest, so the binary runs on more distributions), macOS aarch64 onmacos-latest, macOS x86_64 cross-compiled on the same arm64 runner with Apple's toolchain (not run, Rosetta is not guaranteed), Windows x86_64 MSVC. psm's C code needs only the runner's compiler. - Linux binaries are glibc, not musl. Nothing in the dependency tree needs OpenSSL (reqwest with rustls is dev-only), so a musl build is possible later if a fully static binary is wanted.
- Archives hold
ganymede[.exe]and README.md in aganymede-<version>-<target>/directory. The release job concatenates the per-archive.sha256files intoSHA256SUMSand verifies them beforegh release create --generate-notes. - No code signing or notarization yet: macOS Gatekeeper quarantines a downloaded binary (
xattr -d com.apple.quarantine ganymede).
2026-10-17, release tooling, changelog, issue tracking
scripts/release.sh <level> [--execute]wraps cargo-release (version bump across the workspace, Cargo.lock, commit, tag, push) and git-cliff (CHANGELOG.md). Dry run is the default. It refuses unless HEAD isorigin/main. The release commit goes straight tomain; it is the one commit without a PR.- cargo-release runs
pre-release-hookonce per crate, so the hook lives inganymede-cli's[package.metadata.release], notrelease.toml. Hooks also run in dry runs withDRY_RUN=true, so the hook prints the changelog then instead of writing it.CHANGELOG.mdmust be tracked before the first release: cargo-release commits modified files, not new ones. - The changelog comes from merge commits, offline: GitHub's merge commit is
Merge pull request #N from <branch>with the PR title as its body, and a squash merge's subject ends in(#N). Preprocessors turn both into<title> (#N); every other commit is dropped. No GitHub token needed. A rebase merge would leave no trace and drop the PR. - release.yml takes the notes from the version's CHANGELOG.md section (and fails without one) instead of
--generate-notes, so the release page and the file agree. - Work is tracked in issues;
pr-issue.ymlfails a PR whose body has noCloses/Fixes/Resolves/Refs #N. Issue templates: bug, task. - Install check:
ganymede run -k fake helloandganymede serve+/api/statusneed no kernel, so they are the README's smoke test.
2026-10-17, project site and docs
A landing page in site/ and VitePress docs in docs/, both in cdp-kernel.org's design, built by .github/workflows/pages.yml on pull requests (so a dead link fails the PR) and deployed to GitHub Pages from main. sketch.md was split into docs pages and this log moved from notes.md to docs/notes.md; the README was rewritten around what exists.
- The site is served at
https://tlockney.github.io/ganymede/until there is a custom domain, so paths are not at the root.SITE_URLin pages.yml sets it, andconfig.mtsderives the docs' base (/ganymede/docs/) from it. The landing page uses only relative links, so it works under any prefix. - The repository has no root
package.json; the docs carry their own (docs/package.json, VitePress only). VitePress skipsnode_modulesinside its source directory, so it can live indocs/. - VitePress fails the build on links to
.mdfiles it doesn't publish. Links from docs to repository files (../README.md,../validation/) are rewritten to GitHub URLs inconfig.mts, as cdp-kernel does. - The copied stylesheet's two-column grid used
1frcolumns, whose minimum is the content's width, so a long code line widened the page on phones.minmax(0, 1fr)fixes it; cdp-kernel.org has the same issue. - The architecture diagrams are hand-written SVG in the same style as cdp-kernel's, with a
prefers-color-schemeblock for dark mode. Inside an<img>they follow the system setting, not the docs' theme switch. - GitHub Pages must be enabled with "GitHub Actions" as the source before the first deploy.
2026-10-17, survey of other Jupyter clients
Other Jupyter clients lists frontends beyond JupyterLab and VS Code and whether each could run against Ganymede.
- Most non-browser clients only need token auth,
/api/kernelspecs,/api/kernelsand the channels websocket, which Ganymede already serves. Zed's REPL is one: it readsJUPYTER_SERVERandJUPYTER_TOKENand sends the token in the websocket query. - Clients that load server HTML pages (Juno Connect wants the classic
/treepage, jupynium drives a browser UI) are blocked on pages Ganymede doesn't serve, not on the API. - nteract is now a Tauri app on a Rust daemon that talks ZMQ to local kernels; it has no Jupyter server client. Its design records are useful for our own UI.
- papermill launches kernels itself; to drive Ganymede it needs an engine using
jupyter_server'sGatewayKernelManager, which also exercises Ganymede as a kernel gateway.
2026-10-17, install script
site/install.sh wraps the Install steps: curl -fsSL https://tlockney.github.io/ganymede/install.sh | sh.
- It lives in
site/so Pages serves it at the site root; the raw GitHub URL works too. Everything runs insidemain(), so a truncated download executes nothing. - The latest version comes from the
/releases/latestredirect, as in the manual steps. With no release published the redirect goes to/releases, and the script says so and points tocargo install. - The cdp-kernel step is on by default and never fatal: without Node 24+ or with an
npm install -gthat needs root, it prints the commands instead. GANYMEDE_DOWNLOAD_BASEswaps the download base, soscripts/test-install.shtests the script against a stub release overfile://on Linux and macOS, under sh, bash, and dash, without a published release.
2026-10-17, ganymede lab install
JupyterLab's frontend without Python (#15): ganymede lab install [--version X] downloads the jupyterlab wheel from PyPI, checks its SHA-256, and unpacks <name>.data/data/share/jupyter/lab/{static,schemas,themes} plus the dist-info LICENSE into ganymede/lab/<version>/ under the local data dir. ganymede serve without --static-dir serves the newest one, or --lab-version / lab_version, and only the API when none is installed.
- The default is 4.6.4 (latest 4.x on PyPI at the time). Its wheel's SHA-256 is also pinned in the binary, so a changed PyPI listing for the default fails the install; other versions trust PyPI's JSON (
/pypi/jupyterlab/<v>/json, thebdist_wheelentry ofurls). - The wheel is about 17 MB and unpacks to 18 MB. Fetching on demand keeps it out of the release archives; bundling would make offline installs work.
- HTTP is
ureq3 (blocking, run underspawn_blocking) with rustls onringandrustls-platform-verifier, so there is no OpenSSL, no aws-lc/cmake/nasm in the release build, and the OS trust store is used: a corporate or sandbox proxy's CA installed there just works (it did here, through the agent proxy, with no extra flags). ureq readsHTTPS_PROXY/ALL_PROXYby default. The webpki roots compiled in by ureq'srustlsfeature are unused. zip9 with onlydeflate-flate2-zlib-rs: wheels use stored and deflate entries.enclosed_name()drops absolute and..paths.- Installs unpack into
.<version>-*beside the target and are renamed into place, so a bundle counts as installed oncestatic/index.htmlexists and dot-directories are ignored.lab.rsalready derivesschemas/andthemes/fromstatic/'s parent, so the installed layout needed no server changes beyond picking the directory. - The lookup (
lab::installs_dir,lab::installed, version ordering) lives inganymede-server, which both binaries share; the network and zip code stay in the CLI, away from the wasm-built crates. dirs::data_local_dir()rather thandata_dir(): the same on Linux and macOS, but%LOCALAPPDATA%on Windows, so 18 MB doesn't roam.GANYMEDE_DATA_DIRoverrides it; the tests use that.- The network test (
tests/lab.rs) runs only withGANYMEDE_E2E_LAB=1, which CI's e2e job sets on Linux and macOS;cargo teststays offline.
2026-10-17, cookie login
JupyterLab in a browser now runs notebooks against ganymede serve without the WS_TOKEN=1 workaround: its websockets authenticate with the login cookie. Checked against jupyter_server 2.21.1 (tornado 6.5) and JupyterLab 4.6 in headless Chromium.
What jupyter_server does, and Ganymede now matches (the server e2e transcript records each of these, cookie attributes included):
- Any token-authenticated request that arrives without a valid login cookie gets one, API calls included. The cookie is
username-<host with non-alphanumerics as dashes>, a tornado version 2 signed value holding a JSON user, HttpOnly,Path=<base_url>, expiring in 30 days; signatures are accepted for 31. - A correct token wins over the cookie. An invalid cookie is answered 403 and cleared (
name=""with a past expiry). - Pages redirect a browser with no login to
login?next=<url-encoded path and query>; the API answers403 {"message": "Forbidden"}. /loginrenders a form with a hidden_xsrffield and sets the_xsrfcookie if missing. The POST needs that field (403 without it), answers 401 for a wrong token, and 302s tonextwith the cookie.nextmust be a path underbase_url, elsebase_url./logoutclears the cookie and, being a rendered page, also sets_xsrf.- XSRF applies to methods other than GET, HEAD, and OPTIONS that did not send the token, and runs before the auth check, so an unauthenticated POST gets the XSRF message, not "Forbidden". The token comes from
_xsrf(query or form),X-XSRFToken, orX-CSRFToken. Messages:'_xsrf' argument missing from POST,'_xsrf' argument has invalid format,XSRF cookie does not match POST argument. A cross-origin request fails first with 404._xsrfvalues are2|<4-byte mask>|<masked token>|<time>and compare by the unmasked token; any other numbered version is invalid, and an unnumbered value is a version 1 token, hex or verbatim._xsrfis not HttpOnly, because JupyterLab reads it to set the header. - Cookie-authenticated API requests from another origin get 404, as jupyter_server's
check_origindoes when the token was not sent. - The page config still carries the token, so JupyterLab's REST calls are token-authenticated and need no XSRF header; only websockets and
<img>loads (kernelspec logos, which were 403 before) rely on the cookie.
Choices where Ganymede differs:
- The cookie secret is persisted, as jupyter_server does, in
jupyter_cookie_secretin the runtime dir (owner-only), with the same derivation (HMAC-SHA256 of the file with an empty message, the no-password case).cookie_secret_filein the config moves it. Logins survive a restart; changing the token does not log browsers out, also as in jupyter_server. If the file can't be written, the key is per process. - The login cookie adds
SameSite=Lax, which jupyter_server does not send. The e2e transcript ignores that attribute. - A page URL with a correct
?token=sets the cookie and redirects to the same URL without the token. jupyter_server renders the page with the token still in the address bar. API paths and websocket upgrades keep the parameter. nextis never followed to another origin, even one inallow_origins.- With auth disabled there is no XSRF check. jupyter_server still checks unless
disable_check_xsrfis set. /api/mestill reports one anonymous identity; the cookie's user is not read back.
Findings:
- Python's cookie formatting quotes values containing
=(base64 padding), so jupyter_server'sSet-Cookiesometimes has a quoted value and sometimes not. Browsers send the quotes back; the parser strips them. - tornado's
_websocket_maskneeds a 4-byte mask, so a shorter mask in a version 2_xsrfis "invalid format", not a mismatch. The transcript caught this. form_urlencoded::Serializer::newwith a target that already ends in?adds a&; build the query separately.
2026-10-17, notebook trust
Notebooks are signed and marked trusted as jupyter_server does, so JupyterLab runs the HTML and JavaScript outputs of a notebook it reopens (issue #18). ganymede-contents has trust::Notary, FsContents signs on save and marks cells on read, the server serves POST /api/contents/{path}/trust, and ganymede trust replaces jupyter trust.
- Ganymede shares Jupyter's store instead of keeping its own:
notebook_secretandnbsignatures.dbin the Jupyter data dir (--notary-dir/notary_dirmoves them). A user moving from Jupyter keeps their trusted notebooks, andjupyter trustandganymede trustagree;parity.rschecks both directions. - There is no mature pure-Rust SQLite (
turso_coreis a pre-release), so the database goes throughrusqlitewith SQLite compiled from C. It sits behind the contents crate'ssqlitefeature, which only the server enables:ganymede-contentsalone still checks for iOS and Android with no C cross compiler, and the engine, which does not depend on it, still builds for wasm. Without the feature signatures are kept in memory, as nbformat does whensqlite3is missing. - The signature is an HMAC-SHA256 over nbformat's
yield_everything: for each object, keys in sorted order each followed by its value; strings as UTF-8; everything else as Python'sstr()(True,None,1e-05, so the writer's float repr is reused);metadata.signatureskipped. List items are concatenated with no separator, so a source split into lines hashes the same as the joined string. The key is the secret file's bytes, trailing newlines included. Fixtures fromtests/fixtures/generate_trust.pypin it. - What is hashed is the notebook as served, without
trusted. On save Ganymede hashes the notebook as it will be read back rather than the client's JSON, which is what makes the save-then-reopen round trip verify. jupyter_server hashes the client's dict, so atrustedflag on a markdown cell, which nbformat drops on read, would make its own signature miss. - The save rule is
check_cells: a code cell is trusted when itsmetadata.trustedis truthy (popped either way) or noexecute_resultordisplay_dataoutput has a key besidesoutput_type,execution_countandmetadata. An emptydata: {}counts as unsafe. JupyterLab setstrustedon cells it ran, which is how "every output was produced in this session" reaches the server. - Signatures are content-addressed, so a byte-identical copy of a trusted notebook is trusted too. The JupyterLab check in
validation/trust.mjschanges the copy's metadata before saving it untrusted, or the control would pass for the wrong reason. - nbformat does not create the data dir before writing the key, so
jupyter server --NotebookNotary.data_dir=<missing dir>answers 500 on every notebook read. Ganymede creates it; the parity test creates it for the reference. jupyter trust --resetwrites 1024 raw random bytes as the new key; the key Ganymede generates (then and on first use) is the base64 text nbformat writes on first use. Any bytes work as an HMAC key.- Checked with JupyterLab 4 in headless Chromium against
ganymede serve: after save and reopen in a fresh page, the HTML output's<script>and theapplication/javascriptoutput both run; in an edited copy saved withouttrustedflags neither does.
2026-10-17, releases through a PR
- The first
release.sh --executecould not push the release commit and tag tomain: Claude sessions may push only their own branch (HTTP 403 from the proxy), and direct pushes also skip the PR-links-issue rule. - Now cargo-release only bumps and commits (
tag = false,push = falsein release.toml). The script moves that commit torelease/vX.Y.Z, resets localmain, pushes, and withghopens an issue and a PR that closes it. A release can be cut by merging a PR from the GitHub app. tag-release.ymlruns on pushes tomainthat touchCargo.toml. It tags only when the workspace version has a CHANGELOG.md section and no tag, so dependency changes and the untagged 0.0.1 are ignored.- A tag pushed with
GITHUB_TOKENtriggers no workflows, butworkflow_dispatchis exempt, so tag-release runsgh workflow run release.yml --ref vX.Y.Z(needsactions: write). Dispatched on a tag ref,github.ref_typeistag, so release.yml's version check and publish job behave as on a pushed tag.
2026-10-17, kernels that never answer under parallel starts (#25)
e2e (ubuntu-latest) failed with "timed out waiting for kernel_info_reply" on a PR that changed no Rust. Locally, 40 parallel ganymede run -k cdp-node failed about 1 in 10, as "timed out", "kernel died", or "zmq: Provided sockets combination is not compatible"; the kernels e2e binary failed 2 of 12 runs four at a time.
- Ports are free when picked and taken by whoever binds first. Until the kernel binds, a host's connection can land on another socket on that port: another host's free-port probe, or another kernel's PUB.
- The
zeromqcrate reconnects SUB sockets but not DEALERs. A DEALER whose first peer went away stayed unconnected, and every send failed with "Not connected to peers" until the timeout. DEALERs now reconnect on a failed send, and a failed handshake is retried, not fatal. zeromq's connect retries a refused port with backoff up to 5 s, and cutting a handshake short with itsconnect_timeoutcan leave the kernel's ROUTER holding our routing id, so later connections with it are ignored (attach tests timed out that way). A plain TCP probe, retried every 20-250 ms, now waits for the port before zeromq connects.ZmqTransport::connectreturns at once; each socket connects in its task, so the startup timeout bounds the whole start. Shell sends nothing until stdin and iopub are connected (aninput_requestneeds the stdin socket, a first request's outputs need iopub). The nudge keeps sendingkernel_info_requeston shell until iopub has delivered something, since the SUB socket can connect after shell and control answered.- Two kernels can still be given the same port; the loser exits or never answers.
Relaunch::start(engine start and restart) launches again on fresh ports, up to 3 times, afterKernelDiedor a startup timeout. - Ports are picked at random from 20000-32767, below the ephemeral ranges, so a reconnecting socket cannot be given the kernel's port as its own source port.
- Connecting lazily exposed a bug in the API's restart: the new kernel reuses the id and so the connection file path, and dropping the old launch afterwards deleted the new kernel's file. ipykernel that had not read it yet wrote its own with other ports. The old launch is now dropped before the new kernel starts.
- Result: 0 failures and 0 relaunches in 320 parallel CLI starts, and the whole
ganymede-clie2e suite passes 12 of 12 runs four at a time. The API's own launch path gets the reconnects but not the relaunch yet.
The server parity test later failed once on CI with jupyter_server answering a DELETE with 400. Its contents manager sends files to the trash by default, and send2trash creates ~/.local/share/Trash by checking that it exists and then calling makedirs. On a fresh runner the cdp-kernel and ipykernel parity tests delete at the same moment, both see no trash directory, and one makedirs fails. Paired deletes into a fresh home failed 1 time in 30 locally. The reference server now runs with --FileContentsManager.delete_to_trash=False, which also matches ganymede, which deletes for good.
2026-10-17, prebuilt JupyterLab extensions from wheels
ganymede lab extension install|list|remove (#16) unpacks *.data/data/share/jupyter/labextensions/** from a package's PyPI wheel into ganymede/labextensions/ under the data dir, which serve searches after --labextensions-path and before the Jupyter data dirs. ganymede lab install installs jupyterlab_widgets and anywidget too, unless --no-default-extensions. cdp-kernel's counter widget from its docs/widgets.md renders in JupyterLab served this way, counts clicks, and the kernel reads the count back (validation/widgets.mjs).
- anywidget does not require
jupyterlab_widgetsdirectly: it requiresipywidgets, which has no extension and requiresjupyterlab_widgets. So "follow only dependencies whose wheel carries an extension" stops too early. The rule used: walkrequires_distfrom PyPI's JSON (no downloads), withoutextramarkers, descending only through packages classifiedFramework :: Jupyter; download and unpack those classifiedFramework :: Jupyter :: JupyterLab :: Extensions. For anywidget that is about 20 JSON requests (ipython's dependencies are checked and dropped) and two wheels, under a second here. A named package is always downloaded and must carry an extension. - Dependencies get their latest release; specifiers like
~=3.0.17are not checked. Other markers (sys_platform, ...) are ignored, which can only add a frontend package. - Only pure (
py3-none-any,py2.py3-none-any) wheels are picked, which also applies tojupyterlabnow. - Each package's record is
labextensions/.packages/<name>.json(name, version, extension directories, and the package that pulled it in).find_extensionsskips it because it has nopackage.json, as it skips the.<name>-*staging directories. Remove deletes only the named package's extensions; dependencies stay. - An extension is a top-level directory, or two levels for
@scope/name. Reinstalling drops extensions the new version no longer ships; one that another package had installed is taken over and dropped from that package's record. jupyterlab_widgetsships a settings schema under its extension'sschemas/;/lab/api/settingsalready lists it (@jupyter-widgets/jupyterlab-manager:plugin).lab::data_dir()is now the shared root forlab/andlabextensions/.
2026-10-17, ganymede lab
One command to start JupyterLab (#17): ganymede lab [--no-browser] [serve flags] in the notebook directory.
- Command shape:
labalready hadinstall/list/remove, so the start flags are flattened ontolabitself with an optional subcommand and clap'sargs_conflicts_with_subcommands. Bareganymede labmatchesjupyter lab;ganymede lab list --port 1is a usage error, not a silently ignored flag. Alab startsubcommand would have been the same code with worse parity. The start code is in its own module (lab_start.rs) solab.rskeeps only the bundle management. - The server side is
serve::run_with(cfg, Startup);runisrun_withwith the default.Startupcarriesport_retries,require_lab, and anon_listening(url)callback, so the browser is opened by the CLI after the port is bound and nothing in the server knows about browsers. - Port fallback follows jupyter_server's
port_retries(default 50) but sequentially, without its random ports after the first few, and also when--portwas given. OnlyAddrInUsemoves on; any other bind error fails at once.--port 0never retries.servekeeps failing on a taken port. - The printed URL is
/lab?token=…, asjupyter labprints. The page'sstrip_tokenmiddleware turns that into the login cookie and a redirect to/lab, so the browser opened on it is logged in. - The browser is opened with
open(macOS),xdg-open(elsewhere on Unix), orexplorer(Windows), spawned with null stdio and reaped on a thread. A missing opener or a failing exit is a warning; the URL is on stdout.$BROWSERis not consulted, unlike Python'swebbrowser. - No JupyterLab installed (or a
--static-dirthat isn't a Lab bundle) fails before binding, namingganymede lab install. The "serving the API only" log moved frominstalled_labto the caller solabdoesn't print it right before failing. - The test (
tests/lab_start.rs) needs no network:Lab::detectonly needsstatic/index.htmlcontainingjupyter-config-dataand apackage.jsonwith ajupyterlabkey, under$GANYMEDE_DATA_DIR/lab/<version>/. It holds a port open to check the fallback.
2026-10-17, workstation install with no Python
CI's workstation job (#20) follows Install on ubuntu-latest and macos-latest with Python hidden, through scripts/test-workstation.sh <binary>: the PR's release build, packaged as release.yml packages it and served over file:// with GANYMEDE_DOWNLOAD_BASE, installed with curl ... | sh -s -- --version; then npm install -g @tlockney/cdp-kernel, cdp-kernel install --runtime node, ganymede kernelspec list (must show cdp-node), ganymede run -k cdp-node, ganymede lab install, and ganymede lab --no-browser in a notebook directory. Playwright then runs validation/login.mjs (the printed URL lands on /lab with the login cookie, and the cookie alone lists cdp-node), trust.mjs with a TypeScript cell (jupyter.html with a <script>, plus a raw application/javascript bundle), and widgets.mjs with RELOAD=1.
- Hiding Python:
PATHis a shim dir,~/.local/bin, npm's global bin, and a clean dir of symlinks to node, npm, npx, curl, tar, gzip, sha256sum/shasum, and coreutils. Shims namedpython,python3,pip,pip3, andjupyterlog the call and exit 1. They miss absolute paths, so a sampler also logs, every 0.5 s, any process namedpython*that descends from the script. Sampling all processes would fail on runners, which run Python daemons of their own. Both were checked to fire by callingpython3and/usr/bin/python3on purpose. - The script sets
HOMEto a new directory, as on a fresh machine, and unsets theXDG_*,JUPYTER_*, andGANYMEDE_DATA_DIRvariables, so the kernelspec, JupyterLab, and extensions land under it and a local run touches nothing else. Playwright's browsers are found throughPLAYWRIGHT_BROWSERS_PATH, defaulted from the realHOME.NPM_CONFIG_PREFIXpoints npm's global installs into thatHOME; without it they go next to setup-node'snode, which is not on the cleanPATH. install.shinstalls cdp-kernel itself when Node 24 is present; the explicit npm andcdp-kernel installsteps from "A kernel" then run again, which also checks that both are idempotent.- npm 11 (Node 24.22) skips install scripts not listed in
allowScriptsand warns about esbuild's postinstall. cdp-kernel runs without it. - Widgets after a reload need no saved widget state: JupyterLab's widget manager asks the running kernel (
comm_info_requestforjupyter.widget, then each model's state), and cdp-kernel answers, so the reopened page showscount is 3. The saved notebook holds only theapplication/vnd.jupyter.widget-view+jsonoutput. - The Playwright tooling (
npm ci,playwright install --with-deps) is set up before Python is hidden; it is the test harness, not the workstation. - On macOS the script relies on
/usr/bin/shasum(a Perl script with an absolute shebang) and BSDps. - JupyterLab saves on Cmd+S on macOS, where Playwright's Chromium reports a Mac platform, so
Control+ssilently does nothing there. The scripts pressControlOrMeta+s.
2026-10-17, the workstation install page
Install on a workstation (#21) is the numbered path from a clean Linux or macOS machine to JupyterLab running TypeScript, for a reader who has never installed Jupyter. Its commands are the ones scripts/test-workstation.sh runs, and the page's source comment lists where the two differ and why.
- To cover the page, the script gained three steps:
ganymede execute first.ipynb --inplaceon the page's first-notebook cell (annpm:import of date-fns andjupyter.displayofjupyter.md; the output is checked in the saved file),ganymede servewithvalidation/vscode.cjsreplaying the VS Code Jupyter extension's requests, and the uninstall (ganymede lab remove,lab extension remove,kernelspec remove,npm uninstall -g,rm ~/.local/bin/ganymede). It also runsganymede kernelspec pathsandganymede info, which the troubleshooting section uses. The full script passed locally on Linux x86_64 with Node 24.22 and a debug build. cdp-kernel listandcdp-kernel uninstall(cdp-kernel #251) are on cdp-kernel's main but not in 0.5.0 on npm, so the page givesganymede kernelspec remove cdp-nodeand mentions them as the alternative for later releases. Add them to the script once released.- The VS Code Jupyter extension doesn't need the Python extension for an existing server: its
package.json(2026.6.1) has noextensionDependencies, and itsextensionPackis Keymap, Renderers, Cell Tags, and Slide Show. Not checked by running VS Code. ganymede servedoesn't fall back to another port aslabdoes; it exits withbinding 127.0.0.1:8888: Address already in use.ganymede lab installhonoursHTTPS_PROXYthrough ureq; cdp-kernel'snpm:imports runnpm install, so npm's proxy settings apply.
2026-10-17, docs unstyled on the custom domain
Pages got the custom domain ganymede-server.com, and tlockney.github.io/ganymede/ now redirects there. The docs were still built with SITE_URL=https://tlockney.github.io/ganymede, so VitePress wrote every asset and link under /ganymede/docs/, which 404s on the custom domain: the docs rendered without styles. The landing page uses relative paths and kept working. SITE_URL (pages.yml, and the default in config.mts) is now the custom domain, and the install-script URLs point at it directly instead of through the redirect.
- A project page (
<user>.github.io/<repo>/) and a custom domain serve the same files at different path prefixes. Anything with an absolute base path has to be rebuilt when the domain changes; relative paths survive it. - With an Actions deployment the custom domain lives only in the Pages settings, not in a
CNAMEfile in the repository, so nothing in the build sees a change there. - The deploy job now fetches the landing page,
install.sh, a docs page, and the stylesheet that page links fromSITE_URLafter each deploy, so a base path that doesn't match the domain fails the deploy instead of shipping unstyled docs. Run against the old deploy, the stylesheet check 404s, as it should. - No
site/CNAME: GitHub ignores aCNAMEfile in an Actions-deployed artifact, so it would not keep the domain set; the Pages settings are the only place it lives.
2026-10-17, auth off no longer means open to every web page (#41)
With --token "" the API middleware skipped every check, so any page in the user's browser could create files, trust notebooks, and start kernels with a cross-site text/plain POST (no preflight), and a DNS-rebinding page could read files. jupyter_server keeps two protections with auth off, and Ganymede now matches them:
- XSRF applies to every state-changing request that did not send the token, whether or not auth is on (
check_xsrf_cookieonly skips token-authenticated requests). With auth off, a client that is not a browser must send_xsrfas a cookie and anX-XSRFTokenheader, or run with a token. Reads and websocket upgrades are unaffected; the websocket handlers already checkOrigin. Hostmust be a loopback address or a name inlocal_hostnames(defaultlocalhost) unlessallow_remote_accessis on. It defaults to on only whenipis not loopback, as_default_allow_remotedoes; a host name foripcounts as loopback when every address it resolves to is. The check runs on every route, pages included, and a request with noHostpasses, as tornado treats it as local.- A rebinding page is same-origin with itself (its
OriginandHostagree), so theOrigincheck cannot catch it; onlyHostshows the request was meant for another name. - A reverse proxy in front of a loopback-bound server needs
--allow-remote-accessor its public name inlocal_hostnames, unless it rewritesHost.
2026-10-18, request body limit (#42)
axum caps every body extractor at 2 MB (DefaultBodyLimit) unless a layer says otherwise, and a notebook is saved in one PUT, so a notebook with one large image output could not be saved. The rejection's status was also thrown away: LenientJson mapped every body error to 400.
- The limit is now
max_body_sizein the config file, 512 MiB by default as injupyter_server(ServerApp.max_body_size), applied to every route inbuild_router. - Body rejections keep their own status, so an oversized body gets 413 instead of 400.
DefaultBodyLimitonly affects extractors that read the body (Bytes,Json,Form,LenientJson); it does not limit websocket frames, which have their own caps inWebSocketUpgrade.
2026-10-18, websocket decoders and hostile frames (#48)
legacy::decode_binary indexed offsets[0] without checking the count, so a 4-byte binary frame of zeros on a legacy (no subprotocol) kernel websocket panicked the connection task. It now returns Malformed, and the server logs the frame and keeps the socket open, as it does for any undecodable frame.
- Both decoders now have negative tests: empty and short frames, a zero or too-small count, a count past the frame, truncation, overlapping offsets, and an offset past the end. A decoder may fail with
Jsonrather thanMalformedwhen bad offsets still leave a parsable range (overlapping legacy offsets make the JSON part run into the buffers); the tests only require an error, never a panic. - An attacker-sized count (
u64::MAX) does not allocate: the offset table is collected throughResult, whose size hint starts at 0, and the first read past the frame stops it.
2026-10-18, numbers round-trip on save (#72)
The workspace serde_json had only preserve_order. Its default float parser is not correctly rounded, so reading a notebook and writing it back changed some doubles (5,389 of 20,000 random finite doubles in the new test), saves drifted further each pass, and integers beyond u64 became floats. Trust signatures hash the re-serialized numbers, so a notebook Jupyter signed could read back as untrusted. Both float_roundtrip and arbitrary_precision are on now.
arbitrary_precisionkeeps each number's source text inValue, so the writer'swrite_f64hook no longer sees numbers from a parsed file;PythonFormatter::write_number_strdoes the work instead: floats go throughpython_float_repr, integers are written as read (-0as0, as Python'sintdoes).Number::is_f64is true only for text with.,eorE, so the signing code's int/float split matches Python's for big integers.float_roundtripstill matters witharbitrary_precision: it governs deserializing straight into typedf64fields.#[serde(flatten)]structs with typed integer fields still deserialize from text witharbitrary_precisionon (checked onExecuteResult); older serde releases failed there.- A JSON number Python reads as
inf(1e400) keeps its text; Python would write the non-JSONInfinity. - The Python e2e notebook's
JSONdisplay now carries 17-digit floats, which fails the nbclient parity test without the fix.
2026-10-18, atomic notebook writes from the CLI (#74)
ganymede execute --inplace and -o wrote the notebook with std::fs::write, which truncates the file first. A crash, a full disk or Ctrl-C mid-write left a truncated or empty notebook, and an editor reading the file at the wrong moment saw half of it. The server's contents layer already wrote through a temp file and a rename; the CLI did not share it.
- The temp-file, fsync, rename helper moved from
ganymede-contentsintoganymede-nbformatasatomic_write.Notebook::write_file(and so the CLI'swrite_notebook) and the contents layer both use it. - A symlink is followed to its target, which is replaced; the link stays. The old rename put a regular file in place of the link.
- The temp name
.~<name>-XXXXXX.tmpis cut to fit in 255 bytes, so a file with a long but legal name can still be saved.
Leftover temp files from a killed process are not cleaned up; tempfile removes its file on any error short of that.
2026-10-18, file I/O off the async workers; nbconfig PATCH serialized (#70)
Several HTTP handlers did blocking std::fs work on tokio's async workers: /api/config/{section}, /api/kernelspecs (which reads every kernelspec directory and its resource files on every call), and the JupyterLab settings, workspaces and licenses endpoints. A slow disk stalled every connection served by that worker.
- A
routes::blockinghelper runs a closure onspawn_blocking; those handlers' bodies now run inside it. PATCH /api/config/{section}was an unlocked read-merge-write with a plainfs::write: two PATCHes at once lost one update, and a crash mid-write left a truncated file. PATCHes now hold a mutex, and the file is replaced with the same temp-file-and-renamewrite_atomicthe lab settings use. A test sends 16 concurrent PATCHes and fails without the lock.
Left as is: FsKernelspecs::list still reads the disk on each call (now off the async workers), as jupyter_client does.
2026-10-18, cdp-kernel 0.6.0 pin (#132)
- CI's
CDP_KERNEL_VERSIONmoves from 0.5.0 to 0.6.0; README and workstation.md follow. - 0.6.0 ships
cdp-kernel listandcdp-kernel uninstall(cdp-kernel#249), so the workstation page's uninstall step usescdp-kernel uninstall cdp-nodein place ofganymede kernelspec remove, andscripts/test-workstation.shruns both commands. It removes only kernelspecs cdp-kernel wrote, from the same kernels directory Ganymede reads, so the check thatganymede kernelspec listno longer showscdp-nodestill holds.
2026-10-18, more cdp-kernel scenarios, a nudge that backs off (#69)
The startup nudge re-sent kernel_info_request every 500 ms until shell and control both answered. cdp-kernel answers on shell only once its runtime is up, on one serial loop, so a slow Node start queued a request per round for up to a minute and then answered each with a busy/idle pair. Each unanswered request now waits twice as long as the last (0.5 s up to 4 s), and shell and control are asked at the same time rather than one after the other. A fake kernel whose shell is silent for 30 s (paused clock) received 51 requests before and about ten now.
kernels.rs gained the cdp-kernel e2e.py scenarios that a Rust host can drive, on cdp-node and python3 alike: queued cells aborted after an error (and not with stop_on_error: false), input without stdin, an interrupt while blocked in a prompt, the runtime exiting mid-cell (cdp relaunches it; for ipykernel the kernel dies and the session says so), interrupt_request and shutdown_request on shell, a silent execute publishing nothing, comms to an unknown target closed, a throwing handler, a frontend comm_close, binary buffers echoed through a comm, history tail and search, display then update in order with prints around them, and four kernels started at once.
The shell shutdown_request scenario failed on macOS with KernelDied: the kernel replies and exits, and the session saw the exit and failed every waiting request before the reply had come through the sockets. Now, when the process exits, requests still waiting get up to 500 ms for their replies before the session is marked dead. A jupyter_client client reads the reply off the socket whether the process has gone or not. A unit test has the process exit 50 ms before its reply.
Learned: ipykernel answers interrupt_request on shell by sending SIGINT to its own process group, which interrupts that handler, so no reply comes; that check runs on cdp-kernel only.
Not done: debugger requests, malformed stdin frames, kernel death and parallel starts through serve, and a CI run against cdp-kernel main.
2026-10-18, chunked uploads append (#52)
JupyterLab uploads files over 15 MB in 1 MB pieces, each a PUT with chunk: 1, 2, ... and -1 for the last. The contents layer ignored chunk and saved every PUT as the whole file, so an upload kept only its last megabyte, silently.
SaveRequest now carries chunk, and FsContents::save follows jupyter_server's LargeFileManager.save: chunk 1 writes the file (atomic replace, as a normal save), any other chunk appends its decoded text or base64 content. Only type: file can be chunked (400 otherwise), and chunks skip the last_modified conflict check, as jupyter_server does.
The HTTP status follows from the existing 201-if-new rule: chunk 1 of a new file is 201, later chunks 200, which is what jupyter_server answers.
2026-10-18, CI hygiene (#62)
CI ran every action by a movable tag, with no permissions: in ci.yml, no dependency audit, no MSRV, no timeouts, and the e2e pins only in the e2e job: the workstation and install-script jobs took whatever cdp-kernel npm had that day. Now:
- Actions are pinned by commit SHA with the version in a comment, and Dependabot bumps them, the Cargo lockfiles, and the npm packages weekly. Checkouts don't keep the token (
persist-credentials: false). ci.ymliscontents: read, PR runs cancel their older runs, and every job has a timeout.rust-version = "1.95"and anmsrvjob. 1.95 is not a guess: rustyline 19 usescfg_select!, stable since 1.95, without declaring arust-version, so the highest one incargo metadata(1.89) is wrong. Only building with the old toolchain finds that.cargo denyfor advisories, bans, and sources. Thirteen crates have two versions (base64, digest, getrandom, syn, thiserror...); they are warnings, not failures.- One
env:block holds the e2e pins;test-workstation.shandtest-install.shreadCDP_KERNEL_VERSION(the latter with an npm shim, sincesite/install.shinstalls the latest, as a user gets it). A weekly run uses the latest releases instead. - The
e2ejob runscargo test --workspace(unit tests on macOS too) and fails if any test printedskipping:; therustjob lists its skips in the run summary.cargo docwith-D warningsfound one broken intra-doc link.
See development for the rules.
2026-10-18, CLI behaviour: exit codes, stdin, --json gaps (#71)
The second half of the review's CLI list: places where the commands did something a script could not see or rely on.
ganymede run -k fake </dev/nullran an empty cell and exited 0. Empty or blank stdin is now a usage error, exit 2, like clap's own.run --jsonprinted nothing for a cell that timed out or whose kernel died, only an error on stderr, so "stopped here" looked like "ended". It now prints a line with"status": "incomplete"and the error.executestopped the whole run on an unreadable notebook or a kernel that would not start, but carried on past a failing one. Each kind is now reported and skipped, and the exit status is 1 if any failed.- Exit statuses are documented in
--helpand getting-started.md: 0, 1 for any failure (asjupyter execute), 2 for usage, 128+N for signals. kernelspec removeasks first when stdin is a terminal, asjupyter kernelspec removedoes;-f/-yskips the question. Scripts, with no terminal, are not asked.jupyter execute's flag names (--kernel_name,--startup_timeout,--skip-cells-with-tag,--allow-error-names) are accepted as aliases.- Console: password prompts turn off echo (with
stty, on a terminal), and history is saved after every cell, not only on:quitor Ctrl-D.
Not done: kernel list, server list and lab stop, which need a design; quieting the global flags on commands they don't affect; and moving the console history out of the Jupyter data dir.
2026-10-18, CLI gaps: completions, lab clean, extension toggles (#71)
The first half of the review's CLI list: commands a Python-free user had no way to run, and flags with no help text.
ganymede completions <shell>prints a completion script (viaclap_complete) for bash, zsh, fish, PowerShell and elvish.attachis listed and documented. It was hidden, though it isrun --existingwith a 60-second default timeout.run --interrupt-after-msis listed too.lab cleanremoves the.download-*and staging directories that interrupted installs leave.lab uninstallremoves every JupyterLab version and extensionlab installadded.lab extension disable/enablewritedisabledExtensionsinlabconfig/page_config.jsonunder the Jupyter config dir, in JupyterLab 4's{"name": true}shape, asjupyter labextension disabledoes. The server now reads that file on each page load (both the map and the older list shape); it used to always send[].lab list --jsonandlab extension list --json.- Help text for the positionals of
kernelspec install/remove,lab remove,lab extension remove,execute,nb clearandtrust; forkernel -k; and forserve's--ip,--root-dir,--kernelspec-root,--default-kerneland--cull-idle-secs.
2026-10-18, default config file, log flags, kernel output prefix (#55)
What the first #55 fix left for later:
- Without
--config,serve,labandganymede-serverreadganymede.tomlfrom the Jupyter config dir ($JUPYTER_CONFIG_DIR, else~/.jupyter, asjupyter_config_dir()) when it exists, so a daemon needs no flags. The file read is logged at info.--configstill wins. --log-leveltakes tracing's names and Jupyter'sWARNINGandCRITICAL, and overrides-vandRUST_LOG.--log-fileappends the log there, without colors, instead of stderr; stdout keeps only the URL. Both areServeArgsflags, solabhas them too; the CLI installs the subscriber from them before dispatch.- The kernel host has a
LaunchOptions.prefix_output: the kernel's stdout and stderr are piped and copied to the host's stderr a line at a time behind[kernel <first 8 of id>]. The kernel manager sets it; the CLI's single-kernel commands don't. "launched kernel" now logs the fullkernel_id, so the short prefix can be traced back.
Learned: jupyter_server itself has no prefix (kernels inherit its stderr); the prefix is ours, for a server whose kernels share one log.
2026-10-18, connection files private from the start (#49)
write_connection_file wrote the file with the default mode (0644 under the usual umask) and only then chmodded it to 0600. In between, any local user could read the HMAC key and sign messages to the kernel. It now follows jupyter_core.paths.secure_write:
- Remove any existing file, then create it with
create_newand mode 0600, so the key is never readable by others and a symlink planted in a shared runtime dir is replaced instead of written through. Removing first keeps restarts working: a restart writes the samekernel-<id>.jsonagain. - A runtime dir the host creates is made 0700. An existing dir keeps its mode; the user may have chosen it.
kernel_idgoes into a file name, so it must be letters, digits,-and_(128 at most).LaunchOptions.kernel_idis public, and callers other than the API's UUIDs could pass../x.
2026-10-18, contents races, first checkpoint, trust key (#75)
The review found the last_modified conflict check misdocumented and unlocked, and a few smaller jupyter_server parity and race gaps in ganymede-contents.
- docs/ipados.md said the mtime check on PUT is what
jupyter_serverdoes. It is not: jupyter_server has no such check, and JupyterLab compares timestamps itself before saving. The check stays as an opt-in extension: only a PUT that sendslast_modifiedgets it, and a client echoing the value the server reported never gets a 409, since that value is the mtime cut to microseconds and the window is 500 ms. A stale echoed model does get 409 when the file changed since; that is the protection the iPad/iCloud case wants, so we kept it. - The conflict check and the write of one save now run under a per-path lock, so two saves of one path cannot both pass the check and then interleave.
- The first save of a notebook creates its checkpoint (jupyter_server's "One checkpoint should always exist for notebooks"). Before, there was nothing to revert to until a client POSTed one.
GET ?hash=1read the file twice; it reads once and hashes the bytes it serves.new_untitledandcopypicked a free name, then wrote over whatever was there. They now claim the name withcreate_new(create_dirfor folders) and move on to the next name if someone else took it.- Trust: the key file was created, then written; a crash in between left an empty key that was accepted forever. The key is now written to a 0600 temp file and linked into place (no-clobber for a new key, rename for
--reset), and an empty key file is replaced with a new key.
Left out: rename still checks that the target is free and then renames. Closing that needs a no-replace rename (renameat2 on Linux, renamex_np on macOS), which std does not offer for directories.
2026-10-18, Ctrl-C stops the server with requests open (#125)
Ctrl-C did not stop ganymede lab (v0.2.0). axum's with_graceful_shutdown waits for every open connection to finish its request, with no limit. Idle keep-alive connections close at once and upgraded websockets are no longer tracked, so a JupyterLab tab alone did not hang it in headless Chromium; one connection with a request still in progress did, indefinitely. Reproduced by sending headers without the closing blank line, then SIGINT: the server logged "shutting down kernels" and stayed up. A second Ctrl-C did nothing, because the signal future had already fired and nothing else listened.
serve_until now bounds the drain: after the first Ctrl-C or SIGTERM, open requests get 3 seconds, then the server stops serving and shuts kernels down; a second signal skips the wait. The signal handlers are installed once, before the port is bound, and feed a channel, which is also what the tests drive instead of real signals. Which request a real browser left open in Thomas's case was not established; the bound covers any of them.
2026-10-18, one file per log entry (#83)
Every PR appended to the end of docs/notes.md, so any two open PRs conflicted there and each merge made the others resolve it. Entries are now files in docs/notes/, named YYYY-MM-DD-<topic>.md; the log before the split is docs/notes/archive.md, unchanged apart from its links.
- The site still has one log page.
docs/.vitepress/notes.tsappends the entries tonotes.mdin a Vite plugin that runs before VitePress's Markdown plugin, so the outline and the dead-link check see the full page.srcExcludekeeps the entries from becoming pages too. - Local search does not go through Vite: it reads each page from disk. Its
_renderoption gets the same treatment, or the entries would be missing from search. - Entries link relative to
docs/notes/so they work on GitHub; the plugin rebases those links todocs/for the page. - A file in
docs/notes/with another name fails the build, rather than silently dropping off the page. - Entries on the same day sort by topic, not by merge order.
2026-10-18, docs checked against what shipped (#66)
The review found docs promising what the code didn't do, and since then several of those promises have shipped. A pass over each claim:
- Roadmap: cdp-kernel conformance, kernel lifetime (reconnect replay, death reporting, relaunch, shutdown steps),
ganymede laband its installs,install.sh, and the workstation job moved to Done. Open gained auto-restart, the remaining conformance gaps, nbformat 3, and musl. - Protocol stays a requirements list, but each item Ganymede doesn't meet now says so: no
sys.prefixkernelspec root (on purpose), heartbeat only for attached kernels, no autorestart, no Windows job object, no terminals, assets from disk, no v3. - The language rule said a grep for
pythonin the host crates "finds nothing". It finds nbformat's float layout, the trust signature, PyPI host names, and comments. The rule is about behaviour, and is reworded to say that: no code depends on a kernel's language. metadata.execution.timeouton a cell was documented "as in nbclient" (nbclient has no such key) and never worked: timings resetmetadata.executionbefore the timeout was read. It is read first now, with a test, and documented as Ganymede's own.- Crate READMEs: the api crate's broadcast hub is gone, nbformat's big-int and cell-id gaps are fixed, contents has a validation message, and the server checks
Host. The README's crate table lost its hand-kept test counts.validation/usesganymede serve. The frontend README says the frontend isn't used yet, and that CI runs it on Node 24.
Already fixed before this pass: the iPad page's mtime-check wording, the byte-identical claim (#79, #73), and the log's structure (#83).
2026-10-18, the site deploys from releases, not main (#117)
ganymede lab printed its usage on v0.1.0: that tag predates #33, which added the bare command, but the docs site deployed from main on every merge, so the workstation page described it before any release had it. The CI workstation job builds the PR's own binary, so it could not see the gap.
pages.ymlno longer deploys on push.release.ymldispatches it with the new tag aftergh release create, and the build checks out that tag, so the docs andinstall.shmatch whatinstall.shinstalls. A manual run with norefdeploys the latest release; with arefit publishes a docs fix between releases.- The dispatch runs on
main(--ref main, the workflow file's ref) and passes the tag as an input. Running the workflow on the tag itself would make thegithub-pagesenvironment, which by default only allows the default branch, refuse the deploy. - A workflow started with
GITHUB_TOKENtriggers nothing, exceptworkflow_dispatch, which is why tag-release.yml and release.yml chain throughgh workflow run.
2026-10-18, docs title links to the home page (#128)
The "Ganymede" title in the docs' top bar linked to the docs index. It now goes to the landing page at the site's root, through VitePress's themeConfig.logoLink. The link needs a target (_self): VitePress's router intercepts same-origin links without one and would try to load the root as a docs page. The nav's Home item uses the same trick.
2026-10-18, --existing finds kernels in the runtime dir (#56)
ganymede kernel writes kernel-<id>.json into jupyter_runtime_dir(), but run --existing kernel-<id>.json read the name relative to the current directory, so the README's own example failed anywhere else. The error also printed the OS error twice.
process::find_connection_fileresolves the argument asjupyter_client.find_connection_filedoes, without its globbing: a path that exists is used as given; a bare name is tried as<runtime_dir>/<name>and<runtime_dir>/kernel-<name>.json.Engine::attachuses it.--existingwith no value attaches to the newestkernel-*.json, asjupyter console --existingdoes.mainprints the error chain with:but skips a cause whose text the error before it already contains. Ourthiserrortypes print their source inline (io: {0}), so anyhow's{:#}repeated it.
2026-10-18, extension names from wheels are validated (#46)
extract took any directory under labextensions/ in a wheel as an extension name, so a wheel shipping labextensions/.packages/... made the install delete the record directory and put its own files there. A forged record could then point a later remove at any path, since record extensions entries were joined to the root unchecked.
- An extension name is now
nameor@scope/name, each part of[A-Za-z0-9._~-]and not starting with.or_(npm's rules, with uppercase allowed for old packages). That excludes.,.., the.packagesrecords and the.<key>-*staging directories. A wheel with any other name fails to install rather than being partly unpacked. - The same check runs on every record read: a record naming anything else is ignored as if absent, and
remove_extensionrefuses such a name, so nothing outsidelabextensions/is ever removed. - Taking over another package's extension now needs
--force; before, a dependency pulled in automatically could replace@jupyter-widgets/jupyterlab-managerwith only a warning. ZipFile::enclosed_name(zip 9) drops a leading/and.components rather than rejecting them, so/xand./xland inside the staging directory; only..that would escape returns None. Symlink entries are written as regular files holding the target text.
2026-10-18, frontend/ moves to ganymede-ui (#134)
frontend/ was TypeScript for a UI of Ganymede's own, and nothing in this repository used it. That UI now exists as tlockney/ganymede-ui, which took the package with its history (git subtree split --prefix=frontend) as packages/client. The package went with it, along with its CI job and Dependabot entry. The core keeps no TypeScript; the REST and websocket API is the only contract with any UI.
2026-10-18, If-Match on contents saves (#75)
The last open item of #75: the last_modified check lets an external write slip through when it lands within 500 ms of the client's copy, and it compares timestamps, not content. Thomas chose to add an exact, opt-in precondition.
- A content
GET /api/contents/{path}and every file or notebookPUTanswer withETag: "<sha256>", the same hash?hash=1reports. The body keeps jupyter_server's nullhashunless?hash=1was asked, so the JSON is unchanged.?content=0gets no ETag, since that would mean reading the file only to hash it. PUTwithIf-Matchis 412 unless the file exists and its sha256 is one of the listed tags.*only needs the file to exist. Weak tags (W/"...") never match, per RFC 9110's strong comparison, so a header of only weak tags fails.- The check runs inside the per-path save lock with the write, so two saves naming the same tag cannot both pass. Chunked uploads check it on chunk 1 only; later chunks append to the upload in progress.
- The hash comes back from
Contents::savein the model'shash, so embedders (the Swift shell) get it without a second read; the server moves it to the header. - CORS now allows
If-Matchand exposesETag, or a browser client on another origin could neither send nor read them.
Tornado already sets an Etag (sha1 of the response body) on jupyter_server's GET responses, and no Jupyter client sends If-Match on saves, so the header is additive: existing clients see no change.
2026-10-18, ganymede info shows Ganymede's own paths (#57)
info printed Jupyter's runtime, config and data dirs and nothing of Ganymede's own: not the data dir (GANYMEDE_DATA_DIR), the JupyterLab installs or extensions under it, the notary dir, the console history, or the --kernelspec-root extras. A bug report could not say where things were, and nothing gave the commit or target a build came from.
infoandinfo --jsonadddata_dir,lab_dir,lab_versions,labextensions_dir,extensions(name, version, dir),kernelspec_roots(extras first, as searched),notary_dir,console_history, andbuild(commit, target, profile). Existing keys are unchanged.- The build details are in
info, not--version: the install scripts and the release workflow parseganymede --versionas one line,ganymede X.Y.Z. Abuild.rsreads them; outside a git checkout the commit can be passed inGANYMEDE_BUILD_COMMIT. - The paths come from the same functions the commands use (
ganymede_server::lab::data_dir,console::history_path), soinfocannot drift from what is actually read.
2026-10-18, one Install page (#150)
Install and Install on a workstation covered the same path: the install script, the fake check, cdp-kernel, and the macOS and glibc notes appeared on both. The workstation page's numbered steps are now docs/install.md, with the old Install page's own parts (script options, by hand, verifying a download, from source) in a closing "Other ways to install" section. Their anchors (#by-hand, #from-source, #verify-a-download) are unchanged, since other pages and cdp-kernel link them.
docs/workstation.mdstays as a stub: on the site it redirects to/docs/installwith the hash, so/docs/workstation#_5-start-jupyterlablands on the same step (the numbered headings kept their text, so their slugs match). On GitHub it is a one-line link.search: falsekeeps it out of the search index.- VitePress prefixes a slug that starts with a digit with
_(#_9-troubleshooting); GitHub doesn't (#9-troubleshooting). A link that has to work in both places can't target a numbered heading, so known-gaps.md links the page instead.
2026-10-18, install script: libc check and kernel failures (#61)
The last two items of #61, left over from the release hardening entry, are in site/install.sh:
- On Linux,
target()checks the C library before downloading.getconf GNU_LIBC_VERSIONgives the glibc version, and anything older than 2.35 (what ubuntu-22.04 builds link) is refused with a pointer tocargo install. Without glibc, musl is recognised fromldd --versionor/lib/ld-musl-*. Detecting was chosen over shipping a musl build; a static musl target is still open if Alpine users show up. - A failing
cdp-kernel installno longer fails the script afterganymedeis in place. It prints what failed and the command to rerun, and on success printscdp-kernel version, so an install log records which kernel went with which Ganymede.
scripts/test-install.sh covers both with stub getconf, ldd, node, npm, and cdp-kernel in front of PATH. Stubs go in a directory per case, since a shared one leaks a stub into the next case.
2026-10-18, interrupted prompt flake was ipykernel's abort queue (#137)
input_without_stdin_and_interrupt_while_prompting failed on macOS with an empty result for the cell after the interrupt. Under CPU load it reproduced on Linux too (3 of 40 runs). The failing cell's reply was {"status": "aborted"} with no output: the kernel never ran it.
Cause: the interrupted cell ends in an error, and with stop_on_error (the default) ipykernel 7 sets an aborting flag and posts a "stop aborting" marker through its shell channel thread. Any execute_request that reaches the subshell before that marker is answered aborted. A client that sends the next cell as soon as the error reply arrives can win that race on a slow machine. This is kernel behaviour, not a server bug; jupyter_client clients see the same thing.
Fix: the test sends the interrupted cell with stop_on_error: false, so no abort window opens. Queue aborts stay covered by stop_on_error_aborts_queued_cells. 80 of 80 runs passed under the same load.
Lesson: a cell that comes back aborted after an error is ipykernel's stop-on-error window, so a test that follows an error with another cell should either clear stop_on_error or expect the abort.
2026-10-18, kernel working directory stays under the root (#51)
KernelManager::start joined the client's path onto the root without validating it, so POST /api/kernels {"path": "../../etc"} or a session for ../../etc/x.ipynb started a kernel outside the contents root, and a path to a directory that did not exist failed the launch with a 500.
It now follows jupyter_server's cwd_for_path:
- The path goes through the contents API-path validation (
kernel_cwd). Anything that does not validate (.., absolute, backslashes) gives the root. - From there it walks up to the nearest existing directory, and falls back to the root. A directory that a symlink resolves outside the root also gives the root.
POST /api/kernelstakespathas a directory, asjupyter_serverdoes; sessions pass their notebook's parent (get_kernel_path). Before, both took the parent, so a kernel started for directorynbran in the root.
ganymede-api now depends on ganymede-contents for this, so there is one definition of a valid API path.
2026-10-18, fake-kernel modes and real-process tests for the kernel host (#68)
The fake kernel spoke only what the first session tests needed. It now also covers kernel output and lifecycle that real kernels produce: exit prints, then vanishes mid-cell. display sends display, update and clear. Comms work: target echo sends each message back with its buffers, any other target is closed as ipykernel closes it, comm_info_request lists the open comms, and comm opens one from the kernel side. After an error under stop_on_error, queued execute_requests are answered aborted. Session tests use each of these.
ganymede-kernel-host gained a tests/ directory:
subprocess.rslaunches shell scripts as kernels. Each one checks something the host does to a real process:- the cwd, kernelspec and launch env,
JPY_SESSION_NAMEandJPY_PARENT_PID, and a connection file that the kernel reads and that is removed with the kernel - SIGINT reaching both the kernel and the child it waits on
- a shutdown that escalates to SIGTERM, then SIGKILL, and one that stops at SIGTERM
- an exit noticed on its own
- the cwd, kernelspec and launch env,
zmq_kernel.rsbinds ROUTER, PUB and REP sockets with a signing key, then drives the startup nudge and the heartbeat against them. It sends binary buffers through the real framing: one equal to<IDS|MSG>, an empty one, all 256 byte values and 100 KB. They come back unchanged. A wrong key never completes a nudge.
Already covered elsewhere: the ws decoders' malformed-input tests (#48) and the server config tests (#55). Not covered here: binary buffers through the websocket layer, which belongs with the server tests.
Learned: a non-interactive sh starts & jobs with SIGINT ignored, and a trap cannot undo that, so a group-interrupt test needs a foreground child.
2026-10-18, kernel records freed, deaths reported, restart failures (#43, #44, #45)
Every kernel the API ever started stayed in memory: the task pumping a session into the kernel's hub held the RunningKernel, which held the session, which held the only sender of the channel the task waited on. A kernel that died on its own was never noticed above the session: the model said idle, no status: dead went out, and websockets stayed open. For an attached kernel (run --existing, Engine::connect) nothing noticed at all, because there is no process to wait on and the heartbeat had no caller.
- The pump holds a
Weak<RunningKernel>and exits when the session dies (KernelSession::watch_alive/dead). A test checks theWeakis dead after shutdown and after a death. - A session that dies while it is the kernel's current one, and no start, restart or shutdown is under way, removes the kernel from the map, publishes
status: deadon the hub, and closes clients. That is what jupyter_server does when a kernel dies and is not restarted (its restarter would restart it first; auto-restart is still open). Clients drain the hub before they close, so they see thedeadstatus. Sessions whose kernel is gone drop out of/api/sessions, as jupyter_server's "Kernel culled or died unexpectedly". - Heartbeat: a session with no owned process pings every 3 s and is dead after 3 misses in a row. Misses count only after the first answer, so a kernel that is still binding its ports, or never answers heartbeats, is not declared dead by it. With ipykernel 7.4 attached through
run --existing, killing the kernel mid-cell ends the run withkernel died7 s later; before, it waited for the cell timeout. mark_deadcloses the transport andZmqTransportaborts its tasks on drop: a SUB socket otherwise keeps reconnecting to a dead kernel androutenever ends. No deadline onconnect_retrying: a closed transport stops it, and a deadline would race a slow kernel start.execution_stateignores statuses for jupyter_server'suntracked_message_types(kernel_info, interrupt, shutdown, comm_info, history, debug, usage). An interrupt or a frontend'skernel_info_requestmid-cell no longer shows a busy kernel as idle, which could let culling take it. The startup nudge's statuses are untracked too, so a successful start setsidleitself.- Start and restart go through one
boot: nudge, and onKernelDiedor a timeout launch again on fresh ports, three tries, as the engine'sRelaunch::startdoes (#25). A failed launch or a last failed nudge reports the kernel dead and removes it instead of leaving it "restarting". Twelve parallelPOST /api/kernelswith ipykernel all returned 201. - A per-kernel async lifecycle lock is held across start, restart and shutdown, and each re-checks the map once it has the lock, so a restart racing a DELETE or cull either finishes first or gets a 404; it can no longer launch a process into an entry nobody owns.
- The control-channel interrupt waits 10 s for its reply: cdp-kernel waits up to 5 s for its target before replying, so the old 5 s reported a slow interrupt as failed.
2026-10-18, kernel shutdown steps, cancelled requests, stop signals (#70)
Several small lifecycle gaps from the review, fixed together:
- Shutdown went from
shutdown_requeststraight to SIGKILL of the process group. It now followsjupyter_client'sfinish_shutdown: the request, half the grace period to exit, SIGTERM, the other half, then SIGKILL.KernelProcessgainedterminate(),Unsupportedby default. - A
request()future dropped by its caller (a lostselect!branch, a cancelled handler) left its pending entry behind, androutekept appending that request's iopub output to it until the kernel died. A drop guard now removes the entry however the future ends. - Signals went to
-pideven after the waiter task had reaped the kernel, when the pid (and so the group id) may already belong to someone else. The child process now checks it is still alive first, andKernelSession::interruptrefuses a dead kernel. KernelManager::shutdown_allshuts kernels down concurrently, so N hung kernels take one grace period instead of N.run,executeandconsolehandle SIGTERM and SIGHUP: a kernel they launched is shut down (an attached one is interrupted), and they exit with 128 + the signal. The kernel is in its own process group, so before thistimeout 60 ganymede execute ...or a closed terminal orphaned it.- A cell that hits
--timeoutis interrupted, sorun --existing --timeout Nno longer leaves a shared kernel busy.
Not done: PR_SET_PDEATHSIG (Linux sends it when the spawning thread exits, and a tokio worker or blocking-pool thread can retire while the kernel should live; doing it safely needs a dedicated long-lived spawner thread), and replay protection (digest_history), which is low value on a signed localhost connection. Moving blocking file I/O off the async handlers is a separate change.
2026-10-18, kernels die with the host, a second Ctrl-C stops (#70)
The rest of #70, after the kernel shutdown steps entry:
- A host killed with SIGKILL, or one that panicked past its cleanup, orphaned every kernel; only kernels that watch
JPY_PARENT_PIDnoticed. On Linux the kernel now asks for SIGKILL when its parent goes (PR_SET_PDEATHSIG, set between fork and exec). Linux sends that when the thread that forked the child exits, so kernels are no longer forked from whatever tokio thread calledlaunch: oneganymede-kernel-spawnerthread, started on first use and never stopped, does every spawn, inside the caller's runtime so tokio still watches the child. A test re-runs the test binary as a host, launches a kernel from it, SIGKILLs it, and checks the kernel goes; another checks a kernel outlives the thread that launched it. Elsewhere it is a plain spawn. The signal reaches the kernel only, not its process group. - Whatever a kernel left in its process group (a background job) is SIGKILLed when the kernel exits, as
jupyter_clientkills the group. Done right after the reap: a group id is not reused while the group has members. runandexecute: a second Ctrl-C within 2 s stops the kernel (a launched one is shut down, an attached one interrupted) and exits 130, so a cell that ignores interrupts no longer needskill. The fake kernel'shang <ms>is asleepthat ignores interrupts.- Replay protection: like
jupyter_client'sdigest_history, a signed message whose signature was already seen on the connection is dropped (wire::decode_once,DigestHistory, 65536 signatures, a tenth culled past that).
2026-10-18, limits on lab install downloads (#47)
lab install and lab extension install fetched whatever URL PyPI's JSON named, following redirects, with no timeouts and no size cap, and then unpacked the wheel with no cap either. They ignored yanked, and they dropped every version specifier, so a dependency always got its latest release.
What changed, all in a new lab/pypi.rs:
- The agent is HTTPS only, with connect, response, body and overall timeouts. The API must answer from
pypi.organd wheels fromfiles.pythonhosted.org, checked on the final URL after redirects.GANYMEDE_PYPI_URLnames a mirror (its host is allowed too), andGANYMEDE_PYPI_HOSTSadds more hosts. - Downloads are capped at 256 MB. Extraction is capped at 100,000 entries, 256 MB per file and 1 GB in all (
Limits). - Yanked files and releases are skipped. An explicit
==pin orlab install --versionon a yanked release fails and gives the reason. - PEP 440 versions and specifiers (
==,!=,<,<=,>,>=,~=,==X.*) apply on the command line and inRequires-Dist. An installed dependency is upgraded when it no longer matches. Pre-releases are picked only when asked for or when nothing else matches, with a note. --no-deps, the wheel's URL and SHA-256 printed before install, and a JupyterLab reinstall that renames the old copy aside and restores it if the move fails.- frontends.md says what this trusts: the hash comes from the same response as the URL, so it guards against corruption, not a malicious release.
Not done: attestation or RECORD checks, a lock file, --from-wheel, extension-to-JupyterLab compatibility checks, and locking against concurrent installs.
2026-10-18, kernelspecs and headers parsed as leniently as jupyter_client (#58)
Ganymede rejected input that jupyter_client accepts, and the rejection was quiet: a bad kernel.json was only logged and vanished from /api/kernelspecs, and a kernel reply that failed to decode was dropped, so the host waited until its timeout.
kernel.json:display_nameandlanguagedefault to"".envis a loose dict in Jupyter; numbers and booleans are now stringified and other values (null, lists, objects) skipped with a warning.- Kernelspec names are the lowercased directory name, and directories whose lowercased name is outside
[a-z0-9._-]+are skipped, as in_list_kernels_in.installalready enforced the same rule; it now shares the check. installcopies symlinked subdirectories (it usedDirEntry::file_type, which does not follow links, and thenfs::copyfailed on a directory).- Message headers:
username,dateandversiondefault to""when missing.msg_id,sessionandmsg_typeare still required. nullmetadata reads as{}on the wire, ws v1 and legacy ws paths.
What we learned: for a compatibility target, every required serde field is a place where we are stricter than the reference. Default what the reference defaults.
2026-10-18, login redirect with control characters (#50)
safe_next refused :, \ and a leading // in the next parameter but let tab, CR and LF through. Browsers remove those from a URL before parsing it, so Location: /\t/evil.example lands on //evil.example, a protocol-relative URL on another host. /login redirects at once when the browser is already logged in or auth is off, so one link was enough. This is the class of bug jupyter_server fixed for CVE-2023-39968.
nextwith any control character or whitespace now falls back to the base URL, before the other checks run. Checking for//alone is not enough when the browser rewrites the string afterwards.- A value that starts with a single
/followed by anything else is a path on the same host however the browser normalizes it, so the checks that remain (no scheme, no backslash, no leading//, underbase_url) are sufficient once the string is what the browser sees.
2026-10-18, MIT license file and third-party notices (#64)
README and Cargo.toml said MIT, but no LICENSE file shipped, so GitHub detected none and the release archives carried no copyright notice.
- Audit before adding it: of the 329 crates in
Cargo.lock, every license is permissive or offers a permissive choice. The ones that are not plain MIT/Apache:option-extis MPL-2.0 (file-level copyleft; using it unmodified as a dependency puts nothing on Ganymede),r-efioffers LGPL only as one of three choices,webpki-rootsis CDLA-Permissive-2.0 (the Mozilla root list),clipboard-winandryuallow BSL-1.0,aws-lc-sysandringcombine ISC, Apache-2.0 and BSD-3-Clause. SQLite (bundled by rusqlite) is public domain. The npm packages indocs/,frontend/andvalidation/are dev tools, never shipped, and are MIT/BSD/ISC/ Apache anyway. JupyterLab (BSD-3-Clause) is downloaded byganymede lab installat run time, not redistributed. cargo aboutwritesTHIRD-PARTY-NOTICESfromabout.toml(accepted licenses) andabout.hbs(plain-text template, triple-stash so names are not HTML-escaped).private = { ignore = true }drops the workspace's own crates; build and dev dependencies are ignored since they are not linked. The file is generated per target inrelease.yml(Windows and macOS trees differ) and is not committed, so dependency bumps never need a regenerated file.- CI runs the same generation with
--failand output to/dev/null: a new dependency under a license missing fromabout.tomlfails the build. cargo install cargo-aboutbuilds no binary without--features cli; CI usestaiki-e/install-actioninstead.
2026-10-18, nbformat reading as lenient as Python's (#73)
nbformat.read only logs validation errors, but our typed model rejected whole notebooks over one bad field: an unknown output_type, a stream without name, execution_count: 1.0 or -1, source: null, a lone surrogate escape. Such notebooks were "Unreadable Notebook" on GET and "Invalid notebook" on PUT. Missing and duplicate cell ids were never repaired either.
- An output that does not fit the schema is kept as
Output::Unknown(Value)and written back unchanged (fixtureinvalid_outputs.ipynbround-trips byte for byte againstnbformat.write). - Missing
namereads as"stdout"(new_output's default), missingename/evalueas"";nullmetadata, source, outputs and data read as empty. execution_count: an integral float reads as that integer, anything else invalid (-1,1.5, strings) asNone, so it is saved asnull. Keeping-1would need a non-integer type for the field.- Lone surrogates read as U+FFFD (only after a failed parse, so valid files pay nothing). Python keeps them but then cannot write the file as UTF-8.
- For nbformat 4.5+, ids are filled (8 hex digits) and duplicates replaced on read and on write, as
validator._normalizedoes. Earlier minors are untouched.
Not done: jupyter_server's message field with the validation text. That needs a schema validator to report what was wrong; the reader now repairs instead of failing, so there is nothing to put in it yet.
2026-10-18, notebook validation message, unknown cells (#73)
Follow-up to the lenient nbformat reading entry above, which left three gaps.
- An unknown
cell_type(nbformat 3'sheading, or anything newer) made the whole notebook unreadable. It is nowCell::Unknown(Value), kept and written back unchanged likeOutput::Unknown; so is a known cell whose fields do not fit even leniently (source: 5). Fixtureinvalid_cells.ipynbround-trips byte for byte againstnbformat.write.Cell::source,metadataandcell_typenow returnOption. - jupyter_server adds
message: "Notebook validation failed: ..."to the model of a notebook that breaks the schema, on GET with content and on save. The reader now returns aReadReport(from_slice_with_report,from_value_with_report): a schema check of exactly the spots the lenient reader defaults, empties or keeps verbatim, worded likejsonschemaand located likeinstance['cells'][1]['outputs'][0]. The contents model carries it asmessage, absent when valid. It is not a full schema validator, and the text is not jsonschema's word for word. Missing or duplicate cell ids are not reported, since nbformat repairs them before validating. - A lone surrogate in a PUT body failed with "invalid JSON body". The server's body parser now retries with lone surrogates read as U+FFFD, as the file reader already did.
2026-10-18, paths outside the contents root are 404 (#54)
The last item of the #54 parity fixes. jupyter_server's FileContentsManager._get_os_path answers HTTPError(404, "<path> is outside root contents directory") for a path that resolves outside root_dir; we answered 403, because escapes shared Error::Forbidden with read-only roots and writes to the root itself.
ganymede-contentshas a newError::OutsideRoot, status 404...components, absolute paths, backslashes and symlinks that resolve outside the root use it; the read-only and root-protection errors stayForbidden(403), and a NUL byte stays 400, as injupyter_server.- The message still names the API path, never the OS path.
Learned: jupyter_server checks with abspath, so it follows a symlink out of the root; we keep refusing those, only the status changed.
Left: /api/spec.yaml is still 404. Serving it means vendoring jupyter_server's OpenAPI file, and no client we target fetches it.
2026-10-18, landing page, README, and docs navigation (#67)
The review's presentation list, minus the GitHub repository settings (description, topics, https homepage, social preview), which only an owner can change from the repository's settings page.
- The landing page's first install tab is the install script;
cargoneeds a Rust toolchain, which the audience for a Python-free server often doesn't have. A line under it says which platforms are tested. - cdp-kernel is in the top navigation and in a "who is this for" line in the hero, not only the footer.
- The page has a canonical link,
og:/twitter:tags, and an SVG favicon that the docs use too.og:imageisimages/jupyterlab.png, a JupyterLab screenshot; the pages workflow copiesdocs/images/to/images/, so one file serves the README, the docs, and the card. - "No Python" is qualified where it's claimed: no Python runtime or dependency, with the three places Python does appear (the PyPI wheel
lab installunpacks, the CI reference, the nbformat byte format) in the README's Status section and the landing page's feature card. - The landing page's client table said JupyterLab "Needs cookie login"; cookie login shipped in #30 (#66's last open item). The footer links the security page (#65's last open item) and the changelog.
- The README gained badges, a Features list, and a Quick start, as cdp-kernel's has. It and the roadmap said JupyterLab extensions are unsupported while
ganymede lab extension installexists; they now say server extensions. - Docs: a Changelog page that includes
CHANGELOG.md(as cdp-kernel's does), and a Known gaps page collecting what was spread over frontends.md, python-free.md, install.md, and the roadmap. Sidebar groups start open, except Contributing, so "Replacing Python" no longer hides behind a fold.
Not done: a shared theme package or CI check against cdp-kernel's copy of the CSS (#67 item 9). The two sites' styles are still kept in step by hand.
2026-10-18, release hardening (#61)
A release shipped unstripped binaries without LTO, its only integrity check was a .sha256 fetched from the same place as the archive, the cross-built x86_64 macOS binary never ran, and a release that failed after its tag was pushed had no written way back. Now:
[profile.release]withstrip = "symbols",lto = "thin", andcodegen-units = 1. The x86_64 Linux binary goes from 19.8 MB to 12.8 MB, and a clean build takes about as long as before (2.5 min).- The release job attests build provenance for every archive (
actions/attest-build-provenance), and install says what the checksum does and doesn't prove and how to rungh attestation verify. - The x86_64 macOS binary runs under Rosetta on the arm64 runner, so it gets the same
--versioncheck as the native ones. - Releasing is the recovery procedure: rerun, untag and retag (
tag-release.ymlnow hasworkflow_dispatchfor that, main only), or cut a patch release once a release is public. - Install documents Gatekeeper (what to do after a browser download) and musl (no build yet; install from source).
Not done here: site/install.sh still picks the glibc build on musl and still exits non-zero when cdp-kernel install fails after ganymede is in place. Both are changes to the install script.
2026-10-18, rename without the check-then-rename race (#75)
The first #75 change left rename checking that the target was free and then calling fs::rename, which replaces an existing file (and, on Unix, an empty directory). A file created in between was lost.
renamenow asks the OS to refuse an existing target in the same call:renameat2(RENAME_NOREPLACE)on Linux and Android (the raw syscall, so glibc before 2.28 is fine),renamex_np(RENAME_EXCL)on macOS and iOS, throughlibc, which the contents crate now depends on for those targets.EEXISTis still jupyter_server's 409 "File already exists".- Where the flag is not available (other platforms, a kernel or a filesystem that answers
ENOSYS,EINVALorENOTSUP, such as some network filesystems) it falls back to check-then-rename, as before.
Still open: an opt-in ETag or If-Unmodified-Since precondition on saves. That is a design decision, not a bug fix, so it is left for later.
2026-10-18, a security policy and a security page (#65)
Ganymede is a server that runs code, and nothing said so: no SECURITY.md, and no page with the default bind address, what --ip 0.0.0.0 exposes, what --token "" leaves in place, or how to put TLS in front of it.
SECURITY.mdat the root: report privately through GitHub's advisory form, only the latest release is supported, and what is in scope. A kernel running code, or an extension you installed, is not a vulnerability.- Security in the docs, under Reference: the defaults table, tokens and cookies,
--token ""(Host and XSRF checks still apply since #41, but any local process can connect), why not to use--ip 0.0.0.0on its own, SSH forwarding, TLS throughtailscale serveor a reverse proxy withlocal_hostnamesandtrust_xheaders,--allow-origin '*', and JupyterLab extensions as code with your session's access. CONTRIBUTING.md, which points at development, so GitHub's Contributing tab isn't empty.
Writing the page meant reading auth.rs and config.rs claim by claim. Each setting it describes is one of jupyter_server's, with the same default.
2026-10-18, server config: unknown keys fail, global flags apply (#55)
A config file with tokn = "x" or cull_idle_sec = 30 started a server with a random token and culling off, and said nothing: Config was #[serde(default)] only. ganymede serve and ganymede lab also listed the global -q, -v and --startup-timeout in --help and ignored them, and serve logged at warn while the ganymede-server binary logs at info.
Confignow hasdeny_unknown_fields; the error names the key and the file.--startup-timeoutand-qmap ontostartup_timeout_secsand a newquietkey (kernels' own stdout and stderr discarded), so they apply to kernels the server launches. Unset, the file's values stand: the global flag no longer has a clap default, so "not given" can be told apart.serveandlablog at info by default, debug with-v;RUST_LOGstill wins.- Flags for keys that were TOML-only:
--base-url,--checkpoint-dir,--allow-origin(repeatable),--cookie-secret-file.
Learned: serve's own --kernelspec-root shares the global's name, and clap fills both from one occurrence, so the roots were listed twice.
Left for later: a default config file path, --log-level and --log-file, and a kernel-id prefix on kernel output.
2026-10-18, small auth and REST parity fixes (#54)
A group of low-severity gaps against jupyter_server, found in the project review:
- The token was compared with
==in three places (API auth, page-URL token stripping, the login form). All now use the same constant-time compare as the_xsrfcheck. - The login cookie never had
Secure. It does now when the request came over HTTPS, which for this plain-HTTP server means a TLS-terminating proxy.X-Forwarded-Proto/X-Schemeare believed only with the newtrust_xheadersconfig key, as tornado'sxheaders, since any client can send them. TraceLayerlogged full URIs at debug,?token=included. The span now records the URI with the token replaced by[secret](_scrub_uri).- Error bodies named absolute OS paths (
/tmp/.../root/link/secret.txt is outside the contents root, config write failures). They name the API path or section; the OS path goes to the server log. allow_originschanged the origin check but sent no CORS headers, and a preflight got 403. ACorsLayerfor the listed origins now echoes the origin and answers preflights before auth, with the methods and headersjupyter_server's API handlers allow.- 201 responses (kernels, sessions, contents create and PUT, checkpoints) carry
Location, percent-encoded likeurl_escape. - Legacy text frames include
"buffers": []; binary frames still carry the buffers outside the JSON. /api/me?permissions=answers with the requested actions (the token grants all of them) and 400s on a malformed value.
Left: jupyter_server answers 404, not 403, for paths outside the root. That is the contents crate's Forbidden contract for escapes, shared with read-only and root-protection errors, so it needs its own variant; /api/spec.yaml is still 404.
2026-10-18, a shutdown_reply that only reached iopub (#122)
interrupt_and_shutdown_requests_on_shell failed now and then on macOS with KernelDied. The 500 ms linger after exit didn't help, because the reply was never coming. With ten copies of the test running in parallel it fails on Linux too, about 1 run in 100, always on python3. strace on the kernel showed that the shutdown_reply on shell was never written to the socket in the failing runs. Only the copy ipykernel broadcasts on iopub went out.
The cause is in ipykernel 7. A reply on shell goes from the main thread to the shell-channel thread, which queues it on a ZMQStream. A shutdown then stops that thread, sometimes before the stream has flushed, and the queued reply is dropped. jupyter_client loses it the same way: a script that sends shutdown_request on shell right after other requests missed 3 replies in 250 runs.
What changed: when the kernel dies, a request still waiting for its reply is answered with the shutdown_reply the kernel broadcast on iopub for it, if there was one. The reply on the request's own socket is still preferred: the iopub copy is used only once the kernel is dead, after the linger. The test's unwraps now name the kernel that failed.
What we learned: when a reply goes missing, first check whether it ever left the kernel (strace on sendto) before looking for a race in the host.
2026-10-18, the site deploys from main again (#152)
Deploying only from release tags (#117) kept the docs in step with what install.sh installs, but held every docs fix until a release or a manual pages run. Thomas chose to deploy from main again and mark unreleased features in the docs instead.
pages.ymldeploys on a push tomainthat touchessite/,docs/, or the workflow. A manual run deploysmainor the given ref.release.ymlstill dispatches it after publishing, without a tag now, so the site picks up the new version and changelog.- Docs for a feature not in the latest release carry a GitHub-style
> [!NOTE]alert starting**Unreleased.**. VitePress renders those alerts too, so one syntax works on both. The fixed words are whatrelease.shgreps for; it prints the matches and lists them in the release pull request, and they are removed on the release branch. - The rule lives in a new root
CLAUDE.md, linked from development.md and releasing.md.
2026-10-18, _xsrf for a generic --static-dir (#139)
Serving a ganymede-ui build with ganymede serve --static-dir dist loaded the app, but nothing it did could change state. Opening the printed ?token= URL swaps the token for the login cookie and redirects to /, so the app is cookie-authenticated, and cookie-authenticated requests that change state need an _xsrf cookie echoed in X-XSRFToken. Only JupyterLab's page and the login pages set that cookie; the generic static fallback (ServeDir) set none, so saves and kernel starts got 403.
The fallback is now a router with a middleware that appends the _xsrfSet-Cookie to text/html responses when the browser has none, as jupyter_server does on every page it renders. Assets don't get one. @jupyterlab/services sends X-XSRFToken from the cookie on its own, so a JupyterLab-based app needs nothing more than to connect without a token.
2026-10-18, ganymede ui install and ganymede ui (#148)
ganymede ui install fetches a ganymede-ui release the way lab install fetches JupyterLab, and ganymede ui serves it as ganymede lab does, reusing lab's download agent, size limits, staged install and lab_start (with require_lab off, so the URL is /).
- The release zip unpacks to one
ganymede-ui-X.Y.Z/directory; the install drops that level soui/<version>/index.htmlis the app. - The default version's SHA-256 is pinned in the binary, as JupyterLab's wheel is. Other versions take the hash from the release's
SHA256SUMS, which comes from the same place as the zip: it catches corruption, not a replaced release. ganymede-ui also publishes a provenance attestation per archive; checking it is left for later. - GitHub answers a release download with a 302 to
objects.githubusercontent.com. Behind this container's proxy that 302 came back withTransfer-Encoding: chunkedand an empty body, which ureq failed to drain when following the redirect ("chunk length cannot be read as a number"). The install follows redirects by hand, checking each host, and never reads a redirect's body. GANYMEDE_E2E_UI=1(set in CI's e2e job) runs the real download intests/ui.rs; the rest of that file is offline.
2026-10-18, mark ganymede ui as unreleased (#153)
The docs site publishes from main, so it shows features before they ship. ganymede ui (#149) merged after 0.3.0. Its section in frontends.md now opens with the standard unreleased note (#152), and the README has one under its command list. Remove both at the next release.
2026-10-18, kernel output kept across a websocket drop (#53)
A websocket client subscribed to a per-kernel broadcast from the moment it connected, and session_id in the URL was ignored. When a laptop slept or a tailnet path changed, the output and execute_reply produced while it was away never arrived, and cells stayed [*]. A slow client (a backgrounded tablet) lagged the broadcast and lost messages, its own replies included, with only a server-side warning.
What jupyter_server does (from its source, MappingKernelManager and ZMQChannelsWebsocketConnection):
- The connection's
session_keycomes from thesession_idquery parameter. When the last websocket of a kernel closes,on_closecallsstart_buffering(kernel_id, session_key, channels): that websocket's own ZMQ streams keep running and append every message (iopub, shell, stdin) to a plain list. Nothing bounds or expires it. - The next connection calls
get_buffer(kernel_id, session_key). If the key matches, it takes over the streams, nudges, and replays the list ("Replaying N buffered messages"). If not,stop_bufferingdiscards it ("Discarding N buffered messages"). Shutting the kernel down discards it too. - A new connection whose
session_keyis already open closes the old one ("Replacing stale connection"). - There is no lag: each websocket has its own sockets, and tornado queues writes without limit. Output is dropped only by
iopub_msg_rate_limitandiopub_data_rate_limit, and the client is told with astreammessage on stderr.
What changed:
- The kernel's broadcast hub is gone.
forwardputs each message on every client's own unbounded queue (bridge::Clients): iopub for all of them, replies only for the session in the parent header, as before. connect_client_as(kernel, session_id)follows the three points above. When the last client of a kernel drops, its queue stays registered and keeps filling. The same session id takes the queue over, so there is no gap and nothing is delivered twice. Any other connection, or the kernel ending, discards it. The websocket handler passes?session_id=, and closes a replaced socket with 1000 "replaced by a new connection".- Replies are never dropped. Past 10 000 queued messages, iopub is dropped, and when the client catches up it gets a stderr
streamsaying how many messages it missed, with the parent of the next one, so it shows in the running cell. That is our version of the rate-limit notice. - The engine's streaming handler tracked replayed output by count, so after a lag it showed some messages twice and missed others. It now tracks msg_ids, and the reply's record fills in exactly the missed ones.
Left: the pump from the kernel-host session's own broadcast can still lag, but it only moves messages onto queues. A buffer has no time limit, as in jupyter_server; the culler still shuts the kernel down.
2026-10-18, ZMQ tests bind port 0 (#120)
The ZMQ tests picked ports by binding 127.0.0.1:0 with a std listener, dropping it, and binding the port later. Under cargo test's parallel threads another test could take the port in between, and CI hit AddrInUse. The test kernels now bind tcp://127.0.0.1:0 themselves and build the connection info from the ports bind returns. The same fix applies to any future test that needs a listening port: let the socket that will listen pick it.
2026-10-19, embedding prep for the Swift shell (#156)
Items 1, 3 and 7 of #59, the first step of the plan for the macOS app (Rust core in-process, see the plan).
ganymede-apihaszmqandsubprocessfeatures, on by default, forwarding to kernel-host.ZmqTransportFactoryexists only withzmq. CI checks api for iOS with neither. The server keeps both on: itsrun_withbuilds the subprocess provisioner and ZMQ transport, and until there is another provisioner (#160) a server without them could not start a kernel.- Browser wasm still fails for api, through
ganymede-contents' getrandom (#159); WASI builds. Handler::on_inputis async (async_trait, sinceexecute_withtakes&mut dyn Handler). The terminal still reads stdin inblock_in_place.Notary::openwithoutsqlitelogs a warning that signatures last only for the process.
2026-10-19, ganymede-ffi: the server in-process for the Swift shell (#157)
The macOS app runs the server in its own process and loads ganymede-ui from it in a web view, so the Rust side it needs is small: start, URL, stop. ganymede-ffi exposes that as four C functions declared in crates/ganymede-ffi/include/ganymede.h. The config is JSON with ganymede.toml's keys, so the app and the config file share one schema; port defaults to 0 there.
- The server runs on its own thread with a multi-thread tokio runtime;
startblocks untilon_listeningfires orrun_withfails, andstopsends on a channel and joins the thread, which shuts kernels down first. Startup::stopreplaces the signal handlers and the URL on stdout: an app must not take over SIGINT/SIGTERM or write to stdout.- A hand-written header with no cbindgen: four functions. CI links
tests/smoke.cagainst the static library through it on Linux and macOS, so drift fails to link. scripts/build-xcframework.shbuilds macOS (arm64 + x86_64, lipo), iOS and the iOS simulator and runsxcodebuild -create-xcframeworkwith aGanymedeFFImodule map. The macOS CI job uploads the zip as an artifact. iOS links today with ZeroMQ and subprocesses compiled in; #160 removes them from that build.- The debug static library is about 430 MB (everything, unstripped); release with thin LTO is the one that ships.
2026-10-19, reading nbformat 3 notebooks (#142)
ganymede-nbformat now reads nbformat 3 files by upgrading them to 4.5, as nbformat.reads(s, as_version=4) does: the v3 reader's strip_transient and rejoin_lines, then v4.convert.upgrade. Worksheets are flattened, heading cells become markdown ("#" * level + " " plus the source's lines joined with spaces), html cells become markdown, code cells move input, prompt_number and collapsed, pyout/pyerr become execute_result/error, and output keys such as png become MIME types. A JSON string under json is parsed. Each cell gets a fresh id. nbformat 1 and 2 are still rejected.
orig_nbformat: 3 stays in the read model's metadata, as in Python, so a contents GET shows it as jupyter_server's does; the writer's strip_transient drops it on save. The v4 read path still strips it, as before.
Test: tests/fixtures/v3.ipynb and v3.expected.ipynb come from generate.py; the Rust read of the first, with ids replaced, writes the second byte for byte.
Lesson: v3 joins split lines two ways. Lists from splitlines(True) keep their endings and are concatenated; older files used plain splitlines() and need "\n" between items. nbformat picks by whether the first line ends in a newline, and so does the upgrade.
2026-10-19, kernels without ZeroMQ: the provisioner seam (#160)
Item 2 of #59, so an in-process kernel (the iPadOS app's) can be started and restarted by the engine like any other.
TransportFactorymoved fromganymede-apito kernel-host (api re-exports it), withZmqTransportFactoryinkernel_host::zmq. Provisioner and factory are a pair: the provisioner brings a kernel up, the factory connects to it.LaunchedKernel.connectionisOption<ConnectionInfo>;Nonefor a kernel with no sockets.kernel_host::in_process()is that launch (no process, interrupts over control). The api test double used to invent TCP ports for it.EngineBuilder::transportssets the factory (ZeroMQ by default).Engine::start,start_with,execute_notebookandKernel::restartno longer need thezmqfeature;attachandconnectstill do, since they take connection info.tests/in_process.rsstarts, executes, restarts and shuts down such a kernel; CI runs it with--no-default-features.- The server still builds its own subprocess provisioner and ZMQ factory. Letting an embedder pass its own is the next step, with the iPadOS target.
2026-10-19, the server serves an app's kernels (#165)
The step after #160 on the #59 plan: an app embedding the server (the iPadOS shell) can hand it the kernels it runs itself.
serve::Startuphas optionalkernelspecs,provisionerandtransports. Each unset one falls back to what the server built before: kernelspec dirs,SubprocessProvisioner,ZmqTransportFactory.ganymede-serverhaszmqandsubprocessfeatures, default on, forwarded toganymede-api. Built without one,run_withfails at startup unlessStartupsupplies the replacement.tests/embedded.rsruns the server with an app's kernelspec source, provisioner and factory, then lists, starts and restarts its kernel over REST; CI runs it with--no-default-features.cargo check --target aarch64-apple-iosof the server fails on Linux (rusqlite's bundled SQLite needsxcrun), so the iOS build of the server stays covered by the macOSffijob.ganymede-ffidoesn't expose this yet: that comes with the iPadOS target and its on-device kernel.