feat(stdlib): Jobs, Promises & opt-in Sync concurrency (#14)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 15s
ci / build-and-test (push) Successful in 1m28s
commit-lint / conventional-commits (push) Successful in 2s
docs / build-and-deploy (push) Successful in 23s

A layered concurrency library, safe by default. The recommended tier is
Job.* / Promise.*: a Job is a future — Job.run(kind, arg) starts a
cooperative background compute that advances each Job.pump(budget) and
finishes after enough frames (heavy work spreads out instead of hitching),
or Job.defer + Job.fulfill/fail/cancel drives one by hand. Poll with
done/ok/failed/cancelled, read result/error, count outstanding work with
Job.pending. Promise.all/race combine handle lists into a group job resolved
on the main thread; Promise.count_done/all_done power a loading bar.

The advanced, opt-in Sync.* tier (mutex/atomic/channel + cpu_count) is the
"here be dragons" surface for engine-level message passing.

The whole thing is a deterministic cooperative scheduler: results are
collected on the main thread and a Job never touches the ECS world, so
lockstep and replays stay bit-exact — same jobs + same budget reproduce
byte-for-byte on every target, and a preemptive OS-thread backend can slot
behind this same API later. Ludic has no closures, so a Job carries a
compute kind + int arg (or a hand-driven defer) rather than fn()->…, and
Promise progress is polled rather than chained through then.

Written in Ludic and spliced on demand (like Regex/Dict/Numeric): a program
that never mentions Job.*/Promise.*/Sync.* compiles byte-identically and the
C-free bootstrap fixpoint is untouched. New: runtime/native/jobs.ludic,
emit_ns_call dispatch, parse-time splice, examples/library/jobs.ludic (31
self-asserting checks), 33 docs pages + inventory, changeset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 03:32:31 +03:00
parent 872f458cb2
commit 50ecb8472f
44 changed files with 25637 additions and 22806 deletions

113
examples/library/jobs.ludic Normal file
View file

@ -0,0 +1,113 @@
# jobs.ludic — Job.* / Promise.* (safe, deterministic) + Sync.* (advanced,
# opt-in). Each assertion that holds prints its number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
# One cooperative scheduler backs it all (see runtime/native/jobs.ludic): a
# background Job runs a little each Job.pump and its result is collected on the
# main thread — no hitches, no locks in game code, and byte-identical every run.
program Concurrency {
entry {
# --- Job: background compute, collected on the main thread ---
# kinds: 1 = sum 1..n, 2 = fib(n), 3 = count primes <= n
let sum = Job.run(1, 100) # 1 + 2 + ... + 100 = 5050
Job.pump(0) # budget 0 = run every job to completion
if Job.ok(sum) and Job.result(sum) == 5050 { print(1) }
let fib = Job.run(2, 10) # fib(10) = 55
Job.pump(0)
if Job.result(fib) == 55 { print(2) }
let primes = Job.run(3, 20) # primes <= 20: 2 3 5 7 11 13 17 19 -> 8
Job.pump(0)
if Job.result(primes) == 8 { print(3) }
# cooperative: a job finishes only after enough total budget has been spent,
# so heavy work is spread across frames instead of hitching one.
let slow = Job.run(1, 10) # needs 10 steps; 1+..+10 = 55
Job.pump(3) # 3 of 10
if not Job.done(slow) { print(4) }
Job.pump(3) # 6 of 10
if not Job.done(slow) { print(5) }
Job.pump(100) # finishes this frame
if Job.done(slow) and Job.result(slow) == 55 { print(6) }
# hand-driven future: defer now, fulfill later (no closures needed)
let f = Job.defer()
if not Job.done(f) { print(7) }
Job.fulfill(f, 42)
if Job.ok(f) and Job.result(f) == 42 { print(8) }
# error path: a job can fail with a code
let e = Job.defer()
Job.fail(e, 9)
if Job.failed(e) and Job.error(e) == 9 { print(9) }
# cancel path
let c = Job.defer()
Job.cancel(c)
if Job.cancelled(c) and Job.done(c) and not Job.ok(c) { print(10) }
# --- Promise: combine futures, resolve on the main thread ---
let a1 = Job.defer()
let a2 = Job.defer()
let a3 = Job.defer()
let all = new []int
push(all, a1)
push(all, a2)
push(all, a3)
let grp = Promise.all(all)
if not Job.done(grp) { print(11) }
if Promise.count_done(all) == 0 { print(12) } # loading bar: 0 / 3
Job.fulfill(a1, 1)
Job.fulfill(a2, 2)
if Promise.count_done(all) == 2 { print(13) } # 2 / 3
if not Promise.all_done(all) { print(14) }
Job.fulfill(a3, 3)
if Promise.all_done(all) { print(15) } # 3 / 3
if Job.ok(grp) and Job.result(grp) == 3 { print(16) }
# race: the first member to succeed wins; its handle is the result
let b1 = Job.defer()
let b2 = Job.defer()
let two = new []int
push(two, b1)
push(two, b2)
let winner = Promise.race(two)
Job.fulfill(b2, 77)
if Job.ok(winner) and Job.result(winner) == b2 { print(17) }
# Promise.all fails once the set settles with a failure
let d1 = Job.defer()
let d2 = Job.defer()
let dd = new []int
push(dd, d1)
push(dd, d2)
let dgrp = Promise.all(dd)
Job.fulfill(d1, 1)
Job.fail(d2, 5)
if Job.failed(dgrp) { print(18) }
# --- Sync: the advanced, opt-in tier (cooperative + deterministic today) ---
let m = Sync.mutex()
Sync.lock(m)
if not Sync.try_lock(m) { print(19) } # already held
Sync.unlock(m)
if Sync.try_lock(m) { print(20) } # now free
let at = Sync.atomic()
if Sync.add(at, 5) == 5 { print(21) }
if Sync.add(at, 3) == 8 { print(22) }
if Sync.cas(at, 8, 100) { print(23) } # 8 -> 100
if Sync.get(at) == 100 { print(24) }
if not Sync.cas(at, 8, 0) { print(25) } # stale expect, no swap
let ch = Sync.channel()
if Sync.send(ch, 10) { print(26) }
Sync.send(ch, 20)
Sync.send(ch, 30)
if Sync.len(ch) == 3 { print(27) }
if Sync.recv(ch) == 10 { print(28) } # FIFO order
if Sync.recv(ch) == 20 { print(29) }
if Sync.can_recv(ch) { print(30) } # one left (30)
if Sync.cpu_count() >= 1 { print(31) }
}
}