Skip to content

Upstream provenance and the three founding fixes

pydeno is a fork. This page records where it came from and the three fixes that made forking worth doing, because that is the part of the history that is still load-bearing: each one is a bug class this codebase now has permanent tests against, and knowing why those tests exist is what keeps someone from deleting the guard along with the bug.

It replaces PATCH.md, PATCH_LARGE_SCRIPT_ABORT.md and PATCH_SNAPSHOT_ABORT.md (281 lines at the repo root). Those were written as patch proposals against an upstream that this repo no longer tracks — they carried re-sync instructions, extraction notes and before/after narratives for work that has long since landed, and they read as open items. The substance that survived the fixes is below; the rest was provenance about a workflow that ended.

Provenance

Upstream imfing/jsrun
Upstream base commit 34b786d2db410cdd264e9c8607ad7cb57e873a64 (tag-less main, fetched 2026-09-20)
Licence MIT, retained with the original copyright — see LICENSE
This repo bmsuisse/pydeno — standalone, not a tracking fork

The name is pydeno = python + deno. The package was renamed on the fork, so the name jsrun no longer appears in the code. The code itself is another matter: measured against the base commit above (whitespace-normalised code lines, October 2026), about 70% of upstream's code lines are still present in this repo, roughly half of today's Rust core is upstream's, and a few upstream test files are untouched. Most of what has been added since is new (the isolation layer, the OS sandbox, the wire codec, the tool boundary, the limits, and most of the tests). That is why the MIT licence and the original copyright are kept, and why this page exists. Thank you to jsrun for the idea and the foundation.

1. Cross-thread termination (TerminationHandle)

The bug. Runtime is #[pyclass(unsendable)], which makes any method call from a thread other than the creating one raise pyo3_runtime.PanicException: Runtime is unsendable, but sent to another thread — including terminate(). So the obvious kill switch for a runaway synchronous eval() (spawn a watchdog thread, call runtime.terminate()) panicked in the watchdog instead of stopping the JS, and the loop ran until the process was killed from outside. unsendable is correct for Runtime, which drives a !Send V8 isolate in place; the mistake was that the one safe cross-thread operation was only reachable through it.

The fix. Split the PyO3 surface along the boundary deno_core itself draws. TerminationController owns only an AtomicU8, a Mutex<Option<String>> and a v8::IsolateHandle — and IsolateHandle is documented Clone + Send + Sync precisely so a host can call terminate_execution() from another thread. It is now exposed as a separate, deliberately not unsendable pyclass, handed out by Runtime.termination_handle() before the blocking call starts.

What guards it now. tests/test_termination_handle.py, plus test_pathological_regexes_are_interruptible, and everything in tests/test_parked_termination.py — which covers the harder case this fix did not reach: a runtime parked on a pending promise, where terminate_execution() is a no-op because V8 only acts on a termination when it next enters JavaScript. See BENCHMARKS.md for the kill latencies.

2. Uncatchable SIGABRT on large JS source

The bug. Evaluating more than roughly 148 KB of JS source (binary search found the threshold at 147,654 bytes; 147,303 still worked) aborted the whole Python process:

V8 posted a delayed task, but this isolate was created outside of a tokio
runtime context and the delay cannot be honored.

Not a panic and not catchable from Python: deno_core calls std::process::abort() from a frame Rust cannot unwind. It reproduced through every entry point (eval, eval_async, module-level pydeno.eval_async, RuntimeConfig(bootstrap=...)) and independently under tight heap limits, because the trigger is source size or GC pressure, not the call path.

The root cause. deno_core's register_isolate snapshots tokio::runtime::Handle::try_current() at JsRuntime::new time. V8 later posts a delayed foreground task — streaming/background compilation for a large script, or a GC memory-reducer timer — deno_core looks up that stored handle, finds None, and aborts. spawn_runtime_thread built its tokio runtime and then created the isolate before block_on, so try_current() always failed and every isolate was registered one delayed task away from an abort.

The fix. Enter the thread's own tokio runtime around RuntimeCoreState::new (let _tokio_enter = tokio_rt.enter();), which is exactly what deno_core's abort message recommends. The guard is dropped before block_on takes over. The comment at that line in src/runtime/runner/mod.rs is deliberately long; it is the only thing standing between a future refactor and a reintroduced process abort.

What guards it now. tests/test_large_script_eval.py — 200 KB to 5 MB across all four call paths, the exact pre/post-threshold sizes from triage, and one case that calls a bound host function from inside a large script, plus test_source_around_the_streaming_compile_threshold.

3. The same bug class in SnapshotBuilder, closed pre-emptively

SnapshotBuilder::new had the identical precondition: it builds JsRuntimeForSnapshot on whatever thread Python calls it from, with no tokio runtime entered. It does not crash, and considerable effort went into trying: 20 MB filler bootstraps, snapshots with 300 MB+ of heap data, 50 rounds of ~500 MB GC churn, 60,000 top-level function declarations. None aborted. The reason is that will_snapshot = true routes isolate creation through V8's SnapshotCreator, which appears to force fully synchronous compilation for determinism, so the handle: None is never consulted.

The fix was applied anyway: create_runtime() enters a minimal current-thread tokio runtime around isolate creation. It costs nothing, removes the last register_isolate call site in this codebase that could register handle: None, and means a future V8 or deno_core that does schedule background work during snapshotting finds a handle instead of aborting. This is defence in depth, and it is recorded as such rather than as a fixed crash.

What guards it now. The SnapshotBuilder half of tests/test_large_script_eval.py: large bootstraps through both the constructor and execute_script(), and an end-to-end check that a snapshot built from a large bootstrap still starts a working Runtime.