beatrina 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kernel/latex.R CHANGED
@@ -603,7 +603,308 @@ latex_citation_problems <- function(tex_lines, bib_lines, engine, fetch = FALSE)
603
603
 
604
604
  # ── the rapid preview ──────────────────────────────────────────────────────
605
605
  #
606
- # The live preview (a .tex through pandoc to HTML while you type) is answered by
607
- # the supervisor, host/planes/latex.mjs. It lived here while the R supervisor
608
- # did; it was retired with serve.R (docs/host-plan.md §13), and nothing in this
609
- # file's compile half used it.
606
+ # A .tex through pandoc to HTML, fast enough to keep up with typing. This is
607
+ # the "live rich preview" of the retired prototype (spike/latex-editor.html +
608
+ # spike/latex-workbench.mjs, harvested and deleted 2026-09-06), which was
609
+ # scoped here as WP5 in docs/latex-plan.md and cut for byte budget before it
610
+ # was built. The argv is the prototype's, unchanged, because it was already
611
+ # right; what is new is where it runs and what it is allowed to touch.
612
+ #
613
+ # IT IS NOT A COMPILE, and every difference follows from that. A compile is a
614
+ # JOB — detached, streamed, minutes long, and it produces the artefact you
615
+ # publish. This answers one request in under a second and produces something
616
+ # to look at while you write. So it is answered in the supervisor beside
617
+ # `cite` rather than spawned as a job child: a job that outlives the keystroke
618
+ # that asked for it is exactly what must not happen here.
619
+ #
620
+ # WHAT IT MAY READ is the root's own directory and below, and nothing else.
621
+ # TeX itself reads relative to the document, so that bound is the document's
622
+ # own, not an invention — and it is checked after canonicalisation, because a
623
+ # string-prefix test on an unresolved path is how confinement bugs happen
624
+ # (spike/worker.R within_root says the same thing about the same mistake).
625
+
626
+ LATEX_PREVIEW_TIMEOUT_MS <- 8000L
627
+ LATEX_PREVIEW_MAX_SOURCE <- 2e6 # the .tex the page sends
628
+ LATEX_PREVIEW_MAX_HTML <- 6e6 # what pandoc may print back
629
+ LATEX_PREVIEW_MAX_IMAGE <- 1.5e6 # one figure, before base64
630
+ LATEX_PREVIEW_MAX_ASSETS <- 8e6 # every figure together
631
+ LATEX_PREVIEW_MAX_FILES <- 40L # buffers, and includes followed
632
+ LATEX_PREVIEW_DEPTH <- 6L
633
+
634
+ # What an <img> can actually show. A .pdf or .eps figure is normal in a real
635
+ # paper and cannot be inlined, so it is REPORTED rather than dropped — the
636
+ # pane says "3 figures are only in the PDF", which is true and actionable,
637
+ # where a blank space says nothing.
638
+ LATEX_PREVIEW_IMAGE_TYPES <- c(png = "image/png", jpg = "image/jpeg",
639
+ jpeg = "image/jpeg", gif = "image/gif",
640
+ svg = "image/svg+xml", webp = "image/webp")
641
+
642
+ #' Is `path` inside `dir`, after both are canonicalised?
643
+ #'
644
+ #' The trailing separator is load-bearing: without it, /p/figs would also
645
+ #' admit /p/figs-private. Mirrors plugin_contained() in spike/plugins.R.
646
+ #' @param dir Directory that bounds the read.
647
+ #' @param path Candidate file.
648
+ #' @return The resolved path, or "" when it is outside or absent.
649
+ latex_within <- function(dir, path) {
650
+ if (!latex_scalar(dir) || !latex_scalar(path) || !dir.exists(dir)) return("")
651
+ root <- normalizePath(dir, winslash = "/", mustWork = TRUE)
652
+ full <- if (grepl("^([A-Za-z]:)?[/\\\\]", path)) path else file.path(root, path)
653
+ if (!file.exists(full)) return("")
654
+ full <- normalizePath(full, winslash = "/", mustWork = TRUE)
655
+ if (!identical(full, root) && !startsWith(full, paste0(root, "/"))) return("")
656
+ full
657
+ }
658
+
659
+ #' Pull `\input`/`\include`/`\subfile` bodies into one source, so the preview
660
+ #' shows the whole paper rather than its first chapter.
661
+ #'
662
+ #' Buffers the page sent win over the disk copy, because the point of a live
663
+ #' preview is UNSAVED text. Bounded by depth and by file count, and a file
664
+ #' already on the stack is skipped rather than followed — an include cycle is
665
+ #' a document someone is halfway through writing, not an attack, and it must
666
+ #' cost a stack frame instead of the session.
667
+ #' @param source The root's text.
668
+ #' @param dir Directory the root lives in.
669
+ #' @param buffers Named list of relative path -> text, from open editors.
670
+ #' @param seen Paths already on this branch.
671
+ #' @param depth Remaining depth.
672
+ #' @return One string.
673
+ latex_preview_expand <- function(source, dir, buffers = list(), seen = character(0), depth = LATEX_PREVIEW_DEPTH) {
674
+ if (depth <= 0L) return(source)
675
+ pattern <- "\\\\(?:input|include|subfile)\\{([^{}]{1,200})\\}"
676
+ m <- gregexpr(pattern, source, perl = TRUE)[[1L]]
677
+ if (identical(as.integer(m[1L]), -1L)) return(source)
678
+ starts <- as.integer(m)
679
+ lens <- attr(m, "match.length")
680
+ out <- character(0)
681
+ at <- 1L
682
+ for (i in seq_along(starts)) { # one splice per include
683
+ whole <- substr(source, starts[i], starts[i] + lens[i] - 1L)
684
+ rel <- sub(pattern, "\\1", whole, perl = TRUE)
685
+ if (!grepl("\\.[A-Za-z0-9]{1,5}$", rel)) rel <- paste0(rel, ".tex")
686
+ body <- NULL
687
+ if (!rel %in% seen && length(seen) < LATEX_PREVIEW_MAX_FILES) {
688
+ if (!is.null(buffers[[rel]])) body <- buffers[[rel]]
689
+ else {
690
+ hit <- latex_within(dir, rel)
691
+ if (nzchar(hit) && file.info(hit)$size <= LATEX_PREVIEW_MAX_SOURCE) {
692
+ body <- tryCatch(paste(readLines(hit, warn = FALSE), collapse = "\n"),
693
+ error = function(e) NULL)
694
+ }
695
+ }
696
+ }
697
+ out <- c(out, substr(source, at, starts[i] - 1L),
698
+ if (is.null(body)) "" else
699
+ latex_preview_expand(body, dir, buffers, c(seen, rel), depth - 1L))
700
+ at <- starts[i] + lens[i]
701
+ }
702
+ paste0(paste(out, collapse = ""), substr(source, at, nchar(source)))
703
+ }
704
+
705
+ #' The .bib files a source names, resolved and bounded.
706
+ #' @param source Expanded LaTeX.
707
+ #' @param dir Directory that bounds the read.
708
+ #' @return Character vector of absolute paths, possibly empty.
709
+ latex_preview_bibliography <- function(source, dir) {
710
+ m <- regmatches(source, gregexpr("\\\\(?:bibliography|addbibresource)(?:\\[[^]]*\\])?\\{([^{}]{1,300})\\}",
711
+ source, perl = TRUE))[[1L]]
712
+ if (!length(m)) return(character(0))
713
+ named <- unlist(lapply(m, function(one) {
714
+ inner <- sub(".*\\{([^{}]*)\\}$", "\\1", one)
715
+ trimws(strsplit(inner, ",", fixed = TRUE)[[1L]])
716
+ }), use.names = FALSE)
717
+ named <- named[nzchar(named)]
718
+ named <- ifelse(grepl("\\.[A-Za-z0-9]{1,5}$", named), named, paste0(named, ".bib"))
719
+ found <- vapply(unique(named), function(one) latex_within(dir, one), character(1))
720
+ unname(found[nzchar(found)])
721
+ }
722
+
723
+ #' Turn every local `<img src>` into a data: URI.
724
+ #'
725
+ #' The kernel does this itself, over the HTML IT just produced, so no image
726
+ #' path ever travels on the wire in either direction — the same rule `job_open`
727
+ #' follows when it names a job rather than a file. `readfile` could not have
728
+ #' served here at all: it decodes bytes as UTF-8 or Latin-1 (spike/worker.R
729
+ #' text_from_bytes), so a PNG through it is not a PNG.
730
+ #' @param html pandoc's output.
731
+ #' @param dir Directory that bounds the read.
732
+ #' @return list(html, inlined, dropped) — dropped names what could not be shown.
733
+ latex_preview_inline <- function(html, dir) {
734
+ hits <- gregexpr('src="([^"]{1,400})"', html, perl = TRUE)[[1L]]
735
+ if (identical(as.integer(hits[1L]), -1L)) return(list(html = html, inlined = 0L, dropped = character(0)))
736
+ starts <- as.integer(hits)
737
+ lens <- attr(hits, "match.length")
738
+ out <- character(0)
739
+ at <- 1L
740
+ inlined <- 0L
741
+ budget <- LATEX_PREVIEW_MAX_ASSETS
742
+ dropped <- character(0)
743
+ for (i in seq_along(starts)) { # one rewrite per image
744
+ whole <- substr(html, starts[i], starts[i] + lens[i] - 1L)
745
+ src <- sub('^src="(.*)"$', "\\1", whole)
746
+ replacement <- whole
747
+ if (!grepl("^(?:data:|https?:|//)", src, perl = TRUE)) {
748
+ ext <- tolower(sub(".*\\.([A-Za-z0-9]+)$", "\\1", src))
749
+ mime <- unname(LATEX_PREVIEW_IMAGE_TYPES[ext])
750
+ # A figure named without its extension is what \includegraphics{fig}
751
+ # writes, and TeX picks the file. Try the types an <img> can show, in
752
+ # the order a person would expect them to win.
753
+ candidates <- if (identical(src, ext) || is.na(mime))
754
+ paste0(sub("\\.[A-Za-z0-9]+$", "", src), ".", names(LATEX_PREVIEW_IMAGE_TYPES)) else src
755
+ hit <- ""
756
+ for (cand in candidates) { # first existing wins
757
+ hit <- latex_within(dir, cand)
758
+ if (nzchar(hit)) { mime <- unname(LATEX_PREVIEW_IMAGE_TYPES[tolower(sub(".*\\.([A-Za-z0-9]+)$", "\\1", cand))]); break }
759
+ }
760
+ if (!nzchar(hit) || is.na(mime)) dropped <- c(dropped, basename(src))
761
+ else {
762
+ size <- file.info(hit)$size
763
+ if (!is.finite(size) || size > LATEX_PREVIEW_MAX_IMAGE || size > budget) {
764
+ dropped <- c(dropped, basename(src))
765
+ } else {
766
+ bytes <- tryCatch(readBin(hit, "raw", n = size), error = function(e) NULL)
767
+ if (is.null(bytes)) dropped <- c(dropped, basename(src))
768
+ else {
769
+ budget <- budget - size
770
+ inlined <- inlined + 1L
771
+ replacement <- paste0('src="data:', mime, ';base64,', jsonlite::base64_enc(bytes), '"')
772
+ }
773
+ }
774
+ }
775
+ }
776
+ out <- c(out, substr(html, at, starts[i] - 1L), replacement)
777
+ at <- starts[i] + lens[i]
778
+ }
779
+ list(html = paste0(paste(out, collapse = ""), substr(html, at, nchar(html))),
780
+ inlined = inlined, dropped = unique(dropped))
781
+ }
782
+
783
+ #' Highlighted code blocks as plain verbatim, for the PREVIEW only.
784
+ #'
785
+ #' The LaTeX export writes R chunks as fancyvrb `Highlighting` blocks
786
+ #' (lib/md/latex.js `highlighted`) — correct for a TeX engine, but pandoc's
787
+ #' LaTeX reader does not know `\DefineVerbatimEnvironment{Highlighting}` is
788
+ #' verbatim and renders each block as an EMPTY div, so the preview showed a
789
+ #' document with its code missing while the PDF was right (found by the
790
+ #' Beatrix fork, 2026-09-15). Each block is rewritten to `\begin{verbatim}`
791
+ #' with the token macros dropped and lib/md/latex.js CODE_CH undone. The file
792
+ #' on disk and the compile are untouched.
793
+ #'
794
+ #' Escapes become sentinels BEFORE the macro braces are stripped, so a literal
795
+ #' `{` in the code survives. A code line holding `\end{verbatim}` would close
796
+ #' the environment early; it gains a zero-width space after the backslash,
797
+ #' invisible in the preview.
798
+ #'
799
+ #' @param source LaTeX text.
800
+ #' @return The text with every Shaded/Highlighting block replaced.
801
+ latex_preview_code <- function(source) {
802
+ pattern <- paste0("(?s)(?:\\\\begin\\{Shaded\\}[ \\t]*\\n?)?",
803
+ "\\\\begin\\{Highlighting\\}(?:\\[[^]\\n]*\\])?\\n?(.*?)\\n?",
804
+ "\\\\end\\{Highlighting\\}(?:[ \\t]*\\n?\\\\end\\{Shaded\\})?")
805
+ hits <- gregexpr(pattern, source, perl = TRUE)[[1L]]
806
+ if (identical(as.integer(hits[1L]), -1L)) return(source)
807
+ blocks <- regmatches(source, list(hits))[[1L]]
808
+ # Order matters only for \textbackslash{}, whose braces must not be read as
809
+ # a {-} or stripped first; every other entry is a distinct sequence.
810
+ swaps <- c("\\textbackslash{}" = "\001", "\\textasciitilde{}" = "~", "\\^{}" = "^",
811
+ "\\textless{}" = "<", "\\textgreater{}" = ">", "{-}" = "-",
812
+ "\\{" = "\002", "\\}" = "\003", "\\#" = "#", "\\%" = "%",
813
+ "\\_" = "_", "\\&" = "&")
814
+ plain <- vapply(blocks, function(block) {
815
+ body <- sub(pattern, "\\1", block, perl = TRUE)
816
+ body <- Reduce(function(text, from) gsub(from, swaps[[from]], text, fixed = TRUE),
817
+ names(swaps), body)
818
+ body <- gsub("\\\\[A-Za-z]+Tok\\{", "", body, perl = TRUE)
819
+ body <- gsub("[{}]", "", body, perl = TRUE)
820
+ body <- chartr("\001\002\003", "\\{}", body)
821
+ body <- gsub("\\end{verbatim}", "\\\u200bend{verbatim}", body, fixed = TRUE)
822
+ paste0("\\begin{verbatim}\n", body, "\n\\end{verbatim}")
823
+ }, character(1), USE.NAMES = FALSE)
824
+ regmatches(source, list(hits)) <- list(plain)
825
+ source
826
+ }
827
+
828
+ #' Render one LaTeX source to HTML for the preview pane.
829
+ #'
830
+ #' @param source The root's text, as the editor holds it.
831
+ #' @param path Absolute path of the root, for resolving its neighbours.
832
+ #' @param buffers Optional named list of relative path -> unsaved text.
833
+ #' @param timeout_ms Deadline.
834
+ #' @return list(ok, html, warnings, elapsed_ms, inlined, dropped) or
835
+ #' list(ok = FALSE, error).
836
+ latex_preview <- function(source, path, buffers = list(), timeout_ms = LATEX_PREVIEW_TIMEOUT_MS) {
837
+ if (!latex_scalar(source)) return(list(ok = FALSE, error = "No LaTeX source to preview."))
838
+ if (nchar(source, type = "bytes") > LATEX_PREVIEW_MAX_SOURCE) {
839
+ return(list(ok = FALSE, error = "This document is too large to preview while you type."))
840
+ }
841
+ bin <- find_pandoc()
842
+ if (!nzchar(bin)) {
843
+ return(list(ok = FALSE, error = "pandoc is not installed, or CarmaR could not find it. Compile still works; only the live preview needs pandoc."))
844
+ }
845
+ dir <- if (latex_scalar(path) && file.exists(path)) dirname(normalizePath(path, winslash = "/", mustWork = TRUE)) else ""
846
+ keep <- list()
847
+ if (is.list(buffers)) {
848
+ names_ok <- names(buffers) %||% character(0)
849
+ for (nm in utils::head(names_ok[nzchar(names_ok)], LATEX_PREVIEW_MAX_FILES)) {
850
+ if (latex_scalar(buffers[[nm]])) keep[[nm]] <- buffers[[nm]]
851
+ }
852
+ }
853
+ expanded <- if (nzchar(dir)) latex_preview_expand(source, dir, keep) else source
854
+ expanded <- latex_preview_code(expanded)
855
+ started <- Sys.time()
856
+ args <- c("--from=latex", "--to=html5", "--mathml", "--wrap=none",
857
+ "--standalone", "--sandbox")
858
+ if (nzchar(dir)) {
859
+ for (bib in latex_preview_bibliography(expanded, dir)) {
860
+ args <- c(args, "--citeproc", paste0("--bibliography=", bib))
861
+ }
862
+ }
863
+ # A FILE, not stdin: processx::run has no `input` argument (it takes a path
864
+ # for `stdin`), and cite_run already writes its document to a 0600 temp file
865
+ # for the same reason. pandoc emits the figure paths unchanged either way —
866
+ # this function resolves them itself, against the ROOT's directory rather
867
+ # than the scratch one, which is what makes the temp file harmless here.
868
+ input <- tempfile("carmar-preview-", fileext = ".tex")
869
+ on.exit(unlink(input), add = TRUE)
870
+ writeLines(expanded, input, useBytes = TRUE)
871
+ Sys.chmod(input, "0600")
872
+ ran <- tryCatch(processx::run(
873
+ bin, c(args, input), timeout = timeout_ms / 1000, error_on_status = FALSE,
874
+ echo = FALSE, cleanup_tree = TRUE,
875
+ # HOME and the data dir moved aside for the same reason cite_run moves
876
+ # them: a render must not read or write this user's pandoc configuration.
877
+ env = c("current", PANDOC_DATA_DIR = tempdir(), HOME = tempdir())
878
+ ), error = function(e) e)
879
+ if (inherits(ran, "error")) {
880
+ msg <- conditionMessage(ran)
881
+ return(list(ok = FALSE, error = if (grepl("timeout", msg, ignore.case = TRUE))
882
+ "The preview took too long and was stopped." else paste0("preview: ", msg)))
883
+ }
884
+ if (isTRUE(ran$timeout)) return(list(ok = FALSE, error = "The preview took too long and was stopped."))
885
+ if (!identical(as.integer(ran$status), 0L)) {
886
+ first <- strsplit(ran$stderr %||% "", "\n", fixed = TRUE)[[1L]][1L] %||% ""
887
+ # pandoc names the file it was reading, and that file is our scratch copy.
888
+ # Printing "/var/folders/.../carmar-preview-8b42.tex (line 2, column 2)"
889
+ # at a person tells them nothing and looks like a fault in CarmaR; the
890
+ # line and column are the useful half, so the path becomes the document's
891
+ # own name. An unclosed group is the ORDINARY state of a document being
892
+ # typed, which is why this path is a reported note and not a failure —
893
+ # lib/latex-preview.js keeps the last good render behind it.
894
+ shown <- if (latex_scalar(path) && nzchar(path)) basename(path) else "the document"
895
+ first <- gsub(input, shown, first, fixed = TRUE)
896
+ return(list(ok = FALSE, partial = TRUE,
897
+ error = paste0("pandoc could not read this document",
898
+ if (nzchar(first)) paste0(": ", first) else ".")))
899
+ }
900
+ html <- ran$stdout %||% ""
901
+ if (nchar(html, type = "bytes") > LATEX_PREVIEW_MAX_HTML) {
902
+ return(list(ok = FALSE, error = "The rendered preview is too large to show."))
903
+ }
904
+ figures <- if (nzchar(dir)) latex_preview_inline(html, dir)
905
+ else list(html = html, inlined = 0L, dropped = character(0))
906
+ list(ok = TRUE, html = figures$html,
907
+ warnings = trimws(ran$stderr %||% ""),
908
+ elapsed_ms = as.numeric(difftime(Sys.time(), started, units = "secs")) * 1000,
909
+ inlined = figures$inlined, dropped = figures$dropped)
910
+ }
package/kernel/worker.R CHANGED
@@ -120,7 +120,7 @@ import_sources <- local({
120
120
  else if (length(file_arg)) {
121
121
  dirname(normalizePath(sub("^--file=", "", file_arg[1L]), mustWork = FALSE))
122
122
  } else getwd()
123
- file.path(here, c("fileio.R", "sniff.R", "project.R"))
123
+ file.path(here, c("fileio.R", "sniff.R", "project.R", "workspace-keep.R"))
124
124
  })
