Dependency Resolution
{carrier} internals explanations
This article covers how {carrier} resolves dependencies internally, walking through two Rust functions: cran::client::install_packages for R package dependencies, and ops::module_graph::resolve_transitive for carrier-installed module dependencies. Specifically: what runs when carrier install --install-deps resolves and installs R packages, and what runs when a module’s module_deps gets walked transitively.
Grouping and fetch
Requested packages are grouped by repo first, so each repo’s PACKAGES.gz index gets fetched exactly once per carrier install run, no matter how many packages come from it. The fetch retries: three attempts, exponential backoff (2^attempt seconds) between them, on both non-2xx responses and request failures.
Graph walk
For each repo group, resolve_install_set walks the dependency graph breadth-first starting from the requested packages:
- Every requested package’s version spec is queued.
- For each package popped off the queue, its record is looked up in the fetched index. A package missing from the index just gets skipped with a warning printed, not a hard failure, and anything that only that package would have pulled in gets skipped along with it.
- Its
Imports/Dependsfields are parsed into further (package, spec) pairs, base and recommended R packages filtered out, and pushed onto the same queue.
Cross-repo consistency
A package already resolved by an earlier repo group in the same run doesn’t get resolved a second time. Its already-chosen version is checked against the current repo’s constraints for that package instead: reused as-is if it satisfies them, or the whole install fails loudly naming both repos if it doesn’t. That’s what stops a package pulled in transitively from two different repos from silently landing on two different versions depending on which repo happened to get processed first.
Version selection
For each package, the index’s listed version is tried against all collected specs first. If nothing satisfies them, fetch_archive_versions scrapes CRAN’s Archive/<pkg>/ HTML directory listing (a plain substring scan for href="...", not a full HTML parser) for older versions and retries resolution against that list.
Install order and installation
Resolved packages install in dependency order, a straightforward DFS-based topological sort over the resolved set. For each one, an existing install with a matching version already in the target library is left alone. Anything else gets (re)installed.
Installation prefers a binary archive over building from source, but what counts as “a binary is available” differs by platform, and by what repo you’ve configured.
Where the binary URL comes from
- If you are on Windows,
{carrier}tries to fetch atbin/windows/contrib/<r-version>/<pkg>_<version>.zip, against whatever repo is configured. CRAN’s default mirror has always served this. - If you are on MacOS,
{carrier}then tries to fetch atbin/macosx/<macos-arch>/contrib/<r-version>/<pkg>_<version>.tgz, also against the configured repo directly, no special repo required.<macos-arch>isbig-sur-arm64orbig-sur-x86_64, picked fromR.version$arch. This is deliberately not the flatbin/macosx/contrib/path some mirrors still serve: that legacy path is x86_64-only, and using it unconditionally hands an Intel binary to an Apple Silicon machine, which only fails later atdlopen()time, not at install time. An architecturebinary_url_fordoesn’t recognize skips the binary attempt entirely and goes straight to source, with a warning. - If you work on some Linux distros, it only works against a Package Manager-style repo (Posit Package Manager, formerly RSPM), and only when a distro codename could be detected from
/etc/os-release’sVERSION_CODENAMEfield (Ubuntu and Debian set this; RHEL and openSUSE generally don’t). When both hold, the URL is<repo>/bin/linux/<distro>-<arch>/<r-version>/src/contrib/<pkg>_<version>.tar.gz. CRAN’s default mirror has no such tree at all, so on a plaincloud.r-project.orgrepo, or an unrecognized distro, this step never even fires. There’s no dependency oninstall.packages()’s usual user-agent sniffing here: distro, architecture, and R version are all spelled out in the URL itself, which is exactly why this scheme was picked over Package Manager’s other__linux__/<distro>URL format.
Verifying what came back
A downloaded binary archive is only trusted if it contains Meta/package.rds. A real, pre-built package always has it — a source tree never does. This catches a source tarball served where a binary was expected, or a truncated download, before either is ever unpacked into the library.
Every compiled library inside the archive (.so, .dll, .dylib) gets its actual architecture read straight out of its own header (ELF, PE, or Mach-O respectively) and checked against what the current machine needs. This isn’t redundant with picking the right URL: Package Manager has been observed returning HTTP 200 for an architecture-tagged URL even when the file it serves doesn’t match, instead of the clean 404 a plain CRAN mirror gives for the same kind of miss. A URL-only check would silently accept that.
Falling back to source
<repo>/src/contrib/<pkg>_<version>.tar.gz, then Archive/<pkg>/<pkg>_<version>.tar.gz on a 404, then <pkg>_<version>.9000.tar.gz (CRAN’s dev-version convention) on a second 404. Whichever tarball comes back gets handed to R CMD INSTALL --no-multiarch --no-docs --no-help.
A failed install of a directly requested package aborts the whole run. A failed install of a package that only showed up as a transitive dependency gets logged as a warning, and the run continues.
Archive extraction safety
Both the .zip and .tar.gz binary-extraction paths sanitize every entry path before writing anything to disk. Absolute paths and any .. component get rejected outright, and on Unix, a symlink target gets dropped if it’s absolute or contains a parent-directory component. Extraction always lands in a temp staging directory first. Only after Meta/package.rds is confirmed does the staged package get moved into the real library, via a rename, falling back to a recursive copy if staging and the library happen to live on different filesystems.
Module dependency graph
carrier-installed modules (module_deps in carrier.toml) resolve through a completely separate path from R packages: resolve_transitive in ops::module_graph. It shares no code with the CRAN resolution above. No repo grouping, no binary/source install step, no CRAN index. A module dependency gets fetched as a whole carrier.toml from its declared source (see carrier.toml), not resolved against a package index.
Why this walk is DFS, not BFS
The graph is walked depth-first on purpose, keeping an explicit ancestor path: the chain of module names from the root down to whatever’s currently being resolved. This isn’t an implementation detail you could swap out. A “have I seen this module name before” set on its own can’t tell a real dependency cycle (A depends on B depends on A) apart from an ordinary diamond dependency (two modules both depending on the same third module). Both just look like “this name showed up twice.” Only ancestry, whether the name is still an open call on the current path versus already finished and closed off, tells them apart. A queue-based (BFS) walk has no notion of ancestry, only of what’s already been dequeued. Only a call stack, or an explicit path Vec standing in for one, gives you that.
Resolution steps, per module dependency
- If the module name is already fully resolved, from an earlier branch of the walk, a diamond, its resolved version and source get checked for consistency against the current dependent’s constraint instead of being fetched again. A mismatched
sourceor an incompatible version constraint fails loudly, and will name both requesters. - If you have a module name that is already on the current ancestor path, that’s a real cycle. The walk fails with the full chain (e.g.
a -> b -> a) instead of looping forever. - If ever you have module dependencies carried by
{carrier}and it is written undermodule_depsentry with nosource, the resolution fails immediately as of current version, since there’s no default module registry to fall back to (unlikepackage_deps, which defaults to CRAN). - Otherwise, the dependency’s
carrier.tomlgets fetched from itssource. Its declaredmodule.versionis checked against the requesting constraint, its ownpackage_depsfold into the same package-resolution set used everywhere else, and its ownmodule_depsget recursed into.
What fetches a module
ModuleFetcher is a small trait. resolve_transitive() only knows how to walk the graph, not how to fetch a carrier.toml. The production implementation, GitHubFetcher, understands gh:user/repo sources (optionally pinned via gh:user/repo/tree/<ref>/<subpath>, see CLI Commands) and requires the network feature, same as carrier install’s own gh: handling.
resolve_transitive() exists and is tested, but carrier lock doesn’t call it yet. The current carrier lock uses the shallow, single-level resolve(), which doesn’t fetch anything for module_deps. Transitive module resolution isn’t wired into any CLI command yet.