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
.kilnfiles, read by kiln. Today each product's.kilnis generated from its.qbswhen kiln builds, so.qbsstays the file to edit, and qbs stays the default build until it retires: thenkiln import-qbs --finalcommits every product's.kilnand the.qbsfiles 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 plainqbs resolveis 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 asundefined symbolat 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 onmainlinked 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, inlibconstruct, 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 existand noerror:line anywhere. That reads like a bad merge or a corrupt graph, and it is neither.--check-timestampsdoes 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 inlibres, 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 buildin base linkedagent,huband 13 more against alibpuppetobject from two days earlier:undefined symbol: Personality::IRIS, afterIRISbecame an inlinestatic constexprmember. The object predated the header, the header is included directly, andlibpuppetdeclaresDependsonlibid.qbs build --check-timestamps -p <the 15>recreated the archive and still did not recompile the object. Atouchof 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 isintegration/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-timestampsignores changed flags, and remembers ignoring them. After a resolve that changes a file'scpp.definesor other compiler flags, a plainqbs buildrecompiles it.qbs build --check-timestampsdoes 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 isqbs cleaned.--check-outputson 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 frozeBuildInfo.cpp, so a package reported the commit of the previous day. Use--check-outputsfor normal builds, and--check-timestampsonly for a graph that believes deleted outputs still exist. A tree built with it after a rebase needs aqbs cleanbefore its test results mean anything. -
After a long fast-forward, a build can report a CYCLIC DEPENDENCY that does not exist. The first
qbs buildafter the tree jumps a long way re-resolves the old graph incrementally, and it can stop withERROR: Cyclic dependencies detected:naming two products and aDepends.productTypesedge. There is no cycle.qbs resolve --force-probe-executionon the same tree exits 0. Two tells separate this from a real structural cycle: the second product named is the same every time (anonswarmin every sighting so far, because it fans out overproductTypes: ["octomylib"]), while the first rotates between lanes and runs. A real cycle names the same pair every time. (Found 2026-08-25 aslibfirmware <-> anonswarm, again 2026-10-01 aslibeval <-> anonswarmafter agit merge --ff-onlythrough 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, soconfig:debugpassing says nothing about the lane'sdefaultgraph. -
After adding cross-library
#includes: runqbs run -p analyze-depsthenqbs 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-depsFIRST (it scans sources and needs no resolve), THENqbs 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 withfatal error: <dep>/<header>.hpp: No such file or directoryon an include that plainly exists. (Found 2026-09-10:libheaderresolved beforeanalyze-depswrote itslibactivityedge, 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 (newsrc/tools/<name>/tool.qbs):LibProbe/ToolProbediscovery is cached too — runqbs 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 aqbs resolveand then STICKS in the build graph, where a forgotten environment prefix silently fell back to the default:Was Now BUILD_CACHE_DIRproject.buildCacheDir— the shared cache of vendored LLVM/llama/rl treesOCTOMY_WIN_OPENSSL_ROOTproject.winOpensslRootOCTOMY_WIN_ZLIB_ROOTproject.winZlibRoot— empty means "use Qt's own zlib", which every measured kit hasANDROID_OPENSSL_ROOTproject.androidOpenSslRootANDROID_SDK_ROOT/ANDROID_HOMEthe android profile's own SDK dir, which qbs setup-androidwroteOCTOMY_TEST_HEADLESSproject.headlessTestsOnlyqbs resolve project.buildCacheDir:/build/sharedThe one environment read the build keeps is a child's
PATHon Windows, which has no rpath, inintegration/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. WhenNfires 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
cppandQtdependencies but deliberately do not exportBuildOptions— 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-grouplinker 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:
QtApplicationwith Qt Test integration -
Properties (each one is a line in the suite's
test.qbs):Property Default Meaning categoryunitWhat the suite needs to run, and so whether it is built (see below) headlesstrueThe suite runs on the offscreenplatform.project.headlessTestsOnly:truebuilds only theserequiresRealPlatformfalseThe suite needs a real platform (xcb, wayland, windows), for example for Vulkan. It falls back to offscreenwhere there is none, and still runs unattendedintentionallyOffscreen""The reason why OpenGL on offscreenis the correct backend for this suite. A non-empty reason exempts it from the backend checkexclusivefalseThe suite runs alone, after the parallel part of a kiln test --jobsrun: 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 itsharedfalseThe suite is a member of the shared test binary test/shared/testsLogical, which links every member once;kiln testruns the members as worker processes. Write it as the literalshared: true, becauseSharedTestProbereads the text. A member must be headless, must not need a real platform, and itstest.qbsholds only its contract and its libraries. Its own program is not built by default (builtByDefault: !shared);qbs build -p <suite>still builds itplatformSensitivefalseThe 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 windowsruns only these suitesguardTestfalseThe suite runs in the lint gate, integration/lint/run-all.sh --with-tests.Test.qbsdoes not declare it, so a guard suite declares it itself:property bool guardTest: trueexpectedPassed0(off)The exact number of passed slots. More or fewer fails the run (§TF-A2.3) expectedMinimumPassed0(off)A floor, for a suite whose count depends on the host (for example on a display). A suite declares this or expectedPassed, never bothtimeout60Seconds. 0or-1means none of its own: the runner applies its capextraConditiontrueAn extra build condition, for suites of optional libraries requiresHardwarefalseDeclared, but no suite sets it. Use the hardwarecategoryinitTestCaseandcleanupTestCasecount as passed slots. -
Who reads the properties: the runner reads the
test.qbstext, not the qbs build:kiln testruns any set of suites under the full contract, from the.kilnkeys thatkiln import-qbsmakes from these properties. The keys are in the kiln reference.integration/lint/run-all.sh --with-testsruns only theguardTestsuites, throughkiln test --profile gate.
-
Autotest integration: Registered as
autotesttype for Qt Creator. The qbsautotest-runnerproduct finds only about 3 of the suites (§WLM-RL-9), so do not use it. -
Conditional build: Controlled by the
TestConfigmodule (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:
qsbfrom Qt's bin directory - Output:
.qsbfiles 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-devto build,libqt6core5compat6to run. A full Qt install hides this; a lean build image fails configure withCould 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 aJavaScriptCommand'ssourceCode. Hoistimport qbs.File/qbs.FileInfo/qbs.TextFileto the top of the.qbsfile instead; the failure is a barenot a function.outputArtifactsis 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
- project.qbs declares
property bool wantBluetooth: true(user wants it) - QtModuleProbe detects whether
Qt6BluetoothCMake config exists - project.qbs computes
readonly property bool useBluetooth: wantBluetooth && qtModules.hasBluetooth - EnabledFeatures.qbs emits
OC_USE_FEATURE_BLUETOOTHdefine whenuseBluetoothis true - QtDepends.qbs conditionally adds
Qt.bluetoothdependency whenuseBluetoothis 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 throughstd::_Exit, which skips the handler that runs LeakSanitizer's check at exit. - The sanitizer builds also define
_GLIBCXX_ASSERTIONS. An out-of-rangeoperator[]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>/asaninASAN_OPTIONSandlog_path=<dir>/ubsaninUBSAN_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 |