125
125
  # environment(), not parent.frame(): inside a nested local() the parent frame
126
126
  # is the eval machinery's, not this file's private scope, and the functions
@@ -471,6 +471,29 @@ if (!(INPUT_SHADOW %in% search())) {
471
471
  name = INPUT_SHADOW, warn.conflicts = FALSE)
472
472
  }
473
473
 
474
+ #' Never ask "Hit <Return> to see next plot".
475
+ #'
476
+ #' This worker is interactive, so a plot that turns page prompting on —
477
+ #' tna's plot() for cliques does `par(ask = TRUE)` by default — makes the
478
+ #' graphics engine read the console before each new page. That read is not
479
+ #' readline(), so nothing announces it: the chunk spun forever and every
480
+ #' chunk behind it queued (test/lab-rmd.e2e, the lab's `plot(cliques_of_two,
481
+ #' 4)`). Every page is captured as its own figure here, so there is no page to
482
+ #' wait for. The hooks run before the engine's check on every new page, base
483
+ #' graphics and grid alike; a package's own `par(ask = TRUE)` is left in place
484
+ #' and simply never gets to ask.
485
+ #'
486
+ #' @return Invisibly NULL.
487
+ carmar_no_page_prompt <- function() {
488
+ if (isTRUE(grDevices::devAskNewPage())) grDevices::devAskNewPage(FALSE)
489
+ invisible(NULL)
490
+ }
491
+ local({
492
+ already <- function(hook) any(vapply(getHook(hook), identical, logical(1), carmar_no_page_prompt))
493
+ if (!already("before.plot.new")) setHook("before.plot.new", carmar_no_page_prompt)
494
+ if (!already("before.grid.newpage")) setHook("before.grid.newpage", carmar_no_page_prompt)
495
+ })
496
+
474
497
  #' A function's formals as one display string, defaults included.
475
498
  #'
476
499
  #' `formals()` is NULL for primitives like `sum`; `args()` still knows their
@@ -1110,6 +1133,57 @@ completion_columns <- function(data, max_items = MAX_COMPLETIONS) {
1110
1133
  lapply(cols, function(nm) list(value = nm, type = class(df[[nm]])[1L]))
1111
1134
  }
1112
1135
 
1136
+ #' A progress callback for keep_save()/keep_resume() that emits id-less
1137
+ #' `keep_progress` frames — the supervisor broadcasts an id-less frame to every
1138
+ #' page, which is what lets the page that asked AND a page that follows a
1139
+ #' handoff both draw it. Throttled to four a second; the last one always goes.
1140
+ #' @param phase "save" or "restore".
1141
+ #' @return function(done, total, bytes, name).
1142
+ keep_progress_emitter <- function(phase) {
1143
+ last <- 0
1144
+ function(done, total, bytes = 0, name = "") {
1145
+ now <- as.numeric(Sys.time())
1146
+ if (done < total && now - last < 0.25) return(invisible(NULL))
1147
+ last <<- now
1148
+ emit(list(type = "keep_progress", phase = phase, done = done, total = total,
1149
+ bytes = as.numeric(bytes %||% 0), name = as.character(name)[1L]))
1150
+ }
1151
+ }
1152
+
1153
+ #' "Restart, keep variables", step one: save the global environment
1154
+ #' (spike/workspace-keep.R). No deadline — Stop (the supervisor's
1155
+ #' suspend_cancel) interrupts it, and the partial copy is removed.
1156
+ #' @param id Request id.
1157
+ #' @return Invisibly NULL. Emits one `suspend` frame: token, saved, bytes,
1158
+ #' skipped — or cancelled, or error.
1159
+ emit_suspend <- function(id) {
1160
+ out <- tryCatch(keep_save(globalenv(), progress = keep_progress_emitter("save")),
1161
+ error = function(e) list(error = conditionMessage(e)),
1162
+ interrupt = function(i) list(cancelled = TRUE))
1163
+ emit(c(list(type = "suspend", id = id), out))
1164
+ }
1165
+
1166
+ #' "Restart, keep variables", step two, in the NEW worker: load the kept
1167
+ #' objects back and restore the working directory. Asked by the supervisor
1168
+ #' only, never by a page (it is not in FORWARDED), with a token the save minted.
1169
+ #' @param id Request id.
1170
+ #' @param token The kept workspace's token.
1171
+ #' @return Invisibly NULL. Emits one `resume` frame: restored, failed, skipped,
1172
+ #' cwd — or error.
1173
+ emit_resume <- function(id, token) {
1174
+ out <- tryCatch(keep_resume(token, globalenv(), progress = keep_progress_emitter("restore")),
1175
+ error = function(e) list(error = conditionMessage(e)),
1176
+ interrupt = function(i) list(error = "The restore was interrupted."))
1177
+ # The directory R was in comes back too — unless it is gone (the old R's
1178
+ # tempdir() dies with the old R), which the report then says in words.
1179
+ if (is.character(out$wd) && nzchar(out$wd)) {
1180
+ moved <- dir.exists(out$wd) && !inherits(try(setwd(out$wd), silent = TRUE), "try-error")
1181
+ if (!moved) out$wd_missing <- out$wd
1182
+ }
1183
+ out$wd <- NULL
1184
+ emit(c(list(type = "resume", id = id, cwd = getwd()), out))
1185
+ }
1186
+
1113
1187
  #' Coerce a wire-supplied count, falling back rather than erroring: a malformed
