Qu

Concurrency

The tables below are generated by reading the interpreter's own builtin dispatch table (fn call_builtin, engine/crates/qu-interp/src/lib.rs) directly — every row reflects a match arm that actually runs today, not aspirational API surface. See Book 3 §4 and §5 for a narrative walkthrough of this subsystem.

Platform boundary: everything in this chapter runs on real OS threads (spawn, pmap, parallel for) or real OS synchronization primitives (mutex, semaphore, channel). Qu's wasm32-unknown-unknown browser target has no native threads, so this subsystem is native-CLI-first; a browser Studio target would need its own backend, not something that falls out of the shared Rust core for free.

Pool, not per-call threads: spawn, pmap, and parallel for all dispatch onto rayon's global work-stealing thread pool rather than spawning a fresh OS thread per call — the same pool qu-core's matmul/elementwise kernels already share, so a script mixing worker concurrency with heavy matrix math doesn't oversubscribe the machine with two independent pools competing for cores. Work-stealing means each pool thread pulls from its own queue and steals from others' when idle, rather than tasks being handed out in a fixed rotation — an uneven pmap workload (elements that take wildly different time) still keeps every thread busy, instead of idling one behind a slow neighbor the way strict round-robin assignment would.

Parallel Execution

FunctionSignatureDescription
run_forrun_for(duration)Drives every registered every/after/at timer across a simulated virtual timeline, not a real wall-clock sleep. duration (number — seconds, same convention as every/after/at's own interval arguments) is how far along that virtual timeline to advance. Timers interleave by scheduled fire time, the way a real event loop would. Requires at least one timer to already be registered, or it errors. Returns an integer: the total number of timer firings that occurred while advancing.
pump_watchespump_watches()Takes no arguments. Checks every registered watch ... end (§ Watches, below) exactly ONCE, right now, against its real current external state, firing the body of any whose state has genuinely changed since the last check. Unlike run_for, this is real-time, not simulated — a file's mtime, a variable's value, and an HTTP response body have no "virtual" equivalent to fast-forward through. Requires at least one watch to already be registered, or it errors. Returns an integer: the number of watches that fired on this call.
spawnspawn("fnName", arg1, arg2, ...)Runs an already-defined user function on rayon's global thread pool (the Worker abstraction). "fnName" (string) names a function already defined in this script; arg1, arg2, ... (any number of values of any type, zero or more) are forwarded to it positionally, exactly as if calling it directly. The worker gets an ISOLATED snapshot of the current globals and function table plus a freshly-drawn RNG seed — never the live shared environment — so nothing a worker does can race with the caller or another worker. Only a user-defined function can be spawned, never a builtin (builtins like plot/savefig mutate state with no meaning off the main thread). Returns a Worker handle (a lightweight opaque token, type(w) == "worker") immediately, before the function has necessarily finished; join/worker_done observe completion via a Mutex+Condvar under the hood, since the pool's own dispatch is fire-and-forget.
joinjoin(w) or join(list[, delimiter])Two unrelated forms selected by the argument's type. On a Worker handle w (from spawn): blocks the calling thread until that worker finishes, then returns its function's return value (whatever type that function produces), or re-raises whatever error the worker's body raised as an ordinary runtime error here on the caller. Consumes the handle — joining the same worker twice is an error. On a List of strings list (with an optional string delimiter, default ""): the unrelated collection form, concatenating every element into one string with delimiter inserted between each pair — the inverse of split. Returns a string in this form.
worker_doneworker_done(w)Non-blocking check of whether worker w (a Worker handle from spawn) has finished. Returns a boolean (true/false). The handle stays valid either way — still needs a later join(w) to actually retrieve the result or error.
pmappmap(xs, f)Applies a named user function to every element of xs (a List/vector of any length, elements of any type) across a work-stealing thread pool (rayon), same share-nothing isolation model as spawn/parallel for (each element gets its own env/funcs snapshot and an RNG stream drawn up front from the parent's stream, for reproducibility). f is a one-argument function applied to each element — the function itself (including a lambda, which is carried to the workers), or a Str naming one you defined; a builtin is accepted as a value but refused as a quoted name. Returns a new List/vector of the same length, holding each call's return value in the original order. xs comes first so > composes naturally: signals> pmap("extract_features"). A panic in one element surfaces as an ordinary error rather than poisoning the whole pipeline.

Overloads: join

Case: worker

w a Worker handle (from spawn). Blocks until that worker finishes, then returns its function's return value, or re-raises whatever error the worker's body raised.

heavy(n) := n * n
w = spawn("heavy", 6)
print(join(w))   # 36

output
36

Case: list

list a List of strings, with an optional delimiter (default ""). The unrelated collection form — concatenates every element into one string, the inverse of split — selected purely because the first argument isn't a Worker handle.

print(join(["a", "b", "c"]))        # "abc"

print(join(["a", "b", "c"], "-"))   # "a-b-c"

output
abc
a-b-c

Mutexes

Genuinely shared mutable state — the deliberate opt-in exception to spawn/parallel for's default share-nothing design. Cloning a Mutex value (e.g. capturing it into a worker's environment snapshot) clones the handle, not the data — every clone still points at the same cell.

FunctionSignatureDescription
mutexmutex(initial)Creates a shared mutable cell holding initial (any value, of any type — the mutex has no fixed element type). Returns a Mutex handle (type(m) == "mutex"); cloning that handle (assigning it, capturing it into a worker's snapshot) shares the same underlying cell rather than copying the value inside it.
mutex_getmutex_get(m)Reads the current value out of mutex handle m. Locked only for the instant it takes to clone the value out. Returns whatever value is currently stored (same type as the last mutex_set/mutex/mutex_add/mutex_update put there).
mutex_setmutex_set(m, v)Overwrites the value stored in mutex handle m with v (any value, any type), discarding the old one unconditionally — no read of the previous value is involved. Returns none.
mutex_addmutex_add(m, delta)Atomic read-modify-write in a single lock acquisition (unlike mutex_set(m, mutex_get(m) + delta), which would race). m is a Mutex handle whose current stored value must be numeric; delta (number, may be negative) is added to it in place. The one operation you want for a shared counter/accumulator across parallel for iterations. Returns the new value (number) after the addition.
mutex_updatemutex_update(m, "fnName")Atomic read-modify-write via an arbitrary user function called with the current value as its one argument — for accumulations mutex_add can't express (a running max, an append-only log). m is a Mutex handle; "fnName" (string) names a one-argument user-defined function whose return value becomes the mutex's new stored value. The lock is held for the whole call, so fnName's body must not touch this same mutex itself, directly or indirectly — that deadlocks. Returns the new value (whatever fnName returned).

mutex is the escape hatch from spawn/parallel for's normal share-nothing isolation: every other worker-facing primitive in this chapter gives each worker its own private copy of the world, but a Mutex handle stays a single shared cell no matter how many workers hold a clone of it. The example below creates one, overwrites it directly with mutex_set (no read involved), then performs an atomic read-modify-write through a user function with mutex_update — the pattern you reach for when the update is more than a simple numeric increment (mutex_add covers that simpler case). The final print shows the value mutex_update left behind:

running_max(current) := max(current, 7)

m = mutex(0)
mutex_set(m, 3)                        # overwrite -- no read involved

mutex_update(m, "running_max")         # atomic read-modify-write via a user fn

print(mutex_get(m))                    # 7

output
7

Examples

This section walks through four of the chapter's core primitives together, each in the shape you'd actually reach for it in a real script rather than the minimal one-liners in the tables above.

spawn is for offloading one genuinely expensive call onto its own OS thread so the calling script can keep doing other work (here, polling) while it runs, then collect the result with join once it's ready. The example spawns a function, polls worker_done in a tight loop until it reports completion (a real script would normally do useful work in that loop instead of spinning), then blocks on join to retrieve the return value:

heavy(n) := n * n
w = spawn("heavy", 7)                  # runs on its own OS thread

while not worker_done(w)               # non-blocking poll; handle stays valid

end while
result = join(w)                       # blocks for the result (49)

print(result)                          # 49

output
49

pmap is the tool for the common case of applying the same pure function to every element of a collection and wanting the results back in a vector — feature extraction over a batch of signals, a per-row transform over a dataset — without hand-writing a spawn/join loop yourself. It applies a pure function across a vector using the same share-nothing isolation spawn uses, composing naturally with |>:

extract(x) := x * x
xs = 0 to 9
out = xs |> pmap("extract")            # same as pmap(xs, "extract")

print(out)                             # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

output
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

mutex is for the rarer case where parallel workers genuinely need to share one piece of mutable state rather than working on independent data — a running total, a shared cache, an accumulated log — safely, without the lost-update races that reading-then-writing an ordinary shared value from multiple threads would cause. The example runs 100 loop iterations in parallel, each incrementing a shared counter through the atomic mutex_add, and confirms afterward that all 100 increments were actually counted with none lost to a race:

counter = mutex(0)
parallel for i in 1 to 100
    mutex_add(counter, 1)              # atomic; 100 in, 100 out, no lost updates

end parallel
total = mutex_get(counter)
print(total)                           # 100

output
100

channel is for handing whole values from one worker to another as they become available, rather than sharing a mutable cell — the natural fit for a producer/consumer pipeline where one side generates results and another consumes them as they arrive. The example spawns a producer that sends a single value, and the main thread's channel_recv blocks until that value shows up:

ch = channel()
producer() := channel_send(ch, 42)
w = spawn("producer")
value = channel_recv(ch)               # blocks until producer sends

print(value)                           # 42

join(w)
output
42

Semaphores

A counting semaphore, for bounding concurrent access to a limited shared resource (a hardware port, a connection pool, or just an explicit concurrency cap) across spawn/parallel for workers.

FunctionSignatureDescription
semaphoresemaphore(n)Creates a counting semaphore with n (integer, must be non-negative) initial permits. Returns a Semaphore handle (type(s) == "semaphore"); cloning it shares the same counter, the same convention Mutex/Channel use.
semaphore_acquiresemaphore_acquire(s)Blocks the calling thread until a permit is free on semaphore handle s, then takes it (decrementing the available count by one). Returns none. There is no RAII/finally in Qu, so releasing on an error path (e.g. inside a try/catch) is the caller's own responsibility.
semaphore_releasesemaphore_release(s)Returns one permit to semaphore handle s (incrementing the available count by one). Returns none. Releasing more times than acquired just grows the available count past its starting size — not checked, since a semaphore used purely as a signal (more releases than acquires) is a legitimate pattern.
semaphore_availablesemaphore_available(s)Non-blocking snapshot of how many permits are free right now on semaphore handle s. Returns an integer — a diagnostic, not a guarantee, since it can change the instant after it's read.

A semaphore bounds how many workers may hold a limited resource concurrently — a hardware port, a connection pool, or just an explicit concurrency cap — by handing out a fixed number of permits that semaphore_acquire blocks for and semaphore_release gives back. The example below creates a semaphore with 2 permits, takes one, and returns it, printing semaphore_available's live count at each step so the acquire/release bookkeeping is visible:

s = semaphore(2)                       # 2 permits available

print(semaphore_available(s))          # 2

semaphore_acquire(s)                   # take one

print(semaphore_available(s))          # 1

semaphore_release(s)                   # give it back

print(semaphore_available(s))          # 2

output
2
1
2

Channels

Worker-to-worker message passing: a blocking MPMC queue, the counterpart to mutex's shared-state model. Cloning a Channel value shares the same underlying queue, the same Arc-sharing convention Mutex/Semaphore already use.

FunctionSignatureDescription
channelchannel()Takes no arguments. Creates a new, empty channel. Returns a Channel handle (type(ch) == "channel"); cloning it shares the same underlying queue, not a separate copy.
channel_sendchannel_send(ch, value)Pushes value (any value, any type) onto channel ch's queue and wakes one waiting channel_recv. Returns none. Never blocks — the queue is unbounded, so a fast producer can run arbitrarily far ahead of a slow consumer.
channel_recvchannel_recv(ch)Blocks the calling thread until a value is available on channel ch, then pops and returns it (FIFO — oldest channel_sendd value first). Returns the sent value itself — whatever type the matching channel_send put in.
channel_try_recvchannel_try_recv(ch)Non-blocking counterpart to channel_recv: returns none (Nothing) immediately if nothing is queued on channel ch right now, instead of waiting; otherwise pops and returns the next value the same way channel_recv would. Returns the sent value itself, whatever type it is, or none when the channel is empty.
channel_lenchannel_len(ch)How many messages are queued right now on channel ch, without consuming any of them. Returns an integer — a diagnostic/backpressure-aware-producer helper, not needed for ordinary send/recv.

A channel is the message-passing counterpart to mutex's shared-state model: instead of workers reading and writing one shared cell, they hand whole values to each other through a blocking FIFO queue. The example below checks an empty channel with the non-blocking channel_try_recv (getting none back), sends two values, reads the queue depth with channel_len, then drains one value with the blocking channel_recv — printing the result of each step so the FIFO ordering and the empty-queue behavior are both visible:

ch = channel()
print(channel_try_recv(ch))            # none -- nothing queued yet

channel_send(ch, 1)
channel_send(ch, 2)
print(channel_len(ch))                 # 2

print(channel_recv(ch))                # 1 (FIFO)

output
none
2
1

Job Queue & Worker Pools

A resource-limited worker pool (pool ... with cpu=N ... end pool, or the anonymous pool(n) shorthand), a deferred-job list (queue()), and run <queue> on <pool> to dispatch every queued job across it, collecting an ordered List of results. Distinct from spawn/pmap above in one way: jobs are referenced by function NAME (a string) plus already-evaluated arguments, submitted ahead of time, then all drained together by run — not fired off individually.

Function / formSignatureDescription
queuequeue()Takes no arguments. Creates an empty deferred-job list. Returns a Queue handle (type(q) == "queue"); cloning it shares the same underlying list of pending jobs, not a separate copy.
.pushq.push("fnName", arg1, arg2, ..., on="any")Enqueues a call onto queue q, to run later when run drains it. "fnName" (string) names an already-defined user function — never a builtin; arg1, arg2, ... (zero or more values of any type) are the arguments to call it with, evaluated and captured right now, not when the job later runs. on= is an optional string kwarg, one of "cpu", "gpu", or "any" (default "any") — a resource tag consulted only by run q on any's auto-routing across every registered pool; a run q on <namedPool> ignores it, since the caller already said exactly where the job runs. Returns none; mutates q in place.
pool (named)pool <name> with cpu=N[, gpu=N][, remote=(...)] [policy=round_robin] [on full=queue] end poolA statement, not an expression — builds a persistent, resource-limited worker pool and registers it under the bare identifier name (so run ... on any can find it too, and any later run ... on name can reference it by that same identifier). cpu=N (integer) is the number of local worker threads. gpu=N (integer) parses but is not implemented — a nonzero value errors clearly. policy= only implements round_robin. on full= only implements queue. remote=(...) (a list of "host:port" strings) is covered under Distributed pools below. Produces no value of its own.
pool (anonymous)pool(n)n (integer) local cpu workers, fixed round_robin policy and on full=queue behavior, never registered under any name — scoped to whichever single run ... on pool(n) call uses it. Returns a Pool handle (type(p) == "pool").
run ... onrun <queue> on <pool> / run <queue> on anyDrains queue <queue> (a Queue handle) and runs every job on <pool> (a Pool handle, a registered pool name, or the bare keyword any), blocking the caller until all finish. Returns a List of each job's return value in submission order (never completion order) — element i is the result of the i-th .push call. on any auto-routes each job to whichever registered pool matching its on= tag currently has the most free capacity. A job's error is never swallowed — the first one, in submission order, propagates out of run as an ordinary runtime error.

Overloads: pool

Case: named

pool <name> with cpu=N ... end pool is a statement, not an expression — it registers a persistent pool under the bare identifier name, so more than one later run ... on name call (or run ... on any) can share the same fixed set of worker threads instead of each spinning up its own.

square(x) := x * x
pool workers with cpu=2
end pool
q = queue()
q.push("square", 3)
print(run q on workers)   # (9)

output
(9)

Case: anonymous

pool(n) is an expression — it returns an unregistered Pool handle with n local cpu workers, scoped to whichever single run ... on pool(n) call uses it, with no pool ... end pool registration step at all.

square(x) := x * x
q = queue()
q.push("square", 3)
print(run q on pool(2))   # (9)

output
(9)

A named pool is worth registering when more than one run call — maybe across different stages of a script — should share the same fixed set of worker threads rather than each spinning up its own. The example below defines a pure function, builds a 4-worker pool named workers, queues five calls to it with .push, then drains the whole queue at once with run ... on, printing the resulting list of squares in submission order:

square(x) := x * x

pool workers with cpu=4
end pool

q = queue()
for i in 0 to 4
    q.push("square", i)
end for

results = run q on workers
print(results)              # (0, 1, 4, 9, 16)

output
(0, 1, 4, 9, 16)

The anonymous form skips the pool ... end pool registration entirely -- handy when nothing else needs to share the same workers, since pool(n) can be written directly inline as the target of run ... on:

square(x) := x * x

q = queue()
for i in 0 to 4
    q.push("square", i)
end for

results = run q on pool(2)  # 2 local cpu workers, scoped to this one call

print(results)              # (0, 1, 4, 9, 16)

output
(0, 1, 4, 9, 16)

Distributed pools: listen_pool and remote=(...)

A pool's workers don't have to be local threads — remote = (...) names "host:port" addresses of other Qu processes running listen_pool, and run ... on <pool> transparently splits jobs across local cpu workers and those remote listeners, merging every result into the same ordered List either way. This is a real, minimal, working feature — not the larger worker()/cluster()/auto-discovery cluster vision sketched elsewhere in the language reference's distributed-execution chapter; there is no node discovery, no least_loaded/affinity placement policy, and no fault-tolerant rescheduling here, only "name some addresses, dispatch jobs to them by function name, get results back."

Function / formSignatureDescription
listen_poollisten_pool(port, allow=(...))port (integer, 0-65535) is the TCP port to bind. allow=(...) (a list of strings, required and non-empty) names exactly which already-defined functions this listener may run for a remote caller — a hard security requirement, not an option. Binds port and blocks forever (this call never returns under normal operation), accepting job requests over TCP and running only functions named in allow. A request for anything else is rejected cleanly (never a crash, never executed anyway). Each accepted job runs on a fresh, isolated interpreter seeded from a snapshot of the listening script's own globals/functions, taken once when listen_pool is called. Returns nothing, because it never returns: the call blocks for the life of the listener.
pool ... remote=pool <name> with cpu=N, remote=("host:port", ...) ... end poolAdds one or more remote listeners as additional workers alongside cpu. remote= is a list of strings, each a "host:port" address of another process running listen_pool. cpu (integer) is optional when remote names at least one address (a remote-only pool, pool p with remote=(...) end pool, is valid). A single address may be written bare (remote = "host:port") — Qu's list literal is a parenthesized tuple, and a single-element (x) without a trailing comma is just grouping, not a one-element list, so the bare-string form sidesteps that trap. Produces no value of its own (a statement, like the plain named pool form).

Normally the two sides of this run as separate processes on separate machines, but the demo below folds them into one self-contained script by running the listener in a background worker on the SAME machine, talking to itself over the loopback address — the mechanics (bind, connect, dispatch, merge results) are identical to the real cross-machine case. run_listener wraps listen_pool so it can be spawn'd onto its own thread (since listen_pool blocks forever, it can never run on the main thread if that thread still has to submit jobs); a short sleep gives the listener time to finish binding before the pool tries to connect. workers mixes one local cpu worker with one remote listener on 127.0.0.1, and run q on workers splits the five queued jobs across both, merging every result back into a single ordered List:

square(x) := x * x

function run_listener()
    listen_pool(9321, allow=("square"))
end function

w = spawn("run_listener")     # the "remote" side, on its own thread here

sleep(0.3)                    # give it time to bind before connecting


pool workers with cpu=1, remote=("127.0.0.1:9321")
end pool

q = queue()
for i in 0 to 4
    q.push("square", i)
end for

results = run q on workers    # some local, some "remote" — one ordered List

print(results)                # (0, 1, 4, 9, 16)

Jobs round-robin by submission order across "lanes" (one lane for the whole local cpu pool, one lane per remote address) — the pool's own round_robin policy, now spanning local and remote workers together. Wire format is newline-delimited JSON reusing Value's existing JSON encoding (the same one save/load use). An unreachable, timed-out (bounded connect/IO timeouts, never an indefinite hang), or allowlist-rejecting remote worker fails that one job exactly like a local job's own runtime error — the first failure in submission order propagates out of run, with no silent fallback to running it locally instead.

Networking (HTTP)

Qu's TCP socket library (tcp_listen/tcp_connect/tcp_send/tcp_recv, raw sockets — see the Language Reference's §50.4 for the full design) has no dedicated stdlib chapter of its own yet; http_get is placed here as the nearest existing home for a networking-flavored builtin rather than inventing a new one-function chapter.

FunctionSignatureDescription
http_gethttp_get(url)url (string, must include the scheme, e.g. "https://example.com/") is the address to fetch. Issues an HTTP(S) GET request and returns the response body as a string (UTF-8, decoded lossily — the same convention read_all_text/text-mode read_all already use). GET-only in this v1: no custom headers, auth, or POST. A 30-second timeout applies to the whole request (connect + read) — an unreachable host errors clearly rather than hanging forever. A non-2xx response is a clear error naming the actual status code. Returns a Str: the response body. No status code and no headers come back with it — a call that returns at all succeeded, and everything else raised.

http_get is the quickest way to pull a resource off the web into a string for further processing — parsing JSON out of an API response, scraping a page's text, or just checking that a URL is reachable — without setting up a full streaming connection. The example below fetches a page and prints the length of the body it got back, so the request's success and the size of what came down the wire are both visible:

body = http_get("https://example.com/")
print(length(body))            # e.g. 559 -- byte length of the fetched page

output
559

StreamURL(url) wraps this same request as a read-only stream object (read_line/read_all/eof/close), polymorphic with StreamFile — see Polymorphic Streams: StreamFile / StreamURL.

TCP sockets

Reach for raw TCP sockets when http_get's one-shot GET isn't enough — talking to a non-HTTP service, holding a connection open for more than one exchange, or writing both the client and the server yourself (as the distributed-pool machinery above does internally). The demo below is a self-contained loopback echo server: tcp_listen/tcp_accept on the server side (run on its own worker via spawn, since tcp_accept blocks the calling thread until a client connects), tcp_connect on the client side, tcp_send/tcp_recv to exchange one message, tcp_close to release each connection once done. tcp_listen(0) asks the OS for a free ephemeral port instead of a fixed one, and tcp_port reports which one it actually bound so the client knows where to connect. The final print shows the message coming back exactly as sent, proving the round trip worked:

function echo_once(listener)
    conn = tcp_accept(listener)        # blocks for the one client

    msg = tcp_recv(conn)
    tcp_send(conn, msg)
    tcp_close(conn)
end function

listener = tcp_listen(0)               # 0 -- let the OS pick a free port

port = tcp_port(listener)
w = spawn("echo_once", listener)

client = tcp_connect("127.0.0.1", port)
tcp_send(client, "hello")
print(tcp_recv(client))                # "hello" -- echoed back

tcp_close(client)
join(w)
output
hello

Watches (watch)

Three change-detection trigger conditions, registered as a statement (watch ... [do] ... end) and checked only by the pump_watches builtin above — see the Language Tour's own "watch — file/variable/URL change detection" section for the full execution-model discussion (why this is NOT a background process: Qu's interpreter is single-threaded with no event loop, so a watch callback only ever runs during an explicit pump_watches() call, the same cooperative model every/after/at already have via run_for).

