Skip to content

AdvancedHow the server works inside. Not needed to use it.

Embedding ​

An application can take the Ganymede crates it needs instead of running ganymede as a separate process. There are three ways in:

ProfileCrateWhat the app gets
A Rust app that runs codeganymede-engineKernels and notebook runs as Rust calls. No HTTP.
An app with a web viewganymede-ffiThe whole server on loopback, started from C or Swift.
No sockets or processes (wasm, an app's own kernel)ganymede-engine, default features offThe same API over an in-process transport.

ganymede-gateway gives the engine or the server kernels on another Jupyter server. How the crates fit together is on Architecture; the app that drove this design is on The iPad use case.

Three ways to embed Ganymede: a Rust app calling the engine in-process; an app whose web view talks HTTP to the server that ganymede-ffi runs on loopback; and a wasm or iOS build with zmq and subprocess off, where the kernel sits behind an in-process transport.Three ways to embed Ganymede: a Rust app calling the engine in-process; an app whose web view talks HTTP to the server that ganymede-ffi runs on loopback; and a wasm or iOS build with zmq and subprocess off, where the kernel sits behind an in-process transport.

The crates are not on crates.io, so depend on them from git: ganymede-engine = { git = "https://github.com/tlockney/ganymede", tag = "v0.11.0" }.

The engine in a Rust app ​

Engine finds kernelspecs and brings kernels up; Kernel is one running kernel. The crates underneath are re-exported as ganymede_engine::{protocol, nbformat, host, exec}. The table lists Engine's methods down to execute_notebook, then Kernel's.

CallWhat it does
Engine::new()Kernelspecs from the Jupyter data dirs, kernels as subprocesses over ZeroMQ.
Engine::builder().kernelspec_root(dir), .kernelspecs(source), .provisioner(p), .startup_timeout(d) (60 s), .quiet(bool), .prefix_output(bool), .build().
kernelspecs(), kernelspec(name)List, or look one up ignoring case.
start(name), start_with(name, StartOptions { cwd, env })Launch a kernelspec and wait for kernel_info_reply.
attach(path), connect(info)Use a running kernel by connection file or ConnectionInfo, as jupyter console --existing. A bare name is looked up in the Jupyter runtime dir; an empty path picks the newest.
adopt(transport), adopt_launched(launched, transport)Use a kernel the app drives itself.
execute_notebook(path, kernel, &opts)Run a notebook file on a fresh kernel in its directory, without writing the file. Returns the notebook and the run's result separately, so outputs come back when a cell fails.
kernel.execute(code), execute_with(code, &opts, &mut handler)Run a cell; the second streams messages to a Handler and answers input through it.
complete(code, pos), inspect(code, pos, detail)The reply content as JSON (matches, cursor_start, cursor_end; found, data).
is_complete(code)Complete, Incomplete { indent }, Invalid or Unknown.
comm_info, comm_open, comm_msg, comm_closeComms from the frontend side.
interrupt()A signal or interrupt_request, as the kernelspec says.
restart()Relaunch from the kernelspec, keeping the working directory and environment. Only for kernels from start.
shutdown()Ask the kernel to exit, then kill it if the engine launched it. Idempotent.
run_notebook(&mut nb, &opts), run_notebook_with(.., &mut handler)Run a Notebook in memory with nbclient's semantics; the second reports progress and outputs to a NotebookHandler.

Unless quiet, a kernel process's own stdout and stderr (not its cell output) go to the app's stderr. prefix_output(true) starts each of those lines with [kernel <first 8 of its id>] , so several kernels' output can be told apart.

ExecuteResult has status, execution_count, outputs as a notebook stores them, the execute_reply content, and every iopub message. text() is what a terminal would print; error() is (ename, evalue). ExecuteOptions defaults to a notebook cell's settings, with stdin off and no timeout; a cell that runs past its timeout is interrupted. A request still queued behind another client's cell when it times out is not: the interrupt would stop that cell instead.

A timed-out cell that started fails with Host(Timeout); one still queued fails with Queued, and the kernel runs it when its turn comes.

Kernel is Send + Sync: put it in an Arc to interrupt from one task while another executes. A kernel the engine launched is killed when the Kernel is dropped. Calls return ganymede_engine::Result<T>, except run_notebook, which returns the runner's own ganymede_exec::Error:

rust
pub enum Error {
    Host(ganymede_kernel_host::Error),  // launch, transport, timeout, a dead kernel
    Notebook(ganymede_nbformat::Error), // reading or parsing a notebook
    Exec(ganymede_exec::Error),         // a notebook cell that failed or timed out
    Io(std::io::Error),
    Queued(std::time::Duration),        // timed out before the kernel started the cell
    Usage(String),                      // a call this kernel or build can't serve
}

A failed cell is not an Err: execute returns Ok with statusError. Usage is, for example, restart on an attached or adopted kernel, or start with no provisioner.

Streaming output and stdin ​

A Handler gets each iopub message for the request except status as it arrives, and answers input requests. on_input is async, so a UI can wait for the user without blocking the runtime; None answers with an empty string. Messages a slow handler missed are delivered after the reply. Implementing it needs the async-trait crate.

This uses the built-in fake kernel, which needs no Node or Python. Its cell input asks for a line and echoes it, and raise fails:

rust
use async_trait::async_trait;
use ganymede_engine::host::in_process_pair;
use ganymede_engine::host::testing::{FakeKernel, ReplyOrder};
use ganymede_engine::protocol::Message;
use ganymede_engine::{Engine, Error, ExecuteOptions, Handler};

struct Terminal;

#[async_trait]
impl Handler for Terminal {
    fn on_iopub(&mut self, msg: &Message) {
        if msg.msg_type() == "stream" {
            print!("{}", msg.content["text"].as_str().unwrap_or_default());
        }
    }
    async fn on_input(&mut self, prompt: &str, _password: bool) -> Option<String> {
        println!("[prompt {prompt:?}]");
        Some("hello".into())
    }
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    // With a kernelspec installed: Engine::new().start("cdp-node").await?
    let (host, side) = in_process_pair(64);
    FakeKernel::spawn(side, ReplyOrder::IdleThenReply);
    let kernel = Engine::new().adopt(host).await?;

    let opts = ExecuteOptions { allow_stdin: true, ..Default::default() };
    let r = kernel.execute_with("input", &opts, &mut Terminal).await?;
    println!("status: {:?}, count: {:?}", r.status, r.execution_count);
    println!("error: {:?}", kernel.execute("raise").await?.error());
    println!("matches: {}", kernel.complete("ec", 2).await?["matches"]);
    if let Err(Error::Usage(m)) = kernel.restart().await {
        println!("restart: {m}");
    }
    kernel.shutdown().await
}

It prints:

text
[prompt "? "]
you said: hello
status: Ok, count: Some(1)
error: Some(("FakeError", "raised"))
matches: ["echo"]
restart: restart needs a kernel this engine launched

Streaming a notebook run ​

run_notebook_with takes an exec::NotebookHandler. Its on_event gets CellStarted, CellFinished, and an Output for each iopub message once it is written into the notebook's cells. Output names the cells whose outputs changed, which includes earlier cells when a display is updated. A closure taking an exec::Event is a handler.

Kernels may ask for input only when NotebookOptions::allow_stdin is set; it is off by default, as in nbclient. on_input gets the cell's index and the prompt, and answers as Handler::on_input does.

To run cells one at a time, as an editor does, keep one exec::NotebookRun and call execute_cell_with(&kernel.session(), &mut nb.cells, index, &mut handler) for each. The run carries the execution count and display ids between calls, so a cell that updates a display shown by an earlier cell rewrites that cell's output too.

Without ZeroMQ or subprocesses ​

ganymede-engine has two features, both on by default:

FeatureBrings inWithout it, start needs
zmqThe ZeroMQ transport, the default transport factory, attach and connect.transports(factory) on the builder, else Usage("no transport factory: ...")
subprocessSubprocessProvisioner, the default, which launches kernelspecs as child processes.provisioner(p), else Usage("no provisioner: ...")

With both off, the engine is the session state machine, the in-process transport, kernelspec parsing and the notebook runner. In their place, host::in_process_pair(capacity) returns a KernelTransport for the host and a KernelSide (incoming, outgoing) for whatever runs cells. Adopt the transport, as above, or give the engine a provisioner and transport factory, so start and restart work:

rust
#[async_trait]
impl Provisioner for App {
    // No process to start: in_process() has no connection info, and
    // interrupts go over the control channel.
    async fn launch(&self, _: &Kernelspec, _: LaunchOptions) -> Result<LaunchedKernel> {
        Ok(in_process())
    }
}

#[async_trait]
impl TransportFactory for App {
    async fn connect(&self, _: &LaunchedKernel) -> Result<KernelTransport> {
        let (host, side) = in_process_pair(64);
        FakeKernel::spawn(side, ReplyOrder::IdleThenReply); // the app's kernel here
        Ok(host)
    }
}

Pass Arc::new(App) to the builder's .provisioner and .transports, and a KernelspecSource listing the kernel to .kernelspecs, and engine.start(name) works as with subprocesses.

Profilezmq, subprocessKernelsWhat CI checks
Desktop or serveronkernelspecs as child processes, attached kernels, a gatewayeverything
iOS or Android app, with processesonas on desktop, where the platform allows themthe engine, api and contents build for aarch64-apple-ios and aarch64-linux-android
iOS app, in-app kerneloffthe app's own provisioner and transport factory, or a gatewaythe api builds for aarch64-apple-ios; the engine starts and restarts an in-process kernel; the server serves the app's own kernels
Browser wasm, WASIoffin_process_pair and adoptthe engine, api and contents build for wasm32-unknown-unknown; the engine runs a notebook under WASI (e2e/wasm-smoke)

ganymede-server has the same two features, plus gateway. An app embedding the server passes its own kernelspecs, provisioner and transports in serve::Startup; built without a feature and given none, run_with fails at startup.

ganymede-ffi has the same three features, on by default. The XCFramework's macOS slice keeps them; its iOS slices are built with --no-default-features --features gateway. Built without zmq or subprocess, the server in ganymede-ffi lists no kernels and refuses to start one, rather than failing to start, unless gateway_url is set. An app's own kernels go through the C functions instead (Kernels the app runs).

The server in an app, from C ​

ganymede-ffi is a static library with these C functions, declared in crates/ganymede-ffi/include/ganymede.h. The server runs on its own thread with its own tokio runtime, so the app needs no Rust or async code.

FunctionContract
GanymedeServer *ganymede_server_start(const char *config_json, char **error_out)Starts a server on loopback and blocks until it listens, or for at most 30 seconds, then fails. On failure returns NULL and, if error_out isn't NULL, sets *error_out to a message.
const char *ganymede_server_url(const GanymedeServer *server)The URL with its token. The server owns it: don't free it or use it after stop.
void ganymede_server_stop(GanymedeServer *server)Gives open requests a moment, shuts down every kernel, stops the server and frees it. Blocks until done. NULL is ignored. With registered kernels whose callbacks need the main thread (Swift's hop to the main actor), call it off the main thread, or each such kernel waits out the shutdown grace period.
void ganymede_string_free(char *s)Frees a string the library returned, such as the error. NULL is ignored.
void ganymede_set_panic_callback(GanymedePanicCallback cb)Calls cb(message) on the panicking thread after any panic in the library, with panicked at <file>:<line>:<col>: <message>, valid during the call. For the app's crash reporter: a panic in a server task doesn't end the app, so this is how it hears of one. NULL removes it. The library sends nothing itself.
void ganymede_debug_panic(void)Panics on the calling thread, which aborts the process. For testing crash reports.

config_json is a JSON object with the keys of ganymede.toml, or NULL. port defaults to 0, so the OS picks one; ip is 127.0.0.1 and the token is random, as in ganymede serve. An unknown key is an error. Kernels are the kernelspecs the config finds, or a kernel server's through gateway_url; a build without zmq or subprocess, such as the iOS slices, has only the latter (see above). Strings passed in are UTF-8 and stay the caller's. The embedded server installs no signal handlers and prints nothing to stdout.

A call sequence, run on Linux against a release build of the library:

c
#include <stdio.h>
#include "ganymede.h"

int main(void) {
    char *err = NULL;
    GanymedeServer *server = ganymede_server_start(
        "{\"root_dir\": \"notebooks\"}", &err);
    if (!server) {
        fprintf(stderr, "ganymede: %s\n", err);
        ganymede_string_free(err);
        return 1;
    }
    printf("%s\n", ganymede_server_url(server)); /* load in the web view */
    ganymede_server_stop(server);
    return 0;
}

It prints http://127.0.0.1:42549/?token=.... With a missing root_dir the error is root dir /no/such/dir: No such file or directory (os error 2). scripts/ffi-smoke.sh builds the library and links tests/smoke.c with the system libraries rustc --print native-static-libs names.

From Swift ​

On macOS with Xcode, scripts/build-xcframework.sh builds the library for aarch64-apple-darwin (Apple silicon only), aarch64-apple-ios and aarch64-apple-ios-sim, and writes target/xcframework/GanymedeFFI.xcframework and a zip of it. MACOSX_DEPLOYMENT_TARGET (26.0) and IPHONEOS_DEPLOYMENT_TARGET (26.0) set the minimums.

Each release, and the main-build prerelease, carries the zip as GanymedeFFI.xcframework.zip with GanymedeFFI.xcframework.zip.sha256 beside it, built by release.yml with the release profile. Besides the framework, the zip holds LICENSE and THIRD-PARTY-NOTICES for the crates compiled into it:

sh
gh release download vX.Y.Z --repo tlockney/ganymede -p 'GanymedeFFI.xcframework.zip*'
shasum -a 256 -c GanymedeFFI.xcframework.zip.sha256

Add the framework to an Xcode target and import its module:

swift
import GanymedeFFI

var err: UnsafeMutablePointer<CChar>?
guard let server = ganymede_server_start(#"{"root_dir": "/path/to/notebooks"}"#, &err) else {
    let message = err.map { String(cString: $0) } ?? "unknown error"
    ganymede_string_free(err)
    fatalError(message)
}
let url = URL(string: String(cString: ganymede_server_url(server)))!
// Load url in a WKWebView; at quit, ganymede_server_stop(server).

ganymede-ui's app does this in ServerHost.swift; see its architecture.

Kernels the app runs ​

An app that runs a kernel itself, as the iPad app runs cdp-kernel in a web view, registers it with the server. The server lists it as a kernelspec after the gateway's kernels or its own, in place of one of the same name (so the default kernel stays the server's own unless it has none), and runs it through the same sessions, rooms and execute endpoint as any kernel. The design is In-app kernels as server kernels.

FunctionContract
int ganymede_kernel_register(GanymedeServer *server, const char *spec_json, const GanymedeKernelCallbacks *callbacks, char **error_out)Lists spec_json (kernel.json's keys plus "name"; argv may be left out) as a kernelspec the callbacks run. The name is lowercased and, as on disk, takes only [a-z0-9._-]. Returns 0, or nonzero for a bad spec or name, a NULL start, send or stop, or a name already registered; then no callback is ever called, release included, and *error_out holds a message.
int ganymede_kernel_deliver(GanymedeServer *server, const char *kernel_id, const char *channel, const char *msg_json)A message from the kernel on shell, control, stdin or iopub. Any thread; doesn't block. Returns 0; 1 for a NULL string or a message or channel it can't read; 2 when the kernel isn't running, as after a stop.
void ganymede_kernel_ended(GanymedeServer *server, const char *kernel_id, const char *reason)The kernel ended without being asked to. "lost" (the platform ended it) restarts it, unless restarts are off (kernel_restart_limit 0), without counting toward the limit (a loss before the kernel first answers is a failed start, retried as one), and the status: restarting message carries "reason": "lost". Anything else is a crash. Call it once per run, never for a run already stopped.

GanymedeKernelCallbacks holds ctx and four callbacks, each passed ctx:

  • start(ctx, kernel_id, launch_json) starts a kernel and returns 0 once the start is under way; the server then waits for its kernel_info_reply, as for any kernel. launch_json is {"kernelspec", "cwd", "env"}, and env.JPY_SESSION_NAME is the notebook's path for a notebook's kernel. A restart calls it again with the same id.
  • send(ctx, kernel_id, channel, msg_json) hands the kernel a message on shell, control or stdin.
  • stop(ctx, kernel_id) ends it. A shutdown sends shutdown_request first. A restart reuses the id, so once stop is called, or the app called ganymede_kernel_ended, the app delivers nothing more from that run: a late message would reach the new one.
  • release(ctx), which may be NULL, runs once when the server stops, after every other callback.

The server calls them on its own threads, never the app's main thread, and maybe at once for different kernels, so they must not block; Swift hops to the main actor to reach a web view. Calls to send for one kernel don't overlap and come in order. Messages are Jupyter JSON in the websocket's shape (header, parent_header, metadata, content), with binary buffers as base64 strings in buffers, and nothing is signed. Interrupts go over the control channel as interrupt_request, whatever the kernelspec says.

Remote kernels with ganymede-gateway ​

Gateway is a kernelspec source, provisioner and transport factory in one, for the kernels of another Jupyter or Ganymede server, as jupyter_server's gateway client has them. It needs neither zmq nor subprocess, though it opens sockets of its own (tokio's net), so it is not for the browser profile.

rust
let gw = Arc::new(Gateway::new("https://kernels.example.org", Some(token))?);
gw.refresh().await?; // the kernelspec list is empty until this succeeds
let engine = Engine::builder()
    .kernelspecs(gw.clone())
    .provisioner(gw.clone())
    .transports(gw)
    .build();
let kernel = engine.start("cdp-node").await?;

Gateway::new(url, token) makes no request. refresh_every(d) refreshes now and every d on the current tokio runtime; remote_kernels() lists the remote kernel ids. Start, interrupt and shutdown go over the remote REST API, a restart is a new remote kernel, and channels use its websocket. TLS is rustls with the platform's trust store. The server does the same for gateway_url; see Gateway.

MIT license.