Skip to content

Give every document its own Jupyter kernel (bd-i7yz08uh) - #1173

Merged
cscheid merged 5 commits into
mainfrom
bugfix/bd-i7yz08uh-parallel-project-render-interleaves
Nov 11, 2026
Merged

cscheid merged 5 commits into
mainfrom
bugfix/bd-i7yz08uh-parallel-project-render-interleaves

Conversation

@cscheid

@cscheid cscheid commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Problem

Kernel sessions were keyed by (kernel name, working directory). In a project render the working directory is the project directory, so every Python document of a project shared one kernel:

  • Parallel render (QUARTO_JOBS=2): cells of different documents interleaved on that kernel, so a document could read a value another document had just set (doc a sees owner = b). The per-document execute variables from Give code cells the Quarto 1 execute environment (QUARTO_EXECUTE_INFO and friends) #1161 (QUARTO_DOCUMENT_PATH etc.) were also wrong for later cells.
  • Serial render (QUARTO_JOBS=1): a document could read the globals of whichever document ran before it, so results depended on render order.
  • The daemon-wide sessions lock, taken per cell, also serialized cells on unrelated kernels.

Quarto 1 never shares a kernel between documents. Its one-shot path starts a fresh kernel per document, and its keepalive daemon is keyed by the document's path.

Fix

  • SessionKey gains document (ExecutionContext.source_path).
  • JupyterDaemon::acquire returns a SessionLease, an owned guard on that session's own lock, which the document holds for its whole run. The map lock is never held across an await, so documents on different kernels now run in parallel. Kernels start without a shared lock. The old start_lock also kept concurrent peek_ports calls apart; a small reserved-port set now does that job, holding each kernel's ports until it is ready.
  • KernelScope carries a policy:
    • kernel_scope() (q2 render) shuts a document's kernel down as soon as the document finishes, on both the success and error paths. At most QUARTO_JOBS kernels are alive at once.
    • kernel_scope_retaining() (q2 preview) keeps each document's kernel warm, with its state, for the next render of that document, as Quarto 1's keepalive daemon does. No reset on reuse.
  • Docs: guides/authoring/jupyter-kernels.qmd gains "Each document has its own kernel"; execution-context.qmd drops the shared-kernel paragraph.

Design discussion and decisions: claude-notes/plans/2026-11-11-jupyter-kernel-doc-isolation.md.

Tests

The tests in the first two groups failed at 78b4863c6 before the fix:

  • Fake kernel (CI-safe), text_execute_isolation.rs: two documents in one directory run on different kernel processes; a document's kernel is gone once it finishes; a retaining scope reuses a document's kernel across renders. The fake kernel gains a KernelPid output so a test can see which process ran a cell.
  • Real Python, jupyter_document_isolation_e2e.rs: the interleave under QUARTO_JOBS=2 (four cells, so the interleave happens every time at HEAD; the doc comment explains why) and the globals leak under QUARTO_JOBS=1. render_leaves_no_kernel_behind now expects one kernel per document, and both must be dead after exit.
  • Lease tests in fake_kernel.rs: exclusive per document; a held lease doesn't block another document; Release shuts the kernel down; a waiter whose holder releases gets a fresh kernel the daemon tracks.
  • The QUARTO_JOBS=1 workaround Give code cells the Quarto 1 execute environment (QUARTO_EXECUTE_INFO and friends) #1161 added to execute_env_cli_e2e.rs is removed.

No snapshot files changed.

Verification

  • cargo nextest run --workspace: 19286 passed. Excluded: knitr::capture_tests::capture_classifies_pieces, which fails at main too on this machine because of knitr 1.52 (bd-3p8y4gdt, unrelated).
  • cargo xtask verify --skip-hub-build: lints, clippy, build and the strict claude-notes render pass; the test step stops only on the knitr test above. The Jupyter engine is native-only, so the hub/WASM build is unaffected.
  • Windows cross-check (cargo clippy --target x86_64-pc-windows-msvc) cannot run from macOS (ring/aws-lc-sys build C). The change adds no platform-specific code or cfg gates.
  • End to end with q2 render on the repro fixtures: correct results at QUARTO_JOBS=1 and 2, with no stray kernels afterwards. The interleave fixture renders in 7.2 s at QUARTO_JOBS=2 versus 11.4 s at 1.
  • End to end with q2 preview (headless browser, two "Re-execute" clicks): a.qmd re-ran on the same kernel (same PID, runs 1, then runs 2), b.qmd had its own kernel, and after SIGINT both kernels were gone and their connection files removed.

Second commit: graceful shutdown, and kernels dying at startup

