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

View file

@ -0,0 +1,7 @@
---
id: job
title: Job
order: 37
---
Background work that stays out of the frame. A <code>Job</code> is a future — a handle to a result that lands later. Kick one off with <code>Job.run</code> (a background compute that advances a little each <code>Job.pump</code> and finishes after enough frames, so heavy work never hitches) or <code>Job.defer</code> (a future you resolve yourself with <code>Job.fulfill</code> / <code>Job.fail</code>). Poll it with <code>done</code> / <code>ok</code> / <code>failed</code> / <code>cancelled</code>, read <code>result</code> / <code>error</code>, and always collect on the main thread — a Job must never touch the ECS world directly. The scheduler is deterministic and cooperative, so the same jobs and the same budget reproduce byte-for-byte, every run and every target. Arguments are positional. Spliced in only when a program mentions <code>Job.*</code>.

View file

@ -0,0 +1,19 @@
---
id: job-cancel
name: Job.cancel
category: job
kind: namespace-method
tokens: Job.cancel
sig: Job.cancel(handle) -> void
tip: Cancel a job before it finishes.
order: 4
ns: Job
member: cancel
---
Cancel a job before it finishes.
```ludic
let job = Job.defer()
Job.cancel(job)
```

View file

@ -0,0 +1,20 @@
---
id: job-cancelled
name: Job.cancelled
category: job
kind: namespace-method
tokens: Job.cancelled
sig: Job.cancelled(handle) -> bool
tip: Was the job cancelled?
order: 9
ns: Job
member: cancelled
---
Was the job cancelled?
```ludic
let job = Job.defer()
Job.cancel(job)
let stopped = Job.cancelled(job)
```

View file

@ -0,0 +1,19 @@
---
id: job-defer
name: Job.defer
category: job
kind: namespace-method
tokens: Job.defer
sig: Job.defer() -> Job
tip: A future you resolve yourself later.
order: 0
ns: Job
member: defer
---
A future you resolve yourself later.
```ludic
let job = Job.defer()
Job.fulfill(job, 42)
```

View file

@ -0,0 +1,20 @@
---
id: job-done
name: Job.done
category: job
kind: namespace-method
tokens: Job.done
sig: Job.done(handle) -> bool
tip: Has the job resolved (any outcome)?
order: 6
ns: Job
member: done
---
Has the job resolved (any outcome)?
```ludic
let job = Job.defer()
Job.fulfill(job, 1)
let finished = Job.done(job)
```

View file

@ -0,0 +1,20 @@
---
id: job-error
name: Job.error
category: job
kind: namespace-method
tokens: Job.error
sig: Job.error(handle) -> int
tip: The error code of a failed job.
order: 11
ns: Job
member: error
---
The error code of a failed job.
```ludic
let job = Job.defer()
Job.fail(job, 500)
let code = Job.error(job)
```

View file

@ -0,0 +1,19 @@
---
id: job-fail
name: Job.fail
category: job
kind: namespace-method
tokens: Job.fail
sig: Job.fail(handle, error) -> void
tip: Resolve a pending job as failed.
order: 3
ns: Job
member: fail
---
Resolve a pending job as failed.
```ludic
let job = Job.defer()
Job.fail(job, 404)
```

View file

@ -0,0 +1,20 @@
---
id: job-failed
name: Job.failed
category: job
kind: namespace-method
tokens: Job.failed
sig: Job.failed(handle) -> bool
tip: Did the job fail?
order: 8
ns: Job
member: failed
---
Did the job fail?
```ludic
let job = Job.defer()
Job.fail(job, 9)
let bad = Job.failed(job)
```

View file

@ -0,0 +1,20 @@
---
id: job-free
name: Job.free
category: job
kind: namespace-method
tokens: Job.free
sig: Job.free(handle) -> void
tip: Release a job slot back to the pool.
order: 13
ns: Job
member: free
---
Release a job slot back to the pool.
```ludic
let job = Job.defer()
Job.fulfill(job, 1)
Job.free(job)
```

View file

