[5/5] Add R7RS parameters with Guile semantics where unspecified - #321
Draft
lshoravi wants to merge 10 commits into
Draft
[5/5] Add R7RS parameters with Guile semantics where unspecified#321lshoravi wants to merge 10 commits into
lshoravi wants to merge 10 commits into
Conversation
lshoravi
force-pushed
the
linuss/parameters
branch
from
July 8, 2026 21:05
c9d2f53 to
5384558
Compare
lshoravi
force-pushed
the
linuss/parameters
branch
4 times, most recently
from
July 22, 2026 20:12
4bac51c to
e512465
Compare
lshoravi
force-pushed
the
linuss/parameters
branch
from
July 26, 2026 20:45
e512465 to
00fb6fe
Compare
DynState (dyn_stack, cont_marks) is no longer a field of ContBarrier.
It's now a plain local owned by whichever Application::eval/eval_sync
call is the current true entry point (root eval, or a spawn site with
a snapshot), published to a thread-local/task-local ambient as before.
ContBarrier shrinks to { id, params }: just escape-procedure scoping
and host params, both of which still need to travel as a parameter.
Everything that touches dynamic state - with-exception-handler,
dynamic-wind, call/cc's SavedDynamicState capture, current-input/
output-port, continuation marks - now reads/writes through DYN_STATE
instead of through barrier.state.
DynStateSlot is a unit struct wrapping the two storage mechanisms
(CURRENT_DYN_STATE thread-local, TASK_DYN_STATE task-local) as private
module statics reached only through its methods (is_published, with,
enter_sync, enter_async). Callers never touch the raw locals or write
the try-task-then-thread fallback themselves; that logic lives in
DynStateSlot::with and ::is_published, once. The single static
DYN_STATE is the only instance.
Both locals are RefCell<Option<DynState>>, not raw pointers: no
unsafe, no Send hacks. DynStateSlot::with is the only access point; it
does a short borrow_mut(), runs the closure, drops the borrow. The
trampoline's eval loop never holds a borrow across apply(), so nested
re-entry's own borrow_mut never conflicts with an outer one still in
scope. A double-borrow would panic instead of being UB.
Nested re-entry (Procedure::call/call_sync) is just ContBarrier::new()
plus whatever the ambient already has published; there's no separate
root/nested constructor. Application::eval/eval_sync check
DYN_STATE.is_published() themselves: false means a true entry point,
so they create a fresh DynState and publish it (with_dyn_state/
with_dyn_state_sync, both thin wrappers over DynStateSlot::enter_sync/
enter_async) for the duration; true means a nested call or a spawn
site that already published one, so they run without touching the
ambient. Spawn sites (threads.rs, futures.rs) snapshot the ambient on
the parent side and publish the snapshot into the child's ambient
themselves before calling in; their call sites are unchanged, since
with_dyn_state/with_dyn_state_sync keep the same signatures.
Keeps the two correctness fixes from the earlier spike: the trampoline
owns the continuation-mark balance (push baseline, truncate on every
exit) and escape_procedure halts its own barrier's trampoline on a
cross-barrier rejection instead of raising through the (now
ambient-shared) exception-handler search.
Function signatures that exist only to match a fixed calling
convention (JIT-generated code, or ContinuationPtr/BridgePtr function
pointers) keep their barrier parameter even where the body no longer
uses it, to avoid touching every call site; those are renamed to
`_barrier` and noted as vestigial. Renamed the pop_dyn_stack
continuation function to pop_dyn_stack_cont to avoid colliding with
the new free function of the same name.
Ported from maplant#319 (commit 57bb515). Three properties of the ambient design: a hashtable hash function observes the caller's exception handler; a custom hashtable callback still can't call/cc past the Rust re-entry frame it runs in; a spawned thread doesn't run the parent's winders.
join read the result cell guarded only by a mutex the child locks as its first action; if join won that race it returned the initial empty result, which surfaced downstream as a wrong-arity error at the continuation. Hold the std::thread::JoinHandle and join it before reading. A panicking thunk previously left the cell at its empty default; the panic payload is now stored as an exception so every joiner sees it.
Spawned OS thread thunks went through call_sync, whose apply_sync arm raises on any async bridge, so thunks could not use display, put-string or the (async) library. Block on the async call path instead: enter the runtime via Handle::try_current when one exists (timers and IO work), and fall back to futures::executor::block_on otherwise. call_sync keeps its raising behavior as a strict-sync API; nothing nests block_on on one thread. Tests: an async bridge (display) inside a spawn thunk, which raised "attempt to apply async function in a sync-only context" before, and a timer-based thunk driving a real Poll::Pending round-trip through Handle::block_on on a multi_thread runtime.
HashTableInner::{hash,eq} called Scheme-defined procedures through
call_sync, which raises on async bridges by design (a strict-sync call
API), so Scheme hash/eq functions could not use async bridges at all.
Run them through the async call path instead.
Instead of swapping the table lock for tokio::sync::RwLock (viral:
fmt::Debug for HashTable takes the lock inside a sync trait method,
reachable from every Value print site), keep parking_lot and run the
callbacks unlocked: compute the hash first, snapshot hash-matching
entries under a short read lock, run eq on the clones, and re-acquire
the write lock to mutate, re-finding the entry by eqv? identity.
The cost is a check-then-act window under concurrent mutation (a lost
update, or briefly two eq-equal-but-not-eqv? keys), documented on
make-hashtable; R6RS makes no thread-safety promises for hashtables.
The non-async build is untouched.
Tests: a hash function hitting an async bridge (display) from hashtable
operations, which raised before this change, and a hash function that
awaits a tokio timer under a current_thread runtime.
A library body referencing a binding the same library defines and exports crashed with a native stack overflow: the binding is already registered in the global table as Unexpanded(lib) where lib is the library currently being parsed, maybe_expand short-circuits (state is already Expanding), and the lookup retries the same stale entry forever. lookup_keyword_inner and lookup_var_inner now short-circuit when the unexpanded origin is the environment currently being parsed, and a reentrancy backstop converts any remaining cyclic lookup pattern into a catchable exception instead of a stack overflow.
lshoravi
force-pushed
the
linuss/parameters
branch
from
July 27, 2026 09:37
00fb6fe to
c34100e
Compare
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.
Stacked on #320; the diff includes the earlier stack's commits until they
merge. Supersedes #312 (closed).
R7RS-small parameters, with Guile's semantics where the report is silent
(bare parameter assignment, thread behavior). Parameters are their own
applicable type, as in Guile and Racket:
make-parameterreturns theparameter object itself and application dispatches through a companion
procedure built at construction. No closure wrapping, and no scanning of
closure environments to recover the parameter, which an earlier revision of
this branch did and which coupled parameter semantics to closure-conversion
layout.
Parameterrecord holds id/default/converter plus a companionProcedureclosing over the parameter. Zero args reads; one arg writes,through the converter when present (the set runs in a continuation after
the converter call).
parameter's companion, and
TryFrom<Value> for Procedureextracts thecompanion so every bridge-side call site (apply, call-with-values, map,
...) accepts parameters uniformly.
parameter?is a plain type check.procedure?answers #t forparameters, as in Guile and Racket.
zero or one arguments". Parameters print as
#<parameter>.DynState.param_rootsseededlazily from the default;
parameterizepushes aDynStackElem::Parameterizationfor the body's extent; lookup walks thedyn-stack innermost-out, then the task root, then the default. Spawn
value-copies roots and bindings, so child mutations never escape
(guile-fibers semantics). Cross-library persistence, callback visibility,
and tokio spawn/future inheritance are pinned by tests.
Open question:
%parameter-refand%parameter-set!are now redundantsince the companion handles ref and set;
parameterizestill needs%parameter-converterfor bridge-level access to the record. Happy to trimthe two if you'd rather not keep them.