Request lifecycle
This page follows one POST /api/v1/execute from the first byte to the
response. The architecture page introduces
the processes involved; the execution model
describes requests and results from the client’s side.
The whole sequence
Section titled “The whole sequence”- Admission.
- Client to API server: POST /api/v1/execute
- API server: Request ID, API key, rate limit, body deadline and size
- API server: Validate steps and profiles; fetch uploaded files
- API server: Step cache: longest cached prefix; wait for an identical request in flight
- API server: Reserve one slot per task of the widest step, or 503
- API server to Controller: spawn_blocking, holding the slots
- Sandbox setup.
- Controller: Create faber/req-*; lease a host UID/GID
- Controller to Jailer: start /proc/self/exe; job and files in a memfd
- Jailer: Join req-*/supervisor; oom_score_adj -999
- Jailer: New mount, network, UTS, IPC namespaces; pivot_root; mount /faber, /tmp, /dev; root read-only
- Jailer: Restore the cached workspace, if any
- Each step.
- Jailer to Supervisor: fork, one per task
- Supervisor: New PID namespace and its init; task-* cgroup; write the task's files
- Supervisor to Task: fork
- Task: Join task-*; own /proc; rlimits; user namespace; drop capabilities; Landlock; seccomp; exec
- Supervisor to Task: stdin
- Task to Supervisor: stdout, stderr
- Supervisor to Task: wall timeout or output limit: cgroup.kill
- Supervisor: Read stats and events; remove task-*; pick the outcome; kill init
- Supervisor to Jailer: task result
- Jailer to Controller: workspace snapshot, for cacheable steps
- Results and teardown.
- Jailer to Controller: result frame; the jailer exits
- Controller: Delete the sandbox root; kill and remove req-*; release the UID; free the slots
- Controller to API server: results and snapshots
- API server: Store cache layers; release the cache lock
- API server to Client: 200 with one result per task
- Faber and the kernel (trusted)
- Submitted code (untrusted)
- Outside Faber
- Starts or calls
- Carries data
- Kills
Admission
Section titled “Admission”Everything here happens in the API server’s async handler, before any sandbox exists. A request that fails a check gets an error response and nothing runs.
- Every request gets an
X-Request-Idand a log line. Its body must arrive withinREQUEST_BODY_TIMEOUT_MS(otherwise400) and fitEXECUTE_BODY_LIMIT_BYTES(otherwise413). - The API key is compared, in constant time, with every configured key
(
401). WithRATE_LIMIT_PER_MINUTEset, the key’s token bucket must have a token left (429withRetry-After). - Validation. No steps or an empty parallel step is
400. More steps thanMAX_STEPS_PER_REQUEST, or a step wider thanMAX_PARALLEL_TASKS, is422 limits_exceeded. A profile outsideALLOWED_SANDBOX_PROFILESis422 profile_not_allowed. Tasks without a profile get the default one written into them here, so it is part of their cache key. File IDs must be SHA-256 hex and inline content must decode (400). - Uploaded files the tasks reference are read from the file store. A
missing one is
404 file_not_found; more than the workspace’s 128 MiB in total is422. - The step cache, when enabled, computes the keys of the leading run of cacheable steps, finds the longest cached prefix, and if the prefix is incomplete may wait for an identical request that is already computing the next layer (step cache internals). If every step is a hit, the response is built from the cache and no sandbox starts.
- Slots. The request takes as many task slots as its widest step has
tasks, without waiting: if
MAX_CONCURRENCYcannot cover them, the answer is503 capacity_exhaustedwithRetry-After: 1. The cache wait comes before this, so a request waiting for another one’s result holds no slots.
The slot permit then moves into a blocking thread, the request’s controller, and is released only when that thread returns.
Sandbox setup
Section titled “Sandbox setup”The controller creates the request cgroup faber/req-<pid>-<random>, with a
memory and PID limit sized for its widest step and a supervisor leaf for
Faber’s own processes, and leases a host UID/GID that no other running
request has. It writes the job and every file blob into a memfd and starts
the jailer with that memfd as stdin.
The jailer joins the supervisor leaf, then builds the sandbox: new mount,
network, UTS and IPC namespaces, a root of read-only bind mounts under
/tmp/faber/<random>, pivot_root into it, the device nodes, tmpfs mounts
on /faber and /tmp, hostname faber, the old root detached, and the new
root remounted read-only. The filesystem a task
sees lists every mount. If the step cache
replayed a prefix, the jailer recreates its workspace before the first step.
A failure anywhere in this phase ends the request with 500 sandbox_setup_failed. When the failure was restoring a cached workspace, the
API server also deletes that cache layer, so the next request runs the step
again instead of failing the same way.
Each step
Section titled “Each step”Before a step, the jailer checks two things:
- With
stop_on_failure, if any earlier task did not complete with exit code 0, the step’s tasks are reportednot_started(“Skipped because an earlier step failed”). - If the request’s deadline has passed, the step’s tasks are reported
not_startedtoo. Otherwise each task’s wall timeout isWALL_TIMEOUT_MSor the time left, whichever is shorter.
Then it forks one supervisor per task and collects their results in order.
Each supervisor enters a new PID namespace and forks its init, creates the
task cgroup, writes the task’s files into the workspace, and forks the task.
The task joins its cgroup before anything else, so every byte and process it
uses afterwards is counted, then locks itself down (see isolation
layers) and calls exec.
While the task runs, the supervisor writes its stdin, drains stdout and
stderr, and checks the clock every 10 ms. Output is kept up to
OUTPUT_LIMIT_BYTES per stream and up to the task’s share of what is left of
REQUEST_OUTPUT_LIMIT_BYTES (the remainder divided by the width of the
step). When either runs out, or the wall timeout passes, the supervisor kills
the whole task cgroup with cgroup.kill. Once the task has exited, it reads
the cgroup’s statistics and events, removes the cgroup, decides the
outcome, and kills the init.
After a step that is part of the cacheable run and finished cleanly, the jailer snapshots the workspace and streams the snapshot to the controller.
Results and teardown
Section titled “Results and teardown”After the last step the jailer writes its result frame and exits. The controller, which has been reading the jailer’s output all along, decodes it, checks every snapshot blob against its SHA-256, then tears down: it deletes the sandbox root, kills whatever is left in the request cgroup and removes it, and only then releases the UID. Returning from the blocking thread drops the slot permit.
Back in the async handler, the API server stores the new cache layers,
releases its cache lock (waking requests that waited for these layers), and
answers 200 with one result per task.
Cancellation and deadlines
Section titled “Cancellation and deadlines”Kills, reaps the jailer, then tears down: root deleted, req-*
removed, UID released, slots freed.
- Faber and the kernel (trusted)
- Submitted code (untrusted)
- Outside Faber
- Starts or calls
- Kills
The controller waits for the jailer in a loop that polls its output pipe for at most 50 ms at a time, and on each pass checks:
- Cancellation. The handler holds a guard that cancels the request when
it is dropped, which is what happens when the client disconnects. The API
server answers
503 cancelled, though nobody is left to read it. - Shutdown.
SIGTERMsets a process-wide flag and kills every request cgroup at once. Running requests answer503 shutting_down, and the server gives in-flight responsesSHUTDOWN_TIMEOUT_MSto drain. - The hard deadline. The jailer enforces
OVERALL_TIMEOUT_MSitself, between and within steps, and returns the steps it finished. The controller only steps in if the jailer is still running two seconds after the deadline, and answers504 deadline_exceeded.
In all three cases it kills the jailer’s process group (the jailer, its
supervisors and their inits) and the request cgroup, whose cgroup.kill
reaches every task, then reaps the jailer and tears down as above. If the
jailer exits while one of its descendants still holds the result pipe open,
the controller kills the request cgroup too, so that the pipe closes.
Where this lives in the code
Section titled “Where this lives in the code”crates/faber-api/src/router.rs,middleware.rs,rate_limit.rsandbody_deadline.rs: the layers in front of every handler.crates/faber-api/src/handlers/execute.rs: validation, files, cache coordination, slots, cancellation on drop, error mapping.crates/faber-runtime/src/runtime/core.rs:execute_with_snapshots, the controller loop,terminate_request, the step loop, supervisors and tasks.crates/faber-runtime/src/runtime/jailer.rs: the jailer’s start and output.crates/faber-runtime/src/container/core.rs: sandbox setup and cleanup.crates/faber-runtime/src/runtime/workspace.rs:stop_on_failureand workspace restore.crates/faber-api/src/serve.rs: header timeout and graceful shutdown.