@ -0,0 +1,19 @@
---
id: job-fulfill
name: Job.fulfill
category: job
kind: namespace-method
tokens: Job.fulfill
sig: Job.fulfill(handle, value) -> void
tip: Resolve a pending job with a value.
order: 2
ns: Job
member: fulfill
---
Resolve a pending job with a value.
```ludic
let job = Job.defer()
Job.fulfill(job, 7)
```

View file

@ -0,0 +1,20 @@
---
id: job-ok
name: Job.ok
category: job
kind: namespace-method
tokens: Job.ok
sig: Job.ok(handle) -> bool
tip: Did the job succeed?
order: 7
ns: Job
member: ok
---
Did the job succeed?
```ludic
let job = Job.defer()
Job.fulfill(job, 1)
let good = Job.ok(job)
```

View file

@ -0,0 +1,18 @@
---
id: job-pending
name: Job.pending
category: job
kind: namespace-method
tokens: Job.pending
sig: Job.pending() -> int
tip: How many jobs are still unresolved.
order: 12
ns: Job
member: pending
---
How many jobs are still unresolved.
```ludic
let left = Job.pending() # a ready-made loading-screen counter
```

View file

@ -0,0 +1,19 @@
---
id: job-pump
name: Job.pump
category: job
kind: namespace-method
tokens: Job.pump
sig: Job.pump(budget) -> int
tip: Advance background jobs; collect results.
order: 5
ns: Job
member: pump
---
Advance background jobs; collect results.
```ludic
let job = Job.run(1, 1000)
Job.pump(64) # spend up to 64 steps this frame (0 = finish all)
```

View file

@ -0,0 +1,20 @@
---
id: job-result
name: Job.result
category: job
kind: namespace-method
tokens: Job.result
sig: Job.result(handle) -> int
tip: The success value of a done job.
order: 10
ns: Job
member: result
---
The success value of a done job.
```ludic
let job = Job.defer()
Job.fulfill(job, 42)
let value = Job.result(job)
```

View file

@ -0,0 +1,19 @@
---
id: job-run
name: Job.run
category: job
kind: namespace-method
tokens: Job.run
sig: Job.run(kind, arg) -> Job
tip: Start a background compute job.
order: 1
ns: Job
member: run
---
Start a background compute job.
```ludic
let job = Job.run(1, 100) # 1 = sum 1..arg, 2 = fib, 3 = count primes
Job.pump(0)
```

View file

@ -0,0 +1,7 @@
---
id: promise
title: Promise
order: 38
---
Combine several <code>Job</code> futures and resolve the group on the main thread. <code>Promise.all</code> succeeds once every member has, <code>Promise.race</code> once the first does; both return an ordinary job handle you poll like any other. For a loading screen, <code>Promise.count_done</code> over the same handles is the bar's numerator and <code>len</code> the denominator, and <code>Promise.all_done</code> is the ready check. Ludic has no closures, so progress is polled rather than chained through a <code>then</code> callback. Build the handle list with <code>new []int</code> + <code>push</code>. Spliced in only when a program mentions <code>Promise.*</code>.

View file

@ -0,0 +1,21 @@
---
id: promise-all
name: Promise.all
category: promise
kind: namespace-method
tokens: Promise.all
sig: Promise.all(handles) -> Job
tip: Succeeds when every member succeeds.
order: 0
ns: Promise
member: all
---
Succeeds when every member succeeds.
```ludic
let hs = new []int
push(hs, a)
push(hs, b)
let loaded = Promise.all(hs)
```

View file

@ -0,0 +1,21 @@
---
id: promise-all_done
name: Promise.all_done
category: promise
kind: namespace-method
tokens: Promise.all_done
sig: Promise.all_done(handles) -> bool
tip: Have all members resolved?
order: 3
ns: Promise
member: all_done
---
Have all members resolved?
```ludic
let hs = new []int
push(hs, a)
push(hs, b)
let ready = Promise.all_done(hs)
```

View file