FormSignatureDescription
watch filewatch file(path) [do] ... endpath (string, a filesystem path). Fires when path's modification time differs from the last check (polled via the same filetime crate touch uses — not the notify crate's OS-level filesystem events, a deliberate v1 scope cut: wiring an async filesystem watcher into this single-threaded, cooperatively-pumped interpreter is a separate concurrency design question). A path that doesn't exist yet at registration also fires once it's later created. A statement — registers the watch and its body, produces no value.
watch <name>watch <name> [do] ... end<name> (a bare variable identifier, not a string). Fires when that variable's current value differs from its value as of the last check (same equality == uses) — reassigning the identical value never fires. Checked by reading the named variable fresh at each pump_watches() call, not by hooking every assignment in the interpreter, so scripts that never use watch pay zero extra cost on ordinary variable writes. A statement, no value.
watch urlwatch url(url, [interval_seconds=]) [do] ... endurl (string, must include the scheme). interval_seconds (optional number, default 5, real wall-clock seconds) bounds how often this actually fetches over the network. Fires when a fresh http_get-equivalent fetch of url's response body differs from the last fetch; a pump_watches() call landing before the interval has elapsed is a cheap no-op that doesn't touch the network. The baseline is one real fetch performed at registration time. A statement, no value.

watch is for noticing when something outside your script's own control flow changes — a config file another process might edit, a variable some other part of a larger script mutates, or a URL whose content might update — without polling it by hand on every loop iteration. Registering a watch costs nothing until you actually call pump_watches(), which checks every registered watch once against its current state. The example below watches a file for a modification-time change: the first pump_watches() call sees no change (the file hasn't been touched since registration) and fires nothing, then touch bumps the file's mtime and the second call detects that and runs the watch's body, incrementing fires — both counts are printed so the before/after difference is visible:

fires = 0
watch file("config.txt") do
    fires = fires + 1
end
pump_watches()          # nothing changed yet -> 0 fired

print(fires)             # 0

touch("config.txt")
pump_watches()          # real mtime change -> 1 fired

print(fires)             # 1

output
· pump_watches(): 0 watch firing(s) across 1 registered watch(es)
0
· pump_watches(): 1 watch firing(s) across 1 registered watch(es)
1

Serial Port I/O

Real UART/RS-232/USB-serial device I/O (§ real serial port I/O, 2026-09-01), via the serialport crate — placed in this same chapter as http_get/the TCP socket library above rather than a disconnected new chapter, for the same "nearest existing I/O-flavored home" reasoning. It follows Value::File/Value::TcpConn's own opaque-handle convention: a constructor returns a live handle, and handle.method(...) sugar makes .read_line()-style calls work with no special-casing.

FunctionSignatureDescription
serial_openserial_open(port, baud_rate, [data_bits=8, stop_bits=1, parity="none", timeout_ms=1000])Opens a real OS serial port. port (string) is the OS device name, e.g. "COM3" on Windows or "/dev/ttyUSB0" on Linux/macOS. baud_rate (integer, must be positive) is the line speed. data_bits (optional integer, default 8) accepts 5/6/7/8. stop_bits (optional integer, default 1) accepts 1 or 2 (the underlying crate has no 1.5-stop-bit option). parity (optional string, default "none") accepts "none"/"even"/"odd". timeout_ms (optional number, default 1000, milliseconds) bounds every blocking read below — see the note on timeouts vs. EOF further down. Returns a Serial handle. Errors clearly (naming the port) if the port doesn't exist, is already open elsewhere, or the OS refuses it.
serial_portsserial_ports()Takes no arguments. Lists the short names of every serial device the OS currently reports (e.g. "COM3" on Windows, "/dev/ttyUSB0" on Linux/macOS). Returns a List of strings. An empty list on a machine with no serial hardware attached is a correct, honest result, not an error.
.read_line()read_line(conn)conn (a Serial handle from serial_open). Reads one line without its trailing newline, same contract as Value::File's own read_line — except at end of data: a serial device has no natural EOF, so a read that times out with nothing at all collected yet returns Nothing (a string otherwise; composes with ??, same as EOF elsewhere), while a timeout after some bytes already arrived returns that partial line as-is (a string).
.read_bytes(n)read_bytes(conn, n)conn (a Serial handle); n (integer, the maximum number of bytes to collect). Blocks (bounded by the handle's own timeout_ms) collecting up to n bytes; may return FEWER than n if the timeout elapses first — an honest short read, not an error or a hang. Returns a Vec of 0-255 integer values (length between 0 and n), the same representation read_bin uses.
.available()available(conn)conn (a Serial handle). Returns an integer: how many bytes are currently waiting in the OS's input buffer, without blocking.
.write()write(conn, data)conn (a Serial handle); data may be a string (written as UTF-8 bytes) or a Vec of byte values 0-255. Writes straight through to the wire, no added newline. Returns none.
.close()close(conn)conn (a Serial handle). Releases the OS handle. Returns none. Idempotent, like Value::File's own close — calling it again on an already-closed handle is a harmless no-op.

serial_open plus .read_line() is the everyday pattern for talking to a line-oriented device — a sensor board that streams one text reading per line, a modem, a microcontroller printing debug output. The example below lists whatever serial devices this machine's OS currently reports, opens the first one with a half-second read timeout, and prints whatever line (if any) arrived within that window — line comes back as none, not an error, if nothing showed up in time:

ports = serial_ports()                              # e.g. ["COM3"], or [] with no hardware attached

conn = serial_open(ports[0], 9600, timeout_ms=500)
line = read_line(conn)                              # blocks up to 500ms; `none` if nothing arrived

if line != none
    print("got: {line}")
else
    print("no line arrived within the timeout")
end if
close(conn)
output
no line arrived within the timeout

For a binary protocol instead of line-oriented text, .read_bytes(n) is the one to reach for: it collects raw bytes rather than splitting on newlines, which matters when a device sends fixed-size frames or non-text payloads. .available() is the non-blocking companion check — how much has already arrived in the OS's input buffer right now, useful for deciding whether a read is even worth attempting before committing to timeout_ms's wait. The example below checks availability, then attempts to read up to 4 bytes (an honest short read if timeout_ms elapses first), and prints both numbers so the non-blocking check and the actual read result are both visible — this one already guards the no-hardware case explicitly:

ports = serial_ports()
if length(ports) > 0
    conn = serial_open(ports[0], 9600, timeout_ms=200)
    n = available(conn)                        # bytes waiting, non-blocking

    data = read_bytes(conn, 4)                  # up to 4 bytes, short read on timeout

    print("available={n}, got {length(data)} byte(s)")
    close(conn)
else
    print("no serial hardware attached")
end if
output
available=0, got 0 byte(s)

What does NOT apply to a serial handle, and why. read_all/eof are deliberately not implemented: both assume a fixed, knowable total length (a file's byte count, or an HTTP response's fully-fetched body) so that "everything remaining" and "have I reached the end" are honest questions — a live serial device has neither, since data arrives whenever the far end sends it, or never. Calling either on a serial handle falls through to the existing Value::File error path, which already reports a clear "expected a file handle ... found serial" rather than a fabricated answer.

A genuine real-time capture-and-process demo — catalog/qu_serial_realtime.qu — opens a serial port, reads incoming lines in a bounded loop as they arrive, converts each to a number, and prints a running moving average/min/max after every reading, catching an open failure (no matching device attached) as a clean, expected error rather than a crash. See that script's own header comment for why a live-updating plot specifically is out of scope for a qu run script today (Qu's plotting model builds one static figure exported at the end of a run, with no live GUI window to push points into mid-script) and a real-time console loop is the honest, actually-achievable version of "process data as it arrives."