1114
1188
  #' offset from a client must degrade to the default, not kill the pane.
1115
1189
  #'
@@ -3308,13 +3382,13 @@ if (identical(unname(Sys.info()[["sysname"]]), "Darwin") &&
3308
3382
  # Advertising the vocabulary lets a client know instantly. Kernels older than
3309
3383
  # this simply omit the field, and clients fall back to probing.
3310
3384
  #
3311
- # A fresh worker is a fresh R, always. Until 7.59 a "Restart into" handoff
3312
- # asked this worker to `save.image()` and the successor's first worker loaded
3313
- # it back (`workspace_save` / CARMAR_RESTORE_WORKSPACE / a `restored` field
3314
- # here). That is gone by decision — the owner's "restore work has been
3315
- # abysmally bad and almost invariably a failure" — and the rule is: a session
3316
- # is a process you can see; open → R starts, close → R stops, restart → a
3317
- # fresh R. The saved document is the record. Do not add a restore path back.
3385
+ # A worker starts as a fresh R. Until 7.59 a "Restart into" handoff asked this
3386
+ # worker to `save.image()` and the successor loaded one file back inside a
3387
+ # deadline; that failed on large sessions and was removed ("restore work has
3388
+ # been abysmally bad"). Since 0.8.5 "Restart, keep variables" is an explicit
3389
+ # choice built differently (spike/workspace-keep.R): the page asks `suspend`
3390
+ # with no deadline, the new worker is asked `resume` by the supervisor after
3391
+ # `ready`, one file per object, and the report names what did not come back.
3318
3392
  emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(),
3319
3393
  # I(): a single library path must still ship as an array.
3320
3394
  home = R.home(), libs = I(.libPaths()),
@@ -3329,7 +3403,7 @@ emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(
3329
3403
  "help", "hover", "wd", "files", "sniff",
3330
3404
  "import", "readfile", "writefile", "writefiles_atomic", "view", "colstats",
3331
3405
  "mkdir", "renamepath", "deletepath", "copypath", "revealpath",
3332
- "rm",
3406
+ "rm", "suspend",
3333
3407
  if (identical(WORKER_MODE, "interactive")) "debug_breaks"))))