@ -0,0 +1,21 @@
---
id: promise-count_done
name: Promise.count_done
category: promise
kind: namespace-method
tokens: Promise.count_done
sig: Promise.count_done(handles) -> int
tip: How many members have resolved.
order: 2
ns: Promise
member: count_done
---
How many members have resolved.
```ludic
let hs = new []int
push(hs, a)
push(hs, b)
let progress = Promise.count_done(hs)
```

View file

@ -0,0 +1,21 @@
---
id: promise-race
name: Promise.race
category: promise
kind: namespace-method
tokens: Promise.race
sig: Promise.race(handles) -> Job
tip: Succeeds when the first member does.
order: 1
ns: Promise
member: race
---
Succeeds when the first member does.
```ludic
let hs = new []int
push(hs, a)
push(hs, b)
let first = Promise.race(hs)
```

View file

@ -0,0 +1,7 @@
---
id: sync
title: Sync
order: 39
---
The advanced, opt-in tier — <strong>here be dragons</strong>. Raw building blocks for engine-level systems that pass data around: a <code>mutex</code> (cooperative lock), an <code>atomic</code> counter (<code>get</code> / <code>set</code> / <code>add</code> / <code>cas</code>) and a bounded <code>channel</code> (<code>send</code> / <code>recv</code> / <code>can_recv</code> / <code>len</code>). On today's single-threaded deterministic runtime these are cooperative — correct, ordered, replayable and impossible to deadlock — and exist so message-passing code reads the same now as it will when a preemptive OS-thread backend lands behind this same API. Beginners never need this; reach for <code>Job.*</code> / <code>Promise.*</code> instead. Spliced in only when a program mentions <code>Sync.*</code>.

View file

@ -0,0 +1,19 @@
---
id: sync-add
name: Sync.add
category: sync
kind: namespace-method
tokens: Sync.add
sig: Sync.add(atomic, delta) -> int
tip: Add to the counter; return the new value.
order: 7
ns: Sync
member: add
---
Add to the counter; return the new value.
```ludic
let a = Sync.atomic()
let total = Sync.add(a, 1)
```

View file

@ -0,0 +1,20 @@
---
id: sync-atomic
name: Sync.atomic
category: sync
kind: namespace-method
tokens: Sync.atomic
sig: Sync.atomic() -> int
tip: Create an atomic counter (starts at 0).
order: 4
ns: Sync
member: atomic
---
Create an atomic counter (starts at 0).
Returns an atomic-counter handle. Read it with <code>Sync.get</code>, write with <code>Sync.set</code>, accumulate with <code>Sync.add</code>, and swap conditionally with <code>Sync.cas</code>.
```ludic
let a = Sync.atomic()
```

View file

@ -0,0 +1,19 @@
---
id: sync-can_recv
name: Sync.can_recv
category: sync
kind: namespace-method
tokens: Sync.can_recv
sig: Sync.can_recv(channel) -> bool
tip: Is there a value waiting?
order: 12
ns: Sync
member: can_recv
---
Is there a value waiting?
```ludic
let ch = Sync.channel()
let has = Sync.can_recv(ch)
```

View file

@ -0,0 +1,19 @@
---
id: sync-cas
name: Sync.cas
category: sync
kind: namespace-method
tokens: Sync.cas
sig: Sync.cas(atomic, expect, next) -> bool
tip: Compare-and-set the counter.
order: 8
ns: Sync
member: cas
---
Compare-and-set the counter.
```ludic
let a = Sync.atomic()
let swapped = Sync.cas(a, 0, 1)
```

View file

@ -0,0 +1,20 @@
---
id: sync-channel
name: Sync.channel
category: sync
kind: namespace-method
tokens: Sync.channel
sig: Sync.channel() -> int
tip: Create a bounded int FIFO channel.
order: 9
ns: Sync
member: channel
---
Create a bounded int FIFO channel.
Returns a channel handle — a fixed-capacity queue of ints for handing values between a producer and a consumer. Push with <code>Sync.send</code>, pull the oldest with <code>Sync.recv</code>, and check with <code>Sync.can_recv</code> / <code>Sync.len</code>.
```ludic
let ch = Sync.channel()
```

View file

