civilytics_load_fonts() always errors: '<<-' to a locked namespace binding #18

Closed
opened 2026-07-28 10:58:25 -04:00 by jared · 1 comment
Owner

Summary

civilytics_load_fonts() errors on every call:

Error in .cv_fonts_loaded <<- TRUE :
  cannot change value of locked binding for '.cv_fonts_loaded'

The fonts themselves load fine — the failure is the last statement in the
function, so showtext_auto() has already run by the time it throws. But because
the error propagates, any script that calls it dies, and any script that sources
a themes file calling it dies too.

Reproduction

library(civilytics)
civilytics_load_fonts()
#> Error in .cv_fonts_loaded <<- TRUE :
#>   cannot change value of locked binding for '.cv_fonts_loaded'

Reproduces in a plain Rscript -e, inside source(), and interactively. It is
not conditional on anything in the calling environment.

Cause

The function ends with:

civilytics_load_fonts <- function() {
  sysfonts::font_add_google("Inter", family = "Inter")
  # ... three more font_add_google() calls ...
  showtext::showtext_auto()
  .cv_fonts_loaded <<- TRUE      # <- here
  invisible(NULL)
}

.cv_fonts_loaded is a package-level object, so when the namespace seals at load
time R locks both the environment and its bindings. <<- resolves to that locked
binding and cannot write to it.

Minimal demonstration of the mechanism, and of the fix:

ns <- new.env(parent = baseenv())

# current approach — a plain flag written with <<-
assign(".cv_fonts_loaded", FALSE, envir = ns)
f_bad <- function() { .cv_fonts_loaded <<- TRUE; invisible() }
environment(f_bad) <- ns

# proposed — mutate the *contents* of a state environment
assign(".cv_state", new.env(parent = emptyenv()), envir = ns)
f_good <- function() { .cv_state$fonts_loaded <- TRUE; invisible() }
environment(f_good) <- ns

lockEnvironment(ns, bindings = TRUE)   # what R does when the namespace seals

f_bad()
#> Error: cannot change value of locked binding for '.cv_fonts_loaded'
f_good()
#> works, flag = TRUE

Locking applies to the binding, not to the contents of an environment the
binding points at — which is why the environment-based idiom is the standard way
to hold mutable package state.

The flag is never read

Worth deciding before fixing: .cv_fonts_loaded is not exported, and no function
in the package reads it. civilytics_load_fonts() is the only reference:

e <- asNamespace("civilytics")
users <- Filter(function(nm) {
  obj <- get(nm, envir = e)
  is.function(obj) && grepl(".cv_fonts_loaded", paste(deparse(obj), collapse = " "), fixed = TRUE)
}, ls(e, all.names = TRUE))
users
#> [1] "civilytics_load_fonts"

So it reads as a guard flag whose read side was never written (or was removed).
That gives two reasonable fixes.

Suggested fix

Option A — drop the line. If idempotency was never actually wanted, the
one-line fix is to delete .cv_fonts_loaded <<- TRUE and the object it writes to.
sysfonts::font_add_google() is already safe to call repeatedly.

Option B — make the guard real. If the intent was to avoid re-downloading
fonts on every call, hold the state in an environment and actually check it:

# R/zzz.R (or wherever package state lives)
.cv_state <- new.env(parent = emptyenv())
.cv_state$fonts_loaded <- FALSE

civilytics_load_fonts <- function(force = FALSE) {
  if (.cv_state$fonts_loaded && !force) return(invisible(FALSE))

  sysfonts::font_add_google("Inter", family = "Inter")
  sysfonts::font_add_google("Libre Franklin", family = "Libre Franklin")
  sysfonts::font_add_google("Source Serif 4", family = "Source Serif 4")
  sysfonts::font_add_google("JetBrains Mono", family = "JetBrains Mono")
  showtext::showtext_auto()

  .cv_state$fonts_loaded <- TRUE
  invisible(TRUE)
}

I'd lean to B, since font_add_google() hits the network and the guard then
earns its keep.

