Skip to content

CI throughput: one runner, a 120 s docker client timeout, and a 67-minute release job on every branch #126

Open

Description

@aliasfoxkde

Objective. Restore CI throughput on the GitForge pipeline: the merge campaign is bounded by one runner's container-creation timeout and by the fact that every job rebuilds the world, not by FireCraft's own build times.

Why. Seven pull requests are in flight, all waiting on GitForge receipts, and the queue is serialized behind a single runner with capacity 4. A full branch run is 9 jobs of 10–40 minutes each, and the release job alone is ~67 minutes because it builds the DEB, the tarballs, the CLI and the web bundle on every branch push. Measured on 2026-11-11: 63 queued jobs across 7 runs, with clippy-test queued behind a resource_class_conflict for four different runs at once.

Two infra defects sit under that queue. Neither is a FireCraft code defect, and neither is visible in the FireCraft repo — they live in the runner's deployment — which is why they have gone unrecorded until now.

Measured evidence (from the authoritative store, /var/lib/gitforge/gitforge.db, jobs started 2026-11-10 onward)

Failure class Count Shape
failed to pull image … 404 9 The runner tried to pull an image that was not loaded on its host. The local registry (127.0.0.1:5000) does not carry firecraft-ci-rust at all, so a missing local image is always a 404. Resolved for now: firecraft-ci-rust:1 and :2 are both loaded on the runner host as of 2026-11-11. The class returns whenever an image is rebuilt or a new tag is referenced.
failed to create workspace container: Timeout error 2 The runner's systemd drop-in grants a 600 s sandbox acquisition window (GITFORGE_SANDBOX_ACQUIRE_SECS=600), but bollard's per-request client timeout still caps container creation at 120 s (docker_client_timeout() in crates/gitforge-sandbox/src/docker.rs, defaulting to bollard's DEFAULT_TIMEOUT). The two budgets disagree: a container that takes 121 s to create dies even though 480 s of the acquisition window remain.
failed to create workspace container: 404 No such … 1 A workspace container that vanished between create and use.
job failed (a step's exit code) 23 Mostly the release job failing inside xtask dist --native on branches that carried the DEB desktop integration: debconf: delaying package configuration then a panic in xkbcommon-dl-0.4.2/src/x11.rs — the CI image has no X11, so the desktop-integration path panics rather than degrading. Not on main.

One of the two timeout failures reddened main itself: run b61f198e (2026-11-11T17:04Z) failed at clippy-test with infrastructure_failure, cancelling its six downstream jobs. main currently has no green GitForge run of its own.

The chain's real cost, measured on the same store (2026-11-11/12)

One branch run, docs/deployment-receipt (PR #127, commit fcfe6db1):

Job Started → finished Duration
fmt-layers 22:23Z → 22:33Z 10.0 min
clippy-test 03:07Z → 03:39Z 31.0 min
wasm-web 03:47Z → 04:14Z 26.8 min
corpus-golden 04:14Z → 04:25Z 10.8 min
perf-budgets 04:25Z → 04:36Z 10.4 min
fuzz 04:33Z → 05:03Z 29.2 min
generated-docs 05:03Z → 05:14Z 10.9 min
release 05:14Z → running ~67–88 min

The first job of that chain finished at 22:33Z and the seventh at 05:14Z — about seven hours of wall clock before the last job even started. Every one of those jobs compiled the workspace from scratch, because Cargo's target output went to a disposable directory inside the job container: no job ever started warm. The release job alone carries the merge receipt for a branch push.

Work

  1. Make the two budgets agree. Set GITFORGE_DOCKER_TIMEOUT_SECS=600 on the runner service, so bollard's client timeout matches the acquisition window the runner already grants. The code comment at crates/gitforge-sandbox/src/docker.rs:270 states the rule — "keep bollard's default unless the operator raises it" — and the operator raised the acquisition window but not the client timeout.
  2. Stop pulling images that cannot be pulled. Either pre-warm every referenced tag on every runner host before the commit lands, or make the runner fail fast with a clear "image not present on this host" message instead of a registry 404 that reads like a network fault.
  3. Bring the second runner online. fedora-docker is registered with capacity 5 and has been offline since 2026-11-05; t5500-docker (capacity 2) has been offline since 2026-10-11. fedora is ready for it: the CI images :1, :2 and :chromium are already loaded there, the NAS is mounted, /nas/Temp/repos/GitForge is present, and both the git server (42782) and the API (42781) bind 0.0.0.0 and answer from it. Registering it roughly doubles fleet capacity.
  4. Do not build the release artifact on every branch push. The release job costs ~67 minutes per run and produces artifacts nobody consumes for a branch. The :2 pipeline configuration already in flight on PR docs: reconcile FireCraft status, execution docs, and wiki sources #123 gates it with skip_if; landing that PR is the cheap fix and needs no runner changes.
  5. Start every job from a warm Cargo target tree. The runner can bind-mount a host directory at /cargo-target and export CARGO_TARGET_DIR, so compiled artifacts survive the job — but it takes an exclusive advisory lock on every writable host directory a job mounts, for the job's whole lifetime (GitForge GF-11: two jobs writing one target tree clobber each other's binaries, measured on DIL on 2026-11-05). So the cache directory must be one per job, not one per image: a single shared directory would serialize every Cargo job in the fleet into one lane. PR feat(ci): a warm Cargo cache per job, so a run starts warm #129 (decision 0098) declares {cache_root}/cargo/{image}-<job> per job, and the offline validator now enforces that shape and runs in the GitHub backup CI's fmt job. The first run after the change is still cold; every later run of every branch reuses the artifacts.

Acceptance

  • A GITFORGE_DOCKER_TIMEOUT_SECS value at least as large as GITFORGE_SANDBOX_ACQUIRE_SECS is set on every runner service, and the setting is recorded where the runner is deployed.
  • A job whose workspace container takes longer than 120 s to create completes instead of failing with infrastructure_failure.
  • fedora-docker reports a heartbeat and takes jobs; the runner inventory shows more than one online runner.
  • main has a green GitForge run on its own head SHA.
  • A branch push does not build the release artifacts.
  • A warm run's per-job durations are read from the authoritative store and recorded here, so the throughput claim rests on a measurement rather than on the mechanism.

Boundaries. These are runner-deployment and GitForge-service changes, not FireCraft source changes. Changing the runner's service configuration, re-registering a runner, and touching the runner registration secret are operator actions that need explicit authorization — the registration token is a shared secret and was not read or moved while preparing this issue. Nothing in this issue has been applied.

Tracking: task #227; campaign RP-07.

Next action (owner: CI/release). Item 4 is a merge, not a change: PR #123 carries the skip_if pipeline. Item 5 is PR #129 and needs the same merge queue to drain before it can be rebased onto the :2 pipeline shape. Item 1 is a one-line service setting that removes a failure class the code already documents the remedy for, and it is the cheapest item on the list. Item 3 needs the runner registration secret, so it needs an operator with that secret; item 2 is a code change in GitForge.

Activity

  1. aliasfoxkde commented on Nov 11, 2026

    @aliasfoxkde
    OwnerAuthor

    Fourth defect class found: the pipeline watchdogs are shorter than a passing job can take.

    The issue above lists two runner-deployment defects and one release-job cost. A third, in the FireCraft repo itself, is now measured and fixed.

    A job's timeout: in .gitforge.yml is a watchdog, not a gate: it kills the container and records timed_out with no diagnostic about what was slow. The budgets were sized by guess, and the fleet's own record falsifies the guess. Measured on 2026-11-11 with 9 runs in flight, from the authoritative store:

    job budget worst successful run idle
    fmt-layers 20m 20.1 min 0.3 min
    clippy-test 90m 76.9 min 10.3 min
    wasm-web 60m 45.6 min 9.0 min
    corpus-golden 30m 19.6 min 2.5 min
    perf-budgets 20m 14.3 min 3.2 min
    fuzz 60m 52.9 min 10.6 min
    generated-docs 20m 16.1 min 2.1 min
    release 90m 88.3 min 38.8 min

    Three jobs finished inside 2 minutes of their budget, and the release job's worst successful run finished 1.7 minutes inside 90. Two perf-budgets runs were killed at 20.2 min and 20.3 min while still working, and one fmt-layers run was killed at 20.5 min.

    The cause is the fleet shape this issue already describes: one runner, capacity 4, 16 cores, and each container compiles the workspace. A co-tenant wave inflates a job roughly 4x — fmt-layers measures 0.3 min idle and 20.1 min loaded. A budget that a passing job can outrun is indistinguishable from a failure.

    Fix (FireCraft repo, no runner change): each budget is now ~2.5x the worst successful duration — fmt-layers 45m, clippy-test 150m, wasm-web 90m, corpus-golden 45m, perf-budgets 45m, fuzz 120m, generated-docs 45m, release 180m. No gate is weakened: a budget decides when a container is killed, not whether a check passes. The rationale and the measurement are recorded in the pipeline file itself.

    The change rides on the eight open PRs (#113, #118, #119, #122, #123, #124, #125, #127) because each of their receipts needs the headroom to finish; main inherits it as those land. Verified in the store: every new run carries the raised budgets (fmt-layers:2700 … release:10800), and each push superseded its predecessor run, so the queue did not double.

    • Every job's watchdog exceeds the worst successful duration measured on the fleet under load.

    This does not address items 1–4 above: the docker client timeout, the image-pull 404s, the second runner, and the release job on every branch push are unchanged. The release job's 180-minute budget buys the receipt time; it does not make the job cheaper — item 4 remains the fix for that.

  2. aliasfoxkde commented on Nov 12, 2026

    @aliasfoxkde
    OwnerAuthor

    Fleet measurement, 2026-11-12 (merge-campaign queue). While waiting on receipts for six merge candidates, the runner inventory was read from the authoritative store and the queue's real shape became visible.

    Slot (swarmone-docker, capacity 4) Job Run Started
    1 fuzz 055e2394 — feat/history-rewind-palette @ d7586723 06:19Z
    2 clippy-test aacdd78e — docs/ledger-post-train @ 4982c644 06:19Z
    3 wasm-web 0dda5775 — feat/mask-stack-ui-v2 @ 754b0d80 06:26Z
    4 fmt-layers 54b2799d — feat/history-rewind-palette @ dec2e770 (a merge candidate) 06:46Z

    Finding 1 — a superseded run's in-flight job keeps its runner slot. Runs 055e2394 and 0dda5775 were both marked cancelled at 05:28Z, superseded by the newer pushes on the same refs. Their never-dispatched rows were cancelled, but their running jobs were not: supersede_stale_runs updates only status IN ('pending','queued') and deliberately leaves assigned/running rows to the runner lifecycle (crates/gitforge-db/src/queries.rs:920, and the doc comment above it says so explicitly). Both orphans then held a swarmone-docker slot for the rest of their run — fuzz for 29 minutes and wasm-web for 27 — producing results for a SHA that is already history. Two of four slots were spent on work whose receipt nobody can use.

    Finding 2 — a terminal run can have a live child. The run row for 0dda5775 read cancelled from 05:28Z while its wasm-web job held a lease with heartbeats through 06:48Z. A receipt read from pipeline_runs.status is therefore not a receipt read from the work: the run says the push is finished while the runner is still compiling it. A CI receipt for a superseded SHA is worse than no receipt — it looks complete.

    Finding 3 — the CLI has no verb for the operation the API allows. POST /api/v1/jobs/{id}/cancel authorizes the repository owner (authorize_repo → claims.user_id == repo.owner_id), so the FireCraft owner can cancel their own jobs; the route only requires admin|maintainer for other repos' jobs (crates/gitforge-api/src/routes/ci.rs:103, :128, :1433). gitforge pipeline has no cancel, so the operation is reachable only by hand-rolling a bearer request. gitforge pipeline cancel <run-or-job> (and a gitforge job cancel <id>) would close the gap.

    Next action (owner: CI/release). Item 3 above is a GitForge CLI change and needs no runner change and no registration secret. The slot leak in Finding 1 needs either the supersede path to cancel in-flight jobs (the runner would then release the container) or the CLI verb from Finding 3 to make the operator's recourse available. Both are GitForge-repo work, outside FireCraft's boundary; recorded here because the FireCraft merge queue is what measures them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions