Qbs Build System

Architecture, configuration, and internals of the OctoMY build system

Qbs Build System

Overview

OctoMY uses Qbs as its build system. Qbs is a declarative build tool from the Qt ecosystem that describes what to build rather than how. The project makes extensive use of Qbs features including custom probes, modules, product templates, and conditional compilation.

Note. The build description is moving to .kiln files, read by kiln. Today each product's .kiln is generated from its .qbs when kiln builds, so .qbs stays the file to edit, and qbs stays the default build until it retires: then kiln import-qbs --final commits every product's .kiln and the .qbs files go.

Scale

Metric Count
Total .qbs files ~950
Static libraries 80+
Applications 10
Tools 7
Tests 215
Custom probes 6
Custom modules 3
Product templates 3 (Lib, App, Test)

Canonical build convention

This section is the single source of truth. README.md, AGENTS.md, CLAUDE.md, in-repo memory, and any other guidance points here. Do not duplicate. If a convention changes, update this section and only this section.

Standard command: qbs build. No --build-directory, no -d, no path overrides. With this repo's qbs configuration the build lands at <repo>/default/ — whatever the in-tree qbs files dictate is the canonical answer. Passing override flags scatters binaries across parallel trees that the constellation launcher and other tooling silently load stale copies from.

Output location (canonical, do not parameterize):

Artifact Path
Build root <repo>/default/
Installed binaries <repo>/default/install-root/usr/local/bin/
Per-product build artifacts <repo>/default/<product>.<hash>/

Launching binaries. The constellation launcher (hub) and other multi-process entry points locate sibling binaries relative to themselves. Always launch from <repo>/default/install-root/usr/local/bin/. Launching a hub from any other path (e.g. a stale tree like <repo>/.build/default/install-root/... left behind by a session that overrode the build dir) will silently make peers look for agent/remote/mocknode in the wrong place.

Rule: if you need to evaluate a fresh build interactively, run qbs build and launch from the canonical install-root above. Do not cp binaries into other directories. Do not introduce symlinks to make legacy paths work. Do not parameterize the build dir per-session. If a stale tree like <repo>/.build/default/ exists (from a session that used --build-directory .build), it is dead weight — delete it (rm -rf <repo>/.build/default/) when you're sure nothing else relies on it.

The only sanctioned exceptions are purpose-named trees under .build/. Cross-target and instrumented builds cannot share the host tree, so they get explicit purpose names: .build/android-<abi>-<variant>/, .build/mingw-cross-<variant>/, .build/host-tools-<profile>/, .build/analyze/ (instrumented builds made by integration/analysis/analyze.sh), and .build/logs/. The name .build/default/ is never legitimate — it is always legacy drift from a session that overrode the build dir. Delete it on sight. The host development build lives at <repo>/default/ and nowhere else.

