feat: clojure.core.async on Janet fibers (Phase 1 — API layer)

Janet fibers are stackful coroutines, so a go block is just its body run in a
fiber that parks on channel ops by yielding to the event loop — the interpreter
call stack rides along, no CPS/state-machine transform. So <!/>! work anywhere
(inside try, nested fns, loops), unlike Clojure's go macro.

src/jolt/async.janet implements chan/chan?/close!/<!/>!/<!!/>!!/go/go-loop/
thread/alts!/timeout/put!/take! over ev/ channels and fibers, installed as the
clojure.core.async namespace (pre-populated in init, so require finds it).

A channel is a pair of ev/chans (:ch values + :done close-signal); a take is
(ev/select :ch :done), which drains buffered values before the close signal —
giving Clojure's drain-then-nil semantics without the buffer loss of
ev/chan-close, and with no leaked fibers (close! just closes :done).

Single-threaded cooperative scheduling: <! (park) and <!! (block) coincide.
Dynamic-var conveyance (Phase 2) and channel transducers (Phase 3) are TODO.

spec: test/spec/core-async-spec (16 cases — go/channels, buffering+close,
go-loop pipelines, alts!/timeout, parking inside try/nested-fn). jpm test green.
This commit is contained in:
Yogthos 2026-06-05 15:17:15 -04:00
parent 858c7fed14
commit 7d1e1f42e1
7 changed files with 212 additions and 0 deletions

View file

@ -0,0 +1,55 @@
# Specification: clojure.core.async on Janet fibers (Phase 1 — API layer).
# Each case is self-contained: it requires the ns, sets up channels/go blocks,
# and ends with a take that pumps the event loop and yields the value compared.
(use ../support/harness)
(def REQ
"(require '[clojure.core.async :refer [go go-loop chan <! >! close! alts! timeout put! take! chan?]]) ")
(defn- a [body] (string "(do " REQ body ")"))
(defspec "core.async / go & channels"
["go produce, <! consume"
"42" (a "(def c (chan)) (go (>! c (+ 40 2))) (<! c)")]
["go returns a result channel"
"42" (a "(<! (go (* 6 7)))")]
["go body nil -> channel closes"
"nil" (a "(<! (go nil))")]
["<! parks mid-expression"
"42" (a "(def x (chan)) (def y (chan)) (go (>! x 10)) (go (>! y 32)) (<! (go (+ (<! x) (<! y))))")]
["chan? true" "true" (a "(chan? (chan))")]
["chan? false" "false" (a "(chan? [1 2])")])
(defspec "core.async / buffering & close"
["buffered channel holds values"
"[1 2 3]"
(a "(def c (chan 5)) (go (>! c 1) (>! c 2) (>! c 3) (close! c)) (<! (go-loop [o []] (let [v (<! c)] (if (nil? v) o (recur (conj o v))))))")]
["closed channel drains then nil"
"true"
(a "(def c (chan 2)) (go (>! c :a) (close! c)) (<! (go (and (= :a (<! c)) (nil? (<! c)))))")]
[">! to a closed channel is false"
"false" (a "(def c (chan 1)) (close! c) (>! c 1)")]
["take from closed empty channel is nil"
"nil" (a "(def c (chan)) (close! c) (<! c)")])
(defspec "core.async / go-loop & pipelines"
["go-loop accumulates"
"6"
(a "(def in (chan 5)) (go (>! in 1) (>! in 2) (>! in 3) (close! in)) (<! (go-loop [acc 0] (let [v (<! in)] (if (nil? v) acc (recur (+ acc v))))))")]
["3-stage concurrent pipeline"
"50"
(a "(def in (chan)) (def mid (chan)) (def out (chan)) (go (>! mid (inc (<! in)))) (go (>! out (* 10 (<! mid)))) (go (>! in 4)) (<! out)")])
(defspec "core.async / alts! & timeout"
["alts! picks the ready channel"
"true"
(a "(def x (chan)) (def y (chan)) (go (>! y :v)) (<! (go (let [[v ch] (alts! [x y])] (and (= v :v) (= ch y)))))")]
["timeout wins over an idle channel"
"true"
(a "(def slow (chan)) (<! (go (let [[v ch] (alts! [slow (timeout 30)])] (nil? v))))")])
# Janet fibers are stackful, so <! works in positions Clojure's go macro forbids.
(defspec "core.async / parking anywhere"
["<! inside try/catch"
"99" (a "(<! (go (try (<! (go 99)) (catch :default e -1))))")]
["<! inside a nested fn called in a go"
"7" (a "(def c (chan)) (go (>! c 7)) (<! (go ((fn [] (<! c)))))")])