Two adjacent notes

  1. font_add_google() requires network access. On a machine that already has
    Inter / Libre Franklin / Source Serif 4 / JetBrains Mono installed system-wide,
    the download is unnecessary — ggplot2 resolves them by family name via
    systemfonts. Falling back to the installed copy when
    systemfonts::system_fonts() already has the family would make the function
    work offline and finish faster.

  2. showtext_auto() is a global side effect. It switches all device text
    rendering to showtext for the rest of the session, which changes how
    ggsave() output looks in unrelated plots. Worth documenting on the help
    page, or gating behind an argument.

Workaround

Wrapping the call makes it non-fatal, since the fonts are registered before the
error is raised:

try(civilytics_load_fonts(), silent = TRUE)

Environment

civilytics 0.3.1
R 4.6.1 (2026-06-24), x86_64-pc-linux-gnu
sysfonts 0.8.9
showtext 0.9.8
ggplot2 4.0.3
## Summary `civilytics_load_fonts()` errors on **every** call: ``` Error in .cv_fonts_loaded <<- TRUE : cannot change value of locked binding for '.cv_fonts_loaded' ``` The fonts themselves load fine — the failure is the last statement in the function, so `showtext_auto()` has already run by the time it throws. But because the error propagates, any script that calls it dies, and any script that *sources* a themes file calling it dies too. ## Reproduction ```r library(civilytics) civilytics_load_fonts() #> Error in .cv_fonts_loaded <<- TRUE : #> cannot change value of locked binding for '.cv_fonts_loaded' ``` Reproduces in a plain `Rscript -e`, inside `source()`, and interactively. It is not conditional on anything in the calling environment. ## Cause The function ends with: ```r civilytics_load_fonts <- function() { sysfonts::font_add_google("Inter", family = "Inter") # ... three more font_add_google() calls ... showtext::showtext_auto() .cv_fonts_loaded <<- TRUE # <- here invisible(NULL) } ``` `.cv_fonts_loaded` is a package-level object, so when the namespace seals at load time R locks both the environment and its bindings. `<<-` resolves to that locked binding and cannot write to it. Minimal demonstration of the mechanism, and of the fix: ```r ns <- new.env(parent = baseenv()) # current approach — a plain flag written with <<- assign(".cv_fonts_loaded", FALSE, envir = ns) f_bad <- function() { .cv_fonts_loaded <<- TRUE; invisible() } environment(f_bad) <- ns # proposed — mutate the *contents* of a state environment assign(".cv_state", new.env(parent = emptyenv()), envir = ns) f_good <- function() { .cv_state$fonts_loaded <- TRUE; invisible() } environment(f_good) <- ns lockEnvironment(ns, bindings = TRUE) # what R does when the namespace seals f_bad() #> Error: cannot change value of locked binding for '.cv_fonts_loaded' f_good() #> works, flag = TRUE ``` Locking applies to the *binding*, not to the contents of an environment the binding points at — which is why the environment-based idiom is the standard way to hold mutable package state. ## The flag is never read Worth deciding before fixing: `.cv_fonts_loaded` is not exported, and no function in the package reads it. `civilytics_load_fonts()` is the only reference: ```r e <- asNamespace("civilytics") users <- Filter(function(nm) { obj <- get(nm, envir = e) is.function(obj) && grepl(".cv_fonts_loaded", paste(deparse(obj), collapse = " "), fixed = TRUE) }, ls(e, all.names = TRUE)) users #> [1] "civilytics_load_fonts" ``` So it reads as a guard flag whose read side was never written (or was removed). That gives two reasonable fixes. ## Suggested fix **Option A — drop the line.** If idempotency was never actually wanted, the one-line fix is to delete `.cv_fonts_loaded <<- TRUE` and the object it writes to. `sysfonts::font_add_google()` is already safe to call repeatedly. **Option B — make the guard real.** If the intent was to avoid re-downloading fonts on every call, hold the state in an environment and actually check it: ```r # R/zzz.R (or wherever package state lives) .cv_state <- new.env(parent = emptyenv()) .cv_state$fonts_loaded <- FALSE civilytics_load_fonts <- function(force = FALSE) { if (.cv_state$fonts_loaded && !force) return(invisible(FALSE)) sysfonts::font_add_google("Inter", family = "Inter") sysfonts::font_add_google("Libre Franklin", family = "Libre Franklin") sysfonts::font_add_google("Source Serif 4", family = "Source Serif 4") sysfonts::font_add_google("JetBrains Mono", family = "JetBrains Mono") showtext::showtext_auto() .cv_state$fonts_loaded <- TRUE invisible(TRUE) } ``` I'd lean to **B**, since `font_add_google()` hits the network and the guard then earns its keep. ## Two adjacent notes 1. **`font_add_google()` requires network access.** On a machine that already has Inter / Libre Franklin / Source Serif 4 / JetBrains Mono installed system-wide, the download is unnecessary — ggplot2 resolves them by family name via `systemfonts`. Falling back to the installed copy when `systemfonts::system_fonts()` already has the family would make the function work offline and finish faster. 2. **`showtext_auto()` is a global side effect.** It switches all device text rendering to showtext for the rest of the session, which changes how `ggsave()` output looks in unrelated plots. Worth documenting on the help page, or gating behind an argument. ## Workaround Wrapping the call makes it non-fatal, since the fonts are registered before the error is raised: ```r try(civilytics_load_fonts(), silent = TRUE) ``` ## Environment ``` civilytics 0.3.1 R 4.6.1 (2026-06-24), x86_64-pc-linux-gnu sysfonts 0.8.9 showtext 0.9.8 ggplot2 4.0.3 ```
jared added the bug label 2026-07-28 10:58:25 -04:00
Author
Owner