The first CI run failed once on Ubuntu: q1_equivalence_fixtures hit failed to create shell socket: Connect timed out after 30s. It never reproduced locally in five full loops of all 301 Jupyter tests, and the rerun on fa24d2750 passed. Investigating it found two things, both fixed in fa24d2750:

  • A regression from this PR: kernels were being SIGKILLed. KernelSession::shutdown() was a thin wrapper around shutdown_blocking(), and that function has to skip the graceful shutdown_request when it runs on a tokio runtime thread. On main, kernels were shut down from KernelScope::drop, off the runtime. A document's lease now ends inside engine_runtime().block_on, so every kernel was killed and user atexit handlers never ran. shutdown() now sends the request on the current runtime and waits for the kernel to exit before the kill backstop. A new e2e test, a_finished_documents_kernel_exits_gracefully, has an atexit handler write a marker; it failed before the fix and passes now.
  • Diagnostics. A kernel that died while starting surfaced only as that 30 s timeout, and its stderr was piped but never read. The shell connect now races the process exiting, and such a kernel fails at once with ExitedDuringStartup, carrying its exit code and stderr. This is tested with a fake kernel that exits at startup; I wrote that test alongside the change, not before it. If the timeout recurs, the message will now tell a dead kernel from a slow one. The cause of the original timeout is still unknown: a port collision, or a kernel that started slowly under load.

Follow-up found along the way: bd-wjg6sjxj (kernels orphaned when the owning process is killed by a signal; present on main).

🤖 Generated with Claude Code

cscheid and others added 4 commits November 11, 2026 09:24
…state across documents

Plan skeleton plus interleave and sequential-leak repro fixtures. Both
reproduce at 78b4863. Quarto 1 keys kernels per document; q2 keys per
(kernel, cwd) and locks the whole session map per cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ase/retain policy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kernel sessions were keyed by (kernel, working directory), and a project
render's working directory is the project directory, so all python
documents of a project shared one kernel. A parallel render interleaved
their cells (`doc a sees owner = b`), and even a serial render let one
document read another's globals. The daemon-wide sessions lock, held per
cell, also serialized cells on unrelated kernels.

Quarto 1 never shares a kernel between documents, and q2 now matches it:

- SessionKey gains the document (ExecutionContext.source_path).
- JupyterDaemon::acquire returns a SessionLease, an owned guard on the
  session's own lock, held for the document's whole run. The map lock is
  never held across an await, so documents on different kernels run in
  parallel. Kernels start without a shared lock; a reserved-port set
  replaces start_lock's protection against concurrent peek_ports.
- KernelScope carries a policy: kernel_scope() (q2 render) shuts a
  document's kernel down when the document finishes, on success and
  error; kernel_scope_retaining() (q2 preview) keeps it warm, with its
  state, for the document's next render, as Quarto 1's keepalive does.
- Docs: jupyter-kernels.qmd gains "Each document has its own kernel";
  execution-context.qmd drops the shared-kernel paragraph.

Tests, all red at 78b4863 before the fix: fake-kernel tests that two
documents get two kernels and a kernel is gone once its document
finishes; real-Python e2e tests for the interleave (QUARTO_JOBS=2) and
the globals leak (QUARTO_JOBS=1); render_leaves_no_kernel_behind now
expects one kernel per document. New lease tests (exclusive per
document, no cross-document blocking, release, waiter after release)
and a retain test. The QUARTO_JOBS=1 workaround in
execute_env_cli_e2e.rs is removed.

No snapshot files changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@posit-snyk-bot

posit-snyk-bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cscheid

cscheid commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

@gordonwoodhull FYI, I ran into this pretty bad jupyter kernel bug and it needed a bit of a change on how we manage Jupyter kernels. I hope the changes here don't affect much your concurrent work on #1171 etc, but I thought I'd give you a heads up.

@gordonwoodhull

Copy link
Copy Markdown
Member

This sounds right!

Should be easy to merge. Thanks!

…ng at startup (bd-i7yz08uh)

KernelSession::shutdown() was a wrapper around shutdown_blocking(), which
must skip the graceful shutdown_request on a tokio runtime thread. Before
this branch kernels were shut down from KernelScope::drop, off the
runtime, so they exited gracefully; a document's lease now ends inside
engine_runtime().block_on, so every kernel was SIGKILLed and user exit
handlers (atexit) never ran. shutdown() now sends the request on the
current runtime and waits for the exit before the shared kill backstop.
New e2e test a_finished_documents_kernel_exits_gracefully (an atexit
handler writes a marker) failed before this change and passes now.

CI saw one "failed to create shell socket: Connect timed out after 30s"
(q1_equivalence_fixtures, ubuntu) that does not reproduce locally. A
kernel that dies while starting used to surface only as that timeout,
with its stderr piped and never read. The shell connect now races the
process exiting and fails at once with ExitedDuringStartup (exit code
and stderr). Tested with a fake kernel that exits at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cscheid
cscheid merged commit ee1d474 into main Nov 11, 2026
17 checks passed
@cscheid
cscheid deleted the bugfix/bd-i7yz08uh-parallel-project-render-interleaves branch November 11, 2026 19:22
cscheid added a commit that referenced this pull request Nov 11, 2026
(cherry picked from commit 7d6627449)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants