Repository navigation
Production-harden loader, multi-package dependency tracking, exhaustive tests - #15
Conversation
…ve tests
Loader (R/loadfast.R):
- Track cross-package dependencies: reloading a package flags every cached
package that (transitively) imports it; the flagged package's next
load_fast() auto-upgrades to a full reload, and full loads recursively
reload flagged dependencies first, so a chain C -> B -> A converges with a
single load_fast(C) after A is reloaded. No-change loads do not invalidate.
- Attach DESCRIPTION Depends packages during full loads (with a clear error
when one is missing), matching library()/load_all() semantics.
- Run .onUnload best-effort when unloadNamespace() refuses (dependency
imported by other loaded packages) before force-replacing the registry
entry.
- Clean up half-built namespaces when a full load fails partway (unregister
and detach), while keeping failed-load recovery on the next call.
- Detach/unload the old identity when the Package field changes in place for
a cached path.
- Re-source incremental changes in Collate-aware full-load order instead of
alphabetical basename order.
- Seed the namespace spec version from DESCRIPTION so
getNamespaceVersion() reports correctly.
- Replace the boolean re-entrance flag with a path stack (public load_fast()
stays strict; internal recursion is allowed).
- Replace library("testthat") with a guarded attachNamespace(), inline the
tiny methods::: metadata helpers (.hasS4MetaData/.getGenerics/
.TableMetaPrefix equivalents), move the top-level message to .onAttach,
and make the sources ASCII-only -- R CMD check --as-cran is now clean
(0 errors, 0 warnings; only the inherent attach() NOTE remains).
Packaging:
- DESCRIPTION: version 1.0.0, declare methods/tools in Imports, move dev
dependencies from the non-standard DevDeps field to Suggests.
- roxygen2-generated NAMESPACE and man/ pages for both exports.
- New tests/testthat suite that runs against the installed package during
R CMD check (smoke, incremental, dependency invalidation, error paths).
- .Rbuildignore covers all non-package files; renv settings track Suggests.
Tests (test_loadfast.R, 299 checks total):
- Stage 6: multi-package dependency invalidation -- direct importer
invalidation, transitive chain convergence, no-change loads, whole-
namespace import(), S4 class redefinition across packages, forced
unregister path incl. .onUnload.
- Stage 7: production hardening -- Depends attachment and failure, rename-
in-place cleanup, failed-full-load cleanup and recovery, namespace version,
Collate order on incremental reload, re-entrance guard, verbose logs.
CI: harness now also runs on macOS; new R CMD check --as-cran job on
Linux/Windows/macOS (R release) plus oldrel-1 on Linux.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
…ix CI installs rlang::ns_registry_env() is defunct as of rlang 1.2.0 and hard-errors, which broke every R CMD check CI job on fresh installs (the renv.lock pinned an old rlang, masking it locally). Replace it with .loadfast.ns_register() / .loadfast.ns_unregister(), which construct .Internal(registerNamespace(...)) / .Internal(unregisterNamespace(...)) calls at runtime -- the same approach current pkgload uses. loadfast now has no third-party runtime dependencies; rlang moves to Suggests (still imported by the devpackage test fixture, which now imports the stable rlang::abort instead of the defunct symbol). The harness CI jobs failed separately during renv::restore(): the lockfile drags in dev-machine packages (keyring needs libsecret on ubuntu; a package failed to resolve on macos). Switch those jobs to setup-r-dependencies, which installs exactly the DESCRIPTION Imports+Suggests the harness needs and handles system requirements; renv.lock remains for local development. Verified locally: harness 299/299, R CMD check --as-cran clean (2 expected NOTEs), testthat suite passes under check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
normalizePath(mustWork = TRUE) wording differs by OS: "No such file or directory" on unix vs "The system cannot find the file specified" on Windows. Broaden the two expected-error patterns accordingly; this was the only remaining CI failure (Windows R CMD check). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
User test: built a fresh 3-package stack and ran it against
|
| Behaviour | main |
This PR |
|---|---|---|
Baseline cross-package access (importFrom, import(), ::, S3, S4, R6) |
pass | pass |
| Direct importer picks up a dependency edit | stale — prints No changes in tsstats/R., returns stats-on-v1 |
Full reload of 'tsstats': dependency 'tsdata' was reloaded. → stats-on-v2 |
Transitive importer (tsreport) picks up the edit |
stale | correct |
| S4 method redefinition visible in importer | stale (3 kg, should be 3 kg!) |
correct |
getNamespaceVersion() |
0.0.0 |
0.1.0 (matches DESCRIPTION) |
| Failed full load leaves the namespace registered? | yes — tsbroken stays in loadedNamespaces() |
no, cleaned up |
Rename Package: in place |
both old and new on search() |
old detached, new attached |
Depends: on a missing package |
silently ignored | clear error naming the package |
Collate-order on incremental reload |
alphabetical (wrong value: 30) | Collate order (correct: 150) |
One nuance worth recording, because it explains why main doesn't fail every cross-package test:
staleness on main depends on how the importer reaches the dependency. tsdata::series_scale(...)
resolves live through the registry, so an edit is picked up even without reloading the importer;
an importFrom-imported value like DATA_LAYER_VERSION is a snapshot and goes stale. So on main
the failure is intermittent by access style, which is the worst kind. This PR removes that.
Claims I verified independently
- "No-change loads do not invalidate dependents" — confirmed.
load_fast(tsdata)with no edits leavestsstatson the cheapNo changespath. - "A chain
C -> B -> Aconverges with a singleload_fast(C)" — confirmed. After editing and reloadingtsdata, oneload_fast(tsreport)emitsReloading dependency 'tsstats' first: ...and lands on the right value. - The flag is one-shot — first call after a dep edit is a forced full reload; the second and third are back to
No changes. No sticky full-reload penalty. - Cascade is conservative — I broke
tsdataon disk after flagging its dependents, then reloaded from the top. The cascade rebuildstsstats/tsreportagainst the loadedtsdatanamespace and never re-sources the package I didn't ask it to reload. Good call. - Lifecycle hooks —
.onUnloadthen.onLoadfire correctly for the importer during a forced full reload. load_fast_register_reload()still works after a forced full reload.- Test suites — harness:
299 passed, 0 failedon this branch vs259 passed, 0 failedonmain. The newtests/testthat/suite runs underR CMD check:[ FAIL 0 | WARN 0 | SKIP 0 | PASS 24 ]. R CMD check --as-cran— the only inherent NOTE is theattach()call, as stated. (My run also showed notes/warnings for missing pandoc, no LaTeX, no CRAN index and an unavailable locale — all sandbox artifacts, not the package.)- Base-R-only runtime — I installed the built package into a library containing only
loadfastandR6, with norlanganywhere on.libPaths(). The full stack loads and every cross-package feature works.
The most important fix here isn't in the PR description
main calls rlang::ns_registry_env(), which rlang gates on the R version:
# rlang/R/env-special.R
ns_registry_env <- function() {
if (getRversion() >= "4.6") {
deprecate_stop("`ns_registry_env()` is defunct.")
}
.Call(ffi_ns_registry_env)
}So on R >= 4.6 with rlang >= 1.2.0, main's load_fast() fails on every full load.
I verified this rather than inferring it: I rebuilt rlang with only that version gate flipped
(>= "4.6" -> >= "4.3"), so the function takes exactly the branch it would take on a real R 4.6,
and ran main against it:
ns_registry_env(): STOPS -> `ns_registry_env()` is defunct.
load_fast(): ERROR -> `ns_registry_env()` is defunct.
The same run on this branch works, because commit e973af6 replaced it with runtime .Internal()
calls. That's a "loadfast stops working entirely on the next R release" fix, currently buried in the
second commit's message. I'd promote it to the top of the PR description and add a NEWS entry —
it's the strongest reason to merge, and users on main will want to know.
What the new correctness costs
Synthetic package: 401 R files, 10,002 functions, 202 exports (the realistic shape — exporting all
10k made parseNamespaceFile superlinear and skewed my first measurement).
| Operation | main |
This PR | pkgload::load_all |
|---|---|---|---|
| Cold full load | 0.367s | 0.347s | 1.00s |
| Reload, one own file changed | 0.012s | 0.012s | 0.85s |
| Reload after a dependency edit | 0.007s — stale | 0.317s — correct | 0.80s — correct |
So the honest tradeoff: a leaf reload after touching a base package goes from ~7ms to ~317ms,
roughly a cold load and ~26x the incremental path. That is the right default — a 7ms wrong answer
is worth nothing — and it is still ~2.7x faster than load_all on the same package, with the common
case (editing the package you're in) untouched at 0.012s. Worth one line in the README so nobody is
surprised by the cliff.
Findings
1. NAMESPACE changes are still not detected (pre-existing; affects both versions).
Adding a function and its export() and reloading:
| Incremental reload: 1 changed [aaa_constants.R]
-> tsdata::series_range : ERROR: 'series_range' is not an exported object from 'namespace:tsdata'
full = TRUE fixes it. This isn't a regression, but it lands squarely on this PR's audience —
adding an export to a base package is routine multi-package work, and the README's full = TRUE
guidance only mentions deleted files and removed functions, so there's nothing pointing users at it.
Suggested fix: hash NAMESPACE (and DESCRIPTION) alongside the R/ files and force a full load
when either changes. One extra md5sum per call, it makes the existing rename-in-place detection
fall out naturally, and it closes the gap rather than documenting it.
2. The README overstates the new guarantee.
a plain
load_fast()on an importer never silently reports "No changes" while it is running
against a stale dependency snapshot.
It does, if the dependency was edited but not itself reloaded — which is the natural thing to do
when your cursor is already in the importer:
(tsdata edited on disk, load_fast(tsdata) NOT called)
| No changes in tsstats/R.
-> tsstats::stats_stack_version(): stats-on-vX # correct value: stats-on-vTRAP
The very next paragraph states the correct workflow ("reload the package you edited, then reload
whatever package you are working in"), so this is just a wording fix — something like
"...never reports No changes after you have reloaded a dependency it imports from". Worth
getting right, since it's the sentence a user will rely on.
(Genuinely fixing it looks cheap, if you want it: the cache already holds every loaded package's
path and per-file MD5s, so a forced load could re-hash cached dependencies and flag them. Happy to
leave that for a follow-up.)
3. The comment at R/loadfast.R:6-7 is imprecise about the trigger.
rlang's
ns_registry_env()is defunct as of rlang 1.2.0
It reads as unconditional, but it's defunct in R >= 4.6 — rlang 1.2.0 is where the version gate
was added; on older R it still works (which is why main still passes its suite on R 4.3/4.5).
Suggest: "defunct in R >= 4.6 (rlang >= 1.2.0)". Given this is the load-bearing justification for
the .Internal() approach, it's worth being exact.
4. The import-failure error could carry the same hint the new Depends error does.
The new Depends error is genuinely helpful:
Failed to attach Depends package 'foo': there is no package called 'foo'
If it is a source package, load it with load_fast() first.
The import path has no equivalent, and it's the error a multi-package user actually hits first:
Import failed for "cyca": there is no package called 'cyca'
Adding the same second line there would help exactly the audience this PR targets. (Reached via a
cyclic import() in my test, but any not-yet-loaded source dependency produces it.)
Related and worth noting as an improvement: on main that cycle appeared to load, because the
failed first load left a half-built namespace registered for the second one to import from. This
branch's failed-load cleanup means both packages now fail cleanly instead. Correct, if initially
surprising.
None of these block merging — 1 and 2 are the two I'd want addressed, and 2 is a one-line doc edit.
Generated by Claude Code
Summary
Brings
loadfastinto production shape: fixes real multi-package and lifecycle bugs found by probing, makes the package a clean installable artifact (R CMD check --as-cran: 0 errors, 0 warnings), and extends testing from 259 to 299 harness checks plus a newtestthatsuite that runs against the installed package duringR CMD check.Loader changes (
R/loadfast.R)Multi-package dependency tracking (the main gap): previously, after reloading a dependency, importer packages kept running against a stale snapshot and a plain
load_fast()on them short-circuited with "No changes". Now:load_fast()auto-upgrades to a full reload, with a message explaining why.C -> B -> Aconverges with a singleload_fast(C)afterAis reloaded.Lifecycle fixes:
Depends:packages are attached during full loads (clear error when missing) — previously ignored, so package code relying on them crashed..onUnloadruns best-effort whenunloadNamespace()refuses (namespace imported by other loaded packages) before the registry entry is force-replaced.DESCRIPTIONin place now detaches/unloads the old identity instead of leaving it on the search path forever.DESCRIPTION, sogetNamespaceVersion()reports correctly.load_fast()stays strict, internal dependency-reload recursion is allowed.Packaging
DESCRIPTION: version 1.0.0;methods/toolsdeclared in Imports; dev deps moved from the non-standardDevDepsfield toSuggests.NAMESPACEandman/pages for both exports.message()moved to a suppressible.onAttach; sources are ASCII-only.library("testthat")replaced with a guardedattachNamespace(); tinymethods:::internals (.hasS4MetaData/.getGenerics/.TableMetaPrefix) inlined as equivalents so--as-cranis warning-free..Rbuildignore; renv settings trackSuggests.R CMD check --as-cran: 0 errors, 0 warnings; the only inherent NOTE is theattach()call (same NOTE pkgload carries).Testing
import(), S4 class redefinition across packages, forced-unregister path incl..onUnload.tests/testthat/(new) — self-contained smoke/error-path tests that run against the installed package duringR CMD check, so the check proves the built artifact works.CI
R CMD check --as-cranjob on Linux/Windows/macOS at R release, plus oldrel-1 on Linux.🤖 Generated with Claude Code
https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
Generated by Claude Code