diff --git a/AGENTS.md b/AGENTS.md index 1982bcc..136771c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ This `AGENT.md` file is read by every agent session. !!!Keep them high-signal!!! - **`.loadfast.cache`** is a module-level environment (`parent = emptyenv()`) keyed by `normalizePath(path)`. Each entry stores the namespace env, `pkg_name`, file hashes, `renv.lock` hash baseline, the S3 methods matrix, `import_pkgs` (names of packages this package imports), `needs_full` (NULL, or a reason string set by dependency invalidation), any registered reload files, and the pending reload message to emit on the next load. - **Dependency invalidation**: after any load that re-sources files, `.loadfast.invalidate_dependents()` flags every cached package that (transitively) imports the reloaded package by setting `needs_full`. A flagged package's next `load_fast()` is upgraded to a full reload, and the full-load path first recursively reloads flagged *dependencies* (via `.loadfast.load()`, guarded by the `.loadfast.state$stack` path stack), so one call on the top of a chain converges the whole chain. No-change loads do not invalidate. +- **Earlier-loaded packages**: `.loadfast.state$loaded` holds package paths in the order of their first full load. `load_fast()` (not the internal `.loadfast.load()`) first calls `.loadfast.reload_earlier()`, which runs `.loadfast.load(report_unchanged = FALSE)` on every path loaded before the target. It skips a package whose namespace was replaced since and one with the target's package name. +- **Compiled code** (`.loadfast.load_compiled()`): a package with `src/` never goes through the sourcing path. Each change is built with `R CMD INSTALL` into a new `tempfile()` library and loaded with `library()`. A new path is required: the dynamic loader returns the already-loaded library for a path it knows, so a rebuild in place would run the old code. The old library is never unloaded, because unloading crashes R when a live external pointer's finalizer runs. Change detection hashes `DESCRIPTION`, `NAMESPACE`, `R/` and `src/`, and prunes `.Rbuildignore` paths while walking, so Cargo's `target/` is never listed. - **Depends**: `.loadfast.parse_depends()` reads `Depends` from DESCRIPTION (minus `R (...)`), and the full-load path attaches each with `library()` if not already attached; a missing Depends package is a clear error. - **Teardown** (`.loadfast.teardown()`): detach + `unloadNamespace()`. When the namespace is imported by other loaded namespaces, `unloadNamespace()` errors — the fallback runs the old namespace's `.onUnload` best-effort and force-removes it from the registry via `.loadfast.ns_unregister()`. A failed full load cleans up after itself (unregisters the half-built ns, detaches) via an `on.exit` guard. - **Package identity**: if the `Package:` field changes in place for a cached path, the old name is detached/unloaded and the cache entry dropped before the full load of the new name. @@ -70,6 +72,8 @@ This `AGENT.md` file is read by every agent session. !!!Keep them high-signal!!! - **Stage 5**: Build ad-hoc packages (distinct class names, so nothing collides) to test cross-package fidelity: `exportPattern()` populating exports and `pkg::fn` resolving (5a), `pkg::missing` giving a proper not-exported error rather than the `lazydata` internal (5b), S3 dispatch across package boundaries for both base and package-defined generics (5c), and `exportClasses()`/`exportMethods()` + `importClassesFrom()`/`importMethodsFrom()` for S4 (5d). - **Stage 6**: Multi-package dependency invalidation: direct importer auto-upgraded to full reload (6a), transitive chain convergence with recursive dependency reload (6b), no-change loads do not invalidate (6c), whole-namespace `import()` invalidation (6d), S4 class redefinition propagating to importers (6e), and the forced-unregister path when `unloadNamespace()` refuses (including `.onUnload` execution) (6f). - **Stage 7**: Production hardening: `Depends` attachment + missing-Depends error (7a), rename-in-place cleanup (7b), failed-full-load cleanup + recovery (7c), namespace version from DESCRIPTION (7d), Collate order on incremental re-source (7e), re-entrance guard (7f), verbose timing logs (7g). + - **Stage 8**: A package with C code in `src/`: build, no rebuild without change, `.Rbuildignore` pruning, rebuild on C and R changes, failed build keeps the old build (8a). Earlier-loaded packages, R-only and compiled, are reloaded by a later package's `load_fast()`, but not the other way round (8b). Needs a C compiler. +- The harness loads all its packages into one session, so every `load_fast()` call also reloads the earlier packages whose files a test changed without reloading them. A new test that leaves an edited package unloaded can change what later checks see. - Stage 2 always triggers a full load (different path from Stage 1 = cache miss). Stage 3 exercises the incremental path for `load_fast()` on a stable temp path while mutating files in place. The multi-package stages (3e, 3h) call `strip_s3_fixture()` on their copies so the shared `temperature` S3 fixture from the baseline does not register colliding methods across renamed packages. ## R namespace machinery gotchas diff --git a/R/loadfast.R b/R/loadfast.R index c5387bf..0db3580 100644 --- a/R/loadfast.R +++ b/R/loadfast.R @@ -1,6 +1,8 @@ .loadfast.cache <- new.env(parent = emptyenv()) .loadfast.state <- new.env(parent = emptyenv()) .loadfast.state$stack <- character(0) +# Package paths in the order of their first full load in this session. +.loadfast.state$loaded <- character(0) # R's namespace registry has no public API for dev loaders: the base wrappers # around registerNamespace/unregisterNamespace are not exported, and rlang's @@ -41,6 +43,17 @@ #' against a stale snapshot. A full reload also reloads flagged dependencies #' first, in dependency order. #' +#' Before it loads `path`, `load_fast()` reloads the packages that it loaded +#' earlier in the session, in the order it first loaded them, if their files +#' changed. So after you edit a dependency, one `load_fast()` call in the +#' package you work on picks up the edit. +#' +#' A package with a `src/` directory is built with `R CMD INSTALL` into a new +#' temporary library, and loaded from there. This happens on the first call +#' and whenever a file in `DESCRIPTION`, `NAMESPACE`, `R/` or `src/` changed. +#' Paths that `.Rbuildignore` lists do not count as changes. The library of +#' the previous build stays loaded, so objects that point into it stay valid. +#' #' Packages listed in the `Depends` field of `DESCRIPTION` are attached to #' the search path during a full load, mirroring `library()` semantics. #' @@ -70,10 +83,31 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = if (length(.loadfast.state$stack) > 0L) { stop("load_fast() re-entrance detected -- a sourced file is calling load_fast()") } + .loadfast.reload_earlier(.loadfast.find_package_root(path), verbose) .loadfast.load(path, helpers = helpers, attach_testthat = attach_testthat, full = full, verbose = verbose) } -.loadfast.load <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = FALSE, verbose = FALSE) { +# The multi-package workflow loads dependencies first, so the packages loaded +# before `abs_path` are the ones it can depend on. A package whose namespace was +# replaced since, for example by library(), is left alone, and so is one with +# the name of the package at `abs_path`, because loading that replaces it. +.loadfast.reload_earlier <- function(abs_path, verbose) { + pkg_name <- read.dcf(file.path(abs_path, "DESCRIPTION"), fields = "Package")[1L, 1L] + earlier <- .loadfast.state$loaded + position <- match(abs_path, earlier) + if (!is.na(position)) earlier <- earlier[seq_len(position - 1L)] + for (key in earlier) { + if (!exists(key, envir = .loadfast.cache, inherits = FALSE) || !dir.exists(key)) next + entry <- .loadfast.cache[[key]] + if (identical(entry$pkg_name, unname(pkg_name))) next + if (!isNamespaceLoaded(entry$pkg_name) || !identical(asNamespace(entry$pkg_name), entry$ns_env)) next + .loadfast.load(key, helpers = FALSE, attach_testthat = FALSE, verbose = verbose, report_unchanged = FALSE) + } + invisible(NULL) +} + +.loadfast.load <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = FALSE, verbose = FALSE, + report_unchanged = TRUE) { if (verbose) { .t0 <- proc.time()["elapsed"] .t_last <- .t0 @@ -118,6 +152,13 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = if (!nzchar(pkg_name)) stop("No valid 'Package' field found in DESCRIPTION") pkg_env_name <- paste0("package:", pkg_name) + + if (dir.exists(file.path(abs_path, "src"))) { + return(.loadfast.load_compiled( + abs_path, pkg_name, path_display, helpers, attach_testthat, full, report_unchanged + )) + } + loaded_pkg_path <- .loadfast.loaded_package_path(pkg_name) if (!is.null(loaded_pkg_path) && !identical(loaded_pkg_path, abs_path)) { @@ -182,7 +223,10 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = message("Full reload of '", pkg_name, "': ", needs_full_reason, ".") } + # A package that had src/ before was installed, and R locks an installed + # namespace, so its files cannot be sourced into it. can_incremental <- !is.null(cached) && + is.null(cached$library_dir) && is.null(needs_full_reason) && !is.null(active_ns_env) && identical(cached$ns_env, active_ns_env) && @@ -238,7 +282,7 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = registered_reload_files = character(0), pending_reload_message = NULL ) - message("No changes in ", r_dir_display, ".") + if (report_unchanged) message("No changes in ", r_dir_display, ".") .loadfast.source_helpers(abs_path, pkg_env, helpers, attach_testthat, pkg_name) .timer("TOTAL (no-change)") return(invisible(ns_env)) @@ -594,6 +638,7 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = pending_reload_message = NULL ) .loadfast.invalidate_dependents(pkg_name, abs_path) + .loadfast.state$loaded <- union(.loadfast.state$loaded, abs_path) full_load_ok <- TRUE message("Load ", length(r_files), " file(s) from ", r_dir_display, ".") @@ -601,6 +646,91 @@ load_fast <- function(path = ".", helpers = TRUE, attach_testthat = NULL, full = invisible(ns_env) } +# R runs compiled code only from a shared library that dyn.load() loaded, and +# the operating system does not load a rebuilt library again from a path it has +# already loaded. So every build goes into a new temporary library. The old +# library stays loaded: unloading it would crash R when an object that still +# points into it is used or garbage collected. +.loadfast.load_compiled <- function(abs_path, pkg_name, path_display, helpers, attach_testthat, full, + report_unchanged) { + hashes <- tools::md5sum(.loadfast.build_inputs(abs_path)) + cached <- if (exists(abs_path, envir = .loadfast.cache, inherits = FALSE)) .loadfast.cache[[abs_path]] + is_current <- !isTRUE(full) && + !is.null(cached) && + is.null(cached$needs_full) && + identical(hashes, cached$hashes) && + isNamespaceLoaded(pkg_name) && + identical(asNamespace(pkg_name), cached$ns_env) + + if (!is_current) { + library_dir <- .loadfast.install(abs_path, path_display) + .loadfast.teardown(pkg_name, abs_path) + library(pkg_name, lib.loc = library_dir, character.only = TRUE, warn.conflicts = FALSE) + if (!is.null(cached$library_dir)) unlink(cached$library_dir, recursive = TRUE) + ns_env <- asNamespace(pkg_name) + .loadfast.cache[[abs_path]] <- list( + ns_env = ns_env, + pkg_name = pkg_name, + hashes = hashes, + import_pkgs = setdiff(names(getNamespaceImports(ns_env)), "base"), + needs_full = NULL, + library_dir = library_dir + ) + .loadfast.invalidate_dependents(pkg_name, abs_path) + .loadfast.state$loaded <- union(.loadfast.state$loaded, abs_path) + message("Load ", pkg_name, " from ", path_display, ", built with R CMD INSTALL.") + } else if (report_unchanged) { + message("No changes in ", path_display, ".") + } + + pkg_env <- as.environment(paste0("package:", pkg_name)) + .loadfast.source_helpers(abs_path, pkg_env, helpers, attach_testthat, pkg_name) + invisible(asNamespace(pkg_name)) +} + +# The build of a package with src/ streams its output, because the first build +# of a large Rust or C++ package can take minutes. +.loadfast.install <- function(abs_path, path_display) { + library_dir <- tempfile("loadfast-") + dir.create(library_dir) + # --use-vanilla keeps the child R from reading .Rprofile, which can switch + # libraries (renv does). R_LIBS gives it the libraries of this session instead. + old_r_libs <- Sys.getenv("R_LIBS", unset = NA) + Sys.setenv(R_LIBS = paste(.libPaths(), collapse = .Platform$path.sep)) + on.exit(if (is.na(old_r_libs)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = old_r_libs), add = TRUE) + status <- system2(file.path(R.home("bin"), "R"), c( + "CMD", "INSTALL", "--use-vanilla", "--no-test-load", "--no-staged-install", "--no-byte-compile", + paste0("--library=", shQuote(library_dir)), shQuote(abs_path) + )) + if (status != 0L) { + unlink(library_dir, recursive = TRUE) + stop("R CMD INSTALL failed for ", path_display, ". Its output is above.", call. = FALSE) + } + library_dir +} + +# A build directory, such as Cargo's src/rust/target/, can hold many thousands +# of files. Paths that .Rbuildignore lists are not entered, and neither are the +# object files and libraries that R CMD INSTALL writes into src/. +.loadfast.build_inputs <- function(abs_path) { + ignore_file <- file.path(abs_path, ".Rbuildignore") + ignore <- c( + "^src/.*\\.(o|so|dll|dylib)$", + if (file.exists(ignore_file)) readLines(ignore_file, warn = FALSE) + ) + ignore <- ignore[nzchar(trimws(ignore))] + is_ignored <- function(paths) { + Reduce(`|`, lapply(ignore, grepl, x = paths, ignore.case = TRUE, perl = TRUE), logical(length(paths))) + } + list_dir <- function(dir) { + paths <- file.path(dir, list.files(file.path(abs_path, dir), all.files = TRUE, no.. = TRUE)) + paths <- paths[!is_ignored(paths)] + is_dir <- dir.exists(file.path(abs_path, paths)) + c(paths[!is_dir], unlist(lapply(paths[is_dir], list_dir))) + } + file.path(abs_path, sort(c("DESCRIPTION", "NAMESPACE", list_dir("R"), list_dir("src")))) +} + #' Register one or more files for reload on the next `load_fast()` call #' #' @param path Package root path. diff --git a/README.md b/README.md index 2cfc54c..e831faf 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,11 @@ snapshot taken at its own load time. `load_fast()` tracks these relationships: In a chain `C -> B -> A`, editing and reloading `A` followed by a single `load_fast()` of `C` rebuilds `B` and `C` against the fresh namespaces. -So the workflow is simply: reload the package you edited, then reload whatever -package you are working in — everything in between converges automatically. +You do not even need the first step. Before `load_fast()` loads a package, it +reloads the packages that it loaded earlier in the session, in the order it +first loaded them, if their files changed. So the workflow is: load each +package once, dependencies first. After that, edit any of them and call +`load_fast()` in the package you work on. Reloading a dependency that other loaded packages import cannot use `unloadNamespace()` (R refuses to unload an imported namespace); `load_fast()` @@ -100,6 +103,27 @@ class is imported elsewhere. > Earlier versions failed here with `Error: object 'lazydata' not found` when a > loaded package was accessed with `::`. That is fixed. +## Packages with compiled code + +`load_fast()` also loads a package with a `src/` directory, for example C, +C++ or Rust code. R runs compiled code only from a shared library, and the +operating system does not load a rebuilt library again from a path that is +already loaded. So `load_fast()` builds the package with `R CMD INSTALL` into a +new temporary library, and loads it from there with `library()`. It streams the +build output. + +The build runs on the first call and whenever a file in `DESCRIPTION`, +`NAMESPACE`, `R/` or `src/` changed. Paths that `.Rbuildignore` lists do not +count. List build directories there, such as Cargo's `^src/rust/target$`, so +that `load_fast()` does not hash their files on every call. The make and cargo +caches in `src/` make a rebuild after a small change fast. + +`load_fast()` does not unload the library of the previous build. Objects that +point into it keep working, and they keep running the old code. The package's +functions run the new code, as long as they call native code through +registered routines (`useDynLib(pkg, .registration = TRUE)`). A call by name, +such as `.Call("fn", PACKAGE = "pkg")`, can keep running the old code. + ## Editor setup ### RStudio @@ -143,11 +167,12 @@ You can bind `loadfast::load_fast()` in the Zed keymap: Testing happens at two levels: -1. **Behavioral harness** (`test_loadfast.R`): ~300 checks across seven stages +1. **Behavioral harness** (`test_loadfast.R`): ~300 checks across eight stages covering full loads, incremental reloads, cross-file and cross-package - dependencies, S3/S4/R6 fidelity, multi-package dependency invalidation, and + dependencies, S3/S4/R6 fidelity, multi-package dependency invalidation, production hardening (failed-load recovery, rename-in-place, `Depends` - attachment, `Collate` ordering, re-entrance). + attachment, `Collate` ordering, re-entrance), and packages with compiled + code. Stage 8 needs a C compiler. 2. **`testthat` suite** (`tests/testthat/`): self-contained smoke and error-path tests that run against the *installed* package during `R CMD check`. diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md index efb03c5..52d5e32 100644 --- a/TECHNICAL_DEBT.md +++ b/TECHNICAL_DEBT.md @@ -108,6 +108,22 @@ the class of subtle S4/S3 state this package tries not to reimplement. changes these conventions (unlikely; they are decades old), stages 5d/6e of the harness will catch it. +### 7. A rebuilt compiled package leaves the old library loaded +Each build of a package with `src/` goes into a new temporary library, and the +previous library is never unloaded. Unloading it would crash R as soon as an +external pointer from the old build is used or finalized. The price: every +build stays mapped until R exits, and a native call by name +(`.Call("fn", PACKAGE = "pkg")`) can resolve to the old library through R's +symbol cache. Calls through registered routines, which savvy, extendr and +current Rcpp generate, always reach the new library. + +A whole `R CMD INSTALL` per change is also slower than compiling `src/` and +sourcing `R/` the way `pkgload` does. For packages whose time goes into the +compiler, the difference is a few seconds. + +**Current rule** +- Restart R if a session has rebuilt a large compiled package many times + ## Notes for future reviewers - Most of the implementation complexity is justified by R namespace machinery, especially S4 behavior and imports metadata shape. diff --git a/man/load_fast.Rd b/man/load_fast.Rd index 292d6f1..f7b0621 100644 --- a/man/load_fast.Rd +++ b/man/load_fast.Rd @@ -46,6 +46,17 @@ automatically upgraded to a full reload so importers never keep running against a stale snapshot. A full reload also reloads flagged dependencies first, in dependency order. +Before it loads \code{path}, \code{load_fast()} reloads the packages that it loaded +earlier in the session, in the order it first loaded them, if their files +changed. So after you edit a dependency, one \code{load_fast()} call in the +package you work on picks up the edit. + +A package with a \verb{src/} directory is built with \verb{R CMD INSTALL} into a new +temporary library, and loaded from there. This happens on the first call +and whenever a file in \code{DESCRIPTION}, \code{NAMESPACE}, \verb{R/} or \verb{src/} changed. +Paths that \code{.Rbuildignore} lists do not count as changes. The library of +the previous build stays loaded, so objects that point into it stay valid. + Packages listed in the \code{Depends} field of \code{DESCRIPTION} are attached to the search path during a full load, mirroring \code{library()} semantics. } diff --git a/test_loadfast.R b/test_loadfast.R index b1561ae..29e6064 100644 --- a/test_loadfast.R +++ b/test_loadfast.R @@ -2550,8 +2550,12 @@ check("inv-chain: transitive importer is flagged too", quote( any(grepl("Full reload of 'invtop'", inv_c_reload$messages)) )) +# load_fast() reloads the earlier-loaded invuser before it starts on invtop. check("inv-chain: flagged middle dependency is reloaded first", quote( - any(grepl("Reloading dependency 'invuser' first", inv_c_reload$messages)) + isTRUE( + grep("Full reload of 'invuser'", inv_c_reload$messages)[1L] < + grep("Full reload of 'invtop'", inv_c_reload$messages)[1L] + ) )) check("inv-chain: one load of the top package converges the whole chain", quote( @@ -2934,6 +2938,147 @@ check("verbose: no-change load emits timing summary", quote( any(grepl("TOTAL (no-change)", verbose_nochange$messages, fixed = TRUE)) )) +# ============================================================================ +# STAGE 8: Packages with compiled code, and reloading earlier-loaded packages +# ============================================================================ +cat("\n--- Stage 8: compiled code and earlier-loaded packages ---\n\n") + +write_c_answer <- function(root, value) { + dir.create(file.path(root, "src"), showWarnings = FALSE) + writeLines(c( + "#include ", + "#include ", + "#include ", + paste0("SEXP c_answer(void) { return Rf_ScalarInteger(", value, "); }"), + "static const R_CallMethodDef calls[] = {{\"c_answer\", (DL_FUNC) &c_answer, 0}, {NULL, NULL, 0}};", + "void R_init_cpkg(DllInfo *dll) {", + " R_registerRoutines(dll, NULL, calls, NULL, NULL);", + " R_useDynamicSymbols(dll, FALSE);", + "}" + ), file.path(root, "src", "answer.c")) +} + +# --- 8a: a package with src/ is built, loaded, and rebuilt on change --- +cat("\n--- 8a: compiled package ---\n\n") + +tmp_cpkg <- tempfile("loadfast_cpkg_") +write_pkg( + tmp_cpkg, "cpkg", + desc_extra = character(0), + ns_lines = c("useDynLib(cpkg, .registration = TRUE)", "export(answer)"), + r_files = list("answer.R" = "answer <- function() .Call(c_answer)") +) +write_c_answer(tmp_cpkg, 42) +writeLines("^src/build$", file.path(tmp_cpkg, ".Rbuildignore")) + +cpkg_first <- capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE)) + +check("compiled: first load builds and loads the compiled code", quote( + any(grepl("built with R CMD INSTALL", cpkg_first$messages, fixed = TRUE)) && + getExportedValue("cpkg", "answer")() == 42L +)) + +cpkg_same <- capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE)) + +check("compiled: unchanged package is not rebuilt", quote( + any(grepl("No changes in", cpkg_same$messages, fixed = TRUE)) && + !any(grepl("built with R CMD INSTALL", cpkg_same$messages, fixed = TRUE)) +)) + +dir.create(file.path(tmp_cpkg, "src", "build")) +writeLines("output", file.path(tmp_cpkg, "src", "build", "artifact.txt")) +cpkg_ignored <- capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE)) + +check("compiled: files under a .Rbuildignore path do not trigger a rebuild", quote( + !any(grepl("built with R CMD INSTALL", cpkg_ignored$messages, fixed = TRUE)) +)) + +write_c_answer(tmp_cpkg, 43) +invisible(capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE))) + +check("compiled: a changed C file is rebuilt and the new code runs", quote( + getExportedValue("cpkg", "answer")() == 43L +)) + +writeLines("answer <- function() .Call(c_answer) + 1L", file.path(tmp_cpkg, "R", "answer.R")) +invisible(capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE))) + +check("compiled: a changed R file in a compiled package is picked up", quote( + getExportedValue("cpkg", "answer")() == 44L +)) + +writeLines("this is not C", file.path(tmp_cpkg, "src", "broken.c")) +cpkg_broken <- tryCatch( + load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE), + error = function(e) conditionMessage(e) +) + +check("compiled: a failed build stops with an error", quote( + grepl("R CMD INSTALL failed", cpkg_broken, fixed = TRUE) +)) + +check("compiled: a failed build keeps the previous build loaded", quote( + getExportedValue("cpkg", "answer")() == 44L +)) +unlink(file.path(tmp_cpkg, "src", "broken.c")) + +# --- 8b: load_fast() reloads changed packages that were loaded earlier --- +cat("\n--- 8b: earlier-loaded packages ---\n\n") + +tmp_early <- tempfile("loadfast_early_") +tmp_late <- tempfile("loadfast_late_") +write_pkg( + tmp_early, "earlypkg", + desc_extra = character(0), + ns_lines = "export(early_value)", + r_files = list("a.R" = 'early_value <- function() "one"') +) +write_pkg( + tmp_late, "latepkg", + desc_extra = character(0), + ns_lines = "export(late_value)", + r_files = list("b.R" = 'late_value <- function() paste0("late:", earlypkg::early_value())') +) +invisible(load_fast(tmp_early, helpers = FALSE, attach_testthat = FALSE)) +invisible(load_fast(tmp_late, helpers = FALSE, attach_testthat = FALSE)) + +writeLines('early_value <- function() "two"', file.path(tmp_early, "R", "a.R")) +late_reload <- capture_messages(load_fast(tmp_late, helpers = FALSE, attach_testthat = FALSE)) + +check("earlier: loading a later package reloads the changed earlier package", quote( + getExportedValue("latepkg", "late_value")() == "late:two" +)) + +late_same <- capture_messages(load_fast(tmp_late, helpers = FALSE, attach_testthat = FALSE)) + +check("earlier: unchanged earlier packages report nothing", quote( + sum(grepl("No changes in", late_same$messages, fixed = TRUE)) == 1L +)) + +writeLines('late_value <- function() "changed"', file.path(tmp_late, "R", "b.R")) +invisible(capture_messages(load_fast(tmp_early, helpers = FALSE, attach_testthat = FALSE))) + +check("earlier: loading an earlier package does not reload later ones", quote( + getExportedValue("latepkg", "late_value")() == "late:two" +)) + +write_c_answer(tmp_cpkg, 45) +invisible(capture_messages(load_fast(tmp_late, helpers = FALSE, attach_testthat = FALSE))) + +check("earlier: a changed compiled package is rebuilt when a later package loads", quote( + getExportedValue("cpkg", "answer")() == 46L +)) + +unlink(file.path(tmp_cpkg, "src"), recursive = TRUE) +writeLines("export(answer)", file.path(tmp_cpkg, "NAMESPACE")) +writeLines("answer <- function() 99L", file.path(tmp_cpkg, "R", "answer.R")) +cpkg_plain <- capture_messages(load_fast(tmp_cpkg, helpers = FALSE, attach_testthat = FALSE)) + +check("compiled: a package that loses src/ gets a full load from source", quote( + any(grepl("Load 1 file(s) from", cpkg_plain$messages, fixed = TRUE)) && + getExportedValue("cpkg", "answer")() == 99L +)) + # ============================================================================ # Summary # ============================================================================