Protocol and kernel host
What a Jupyter server has to get right, component by component: the requirements Ganymede was built against. Most are met; where Ganymede does something different or doesn't do it yet, the item says so ("Not yet", "Ganymede"). The roadmap tracks what is planned. For how the crates are arranged, see Architecture.
Protocol crate
- Message envelope: ZMQ multipart frames:
[identities..., "<IDS|MSG>", hmac, header, parent_header, metadata, content, buffers...]. - HMAC-SHA256 over the four JSON frames, keyed from the connection file. The empty-key case (no signing) must be supported.
- Header:
msg_id,session,username,date(ISO 8601),msg_type,version("5.5"), optionalsubshell_id(5.5). - Typed
content, with unknown fields preserved (serde_json::Valuepassthrough) because kernels add extras. Ganymede types the messages it reads or builds (execute, complete, inspect, is_complete, kernel_info, shutdown, interrupt, the iopub outputs, stdin, comms); the rest (debug, history, subshells) pass through as JSON. - Five channels: shell (DEALER), control (DEALER), iopub (SUB; XPUB on the kernel side as of 5.5), stdin (DEALER), heartbeat (REQ).
- Connection file: JSON with ip, transport (
tcporipc), five ports, key, signature_scheme, kernel_name. Written to the runtime dir askernel-<uuid>.json. - Messages to cover: execute, inspect, complete, history, is_complete, comm_info, kernel_info, shutdown, interrupt, debug (DAP), stream, display_data, update_display_data, execute_input, execute_result, error, status, clear_output, comm_open/msg/close, input_request/reply, and the 5.5 subshell messages (create/delete/list) plus
iopub_welcome. Ganymede carriessubshell_idin the header and passes subshell messages through; it neither sends nor needsiopub_welcomeyet.
ZMQ options: the pure-Rust zeromq crate (used by runtimelib and Deno's built-in kernel) or zmq bindings to libzmq. Pure Rust avoids a C dependency; libzmq is the reference behaviour. Ganymede uses pure Rust, behind the KernelTransport trait so it can be swapped.
The jupyter-protocol, runtimelib, and nbformat crates from the runtimed project cover much of this and are maintained (Zed depends on them). Ganymede implements its own and tests against the Python reference implementations instead.
Kernel host
A long-running process that owns the ZMQ sockets for every kernel and multiplexes clients onto them. Responsibilities:
- Kernelspec discovery:
kernel.jsonunder the Jupyter data dirs (JUPYTER_PATH,~/.local/share/jupyter/kernels,/usr/share/jupyter/kernels, macOS~/Library/Jupyter, Windows%APPDATA%).jupyter_clientalso searches<sys.prefix>/share/jupyter/kernels, the Python environment it runs in. Ganymede has no Python environment, so it doesn't;--kernelspec-rootadds a conda or venv prefix by hand. Fields:argvwith{connection_file}substitution,env,display_name,language,interrupt_mode,metadata. - Port allocation: bind to find free ports, release, pass to kernel. Racy by design;
ipctransport sidesteps it on POSIX. - Launch: subprocess with env, cwd, process group (POSIX) or job object (Windows) so interrupt and kill reach children. Not yet on Windows: there is no job object, and the Windows build is untested.
- Startup handshake: send
kernel_info_requeston shell and control repeatedly until both reply. This "nudge" exists because PUB/SUB has a slow-joiner problem: iopub messages sent before the subscriber connects are lost, including the initialstartingstatus. Don't skip it. - Heartbeat: REQ/REP ping on an interval; missing replies mean the kernel is hung or dead. Ganymede pings only kernels it attached to (
run --existing,Engine::connect); for a kernel it launched, the process exiting is the signal. - Interrupt:
SIGINTto the process group wheninterrupt_modeissignal;interrupt_requeston control when it ismessage. Windows has no SIGINT for this; ipykernel uses a named event via an env var. - Shutdown:
shutdown_requeston control, wait, then SIGTERM, then SIGKILL. Remove the connection file. - Autorestart: poll the process; restart on unexpected exit, bounded retries, with a
restartingstatus broadcast to clients. Not yet: Ganymede reports the kerneldeadand removes it. - Multi-client: many websocket clients per kernel. iopub is broadcast; shell replies are routed back to the client whose
sessionsent the request.execute_inputon iopub is how other clients see executions. - Execution semantics the host should not hide: the kernel runs shell requests serially; after an error, queued requests come back with
status: aborted;stop_on_error,silent,store_history,allow_stdin,user_expressionsare per-request flags. - Completion detection: a request is done when the shell reply has arrived and an iopub
status: idlewith the matchingparent_header.msg_idhas arrived. The two sockets have no ordering guarantee between them. - stdin channel:
input_requestfrom the kernel means a client must answer withinput_reply; only the requesting client should be prompted.
Server (REST + websocket)
If any existing frontend or tool should connect (JupyterLab, VS Code, nbclient, papermill, Quarto), the REST surface must match jupyter_server:
/api/kernelspecs,/api/kernels(list, start, get, delete, interrupt, restart),/api/sessions(notebook path to kernel mapping),/api/contents(get, put, patch, delete, checkpoints),/api/status,/api/terminals(optional; Ganymede has none)./api/kernels/{id}/channelswebsocket. Two subprotocols: the default (JSON with achannelkey, binary buffers in a separate frame layout) andv1.kernel.websocket.jupyter.org(single binary frame with offset table, channel name, and buffers). Implement v1; the default is for older clients.- Auth: token in header or query, cookie session, XSRF token for non-GET, origin check on websocket upgrade, CORS. The server executes arbitrary code by design, so this is not optional even on localhost.
base_urlprefix support for reverse proxies and JupyterHub.- Static asset serving for the UI. Ganymede serves JupyterLab's bundle from disk, as
ganymede lab installunpacks it, rather than embedding one.
Stack: tokio, axum, tokio-tungstenite, serde, hmac/sha2, uuid, chrono. Terminals (portable-pty) and file watching (notify) would add to it; neither is built.
Notebook format
- nbformat 4.5:
cells[]withid,cell_type(code, markdown, raw),source(string or array of lines),metadata,outputs[],execution_count,attachmentsfor markdown cells. - Output types:
stream(name, text),display_data,execute_result(plusexecution_count),error(ename, evalue, traceback). MIME bundles are{mime: string | string[] | json};application/jsonand+jsontypes are stored as JSON, not strings. - Round-trip fidelity matters for git diffs:
nbformatwritesindent=1,sort_keys=True, trailing newline. Match it exactly or every save produces a diff. - Validate against the JSON schema; accept v3 and upgrade on load. Ganymede reads leniently and reports schema problems the way
jupyter_serverdoes (messagein the contents model); v3 is not read yet. ganymede-nbformatmatches Python's writer byte for byte.
Document model and collaboration
Jupyter's modern document model is a Yjs document (jupyter_ydoc), and JupyterLab 4 syncs it over y-websocket. Rust has yrs, the official Yjs port, and y-sync for the protocol. This is the one place where Rust makes the server simpler than the Python original: one process can own the CRDT, the file on disk, and the kernel connection.
Retrofitting collaboration onto a non-CRDT model is a rewrite. Ganymede's plan is the file as the source of truth, reloaded when it changes on disk, with a yrs document as an optional live-session layer later; see the iPad use case for why.
What is usually missed
- Widgets. Comms are cheap: route
comm_*messages and buffers. Widgets are not: they need a browser-side widget manager, model state sync, and loading widget JS shipped with Python packages (via labextension or CDN). Users expect ipywidgets, bokeh, plotly, and itables to work. Either embed@jupyter-widgets/html-manageror declare widgets out of scope. - The slow-joiner nudge and the dual completion condition (shell reply plus idle status). Both produce intermittent "notebook hangs on first cell" bugs when missing.
- Windows. Interrupts, process groups, path handling, ZMQ behaviour. Decide whether Windows is a target before designing process management.
- Security. Code execution service, HTML output sanitization, trust, token auth, XSRF, websocket origin. Localhost-only is not a defense: DNS rebinding and malicious notebooks are the standard attack paths.
- Binary buffers in messages (widgets, arrays). Easy to drop in the websocket bridge.
- Large and fast output. Rate limiting iopub, truncating outputs, not re-rendering the whole output on each stream chunk.
- Multiple clients on one kernel and reconnect after websocket drop without losing the kernel.
- Debugger over
debug_request(DAP). Optional, but the protocol surface is in 5.5 and ipykernel advertises it. - Subshells (5.5). New kernels will support them; the host should at least pass
subshell_idthrough. - Headless execution (nbclient, papermill). Cheap once the host exists, useful on its own, and the best conformance test.
- Config and ecosystem contracts.
jupyter --paths, runtime dir, JupyterHub single-user contract,base_url. Skipping these is fine for a personal tool; it is the difference between "works for me" and deployable. - Extension story. JupyterLab's adoption rests on extensions. A core without one is a different product; say so in the goals.
- Language server integration, terminals, nbconvert export, and
%matplotlibinline backends all depend on things outside the core. - nbformat round-trip fidelity (indentation, key order) so saves do not churn git history.