Follow-up after digging further — the impact is narrower than the original report implies, but sharper, and it lands on the recovery path the package itself recommends.

Why .onLoad gets away with it

.onLoad() calls civilytics_load_fonts() and it succeeds, because R seals a
namespace after .onLoad runs. The <<- therefore writes fine at load time:

e <- asNamespace("civilytics")
get(".cv_fonts_loaded", envir = e)     #> TRUE   (set during .onLoad)
bindingIsLocked(".cv_fonts_loaded", e) #> TRUE   (sealed afterwards)

So on a normal machine nothing looks wrong: the fonts register, the flag is set,
.civilytics_fonts_failed stays FALSE, and no startup message appears. Only a
subsequent, manual call hits the locked binding.

The bite

That subsequent manual call is exactly what the package tells users to make.
.onLoad wraps the call in tryCatch and sets a flag; .onAttach then says:

[civilytics] Brand fonts (…) could not be loaded from Google Fonts. Charts will
fall back to system fonts. Call civilytics_load_fonts() once you have an
internet connection.

For an offline user the sequence is:

  1. .onLoad → font_add_google() fails on no network → tryCatch catches it →
    .civilytics_fonts_failed = TRUE
  2. .onAttach prints the message above
  3. User reconnects and follows the instruction
  4. civilytics_load_fonts() → fonts register fine → then dies on
    cannot change value of locked binding for '.cv_fonts_loaded'

So the documented remedy fails with an error that says nothing about fonts or
networks. Worth noting the fonts are registered by the time it throws — the
error is the last statement — so try(civilytics_load_fonts(), silent = TRUE)
genuinely works as a workaround.

Scope of the fix

<<- appears exactly once in the package, so this is isolated to the one function:

e <- asNamespace("civilytics")
Filter(function(nm) {
  o <- get(nm, envir = e)
  is.function(o) && grepl("<<-", paste(deparse(o), collapse = "\n"), fixed = TRUE)
}, ls(e, all.names = TRUE))
#> [1] "civilytics_load_fonts"

Suggested addition to the fix

Beyond moving the flag into an environment (Option B in the original report), the
.onLoad handler is worth tightening: it currently attributes any error to a
Google Fonts failure. Distinguishing a genuine network failure from an internal
one would have surfaced this immediately rather than hiding it behind a
misleading message —