3334
3408
  # (No package count here on purpose: installed.packages() reads every
3335
3409
  # package's DESCRIPTION — 0.3–1.8 s on a big library — and no client ever
@@ -3379,6 +3453,8 @@ carmar_dispatch <- function(cmd) {
3379
3453
  if (identical(cmd$type, "doctor")) emit_doctor(cmd$id)
3380
3454
  if (identical(cmd$type, "complete")) emit_complete(cmd$id, cmd$line, cmd$cursor,
3381
3455
  fn = cmd$fn, data = cmd$data)
3456
+ if (identical(cmd$type, "suspend")) emit_suspend(cmd$id)
3457
+ if (identical(cmd$type, "resume")) emit_resume(cmd$id, cmd$token)
3382
3458
  if (identical(cmd$type, "packages")) emit_packages(cmd$id, cmd$scope)
3383
3459
  if (identical(cmd$type, "package_action")) emit_package_action(cmd$id, cmd$action, cmd$name, cmd$lib)
3384
3460
  if (identical(cmd$type, "package_help")) emit_package_help(cmd$id, cmd$name)
@@ -0,0 +1,188 @@
1
+ # workspace-keep.R — "Restart, keep variables": the global environment carried
2
+ # across a restart, the way RStudio carries it.
3
+ #
4
+ # Sourced into the WORKER's private scope (worker.R's import_sources), and
5
+ # testable without a worker: spike/test-workspace-keep.R sources this file
6
+ # alone.
7
+ #
8
+ # This replaces a removed design, and the difference is the point. Until 7.59
9
+ # a restart ran save.image() inside a handoff with a deadline and load()ed one
10
+ # file back; large sessions missed the deadline, one unreadable object sank the
11
+ # whole file, and nothing said what was lost. The owner removed it ("restore
12
+ # work has been abysmally bad"). What is here instead:
13
+ #
14
+ # * NO DEADLINE. The save is a worker command like any other: progress frames
15
+ # while it runs, Interrupt (Stop) cancels it, and the restart only happens
16
+ # once the save has answered.
17
+ # * ONE FILE PER OBJECT. An object that fails to save or to load is that
18
+ # object, named in the report — never the whole workspace.
19
+ # * PACKAGES ARE NOT RECORDED. A notebook's library() calls live in its setup
20
+ # chunk, and the page runs setup before the first chunk of a fresh R
21
+ # (lib/document-execution.js) — RStudio's own answer, and the reason this
22
+ # file keeps variables only.
23
+ # * WHAT CANNOT TRAVEL IS SAID. A connection or an external pointer
24
+ # serialises to a dead handle; it is skipped at save time and listed.
25
+ #
26
+ # Known limit, stated rather than hidden: objects are saved separately, so two
27
+ # objects that SHARE an environment (R6 objects pointing at one another) come
28
+ # back as copies that no longer share it.
29
+ #
30
+ # The token names a directory under the keep root and is the only thing that
31
+ # travels: the page and the supervisor never pass a path, so a resume can only
32
+ # ever read a directory this code wrote.
33
+
34
+ #' Where kept workspaces live: 0700, per user. `CARMAR_KEEP_DIR` relocates it
35
+ #' for tests.
36
+ #' @return A directory path (not created).
37
+ keep_root <- function() {
38
+ env <- Sys.getenv("CARMAR_KEEP_DIR", "")
39
+ if (nzchar(env)) env else file.path(tools::R_user_dir("carmar", "data"), "kept-workspace")
40
+ }
41
+
42
+ KEEP_TOKEN_RE <- "^[0-9]{14}-[0-9a-f]{16}$"
43
+
44
+ #' Is `x` a token this file could have minted?
45
+ #' @param x Anything the wire delivered.
46
+ #' @return TRUE for one well-formed token.
47
+ keep_token_ok <- function(x) {
48
+ is.character(x) && length(x) == 1L && !is.na(x) && grepl(KEEP_TOKEN_RE, x)
49
+ }
50
+
51
+ #' A fresh token: a timestamp (sorts, and ages out) plus 64 random bits.
52
+ #' @return A token matching KEEP_TOKEN_RE.
53
+ keep_token_new <- function() {
54
+ bytes <- tryCatch({
55
+ con <- file("/dev/urandom", "rb", raw = TRUE)
56
+ on.exit(close(con), add = TRUE)
57
+ readBin(con, "raw", n = 8L)
58
+ }, error = function(e) as.raw(sample.int(256L, 8L, replace = TRUE) - 1L))
59
+ paste0(format(Sys.time(), "%Y%m%d%H%M%S"), "-", paste(format(bytes), collapse = ""))
60
+ }
61
+
62
+ #' Why an object cannot travel, or NULL when it can.
63
+ #'
64
+ #' A connection is an integer index into THIS process's connection table, and
65
+ #' an external pointer is an address in this process's memory: both serialise
66
+ #' without complaint and come back as something that looks right and is dead.
67
+ #' @param x The object.
68
+ #' @return NULL, or a short phrase for the report.
69
+ keep_unsaveable <- function(x) {
70
+ if (inherits(x, "connection")) return("a connection (a file or URL handle)")
71
+ if (inherits(x, "DBIConnection")) return("a database connection")
72
+ if (typeof(x) == "externalptr") return("an external pointer")
73
+ NULL
74
+ }
75
+
76
+ #' Delete kept workspaces older than `max_age_s` — a restart that never
77
+ #' resumed (a crash, a closed laptop) must not keep a copy of someone's data
78
+ #' forever.
79
+ #' @param root The keep root.
80
+ #' @param max_age_s Age in seconds.
81
+ #' @return Invisibly, the tokens removed.
82
+ keep_prune <- function(root = keep_root(), max_age_s = 86400) {
83
+ if (!dir.exists(root)) return(invisible(character(0)))
84
+ tokens <- Filter(keep_token_ok, list.files(root))
85
+ old <- Filter(function(t) {
86
+ age <- as.numeric(difftime(Sys.time(), file.info(file.path(root, t))$mtime, units = "secs"))
87
+ isTRUE(age > max_age_s)
88
+ }, tokens)
89
+ invisible(vapply(old, function(t) { unlink(file.path(root, t), recursive = TRUE); t }, character(1)))
90
+ }
91
+
92
+ #' Save every object of `env` into a new kept workspace.
93
+ #'
94
+ #' @param env The environment to keep (the worker passes globalenv()).
95
+ #' @param progress function(done, total, bytes, name) called after each object.
96
+ #' @param root The keep root.
97
+ #' @return list(token, saved, bytes, skipped = list of {name, why}).
98
+ #' An interrupt removes the partial directory and propagates, so the caller
99
+ #' reports "cancelled" and nothing half-written is ever resumed.
100
+ keep_save <- function(env = globalenv(), progress = function(...) NULL, root = keep_root()) {
101
+ stopifnot("`env` must be an environment" = is.environment(env))
102
+ dir.create(root, recursive = TRUE, showWarnings = FALSE, mode = "0700")
103
+ Sys.chmod(root, mode = "0700")
104
+ keep_prune(root)
105
+ token <- keep_token_new()
106
+ dir <- file.path(root, token)
107
+ dir.create(dir, mode = "0700")
108
+ names_ <- sort(ls(env, all.names = TRUE))
109
+ total <- length(names_)
110
+ bytes <- 0
111
+ done <- FALSE
112
+ on.exit(if (!done) unlink(dir, recursive = TRUE), add = TRUE)
113
+ # Objects are indexed, never named, on disk: an object name may hold any
114
+ # character, and a file name must not.
115
+ entries <- lapply(seq_along(names_), function(i) {
116
+ name <- names_[[i]]
117
+ value <- get(name, envir = env, inherits = FALSE)
118
+ why <- keep_unsaveable(value)
119
+ entry <- if (!is.null(why)) list(name = name, status = "skipped", why = why)
120
+ else {
121
+ file <- sprintf("%06d.rds", i)
122
+ failed <- tryCatch({ saveRDS(value, file.path(dir, file), compress = FALSE); NULL },
123
+ error = function(e) conditionMessage(e))
124
+ if (is.null(failed)) {
125
+ bytes <<- bytes + file.size(file.path(dir, file))
126
+ list(name = name, status = "saved", file = file, class = class(value)[1L])
127
+ } else {
128
+ unlink(file.path(dir, file))
129
+ list(name = name, status = "skipped", why = paste("could not be saved:", failed))
130
+ }
131
+ }
132
+ progress(i, total, bytes, name)
133
+ entry
134
+ })
135
+ manifest <- list(version = 1L, created = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"),
136
+ wd = getwd(), entries = entries)
137
+ writeLines(jsonlite::toJSON(manifest, auto_unbox = TRUE), file.path(dir, "manifest.json"))
138
+ Sys.chmod(list.files(dir, full.names = TRUE), mode = "0600")
139
+ done <- TRUE
140
+ status <- vapply(entries, `[[`, "", "status")
141
+ list(token = token, saved = sum(status == "saved"), bytes = bytes,
142
+ skipped = lapply(entries[status == "skipped"], function(e) list(name = e$name, why = e$why)))
143
+ }
144
+
145
+ #' Load a kept workspace into `env`, then delete it.
146
+ #'
147
+ #' Each object is read on its own; one that fails is reported and the rest
148
+ #' still arrive. The directory is removed afterwards whatever happened — a
149
+ #' kept workspace is used once.
150
+ #' @param token A token from keep_save().
151
+ #' @param env Where the objects go (the worker passes globalenv()).
152
+ #' @param progress function(done, total, bytes, name) called after each object
153
+ #' (the same shape keep_save() calls, so one callback serves both; bytes is 0).
154
+ #' @param root The keep root.
155
+ #' @return list(restored, failed = list of {name, why}, skipped = the save's
156
+ #' skips, carried so the one report says everything), or list(error).
157
+ keep_resume <- function(token, env = globalenv(), progress = function(...) NULL, root = keep_root()) {
158
+ if (!keep_token_ok(token)) return(list(error = "That is not a kept workspace."))
159
+ dir <- file.path(root, token)
160
+ manifest_file <- file.path(dir, "manifest.json")
161
+ if (!file.exists(manifest_file)) {
162
+ return(list(error = "The kept workspace is gone — it was already restored or removed."))
163
+ }
164
+ on.exit(unlink(dir, recursive = TRUE), add = TRUE)
165
+ manifest <- tryCatch(jsonlite::fromJSON(manifest_file, simplifyVector = FALSE),
166
+ error = function(e) NULL)
167
+ if (is.null(manifest) || !is.list(manifest$entries)) {
168
+ return(list(error = "The kept workspace's record could not be read."))
169
+ }
170
+ saved <- Filter(function(e) identical(e$status, "saved"), manifest$entries)
171
+ total <- length(saved)
172
+ outcomes <- lapply(seq_along(saved), function(i) {
173
+ e <- saved[[i]]
174
+ # The file name is re-checked, never trusted: only NNNNNN.rds inside dir.
175
+ why <- if (!is.character(e$file) || !grepl("^[0-9]{6}\\.rds$", e$file)) "its record is malformed"
176
+ else tryCatch({
177
+ assign(e$name, readRDS(file.path(dir, e$file)), envir = env)
178
+ NULL
179
+ }, error = function(err) conditionMessage(err))
180
+ progress(i, total, 0, e$name)
181
+ if (is.null(why)) NULL else list(name = e$name, why = paste("could not be restored:", why))
182
+ })
183
+ failed <- Filter(Negate(is.null), outcomes)
184
+ skipped <- lapply(Filter(function(e) identical(e$status, "skipped"), manifest$entries),
185
+ function(e) list(name = e$name, why = e$why))
186
+ list(restored = total - length(failed), failed = failed, skipped = skipped,
187
+ wd = if (is.character(manifest$wd)) manifest$wd else "")
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beatrina",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "Beatrina is a modern, powerful, feature-rich integrated development environment (IDE) for R, Python, and JavaScript.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "lib",
25
25
  "check",
26
26
  "build-info.json",
27
- "carmar_V0.8.6.html",
27
+ "carmar_V0.8.7.html",
28
28
  "README.md",
29
29
  "LICENSE",
30
30
  "NOTICES"