Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5c341d2
vm: add per-interpreter runtime state and interpreter registry
youknowone Aug 13, 2026
43f4b23
gc: stop every interpreter while collecting
youknowone Aug 13, 2026
ffd3d1e
vm: scope the interpreter registry per thread without threading
youknowone Aug 13, 2026
0079994
vm: keep the interpreter registry usable across bootstrap, drop and fork
youknowone Aug 13, 2026
8f108ff
vm: attach and detach thread slots when switching interpreters
youknowone Aug 13, 2026
696a8c1
vm: list only the current interpreter's subclasses
youknowone Aug 14, 2026
562b657
gc: give each interpreter its own collector state
youknowone Aug 14, 2026
30e2020
_queue, _thread: detach before taking locks held across waits
youknowone Aug 14, 2026
5ce062e
_io, _winapi: detach on the remaining stopped-holdable lock takes
youknowone Aug 14, 2026
87b6c68
gc: size the interpreter owner tag to the header padding
youknowone Aug 15, 2026
fd27902
vm: reuse cleared frame blocks and shorten interpreter hot paths
youknowone Aug 15, 2026
c3fcc70
gc, vm: address review notes on owner tags and interpreter docs
youknowone Aug 15, 2026
57428cd
vm: gate interpreter registration on stop-the-world admission
youknowone Aug 15, 2026
8d07831
gc: keep the tracking counters off the barrier path
youknowone Aug 15, 2026
4e518d3
dict: settle lookups and iteration steps under one read guard
youknowone Aug 15, 2026
eb94daf
vm: build a call's argument vector once
youknowone Aug 15, 2026
f70db35
vm: reach an instance dict without cloning it
youknowone Aug 15, 2026
4a76424
vm: shorten the per-instruction safepoint and the call preamble
youknowone Aug 15, 2026
b0f0dcc
vm: give KwArgs a zero-sized hasher
youknowone Aug 15, 2026
f9bbc6d
vm: promote borrowed stack refs before a yield
youknowone Aug 15, 2026
b4bb43c
vm: run the exact-args vectorcall on a heap frame again
youknowone Aug 15, 2026
98d2410
vm: release a tail call's function at the callee's return
youknowone Aug 16, 2026
08bd522
vm: stop copying a running frame's locals when it materializes
youknowone Aug 16, 2026
c7c1452
vm: key data stack frame reuse on the exact frame size
youknowone Aug 16, 2026
2d58fc8
vm: store the frame object payload address in the cross-thread slot
youknowone Aug 16, 2026
e24d831
vm: copy a foreign thread's frames instead of linking them
youknowone Aug 16, 2026
5d0d9be
vm: resolve the two rustdoc links this branch added
youknowone Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
vm: gate interpreter registration on stop-the-world admission
A collection snapshots the registry, stops the interpreters it found, and
then reads tracked objects with their threads parked. An interpreter that
registered after the snapshot was taken was absent from it, so nothing
stopped it and its bootstrap ran Python — allocating and mutating the
shared generation lists — underneath that scan.

Registration and the stop now share a process-global gate: the collection
holds it from before the snapshot until the restart, and registration takes
it around the registry insert. The insert runs detached, since a thread
that waited for the gate, or re-attached while holding it, would leave the
stop it waits for no safepoint to complete at.

Assisted-by: Claude
  • Loading branch information
youknowone committed Aug 15, 2026
commit 57428cd92e4eca2df9d2818ae78847423ce86199
9 changes: 9 additions & 0 deletions crates/vm/src/gc_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ struct CollectStopTheWorld {
/// interpreter's objects, and so removes them from these lists — happens
/// after the collection has let go of the generation locks.
stopped: Vec<crate::common::rc::PyRc<crate::vm::PyGlobalState>>,
/// Keeps interpreters from registering between the snapshot below and the
/// restart. One registered in that window would be missing from `stopped`,
/// so its bootstrap would keep running — and mutating the shared generation
/// lists — while this collection reads them.
admission: Option<parking_lot::MutexGuard<'static, ()>>,
restarted: bool,
}

Expand All @@ -195,6 +200,7 @@ impl CollectStopTheWorld {
if !crate::vm::thread::current_vm_is_set() {
return Self {
stopped: Vec::new(),
admission: None,
restarted: true,
};
}
Expand All @@ -205,6 +211,7 @@ impl CollectStopTheWorld {
// and their exclusion held forever.
let mut guard = Self {
stopped: Vec::new(),
admission: Some(crate::vm::runtime::lock_admission_for_stop()),
restarted: false,
};
for state in crate::vm::runtime::live_interpreter_states() {
Expand All @@ -225,6 +232,8 @@ impl CollectStopTheWorld {
for state in self.stopped.iter().rev() {
state.stop_the_world.start_the_world(state);
}
// Nothing is parked any more, so registration may resume.
self.admission = None;
}

/// Whether this collection actually stopped the world.
Expand Down
38 changes: 38 additions & 0 deletions crates/vm/src/vm/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,44 @@ mod tests {
}
}

/// A collection snapshots the registry and then reads tracked objects with
/// the interpreters it found parked. An interpreter that registered inside
/// that window would be missing from the snapshot, so nothing would stop it
/// and its bootstrap would run under the scan; registration therefore waits
/// for the stop to end.
#[cfg(feature = "threading")]
#[test]
fn registering_waits_for_an_in_flight_stop() {
use core::time::Duration;
use std::sync::mpsc;

// Stands in for a collector between its snapshot and its restart.
let admission = runtime::lock_admission_for_stop();

let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let interp = Interpreter::without_stdlib(Default::default());
tx.send(interp.id()).expect("receiver is alive");
interp
});

assert!(
matches!(
rx.recv_timeout(Duration::from_millis(200)),
Err(mpsc::RecvTimeoutError::Timeout)
),
"an interpreter registered while a stop-the-world was in flight"
);

drop(admission);
let id = rx
.recv_timeout(Duration::from_secs(30))
.expect("registration proceeds once the world restarts");
assert!(runtime::lookup_interpreter(id).is_some());
drop(worker.join().expect("worker did not panic"));
wait_until_unregistered(id);
}