.onLoad <- function(libname, pkgname) {
  tryCatch(
    civilytics_load_fonts(),
    error = function(e) {
      # only claim a font/network problem if that is what actually happened
      if (!all(c("Inter", "Libre Franklin", "Source Serif 4", "JetBrains Mono")
               %in% sysfonts::font_families())) {
        options(.civilytics_fonts_failed = TRUE)
      } else {
        warning("civilytics: fonts loaded but load hook errored: ",
                conditionMessage(e), call. = FALSE)
      }
    }
  )
}
Follow-up after digging further — the impact is narrower than the original report implies, but sharper, and it lands on the recovery path the package itself recommends. ## Why `.onLoad` gets away with it `.onLoad()` calls `civilytics_load_fonts()` and it **succeeds**, because R seals a namespace *after* `.onLoad` runs. The `<<-` therefore writes fine at load time: ```r e <- asNamespace("civilytics") get(".cv_fonts_loaded", envir = e) #> TRUE (set during .onLoad) bindingIsLocked(".cv_fonts_loaded", e) #> TRUE (sealed afterwards) ``` So on a normal machine nothing looks wrong: the fonts register, the flag is set, `.civilytics_fonts_failed` stays `FALSE`, and no startup message appears. Only a *subsequent, manual* call hits the locked binding. ## The bite That subsequent manual call is exactly what the package tells users to make. `.onLoad` wraps the call in `tryCatch` and sets a flag; `.onAttach` then says: > `[civilytics]` Brand fonts (…) could not be loaded from Google Fonts. Charts will > fall back to system fonts. **Call `civilytics_load_fonts()` once you have an > internet connection.** For an offline user the sequence is: 1. `.onLoad` → `font_add_google()` fails on no network → `tryCatch` catches it → `.civilytics_fonts_failed = TRUE` 2. `.onAttach` prints the message above 3. User reconnects and follows the instruction 4. `civilytics_load_fonts()` → fonts register fine → then dies on `cannot change value of locked binding for '.cv_fonts_loaded'` So the documented remedy fails with an error that says nothing about fonts or networks. Worth noting the fonts *are* registered by the time it throws — the error is the last statement — so `try(civilytics_load_fonts(), silent = TRUE)` genuinely works as a workaround. ## Scope of the fix `<<-` appears exactly once in the package, so this is isolated to the one function: ```r e <- asNamespace("civilytics") Filter(function(nm) { o <- get(nm, envir = e) is.function(o) && grepl("<<-", paste(deparse(o), collapse = "\n"), fixed = TRUE) }, ls(e, all.names = TRUE)) #> [1] "civilytics_load_fonts" ``` ## Suggested addition to the fix Beyond moving the flag into an environment (Option B in the original report), the `.onLoad` handler is worth tightening: it currently attributes *any* error to a Google Fonts failure. Distinguishing a genuine network failure from an internal one would have surfaced this immediately rather than hiding it behind a misleading message — ```r .onLoad <- function(libname, pkgname) { tryCatch( civilytics_load_fonts(), error = function(e) { # only claim a font/network problem if that is what actually happened if (!all(c("Inter", "Libre Franklin", "Source Serif 4", "JetBrains Mono") %in% sysfonts::font_families())) { options(.civilytics_fonts_failed = TRUE) } else { warning("civilytics: fonts loaded but load hook errored: ", conditionMessage(e), call. = FALSE) } } ) } ```
jared added the kodorkodor/fix labels 2026-07-28 11:10:09 -04:00
kodor added the kodor/triaged label 2026-07-29 02:06:42 -04:00
kodor was assigned by jared 2026-07-29 07:02:35 -04:00
kodor added kodor/needs-reviewkodor/needs-review and removed kodor/fix labels 2026-08-01 02:09:44 -04:00
jared closed this issue 2026-08-03 10:47:00 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Civilytics/civilyticsR#18