More functions

FunctionSignatureDescription
nownow()Takes no arguments. Returns the current time (UTC) as a Record with integer fields .year, .month, .day, .hour, .minute, .second, plus .unix (number, Unix epoch seconds, for arithmetic) and .iso (string, an ISO-8601 timestamp you can print or store).
sleepsleep(seconds)seconds (number, fractional values honoured, e.g. 0.5 for half a second). Pauses the calling thread for that long. Returns none.
sysinfosysinfo()Takes no arguments. Returns a Record describing this machine: .os/.arch/.hostname/.cpu/.qu_version/.build (strings), .logical_cores (integer), .physical_cores and .total_memory (number, or none when the platform doesn't report them), and .gpu/.gpu_reason (string, or none/an explanatory string when this build has no GPU support). What to branch on before sizing a pool.
mem_usagemem_usage()Takes no arguments. Returns this process's resident-set size right now, in bytes (a Num) — what THIS run has actually mapped in, not the machine's total memory (sysinfo()'s job). none on a platform this isn't implemented for (anything but Windows and Linux today). Call it before and after the code being compared and take the difference: the question "which of these two ways of computing the same result used more memory" that timing alone can't answer. See also profile_start/profile_end (repl-diagnostics chapter) for the same reading bundled with elapsed time into one clean before/after window, instead of two bare mem_usage() calls.
poolpool(n)n (integer, number of local cpu workers). Returns a Pool handle, unregistered and scoped to wherever it's used. pmap makes one of these implicitly under the hood; make it explicit (or use the named pool ... end pool form above) when several stages should share workers rather than each starting their own.
tcp_listen, tcp_accepttcp_listen(port), tcp_accept(listener)tcp_listen(port): port (integer 0-65535, 0 asks the OS for a free ephemeral port) binds and returns a TcpListener handle immediately (does not block). tcp_accept(listener): takes that handle and blocks the calling thread until a client connects, then returns a TcpConn handle for the new connection — call it in a loop to accept multiple clients, one call per connection. Returns a TcpListener handle from tcp_listen, and a TcpConn handle from tcp_accept.
tcp_connect, tcp_porttcp_connect(host, port), tcp_port(listener)tcp_connect(host, port): host (string, hostname or IP) and port (integer 0-65535) — blocks until connected (or the OS gives up and errors), returning a TcpConn handle, the client-side mirror of tcp_accept. tcp_port(listener): takes a TcpListener handle and returns an integer, the port it actually bound — which matters when you asked for 0 and let the system choose. Returns a TcpConn handle from tcp_connect, and the bound port as a scalar Num from tcp_port.
tcp_send, tcp_recvtcp_send(sock, data), tcp_recv(sock, [max_bytes=4096])tcp_send: sock (a TcpConn handle) and data (string, written as UTF-8 bytes) — returns none. tcp_recv: sock (a TcpConn handle) and an optional integer kwarg max_bytes (default 4096) — blocks until at least one byte arrives, reads up to max_bytes, and returns it as a string (UTF-8, decoded lossily), or none if the peer closed the connection (a zero-byte read). Neither call frames the message for you — TCP is a stream, so a length prefix or delimiter is yours to add if you need distinct messages. Returns a Str of the bytes read from tcp_recv, or none once the peer has closed; tcp_send hands back no value.
tcp_closetcp_close(sock)sock must be a TcpConn handle (from tcp_accept/tcp_connect) — not a TcpListener; calling it on a listener errors, since a listener has no explicit close and simply releases its OS socket when the handle is dropped. Shuts the connection down and marks it closed; returns none. Idempotent — closing an already-closed connection is a harmless no-op, not an error. Returns Nothing.
availableavailable(conn)conn (a Serial handle from serial_open). Returns an integer: bytes waiting on the OS's serial input buffer right now, without blocking. Check it before a read you do not want to block on.
read_bytesread_bytes(conn, n)conn (a Serial handle); n (integer, maximum bytes to collect). Blocks up to the handle's timeout_ms, returning a Vec of 0-255 integer values — possibly fewer than n if the timeout elapses first. Use read_line instead when the data is text with a line terminator. Returns a Vec of 0-255 integer values, at most n long and possibly shorter.

now/sysinfo are the two functions to reach for when a script needs to know about its own environment rather than about the concurrency machinery itself — timestamping output, or deciding how many workers a pool should use based on the machine's actual core count. The example below reads the current UTC year and prints it, then reads how many logical cores this machine reports — exactly the number you'd branch on before calling pool(n) with a sensible n:

t = now()
print(t.year)                 # e.g. 2026 -- UTC

info = sysinfo()
print(info.logical_cores)     # e.g. 16 -- what to branch on before sizing a pool(n)

output
2026
16