Repository navigation
Give every document its own Jupyter kernel (bd-i7yz08uh) - #1173
Merged
cscheid merged 5 commits intoNov 11, 2026
Merged
Conversation
…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>
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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. |
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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_PATHetc.) were also wrong for later cells.QUARTO_JOBS=1): a document could read the globals of whichever document ran before it, so results depended on render order.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
SessionKeygainsdocument(ExecutionContext.source_path).JupyterDaemon::acquirereturns aSessionLease, 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 oldstart_lockalso kept concurrentpeek_portscalls apart; a small reserved-port set now does that job, holding each kernel's ports until it is ready.KernelScopecarries 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 mostQUARTO_JOBSkernels 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.guides/authoring/jupyter-kernels.qmdgains "Each document has its own kernel";execution-context.qmddrops 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
78b4863c6before the fix: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 aKernelPidoutput so a test can see which process ran a cell.jupyter_document_isolation_e2e.rs: the interleave underQUARTO_JOBS=2(four cells, so the interleave happens every time at HEAD; the doc comment explains why) and the globals leak underQUARTO_JOBS=1.render_leaves_no_kernel_behindnow expects one kernel per document, and both must be dead after exit.fake_kernel.rs: exclusive per document; a held lease doesn't block another document;Releaseshuts the kernel down; a waiter whose holder releases gets a fresh kernel the daemon tracks.QUARTO_JOBS=1workaround Give code cells the Quarto 1 execute environment (QUARTO_EXECUTE_INFO and friends) #1161 added toexecute_env_cli_e2e.rsis removed.No snapshot files changed.
Verification
cargo nextest run --workspace: 19286 passed. Excluded:knitr::capture_tests::capture_classifies_pieces, which fails atmaintoo 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.cargo clippy --target x86_64-pc-windows-msvc) cannot run from macOS (ring/aws-lc-sysbuild C). The change adds no platform-specific code or cfg gates.q2 renderon the repro fixtures: correct results atQUARTO_JOBS=1and2, with no stray kernels afterwards. The interleave fixture renders in 7.2 s atQUARTO_JOBS=2versus 11.4 s at1.q2 preview(headless browser, two "Re-execute" clicks):a.qmdre-ran on the same kernel (same PID,runs 1, thenruns 2),b.qmdhad 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_fixtureshitfailed 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 onfa24d2750passed. Investigating it found two things, both fixed infa24d2750:KernelSession::shutdown()was a thin wrapper aroundshutdown_blocking(), and that function has to skip the gracefulshutdown_requestwhen it runs on a tokio runtime thread. Onmain, kernels were shut down fromKernelScope::drop, off the runtime. A document's lease now ends insideengine_runtime().block_on, so every kernel was killed and useratexithandlers 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 anatexithandler write a marker; it failed before the fix and passes now.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