feat: core.async Phase 2 — dynamic var binding conveyance

Jolt's dynamic-var binding stack was a single global array, so concurrent go
blocks interleaved each other's bindings and a go block didn't see the bindings
in effect when it was spawned.

Move the binding stack into Janet's fiber-local dyn (:jolt/binding-stack): each
fiber (go block) lazily gets its own array, so bindings can't interleave. Janet
ev/go fibers inherit the parent's dyn, but go-spawn now snapshots the binding
stack at spawn time and installs a private copy in the new fiber — Clojure
binding conveyance. A go block's own (binding ...) shadows the conveyed frame.

types.janet: cur-binding-stack / snapshot-bindings / install-bindings; var-get/
var-set/push/pop use the fiber-local stack. async.janet: go-spawn conveys.

spec: core.async/binding-conveyance (4 cases — conveyance, isolation, no leak to
root, inner shadowing). jpm test green.
This commit is contained in:
Yogthos 2026-06-05 15:22:38 -04:00
parent 7d1e1f42e1
commit e8cb4605ac
4 changed files with 52 additions and 9 deletions

View file

@ -6,7 +6,7 @@
{"_type":"issue","id":"jolt-alz","title":"CRITICAL: self-referential lazy-seq/lazy-cat yields only literal prefix","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T17:46:13Z","created_by":"Yogthos","updated_at":"2026-06-04T18:09:15Z","closed_at":"2026-06-04T18:09:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-alz","title":"CRITICAL: self-referential lazy-seq/lazy-cat yields only literal prefix","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T17:46:13Z","created_by":"Yogthos","updated_at":"2026-06-04T18:09:15Z","closed_at":"2026-06-04T18:09:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-wec","title":"CRITICAL: multi-collection map returns unrealized thunk","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T17:46:12Z","created_by":"Yogthos","updated_at":"2026-06-04T18:06:02Z","closed_at":"2026-06-04T18:06:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-wec","title":"CRITICAL: multi-collection map returns unrealized thunk","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-06-04T17:46:12Z","created_by":"Yogthos","updated_at":"2026-06-04T18:06:02Z","closed_at":"2026-06-04T18:06:02Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-5i5","title":"core.async Phase 2: dynamic var binding conveyance","description":"Snapshot/restore the global binding-stack per fiber so dynamic var bindings don't interleave across concurrent go blocks (Clojure binding conveyance).","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-06-05T18:55:08Z","created_by":"Yogthos","updated_at":"2026-06-05T18:55:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-5i5","title":"core.async Phase 2: dynamic var binding conveyance","description":"Snapshot/restore the global binding-stack per fiber so dynamic var bindings don't interleave across concurrent go blocks (Clojure binding conveyance).","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-06-05T18:55:08Z","created_by":"Yogthos","updated_at":"2026-06-05T18:55:08Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-tkw","title":"core.async on Janet fibers — Phase 1: API layer","description":"Implement clojure.core.async API (chan/go/\u003c!/\u003e!/\u003c!!/\u003e!!/close!/alts!/timeout/put!/take!/buffers) on top of Janet ev/ channels and fibers. go = run body in a fiber (stackful, no CPS macro needed).","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-06-05T18:55:07Z","created_by":"Yogthos","updated_at":"2026-06-05T18:55:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-tkw","title":"core.async on Janet fibers — Phase 1: API layer","description":"Implement clojure.core.async API (chan/go/\u003c!/\u003e!/\u003c!!/\u003e!!/close!/alts!/timeout/put!/take!/buffers) on top of Janet ev/ channels and fibers. go = run body in a fiber (stackful, no CPS macro needed).","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-06-05T18:55:07Z","created_by":"Yogthos","updated_at":"2026-06-05T19:17:25Z","closed_at":"2026-06-05T19:17:25Z","close_reason":"Phase 1 complete: chan/go/\u003c!/\u003e!/close!/alts!/timeout/put!/take! on Janet fibers; two-channel design (values + done) gives drain-then-nil with no leaked fibers; 16 spec cases","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-lko","title":"clojure-test-suite: strictness — throw on malformed calls like Clojure","description":"Make jolt throw where Clojure throws (bad-shape conj!/assoc!, arithmetic on non-numbers, out-of-range indices, etc.) to satisfy the suite's ~292 p/thrown? assertions. Targeted input validation across affected fns; guard against regressing jolt's spec/conformance.","status":"closed","priority":2,"issue_type":"task","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T14:45:20Z","created_by":"Yogthos","updated_at":"2026-06-05T18:07:56Z","started_at":"2026-06-05T14:45:39Z","closed_at":"2026-06-05T18:07:56Z","close_reason":"Implemented Clojure-strict argument validation across ~30 fns; suite pass 3691-\u003e3898, p/thrown? bucket 292-\u003e~60 (remainder is platform: int 32-bit range, subs Unicode, min-key NaN, subvec floats, map-entry-as-2-vector)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-lko","title":"clojure-test-suite: strictness — throw on malformed calls like Clojure","description":"Make jolt throw where Clojure throws (bad-shape conj!/assoc!, arithmetic on non-numbers, out-of-range indices, etc.) to satisfy the suite's ~292 p/thrown? assertions. Targeted input validation across affected fns; guard against regressing jolt's spec/conformance.","status":"closed","priority":2,"issue_type":"task","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T14:45:20Z","created_by":"Yogthos","updated_at":"2026-06-05T18:07:56Z","started_at":"2026-06-05T14:45:39Z","closed_at":"2026-06-05T18:07:56Z","close_reason":"Implemented Clojure-strict argument validation across ~30 fns; suite pass 3691-\u003e3898, p/thrown? bucket 292-\u003e~60 (remainder is platform: int 32-bit range, subs Unicode, min-key NaN, subvec floats, map-entry-as-2-vector)","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-kxb","title":"clojure-test-suite: transducers don't short-circuit over infinite seqs (hangs)","description":"(into [] (take 5) (range)) and similar transducer-over-infinite forms hang jolt's eager evaluator (no reduced/early-termination). 7 suite files time out. Make transducing honor reduced.","status":"closed","priority":2,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:18Z","created_by":"Yogthos","updated_at":"2026-06-05T13:18:37Z","started_at":"2026-06-05T13:09:59Z","closed_at":"2026-06-05T13:18:37Z","close_reason":"reduce-with-reduced steps lazy seqs incrementally; transducers short-circuit over infinite seqs","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-kxb","title":"clojure-test-suite: transducers don't short-circuit over infinite seqs (hangs)","description":"(into [] (take 5) (range)) and similar transducer-over-infinite forms hang jolt's eager evaluator (no reduced/early-termination). 7 suite files time out. Make transducing honor reduced.","status":"closed","priority":2,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T13:05:18Z","created_by":"Yogthos","updated_at":"2026-06-05T13:18:37Z","started_at":"2026-06-05T13:09:59Z","closed_at":"2026-06-05T13:18:37Z","close_reason":"reduce-with-reduced steps lazy seqs incrementally; transducers short-circuit over infinite seqs","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"jolt-js6","title":"Port clojure-test-suite as integration battery","description":"Build a minimal clojure.test + portability shim so Jolt can load the external ~/src/clojure-test-suite (240 per-fn .cljc files in clojure.test format). Add a baseline-guarded integration runner following the jank-conformance pattern (skip if absent).","status":"closed","priority":2,"issue_type":"task","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T12:22:01Z","created_by":"Yogthos","updated_at":"2026-06-05T13:05:04Z","started_at":"2026-06-05T12:22:14Z","closed_at":"2026-06-05T13:05:04Z","close_reason":"Ported clojure-test-suite as baseline-guarded integration battery; surfaced+fixed 2 evaluator macro bugs","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"jolt-js6","title":"Port clojure-test-suite as integration battery","description":"Build a minimal clojure.test + portability shim so Jolt can load the external ~/src/clojure-test-suite (240 per-fn .cljc files in clojure.test format). Add a baseline-guarded integration runner following the jank-conformance pattern (skip if absent).","status":"closed","priority":2,"issue_type":"task","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-06-05T12:22:01Z","created_by":"Yogthos","updated_at":"2026-06-05T13:05:04Z","started_at":"2026-06-05T12:22:14Z","closed_at":"2026-06-05T13:05:04Z","close_reason":"Ported clojure-test-suite as baseline-guarded integration battery; surfaced+fixed 2 evaluator macro bugs","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -55,9 +55,15 @@
# Run thunk (a jolt 0-arg closure, directly callable) in a fiber; return a # Run thunk (a jolt 0-arg closure, directly callable) in a fiber; return a
# buffered(1) channel that conveys its value once, then closes. A nil result # buffered(1) channel that conveys its value once, then closes. A nil result
# just closes. Buffered(1) so a fire-and-forget go leaves no parked fiber. # just closes. Buffered(1) so a fire-and-forget go leaves no parked fiber.
#
# The dynamic-var bindings in effect at spawn time are conveyed into the fiber
# (Clojure binding conveyance): we snapshot them here (on the spawning fiber)
# and install a private copy inside the new fiber before running the body.
(defn async-go-spawn [thunk] (defn async-go-spawn [thunk]
(def snap (snapshot-bindings))
(def w (async-chan 1)) (def w (async-chan 1))
(ev/go (fn [] (ev/go (fn []
(install-bindings snap)
(def res (protect (thunk))) (def res (protect (thunk)))
(when (and (in res 0) (not (nil? (in res 1)))) (when (and (in res 0) (not (nil? (in res 1))))
(async-give w (in res 1))) (async-give w (in res 1)))

View file

@ -40,17 +40,36 @@
# Var # Var
# ============================================================ # ============================================================
(def- binding-stack @[]) # stack of {var → value} tables for thread-local bindings # Dynamic-var binding stack. Stored fiber-locally (via Janet's dyn), so that
# concurrent go blocks — each a Janet fiber — don't interleave each other's
# dynamic bindings, and a go block conveys the bindings in effect when it was
# spawned (see snapshot-bindings/install-bindings). Each fiber lazily gets its
# own array on first use.
(defn cur-binding-stack []
(or (dyn :jolt/binding-stack)
(let [s @[]] (setdyn :jolt/binding-stack s) s)))
(defn push-thread-bindings (defn push-thread-bindings
"Push a frame of dynamic var bindings. Takes a struct of var→value." "Push a frame of dynamic var bindings. Takes a struct of var→value."
[bindings] [bindings]
(array/push binding-stack bindings)) (array/push (cur-binding-stack) bindings))
(defn pop-thread-bindings (defn pop-thread-bindings
"Pop the most recent frame of dynamic var bindings." "Pop the most recent frame of dynamic var bindings."
[] []
(array/pop binding-stack)) (array/pop (cur-binding-stack)))
(defn snapshot-bindings
"Shallow copy of the current binding stack (frames are immutable value maps).
Captured by a go block at spawn time for binding conveyance."
[]
(array/slice (cur-binding-stack)))
(defn install-bindings
"Install a snapshot as this fiber's binding stack (a fresh copy, so the
fiber's own push/pop/var-set don't mutate the snapshot's frames array)."
[snap]
(setdyn :jolt/binding-stack (array/slice snap)))
(defn make-var (defn make-var
"Create a new Jolt Var. "Create a new Jolt Var.
@ -107,10 +126,11 @@
Otherwise return the root binding." Otherwise return the root binding."
[v] [v]
# walk binding stack top-down for this var # walk binding stack top-down for this var
(def bs (cur-binding-stack))
(var result nil) (var result nil)
(var i (dec (length binding-stack))) (var i (dec (length bs)))
(while (>= i 0) (while (>= i 0)
(let [frame (in binding-stack i) (let [frame (in bs i)
val (get frame v)] val (get frame v)]
(if (not (nil? val)) (if (not (nil? val))
(do (do
@ -124,12 +144,13 @@
the innermost frame that binds it (matching Clojure, where var-set targets the the innermost frame that binds it (matching Clojure, where var-set targets the
current binding); otherwise set the root." current binding); otherwise set the root."
[v val] [v val]
(var i (dec (length binding-stack))) (def bs (cur-binding-stack))
(var i (dec (length bs)))
(var done false) (var done false)
(while (and (not done) (>= i 0)) (while (and (not done) (>= i 0))
(let [frame (in binding-stack i)] (let [frame (in bs i)]
(if (not (nil? (get frame v))) (if (not (nil? (get frame v)))
(do (put binding-stack i (merge frame {v val})) (set done true)) (do (put bs i (merge frame {v val})) (set done true))
(-- i)))) (-- i))))
(unless done (put v :root val)) (unless done (put v :root val))
val) val)

View file

@ -53,3 +53,19 @@
"99" (a "(<! (go (try (<! (go 99)) (catch :default e -1))))")] "99" (a "(<! (go (try (<! (go 99)) (catch :default e -1))))")]
["<! inside a nested fn called in a go" ["<! inside a nested fn called in a go"
"7" (a "(def c (chan)) (go (>! c 7)) (<! (go ((fn [] (<! c)))))")]) "7" (a "(def c (chan)) (go (>! c 7)) (<! (go ((fn [] (<! c)))))")])
# Dynamic-var binding conveyance (Phase 2): a go block sees the dynamic bindings
# in effect when it was spawned, concurrent go blocks don't interleave, and a
# go block's own binding shadows the conveyed one.
(defn- d [body] (string "(do " REQ "(def ^:dynamic *x* 0) " body ")"))
(defspec "core.async / binding conveyance"
["go conveys the binding"
"10" (d "(<! (binding [*x* 10] (go (<! (timeout 5)) *x*)))")]
["concurrent go blocks isolated"
"[:a :b]"
(d "(def ra (binding [*x* :a] (go (<! (timeout 20)) *x*))) (def rb (binding [*x* :b] (go (<! (timeout 5)) *x*))) [(<! ra) (<! rb)]")]
["binding doesn't leak to root"
"0" (d "(<! (binding [*x* 99] (go (<! (timeout 5))))) *x*")]
["go's own binding shadows conveyed"
":inner"
(d "(<! (binding [*x* :outer] (go (binding [*x* :inner] (<! (timeout 5)) *x*))))")])