Stateful tools
How to integrate conan, pip/venv, cargo, sbt, maven and similar tools into an open-lmake flow.
open-lmake's model: a job's output is a pure function of the file contents it reads.
Package managers and some build tools violate this by design: they keep internal databases (often sqlite),
out-of-repo homes (~/.conan2, ~/.cargo, ~/.ivy2),
and decide what exists by consulting that state instead of the filesystem.
Autodep can trace file reads and writes, but it cannot assign meaning to "row updated inside a shared sqlite file" —
so shared mutable homes are fundamentally at odds with tracking, caching and multi-user soundness.
First, answer: who guarantees what?
Before picking a strategy, decide who owns each piece of tool state — the answer dictates everything:
- The tool owns its state: you trust it unconditionally (its cache/DB is correct whatever happens around it, including concurrent use).
Then
ignoreits whole state area and accept the consequences in full (level 3 below): open-lmake cannot help you inside that zone, and the shared cache must be off for every rule that reads it. - open-lmake owns the state: then every file in the zone is a job output like any other —
it must be possible to
lmakeany file there unambiguously, with exactly one official producer per file.
The unsound middle ground — half-declared state, rules stepping on each other's targets — is the only truly bad choice. A layered scheme (e.g. an install rule at higher prio and a build rule below it, both covering the same tree with star targets) is legitimate as long as no rule ever produces a file whose official producer is another rule.
There is a ladder of integration strategies, from best to last-resort. Always start at the top.
Level 0 — do not run the tool at build time
Vendor the dependency (submodule, subrepo) or fetch it with a plain rule:
one artifact = one rule = one target, pinned by version + checksum verified in cmd.
The package manager runs outside the build (to compute a lockfile, which is then a source);
the build itself only consumes pinned artifacts.
This is the only level with full cache soundness and zero caveats.
Prefer it whenever the dependency count is manageable.
Level 1 — confine the tool's whole state inside the repo, one rule owns it
Give the tool a home that is a target tree of exactly ONE rule.
The conan unit test is instructive precisely because it splits the tree across two rules
and its driver asserts the resulting failure (done=1, failed=1, rc=1):
class Pip(Rule) : # succeeds: owns the whole tree
targets = { 'PIP' : 'venv/bin/pip' , 'VENV' : r'venv/{*:.*}' }
readdir_ok = True # pip insists on scanning dirs
cmd = 'python3 -m venv venv'
class Conan(Rule) : # FAILS, and the test asserts it:
targets = { 'CONAN' : 'venv/bin/conan' } # pip install writes throughout
deps = { 'PIP' : 'venv/bin/pip' } # venv/ (lib, bin, ...) — all
readdir_ok = True # undeclared here, and the tree
cmd = 'venv/bin/python3 -m pip install conan' # already belongs to VENV above
The working shape is one rule per tool venv, owning the whole tree — creation and install in the same job:
class ConanVenv(Rule) :
targets = { 'CONAN' : 'conan-venv/bin/conan'
, 'VENV' : r'conan-venv/{*:.*}' }
environ = { 'PIP_NO_CACHE_DIR' : '1' }
readdir_ok = True
cmd = '''
python3 -m venv conan-venv
conan-venv/bin/pip install conan==<version> # pin it (hardcoded, or read from a dep file)
'''
Key ingredients:
- the entire tree is a star target (
.../{*:.*}) of exactly one job — no other job writes there, soreaddir_okon it is defensible; - the tool's own cache is disabled (
PIP_NO_CACHE_DIR=1— pip actually disables its cache for any value of this variable) or pointed into the tree or$TMPDIR, so no state escapes; - downstream rules depend on concrete files in the tree (
.../bin/conan).
Cost: installing per-repo (not per-machine). That is the price of correctness, and the shared cache gives most of it back (the install job itself becomes a cache hit for every user).
Level 2 — declared incremental scratch (the cargo pattern)
For tools with a valuable warm cache (incremental compilers), declare the tool's scratch tree as an incremental side/star target:
it is not wiped between runs, the tool reuses it, and open-lmake still records everything.
From the cargo unit test:
class CompileRust(HomelessRule,RustRule) : # excerpt; test-harness lines omitted
targets = { 'EXE' : r'{Dir:.+/|}{Module:[^/]+}/target/debug/{Module}' }
side_targets = { 'SCRATCHPAD' : ( r'{Dir}{Module}/{*:.*}' , 'Incremental' ) }
deps = { 'PKG' : '{Dir}{Module}/Cargo.toml' , 'SRC' : '{Dir}{Module}/src/main.rs' }
stderr_ok = True
cmd = 'cd {Dir}{Module} ; cargo build'
Constraints for this to be sound:
- one job owns the scratch tree (patterns must not overlap between jobs);
incrementalis a promise: from ANY prior scratch state, the tool must produce a correct result (true for well-behaved incremental compilers, false for tools whose DB can go stale or corrupt — judge per tool);- the home and env of the tool must still be confined (e.g.
RUSTUP_HOMEpointed at a controlled location,HomelessRuleto shield$HOME).
Level 3 — shared home + ignore (last resort, document the debt)
Pointing the tool at a shared home and flagging it ('home/{*:.*}', 'ignore') as a side_dep/side_target makes the errors disappear — and with them, correctness:
- jobs read state open-lmake does not record → identical tracked inputs can yield different outputs;
- the multi-user / shared cache becomes unsound for every rule touching the home
(a hit computed by user A may be wrong for user B) — disable
cacheon these rules; - concurrent jobs race on the tool's internal DB → you must add manual locking (flock around every invocation; sqlite "database is locked" retries are the smell of this) because open-lmake cannot serialize accesses it does not see;
- rebuild-from-clean is no longer guaranteed to reproduce.
If forced here (tool has no way to confine state, dependency set too large for level 0/1): keep the ignored zone as small as possible, wrap every tool invocation in a lock, disable the shared cache for affected rules, and leave a comment marking this as a known unsoundness with the intended exit (usually level 0: pre-materialize packages from a lockfile into tracked paths).
Per-tool quick notes
- pip/venv: level 1 works well (venv-as-star-target).
Disable the pip cache (
PIP_NO_CACHE_DIR=1, or pointPIP_CACHE_DIRinto$TMPDIR), unsetPYTHONPATH/VIRTUAL_ENVinherited assumptions, pin the interpreter. A venv cannot inherit another venv's packages (interpreters chain via symlinks, site-packages do not) — one venv per tool, each installed by one rule. Spell canonical names in deps: a venv may create alib64 -> libsymlink, and names spelled through it are non-buildable by the up-hill rule (see the FAQ). - conan (v2): internally sqlite-backed; a shared
CONAN_HOMEis level 3 with all its debt. Prefer level 0: resolve outside the build to a lockfile, then one rule per package materializing from the lock into tracked paths; or level 1 with a per-repo home built by a single rule chain (profiles → install → build), accepting the serialization through that chain. - cargo: level 2 is proven (see above). Confine
RUSTUP_HOME/CARGO_HOME. - sbt/ivy/coursier, maven: default homes under
~; run underHomelessRuleand confine caches into the repo or$TMPDIR. Resolution is network-bound → push it to level 0 (fetch rules per artifact, checksummed) and keep compilation as plain rules. - Any tool with a daemon (gradle, sbt server, bazel): disable the daemon;
a background process outliving the job is untracked state by definition (
kill_daemonsexists for the ill-behaved ones).
Rule of thumb
If the tool decides what to do by consulting anything other than the files the job reads, either feed it a fresh, controlled state (levels 0-1), own its scratch explicitly (level 2), or accept and fence the unsoundness (level 3). Never level 3 by default, and never level 3 combined with a shared cache.