Build trees are not relocatable. The qbs build graph (default/default.bg) stores absolute paths to every source and artifact. If a checkout is copied or renamed (e.g. cloning a session directory), the copied build graph still points at the original tree: qbs will silently resolve and read sources from the old path while writing artifacts into the new one. After copying a checkout, delete every copied build tree (default/, .build/*) before the first build and rebuild from scratch. What does carry over safely: the source tree, src/libs/libllvm/generated/ (prebuilt LLVM libs), and the vendored llvm-src tree — so a post-copy rebuild never rebuilds LLVM. (llvm-src is a real directory copied in by the fetch tool, not a symlink: linking was withdrawn in I-15 after a root-owned link to a container-only path was written into a bind-mounted worktree. See integration/qbs/imports/vendoredsource.js.)

Why this matters. Build-output drift is the #1 cause of "I committed the fix but the test session shows old behavior." Multiple LLM sessions writing to different build dirs, copying binaries between trees to compensate, and adding ad-hoc symlinks compound the problem. A single canonical location with one entry path eliminates the whole class of drift.

Build system notes

OctoMY uses Qbs with cached file discovery. Source files are scanned once during qbs resolve and cached across builds. This makes incremental builds fast (~3s overhead instead of ~2min).

  • After adding, removing OR MOVING source files — including when you only PULLED a change that did: run qbs resolve --force-probe-execution project.fileCacheMode:"never" — the same cache bust a new product needs. A plain qbs resolve is NOT sufficient on a warm build tree: the cached file discovery can keep serving the old manifest, the new sources never compile, and the failure surfaces later as undefined symbol at link — reading as a code defect in whoever added the files rather than as a stale cache. (Found the hard way 2026-09-08: two files added on main linked fine from their author's tree and broke every warm sibling tree that plain-resolved. A CLEAN checkout never hits this, so CI stays green while warm trees fail.) The "including when you only pulled" half is not padding. This bullet was written from the author's point of view, and a lane that merely rebases does not read "after adding source files" as being about them — opal walked into the identical link failure on 2026-09-14, in libconstruct, having already read the advisory that points here.

  • A moved file fails in a product you never touched. Files that move out from under a wildcard leave the old paths in the graph, and the build then fails inside the owning product with File '…/<old path>' does not exist and no error: line anywhere. That reads like a bad merge or a corrupt graph, and it is neither. --check-timestamps does NOT cover it. Only the force-probe resolve above re-expands the group. (Found 2026-09-10: the icon wave moved six SVGs, and every warm sibling tree failed in libres, which nobody was working on.)

  • A header edit can leave an object stale, and no flag shows it. On 2026-10-05 a full qbs build in base linked agent, hub and 13 more against a libpuppet object from two days earlier: undefined symbol: Personality::IRIS, after IRIS became an inline static constexpr member. The object predated the header, the header is included directly, and libpuppet declares Depends on libid. qbs build --check-timestamps -p <the 15> recreated the archive and still did not recompile the object. A touch of the source did, and it also recompiled a second stale object beside it. This is the lost-header-edge class of windows-field-defects §WFD-RL-59.4, now seen on Linux, where no guard exists (the MSVC guard is integration/win/check-stale-objects.py). An object that is stale and still links is not reported at all. So a test sweep whose results must be trusted runs on a fresh build graph, and a link error against a signature the source no longer has is this class until shown otherwise.

  • --check-timestamps ignores changed flags, and remembers ignoring them. After a resolve that changes a file's cpp.defines or other compiler flags, a plain qbs build recompiles it. qbs build --check-timestamps does not, and it records the new command as built, so every LATER plain build skips the file too. The object stays stale until its source changes or the product is qbs cleaned. --check-outputs on its own behaves correctly. Measured on qbs 3.3.1 on 2026-09-26: a two-file project shows it in three builds. In this tree it froze BuildInfo.cpp, so a package reported the commit of the previous day. Use --check-outputs for normal builds, and --check-timestamps only for a graph that believes deleted outputs still exist. A tree built with it after a rebase needs a qbs clean before its test results mean anything.

  • After a long fast-forward, a build can report a CYCLIC DEPENDENCY that does not exist. The first qbs build after the tree jumps a long way re-resolves the old graph incrementally, and it can stop with ERROR: Cyclic dependencies detected: naming two products and a Depends.productTypes edge. There is no cycle. qbs resolve --force-probe-execution on the same tree exits 0. Two tells separate this from a real structural cycle: the second product named is the same every time (anonswarm in every sighting so far, because it fans out over productTypes: ["octomylib"]), while the first rotates between lanes and runs. A real cycle names the same pair every time. (Found 2026-08-25 as libfirmware <-> anonswarm, again 2026-10-01 as libeval <-> anonswarm after a git merge --ff-only through 90 commits.) Resource exhaustion is not the only cause. The earlier sightings came from several sessions building with the disk or memory full, so that was written down as the cause; the 2026-10-01 one had 500 GB free and no other build running. So treat any big fast-forward as reason enough, and force-probe before reading a cycle as an architecture break. Note that resolving a fresh configuration hides this rather than testing it — a clean graph never has the problem, so config:debug passing says nothing about the lane's default graph.

  • After adding cross-library #includes: run qbs run -p analyze-deps then qbs resolve

  • After adding a NEW lib that includes other libs (both at once): the order matters and one step is stronger. Run qbs run -p analyze-deps FIRST (it scans sources and needs no resolve), THEN qbs resolve --force-probe-execution project.fileCacheMode:"never". A resolve that runs before the manifest carries the new lib bakes in an empty dependency list, and a later PLAIN resolve serves that cached state — the build then fails with fatal error: <dep>/<header>.hpp: No such file or directory on an include that plainly exists. (Found 2026-09-10: libheader resolved before analyze-deps wrote its libactivity edge, and the plain re-resolve did not pick the manifest up.)

  • After adding a new library product (new src/libs/lib<name>/lib.qbs) or tool (new src/tools/<name>/tool.qbs): LibProbe/ToolProbe discovery is cached too — run qbs resolve --force-probe-execution project.fileCacheMode:"never" once so the new product registers

  • To bypass caching (debug): qbs build project.fileCacheMode:"never"

  • Project properties replaced the build's environment variables on 2026-09-16 (no-environment-variables §NEV-3.3: "qbs and JavaScript take project properties, not Environment.getEnv"). A property is passed on a qbs resolve and then STICKS in the build graph, where a forgotten environment prefix silently fell back to the default:

    Was Now
    BUILD_CACHE_DIR project.buildCacheDir — the shared cache of vendored LLVM/llama/rl trees
    OCTOMY_WIN_OPENSSL_ROOT project.winOpensslRoot
    OCTOMY_WIN_ZLIB_ROOT project.winZlibRoot — empty means "use Qt's own zlib", which every measured kit has
    ANDROID_OPENSSL_ROOT project.androidOpenSslRoot
    ANDROID_SDK_ROOT / ANDROID_HOME the android profile's own SDK dir, which qbs setup-android wrote
    OCTOMY_TEST_HEADLESS project.headlessTestsOnly
    qbs resolve project.buildCacheDir:/build/shared
    

    The one environment read the build keeps is a child's PATH on Windows, which has no rpath, in integration/qbs/imports/childenv.js — and it is a pass-through to a child, not a decision this process makes.

  • Optional features can be disabled per-workstation via oc_use.json (gitignored):

    { "llvm": false, "webEngine": false, "multimedia": false }
    
  • ccache is autodetected and prepended to the compiler when present. One-time per-machine config (cross-checkout sharing) and the full rationale live in ccache Setup. If ccache is installed but misconfigured, the build prints a warning naming the fix.

  • Several branches on one machine? Set up a build site is the end-to-end runbook for the bare-repo-plus-worktrees layout, including the qbs-from-source step that a stock Debian box needs.

Long resolves and builds must be detached

A force-probe resolve discards the cached manifest, so the build after it is a long one. Never let a long qbs build run as a foreground command under an agent harness. The harness kills a command that outruns its time limit, and it kills with SIGTERM. A qbs that takes SIGTERM mid-write corrupts default.bg exactly as an OOM kill does.

Recovery is not the cheap re-resolve this page implies elsewhere. A fresh graph has no record of what the existing object files were built from, so it rebuilds them. Measured 2026-09-10: 2913 libllvm compile invocations for a one-line test change, with git diff proving that no libllvm source had changed. The product holds 2901 *.cpp.o files in this configuration, so 2913 invocations means the whole product, rebuilt from scratch. The object count was confirmed independently on two build trees of the same configuration, the base and opal lanes, so it is not one machine on one run.

After a graph loss, rebuild the smallest product that answers your question. The recovery cost is set by what you ask for next, not by the graph loss itself. The figure above came from asking for a test product and hub, both of which drag libconstruct and therefore libllvm. Recovering the same lost graph and building -p libres finished in minutes and compiled no libllvm at all. Measured on the opal lane 2026-09-14, and the same asymmetry was seen on base the same week: a test product rebuilt most of the tree where libres alone did not.

Two counts look wrong here and are not. Only 2773 of those 2913 log lines are distinct, because qbs logs the basename and LLVM has same-named files in different directories — X86.cpp appears five times, and each one is its own translation unit. And find src/libs/libllvm -name '*.cpp' returns 4251, which would make the rebuild look partial. Do not use that number: this configuration compiles a subset of the vendored sources, so the denominator that matters is the object count in the product directory, not the source count in the tree.

Detach both steps and poll the logs instead:

setsid nohup bash -c 'qbs resolve --force-probe-execution project.fileCacheMode:"never" > r.log 2>&1
                      echo "EXIT=$?" >> r.log
                      qbs build --jobs 8 --job-limits linker:4 -p <product> > b.log 2>&1
                      echo "EXIT=$?" >> b.log' >/dev/null 2>&1 & disown

setsid matters, not only nohup. A harness that kills a process group reaches a nohup child that is still in the group. The watcher is disposable. The build is not.

If your harness has a native background mode, use it instead of the recipe above. In Claude Code that is the Bash tool's run_in_background option, which hands the command to the harness rather than to your shell, so no timeout of yours can reach it. This is worth stating because the recipe above is easy to get subtly wrong: a nohup ... & started inside a tool call that itself later times out is still killed, since the child inherits that call's process group. Both failure modes were hit on 2026-09-19 in the audit lane, within two hours of each other, with this page already written.

Two related traps, same cause:

  • Never wrap a qbs build in timeout N. When N fires it sends SIGTERM, which is the corrupting signal.
  • To stop a runaway qbs, send SIGINT only. It drains, lets in-flight linkers finish and writes the graph intact.

After any kill, verify the graph before you trust an incremental build. The first bytes must name the persistence version:

# grep -a is REQUIRED: the header is NUL-padded, and without it grep calls the
# input binary and exits 1 — reporting a HEALTHY graph as corrupt.
head -c 24 default/default.bg | grep -qa 'QBSPERSISTENCE' && echo GOOD || echo CORRUPT

Directory Layout

project.qbs                          # Top-level project, feature detection, CI parameters
integration/qbs/
  main.qbs                           # Orchestrator: references libs, apps, tests, tools, analysis
  meta.qbs                           # Collects non-source metadata files
  probes.qbs                         # OctoMYLibProbe definition
  imports/                           # Product templates, probes, and configuration
    Lib.qbs                          #   Static library template (tagged "octomylib")
    App.qbs                          #   GUI application template
    Test.qbs                         #   Test executable template (Qt Test integration)
    BaseProbe.qbs                    #   Generic directory scanner probe
    LibProbe.qbs                     #   Library discovery probe (extends BaseProbe)
    AppProbe.qbs                     #   Application discovery probe (extends BaseProbe)
    TestProbe.qbs                    #   Test discovery probe (extends BaseProbe)
    QtModuleProbe.qbs                #   Qt optional module availability detector
    GitInfo.qbs                      #   Git metadata probe (branch, commit, dirty)
    BuildOptions.qbs                 #   Compiler/linker flags (C++20, warnings, sanitizers, LTO)
    QtDepends.qbs                    #   Qt module dependency list
    EnabledFeatures.qbs              #   Feature flag preprocessor defines
    Sources.qbs                      #   Source file enumeration (cpp, hpp, c, h, ui)
    Resources.qbs                    #   Resource file enumeration (qrc, svg, png, yaml, etc.)
    Files.qbs                        #   Base file group with project-wide excludes
    OctoMYProperties.qbs             #   Convenience bundle (BuildOptions + EnabledFeatures)
    AndroidOptions.qbs               #   Android API level configuration
    Meta.qbs                         #   Metadata file collection
    Documentation.qbs                #   Documentation file collection
    utils.js                         #   JavaScript helper functions
  modules/
    OctoMYLibs/module.qbs            #   Library metadata module
    TestConfig/module.qbs            #   Test category filtering module
    Shaders/module.qbs               #   GLSL shader compilation module (.vert/.frag -> .qsb)
src/
  libs.qbs                           # Library aggregator (uses LibProbe)
  apps.qbs                           # Application aggregator (uses AppProbe)
  libs/lib*/lib.qbs                  # Per-library product definitions
  <appname>/app.qbs                  # Per-application product definitions
  tools/tools.qbs                    # Tool aggregator
  tools/*/tool.qbs                   # Per-tool product definitions
test/
  tests.qbs                          # Test aggregator (uses TestProbe)
  test*/test.qbs                     # Per-test product definitions
  common_test/lib.qbs                # Shared test utilities library
integration/analysis/
  analysis.qbs                       # Code quality analysis targets (cppcheck, clang-tidy, etc.)

Product Templates

Lib.qbs — Static Library

Every library in src/libs/lib*/ uses the Lib template. Key behaviors:

  • Product type: Tagged as "octomylib" for dependency resolution
  • Dependencies: LibProbe (include paths), GitInfo, BuildOptions, EnabledFeatures, QtDepends, Shaders
  • Include paths: Every library sees headers from all other libraries (flat visibility)
  • Exports: Libraries export cpp and Qt dependencies but deliberately do not export BuildOptions — compiler flags are applied only at the final product level
  • Shader support: GLSL files (.vert, .frag) are automatically compiled to .qsb via the Shaders module
// src/libs/libfoo/lib.qbs
Lib {
    localPath: path
    // Optional: explicit dependencies on other OctoMY libraries
    Depends { name: "libbar" }
}

App.qbs — Application

Applications in src/<appname>/ use the App template:

  • Extends: Application (GUI executable)
  • Dependencies: Same as Lib plus all octomylib-tagged products
  • Linking: Uses --start-group/--end-group linker flags to resolve circular dependencies among static libraries
  • Window icons: Auto-detected from integration/icons/window/
  • Git info: Branch, commit hash, and date baked into defines
  • Install: Installed to usr/local/bin/

Test.qbs — Test Executable

Tests in test/test*/ use the Test template:

  • Extends: QtApplication with Qt Test integration

  • Properties (each one is a line in the suite's test.qbs):

    Property Default Meaning
    category unit What the suite needs to run, and so whether it is built (see below)
    headless true The suite runs on the offscreen platform. project.headlessTestsOnly:true builds only these
    requiresRealPlatform false The suite needs a real platform (xcb, wayland, windows), for example for Vulkan. It falls back to offscreen where there is none, and still runs unattended
    intentionallyOffscreen "" The reason why OpenGL on offscreen is the correct backend for this suite. A non-empty reason exempts it from the backend check
    exclusive false The suite runs alone, after the parallel part of a kiln test --jobs run: it loads every core, measures CPU starvation, binds a fixed port or is a heavy benchmark. A suite that needs a display runs alone without it
    shared false The suite is a member of the shared test binary test/shared/testsLogical, which links every member once; kiln test runs the members as worker processes. Write it as the literal shared: true, because SharedTestProbe reads the text. A member must be headless, must not need a real platform, and its test.qbs holds only its contract and its libraries. Its own program is not built by default (builtByDefault: !shared); qbs build -p <suite> still builds it
    platformSensitive false The suite's result can differ by platform: it tests a platform branch, file paths, processes or IPC, an RHI backend, fonts or packaging. kiln test --profile windows runs only these suites
    guardTest false The suite runs in the lint gate, integration/lint/run-all.sh --with-tests. Test.qbs does not declare it, so a guard suite declares it itself: property bool guardTest: true
    expectedPassed 0 (off) The exact number of passed slots. More or fewer fails the run (§TF-A2.3)
    expectedMinimumPassed 0 (off) A floor, for a suite whose count depends on the host (for example on a display). A suite declares this or expectedPassed, never both
    timeout 60 Seconds. 0 or -1 means none of its own: the runner applies its cap
    extraCondition true An extra build condition, for suites of optional libraries
    requiresHardware false Declared, but no suite sets it. Use the hardware category

    initTestCase and cleanupTestCase count as passed slots.

  • Who reads the properties: the runner reads the test.qbs text, not the qbs build:

    • kiln test runs any set of suites under the full contract, from the .kiln keys that kiln import-qbs makes from these properties. The keys are in the kiln reference.
    • integration/lint/run-all.sh --with-tests runs only the guardTest suites, through kiln test --profile gate.
  • Autotest integration: Registered as autotest type for Qt Creator. The qbs autotest-runner product finds only about 3 of the suites (§WLM-RL-9), so do not use it.

  • Conditional build: Controlled by the TestConfig module (see TestConfig)

Test Categories

Choose the category by what the test REQUIRES, never by what subsystem it belongs to.

Category Built Choose it when the test…
unit yes needs nothing — pure logic, no device, no display, no clock, no network. The default, and the right answer far more often than it is used
integration yes exercises several components together but still runs unattended and headless
widget yes constructs real widgets but runs headless (offscreen platform)
opal yes drives OPAL task plans
interactive no genuinely needs a human, or a real display it cannot get headless
hardware no needs a physical device present (serial, camera, controller)
live_audio no opens a real audio output device and makes sound
live_net no builds network namespaces with libnettest's live tier, which needs root. Run these through the nettest tool under sudo or pkexec
perf no is a benchmark harness whose numbers are meaningless in CI
legacy no is unmaintained and known-broken, kept only for reference

What "not built" actually means

A test in a non-built category is not compiled and not executed. It does not appear as skipped; it does not appear at all. It looks like coverage on a listing and asserts nothing — and because it is never compiled, it can also rot silently as the code around it moves.

The trap: a suite's category is set by its most demanding test

If one test in a file needs a display, the whole suite is interactive and every other test in that file stops running too.

This is not hypothetical. AudioDeviceWatchdog had fifteen tests, one aimed squarely at a defect that later leaked 7,838 threads onto a user's desktop. All fifteen sat in a suite categorised live_audio — because other tests in that file opened a real audio device. The watchdog's own logic is pure arithmetic and would have run anywhere. None of them ran for months.

Split the suite instead. The pure half almost always qualifies as unit.

Disabling a test needs the owner's approval

Moving a test into a non-built category requires explicit approval; moving one back never does. Enforced by integration/lint/check-disabled-tests.py, which baselines the disabled set and fails when it grows. See AGENTS.md.

// test/testFoo/test.qbs
Test {
    localPath: path
    category: "integration"
    timeout: 120
}

Probes

Probes run during the resolve phase to gather information from the host system.

BaseProbe — Directory Scanner

Foundation probe that scans a directory for products by looking for marker files:

Parameter Description Default
searchDir Directory to scan —
fileName Marker file to find —
folderPrefix Strip prefix from folder names ""
definePrefix Prefix for generated defines ""
debug Print discovery results false

Outputs: names[], folders[], files[], defines[]

Specialized Probes

Probe searchDir fileName Purpose
LibProbe src/libs lib.qbs Discovers libraries
AppProbe src app.qbs Discovers applications
TestProbe test test.qbs Discovers tests

QtModuleProbe

Detects which optional Qt modules are installed by searching for CMake config directories. Checks multiple paths: $HOME/Qt, $QTDIR, system paths, Windows paths.

Detected modules: Bluetooth, Positioning, Sensors, SerialPort, WebSockets, WebEngine, PDF, Multimedia

GitInfo

Spawns git processes to extract repository metadata:

Outputs: branch, commit, commitShort, commitDate, isDirty, isMainBranch

Modules

TestConfig

Controls which test categories are built. It has two lists:

Property Default Set it with
categories [], which means every category that is not excluded project.testCategories:'["unit","integration"]'
excludeCategories ["legacy", "interactive", "hardware", "perf", "live_audio", "live_net"] project.testExcludeCategories:'["legacy"]'

The exclusion list wins: a category in both lists is not built. The two project properties, project.testCategories and project.testExcludeCategories, are declared in project.qbs. When project.testExcludeCategories is not set, the exclusion list is the module's read-only defaultExcludeCategories. The same two lists also work as module properties, for example modules.TestConfig.categories:'["unit"]'.

Shaders

Compiles GLSL shader files to Qt Shader Binary format (.qsb):

  • Input extensions: .vert, .frag, .geom, .comp, .tesc, .tese
  • Targets: GLSL 450 + GLSL ES 320
  • Tool: qsb from Qt's bin directory
  • Output: .qsb files automatically included as resources

OctoMYLibs

Exposes library metadata (names, folders, defines) via LibProbe. Used by the aggregator products.

Generated Sources — the generate-then-enumerate two-step

A recurring question when vendoring third-party code: a qbs Rule runs at build time over files that are already enumerated, so how can a rule create the sources that enumeration itself needs? The set is not knowable until the fetch has run.

It can, and entirely at build time — no resolve-time side effects. The mechanism is that qbs rules chain on file tags, not on the files: property, and Rule.outputArtifacts may be computed from an input's content.

Two rules:

// STEP 1 — fetch. Only the MANIFEST is declared as an artifact, because the
// source set is not knowable in advance.
Rule {
    inputs: ["vendorpin"]                       // e.g. a file holding the pin
    Artifact { filePath: "vendor/manifest.txt"; fileTags: ["vendormanifest"] }
    prepare: { /* fetch; write the sources; write manifest.txt naming them */ }
}

// STEP 2 — enumerate. outputArtifacts is derived from the manifest's CONTENT,
// which exists only after step 1 has run. Tagged "cpp", so the ordinary C++
// compile rule consumes them with no further wiring.
Rule {
    inputs: ["vendormanifest"]
    outputFileTags: ["cpp"]
    outputArtifacts: {
        var f = new TextFile(input.filePath, TextFile.ReadOnly);
        var names = f.readAll().split("\n"); f.close();
        var out = [];
        for (var i = 0; i < names.length; ++i) {
            if (names[i].trim() === "") continue;
            out.push({
                filePath: FileInfo.joinPaths(FileInfo.path(input.filePath), names[i].trim()),
                fileTags: ["cpp"]
            });
        }
        return out;
    }
    prepare: { /* no-op: step 1 already wrote the files */ }
}

Verified on qbs 2.6.0 and 3.1.2 with a five-source generator, then re-verified after changing the pin to produce more:

Property Result
First build fetch → enumerate → all sources compiled and archived
Null rebuild no work at all
Pin changed re-fetched, re-enumerated, compiled the new set

So dependency tracking flows correctly through the manifest, and incrementality is preserved.

The enforced floor is qbs 3.3.1, declared in integration/toolchain-requirements.json and checked at resolve.

The floor is not about this pattern — the pattern was proven on 2.6.0 and 3.1.2 and re-proven on 3.3.x, and nothing in it is new API. It is about having one qbs version for the whole project, set to the highest any single target needs. Android set the number: qbs 2.6.1's parallel scheduler SIGSEGVs on the android multi-arch graph (three release legs died there, one of them running alone at a 25% job throttle, which is what ruled out the "out of memory?" the builder guesses in that message). 3.3.1 does not.

Every other platform then runs the same version deliberately, rather than being merely allowed to be older: a per-platform version matrix is how you get a defect only one leg can reproduce. knownBad in that file records what each rejected version actually does, so a mystery segfault has a name.

Building it. There are no official Linux binaries — download.qt.io ships source only, and the GitHub mirror carries no release assets:

curl -sLO https://download.qt.io/official_releases/qbs/3.3.1/qbs-src-3.3.1.tar.gz
cmake -S qbs-src-3.3.1 -B b -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_PREFIX_PATH=<QtDir> -DCMAKE_INSTALL_PREFIX=<prefix> \
  -DCMAKE_INSTALL_RPATH=<QtDir>/lib -DCMAKE_INSTALL_RPATH_USE_LINK_PATH=ON
cmake --build b -j && cmake --install b

Two things that cost time, both measured rather than read:

  • The rpath flags are not optional. Without them the installed binary resolves the distro's Qt and dies with version 'Qt_6.10' not found, which reads like a broken build rather than a link-path problem.
  • qbs 3.3 hard-requires Qt6Core5Compat — a find_package, not an option. On Debian: qt6-5compat-dev to build, libqt6core5compat6 to run. A full Qt install hides this; a lean build image fails configure with Could NOT find Qt6Core5Compat.

The Qt Creator bundle is habitually older than this floor (3.1.2 against a 3.3.1 floor on the dev box), so "use the one Qt shipped" is not a route. Replacing an install also does not carry its profiles over — they are keyed by qbs version, and a cross profile has to be re-created afterwards.

Two gotchas that cost time:

  • require("qbs.X") does not work inside a JavaScriptCommand's sourceCode. Hoist import qbs.File / qbs.FileInfo / qbs.TextFile to the top of the .qbs file instead; the failure is a bare not a function.
  • outputArtifacts is evaluated before the rule's command runs, so it cannot enumerate files that command is about to create. That is precisely why the work splits into two rules rather than one.

Why this beats fetching from a Probe. A probe runs at resolve, so the fetch becomes a side effect of qbs resolve — which runs far more often than a build, has no dependency tracking, and cannot express "re-fetch because the pin changed". The two-step is a normal part of the build graph: ordered, incremental, and cached like anything else.

Bootstrap — a checkout with no vendored sources

The authoritative description of this sequence is the bootstrap block in integration/toolchain-requirements.json. It is reproduced here because a contract that only exists as a comment inside a JSON file is one nobody reads before they need it.

The ordering trap

libllvm enumerates ~2900 sources through relative prefixes, so the vendored tree must exist at resolve time. The tool that fetches it — llvm-fetch — is itself a product of this project, so on a fresh checkout it does not exist yet. A first resolve therefore reports the sources missing, before anything could have built the tool.

That is not a failure. It is the bootstrap case, and the diagnostic names the one command that fixes it.

The sequence

qbs resolve --force-probe-execution      # 1. graph exists; vendored trees reported absent
qbs build -p llvm-source                 # 2. builds llvm-fetch, then runs it
qbs resolve --force-probe-execution      # 3. the probe re-runs and now sees the tree
qbs build                                # 4. ordinary build, LLVM compiled in

Steps 2 and 3 repeat per vendored subsystem you want: llvm-source, llama-source, rl-source. Step 3 may be done once after all of them.

Why step 3 cannot be skipped

A plain qbs resolve is not sufficient. Probe results live in the build graph and cache on properties, not on files, so a probe that once answered "absent" keeps answering "absent" — including immediately after a successful fetch. --force-probe-execution is what re-asks the question.

This is the two-step contract that Generated Sources describes in general: the fetch declares a manifest as its artifact, because the fetched source set is not knowable in advance, and the availability probe only learns the answer on the next forced resolve.

Skipping it entirely

Leaving BUILD_CACHE_DIR unset and never running step 2 is a supported state. The three subsystems resolve as BUILT OUT, OC_NO_LLVM is defined, and the build proceeds without firmware compilation or local inference. On a machine that will never need those, that is the right choice — LLVM alone is ~2900 sources.

The cache

With BUILD_CACHE_DIR set, the fetch tool keys trees at $BUILD_CACHE_DIR/<pinKey>/<commit>[+<overrides-fingerprint>] and materialises them into a checkout by copying. The override fingerprint is part of the key because two different override sets for one commit must not share an entry.

The tool owns that layout; nothing else should recompute the key. Measured 2026-09-08 on the pinned LLVM slice (10,505 files): materializing from a populated cache takes 0.16 s, and a cold fetch runs at the network's own speed — 55 s against a same-hour 57 s curl of the bare archive — because inflate, digest and spool ride the download (vendor-fetch-performance §VFP-D3).


Feature Gating

Features follow a three-level system:

User Intent (project.want*)  →  System Availability (probe)  →  Effective (project.use*)

Flow

  1. project.qbs declares property bool wantBluetooth: true (user wants it)
  2. QtModuleProbe detects whether Qt6Bluetooth CMake config exists
  3. project.qbs computes readonly property bool useBluetooth: wantBluetooth && qtModules.hasBluetooth
  4. EnabledFeatures.qbs emits OC_USE_FEATURE_BLUETOOTH define when useBluetooth is true
  5. QtDepends.qbs conditionally adds Qt.bluetooth dependency when useBluetooth is true

User Override

Per-machine overrides via oc_use.json in project root:

{
    "bluetooth": false,
    "llvm": false
}

Feature Define Namespaces

Prefix Scope
OC_USE_LIB_* Internal OctoMY libraries
OC_USE_LIB_EXT_* External/vendored libraries
OC_USE_FEATURE_* Cross-cutting features
OC_USE_TESTS_* Test-specific features

Build Configuration (BuildOptions.qbs)

Compiler Settings

  • Language: C++20 (GNU extensions), C17
  • RTTI: Disabled (-fno-rtti)
  • Exceptions: Disabled (-fno-exceptions)
  • Position-independent code: Enabled
  • Reproducible builds: Enabled (-ffile-prefix-map)

Build Variants

Setting Debug Release Profiling
Optimization -O0 -O2 -O2
Debug symbols Full None Full
Frame pointers Yes No Yes
LTO No Yes (thin) No
Linker mold lld default
Warnings as errors No No, unless project.treatWarningsAsErrors:true No
Security hardening Yes Yes Yes

Sanitizers

Enabled via project.enableSanitizers:true (debug builds only):

  • AddressSanitizer (ASAN)
  • UndefinedBehaviorSanitizer (UBSAN)
qbs build config:asan profile:qt610 project.enableSanitizers:true qbs.buildVariant:debug --jobs 4 -p <target>
ASAN_OPTIONS=verify_asan_link_order=0 QT_QPA_PLATFORM=offscreen ./asan/<target>.<hash>/<target>

--jobs 4 avoids the instrumented-link OOM kill; a distinct config: value keeps a separate build dir so default/ is untouched. verify_asan_link_order=0 is required for Qt apps.

A few things to know before a run:

  • Leak checks need project.fullTeardown:true. The applications normally exit through std::_Exit, which skips the handler that runs LeakSanitizer's check at exit.
  • The sanitizer builds also define _GLIBCXX_ASSERTIONS. An out-of-range operator[] that lands inside an allocation is reported, which ASan alone cannot see. Container layouts do not change, so the build stays compatible with Qt.
  • Host tools are instrumented too. A tool links instrumented libraries, so it compiles and links with the same -fsanitize= flags.
  • Write reports to files with log_path=<dir>/asan in ASAN_OPTIONS and log_path=<dir>/ubsan in UBSAN_OPTIONS, so a long session does not lose them in the log.
  • Never call LeakSanitizer from a debugger. It does not work under ptrace, and the process aborts.

The campaign that set these up, with its run protocol and its findings, is the construct-stress-hardening specification in the developer documentation, at content/doc/internal/specs/construct-stress-hardening/campaign.md in the source tree.

ThreadSanitizer

Enabled via project.enableThreadSanitizer:true (debug builds only). Detects data races, which AddressSanitizer cannot see. Injects -fsanitize=thread on both compiler and linker.

qbs build config:tsan profile:qt610 project.enableThreadSanitizer:true qbs.buildVariant:debug --jobs 4 -p <target>
TSAN_OPTIONS=second_deadlock_stack=1 QT_QPA_PLATFORM=offscreen ./tsan/<target>.<hash>/<target>

Mutually exclusive with enableSanitizers (ASAN), enableCoverage and enableValgrind — TSan and ASan instrument the same memory accesses and cannot coexist in one binary. Setting enableThreadSanitizer:true together with any of them fails the resolve with an explicit error rather than producing a broken binary.

Split DWARF

project.splitDwarf is true by default since 2026-09-14. Every debug compile gets -gsplit-dwarf, and the debug information goes into a .dwo beside the object instead of being copied into every binary that links it.

Turning it on or off costs the lane one cold rebuild. The flag is part of the ccache hash key, so ccache cannot absorb the change. Change it with a resolve, never with a command-line flag override:

qbs resolve project.splitDwarf:false      # correct
qbs build modules.cpp.cxxFlags:...        # fails: "Property values set on
                                          # the command line differ"

Do not check the flag by counting .dwo files. A .dwo is not a declared artifact, so nothing ever deletes it. A directory that built with the flag once keeps every .dwo afterwards, and the count then reports a flag that is off. Reading DW_AT_producer in the object is wrong the other way, because the flag moves the producer string into the .dwo.

Check a section name instead. Split DWARF must leave .debug_addr in the object, and a plain -g object has none:

readelf -S --wide X.o | grep -q '\.debug_addr' && echo SPLIT || echo FULL

Security Hardening

Always enabled:

  • -fstack-protector-strong
  • -D_FORTIFY_SOURCE=2
  • Full RELRO
  • Stack clash protection

Dependency Architecture

Static Library Linking

All 80+ libraries are linked as static archives. Because static libraries have circular dependencies, the linker uses --start-group/--end-group to resolve symbols across all archives:

gcc ... -Wl,--start-group -lutil -lcomms -lsec ... -Wl,--end-group

Include Path Strategy

Every library and application gets include paths to all library directories via LibProbe:

cpp.includePaths: base.concat(allLibs.folders, [vulkanHeaders])

This means any source file can #include headers from any library without explicit dependency declarations. While convenient, this creates an N² include relationship (see Known Issues).

Product Dependency Resolution

Applications and tests depend on all octomylib-tagged products:

Depends { productTypes: ["octomylib"] }

This ensures all libraries are built before any application or test that needs them.

Analysis Integration

Qbs targets for code quality tools:

qbs build -p analyze-cppcheck      # Static analysis
qbs build -p analyze-clang-tidy    # Clang linter
qbs build -p analyze-scan-build    # Clang static analyzer
qbs build -p analyze-valgrind      # Memory error detection
qbs build -p analyze               # cppcheck + clang-tidy
qbs build -p analyze-all           # All tools

Reports are written to .analysis/<tool>/report_<timestamp>.*

CI/CD Build Parameters

Parameters exposed for CI pipelines:

# Treat warnings as errors in a release build (default: false)
qbs build project.treatWarningsAsErrors:true

# Parallel build jobs (default: "100%")
qbs build project.buildJobs:"50%"

# Enable sanitizers (default: false)
qbs build project.enableSanitizers:true

# Enable ThreadSanitizer (default: false; mutually exclusive with the above)
qbs build project.enableThreadSanitizer:true

# Enable LTO (default: true in release)
qbs build project.enableLto:true

# Use mold linker for debug (default: true on Linux)
qbs build project.useMoldLinker:true

# Select test categories (see TestConfig)
qbs build project.testCategories:'["unit","integration"]'
qbs build project.testExcludeCategories:'["legacy","interactive","hardware"]'

# The kind of test run after the build (default: "ci"); the build does not read it
qbs build project.testProfile:nightly

project.testProfile is the build form's choice of kiln test profile. The farm passes it to kiln test --options build-request.json. See The build request.

Known Issues and Improvement Opportunities

1. Excessive Memory During Resolve Phase (~16 GB)

The resolve phase consumes over 16 GB of RAM, which stalls builds on machines with limited memory.

Root causes:

Redundant LibProbe Execution

LibProbe is instantiated independently inside every product template (Lib, App, Test). With 304 total products (79 libs + 10 apps + 215 tests), the probe runs 304 times, each time scanning src/libs/ for 79 marker files.

  • Impact: 304 probes × 79 checks = 24,016 redundant filesystem calls
  • Files: Lib.qbs:18-21, App.qbs:12-15, Test.qbs:134-137

Possible fix: Move LibProbe to a single project-level probe in project.qbs and pass its results to products via a module property, or use the existing OctoMYLibs module which already wraps LibProbe (currently unused by the templates).

Redundant GitInfo Execution

GitInfo spawns 6+ git processes per product (304 × 6 = 1,824 git invocations). Since git metadata is identical for every product in a single build, this is pure waste.

  • Impact: 1,824 redundant process spawns
  • Files: Lib.qbs:24-26, App.qbs:18-20, Test.qbs:140-142

Possible fix: Run GitInfo once at project level and propagate results via module properties or project-level properties.

N² Include Path Relationships

Every library product receives include paths to all 79 other library directories. This creates 79 × 79 = 6,241 include path entries in the dependency graph, plus 225 × 79 = 17,775 more for apps and tests.

  • Impact: ~24,000 include path entries cached in the property graph
  • Files: Lib.qbs:66, App.qbs:73, Test.qbs:156

Possible fix: Declare explicit per-library include dependencies instead of flat global visibility. This requires adding explicit Depends declarations between libraries but reduces the include graph dramatically.

2. All-Libraries Dependency in Apps and Tests

Depends { productTypes: ["octomylib"] } causes every application and test to link against all 80+ static libraries, even if only a few are actually needed.

Impact: Slower linking, larger binaries, and more work during the resolve phase.

Possible fix: Replace blanket productTypes dependencies with explicit library lists per application. The --start-group/--end-group linker flags already handle circular deps, so unused libraries would simply be ignored by the linker — but Qbs still must resolve and schedule them all.

3. File Enumeration via Glob Patterns

The Files.qbs base template uses broad glob patterns (/**/*.cpp, /**/*.hpp, etc.) that scan the entire source tree per product. With exclusion patterns for internal/, shared/octomy/, etc., each product performs a full tree walk.

Impact: Moderate — filesystem caching mitigates repeated walks, but 304 products still generate substantial resolve-time work.

4. No Incremental Resolve

Qbs does support incremental resolve (reusing a saved build graph), but the initial resolve from a clean state requires holding the entire property graph in memory. For projects of this scale, the graph exceeds what modest machines can handle.

Possible fix: Maintain a build graph cache and avoid full re-resolve. qbs resolve caches into the build directory — ensure CI and developer workflows preserve the canonical build directory (<repo>/default/) between runs.

Summary of Optimization Priorities

Priority Issue Estimated Savings Effort
P0 Deduplicate LibProbe (304 → 1) 2-3 GB Low
P0 Deduplicate GitInfo (304 → 1) 0.5-1 GB Low
P1 Explicit library include deps 3-4 GB Medium-High
P1 Per-app library deps 1-2 GB link time Medium
P2 File enumeration optimization 0.5-1 GB Medium
— Preserve build graph cache Avoids full re-resolve Workflow change
In this section
Topics
reference build-system qbs project-configuration development
See also