Skip to content

[5/5] Add R7RS parameters with Guile semantics where unspecified - #321

Draft
lshoravi wants to merge 10 commits into
maplant:mainfrom
lshoravi:linuss/parameters
Draft

[5/5] Add R7RS parameters with Guile semantics where unspecified#321
lshoravi wants to merge 10 commits into
maplant:mainfrom
lshoravi:linuss/parameters

Conversation

@lshoravi

@lshoravi lshoravi commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

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-parameter returns the
parameter 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.

  • Parameter record holds id/default/converter plus a companion
    Procedure closing over the parameter. Zero args reads; one arg writes,
    through the converter when present (the set runs in a continuation after
    the converter call).
  • Applicability is plumbed at two seams: the JIT apply dispatch applies a
    parameter's companion, and TryFrom<Value> for Procedure extracts the
    companion so every bridge-side call site (apply, call-with-values, map,
    ...) accepts parameters uniformly.
  • parameter? is a plain type check. procedure? answers #t for
    parameters, as in Guile and Racket.
  • Applying a parameter to more than one argument raises "parameter accepts
    zero or one arguments". Parameters print as #<parameter>.
  • Per-task roots in DynState.param_roots seeded
    lazily from the default; parameterize pushes a
    DynStackElem::Parameterization for the body's extent; lookup walks the
    dyn-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-ref and %parameter-set! are now redundant
since the companion handles ref and set; parameterize still needs
%parameter-converter for bridge-level access to the record. Happy to trim
the two if you'd rather not keep them.

@lshoravi
lshoravi force-pushed the linuss/parameters branch from c9d2f53 to 5384558 Compare July 8, 2026 21:05
@lshoravi
lshoravi force-pushed the linuss/parameters branch 4 times, most recently from 4bac51c to e512465 Compare July 22, 2026 20:12
@lshoravi
lshoravi force-pushed the linuss/parameters branch from e512465 to 00fb6fe Compare July 26, 2026 20:45
lshoravi added 10 commits July 27, 2026 11:02
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
lshoravi force-pushed the linuss/parameters branch from 00fb6fe to c34100e Compare July 27, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant