shape_value/shape_graph_current.rs
1//! Task-local "current" `ShapeTransitionTable` handle.
2//!
3//! Mirrors the pattern established in
4//! `shape-runtime::type_schema::current` for `TypeSchemaRegistry` (B1.3),
5//! but lives in `shape-value` because `ShapeTransitionTable` is a
6//! shape-value type and pulling it up into shape-runtime would invert
7//! the crate dependency.
8//!
9//! Two layers, consulted in order:
10//!
11//! 1. **Task-local** (`CURRENT_SHAPE_TABLE`) — survives task migration
12//! across tokio worker threads so any descendant `.await` in a Shape
13//! execution future inherits the handle automatically.
14//! 2. **Thread-local** (`SYNC_CURRENT_SHAPE_TABLE`) — fallback for
15//! synchronous entry points (CLI, tests, REPL one-shots) that are not
16//! running under a tokio task. A [`SyncShapeTableScope`] RAII guard
17//! pushes/pops the value so nested scopes compose correctly.
18//!
19//! Unlike the type-schema current module, this one exposes a
20//! [`try_current_shape_table`] accessor that is the primary lookup used
21//! by `shape_graph`'s free functions (`shape_transition`,
22//! `shape_for_hashmap_keys`, `shape_property_index`,
23//! `drain_shape_transitions`). When no scope is active those free
24//! functions degrade to `None` / empty-drain — this preserves the
25//! existing "fall back to hash lookup, no shape tracking" semantic that
26//! was already returned for lock-poisoned or overflow cases by the
27//! previous global-backed implementation, which keeps unit tests that
28//! poke `HashMapData::compute_shape` without a VM alive.
29
30use crate::shape_graph::{ShapeId, ShapeTransitionTable};
31use std::cell::RefCell;
32use std::future::Future;
33use std::sync::{Arc, LazyLock, Mutex};
34
35/// Shareable handle to a shape transition table and its transition log.
36///
37/// The table is the same object that the pre-B5 `GLOBAL_SHAPE_TABLE`
38/// exposed — a `Mutex`-guarded transition graph. The log records
39/// `(parent, child)` pairs for JIT shape-guard invalidation and is
40/// drained by `TierManager::check_shape_invalidations`.
41///
42/// The interior `Mutex`s are deliberately simple: table writes are
43/// expected to be rare (only when a HashMap gains a new key) and the
44/// lock is held briefly.
45pub struct ShapeTableHandle {
46 table: Mutex<ShapeTransitionTable>,
47 transition_log: Mutex<Vec<(ShapeId, ShapeId)>>,
48}
49
50impl ShapeTableHandle {
51 /// Build a fresh handle over an empty transition table.
52 pub fn new() -> Arc<Self> {
53 Arc::new(Self {
54 table: Mutex::new(ShapeTransitionTable::new()),
55 transition_log: Mutex::new(Vec::new()),
56 })
57 }
58
59 /// Access the inner transition table mutex.
60 #[inline]
61 pub fn table(&self) -> &Mutex<ShapeTransitionTable> {
62 &self.table
63 }
64
65 /// Access the inner transition-log mutex.
66 #[inline]
67 pub fn transition_log(&self) -> &Mutex<Vec<(ShapeId, ShapeId)>> {
68 &self.transition_log
69 }
70}
71
72impl Default for ShapeTableHandle {
73 fn default() -> Self {
74 Self {
75 table: Mutex::new(ShapeTransitionTable::new()),
76 transition_log: Mutex::new(Vec::new()),
77 }
78 }
79}
80
81tokio::task_local! {
82 /// Task-local current handle. Set by [`with_async_shape_table_scope`]
83 /// around any async execution entry. Inherited by all descendant
84 /// `.await`s of that future.
85 static CURRENT_SHAPE_TABLE: Arc<ShapeTableHandle>;
86}
87
88thread_local! {
89 /// Synchronous fallback. Managed exclusively by
90 /// [`SyncShapeTableScope`] for push/pop semantics.
91 static SYNC_CURRENT_SHAPE_TABLE: RefCell<Option<Arc<ShapeTableHandle>>> =
92 const { RefCell::new(None) };
93}
94
95/// Process-wide default handle used when neither a task-local nor a
96/// thread-local scope is active.
97///
98/// Callers that poke `HashMapData::compute_shape` / `shape_get`
99/// directly from a stdlib function or unit test — without a VM
100/// execution scope — historically relied on the pre-B5
101/// `GLOBAL_SHAPE_TABLE` static always being available. This fallback
102/// preserves that semantic: scopeless callers share one isolated-
103/// per-process table instead of getting `None`. Scoped callers
104/// (Runtime-installed) still get their own per-VM isolation, which
105/// was the B5 goal.
106static DEFAULT_SHAPE_TABLE: LazyLock<Arc<ShapeTableHandle>> =
107 LazyLock::new(ShapeTableHandle::new);
108
109/// RAII guard that installs a shape-table handle on the thread-local
110/// slot for the lifetime of the guard.
111///
112/// The previous value (if any) is captured on construction and restored
113/// on drop, so nested scopes compose correctly. Used by synchronous VM
114/// execution entry points (CLI, unit tests, REPL one-shot) that are not
115/// running under a tokio task.
116#[must_use = "the scope only lives as long as the guard is held"]
117pub struct SyncShapeTableScope {
118 prev: Option<Arc<ShapeTableHandle>>,
119}
120
121impl SyncShapeTableScope {
122 /// Install `handle` as the current thread-local shape-table handle,
123 /// saving the previous value for restoration on drop.
124 pub fn enter(handle: Arc<ShapeTableHandle>) -> Self {
125 let prev = SYNC_CURRENT_SHAPE_TABLE
126 .with(|cell| cell.borrow_mut().replace(handle));
127 Self { prev }
128 }
129}
130
131impl Drop for SyncShapeTableScope {
132 fn drop(&mut self) {
133 SYNC_CURRENT_SHAPE_TABLE.with(|cell| {
134 *cell.borrow_mut() = self.prev.take();
135 });
136 }
137}
138
139/// Return the current ambient shape-table handle, or `None` if no scope
140/// is active.
141///
142/// Checks the task-local slot first, then falls back to the
143/// thread-local slot. Returning `None` rather than panicking is
144/// deliberate: the shape-graph free functions that consult this handle
145/// (`shape_transition`, `shape_for_hashmap_keys`,
146/// `shape_property_index`, `drain_shape_transitions`) already return
147/// `Option`/`Vec` and already degrade gracefully when no shape table is
148/// accessible.
149pub fn try_current_shape_table() -> Option<Arc<ShapeTableHandle>> {
150 if let Ok(h) = CURRENT_SHAPE_TABLE.try_with(|h| h.clone()) {
151 return Some(h);
152 }
153 if let Some(h) = SYNC_CURRENT_SHAPE_TABLE.with(|cell| cell.borrow().clone()) {
154 return Some(h);
155 }
156 Some(DEFAULT_SHAPE_TABLE.clone())
157}
158
159/// Panicking variant of [`try_current_shape_table`]. Callers that are
160/// guaranteed to execute within a VM scope may prefer this for fast-
161/// fail diagnostics.
162///
163/// # Panics
164///
165/// Panics if no scope is active.
166pub fn current_shape_table() -> Arc<ShapeTableHandle> {
167 try_current_shape_table().expect(
168 "no current ShapeTransitionTable is active; wrap execution in \
169 shape_graph_current::with_async_shape_table_scope or hold a \
170 SyncShapeTableScope",
171 )
172}
173
174/// Run `fut` with `handle` installed as the task-local current shape
175/// table. Inherited by all descendant `.await` points and survives
176/// tokio task migration across worker threads.
177pub async fn with_async_shape_table_scope<R>(
178 handle: Arc<ShapeTableHandle>,
179 fut: impl Future<Output = R>,
180) -> R {
181 CURRENT_SHAPE_TABLE.scope(handle, fut).await
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn sync_scope_push_pop_restores_previous() {
190 let h1 = ShapeTableHandle::new();
191 let h2 = ShapeTableHandle::new();
192
193 let baseline = try_current_shape_table().expect("default fallback is always present");
194
195 let outer = SyncShapeTableScope::enter(h1.clone());
196 assert!(Arc::ptr_eq(¤t_shape_table(), &h1));
197
198 {
199 let inner = SyncShapeTableScope::enter(h2.clone());
200 assert!(Arc::ptr_eq(¤t_shape_table(), &h2));
201 drop(inner);
202 }
203
204 // Outer restored after inner drop.
205 assert!(Arc::ptr_eq(¤t_shape_table(), &h1));
206 drop(outer);
207
208 // Default fallback visible again after outer drop.
209 assert!(Arc::ptr_eq(¤t_shape_table(), &baseline));
210 }
211
212 #[test]
213 fn try_current_falls_back_to_process_default_without_scope() {
214 // On a fresh thread with no installed scope, the process-wide
215 // default handle is returned so scopeless stdlib / unit-test
216 // callers retain pre-B5 shape-tracking semantics.
217 let first = try_current_shape_table().expect("default fallback");
218 let second = try_current_shape_table().expect("default fallback stable");
219 assert!(Arc::ptr_eq(&first, &second));
220 }
221
222 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
223 async fn async_scope_survives_task_migration() {
224 let handle = ShapeTableHandle::new();
225 let expected = handle.clone();
226
227 with_async_shape_table_scope(handle, async move {
228 tokio::task::yield_now().await;
229 let observed = current_shape_table();
230 assert!(Arc::ptr_eq(&observed, &expected));
231
232 // Nested scopes compose normally.
233 let inner = ShapeTableHandle::new();
234 with_async_shape_table_scope(inner.clone(), async {
235 tokio::task::yield_now().await;
236 assert!(Arc::ptr_eq(¤t_shape_table(), &inner));
237 })
238 .await;
239
240 assert!(Arc::ptr_eq(¤t_shape_table(), &expected));
241 })
242 .await;
243 }
244
245 #[test]
246 fn task_local_takes_precedence_over_thread_local() {
247 let rt = tokio::runtime::Builder::new_current_thread()
248 .build()
249 .expect("current-thread runtime");
250
251 let sync_handle = ShapeTableHandle::new();
252 let async_handle = ShapeTableHandle::new();
253
254 let _guard = SyncShapeTableScope::enter(sync_handle.clone());
255 assert!(Arc::ptr_eq(¤t_shape_table(), &sync_handle));
256
257 rt.block_on(async {
258 with_async_shape_table_scope(async_handle.clone(), async {
259 assert!(Arc::ptr_eq(¤t_shape_table(), &async_handle));
260 })
261 .await;
262 });
263
264 // After the async scope ends, the thread-local is visible again.
265 assert!(Arc::ptr_eq(¤t_shape_table(), &sync_handle));
266 }
267}