@ -0,0 +1,18 @@
---
id: sync-cpu_count
name: Sync.cpu_count
category: sync
kind: namespace-method
tokens: Sync.cpu_count
sig: Sync.cpu_count() -> int
tip: Worker lanes available to the scheduler.
order: 14
ns: Sync
member: cpu_count
---
Worker lanes available to the scheduler.
```ludic
let lanes = Sync.cpu_count()
```

View file

@ -0,0 +1,19 @@
---
id: sync-get
name: Sync.get
category: sync
kind: namespace-method
tokens: Sync.get
sig: Sync.get(atomic) -> int
tip: Read the counter.
order: 5
ns: Sync
member: get
---
Read the counter.
```ludic
let a = Sync.atomic()
let v = Sync.get(a)
```

View file

@ -0,0 +1,19 @@
---
id: sync-len
name: Sync.len
category: sync
kind: namespace-method
tokens: Sync.len
sig: Sync.len(channel) -> int
tip: How many values are queued.
order: 13
ns: Sync
member: len
---
How many values are queued.
```ludic
let ch = Sync.channel()
let n = Sync.len(ch)
```

View file

@ -0,0 +1,19 @@
---
id: sync-lock
name: Sync.lock
category: sync
kind: namespace-method
tokens: Sync.lock
sig: Sync.lock(mutex) -> void
tip: Take the lock.
order: 1
ns: Sync
member: lock
---
Take the lock.
```ludic
let m = Sync.mutex()
Sync.lock(m)
```

View file

@ -0,0 +1,20 @@
---
id: sync-mutex
name: Sync.mutex
category: sync
kind: namespace-method
tokens: Sync.mutex
sig: Sync.mutex() -> int
tip: Create a cooperative lock.
order: 0
ns: Sync
member: mutex
---
Create a cooperative lock.
Creates a mutex handle for guarding a critical section. On the deterministic single-threaded runtime it never blocks — pair <code>Sync.lock</code> / <code>Sync.unlock</code> around the section, or probe with <code>Sync.try_lock</code>.
```ludic
let m = Sync.mutex()
```

View file

@ -0,0 +1,20 @@
---
id: sync-recv
name: Sync.recv
category: sync
kind: namespace-method
tokens: Sync.recv
sig: Sync.recv(channel) -> int
tip: Dequeue the oldest value.
order: 11
ns: Sync
member: recv
---
Dequeue the oldest value.
```ludic
let ch = Sync.channel()
Sync.send(ch, 42)
let v = Sync.recv(ch)
```

View file

@ -0,0 +1,19 @@
---
id: sync-send
name: Sync.send
category: sync
kind: namespace-method
tokens: Sync.send
sig: Sync.send(channel, value) -> bool
tip: Enqueue a value (false if full).
order: 10
ns: Sync
member: send
---
Enqueue a value (false if full).
```ludic
let ch = Sync.channel()
let sent = Sync.send(ch, 42)
```

View file

@ -0,0 +1,19 @@
---
id: sync-set
name: Sync.set
category: sync
kind: namespace-method
tokens: Sync.set
sig: Sync.set(atomic, value) -> void
tip: Store a value in the counter.
order: 6
ns: Sync
member: set
---
Store a value in the counter.
```ludic
let a = Sync.atomic()
Sync.set(a, 10)
```

View file

@ -0,0 +1,19 @@
---
id: sync-try_lock
name: Sync.try_lock
category: sync
kind: namespace-method
tokens: Sync.try_lock
sig: Sync.try_lock(mutex) -> bool
tip: Take the lock only if it is free.
order: 3
ns: Sync
member: try_lock
---
Take the lock only if it is free.
```ludic
let m = Sync.mutex()
let got = Sync.try_lock(m)
```

View file

@ -0,0 +1,20 @@
---
id: sync-unlock
name: Sync.unlock
category: sync
kind: namespace-method
tokens: Sync.unlock
sig: Sync.unlock(mutex) -> void
tip: Release the lock.
order: 2
ns: Sync
member: unlock
---
Release the lock.
```ludic
let m = Sync.mutex()
Sync.lock(m)
Sync.unlock(m)
```