System interface
This document describes the parts of Gizlang that a program uses to reach outside itself: tasks, files, the network, processes, native libraries and live state. The language itself is in LANGUAGE.
Contents
- Tasks
- System
- Files and directories
- Streams, listeners and sockets
- TLS
- Many connections
- Processes and standard streams
- Native functions
- Block devices
- Live state
Tasks
import {System} from "gizlang:system";
import * as Task from "gizlang:task";
function fib(n: i64): i64 { if (n < 2) { return n; } return fib(n - 1) + fib(n - 2); }
function countLines(sys: System, path: string): i64 { return string(sys.readFile(path)!).split("\n").length; }
function main(sys: System): void {
const a = Task.spawn(() => { return fib(20); });
const b = Task.spawn(() => { Task.sleep(5); return "done"; });
const total = Task.join(a); // Result<i64, Task.Fault>
const lines = Task.channel<string>(16);
Task.spawn(() => { Task.send(lines, "hello"); Task.close(lines); return 0; });
const first = Task.receive(lines); // Option<string>
const rows = Task.parallel(4, (band: i64): Vec<f64> => { return [f64(band)]; });
const counted = Task.thread((own: System): i64 => { return countLines(own, "big.log"); });
const many = counted.join(); // Result<i64, Task.Fault>
print(`${total.unwrap()} ${first ?? ""} ${rows.length} ${Task.join(b).unwrap()} ${many.unwrap()}`);
}Tasks on one thread
gizlang:task runs closures as tasks on the thread of the program.
| Function | Meaning |
|---|---|
spawn(() => {...}) | Starts a task. Returns Task<T>. |
zip(a, b) | Starts two tasks, joins both, and returns a typed Pair or a Fault. |
join(t) | Waits for the task. Gives Result<T, Fault>. The value can be taken one time. |
sleep(ms) | Suspends the calling task. |
pause() | Lets the other ready tasks run. |
now() | Reads a monotonic clock. |
cancel(t) | Gives fault 12 at the next suspension point of the task. The task unwinds through its ordinary exits. |
retry(work, n) | Runs work in new tasks until one succeeds. |
within(ms, work) | Cancels work at a deadline (fault 12). |
sleep,joinand I/O suspend only the calling task.- A task never lives longer than the task that spawned it. When the work of a task returns,
its children that are not joined are cancelled, and the task waits for them. The same is
true for the process
main. - A fault ends only its own task. The parent sees it as
Result.err. - Spawn has the effects
{io, fail}. The effects of the closure belong to the task that spawns it.
Channels
channel<T>(capacity) makes a bounded first-in, first-out queue between tasks.
send(c, v)movesvin. It waits while the channel is full. It returns false after the channel is closed.receive(c)waits for the oldest value and givesOption<T>. It givesnonewhen the channel is closed and empty.close(c)ends sending.- A channel belongs to the task that made it. When that task ends, the channel closes and releases the values that it still holds.
- A receive that nothing can satisfy is fault 13.
trySend(c, v)never waits. It dropsvwhen the channel is full or closed.respond(c, v)also drops a late response when the task of the recipient has ended, and returns false. It cannot send into a slot that is used again. Ordinary sends to stale handles still give fault 14.- A value costs approximately 13 ns through a plain channel between tasks.
Crossing channels and cells
crossing<T>(capacity) is a channel between threads.
- The tasks of any thread (
thread) can send, receive and close it while the task that made it lives. - Each value is received one time, however many threads receive.
- A sent value is marked shared. The sender can keep a copy and change it (a write copies).
- Values cannot hold
Refvalues. The compiler refuses them. - A wait on a crossing channel is never fault 13, because a different thread can still send.
- A plain channel belongs to its thread. A use from a different thread is fault 14.
- A value costs approximately 170 to 200 ns through a crossing channel between threads.
cell<T>(initial) is one value that every thread reads. It has the role of ArcSwap in Rust.
c.load()gives the latest stored value.c.store(v)replaces it.c.close()drops the value at once, not when the task that made the cell ends. A load or store after it is a fault. A long task that makes cells for data it later retires closes them to give the memory back.- Neither operation waits. A reader keeps what it loaded.
- Its values are marked shared and cannot hold
Refvalues, as on a crossing channel. A cell is a crossing channel with one slot: a store replaces it, and a load copies it. - A load costs approximately 11 ns.
Atomic is one i64 that every task of every thread changes. The operations use C11 <stdatomic.h>. They do not
wait and do not allocate. Each one takes an optional memory order, sequentially consistent ("seqCst") by default:
a load "relaxed", "acquire" or "seqCst", a store "relaxed", "release" or "seqCst", and fetchAdd and
compareExchange any of these or "acqRel" (counter.fetchAdd(1, "relaxed")). The types LoadOrder, StoreOrder
and Order admit only the orders valid for each operation.
atomic(initial)makes a new atomic. It uses 8 bytes until the process ends.a.load()gives the value.a.store(v)replaces it and givestrue.a.fetchAdd(delta)addsdeltaand gives the value from before the addition. An overflow wraps.a.compareExchange(expected, desired)setsdesiredonly if the value isexpected. It givestrueif it did.atomicGlobal(name)gives the one atomic ofnamein the process. The first call makes it with the value 0, and every thread gets the same atomic. The call takes a lock, so keep the result and do not call it in a hot loop.- A copy of an
Atomicrefers to the same cell.
Ref values in tasks
Closures, results, channels and function values can hold Ref values. The heap keeps them
live while a task, a channel or a function value owns them. A function value is a root while
it lives. Thus a function value stored in the object that its own closure refers to keeps
that object until the value is replaced.
Parallel work
parallel(n, body) runs body(i) for each i below n on the threads of the process (a
pool of one thread for each CPU that it can use). It gives the results in index order as a Vec.
- The body can only compute and fail (effects
{fail}: no I/O, console,Refvalues or shared state). Thus no order of its calls is observable. - What the body captures is read-only while it runs, and stays shared after. Its counts change atomically. A later write copies it, unless the writer holds its only reference. Then it belongs to the writer again and is written in place.
- A fault in any call is the fault of
parallel. The remaining indexes do not start. - A
parallelinside a body shares the pool as any other: a sort inside a parallel group uses every CPU. Sections that run at the same time share the threads evenly, and the pool grows to what they want together, up to one thread for each CPU. Past 64 sections at once, a new one runs on the thread of its caller. - A
parallelalso runs on the thread of its caller when the last calls of its body took less time in total than to wake the threads (20 µs). A section that becomes longer shares the remainder.
The number of threads is the number of CPUs that the process can use: its affinity, or fewer
when the CPU quota of a container (cgroup v2 cpu.max) allows less. GIZLANG_THREADS=N sets a
maximum of N threads, and also limits Task.cores() to N.
A thread that has finished its part of a section waits 100 µs for the next section before it sleeps. It does this only when that section started within 100 µs of the end of the previous section (only the last gap counts). If not, it sleeps immediately.
fill(total, chunk, body) makes one Vec in the same way.
body(start)gives thechunkitems fromstart. The last chunk is the remainder. A different length is fault R001.- Each thread copies its chunk into place, so no serial copy joins the parts.
gizml:tensorfuses and multiplies through it:relu(a*b+c)over 4M floats went from 3.1 ms to 0.45 ms.- Its items own nothing: numbers, bools and records of them.
Memory budget:
- The threads take slices of the memory budget of the caller as they allocate. Thus one busy body can use what the others do not use.
- A value shared between threads (captured, sent on a crossing channel, or stored in a cell) leaves the budget of its World. It counts against one total of shared bytes, with a maximum of the memory limit.
- The thread that frees it last returns it there. Thus a writer keeps its budget when a reader frees its old snapshots.
Threads
thread((sys: System) => {...}) runs work on its own OS thread, in its own World: its own
event loop, tasks, handles and memory budget. sys is its own root System, as a process
main gets.
- It gives
Thread<T>.t.join()waits forResult<T, Fault>, and suspends only the calling task. - What the closure captures is marked shared, as for
parallel. The thread can allocate and useRefvalues inside its own World. - A closure or result that holds references of the spawner is refused. Captured erased functions that can access hidden mutable reference state are also refused.
- A handle of the spawner captured into the thread is fault 14 there.
- A thread that still runs when the task that started it ends is cancelled, as a child task
that is not joined is (fault 12 at its next suspension point, for example a
Ready.wait). The task then waits for it. - A join is cancelled with the task that joins.
- A thread that waits on nothing else is still fault 13.
- A
threadruns on a waiting thread of theparallelpool, or on a new one that joins the pool when the work returns. Thus a round trip costs approximately 55 µs. Most of this time is a new World.
System
The authority of a program over the outside world comes as one value: main(sys: System)
from gizlang:system. All other authority comes from it, and the program passes it on
explicitly. These types have private fields. Thus library code cannot forge them, and cannot
reach anything that it was not given.
| Operation | Meaning |
|---|---|
sys.files() | The working directory, as a Dir. |
sys.directory(path) | Any other directory, by an absolute or relative path (for example, a path from the command line). |
sys.connect(host, port), sys.listen(host, port) | TCP. Names resolve through DNS. |
sys.bind(host, port) | UDP. |
sys.listenUnix(path), sys.connectUnix(path) | Unix-domain sockets. Refer to Streams. |
sys.arguments(), sys.environment(name) | The command line and the environment. |
sys.time() | Wall-clock milliseconds since 1970. |
sys.entropy() | An unpredictable 64-bit seed. |
sys.randomBytes(n) | Result<bytes, IoError>: n secure bytes from the OS, through the synchronous CSPRNG primitive of libuv, for keys and nonces. Zero gives empty bytes. A negative length gives IoError. An OS failure gives no partial result. |
sys.usage() | Process measurements. Refer to Usage. |
sys.stdin(), sys.stdout(), sys.stderr() | Standard streams. Refer to Processes. |
sys.printError(line) | Writes line and a newline on standard error, for diagnostics. |
sys.exit(code) | Ends the process. Refer to Temporary directories and exit. |
import {System, IoError} from "gizlang:system";
import {Result} from "gizlang:core";
function count(sys: System): Result<i64, IoError> {
const text = try (try sys.files()).readFile("notes.txt");
return Result.ok(text.length);
}Usage
sys.usage(): Result<Usage, IoError> observes Linux process RSS, peak RSS, user and system
CPU microseconds, and physical disk bytes, from getrusage and /proc.
- The optional cgroup v2 observation includes current memory, CPU microseconds and disk bytes for the full cgroup. The cgroup can contain other processes.
- When the process is not in a cgroup v2, the result is
Option.none. - Memory and disk counters are
Option.noneseparately when their controller is disabled at the actual cgroup of the process. The SDK does not use counters from an ancestor in their place. - Other counters that are not available or not valid fail. They do not become false zero values.
- These are observations. They are not resource reservations or attribution to each request.
Memory limit
gizlang:memory.limitBytes() gives the configured World and shared memory limit of the allocator. It is not free RAM,
and it is not the remaining allocation credit. The DB resource policy gets the limit from this owner. It does not parse
the environment and defaults again.
Files and directories
Paths and process directories
sys.currentDirectory()returns the physical working directory of the process, independently ofPWD.sys.absolute(path)returns an absolute path unchanged. It reads a relative path againstcurrentDirectory(). The path does not have to exist.sys.realPath(path)resolves an existing path in the namespace of the process.- They return
Result<string, IoError>. They refuse invalid UTF-8 and paths that are too long. - The OS resolver returns its path and error together. It keeps missing paths, components that are not directories, and symbolic link loops as different errors.
sys.makeDirectories(path)creates parent directories. It does not collapse symbolic links and..lexically.sys.removeTree(path)anddir.removeTree(relativePath)remove children through the directory capability. They never follow child links. A missing tree is already removed. File system roots and dot roots are refused.
Copy, move and links
dir.cloneFile(from, target, to, immutable)creates an exclusive, durable file clone. It returnsFileClone.reflink()orFileClone.hardLink(). It never copies file bytes. Mutable files require LinuxFICLONEsupport. Other file systems return an error. Setimmutableto true only when both namespaces keep the inode immutable for its entire lifetime. This permitslinkatwhen reflink is unavailable. Replacement and unlink are safe; writes, truncation and inode metadata changes violate this contract. Hard-link fallback also requires WRITE on the source directory grant. A read grant alone cannot give a destination authority to change the source inode.sys.copyFile(from, to)streams bounded chunks and publishes throughreplaceWith. It copies ordinary permission bits before publication.sys.copyTree(from, to)copies regular files, directories and stored link text, without following links. It refuses to copy a directory into itself. It publishes the completed tree with one rename.sys.rename(from, to)opens the two parent capabilities.dir.renameTo(destination, from, to)gives the same confined operation between existing grants.sys.moveuses a durable tree copy, then removes the source, when the rename givesEXDEV.dir.readLinkreads stored link text.dir.makeLinkstores that text without resolving it.File and tree publication share the same owner of rename and parent-directory sync. Failed private trees are removed without a change to the destination.
Traversal keeps one bounded cursor page for each directory depth. Regular file data uses bounded stream buffers.
Touch and append
sys.touch(path)updates an existing writable file through its descriptor. It does not create a missing file.stream.setMode(mode)andstream.touch()require write authority on the held file.APPENDis an open modifier that requiresWRITE. POSIXO_APPENDimplements it. Writes append, also after a seek to zero.dir.appendFile(path, bytes)uses this mode. It works with independent append writers without cooperative locks, and it does not replace an existing inode.
Dir operations
dir.open(path, READ | WRITE | CREATE | TRUNCATE) opens a file below dir. Paths are
relative and never leave the directory: no .., no absolute paths and no symbolic links.
| Operation | Meaning |
|---|---|
readFile, writeFile | Whole files. |
makeDir, remove, list | Directories. remove removes a file or an empty directory. |
readBounded(path, limit) | Admits the size of an opened regular file before it allocates. Then it reads exactly that number of bytes from the same handle, and refuses more bytes. It closes the handle on success and on failure. A negative limit or a file that is too large gives INVALID_ARGUMENT. If the file becomes smaller or larger during the read, the result is IO_FAILURE. A concurrent change of the same size still requires a content digest of the caller. |
rename(from, to) | Renames entries atomically inside the capability. |
replaceFile(path, bytes) | Creates an exclusive 0600 staging file in the same directory, writes it, runs fsync, closes it, renames it over the destination, and syncs the parent directory. |
replaceWith(path, writer) | The same publication protocol, for a bounded streaming writer. |
publishFile(path, bytes), publishWith | The same staging owner, with an atomic rename that does not replace. Existing files, directories and links stay unchanged. Concurrent publishers can admit only one final entry. If the OS has no such rename, the operation fails. It does not use check-then-rename. |
info(path) | Examines an entry without opening it or following its final link. "" examines the Dir itself. |
setMode(path, mode) | Changes the bits of a file or directory. It refuses links and special files. |
sub(path) | Delegates an existing directory without following links. A missing directory is NOT_FOUND. |
walk(path) | Returns entry paths relative to the Dir, sorted depth-first. It excludes the start directory. It includes links, but does not traverse them. |
entries(path) | A directory cursor. Refer to Directory cursors. |
Replace and publish failures:
- A failure before the rename removes the staging file and keeps the old entry.
- A failure after the rename reports an uncertain publication. It does not remove the new entry.
FileInfo has:
size.modified, in UTC milliseconds.kind:"file","directory","link"or"other".mode: the POSIX permission bits, including the set-id and sticky bits (0 to 4095).
File streams return the same metadata. sub and walk accept "" for the Dir itself.
No operation normalizes malformed paths or permits .. or absolute paths.
Directory cursors
dir.entries(path): Result<DirectoryEntries, IoError> opens a confined directory cursor.
read(limit)accepts 1 to 4096. It returns a maximum of that number of names. An empty result means the end.- Names exclude
.and.., must be UTF-8, and keep the traversal order of the OS. - A delegated cursor keeps its directory descriptor after its parent capability closes.
close()releases it. - A concurrent change of the directory does not give a stable snapshot.
dir.list(path)uses the same cursor and sorts the full listing in Gizlang. If you need bounded memory, use the cursor directly.
Temporary directories and exit
sys.temporaryDir() returns a new private 0700 directory under /tmp.
- When it closes, or when its owner task ends, its contents are removed recursively, without following links. The same resource destructor as World teardown does this.
- Close the temporary parent after its delegated files and directories. If you close it first, their names are removed, also when a delegated descriptor stays open.
dir.temporaryPath()returns its absolute path, to pass to a child process. Ordinary and delegatedDircapabilities returnDENIED.- The path is valid only while the original temporary
Dirstays open.
sys.exit(code) accepts 0 to 255. It ends the process without a print of the result of
main. It runs the cleanup of file and Dir resources, and kills the children that it has
not waited for, as the end of their owner task does. An invalid code is an IoError. No
cleanup is possible after an external SIGKILL.
Streams, listeners and sockets
Streams
A Stream is a file or a connection.
| Operation | Meaning |
|---|---|
read(limit) | Reads. Empty at the end. |
readAll(), write(data), close() | Read all, write, close. |
info() | Files only. The same FileInfo. |
seek(offset) | Files only. |
readAt(offset, count), writeAt(offset, data) | Files only. pread and pwrite: they do not use or move the position of read and write, and several run at once on one file. readAt is short only at the end of the file. |
readMany(offsets, size) | Files only. size bytes from each offset, in order, in one value: the scattered reads of an index walk in one call. On Linux they go to the device together in one io_uring submission of the calling thread. A part past the end of the file is an error. |
lock() | Files only. An exclusive flock, without a wait. true when this process now holds it, false when another process does. It lasts while the file is open, so one server owns a data folder. |
timeout(ms) | Connections only. After the timeout, a read or write that still waits fails with TIMED_OUT. Each connection has one timer that is armed again lazily, as the timers of nginx are. |
unread(bytes) | Puts back the start of a message. Refer to Many connections. |
duplicate(READ | WRITE) | Refer to Descriptors. |
- A
Listenerhasaccept()andport(). sys.listenUnix(path)andsys.connectUnix(path)do the same over a Unix-domain socket.port()is 0. When the listener closes, its file is removed. An isolated process can reach a socket in its folder, or in the folder of its database, and nothing outside.- A
Socket(UDP) hassend(host, port, data),receive(limit)(aDatagramwith thehostandportof its sender) andport().
Errors and ownership
- Failures that the program must handle return
Result.err(IoError). Itsmessage()names the cause. NOT_FOUND,DENIED,BAD_PATHandTIMED_OUTname the codes that a caller can tell apart.- Every handle belongs to the task that opened it, and closes when that task ends. A use of a closed handle is fault 14.
stream.detach()gives a TCP stream to no task, thus it stays open after its task ends.stream.adopt()gives it to the calling task. The clientPoolofgizlang:httpkeeps its idle connections in this way.- I/O suspends only the calling task. When the task is cancelled, its pending operation is cancelled.
- A handle has one pending operation at a time. A second operation from a different task fails immediately (busy). Thus one task accepts connections and gives them to other tasks.
- For files and imported native descriptors,
close()returnsfalsewhile an operation is pending. Seek, read, write and sync refuse concurrent use with busy. Finish the pending operation before you close. - A positionless OS read that has already started can need input or the end of input to finish, also after the task is cancelled.
Descriptors
sys.importDescriptor(fd, READ | WRITE) duplicates a descriptor that an authorized native OS
ABI gave, into a stream that a task owns.
- It verifies the requested access against the actual descriptor.
- It refuses directories and invalid modes.
- The caller keeps the ownership of the original descriptor. When either duplicate closes, the other stays open.
- Device integrations can use the same API.
stream.duplicate(READ | WRITE) gives file streams and imported native-descriptor streams
independent I/O ownership. It narrows the permissions when you request it.
- Each duplicate has its own pending-operation slot and a copy of the logical file cursor.
- The OS open-file description state is shared. This includes flock ownership and the behavior of positionless devices.
- It refuses TCP, TLS and in-process streams. It cannot bypass TLS or broaden an existing capability.
TLS
gizlang:tls secures a TCP Stream in place. Only a program that imports the module links OpenSSL.
import {System, Stream, IoError} from "gizlang:system";
import * as Tls from "gizlang:tls";
/** A TLS stream to example.com. */
export function secure(sys: System): Stream throws IoError {
return try Tls.client(try sys.connect("example.com", 443), "example.com");
}| Function | Meaning |
|---|---|
Tls.client(stream, host) | Verifies the server against the trusted roots of the system, and checks that its certificate names host. |
Tls.clientTrusting(stream, host, pem) | Trusts only the given certificates. It refuses an empty explicit authority. |
Tls.credentials(sys, chainPath, keyPath) | Reads absolute PEM paths and validates the chain and private key. Returns shared Credentials. |
Tls.credentialsOf(sys, chain, key) | The same from PEM values, such as a certificate that gizlang:acme issued. reload has no files to read for them. |
credentials.replace(chain, key) | Validates PEM bytes, then swaps the TLS context atomically. A failed replacement keeps the current context. |
credentials.reload() | Reads the configured files and replaces the context. |
Tls.server(stream, credentials) | Secures any accepted byte stream with the current credentials. |
Tls.serve(listener, credentials, handler) | Serves HTTPS with the handler of HTTP serve; Tls.serveStreamed(listener, credentials, handler, onHead) adds its request-head hook. ALPN offers h2 and http/1.1, so a browser can choose HTTP/2. |
Tls.authority(pem) | An explicit trust authority. |
Tls.openUrlTrusting(system, url, request, pem), openAtTrusting | HTTPS with explicit trust. openAtTrusting takes an admitted connection address. |
- After the handshake, the stream reads and writes through TLS.
close()sends close_notify first. - Handshake and certificate failures are
IoErrorvalues that name the OpenSSL or X.509 error. - Keep the task that creates
Credentialsalive while servers use it. The runtime releases the context when that task ends, after its child tasks settle. - Replacement affects subsequent server connections. Existing connections keep their context.
A listener needs no restart. Write both files before
reload, or callreplacewith both PEM values.
Explicit trust
- The authority owner admits 1 to 65,536 UTF-8 bytes that contain only public PEM CA certificates and white space.
- It refuses leaf certificates, private keys, and malformed or trailing content.
- It uses the same OpenSSL context and parser as the handshake.
- Explicit trust refuses empty bundles and HTTP before it connects. It checks the URL host. It never uses the system roots in place of the given authority.
clientis the separate operation with system roots.tests/runtime/system/tls-authority/run.gizverifies these limits and real TLS requests with the test credentials on Linux.
macOS (not executed)
The Darwin source path is not executed yet.
- For ordinary
clientcalls, it uses theSecTrustSSL policy of the operating system. It keeps the configured DNS name or IP reference. - It does not copy anchors. It does not read the OpenSSL root-path environment overrides, and it does not use the default OpenSSL files. Linux keeps its OpenSSL system-root path.
- Explicit authority keeps its separate PEM policy.
- Peer conversion admits a maximum of 32 certificates, 65,536 DER bytes for each certificate, and 262,144 DER bytes in total. Native intermediate fetch is disabled.
- Evaluation uses the bounded pending quota of I/O services and the libuv worker pool. Only the calling task waits. Cancellation settles the worker before trust is freed or a TLS layer is admitted.
- A
SecTrustEvaluateWithErrorthat is already running cannot be interrupted. Thus its settlement is not an absolute guarantee of wall time. - Legacy renegotiation is disabled for this system-trust context.
Many connections
A server with many connections keeps them in a Ready set, not in a task for each
connection, as nginx keeps them in epoll. One task, or a few, can serve thousands of
connections in this way (serve of gizlang:http).
ready(idleMs)makes a set. It grants nothing.set.accept(listener)accepts into the set.set.wait()returns the next connection that has bytes or has ended. The data is read ahead, so itsreadreturns immediately.- The connection now belongs to the task that took it (a handler fault closes it).
set.watch(stream)gives it back, to be reported one more time.
set.channel(c) puts a crossing channel in the set, with no socket. Watch the stream that it returns.
set.wait() returns that stream, one time for each watch, when a different thread sent a value or closed the
channel. Drain the channel with Task.tryReceive, then watch the stream again. One set watches a channel. A
hand-off from a different thread and its reply cost approximately 4 µs, with no system call for each value.
stream.unread(bytes) puts back the start of a message that a parser cannot finish yet. Those
bytes are read first, but they do not report the connection again. If the connection ends, or
stays silent longer than idleMs, the reads report that.
A server of your own protocol
serveConnections(set, turn) is the one scheduler of every server here (Http.serve is a turn on it). Fill a Ready set
with set.accept(listener); turn(stream) reads what the connection sent, answers, gives the connection back with
set.watch(stream) (or closes it) and returns the number of requests it served. It starts WORKERS tasks and adds one when a
connection is taken while none waits, up to MOST_WORKERS. A fault in a turn ends only its connection. When the listener closes,
the turns in flight finish and the call returns the sum. tests/sdk/system/protocol is a complete line protocol.
Behind a balancer (PROXY protocol)
A TCP balancer (HAProxy, AWS NLB, nginx proxy_protocol on) writes a PROXY header (version 1 or 2) first on each connection it
forwards. listener.proxyProtocol(true), set before the first accept, makes Http read it: the client address in the header becomes
stream.peer() and Http.PEER_HEADER, once for the connection. A connection without a header is closed, and a header later in the
connection is not HTTP. A LOCAL or UNKNOWN header (the balancer's health check) keeps the socket's peer. Web.Serving.proxyProtocol
does this for Web.listen. Turn it on only for a port that nothing but a balancer can reach, since a client could write the header itself.
proxyHeader(bytes) parses a header for a protocol of your own; the header must come whole in the first read (it is at most 536 bytes).
One reactor for each core
serveThreads(sys, count, work) runs work(own, i) for i in 0..count: index 0 on the calling thread, the others on
threads of their own (Task.thread), each with its own System, Ready set and memory. Each call listens on the same port with
listenShared, and the kernel spreads the connections among them. This is the way for a server of any protocol, not only HTTP:
import {System, IoError, serveThreads} from "gizlang:system";
import * as Task from "gizlang:task";
import * as Http from "gizlang:http";
function handlerOf(own: System): (request: Http.Request) => Http.Response {
return (request: Http.Request): Http.Response => { return Http.text(200, "hello"); };
}
function main(sys: System): void {
const port = 8080;
const ended = serveThreads(sys, Task.cores(), (own: System, i: i64): Result<i64, IoError> => {
const listener = own.listenShared("0.0.0.0", port)!;
return Http.serve(listener, handlerOf(own)); // or your own loop over ready(), accept and wait
});
print(`${ended.unwrap()} requests`);
}- It returns the sum of the counts, or the first error. A thread that faults is
IO_FAILURE. - Make the handler (and any connection to a database) inside
work, for each thread. ARefor a handle of one thread is not reachable from another one. State that every thread sees lives in a database. - A listener handed to the process (
sys.handed) is index 0's, so it must be made withlistenShared: the other threads join its port. Web.listendoes this withServing.threads(defaultTask.cores()).
Which servers are alive (gizlang:mesh)
Mesh.join(sys, id, host, port, meta, seeds, intervalMs, holdMs) makes this process a node of a service. Each interval it exchanges
its view with every node it knows (and the seeds) in one short TCP connection. A node not heard of for holdMs is dead.
mesh.alive()lists the live members, this node first.mesh.owner(key)is the live member with the highest hash of (member, key) (rendezvous hashing). A join or a leave moves only the keys of that member, and all nodes with the same view agree.mesh.leave(sys)stops answering and probing, and tells each member, which drops the node at once. Close the listener of the service first: the requests it answers still finish.Http.Balancer.follow(mesh.alive())makes the live members the upstreams of a balancer, at the port in theirmeta.tests/sdk/http/clusteris a service of three servers where one leaves and no request is lost.metais one word that the node tells the others, such as the port of its service. A mesh belongs to the thread that made it.
Worker processes
GIZLANG_WORKERS=N (or auto, one for each CPU) runs main in N processes, as nginx runs workers.
- Each worker has its own memory and event loop.
- Listening sockets share their port, and the kernel distributes connections among the workers.
- The first process forwards SIGINT and SIGTERM to the workers, and exits with the first failure.
- Workers share nothing else. State between them goes through a store or messages.
Processes and standard streams
Standard streams
sys.stdin(), sys.stdout() and sys.stderr() give Result<Stream, IoError>.
- Reads, also from piped stdin, suspend only the calling task.
- When you close one, its capability closes. The standard descriptor of the process stays available to inherited children.
Spawn
sys.spawn(program, args, options) finds program in PATH and gives Result<Child, IoError>.
Arguments are passed literally, without a shell.
spawnOptions() starts with inherited streams, the current working directory and the
current environment. A SpawnOptions has:
| Field | Meaning |
|---|---|
stdin, stdout, stderr | Stdio.inherit(), Stdio.pipe(), Stdio.ignore() or Stdio.stream(stream), independently for each. |
cwd: Option<string> | The working directory. |
environment: Vec<string> | Additions written as NAME=value. The last addition replaces that name. |
isolated: bool | Refer to Isolation. |
handed | Refer to Handed resources. |
limits | Refer to Resource limits. |
Stdio.stream(stream) gives a native file or an imported descriptor to the child as the
selected standard descriptor. It requires the corresponding read or write access, and the
full physical access rights of the OS descriptor, as a stream grant does. Native child code
can use either direction, whatever the standard name of the descriptor is. Thus a narrowed
capability cannot export broader access.
import {System, IoError, SpawnOptions, Stdio, spawnOptions} from "gizlang:system";
export function runBoth(sys: System): i64 throws IoError {
const output = try sys.run("printf", ["hello %s", "world"]);
const defaults = spawnOptions();
const options: SpawnOptions = { ...defaults, stdin: Stdio.pipe(), stdout: Stdio.pipe() };
const child = try sys.spawn("cat", [], options);
print(string(output.stdout));
return (try child.wait()).exitCode();
}Child processes
A Child has:
stdin(),stdout()andstderr()asOption<Stream>. Only piped streams are present.pid().wait(): Result<ExitStatus, IoError>. A wait suspends only its caller, and you can repeat it.ExitStatus.code(n)is a normal exit.ExitStatus.signal(n)is a signal that ended the child.kill(signal): Result<bool, IoError>.close(): kills and reaps a child that still runs.
The child and its pipes belong to the task that spawned them. When that task ends, it kills and reaps a child that still runs.
sys.run(program, args) is written in Gizlang over spawn and tasks. It ignores stdin, drains
both output pipes concurrently, closes them, and returns Result<Output, IoError> with
status, stdout bytes and stderr bytes.
Signals
A program reads process signals as a stream in a task. It does not install handlers:
import {System, TERM, INT, HUP} from "gizlang:system";
function main(sys: System): void {
const signals = sys.signals([TERM, INT, HUP])!;
// ... start the server tasks ...
const first = signals.next()!; // the task waits here
// Write a checkpoint, close the listeners, then return.
}sys.signals(ids)givesResult<Signals, IoError>. The ids are the Linux signal numbers (HUP,INT,QUIT,USR1,USR2,TERM,WINCHare named; the runtime maps them on other platforms): every signal a handler can catch exceptCHLD(child processes use it) and the faultsILL,BUS,FPEandSEGV. Another id givesEINVAL. On Windows onlyINTandTERMcome.next(): Result<i64, IoError>gives the next signal that the program has not read yet. It suspends only its caller. A signal that comes when no task reads is kept for the next read. If the same signal comes two times before a read, the program can see it only one time, because the kernel keeps one pending signal of each kind.- After a program subscribes to a signal, that signal does not stop the program. The program
stops when
mainreturns. close()stops the subscription. A task that waits innext()then gets an error. The subscription also closes when the task that opened it ends.
The runtime installs one handler for each signal. The handler only counts the signal and wakes
the event loop of each reader, so no Gizlang code runs in a signal handler. The runtime does not
use signalfd: signalfd needs the signal blocked in all threads from the start of the process,
and threads that the runtime does not start (the libuv pool, a host program) do not block it.
Isolation
isolated: true starts a Gizlang program on Linux that is confined to cwd. cwd is
required. Before any of its code runs, the program:
- Moves into its own user, mount, network, IPC, UTS, cgroup and PID namespaces.
- Makes
cwdits whole file system, and mountscwdnoexec. - Drops every capability.
- Installs a seccomp filter. The filter refuses mounts, namespaces, credentials, kernel
modules, tracing, bpf, perf, userfaultfd, keyrings, io_uring and new machine code
(executable memory that is not from a file,
mprotectto executable, memfd).
The implementation is runtime/isolate.c.
- The network of the program has no interface up. Thus only its pipes and the listeners that it was given can reach anything.
- No process outside is visible or can receive a signal. This is important because every app runs as the same user.
- The process that its parent started waits outside. It relays SIGTERM, SIGINT, SIGHUP, SIGQUIT, SIGUSR1 and SIGUSR2. It ends with the status of the program, or 128 + the signal when a signal ended the program.
- Where the kernel cannot do this (no unprivileged user namespaces), the child exits with 71
without running. It never runs without confinement (
tests/tools/isolate).
Warning: Isolation is a first step to a process for each tenant. It has had no independent review.
Handed resources
handed: [ProcessGrant.listener(listener), ProcessGrant.stream(stream), ...] explicitly
grants a maximum of 16 resources to any child, isolated or not.
- Both kinds use descriptor slots 3, 4, ... in list order.
- A Gizlang child takes a listener one time with
sys.handed(index). Native consumers use the descriptor ABI. - Stream grants must keep the full access rights of the actual descriptor. A narrowed capability cannot export broader OS access.
- The parent keeps its own resource.
- Listeners stay bound to the network of the parent. Thus an isolated child can serve connections that it cannot open itself.
- Until the parent calls
accept, connections go to its children (start_listeninginruntime/io.c).
Isolated live host
The live host (_live-host, which giz live and the web stack run) keeps its builder
outside when it is spawned isolated.
- The builder watches the sources and runs the compilers.
- Only the forked host, which loads generations and runs the program, is confined.
- Its work folder must be inside
cwd. It is the only folder from which the host can execute files, and it is read-only there. Thus the host runs only code that its builder wrote (runtime/live.c).
Resource limits
limits: Limits(memory, cpu, processes) runs a Gizlang child in its own cgroup (v2) below
the cgroup of the spawner. The units are bytes, thousandths of a core, and processes and
threads. 0 leaves a limit off.
- The cgroup of the spawner must be delegated (systemd
Delegate=yes). The first time, the processes of the spawner move to a leafself. - The child moves itself in before any of its code runs. Thus nothing that it forks starts outside. If it cannot move in, it exits with 71.
- When it uses more than its memory, the whole cgroup is killed.
- When the child ends, what it left running is killed and the cgroup is removed.
- A live host enters with its builder, so the compiles of an app also count.
- Where the cgroup of the spawner is not delegated, the spawn fails (
tests/tools/isolate).
Native functions
declare function name(params): T effects {...} from "library"; binds a C function. It is
linked with -llibrary. The from clause is optional for the C library.
declare function cbrt(x: f64): f64 effects {} from "m";
declare function compress2(dest: mut bytes, destLen: mut u64, source: bytes, sourceLen: u64, level: i32): i32 effects {} from "z";
type Tm = { sec: i32; min: i32; hour: i32; mday: i32; mon: i32; year: i32; wday: i32; yday: i32; isdst: i32; gmtoff: i64; zone: u64; };
declare function gmtime_r(time: mut i64, out: mut Tm): u64 effects {};
declare function qsort(base: mut bytes, count: u64, size: u64, compare: (a: u64, b: u64) => i32): void effects {};The declared types are the C types:
| Gizlang | C |
|---|---|
i8 to i64, u8 to u64, f32, f64, bool | The integer, float or bool of that width. A value that does not fit the parameter fails (R003). |
string | A const char * to a copy that ends with NUL. A string that contains NUL fails (R003). As the result, the returned text is copied. NULL is "". Invalid UTF-8 fails. |
bytes | A pointer to the first byte, or NULL when empty. Pass the length as a separate argument. |
| A record of the scalars above | A pointer to a C struct with those fields in order. |
mut scalar | A pointer to it: an out-parameter of C. It is read back into the local after the call. |
mut bytes, mut record | C writes into the buffer or struct of the local. |
u64 | Any other pointer, for example a handle that C returns (sqlite3 *). |
(a: T, ...) => R | A C function pointer. The argument names a top-level function whose types are the value types of the signature (i64 for i32). A maximum of 8 scalar parameters. R is a scalar or void. |
giz bind HEADER LIBRARY NAME... writes these declarations from a C header
(TOOLS).
Calls and callbacks
- A
mutargument is aletlocal. - A callback runs on the calling task. After a fault inside a callback, later calls return 0 to C without running. The fault fails the caller of the native call when C returns.
- A callback result that does not fit its C type is R003.
- Pointers that a callback receives are
u64. To read through one, declare a C function such asmemcpy.
Authority
The checker cannot see native code. Thus the effects of a native function are its declared
row, which is {io, fail} by default. A native function has all the authority of the process.
- Only the application can declare a native function: its entry module, or a package module
whose manifest entry grants the library (
"native": ["m"], or"c"for functions withoutfrom). Any other declaration isE_MODULE. - The manifest of a dependency can grant only what its parent granted to it (the same
"native"on its entry independencies). If not, the package isE_PACKAGE. - Symbols that start with
gizlang_are runtime services with the Gizlang calling convention. Only the SDK declares them.
JavaScript and Python
A program calls JavaScript, TypeScript and Python in its own process through the library
gizvm (vm/main.giz). The VM runs them (VM). No other process starts.
import {System} from "gizlang:system";
import * as Vm from "gizvm";
import {Value} from "gizvm";
function base(sys: System, path: string): string throws string {
return Vm.javascript(sys).load("node:path").call("basename", [Value.text(path)]).toText();
}Vm.javascript(sys)andVm.python(sys)open the realm of the language. A process has one of each. The first open starts it. TypeScript runs in the JavaScript realm.Realm.load(SPEC)gives a module, which loads once. JavaScript resolves SPEC as Node does from the working folder (node:path, a package innode_modules,./lib.ts). Python takes a module name (json) or a file (./model.py).Realm.module(NAME, SOURCE)makes a module from source text, for example a text module of the program.- A module, a function, a class and every other object come back as a handle
(
Value.object).get,call,callNamed(Python keyword arguments),invokeandconstructuse a handle.releasefrees it. - Plain data is copied at each call:
none,boolean,integer,float,text,binary(Uint8Array,bytes),list(an array, a list or a tuple) andmap(a plain object, or a dict with text keys). A value in a cycle, or nested deeper than 64, stays a handle. - A returned promise or coroutine is awaited. An exception in the realm is thrown as a
string: its message and stack. - Tasks enter a realm one at a time. Each task's stack is scanned by the VM's collector.
- A
declare functionfrom a runtime module (from "node:path") isE_MODULE. tests/vm/embed/main.gizcovers these rules.
Import limits
The "imports" field of a manifest entry limits what a module can reach to the modules that
it lists ("gizlang:core", or a file by its path in the package, "./stack.giz").
- The limit applies to imports, re-exports and qualified names (
_gizlang_sdk_text__join, the import that the projection writes inline). - Anything else is
E_MODULEat the import, or an unknown name. - An entry with
"imports"is code that the application does not trust, for example the test file of a hosted app that runs as the entry. Thus it too can declare C functions only with"native".
A script folder can grant its own files in this way. Put a package.json in the folder that
lists them by "path", with "imports" or "native" and no "sha256". gizlang pins the
files when it builds the script. Thus a host that writes a file and its grant again together
never sees them out of step. This is for a host that confines each app that it runs
(tests/tools/scripts).
Rings
System.ring(entries) makes an io_uring of 128-byte submissions for passthrough commands
(IORING_OP_URING_CMD): the ublk control and queue commands, NVMe passthrough. It is Linux only;
elsewhere it fails with errno 38 (ENOSYS). An isolated process cannot make one: its seccomp
filter refuses io_uring.
| Item | Meaning |
|---|---|
ring.command(fd, op, userData, w0, w1, w2, w3) | Queues command op on descriptor fd with userData (0 to 2^31 - 1) and 32 bytes of command data in four little-endian words. False when the ring is full. |
ring.enter(wait) | Submits the queued commands and waits until wait completions are ready (0: no wait). Gives the number submitted. An interrupted wait fails with errno 4 (EINTR); call it again. |
ring.next() | The oldest completion, consumed: {userData, result} (result below 0 is -errno), or none. |
ring.close() | Closes the ring. |
sys.mapShared(fd, offset, length, writable = false, alignment = 1) | length bytes of fd from offset, mapped shared and read-only unless writable, at an address that is a multiple of alignment (a power of two; 1 for any). Gives the address, which Native.readInto reads. sys.unmap(address, length) unmaps it. |
sys.pageSize(), sys.processId() | The page size in bytes, and the id of this process. |
A ring belongs to one thread: its submitter and its reaper.
Block devices
fs/ublk.giz makes a local block device, /dev/ublkbN, through the Linux ublk driver. Your
Gizlang code serves its requests. It is a storage module, as fs/fuse.giz is, not an SDK module. Use it to attach a volume on the local host. For a volume
on a different host, use NBD: it is the network wire only.
import {System, READ, WRITE} from "gizlang:system";
import {Result} from "gizlang:core";
import * as Ublk from "gizfs:ublk";
/** Serves the file disk of FOLDER as a block device, then removes the device. */
export function serveDisk(sys: System, folder: string): void {
const defaults = Ublk.options(16384); // 8 MiB in 512-byte sectors
const options = {...defaults, queues: 2, depth: 32};
const device = Ublk.create(sys, options, (own: System, queue: i64): Ublk.Handler => {
const file = own.directory(folder)!.open("disk", READ | WRITE)!;
return (request: Ublk.Request, data: mut bytes): Result<i64, i64> => {
if (request.op === Ublk.READ) { data = file.readAt(request.offset, request.length)!; return Result.ok(data.length); }
if (request.op === Ublk.WRITE) { return Result.ok(file.writeAt(request.offset, slice(data, 0, request.length))!); }
return Result.ok(0);
};
})!;
// ... use device.path ...
device.stop(sys)!;
device.delete(sys)!;
}| Item | Meaning |
|---|---|
Options | sectors (512-byte sectors, as the kernel counts), logicalBlockSize (512 to the page size), queues (1 to 4096), depth (requests in flight on each queue, 1 to 4096), maxIo (the largest request in bytes, a multiple of the page size, at most 32 MiB), flags. options(sectors) gives 4096-byte blocks, one queue, depth 64, 512 KiB and VOLATILE_CACHE. |
flags | VOLATILE_CACHE: the kernel sends FLUSH, and FUA writes. DISCARDS: the kernel sends DISCARD and WRITE_ZEROES. READ_ONLY. |
create(sys, options, serve) | Adds, configures and starts the device. It returns when the kernel has made /dev/ublkbN. Gives Result<BlockDevice, string>. |
Request | op (READ, WRITE, FLUSH, DISCARD, WRITE_ZEROES), flags (FUA), offset and length in bytes, queue, tag. |
Handler | (request, data: mut bytes) => Result<i64, i64>. It gives the bytes done (0 for FLUSH and DISCARD), or a positive errno. The kernel sees -errno. A fault in the handler gives -EIO for that request only. |
device.id, device.path | The device number N and /dev/ublkbN. |
device.stop(sys) | Stops the device. /dev/ublkbN goes away and the queue threads end. Gives the number of requests served. |
device.delete(sys) | Deletes a stopped device. |
remove(sys, id) | Deletes device id: a stopped one, or one whose server process ended. |
Queues:
- Each queue runs on its own thread (
Task.thread).serve(own, queue)runs on that thread first and makes the handler of the queue. What it opens, and theRefvalues that it makes, belong to that thread. Thusservemust not captureRefvalues of the caller. Use a crossing channel or a cell to reach the caller. - A queue serves one request at a time, in the order that the kernel completes them.
datais the buffer of the queue. It hasmaxIobytes, and the queue uses it again for each request. ForWRITE, its firstlengthbytes are the data. ForREAD, write the data into it, or put a new value of at least the bytes done indata.- The memory of a queue is fixed:
depth * maxIobytes of native buffers, onedatabuffer and the descriptor map. A request allocates nothing in the queue loop. - The task that calls
createowns the device. Stop the device before that task ends. If you do not, the task waits for the queue threads, and they wait for the kernel. - A fault that escapes a queue ends the process with exit code 70 (fail-stop). The kernel fails the requests in flight of a device only when its char device closes, that is, when the server process ends. Thus do not read or write a device from the process that serves it: when a request hangs, the reader waits in the kernel and the process can never end. Serve the device in its own process, and kill that process when it stops making progress.
- An unknown
op, or a request longer thanmaxIo, gets-EOPNOTSUPPand does not reach the handler.
Access:
- Every device is unprivileged (
UBLK_F_UNPRIVILEGED_DEV). The process needs no capability. It needs read-write access to/dev/ublk-control, and to/dev/ublkcNand/dev/ublkbNof the devices that it makes. A udev rule gives this access. Without it,createfails with errno 13. - The kernel module
ublk_drvmust be loaded. Linux 6.5 or later is necessary. gizlang:systemgives the io_uring that carries the commands (Rings) and the read-only map of the descriptors of a queue (System.mapShared). Everything else is infs/ublk.giz: the control commands (ADD_DEV,SET_PARAMS,START_DEV,STOP_DEV,DEL_DEV),FETCH_REQandCOMMIT_AND_FETCH_REQ, and the request decoding.- Without Linux, every call fails with errno 38 (
ENOSYS). - An isolated process cannot make a device: its seccomp filter refuses io_uring (Isolation).
tests/fs/blockneeds theublkcapability. It serves a device from a file on two queues in a child process, writes and reads it withO_DIRECTfrom the test process, flushes, reads a block whose handler faults (EIO, and the device keeps serving), stops and deletes it. Then a handler hangs: a watchdog kills the server after 5 s with no progress, the reader gets EIO andremovedeletes the device.
Live state
Live.state(name, initial) from gizlang:live returns the managed object (Ref<T>) bound to
name. The first time, it creates the object from initial. Under giz live, every
generation sees the same named state.
import * as Live from "gizlang:live";
import * as Memory from "gizlang:memory";
type Counter = { count: i64; label: string; }; // label added while running
function step(): i64 {
const state = Live.state("counter", Counter(0, "new"));
const c = Memory.load(state);
Memory.store(state, Counter(c.count + 1, c.label));
return c.count;
}When a generation changes the type of the state, the stored value moves into a new object:
- Each field whose name and type are unchanged keeps its value. A field that
giz editrenamed keeps its value through its@wasdoc tag. - Each other field gets its value from
initial. - A field whose value is not kept (removed, or with a changed type) is named on stderr:
gizlang: live state NAME: field F not kept: removed, or its type changed. Thus a value is never dropped silently. - A deployed host (
giz live --accept, CLI guide) keeps every field: it refuses a change that drops one. The bind fails, the stored value stays, and the generation that asked is withdrawn (runtime/state.c,runtime/live.c).
Live.generation() is the number of the newest generation (1, 2, ...; 0 outside a live host). A program that
keeps a value made at its start compares it to see that a new generation took over (Web.Generation does).
Live.reject() withdraws the generation that runs when its startup failed: true on a deployed host (the one
before it serves again), false on a development host, which keeps the generation so the next edit can fix it.
A function is replaced only by a function with the same name and the same parameter and result types, names included. Thus a call that already runs in old code continues to call old code, and never receives a value of a changed type.
WebSocket compression
Http.webSocket accepts permessage-deflate (RFC 7692) when the client offers it. It uses no context between messages
(*_no_context_takeover), and sends a message of 128 bytes or more compressed (RSV1). Http.openWebSocket offers it when its
headers carry Sec-WebSocket-Extensions: permessage-deflate; send and receive then compress and inflate on their own.
gRPC and protobuf (gizlang:grpc, gizlang:protobuf)
gizlang:protobuf is the wire format: a message is a Vec<Field>; encode and decode convert it, varintField, stringField,
messageField, packedField, fixed32Field and fixed64Field make fields, and last, all and unpacked read them. The schema
stays in your code: you read the numbers you know and skip the rest.
gizlang:grpc runs gRPC on HTTP/2. A server is Http.serve(listener, Grpc.service([Grpc.Method(path, handler)])) over h2c, or over
TLS that chose h2. A handler gets the request and its messages, sends replies with replies.send as it makes them, and returns a
Status; the status goes out in the trailers (Http.Exchange.trailers). A client is Grpc.dial(sys, host, port) (or Grpc.use on
a TLS stream) and channel.call(path, messages, metadata), which sends the request messages and returns all replies: unary,
client-streaming and server-streaming calls fit. A call whose requests and replies depend on each other while it runs is not
supported. Messages are not compressed. Http.openH2 is the HTTP/2 client under it.
OCSP stapling
credentials.staple() asks the OCSP responder named in the certificate (its AIA extension) for the certificate's status, checks the
answer (signed by the issuer, good, current) and keeps it: a client that asks for the status gets the answer in the TLS handshake.
The issuer must be the second certificate of the chain. It returns the seconds until the answer's next update; Tls.keepStapled
asks again at half that time, in a task of its own. Replacing the credentials drops the answer, since the certificate changed.
HTTP/3
gizlang:http3 serves HTTP/3 (RFC 9114) over the QUIC server of OpenSSL 3.5 or later. The runtime finds that server at run time, so a
host with an older OpenSSL still runs everything else; Http3.supported() says whether HTTP/3 is available (bin/giz openssl builds OpenSSL 3.5.4 into
$GIZLANG_CACHE/openssl, and the runtime, the CLI and the live host link and rpath to it). Http3.open(credentials, host, port) listens on UDP (host "" is every
address, port 0 any) with the certificate of the TLS credentials, and server.serve(sys, reply) answers every request with
reply: (Http.Request) => Http.ReplyPlan, the same reply plans as HTTP/2 (Http.replyOn), until server.close(). A request body streams
through head (Http.RequestHead) like an HTTP/2 upload, or is read whole up to Http.MOST_BODY without it. Header fields use QPACK with the static
table only (the server announces a dynamic table of capacity 0). Web.listen with a domain opens HTTP/3 on the same port number
over UDP when it is available, and the TLS answers carry alt-svc: h3=":PORT"; ma=86400. The peer address of an HTTP/3 request is
not available (OpenSSL has no getter for it). A certificate that is
renewed (Credentials.replace) reaches an open listener: the next handshake offers it.
A test needs an HTTP/3 client: tests/sdk/http3 runs the client in GIZLANG_H3_CLIENT (quic-go's http3.Transport) and says so
when it or QUIC is missing.
Gateway
gizlang:gateway is a reverse proxy for a host: giz tools/gateway/main.giz CONFIG.json. The JSON file names the ports
(http, https, host), a metrics address, an absolute accessLog path (JSON lines, reopened every 30 s), a challenges folder
(HTTP-01 files for ACME, and every other plain-HTTP request is redirected to HTTPS when https is set), headers added to every answer,
certs (names, chain, key, or names and acme (folder, directory, contact) for a certificate that the gateway issues and
renews itself by HTTP-01 on the plain port: files chain.pem and domain.key in the folder; the first is issued before the ports open, the
others while the gateway serves (also one that a reload adds, which then loads with no restart); each is checked every 6 h, after a failure too, and
renewed in the last third of its life; the first is the default and the others are picked by the TLS server name, exact or *. wildcard,
for TLS and for QUIC), limit (requests, windowMs, proxies: at most that many requests from one client in the window, before any
route; the client is the peer, or the last X-Forwarded-For hop when the peer is one of proxies, whose X-Forwarded-Proto is also kept; over it, 429 with Retry-After; each
serving thread counts on its own, and a reload keeps the counts while the limit stays the same) and routes (name, hosts, prefix, stripPrefix, upstreams, standby, holdMs, keepHost, headers, priority, responseMs, retry, errors, redirect, pathRegex, hostRegex, basicAuth).
A route is chosen by priority (higher first), then host fit (exact 2, wildcard 1, any 0), then the longest prefix, on whole path segments.
standby upstreams get a request only when no upstream can take it. An upstream that refuses the connection is held back, and the
request goes to the next one, whatever its method, since nothing was sent. responseMs limits the wait for a response head. retry
(attempts, statuses) sends an idempotent request again, to the next upstream, when an upstream
answers with a listed status. errors (statuses, upstream) answers with the page the upstream serves at /, under the original
status, for a listed status or when no upstream can be reached (502). redirect (to, status) replaces the upstreams. pathRegex and hostRegex must also match the path and the host.
basicAuth (realm, users: htpasswd lines with $2y$ (bcrypt, Crypto.bcrypt; checked once a password, then remembered by its SHA-256), $apr1$, $1$ or {SHA} hashes; Crypto.md5Crypt) answers 401 without a listed login. The file is read
every 2 s: a changed file replaces the routes, a bad one is refused and the old routes stay; a changed certificate file is reloaded.
Each answer is relayed as the upstream sends it (Http.Balancer.relay: status and headers at once, then each body piece, no size
bound); an Upgrade request (a WebSocket) becomes a byte tunnel after the upstream's 101. The upstream gets X-Forwarded-For
(the peer appended to the client's chain), X-Forwarded-Proto and X-Forwarded-Host; other client-sent forwarding fields are dropped
(Http.forwarded). A request body that is chunked or of 256 KiB or more goes to the first upstream that can be reached as it arrives
(Http.Balancer.forward; chunked stays chunked, nothing is held, no size bound; a body sent cannot be sent again, so a failure after its
first byte is a 502); a smaller body is read whole before it is sent on. The metrics are gateway_requests_total
(route, status class), gateway_request_seconds, gateway_unrouted_total and gateway_limited_total. tests/sdk/gateway covers the file checks, routing, the limit, the
relay, the tunnel, the log, the metrics, the certificate choice and the reload.