Skip to content

Commit e780856

Browse files
committed
Commit cpp_options only on a real compilation
A dry run builds nothing, so it no longer records cpp_options_ or moves hpp_file_; the latter previously pointed $hpp_file() at a temporary file the dry run never wrote. exe_file_ and cmdstan_version_ stay in the tail, commented as the deliberate exceptions: during a dry run they are also the configured destination and the toolchain version, so they are assigned on dry runs and on success but never on a failure. cmdstan_version() is now evaluated into a local before any compilation work rather than after the executable is installed. It is not infallible despite being an accessor: set_cmdstan_path() stores PATH and VERSION together, and when read_cmdstan_version() returns NULL the guard falls through and leaves PATH set with VERSION NULL. In that state stanc and make both run and only this call errors. It cannot be hoisted any higher, since an ordinary no-op returns before ever reaching it and would gain a failure mode it does not have today. If discarding the staged candidate fails while another error is being raised, the diagnostic now names the leftover path instead of implying it was removed. The two tests that asserted on $cpp_options() after a dry run move to mocked successful compiles, and the header precedence test asserts that the ignored spelling is dropped rather than retained.
1 parent 6d634fc commit e780856

6 files changed

Lines changed: 99 additions & 27 deletions

File tree

NEWS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ failed because those inputs were dropped. (#1234)
4040
* A `user_header` supplied to `cmdstan_model()` is now used by a later
4141
`$compile()`. Previously it was only honored when the model was compiled
4242
immediately. (#1234)
43+
* `$compile()` now accepts `user_header = NULL` to compile without a user
44+
header. Previously a header, once supplied, could not be removed. (#1235)
45+
* `$compile()` now recompiles when the user header changes. Previously a
46+
different header was ignored if the executable was otherwise up to date. (#1235)
47+
* `$compile()` now reduces duplicate `USER_HEADER`/`user_header` entries in
48+
`cpp_options` to the one actually used, so `$cpp_options()` no longer reports
49+
the ignored spelling after a successful compilation. (#1235)
50+
* A `$compile()` call that finds the executable up to date no longer erases
51+
`$cpp_options()`. Previously the recorded options were replaced with whatever
52+
the call supplied, so a bare `$compile()` on a model built with
53+
`cpp_options = list(stan_threads = TRUE)` dropped `stan_threads` and the
54+
executable then ran single-threaded. (#1235)
55+
* `$expose_functions()` now works after a `$compile()` call that found the
56+
executable up to date. It previously failed with "not possible with a
57+
pre-compiled Stan model" on a model that had compiled itself. (#1235)
58+
* A failed compilation no longer moves `$exe_file()` or replaces the generated
59+
C++ used by `$hpp_file()` and `fit$init_model_methods()`. Previously a failure
60+
at the C++ stage left the old executable paired with model methods generated
61+
from the new program. A `dry_run = TRUE` compilation likewise no longer moves
62+
`$hpp_file()`, which previously pointed at a temporary file it never wrote.
63+
(#1235)
64+
* `$compile()` now errors if the newly compiled executable cannot be installed,
65+
restoring the previous executable. Previously the replacement was unchecked, so
66+
a failure could silently leave the model with no executable at all. (#1235)
4367
* CmdStanModel methods now correctly handle `#include` directories with spaces
4468
in their paths. (#820)
4569
* `$include_paths()` now returns absolute paths, and relative include paths are

R/model.R

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,10 +496,13 @@ NULL
496496
#' the model object is created (or when `$compile()` is called) and stored as
497497
#' absolute paths, so subsequent changes to the working directory do not
498498
#' affect them. If `$compile()` is called again without `include_paths`, the
499-
#' paths used for the previous compilation are reused.
499+
#' most recently supplied paths are reused.
500500
#' @param user_header (string) The path to a C++ file (with a .hpp extension)
501501
#' to compile with the Stan model. If `$compile()` is called again without
502-
#' `user_header`, the header used for the previous compilation is reused.
502+
#' `user_header`, the most recently supplied header is reused, and changing
503+
#' it forces recompilation. Pass `user_header = NULL` to compile without one.
504+
#' A header can also be supplied via `cpp_options` as `USER_HEADER` or
505+
#' `user_header`; the `user_header` argument takes precedence over both.
503506
#' @param cpp_options (list) Any makefile options to be used when compiling the
504507
#' model (`stan_threads`, `stan_mpi`, `stan_opencl`, etc.). Anything you would
505508
#' otherwise write in the `make/local` file. For an example of using threading
@@ -738,6 +741,14 @@ compile <- function(quiet = TRUE,
738741
}
739742
}
740743

744+
# Evaluated here rather than in the tail: cmdstan_version() is not infallible
745+
# despite being an accessor, because set_cmdstan_path() can leave PATH set
746+
# while VERSION stays NULL, and in that state stanc and make both run but this
747+
# errors. Staging it removes a failure point after the executable is already
748+
# installed. It cannot be hoisted above the no-op return, which today never
749+
# reaches this call at all.
750+
compiled_cmdstan_version <- cmdstan_version()
751+
741752
if (os_is_wsl() && (compile_model_methods || compile_standalone)) {
742753
warning("Additional model methods and standalone functions are not ",
743754
"currently available with WSLv1 CmdStan and will not be compiled.",
@@ -862,6 +873,7 @@ compile <- function(quiet = TRUE,
862873
private$user_header_dirty_ <- FALSE
863874
private$hpp_file_ <- hpp_file
864875
private$model_methods_env_ <- model_methods_env
876+
private$cpp_options_ <- cpp_options
865877
private$precompile_cpp_options_ <- NULL
866878
private$precompile_stanc_options_ <- NULL
867879
private$precompile_include_paths_ <- NULL
@@ -871,9 +883,12 @@ compile <- function(quiet = TRUE,
871883
}
872884
} # End - if(!dry_run)
873885

874-
private$cmdstan_version_ <- cmdstan_version()
886+
# Both are exceptions to the rule that state describing the compiled artifact
887+
# is committed only above: during a dry run they are also the configured
888+
# destination and the toolchain version, so they are assigned on a dry run and
889+
# on success -- and, for exe_file_, on a no-op -- but never on a failure.
890+
private$cmdstan_version_ <- compiled_cmdstan_version
875891
private$exe_file_ <- exe
876-
private$cpp_options_ <- cpp_options
877892

878893
if (!dry_run) {
879894
if (compile_model_methods) {

R/utils.R

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,17 @@ copy_temp_files <-
303303
#' describing it.
304304
install_executable <- function(from, to) {
305305
candidate <- tempfile(pattern = "exe-new-", tmpdir = dirname(to))
306+
# Discarding the staged copy can fail too, so the diagnostics say where it was
307+
# left rather than implying it is gone and sending the user looking for a file
308+
# that is still there.
309+
discard_candidate <- function() {
310+
if (unlink(candidate) == 0L) {
311+
""
312+
} else {
313+
paste0(" The staged copy has been left at '", candidate, "'.")
314+
}
315+
}
316+
306317
if (!isTRUE(suppressWarnings(file.copy(from, candidate)))) {
307318
stop(
308319
"Could not stage the compiled executable at '", candidate, "'. ",
@@ -317,10 +328,10 @@ install_executable <- function(from, to) {
317328
error_on_status = FALSE
318329
)
319330
if (is.na(chmod$status) || chmod$status != 0) {
320-
unlink(candidate)
321331
stop(
322332
"Could not make the compiled executable executable. ",
323333
"The model executable at '", to, "' was not modified.",
334+
discard_candidate(),
324335
call. = FALSE
325336
)
326337
}
@@ -330,20 +341,21 @@ install_executable <- function(from, to) {
330341
if (file.exists(to)) {
331342
backup <- tempfile(pattern = "exe-old-", tmpdir = dirname(to))
332343
if (!isTRUE(suppressWarnings(file.rename(to, backup)))) {
333-
unlink(candidate)
334344
stop(
335345
"Could not move the existing executable '", to, "' aside. ",
336346
"It was not modified.",
347+
discard_candidate(),
337348
call. = FALSE
338349
)
339350
}
340351
}
341352

342353
if (!isTRUE(suppressWarnings(file.rename(candidate, to)))) {
343-
unlink(candidate)
354+
leftover_candidate <- discard_candidate()
344355
if (is.null(backup)) {
345356
stop(
346357
"Could not install the compiled executable at '", to, "'.",
358+
leftover_candidate,
347359
call. = FALSE
348360
)
349361
}
@@ -352,12 +364,14 @@ install_executable <- function(from, to) {
352364
"Could not install the compiled executable at '", to, "' and the ",
353365
"previously compiled executable could not be restored. It has been ",
354366
"kept at '", backup, "'.",
367+
leftover_candidate,
355368
call. = FALSE
356369
)
357370
}
358371
stop(
359372
"Could not install the compiled executable at '", to, "'. ",
360373
"The previously compiled executable has been restored.",
374+
leftover_candidate,
361375
call. = FALSE
362376
)
363377
}

man/model-method-compile.Rd

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-model-compile-user_header.R

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ test_that("a no-op compile preserves a header supplied via cpp_options", {
142142
})
143143

144144
test_that("compile() uses a user header supplied to cmdstan_model()", {
145-
stan_file <- testing_stan_file("bernoulli_external")
146145
user_header <- withr::local_tempfile(lines = "", fileext = ".hpp")
147146
received_stancflags <- list()
148147
local_mocked_bindings(
@@ -153,8 +152,18 @@ test_that("compile() uses a user header supplied to cmdstan_model()", {
153152
}
154153
)
155154

156-
model <- cmdstan_model(stan_file, user_header = user_header, compile = FALSE)
157-
model$compile(force_recompile = TRUE, dry_run = TRUE)
155+
model <- cmdstan_model(
156+
local_external_model(),
157+
user_header = user_header,
158+
compile = FALSE
159+
)
160+
# A mocked compile rather than a dry run: a dry run builds nothing, so it
161+
# records nothing about a compiled artifact.
162+
with_mocked_cli(
163+
compile_ret = list(status = 0),
164+
info_ret = list(status = 1),
165+
code = model$compile(force_recompile = TRUE)
166+
)
158167

159168
expect_equal(
160169
model$cpp_options()[["USER_HEADER"]],
@@ -482,15 +491,18 @@ test_that("cmdstan_model works with user_header with mock", {
482491

483492
test_that("wsl path conversion is done as expected", {
484493
tmp_file <- withr::local_tempfile(lines = hpp, fileext = ".hpp")
494+
local_mocked_stanc()
495+
# Mocked successful compiles rather than dry runs: only a compilation that
496+
# produced an executable records the options describing it.
497+
485498
# Case 1: arg
486499
with_mocked_cli(
487-
compile_ret = list(status = 1),
488-
info_ret = list(),
500+
compile_ret = list(status = 0),
501+
info_ret = list(status = 1),
489502
code = {
490503
mod <- cmdstan_model(
491-
stan_file = testing_stan_file("bernoulli_external"),
492-
user_header = tmp_file,
493-
dry_run = TRUE
504+
stan_file = local_external_model(),
505+
user_header = tmp_file
494506
)
495507
}
496508
)
@@ -502,15 +514,14 @@ test_that("wsl path conversion is done as expected", {
502514

503515
# Case 2: cpp opt USER_HEADER
504516
with_mocked_cli(
505-
compile_ret = list(status = 1),
506-
info_ret = list(),
517+
compile_ret = list(status = 0),
518+
info_ret = list(status = 1),
507519
code = {
508520
mod <- cmdstan_model(
509-
stan_file = testing_stan_file("bernoulli_external"),
521+
stan_file = local_external_model(),
510522
cpp_options = list(
511523
USER_HEADER = tmp_file
512-
),
513-
dry_run = TRUE
524+
)
514525
)
515526
}
516527
)
@@ -522,15 +533,14 @@ test_that("wsl path conversion is done as expected", {
522533

523534
# Case # 3: only user_header opt
524535
with_mocked_cli(
525-
compile_ret = list(status = 1),
526-
info_ret = list(),
536+
compile_ret = list(status = 0),
537+
info_ret = list(status = 1),
527538
code = {
528539
mod <- cmdstan_model(
529-
stan_file = testing_stan_file("bernoulli_external"),
540+
stan_file = local_external_model(),
530541
cpp_options = list(
531542
user_header = tmp_file
532-
),
533-
dry_run = TRUE
543+
)
534544
)
535545
}
536546
)

tests/testthat/test-model-compile.R

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,15 @@ test_that("*hpp_file() functions work", {
692692
expect_equal(mod$hpp_file(), file.path(dirname(mod$stan_file()), "bernoulli.hpp"))
693693
mod$save_hpp_file(tmp_dir)
694694
expect_equal(mod$hpp_file(), file.path(tmp_dir, "bernoulli.hpp"))
695+
# A dry run generates no C++, so it leaves the saved location alone rather
696+
# than pointing $hpp_file() at a temporary file that was never written.
695697
mod$compile(force_recompile = TRUE, dry_run = TRUE)
698+
expect_equal(mod$hpp_file(), file.path(tmp_dir, "bernoulli.hpp"))
699+
# A real recompilation does write it, to a fresh temporary location.
700+
expect_call_compilation(mod$compile(force_recompile = TRUE))
696701
expect_false(isTRUE(all.equal(mod$hpp_file(), file.path(tmp_dir, "bernoulli.hpp"))))
697702
expect_false(isTRUE(all.equal(mod$hpp_file(), file.path(dirname(mod$stan_file()), "bernoulli.hpp"))))
703+
checkmate::expect_file_exists(mod$hpp_file())
698704
})
699705

700706
test_that("check_syntax() works", {

0 commit comments

Comments
 (0)