/// Dropping a subinterpreter releases it; main remains.
#[test]
fn drop_subinterpreter_unregisters() {
Expand Down
69 changes: 54 additions & 15 deletions crates/vm/src/vm/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,53 @@ pub(crate) fn alloc_interpreter_id() -> i64 {
registry().next_id.fetch_add(1, Ordering::Relaxed)
}

/// Gate between registering an interpreter and a collection's stop-the-world.
///
/// A collection snapshots the registry, stops every interpreter in the
/// snapshot, and then reads tracked objects with those threads parked. An
/// interpreter that registered after the snapshot was taken would not be in it,
/// so nothing would stop it, and its bootstrap — which runs Python and mutates
/// the shared generation lists — would run underneath that scan. Registration
/// therefore waits for an in-flight stop to end; the next collection's snapshot
/// then contains the new interpreter.
fn admission() -> &'static Mutex<()> {
static ADMISSION: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
ADMISSION.get_or_init(|| Mutex::new(()))
}

/// Take the admission gate for the duration of a stop-the-world.
#[cfg(feature = "threading")]
pub(crate) fn lock_admission_for_stop() -> parking_lot::MutexGuard<'static, ()> {
admission().lock()
}

/// Add the registry entry, behind the admission gate.
///
/// Only ever called with this thread detached, because the gate is held across
/// a stop-the-world: an attached thread waiting here, or re-attaching while
/// holding the gate, would leave that stop no safepoint to complete at. Nothing
/// under the gate blocks or allocates a tracked object, so this cannot re-enter
/// the collection it waits for.
fn insert_registry_entry(state: &PyRc<PyGlobalState>) {
let _admission = admission().lock();
let mut entries = registry().entries.lock();
// Entries are weak and an interpreter's lifetime is decided by its last
// `PyRc<PyGlobalState>` — which outlives the `Interpreter` handle whenever
// `new_thread()` workers are still running — so nothing removes them at a
// fixed point. Reap the dead ones here to bound the table instead.
entries.retain(|_, entry| entry.state.strong_count() > 0);
entries.insert(
state.interpreter_id,
RegistryEntry {
whence: state.whence,
state: PyRc::downgrade(state),
},
);
}

/// Register an interpreter state in the registry.
pub(crate) fn register_interpreter(state: &PyRc<PyGlobalState>) {
let id = state.interpreter_id;
let whence = state.whence;
if state.is_main {
// First `is_main` interpreter defines the main for `get_main()`.
// Additional top-level Interpreters (embedding) keep their own `is_main`
Expand All @@ -149,19 +192,14 @@ pub(crate) fn register_interpreter(state: &PyRc<PyGlobalState>) {
Ordering::Relaxed,
);
}
let mut entries = registry().entries.lock();
// Entries are weak and an interpreter's lifetime is decided by its last
// `PyRc<PyGlobalState>` — which outlives the `Interpreter` handle whenever
// `new_thread()` workers are still running — so nothing removes them at a
// fixed point. Reap the dead ones here to bound the table instead.
entries.retain(|_, entry| entry.state.strong_count() > 0);
entries.insert(
id,
RegistryEntry {
whence,
state: PyRc::downgrade(state),
},
);
// A subinterpreter is registered by a thread that is running its parent, so
// detach for the whole insert rather than only for the wait.
let detached = crate::vm::thread::try_with_current_vm(|vm| {
vm.allow_threads(|| insert_registry_entry(state))
});
if detached.is_none() {
insert_registry_entry(state);
}
}

/// Look up a live interpreter state by id.
Expand Down Expand Up @@ -206,12 +244,13 @@ pub fn interpreter_count() -> usize {
///
/// # Safety
/// Must only be called after `fork()` in the child process, when no other
/// threads exist and the calling thread holds neither lock.
/// threads exist and the calling thread holds none of these locks.
#[cfg(all(unix, feature = "threading"))]
pub unsafe fn reinit_after_fork() {
unsafe {
crate::common::lock::reinit_mutex_after_fork(&registry().entries);
crate::common::lock::reinit_mutex_after_fork(owned_interpreters());
crate::common::lock::reinit_mutex_after_fork(admission());
}
}

Expand Down