beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
package/kernel/worker.R
ADDED
|
@@ -0,0 +1,3496 @@
|
|
|
1
|
+
#!/usr/bin/env Rscript
|
|
2
|
+
#
|
|
3
|
+
# Worker — evaluates R code on behalf of the supervisor.
|
|
4
|
+
#
|
|
5
|
+
# Protocol
|
|
6
|
+
# stdin NDJSON commands, one per line: {"type":"exec","id":...,"source":...}
|
|
7
|
+
# stdout the user's own output, untouched, PLUS control frames: a line
|
|
8
|
+
# beginning with the session sentinel followed by JSON.
|
|
9
|
+
# stderr R diagnostics; notebook execution also frames protocol here while
|
|
10
|
+
# evaluate owns stdout's capture sink (including that run's done).
|
|
11
|
+
#
|
|
12
|
+
# Frames emitted per cell: `stream` (warning/message), `plot` (one per graphics
|
|
13
|
+
# page, base64 PNG), `dataframe` (a structured top-level data.frame), and
|
|
14
|
+
# exactly one `done`.
|
|
15
|
+
#
|
|
16
|
+
# Why no output capture: `sink()` / `capture.output()` buffer, which destroys
|
|
17
|
+
# streaming — the caller sees nothing until the cell finishes. Letting R print
|
|
18
|
+
# straight to stdout keeps output real-time and costs nothing, as long as
|
|
19
|
+
# control frames are distinguishable. The sentinel is a per-session random
|
|
20
|
+
# token supplied by the supervisor, so user code cannot forge a frame.
|
|
21
|
+
#
|
|
22
|
+
# Why a device rather than source-sniffing: the notebook owns this session, so
|
|
23
|
+
# it can install a graphics device once and let every page land in it. Guessing
|
|
24
|
+
# whether a cell plots by grepping its source for "plot(" misses plots drawn
|
|
25
|
+
# inside functions and fires on a variable named `myplot`.
|
|
26
|
+
|
|
27
|
+
# Finder/launchd can start the app with LC_CTYPE=C even on a UTF-8 Mac. In that
|
|
28
|
+
# locale R prints box drawing and other non-ASCII text as escapes, and knit can
|
|
29
|
+
# only preserve the already-corrupted output. Select an installed UTF-8 CTYPE
|
|
30
|
+
# inside the worker; keep every other locale category (numbers, dates, sorting)
|
|
31
|
+
# untouched. The candidates cover current Linux, macOS, and older R builds.
|
|
32
|
+
if (!isTRUE(l10n_info()[["UTF-8"]])) {
|
|
33
|
+
for (candidate in c("C.UTF-8", "en_US.UTF-8", "UTF-8")) {
|
|
34
|
+
suppressWarnings(try(Sys.setlocale("LC_CTYPE", candidate), silent = TRUE))
|
|
35
|
+
if (isTRUE(l10n_info()[["UTF-8"]])) break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Finder also starts apps with the filesystem ROOT as their working directory,
|
|
40
|
+
# and everything downstream inherits it: getwd() is "/", so every native file
|
|
41
|
+
# dialog opens at the Computer view, "Reveal" reveals the root, and relative
|
|
42
|
+
# paths land where nobody looks. Exactly "/" is the pathological case — a
|
|
43
|
+
# kernel started from a terminal keeps that terminal's directory, which is the
|
|
44
|
+
# developer's own choice and is left alone.
|
|
45
|
+
if (identical(getwd(), "/")) {
|
|
46
|
+
try(setwd(path.expand("~")), silent = TRUE)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# jsonlite is used via :: only — attaching it would put it on the USER's search
|
|
50
|
+
# path, and the worker must leave no trace in the session it hosts.
|
|
51
|
+
stopifnot(requireNamespace("jsonlite", quietly = TRUE))
|
|
52
|
+
|
|
53
|
+
# Everything below lives in a private scope. Two reasons, both load-bearing:
|
|
54
|
+
# the Environment pane must show the USER's objects and not the kernel's
|
|
55
|
+
# plumbing, and user code must not be able to clobber `emit` or `sentinel` and
|
|
56
|
+
# break the protocol from inside the session it is running in.
|
|
57
|
+
local({
|
|
58
|
+
|
|
59
|
+
# Batch mode (Rscript worker-boot.R <sentinel>) passes the sentinel in argv.
|
|
60
|
+
# Interactive mode (R --interactive, booted by a sys.source line on stdin)
|
|
61
|
+
# has no argv to give, so the tokens arrive in the environment instead.
|
|
62
|
+
# WORKER_MODE decides which reader serves the dispatch loop below: the
|
|
63
|
+
# interactive worker MUST read through R's console (stdin()), because that is
|
|
64
|
+
# the one reader a native browser() prompt shares — a second buffered reader
|
|
65
|
+
# on the same fd would steal bytes from the debugger.
|
|
66
|
+
WORKER_MODE <- Sys.getenv("CARMAR_WORKER_MODE", "batch")
|
|
67
|
+
sentinel <- if (identical(WORKER_MODE, "interactive")) {
|
|
68
|
+
Sys.getenv("CARMAR_SENTINEL", "")
|
|
69
|
+
} else {
|
|
70
|
+
args <- commandArgs(trailingOnly = TRUE)
|
|
71
|
+
if (length(args) >= 1L) args[1] else ""
|
|
72
|
+
}
|
|
73
|
+
stopifnot(nzchar(sentinel))
|
|
74
|
+
# Commands from the supervisor arrive as "#<cmdtag> {json}" in interactive
|
|
75
|
+
# mode — a comment, so a line that ever reached R's raw top level would be
|
|
76
|
+
# inert. The prefix is stripped here before parsing.
|
|
77
|
+
CMD_PREFIX <- local({
|
|
78
|
+
tag <- Sys.getenv("CARMAR_CMD_TAG", "")
|
|
79
|
+
if (nzchar(tag)) paste0("#", tag, " ") else ""
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
PLOT_WIDTH <- 900L
|
|
83
|
+
PLOT_HEIGHT <- 620L
|
|
84
|
+
PLOT_RES <- 110L
|
|
85
|
+
# Hard ceilings for the data viewer. Server-side because the client is one
|
|
86
|
+
# `limit: 1e9` typo away from asking for everything; the reply reports what was
|
|
87
|
+
# clamped, so the UI never has to guess what it actually received.
|
|
88
|
+
MAX_VIEW_ROWS <- 500L
|
|
89
|
+
MAX_VIEW_COLS <- 100L
|
|
90
|
+
MAX_VIEW_BYTES <- 512L * 1024L
|
|
91
|
+
MAX_VIEW_CELL_CHARS <- 512L
|
|
92
|
+
MAX_VIEW_LABEL_CHARS <- 128L
|
|
93
|
+
MAX_STRUCT_CHILDREN <- 200L
|
|
94
|
+
MAX_COMPLETIONS <- 200L
|
|
95
|
+
|
|
96
|
+
# Path confinement (confine_root / within_root / outside_root_msg) and the file
|
|
97
|
+
# ops live in fileio.R, sourced below with sniff.R.
|
|
98
|
+
# R ships the completion engine RStudio and Jupyter both drive; these knobs
|
|
99
|
+
# make package names (ipck), function signatures (func → trailing "("),
|
|
100
|
+
# argument names (args → trailing "=") and quoted file paths (files) all
|
|
101
|
+
# participate. try(): a future R renaming a setting must not kill the boot.
|
|
102
|
+
try(utils::rc.settings(ipck = TRUE, func = TRUE, args = TRUE, files = TRUE),
|
|
103
|
+
silent = TRUE)
|
|
104
|
+
|
|
105
|
+
# ── the import sniffer ──────────────────────────────────────────────────────
|
|
106
|
+
# Format and column-type detection lives in its own file so it can be tested
|
|
107
|
+
# without starting a worker (test/import-sniff.test.R sources it directly);
|
|
108
|
+
# this file runs a dispatch loop the moment it loads, so nothing inside it is
|
|
109
|
+
# reachable from a test. `local = TRUE` keeps the functions in the worker's
|
|
110
|
+
# private scope, where the rest of the plumbing lives — the Environment pane
|
|
111
|
+
# must keep showing the user's objects and not the kernel's.
|
|
112
|
+
#
|
|
113
|
+
# Resolved from the WORKER'S OWN path, not the working directory: the worker
|
|
114
|
+
# is started by three different launchers (kernel.R, the Chrome bridge host,
|
|
115
|
+
# and the packaged inst/app/kernel) and none of them guarantee a cwd.
|
|
116
|
+
import_sources <- local({
|
|
117
|
+
env_dir <- Sys.getenv("CARMAR_WORKER_DIR", "")
|
|
118
|
+
file_arg <- grep("^--file=", commandArgs(FALSE), value = TRUE)
|
|
119
|
+
here <- if (nzchar(env_dir)) env_dir
|
|
120
|
+
else if (length(file_arg)) {
|
|
121
|
+
dirname(normalizePath(sub("^--file=", "", file_arg[1L]), mustWork = FALSE))
|
|
122
|
+
} else getwd()
|
|
123
|
+
file.path(here, c("fileio.R", "sniff.R", "project.R"))
|
|
124
|
+
})
|
|
125
|
+
# environment(), not parent.frame(): inside a nested local() the parent frame
|
|
126
|
+
# is the eval machinery's, not this file's private scope, and the functions
|
|
127
|
+
# landed somewhere the handlers could not see them. Caught end-to-end, where
|
|
128
|
+
# the kernel answered "this kernel is too old for the import wizard".
|
|
129
|
+
# The target environment is captured HERE, at the worker's own scope. Calling
|
|
130
|
+
# environment() inside the lambda would name the lambda's frame and the
|
|
131
|
+
# functions would load somewhere no handler can see — the same mistake this
|
|
132
|
+
# block already made once, with sniff.R, and it surfaced only end-to-end as
|
|
133
|
+
# "this kernel is too old for the import wizard".
|
|
134
|
+
import_env <- environment()
|
|
135
|
+
invisible(lapply(Filter(file.exists, import_sources),
|
|
136
|
+
function(f) sys.source(f, envir = import_env)))
|
|
137
|
+
|
|
138
|
+
# `Rscript` starts with repos = "@CRAN@", a placeholder that resolves only by
|
|
139
|
+
# ASKING the user to pick a mirror — which a non-interactive session cannot do,
|
|
140
|
+
# so install.packages() fails with "trying to use CRAN without setting a
|
|
141
|
+
# mirror" before it does anything. Fill it in, but never override a mirror the
|
|
142
|
+
# user already set: their .Rprofile loads here (we deliberately do not use
|
|
143
|
+
# --vanilla), and it is where r-universe and institutional mirrors live.
|
|
144
|
+
local({
|
|
145
|
+
managed_mirror <- trimws(Sys.getenv("CARMAR_CRAN_MIRROR", ""))
|
|
146
|
+
if (nzchar(managed_mirror) && !grepl("^https://", managed_mirror)) {
|
|
147
|
+
stop("CARMAR_CRAN_MIRROR must be an HTTPS URL.")
|
|
148
|
+
}
|
|
149
|
+
repos <- getOption("repos")
|
|
150
|
+
cran <- if (is.null(repos)) NA_character_ else unname(repos["CRAN"])
|
|
151
|
+
unset <- is.null(repos) || is.na(cran) || !nzchar(cran) || identical(cran, "@CRAN@")
|
|
152
|
+
if (nzchar(managed_mirror)) {
|
|
153
|
+
options(repos = unlist(utils::modifyList(as.list(if (is.null(repos)) character() else repos),
|
|
154
|
+
list(CRAN = managed_mirror))))
|
|
155
|
+
Sys.setenv(RENV_CONFIG_REPOS_OVERRIDE = managed_mirror)
|
|
156
|
+
} else if (unset) {
|
|
157
|
+
# Keep every other repo the profile declared; only CRAN was missing.
|
|
158
|
+
# modifyList says that in one verb: it replaces CRAN where it exists and
|
|
159
|
+
# appends it where it does not, leaving r-universe and institutional
|
|
160
|
+
# mirrors exactly as the user's .Rprofile left them.
|
|
161
|
+
others <- if (is.null(repos)) list() else as.list(repos)
|
|
162
|
+
options(repos = unlist(utils::modifyList(
|
|
163
|
+
others, list(CRAN = "https://cloud.r-project.org"))))
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
# An interactive R ASKS where a batch R silently defaulted, and every such
|
|
168
|
+
# question is a console read that would consume protocol lines and wedge the
|
|
169
|
+
# session. Close the known askers: install.packages' "install from sources
|
|
170
|
+
# which need compilation?" question, and any menu() falling back to a
|
|
171
|
+
# graphical chooser it cannot open.
|
|
172
|
+
if (identical(WORKER_MODE, "interactive")) {
|
|
173
|
+
options(install.packages.compile.from.source = "never",
|
|
174
|
+
menu.graphics = FALSE)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# The reserved words R's engine offers with "(" appended (for/if/while) or
|
|
178
|
+
# bare; they are neither functions nor variables and the UI badges them apart.
|
|
179
|
+
R_KEYWORDS <- c("if", "else", "repeat", "while", "function", "for", "in",
|
|
180
|
+
"next", "break", "TRUE", "FALSE", "NULL", "Inf", "NaN",
|
|
181
|
+
"NA", "NA_integer_", "NA_real_", "NA_character_", "NA_complex_")
|
|
182
|
+
|
|
183
|
+
#' Find a graphics device type that actually WORKS in this R.
|
|
184
|
+
#'
|
|
185
|
+
#' `capabilities("cairo")` reports what R was COMPILED with, not what loads.
|
|
186
|
+
#' On the CRAN macOS build it says TRUE while `png(type="cairo")` fails to
|
|
187
|
+
#' dlopen (it wants libXrender from XQuartz) and silently produces no file —
|
|
188
|
+
#' so every plot vanished with no error anywhere. The only honest test is to
|
|
189
|
+
#' open a device, draw a page, and see whether bytes appear.
|
|
190
|
+
#'
|
|
191
|
+
#' CAIRO IS TRIED BEFORE QUARTZ, and that ORDER is a hang fix, not a
|
|
192
|
+
#' preference. `png(type="quartz")` pulls in the macOS Aqua/AppKit graphics
|
|
193
|
+
#' backend, which spawns a Cocoa event loop (an NSEventThread plus grDevices'
|
|
194
|
+
#' own ELThread) the first time it opens. CarmaR's worker is a BackgroundOnly
|
|
195
|
+
#' process with no window-server access, so when R pumps that event loop
|
|
196
|
+
#' mid-evaluation — which it does from inside a long R-level loop, e.g. the
|
|
197
|
+
#' permutation loop in `markov_order_test` — `ReceiveNextEventCommon` blocks
|
|
198
|
+
#' and the whole session hangs at 0% CPU, uninterruptibly (Stop is a SIGINT R
|
|
199
|
+
#' never reaches). It only bites a GUI-launched worker: a terminal R is a
|
|
200
|
+
#' different session and never blocks, which is exactly why it presented as
|
|
201
|
+
#' "only this one function hangs, and only in the app." Cairo renders the
|
|
202
|
+
#' identical PNG headlessly with no AppKit thread at all, and — because Cairo
|
|
203
|
+
#' is opened first and works — quartz is never even probed, so the event loop
|
|
204
|
+
#' is never created. The `works()` guard below still protects the broken-CRAN
|
|
205
|
+
#' case: if this R's Cairo is the libXrender-less one, it falls through to
|
|
206
|
+
#' quartz, and a machine that reaches that fallback is not the GUI worker the
|
|
207
|
+
#' hang needs anyway.
|
|
208
|
+
#'
|
|
209
|
+
#' THE TYPE VOCABULARY IS PER-PLATFORM, and getting that wrong is what
|
|
210
|
+
#' produced "no cairo dll" on Windows. `png(type=)` accepts "windows",
|
|
211
|
+
#' "cairo" and "cairo-png" on Windows; "cairo", "quartz" and "Xlib" on
|
|
212
|
+
#' Unix-alikes. The old ladder was cairo → quartz → Xlib on every platform,
|
|
213
|
+
#' which on Windows means: cairo, then a type that CANNOT work there. So a
|
|
214
|
+
#' Windows R whose cairo.dll is missing — a stripped or portable install, or
|
|
215
|
+
#' one where grDevices' cairo was not shipped — had no reachable candidate.
|
|
216
|
+
#'
|
|
217
|
+
#' It then hit the second half of the bug. `if (is.na(hit)) candidates[1L]`
|
|
218
|
+
#' returns the FIRST candidate when nothing works, and the first candidate is
|
|
219
|
+
#' cairo — the one just proven broken. PLOT_TYPE became "cairo", and every
|
|
220
|
+
#' plot after that raised the cairo.dll load error at the user. The fallback
|
|
221
|
+
#' is now the platform's OWN default, which on Windows is the GDI device and
|
|
222
|
+
#' renders perfectly well without cairo.
|
|
223
|
+
#'
|
|
224
|
+
#' `os` and `works` are arguments so the ladder can be tested for every
|
|
225
|
+
#' platform from one machine (spike/test-plot-type.R).
|
|
226
|
+
#'
|
|
227
|
+
#' @param os One of "windows" or "unix" (.Platform$OS.type).
|
|
228
|
+
#' @param mac Whether this is macOS (quartz only exists there).
|
|
229
|
+
#' @return The first working type; failing that, the platform's own default.
|
|
230
|
+
detect_plot_type <- function(os = .Platform$OS.type,
|
|
231
|
+
mac = isTRUE(capabilities("aqua")),
|
|
232
|
+
works = NULL) {
|
|
233
|
+
windows <- identical(os, "windows")
|
|
234
|
+
candidates <- if (windows) {
|
|
235
|
+
# No Xlib and no quartz on Windows, ever. "windows" is the GDI device R
|
|
236
|
+
# itself defaults to and needs no external library at all.
|
|
237
|
+
c(if (isTRUE(capabilities("cairo"))) "cairo", "windows")
|
|
238
|
+
} else {
|
|
239
|
+
c(if (isTRUE(capabilities("cairo"))) "cairo",
|
|
240
|
+
if (mac) "quartz",
|
|
241
|
+
"Xlib")
|
|
242
|
+
}
|
|
243
|
+
if (is.null(works)) {
|
|
244
|
+
works <- function(type) {
|
|
245
|
+
f <- tempfile(fileext = ".png")
|
|
246
|
+
ok <- tryCatch({
|
|
247
|
+
# 200x200, not 12x12. plot.new() on a 12-pixel canvas raises "figure
|
|
248
|
+
# margins too large" — a PLOTTING error about the canvas, nothing to do
|
|
249
|
+
# with the device — and the tryCatch below turned that into "this
|
|
250
|
+
# device does not work". Measured: quartz fails the 12px probe and
|
|
251
|
+
# renders perfectly at 200. A probe that rejects a working device
|
|
252
|
+
# sends the worker down the ladder for no reason, and on Windows the
|
|
253
|
+
# bottom of that ladder was the broken cairo. Same size the ragg probe
|
|
254
|
+
# already uses.
|
|
255
|
+
suppressWarnings(grDevices::png(f, width = 200, height = 200, type = type))
|
|
256
|
+
graphics::plot.new()
|
|
257
|
+
grDevices::dev.off()
|
|
258
|
+
file.exists(f) && file.info(f)$size > 0L
|
|
259
|
+
}, error = function(e) FALSE, warning = function(w) FALSE)
|
|
260
|
+
if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
|
|
261
|
+
unlink(f)
|
|
262
|
+
isTRUE(ok)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
hit <- Position(works, candidates)
|
|
266
|
+
if (!is.na(hit)) return(candidates[hit])
|
|
267
|
+
# Nothing rendered. Return what R itself would use rather than the first
|
|
268
|
+
# candidate, which is the type the probe just disproved.
|
|
269
|
+
if (windows) "windows" else if (mac) "quartz" else "Xlib"
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#' The raster backend for cell plots. `ragg::agg_png` is a pure-C++ device with
|
|
273
|
+
#' NO quartz and NO cairo, so it never opens the macOS AppKit graphics subsystem
|
|
274
|
+
#' — and it is the only headless raster that both actually loads on a stock
|
|
275
|
+
#' framework R (whose cairo fails to dlopen; see detect_plot_type) AND does not
|
|
276
|
+
#' spawn the Cocoa event loop that hangs a BackgroundOnly worker. Probed the same
|
|
277
|
+
#' honest way as the png types — an installed-but-broken ragg must fall through.
|
|
278
|
+
#'
|
|
279
|
+
#' @return "ragg" when agg_png renders bytes, else "png" (use grDevices::png).
|
|
280
|
+
detect_raster_device <- function() {
|
|
281
|
+
if (!requireNamespace("ragg", quietly = TRUE)) return("png")
|
|
282
|
+
f <- tempfile(fileext = ".png")
|
|
283
|
+
ok <- tryCatch({
|
|
284
|
+
# A real-sized canvas with zero margins: a 12x12-pixel probe made
|
|
285
|
+
# plot.new() raise "figure margins too large", which this tryCatch counted
|
|
286
|
+
# as ragg FAILING — so the worker fell through to the png/quartz path and
|
|
287
|
+
# opened the AppKit event loop it was trying to avoid. The probe must fail
|
|
288
|
+
# only when ragg genuinely does not render.
|
|
289
|
+
ragg::agg_png(f, width = 200, height = 200, res = 72)
|
|
290
|
+
old <- graphics::par(mar = c(0, 0, 0, 0))
|
|
291
|
+
graphics::plot.new()
|
|
292
|
+
graphics::par(old)
|
|
293
|
+
grDevices::dev.off()
|
|
294
|
+
file.exists(f) && file.info(f)$size > 0L
|
|
295
|
+
}, error = function(e) FALSE, warning = function(w) FALSE)
|
|
296
|
+
if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
|
|
297
|
+
unlink(f)
|
|
298
|
+
if (isTRUE(ok)) "ragg" else "png"
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
RASTER_DEVICE <- detect_raster_device()
|
|
302
|
+
# Probing png TYPES opens a quartz device on macOS — the very thing whose AppKit
|
|
303
|
+
# event loop hangs a background worker — so a worker that has ragg must never run
|
|
304
|
+
# detect_plot_type() at all. Only compute PLOT_TYPE when the png fallback is what
|
|
305
|
+
# we will actually use.
|
|
306
|
+
PLOT_TYPE <- if (identical(RASTER_DEVICE, "png")) detect_plot_type() else NA_character_
|
|
307
|
+
|
|
308
|
+
#' Find a VECTOR device that actually works, or NULL.
|
|
309
|
+
#'
|
|
310
|
+
#' Same discipline as detect_plot_type and for a sharper reason: on the CRAN
|
|
311
|
+
#' macOS build `grDevices::svg()` opens without error, draws without error,
|
|
312
|
+
#' and writes a ZERO-BYTE file, because it is Cairo and Cairo cannot dlopen
|
|
313
|
+
#' without XQuartz. Measured on this machine. So the probe draws a page and
|
|
314
|
+
#' insists on bytes that actually begin an SVG document.
|
|
315
|
+
#'
|
|
316
|
+
#' Unlike detect_plot_type this returns NULL rather than falling through to a
|
|
317
|
+
#' first candidate: "no vector device" is a real answer the caller must handle
|
|
318
|
+
#' by saying so, not by silently producing something else.
|
|
319
|
+
#'
|
|
320
|
+
#' @return "svglite", "grDevices", or NULL.
|
|
321
|
+
detect_svg_device <- function() {
|
|
322
|
+
works <- function(open) {
|
|
323
|
+
f <- tempfile(fileext = ".svg")
|
|
324
|
+
ok <- tryCatch({
|
|
325
|
+
suppressWarnings(open(f))
|
|
326
|
+
graphics::plot.new()
|
|
327
|
+
graphics::text(0.5, 0.5, "x")
|
|
328
|
+
grDevices::dev.off()
|
|
329
|
+
file.exists(f) && file.info(f)$size > 0L &&
|
|
330
|
+
grepl("<svg", paste(readLines(f, n = 8L, warn = FALSE), collapse = ""), fixed = TRUE)
|
|
331
|
+
}, error = function(e) FALSE, warning = function(w) FALSE)
|
|
332
|
+
if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
|
|
333
|
+
unlink(f)
|
|
334
|
+
isTRUE(ok)
|
|
335
|
+
}
|
|
336
|
+
# svglite first: pure C++, no Cairo, and the only one that works on a stock
|
|
337
|
+
# macOS R. grDevices::svg() is the fallback for machines that have Cairo.
|
|
338
|
+
if (requireNamespace("svglite", quietly = TRUE) &&
|
|
339
|
+
works(function(f) svglite::svglite(f, width = 2, height = 2))) return("svglite")
|
|
340
|
+
if (works(function(f) grDevices::svg(f, width = 2, height = 2))) return("grDevices")
|
|
341
|
+
NULL
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
SVG_DEVICE <- detect_svg_device()
|
|
345
|
+
|
|
346
|
+
#' Is this chunk asking for vector output, and can we give it?
|
|
347
|
+
plot_is_svg <- function(dims) isTRUE(dims$format == "svg") && !is.null(SVG_DEVICE)
|
|
348
|
+
|
|
349
|
+
#' Write one control frame to stdout and flush immediately.
|
|
350
|
+
#'
|
|
351
|
+
#' @param obj Named list, serialised to JSON.
|
|
352
|
+
#' @return Invisibly NULL.
|
|
353
|
+
emit_wire <- function(obj) {
|
|
354
|
+
# evaluate owns an output sink during notebook execution. Keep protocol
|
|
355
|
+
# frames on the other pipe for that whole command (including done), so
|
|
356
|
+
# output capture cannot swallow them or settle the command before its data.
|
|
357
|
+
message_sink <- sink.number(type = "message")
|
|
358
|
+
if (isTRUE(KNIT_PROTOCOL) && message_sink != 2L) {
|
|
359
|
+
# A user's message log must not swallow the command's done frame. Message
|
|
360
|
+
# sinks have one current connection (not an output-sink stack), so it can
|
|
361
|
+
# be restored exactly after this private protocol write.
|
|
362
|
+
sink(type = "message")
|
|
363
|
+
on.exit(try(sink(getConnection(message_sink), type = "message"), silent = TRUE), add = TRUE)
|
|
364
|
+
}
|
|
365
|
+
channel <- if (isTRUE(KNIT_PROTOCOL)) stderr() else stdout()
|
|
366
|
+
cat(sentinel, jsonlite::toJSON(obj, auto_unbox = TRUE, null = "null", na = "null",
|
|
367
|
+
digits = NA), "\n", sep = "", file = channel)
|
|
368
|
+
flush(channel)
|
|
369
|
+
invisible(NULL)
|
|
370
|
+
}
|
|
371
|
+
emit <- function(obj) {
|
|
372
|
+
if (is.null(KNIT_CAPTURE) || isTRUE(KNIT_CAPTURE(obj))) emit_wire(obj)
|
|
373
|
+
invisible(NULL)
|
|
374
|
+
}
|
|
375
|
+
here <- dirname(import_sources[[1L]])
|
|
376
|
+
sys.source(file.path(here, "knitr-run.R"), envir = environment())
|
|
377
|
+
|
|
378
|
+
# ── interactive input ────────────────────────────────────────────────────────
|
|
379
|
+
#
|
|
380
|
+
# The worker runs under `R --interactive`, so base::readline() genuinely blocks
|
|
381
|
+
# on the console — the SAME reader the debugger's Browse prompt uses, which is
|
|
382
|
+
# why answering it needs no new channel: the supervisor writes a raw console
|
|
383
|
+
# line exactly as it writes a debug step.
|
|
384
|
+
#
|
|
385
|
+
# What was missing was the announcement. R sat waiting on a prompt while the
|
|
386
|
+
# notebook sat on a spinner with no way to answer it, so any code that asked a
|
|
387
|
+
# question — readline(), and the menu()/select.list() built on it — could only
|
|
388
|
+
# be stopped, never completed.
|
|
389
|
+
#
|
|
390
|
+
# `readline` is SHADOWED on the search path rather than replaced in base: user
|
|
391
|
+
# code and every package keep seeing base's own function, and removing the
|
|
392
|
+
# attached frame restores the original behaviour exactly. The delegation is the
|
|
393
|
+
# whole implementation — announce, then let R do what it already did.
|
|
394
|
+
carmar_readline <- function(prompt = "") {
|
|
395
|
+
text <- tryCatch(as.character(prompt)[[1]], error = function(e) "")
|
|
396
|
+
if (!length(text) || is.na(text)) text <- ""
|
|
397
|
+
# The question belongs to the RUN that asked it, and says so.
|
|
398
|
+
#
|
|
399
|
+
# It carried no id until 7.20, and serve.R adopts `worker_active` as the wire
|
|
400
|
+
# id only for stdout/stderr/debug — so an input_request fell through to a
|
|
401
|
+
# BROADCAST. Three consequences, all of them the same defect: the prompt
|
|
402
|
+
# reached every open tab, the ownership check on the answer (serve.R
|
|
403
|
+
# input_reply) then refused all of them but one, and a reload lost the
|
|
404
|
+
# question entirely while preserving the computation blocked on it — because
|
|
405
|
+
# a broadcast frame is not recorded on any route and so is not replayed when
|
|
406
|
+
# the run is adopted. With the id it is a routed frame like every other, and
|
|
407
|
+
# adoption replays it to the page that comes back.
|
|
408
|
+
#
|
|
409
|
+
# RUN_STATE$id is NULL outside run_cell, and then there is no owner to name;
|
|
410
|
+
# the frame goes out id-less exactly as it always did.
|
|
411
|
+
#
|
|
412
|
+
# `id` SECOND, like every other emit site: relay_frame rewrites the routing
|
|
413
|
+
# id by finding the first `"id":…` in the worker's own bytes, so a prompt
|
|
414
|
+
# containing that text would otherwise be rewritten instead of the id.
|
|
415
|
+
emit(list(type = "input_request", id = RUN_STATE$id, prompt = text))
|
|
416
|
+
answer <- base::readline(prompt)
|
|
417
|
+
# Symmetry matters more than it looks: a page that opened an input row on the
|
|
418
|
+
# request must be told to close it, INCLUDING when the answer arrived by some
|
|
419
|
+
# other route (an interrupt, a second page). Without this the prompt row
|
|
420
|
+
# outlives the question it asked. Same id, for the same reason — a routed
|
|
421
|
+
# request answered by a broadcast would close the row in tabs that never
|
|
422
|
+
# opened one.
|
|
423
|
+
emit(list(type = "input_done", id = RUN_STATE$id))
|
|
424
|
+
answer
|
|
425
|
+
}
|
|
426
|
+
# `print(df)` joins the same shadow, for the same reason readline did: the
|
|
427
|
+
# machinery already exists, nothing announced the moment. Autoprint sends a
|
|
428
|
+
# visible data.frame as a structured `dataframe` frame; an EXPLICIT print()
|
|
429
|
+
# returned invisibly and streamed 51 rows of console text, so the two spellings
|
|
430
|
+
# of "show me this table" produced different universes. The shadow narrows to
|
|
431
|
+
# exactly the calls where table intent is unambiguous — a data.frame or matrix,
|
|
432
|
+
# no formatting arguments — and delegates everything else to base untouched:
|
|
433
|
+
# - extra args (`print(df, digits = 3)`) ask for R's console formatting; honor it.
|
|
434
|
+
# - an active sink means the caller is CAPTURING text (capture.output); a
|
|
435
|
+
# frame emitted there would be captured with it — corrupted for the caller,
|
|
436
|
+
# lost to the page.
|
|
437
|
+
# - no run id means no page is listening; base behaviour is the only one.
|
|
438
|
+
# RUN_STATE carries the current run's id from run_cell to the shadow.
|
|
439
|
+
RUN_STATE <- new.env(parent = emptyenv())
|
|
440
|
+
RUN_STATE$id <- NULL
|
|
441
|
+
carmar_print <- function(x, ...) {
|
|
442
|
+
if (is.null(RUN_STATE$id) || length(list(...)) || sink.number() > (RUN_STATE$capture_depth %||% 0L)) {
|
|
443
|
+
return(base::print(x, ...))
|
|
444
|
+
}
|
|
445
|
+
if (is.data.frame(x)) {
|
|
446
|
+
emit_dataframe(RUN_STATE$id, x)
|
|
447
|
+
return(invisible(x))
|
|
448
|
+
}
|
|
449
|
+
if (is.matrix(x) && nrow(x) > 0L && ncol(x) > 0L) {
|
|
450
|
+
emit_dataframe(RUN_STATE$id, matrix_to_df(x))
|
|
451
|
+
return(invisible(x))
|
|
452
|
+
}
|
|
453
|
+
rich <- rich_html_of(x)
|
|
454
|
+
if (!is.null(rich)) {
|
|
455
|
+
emit_rich(RUN_STATE$id, rich)
|
|
456
|
+
return(invisible(x))
|
|
457
|
+
}
|
|
458
|
+
base::print(x, ...)
|
|
459
|
+
}
|
|
460
|
+
INPUT_SHADOW <- "carmar:input"
|
|
461
|
+
carmar_flush_console <- function() {
|
|
462
|
+
if (!is.null(KNIT_CAPTURE)) evaluate::flush_console()
|
|
463
|
+
else utils::flush.console()
|
|
464
|
+
invisible(NULL)
|
|
465
|
+
}
|
|
466
|
+
if (!(INPUT_SHADOW %in% search())) {
|
|
467
|
+
# warn.conflicts = FALSE: masking `readline` (and `print`) is the entire
|
|
468
|
+
# point, and a startup warning about it would be printed into the user's
|
|
469
|
+
# first cell.
|
|
470
|
+
attach(list(readline = carmar_readline, print = carmar_print, flush.console = carmar_flush_console),
|
|
471
|
+
name = INPUT_SHADOW, warn.conflicts = FALSE)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#' A function's formals as one display string, defaults included.
|
|
475
|
+
#'
|
|
476
|
+
#' `formals()` is NULL for primitives like `sum`; `args()` still knows their
|
|
477
|
+
#' signature, so it is the fallback. The empty symbol deparses to "", which is
|
|
478
|
+
#' how an argument with no default is told apart from one whose default is "".
|
|
479
|
+
#'
|
|
480
|
+
#' @param f A function.
|
|
481
|
+
#' @return A single string, e.g. "x, y = 2, ...".
|
|
482
|
+
formals_string <- function(f) {
|
|
483
|
+
fl <- formals(f)
|
|
484
|
+
if (is.null(fl)) fl <- tryCatch(formals(args(f)), error = function(e) NULL)
|
|
485
|
+
if (is.null(fl) || length(fl) == 0L) return("")
|
|
486
|
+
paste(vapply(names(fl), function(nm) {
|
|
487
|
+
default <- paste(deparse(fl[[nm]]), collapse = " ")
|
|
488
|
+
if (nzchar(default)) paste(nm, "=", default) else nm
|
|
489
|
+
}, character(1)), collapse = ", ")
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#' Describe every object in the global environment, for the Environment pane.
|
|
493
|
+
#'
|
|
494
|
+
#' `str()`-style one-liners rather than values: an IDE pane must stay cheap to
|
|
495
|
+
#' refresh after every cell, and a 2 GB matrix must not be serialised to say it
|
|
496
|
+
#' exists. Sizes come from object.size so the pane can show what is costing.
|
|
497
|
+
#'
|
|
498
|
+
#' @param id Request id, echoed back.
|
|
499
|
+
#' @return Invisibly NULL. Emits one `env` frame.
|
|
500
|
+
emit_env <- function(id) {
|
|
501
|
+
names_ <- ls(globalenv(), all.names = FALSE)
|
|
502
|
+
describe <- function(nm) {
|
|
503
|
+
v <- get(nm, envir = globalenv())
|
|
504
|
+
dims <- if (!is.null(dim(v))) paste(dim(v), collapse = " × ") else as.character(length(v))
|
|
505
|
+
summary_line <- if (is.data.frame(v)) {
|
|
506
|
+
sprintf("%d obs. of %d variable%s", nrow(v), ncol(v), if (ncol(v) == 1L) "" else "s")
|
|
507
|
+
} else if (is.function(v)) {
|
|
508
|
+
paste0("function(", paste(names(formals(v)), collapse = ", "), ")")
|
|
509
|
+
} else {
|
|
510
|
+
paste(utils::capture.output(utils::str(v, max.level = 0, give.attr = FALSE))[1L],
|
|
511
|
+
collapse = "")
|
|
512
|
+
}
|
|
513
|
+
# Three kinds because the pane treats them differently: "data" opens the
|
|
514
|
+
# viewer, "function" opens the inspector at its source, "value" just shows
|
|
515
|
+
# itself. Matrices count as data — cor(), table() and coef(summary()) are
|
|
516
|
+
# the most viewer-worthy things R produces (see matrix_to_df).
|
|
517
|
+
is_data <- is.data.frame(v) || is.matrix(v)
|
|
518
|
+
kind <- if (is.function(v)) "function" else if (is_data) "data" else "value"
|
|
519
|
+
base <- list(name = nm, class = class(v)[1L], dims = dims,
|
|
520
|
+
bytes = as.numeric(utils::object.size(v)), summary = summary_line,
|
|
521
|
+
kind = kind)
|
|
522
|
+
if (is_data) c(base, list(nrow = nrow(v), ncol = ncol(v)))
|
|
523
|
+
else if (is.function(v)) c(base, list(args = formals_string(v)))
|
|
524
|
+
else base
|
|
525
|
+
}
|
|
526
|
+
emit(list(type = "env", id = id,
|
|
527
|
+
objects = if (length(names_)) lapply(names_, describe) else list()))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#' Inspect one object — the pane an Environment row expands into.
|
|
531
|
+
#'
|
|
532
|
+
#' str() plus a capped print, never the value itself: an inspector's job is to
|
|
533
|
+
#' describe a 2 GB model without shipping it. `max.print` bounds what print()
|
|
534
|
+
#' GENERATES — capping capture.output afterwards would still pay to render a
|
|
535
|
+
#' 1e8-element vector first.
|
|
536
|
+
#'
|
|
537
|
+
#' @param id Request id.
|
|
538
|
+
#' @param name Name of an object visible from the global environment.
|
|
539
|
+
#' @param max_lines Cap applied separately to str, preview and source.
|
|
540
|
+
#' @return Invisibly NULL. Emits one `obj` frame.
|
|
541
|
+
emit_obj <- function(id, name = NULL, max_lines = 200L) {
|
|
542
|
+
# Wire-supplied fields get guards, not stopifnot: a malformed request must
|
|
543
|
+
# answer with an error field, never take the worker down mid-session.
|
|
544
|
+
if (!is.character(name) || length(name) != 1L || !nzchar(name)) {
|
|
545
|
+
emit(list(type = "obj", id = id, name = name, error = "bad name"))
|
|
546
|
+
return(invisible(NULL))
|
|
547
|
+
}
|
|
548
|
+
# Default inherits = TRUE, so package data (mtcars) inspects like the user's.
|
|
549
|
+
if (!exists(name, envir = globalenv())) {
|
|
550
|
+
emit(list(type = "obj", id = id, name = name, error = "not found"))
|
|
551
|
+
return(invisible(NULL))
|
|
552
|
+
}
|
|
553
|
+
v <- get(name, envir = globalenv())
|
|
554
|
+
capped <- function(lines) {
|
|
555
|
+
if (length(lines) > max_lines) {
|
|
556
|
+
lines <- c(lines[seq_len(max_lines)],
|
|
557
|
+
sprintf("... (%d more lines)", length(lines) - max_lines))
|
|
558
|
+
}
|
|
559
|
+
paste(lines, collapse = "\n")
|
|
560
|
+
}
|
|
561
|
+
old <- options(max.print = 2000L)
|
|
562
|
+
on.exit(options(old), add = TRUE)
|
|
563
|
+
frame <- list(
|
|
564
|
+
type = "obj", id = id, name = name, class = class(v)[1L],
|
|
565
|
+
str = capped(tryCatch(utils::capture.output(utils::str(v)),
|
|
566
|
+
error = function(e) conditionMessage(e))),
|
|
567
|
+
preview = capped(tryCatch(utils::capture.output(base::print(v)),
|
|
568
|
+
error = function(e) conditionMessage(e)))
|
|
569
|
+
)
|
|
570
|
+
if (is.function(v)) {
|
|
571
|
+
frame$formals <- formals_string(v)
|
|
572
|
+
frame$source <- capped(deparse(v))
|
|
573
|
+
}
|
|
574
|
+
emit(frame)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#' The child accessors a node exposes — the tree viewer's notion of "expandable".
|
|
578
|
+
#'
|
|
579
|
+
#' S4 slots, then list elements (data.frames are lists of columns). Everything
|
|
580
|
+
#' else — atomics, functions, calls, and above all ENVIRONMENTS — reports no
|
|
581
|
+
#' children: environments can be cyclic (an env holding itself, R6 objects), so
|
|
582
|
+
#' walking them turns a lazy tree into an infinite one.
|
|
583
|
+
#'
|
|
584
|
+
#' @param obj Any object.
|
|
585
|
+
#' @return Character vector of accessor tokens: names, or "[[i]]" for unnamed.
|
|
586
|
+
child_keys <- function(obj) {
|
|
587
|
+
if (isS4(obj)) return(methods::slotNames(class(obj)))
|
|
588
|
+
if (is.environment(obj) || !is.list(obj)) return(character(0))
|
|
589
|
+
nms <- names(obj)
|
|
590
|
+
if (is.null(nms)) nms <- character(length(obj))
|
|
591
|
+
ifelse(nzchar(nms), nms, sprintf("[[%d]]", seq_along(obj)))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#' Fetch one child by the token child_keys() produced for it.
|
|
595
|
+
#'
|
|
596
|
+
#' @param obj The parent node.
|
|
597
|
+
#' @param key A name, "[[i]]" index token, or S4 slot name.
|
|
598
|
+
#' @return The child value.
|
|
599
|
+
child_get <- function(obj, key) {
|
|
600
|
+
if (isS4(obj)) return(methods::slot(obj, key))
|
|
601
|
+
if (grepl("^\\[\\[\\d+\\]\\]$", key)) obj[[as.integer(gsub("\\D", "", key))]]
|
|
602
|
+
else obj[[key]]
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#' One line that says what a node IS without shipping what it holds.
|
|
606
|
+
#'
|
|
607
|
+
#' @param v Any object.
|
|
608
|
+
#' @return A single short string.
|
|
609
|
+
struct_preview <- function(v) {
|
|
610
|
+
if (is.environment(v)) return("<environment>")
|
|
611
|
+
if (is.function(v)) return(paste0("function(", formals_string(v), ")"))
|
|
612
|
+
if (isS4(v)) return(paste0("S4 object of class ", class(v)[1L]))
|
|
613
|
+
if (is.data.frame(v) || is.matrix(v)) return(paste(dim(v), collapse = " × "))
|
|
614
|
+
if (is.list(v)) return(sprintf("list of %d", length(v)))
|
|
615
|
+
if (is.atomic(v) && length(v) > 0L) {
|
|
616
|
+
# head() before format(): formatting five elements of a 1e8 vector must
|
|
617
|
+
# not pay for the other 99,999,995.
|
|
618
|
+
shown <- format(utils::head(v, 5L), trim = TRUE)
|
|
619
|
+
return(paste0(paste(shown, collapse = ", "), if (length(v) > 5L) ", …" else ""))
|
|
620
|
+
}
|
|
621
|
+
if (is.atomic(v)) return(paste0(class(v)[1L], "(0)"))
|
|
622
|
+
class(v)[1L]
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#' One level of an object's structure, for a lazily expanded tree viewer.
|
|
626
|
+
#'
|
|
627
|
+
#' The client sends back the `path` a child arrived with to expand that child;
|
|
628
|
+
#' nothing below the requested level is ever serialised, so an lm holding a
|
|
629
|
+
#' thousand-row model frame costs 13 child descriptors, not the frame.
|
|
630
|
+
#'
|
|
631
|
+
#' @param id Request id.
|
|
632
|
+
#' @param name Name of an object visible from the global environment.
|
|
633
|
+
#' @param path Character vector of accessor tokens; empty/NULL for the root.
|
|
634
|
+
#' @return Invisibly NULL. Emits one `struct` frame.
|
|
635
|
+
emit_struct <- function(id, name = NULL, path = NULL) {
|
|
636
|
+
if (!is.character(name) || length(name) != 1L || !nzchar(name)) {
|
|
637
|
+
emit(list(type = "struct", id = id, name = name, error = "bad name"))
|
|
638
|
+
return(invisible(NULL))
|
|
639
|
+
}
|
|
640
|
+
if (!exists(name, envir = globalenv())) {
|
|
641
|
+
emit(list(type = "struct", id = id, name = name, error = "not found"))
|
|
642
|
+
return(invisible(NULL))
|
|
643
|
+
}
|
|
644
|
+
keys <- if (is.null(path)) character(0) else as.character(unlist(path))
|
|
645
|
+
# Each step validates the token against child_keys first: `lst[["absent"]]`
|
|
646
|
+
# returns NULL rather than erroring, and a bad path must be an error frame,
|
|
647
|
+
# not a silent NULL node described with a straight face.
|
|
648
|
+
step <- function(o, k) {
|
|
649
|
+
if (is.environment(o)) stop("environments are not walked")
|
|
650
|
+
if (!(k %in% child_keys(o))) stop("no such element: ", k)
|
|
651
|
+
child_get(o, k)
|
|
652
|
+
}
|
|
653
|
+
node <- tryCatch(Reduce(step, keys, init = get(name, envir = globalenv())),
|
|
654
|
+
error = function(e) structure(class = "carmar_fail",
|
|
655
|
+
list(msg = conditionMessage(e))))
|
|
656
|
+
if (inherits(node, "carmar_fail")) {
|
|
657
|
+
emit(list(type = "struct", id = id, name = name, path = as.list(keys),
|
|
658
|
+
error = node$msg))
|
|
659
|
+
return(invisible(NULL))
|
|
660
|
+
}
|
|
661
|
+
kids <- child_keys(node)
|
|
662
|
+
describe_child <- function(k) {
|
|
663
|
+
v <- tryCatch(child_get(node, k), error = function(e) NULL)
|
|
664
|
+
list(name = k, path = as.list(c(keys, k)), class = class(v)[1L],
|
|
665
|
+
type = typeof(v), length = length(v),
|
|
666
|
+
size = as.numeric(utils::object.size(v)),
|
|
667
|
+
isLeaf = length(child_keys(v)) == 0L,
|
|
668
|
+
preview = struct_preview(v))
|
|
669
|
+
}
|
|
670
|
+
emit(list(type = "struct", id = id, name = name, path = as.list(keys),
|
|
671
|
+
class = class(node)[1L], type = typeof(node), length = length(node),
|
|
672
|
+
size = as.numeric(utils::object.size(node)),
|
|
673
|
+
preview = struct_preview(node),
|
|
674
|
+
children = lapply(utils::head(kids, MAX_STRUCT_CHILDREN), describe_child),
|
|
675
|
+
truncated = length(kids) > MAX_STRUCT_CHILDREN))
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
#' Describe one column the way a data viewer shows it: type, a sparkline's
|
|
679
|
+
#' worth of shape, a stat label, and how much is missing.
|
|
680
|
+
#'
|
|
681
|
+
#' The bins are computed HERE rather than shipping the column: a 500k-row
|
|
682
|
+
#' numeric vector is 4 MB of JSON and 12 counts is 60 bytes, and the viewer
|
|
683
|
+
#' only ever draws the 12.
|
|
684
|
+
#'
|
|
685
|
+
#' @param col A column vector.
|
|
686
|
+
#' @param name Column name.
|
|
687
|
+
#' @return A named list.
|
|
688
|
+
describe_column <- function(col, name) {
|
|
689
|
+
n <- length(col)
|
|
690
|
+
# ONE is.na pass. It was computed twice — once for the count, once to build
|
|
691
|
+
# `ok` — and on a million-row column that second pass is a wasted 4 MB
|
|
692
|
+
# logical and ~0.6 ms, paid for every column of every page.
|
|
693
|
+
na <- is.na(col)
|
|
694
|
+
missing <- sum(na)
|
|
695
|
+
base <- list(name = name, class = class(col)[1L], n = n, missing = missing)
|
|
696
|
+
# A SHORT summary for the hover panel, formatted here for the same reason the
|
|
697
|
+
# card's is (fmt_pct): R decides significant digits, and two formatters would
|
|
698
|
+
# eventually disagree about the same number. Deliberately not the card's list
|
|
699
|
+
# — hovering wants the six figures you would glance at, not fourteen.
|
|
700
|
+
pair <- function(label, value) list(label = label, value = value)
|
|
701
|
+
miss_pair <- if (missing > 0L) list(pair("Missing", fmt_share(missing, n))) else list()
|
|
702
|
+
|
|
703
|
+
# Every `bins`/`levels` below is wrapped in I(): auto_unbox turns a length-1
|
|
704
|
+
# vector into a bare scalar, so a constant column or single-level factor
|
|
705
|
+
# shipped `"bins": 10` and broke every client that mapped over it. I() pins
|
|
706
|
+
# the array shape regardless of length; scalars elsewhere stay scalars.
|
|
707
|
+
if (is.numeric(col)) {
|
|
708
|
+
ok <- col[!na]
|
|
709
|
+
if (length(ok) == 0L) return(c(base, list(kind = "numeric", bins = I(integer(0)),
|
|
710
|
+
stat = "all missing",
|
|
711
|
+
summary = I(list(pair("Missing",
|
|
712
|
+
fmt_share(missing, n)))))))
|
|
713
|
+
rng <- range(ok)
|
|
714
|
+
# tabulate(), not table(): table drops empty bins, so a gap in the data
|
|
715
|
+
# silently shortens the sparkline and every bar after it shifts left.
|
|
716
|
+
bins <- if (diff(rng) == 0) rep(length(ok), 1L) else
|
|
717
|
+
tabulate(cut(ok, breaks = 12L, labels = FALSE, include.lowest = TRUE), nbins = 12L)
|
|
718
|
+
med <- stats::median(ok)
|
|
719
|
+
# mean and sd measure at 0.1 and 0.2 ms on a million rows, against the
|
|
720
|
+
# 3.1 ms this function already costs — the cheapest useful thing to add.
|
|
721
|
+
mu <- mean(ok)
|
|
722
|
+
sigma <- stats::sd(ok)
|
|
723
|
+
c(base, list(kind = "numeric", bins = I(as.integer(bins)),
|
|
724
|
+
min = rng[1L], max = rng[2L], median = med, mean = mu, sd = sigma,
|
|
725
|
+
summary = I(c(list(pair("Mean", fmt_num(mu)),
|
|
726
|
+
pair("Std. dev.", fmt_num(sigma)),
|
|
727
|
+
pair("Minimum", fmt_num(rng[1L])),
|
|
728
|
+
pair("Median", fmt_num(med)),
|
|
729
|
+
pair("Maximum", fmt_num(rng[2L]))), miss_pair)),
|
|
730
|
+
stat = sprintf("%s – %s", fmt_num(rng[1L]), fmt_num(rng[2L]))))
|
|
731
|
+
} else if (is.logical(col)) {
|
|
732
|
+
yes <- sum(col %in% TRUE)
|
|
733
|
+
no <- sum(col %in% FALSE)
|
|
734
|
+
c(base, list(kind = "logical",
|
|
735
|
+
bins = I(as.integer(c(yes, no))),
|
|
736
|
+
levels = I(c("TRUE", "FALSE")),
|
|
737
|
+
summary = I(c(list(pair("TRUE", fmt_share(yes, yes + no)),
|
|
738
|
+
pair("FALSE", fmt_share(no, yes + no))), miss_pair)),
|
|
739
|
+
stat = sprintf("%d true / %d false", yes, no)))
|
|
740
|
+
} else {
|
|
741
|
+
text <- as.character(col[!na])
|
|
742
|
+
tab <- sort(table(text), decreasing = TRUE)
|
|
743
|
+
top <- utils::head(tab, 12L)
|
|
744
|
+
# The commonest levels answer "what is in here" far better than the level
|
|
745
|
+
# COUNT alone, and they are already computed for the sparkline. Four are
|
|
746
|
+
# listed when four is all there is, so a column with exactly four levels
|
|
747
|
+
# does not show three and silently swallow the last one.
|
|
748
|
+
lead <- utils::head(tab, if (length(tab) <= 4L) length(tab) else 3L)
|
|
749
|
+
tops <- lapply(seq_along(lead), function(i)
|
|
750
|
+
pair(substr(names(lead)[i], 1L, MAX_VIEW_LABEL_CHARS),
|
|
751
|
+
fmt_share(lead[[i]], length(text))))
|
|
752
|
+
c(base, list(kind = "categorical", bins = I(as.integer(top)),
|
|
753
|
+
levels = I(substr(as.character(names(top)), 1L, MAX_VIEW_LABEL_CHARS)),
|
|
754
|
+
nlevels = length(tab),
|
|
755
|
+
summary = I(c(list(pair("Distinct levels", fmt_count(length(tab)))),
|
|
756
|
+
tops, miss_pair)),
|
|
757
|
+
stat = sprintf("%d level%s", length(tab), if (length(tab) == 1L) "" else "s")))
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
fmt_num <- function(x) {
|
|
762
|
+
# as.character(NA_real_) is NA_character_, not "NA" — and a genuine NA in a
|
|
763
|
+
# display field ships as JSON null, which the card then prints as the word
|
|
764
|
+
# "null". A constant column's skewness is exactly this case (sd is 0, so the
|
|
765
|
+
# z-scores are 0/0), so it is a value the viewer really does meet.
|
|
766
|
+
if (is.nan(x)) return("NaN")
|
|
767
|
+
if (is.na(x)) return("NA")
|
|
768
|
+
if (!is.finite(x)) return(as.character(x))
|
|
769
|
+
if (abs(x) >= 1e5 || (abs(x) < 1e-3 && x != 0)) format(x, digits = 3, scientific = TRUE)
|
|
770
|
+
else format(round(x, 3), trim = TRUE)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
fmt_count <- function(x) format(as.numeric(x), big.mark = ",", trim = TRUE, scientific = FALSE)
|
|
774
|
+
|
|
775
|
+
#' A share as a bare percentage: "85.3%".
|
|
776
|
+
#'
|
|
777
|
+
#' Every percentage on the statistics card comes from HERE, including the ones
|
|
778
|
+
#' beside each level in the bar list. R and JavaScript do not round halves the
|
|
779
|
+
#' same way — R's round() goes to even, so 50/4000 is 1.2%, while JavaScript's
|
|
780
|
+
#' toFixed(1) gives 1.3% — and a card that computed some of its own percentages
|
|
781
|
+
#' printed both, for the same count, two inches apart.
|
|
782
|
+
#' Always one decimal: format() drops a trailing zero, so a column of shares
|
|
783
|
+
#' read "1.2% / 1.1% / 1% / 1%" and the eye stopped trusting the alignment.
|
|
784
|
+
fmt_pct <- function(part, whole) {
|
|
785
|
+
if (!is.finite(whole) || whole <= 0) return("")
|
|
786
|
+
paste0(formatC(100 * part / whole, format = "f", digits = 1), "%")
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
#' A share as count and percentage: "3,412 (85.3%)".
|
|
790
|
+
fmt_share <- function(part, whole) {
|
|
791
|
+
if (!is.finite(whole) || whole <= 0) return(fmt_count(part))
|
|
792
|
+
sprintf("%s (%s)", fmt_count(part), fmt_pct(part, whole))
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
MAX_STATS_LEVELS <- 15L
|
|
796
|
+
STATS_BINS <- 24L
|
|
797
|
+
|
|
798
|
+
#' Everything a statistics card shows about ONE column.
|
|
799
|
+
#'
|
|
800
|
+
#' Deliberately separate from describe_column(): that one runs for every column
|
|
801
|
+
#' of every page and must stay cheap, so it ships 12 bins and a label. This one
|
|
802
|
+
#' runs when a reader asks about a single column and can afford quantiles, shape
|
|
803
|
+
#' moments and a level table.
|
|
804
|
+
#'
|
|
805
|
+
#' Numbers come back BOTH ways — as strings R formatted (so the card never
|
|
806
|
+
#' re-invents significant digits in JavaScript) and, where a drawing needs them,
|
|
807
|
+
#' as raw numerics. The two never disagree because they come from one value.
|
|
808
|
+
#'
|
|
809
|
+
#' @param col A column vector.
|
|
810
|
+
#' @param column_name Its name.
|
|
811
|
+
#' @return A named list; see the `colstats` frame.
|
|
812
|
+
colstats_payload <- function(col, column_name) {
|
|
813
|
+
n <- length(col)
|
|
814
|
+
missing <- sum(is.na(col))
|
|
815
|
+
ok <- col[!is.na(col)]
|
|
816
|
+
pair <- function(label, value) list(label = label, value = value)
|
|
817
|
+
base <- list(column = column_name, class = paste(class(col), collapse = "/"),
|
|
818
|
+
n = n, missing = missing, present = length(ok),
|
|
819
|
+
distinct = length(unique(ok)))
|
|
820
|
+
|
|
821
|
+
if (is.numeric(col) || inherits(col, "Date") || inherits(col, "POSIXct")) {
|
|
822
|
+
dated <- inherits(col, "Date") || inherits(col, "POSIXct")
|
|
823
|
+
x <- as.numeric(ok)
|
|
824
|
+
if (!length(x)) {
|
|
825
|
+
return(c(base, list(kind = if (dated) "date" else "numeric",
|
|
826
|
+
summary = I(list(pair("Present", "0 — every value is missing"))))))
|
|
827
|
+
}
|
|
828
|
+
show <- if (dated) function(v) format(if (inherits(col, "Date"))
|
|
829
|
+
as.Date(v, origin = "1970-01-01") else as.POSIXct(v, origin = "1970-01-01", tz = "UTC"))
|
|
830
|
+
else fmt_num
|
|
831
|
+
q <- stats::quantile(x, c(0.25, 0.5, 0.75), names = FALSE, type = 7)
|
|
832
|
+
iqr <- q[3L] - q[1L]
|
|
833
|
+
m <- mean(x)
|
|
834
|
+
s <- stats::sd(x)
|
|
835
|
+
# Fences are Tukey's, so "outlier" here means what a boxplot means by it —
|
|
836
|
+
# not a normal-theory z cut, which would be a different claim about data
|
|
837
|
+
# nobody has shown is normal.
|
|
838
|
+
lo_fence <- q[1L] - 1.5 * iqr
|
|
839
|
+
hi_fence <- q[3L] + 1.5 * iqr
|
|
840
|
+
outliers <- sum(x < lo_fence | x > hi_fence)
|
|
841
|
+
# Moments by hand rather than by dependency: both are one line, and
|
|
842
|
+
# e1071/moments are not worth a require() in a kernel that must boot fast.
|
|
843
|
+
z <- if (is.finite(s) && s > 0) (x - m) / s else rep(NA_real_, length(x))
|
|
844
|
+
skew <- if (all(is.finite(z))) mean(z^3) else NA_real_
|
|
845
|
+
kurt <- if (all(is.finite(z))) mean(z^4) - 3 else NA_real_
|
|
846
|
+
rng <- range(x)
|
|
847
|
+
bins <- if (diff(rng) == 0) as.integer(length(x)) else
|
|
848
|
+
tabulate(cut(x, breaks = STATS_BINS, labels = FALSE, include.lowest = TRUE),
|
|
849
|
+
nbins = STATS_BINS)
|
|
850
|
+
summary <- list(
|
|
851
|
+
pair("Mean", show(m)), pair("Std. dev.", if (dated) fmt_num(s) else fmt_num(s)),
|
|
852
|
+
pair("Minimum", show(rng[1L])), pair("1st quartile", show(q[1L])),
|
|
853
|
+
pair("Median", show(q[2L])), pair("3rd quartile", show(q[3L])),
|
|
854
|
+
pair("Maximum", show(rng[2L])), pair("IQR", if (dated) fmt_num(iqr) else fmt_num(iqr)),
|
|
855
|
+
pair("Median abs. dev.", fmt_num(stats::mad(x))),
|
|
856
|
+
pair("Skewness", fmt_num(skew)), pair("Excess kurtosis", fmt_num(kurt)),
|
|
857
|
+
pair("Outliers (1.5 IQR)", fmt_share(outliers, length(x))))
|
|
858
|
+
if (!dated) summary <- c(summary, list(
|
|
859
|
+
pair("Zeros", fmt_share(sum(x == 0), length(x))),
|
|
860
|
+
pair("Negative", fmt_share(sum(x < 0), length(x)))))
|
|
861
|
+
if (dated) summary <- c(summary, list(pair("Span", paste(fmt_num(diff(rng) /
|
|
862
|
+
if (inherits(col, "Date")) 1 else 86400), "days"))))
|
|
863
|
+
return(c(base, list(kind = if (dated) "date" else "numeric",
|
|
864
|
+
summary = I(summary),
|
|
865
|
+
bins = I(as.integer(bins)),
|
|
866
|
+
binMin = rng[1L], binMax = rng[2L],
|
|
867
|
+
box = list(min = rng[1L], q1 = q[1L], median = q[2L],
|
|
868
|
+
q3 = q[3L], max = rng[2L],
|
|
869
|
+
lower = max(lo_fence, rng[1L]),
|
|
870
|
+
upper = min(hi_fence, rng[2L]),
|
|
871
|
+
outliers = outliers))))
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (is.logical(col)) {
|
|
875
|
+
yes <- sum(col %in% TRUE)
|
|
876
|
+
no <- sum(col %in% FALSE)
|
|
877
|
+
return(c(base, list(kind = "logical",
|
|
878
|
+
summary = I(list(pair("TRUE", fmt_share(yes, yes + no)),
|
|
879
|
+
pair("FALSE", fmt_share(no, yes + no)),
|
|
880
|
+
pair("Missing", fmt_share(missing, n)))),
|
|
881
|
+
levels = I(c("TRUE", "FALSE")), counts = I(as.integer(c(yes, no))),
|
|
882
|
+
shares = I(c(fmt_pct(yes, yes + no), fmt_pct(no, yes + no))), other = 0L)))
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
text <- as_search_text(ok)
|
|
886
|
+
tab <- sort(table(text), decreasing = TRUE)
|
|
887
|
+
top <- utils::head(tab, MAX_STATS_LEVELS)
|
|
888
|
+
widths <- nchar(text)
|
|
889
|
+
summary <- list(
|
|
890
|
+
pair("Distinct levels", fmt_count(length(tab))),
|
|
891
|
+
pair("Most common", if (length(tab)) sprintf("%s — %s", names(tab)[1L],
|
|
892
|
+
fmt_share(tab[[1L]], length(text))) else "—"),
|
|
893
|
+
pair("Least common", if (length(tab)) sprintf("%s — %s", names(tab)[length(tab)],
|
|
894
|
+
fmt_share(tab[[length(tab)]], length(text))) else "—"),
|
|
895
|
+
pair("Empty strings", fmt_share(sum(!nzchar(text)), length(text))),
|
|
896
|
+
pair("Shortest", if (length(widths)) fmt_count(min(widths)) else "—"),
|
|
897
|
+
pair("Longest", if (length(widths)) fmt_count(max(widths)) else "—"),
|
|
898
|
+
pair("Mean length", if (length(widths)) fmt_num(mean(widths)) else "—"))
|
|
899
|
+
other <- as.integer(length(text) - sum(top))
|
|
900
|
+
c(base, list(kind = "categorical", summary = I(summary),
|
|
901
|
+
levels = I(substr(names(top), 1L, MAX_VIEW_LABEL_CHARS)),
|
|
902
|
+
counts = I(as.integer(top)),
|
|
903
|
+
shares = I(vapply(as.integer(top), fmt_pct, character(1),
|
|
904
|
+
whole = length(text))),
|
|
905
|
+
other = other, otherShare = fmt_pct(other, length(text)),
|
|
906
|
+
nlevels = length(tab)))
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
#' The statistics card: one column, profiled over the rows the grid is showing.
|
|
910
|
+
#'
|
|
911
|
+
#' Resolves the object exactly as emit_view does, and takes the SAME query and
|
|
912
|
+
#' filters, so the panel describes the visible data rather than a different
|
|
913
|
+
#' population that happens to share a name.
|
|
914
|
+
#'
|
|
915
|
+
#' @param id Request id.
|
|
916
|
+
#' @param name Object name or expression, as for `view`.
|
|
917
|
+
#' @param column The column to profile.
|
|
918
|
+
#' @param query,filters The viewer's active search and column filters.
|
|
919
|
+
emit_colstats <- function(id, name, column, query = NULL, filters = NULL) {
|
|
920
|
+
column <- if (is.character(column) && length(column)) column[[1L]] else ""
|
|
921
|
+
# Same console-read wedge as emit_view: parse(text = NULL) blocks the
|
|
922
|
+
# interactive worker on its own protocol stream.
|
|
923
|
+
if (!is.character(name) || length(name) != 1L || is.na(name) || !nzchar(name)) {
|
|
924
|
+
emit(list(type = "colstats", id = id, name = name, column = column,
|
|
925
|
+
error = "bad name"))
|
|
926
|
+
return(invisible(NULL))
|
|
927
|
+
}
|
|
928
|
+
obj <- tryCatch(eval(parse(text = name), globalenv()), error = function(e) NULL)
|
|
929
|
+
if (!is.data.frame(obj)) obj <- tryCatch(as.data.frame(obj, stringsAsFactors = FALSE),
|
|
930
|
+
error = function(e) NULL)
|
|
931
|
+
if (is.null(obj) || !nzchar(column)) {
|
|
932
|
+
emit(list(type = "colstats", id = id, name = name, column = column,
|
|
933
|
+
error = "not found"))
|
|
934
|
+
return(invisible(NULL))
|
|
935
|
+
}
|
|
936
|
+
total <- nrow(obj)
|
|
937
|
+
narrowed <- view_filter(obj, query, filters)
|
|
938
|
+
obj <- narrowed$obj
|
|
939
|
+
# The viewer truncates long column names for display; match on the truncated
|
|
940
|
+
# name too, or a card opened from a clipped header can never find its column.
|
|
941
|
+
hit <- match(column, names(obj))
|
|
942
|
+
if (is.na(hit)) hit <- match(column, substr(names(obj), 1L, MAX_VIEW_LABEL_CHARS))
|
|
943
|
+
if (is.na(hit)) {
|
|
944
|
+
emit(list(type = "colstats", id = id, name = name, column = column,
|
|
945
|
+
error = "no such column"))
|
|
946
|
+
return(invisible(NULL))
|
|
947
|
+
}
|
|
948
|
+
tryCatch(
|
|
949
|
+
emit(c(list(type = "colstats", id = id, name = name),
|
|
950
|
+
colstats_payload(obj[[hit]], column),
|
|
951
|
+
list(rows = nrow(obj), totalRows = total,
|
|
952
|
+
filtered = nzchar(narrowed$query) || narrowed$count > 0L))),
|
|
953
|
+
interrupt = function(i) emit(list(type = "colstats", id = id, name = name,
|
|
954
|
+
column = column, error = "interrupted"))
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#' Autocomplete via R's own engine — the one behind TAB in the console,
|
|
959
|
+
#' RStudio and Jupyter. Not hand-rolled: the engine already understands `$`
|
|
960
|
+
#' and `@` access, argument names inside a call, `::` namespaces, library()
|
|
961
|
+
#' package names and quoted file paths, and hand-rolling any one of those
|
|
962
|
+
#' badly is worse than none.
|
|
963
|
+
#'
|
|
964
|
+
#' The five utils functions driving it are internal (:::), so the whole
|
|
965
|
+
#' pipeline is wrapped and degrades to an empty item list if a future R
|
|
966
|
+
#' renames them — a completion popup that is sometimes empty beats a worker
|
|
967
|
+
#' that dies on a keystroke.
|
|
968
|
+
#'
|
|
969
|
+
#' `start`/`end` are the 0-based character range of `line` the completion
|
|
970
|
+
#' REPLACES (`end` exclusive, = cursor): the engine completes "x$al" to the
|
|
971
|
+
#' full "x$alpha", so the client splices `line[0:start] + value + line[end:]`
|
|
972
|
+
#' and must never re-derive the token itself.
|
|
973
|
+
#'
|
|
974
|
+
#' @param id Request id.
|
|
975
|
+
#' @param line The source line being typed.
|
|
976
|
+
#' @param cursor Character offset of the caret in `line` (0-based).
|
|
977
|
+
#' @param max_items Cap on items shipped; `truncated` reports the cut.
|
|
978
|
+
#' @param fn Optional callee name (context the line alone cannot show); its
|
|
979
|
+
#' formals ride as `args`.
|
|
980
|
+
#' @param data Optional data-frame name in scope (pipe head, `data =`); its
|
|
981
|
+
#' columns ride as `columns: [{value, type}]`.
|
|
982
|
+
#' @return Invisibly NULL. Emits one `complete` frame. A frame with no items
|
|
983
|
+
#' carries `reason` when the engine actually FAILED, so a completion that has
|
|
984
|
+
#' gone dark can be told apart from a token R simply had nothing to say
|
|
985
|
+
#' about — the difference used to be invisible from the browser.
|
|
986
|
+
emit_complete <- function(id, line = NULL, cursor = NULL, max_items = MAX_COMPLETIONS,
|
|
987
|
+
fn = NULL, data = NULL) {
|
|
988
|
+
if (!is.character(line) || length(line) != 1L || is.na(line)) line <- ""
|
|
989
|
+
cursor <- max(0L, min(as_count(cursor, nchar(line)), nchar(line)))
|
|
990
|
+
empty <- list(type = "complete", id = id, start = cursor, end = cursor,
|
|
991
|
+
token = "", items = list(), truncated = FALSE,
|
|
992
|
+
args = I(completion_call_args(fn)),
|
|
993
|
+
columns = completion_columns(data, max_items))
|
|
994
|
+
# Degrading to an empty list keeps a keystroke from killing the worker, but
|
|
995
|
+
# a silent empty list is indistinguishable from "no matches" — and that is
|
|
996
|
+
# exactly the shape a broken completion engine takes in a long-lived
|
|
997
|
+
# session. The condition is kept and shipped with the empty frame.
|
|
998
|
+
reason <- NULL
|
|
999
|
+
st <- tryCatch({
|
|
1000
|
+
ce <- utils:::.CompletionEnv
|
|
1001
|
+
utils:::.assignLinebuffer(line)
|
|
1002
|
+
utils:::.assignEnd(cursor)
|
|
1003
|
+
utils:::.guessTokenFromLine()
|
|
1004
|
+
utils:::.completeToken()
|
|
1005
|
+
list(token = as.character(ce[["token"]]), start = as.integer(ce[["start"]]),
|
|
1006
|
+
comps = as.character(utils:::.retrieveCompletions()),
|
|
1007
|
+
quoted = isTRUE(ce[["fileName"]]))
|
|
1008
|
+
}, error = function(e) { reason <<- conditionMessage(e); NULL },
|
|
1009
|
+
interrupt = function(i) { reason <<- "interrupted"; NULL })
|
|
1010
|
+
if (is.null(st) || length(st$start) != 1L || is.na(st$start)) {
|
|
1011
|
+
if (is.null(reason) && !is.null(st)) {
|
|
1012
|
+
reason <- "the completion engine returned no position for this token"
|
|
1013
|
+
}
|
|
1014
|
+
if (!is.null(reason)) empty$reason <- as.character(reason)[[1L]]
|
|
1015
|
+
emit(empty)
|
|
1016
|
+
return(invisible(NULL))
|
|
1017
|
+
}
|
|
1018
|
+
# The engine's own fileName flag is not stable across versions (observed
|
|
1019
|
+
# FALSE on 4.5.2 while returning paths), so the quote immediately before
|
|
1020
|
+
# the token is the authoritative signal for path completion.
|
|
1021
|
+
quoted <- st$quoted ||
|
|
1022
|
+
(st$start > 0L && substr(line, st$start, st$start) %in% c("\"", "'"))
|
|
1023
|
+
in_library <- grepl(
|
|
1024
|
+
"(library|require|requireNamespace|loadNamespace)\\s*\\(\\s*[\"']?\\s*$",
|
|
1025
|
+
substr(line, 1L, st$start))
|
|
1026
|
+
is_name <- function(v) grepl("^[.a-zA-Z][._a-zA-Z0-9]*$", v)
|
|
1027
|
+
describe_item <- function(v) {
|
|
1028
|
+
if (endsWith(v, "=")) return(list(value = v, kind = "argument", detail = ""))
|
|
1029
|
+
if (endsWith(v, "::")) return(list(value = v, kind = "package", detail = ""))
|
|
1030
|
+
if (quoted) return(list(value = v, kind = "file", detail = ""))
|
|
1031
|
+
if (in_library) return(list(value = v, kind = "package", detail = ""))
|
|
1032
|
+
bare <- sub("\\($", "", v)
|
|
1033
|
+
if (bare %in% R_KEYWORDS) return(list(value = bare, kind = "keyword", detail = ""))
|
|
1034
|
+
if (endsWith(v, "(")) {
|
|
1035
|
+
# value ships WITHOUT the "(" — the editor decides about parentheses —
|
|
1036
|
+
# and the signature rides as the right-hand hint instead.
|
|
1037
|
+
f <- if (is_name(bare)) get0(bare, envir = globalenv(), mode = "function")
|
|
1038
|
+
else NULL
|
|
1039
|
+
return(list(value = bare, kind = "function",
|
|
1040
|
+
detail = if (is.function(f)) paste0("(", formals_string(f), ")")
|
|
1041
|
+
else ""))
|
|
1042
|
+
}
|
|
1043
|
+
detail <- ""
|
|
1044
|
+
if (is_name(v)) {
|
|
1045
|
+
o <- get0(v, envir = globalenv())
|
|
1046
|
+
if (!is.null(o)) {
|
|
1047
|
+
dims <- if (!is.null(dim(o))) paste(dim(o), collapse = " × ")
|
|
1048
|
+
else as.character(length(o))
|
|
1049
|
+
detail <- paste0(class(o)[1L], " · ", dims)
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
list(value = v, kind = "variable", detail = detail)
|
|
1053
|
+
}
|
|
1054
|
+
frame <- list(type = "complete", id = id, start = st$start, end = cursor,
|
|
1055
|
+
token = st$token,
|
|
1056
|
+
items = lapply(utils::head(st$comps, max_items), describe_item),
|
|
1057
|
+
truncated = length(st$comps) > max_items)
|
|
1058
|
+
frame$args <- I(completion_call_args(fn))
|
|
1059
|
+
frame$columns <- completion_columns(data, max_items)
|
|
1060
|
+
emit(frame)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
#' A bare R name, or `pkg::name` — the only shape a completion context may
|
|
1064
|
+
#' name. Anything else (a call, a subset, an expression) is refused, because
|
|
1065
|
+
#' these names are looked up, and a lookup must never become an evaluation.
|
|
1066
|
+
#'
|
|
1067
|
+
#' @param x Anything the wire delivered.
|
|
1068
|
+
#' @param namespaced Whether `pkg::name` is accepted.
|
|
1069
|
+
#' @return TRUE when `x` is a single syntactic name.
|
|
1070
|
+
is_completion_name <- function(x, namespaced = FALSE) {
|
|
1071
|
+
pattern <- if (namespaced) "^([.A-Za-z][._A-Za-z0-9]*:::?)?[.A-Za-z][._A-Za-z0-9]*$"
|
|
1072
|
+
else "^[.A-Za-z][._A-Za-z0-9]*$"
|
|
1073
|
+
is.character(x) && length(x) == 1L && !is.na(x) && grepl(pattern, x)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
#' The argument names of the call the caret is inside, for a call split over
|
|
1077
|
+
#' lines — R's engine sees only the current line, so `lm(\n fo` would
|
|
1078
|
+
#' otherwise offer no arguments at all. Looked up with get0 in the global
|
|
1079
|
+
#' environment, or in an ALREADY LOADED namespace for `pkg::fn`; nothing is
|
|
1080
|
+
#' loaded or evaluated to answer.
|
|
1081
|
+
#'
|
|
1082
|
+
#' @param fn The callee's name, as the page's context detection found it.
|
|
1083
|
+
#' @return Character vector of formal names without `...`; empty when unknown.
|
|
1084
|
+
completion_call_args <- function(fn) {
|
|
1085
|
+
if (!is_completion_name(fn, namespaced = TRUE)) return(character(0))
|
|
1086
|
+
parts <- strsplit(fn, ":::?")[[1L]]
|
|
1087
|
+
f <- if (length(parts) == 2L) {
|
|
1088
|
+
if (parts[[1L]] %in% loadedNamespaces())
|
|
1089
|
+
get0(parts[[2L]], envir = asNamespace(parts[[1L]]), mode = "function")
|
|
1090
|
+
} else get0(fn, envir = globalenv(), mode = "function")
|
|
1091
|
+
if (!is.function(f)) return(character(0))
|
|
1092
|
+
fl <- formals(f)
|
|
1093
|
+
if (is.null(fl)) fl <- tryCatch(formals(args(f)), error = function(e) NULL)
|
|
1094
|
+
setdiff(names(fl), c("...", ""))
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
#' The columns of the data frame a data-masking context names (a pipe head,
|
|
1098
|
+
#' `filter(df, `, `data = df`), typed like the Environment pane types them.
|
|
1099
|
+
#' A name that is not a data frame in the session yields nothing — an unrun
|
|
1100
|
+
#' chunk has no columns to offer, exactly as `df$` behaves.
|
|
1101
|
+
#'
|
|
1102
|
+
#' @param data The data frame's name.
|
|
1103
|
+
#' @param max_items Cap on columns shipped.
|
|
1104
|
+
#' @return A list of `{value, type}`; empty when there is no such data frame.
|
|
1105
|
+
completion_columns <- function(data, max_items = MAX_COMPLETIONS) {
|
|
1106
|
+
if (!is_completion_name(data)) return(list())
|
|
1107
|
+
df <- get0(data, envir = globalenv())
|
|
1108
|
+
if (!is.data.frame(df)) return(list())
|
|
1109
|
+
cols <- utils::head(names(df), max_items)
|
|
1110
|
+
lapply(cols, function(nm) list(value = nm, type = class(df[[nm]])[1L]))
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
#' Coerce a wire-supplied count, falling back rather than erroring: a malformed
|
|
1114
|
+
#' offset from a client must degrade to the default, not kill the pane.
|
|
1115
|
+
#'
|
|
1116
|
+
#' @param x Anything the wire delivered.
|
|
1117
|
+
#' @param default Used when x is absent or not a number.
|
|
1118
|
+
#' @return A single integer.
|
|
1119
|
+
as_count <- function(x, default) {
|
|
1120
|
+
n <- suppressWarnings(as.integer(x))
|
|
1121
|
+
if (length(n) != 1L || is.na(n)) default else n
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#' Assemble the body of a view reply: true shape, whole-column descriptions,
|
|
1125
|
+
#' and one row × column window. Shared by `view` and `import`, so the two
|
|
1126
|
+
#' frames stay the same shape — and the same CAPS — by construction rather
|
|
1127
|
+
#' than by discipline.
|
|
1128
|
+
#'
|
|
1129
|
+
#' A column as searchable text, whatever it holds.
|
|
1130
|
+
as_search_text <- function(col) {
|
|
1131
|
+
tryCatch(as.character(col), error = function(e) rep("", length(col)))
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
# `grepl(..., fixed = TRUE, ignore.case = TRUE)` is NOT case-insensitive:
|
|
1135
|
+
# R ignores ignore.case whenever fixed is TRUE, and says so in a warning
|
|
1136
|
+
# that goes nowhere anyone reads. The viewer's search box and its text
|
|
1137
|
+
# column filters all promised case-insensitive matching in their own
|
|
1138
|
+
# documentation and all silently required the exact case — searching
|
|
1139
|
+
# "Cohesion" found nothing in a column of "cohesion".
|
|
1140
|
+
#
|
|
1141
|
+
# So the needle is escaped to a literal pattern instead and `fixed` is
|
|
1142
|
+
# dropped, which keeps every metacharacter inert (`a.c` matches only "a.c",
|
|
1143
|
+
# `f[1]` does not blow up) while letting PCRE fold case, including for
|
|
1144
|
+
# non-ASCII: "É" matches "é".
|
|
1145
|
+
escape_regex <- function(s) gsub("([][{}()*+?.^$|\\\\])", "\\\\\\1", s)
|
|
1146
|
+
contains_ci <- function(text, needle) {
|
|
1147
|
+
grepl(escape_regex(needle), text, ignore.case = TRUE, perl = TRUE)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
#' One column filter spec → a logical keep-mask. Never evaluates the spec:
|
|
1151
|
+
#' comparisons and ranges are parsed, everything else is literal text.
|
|
1152
|
+
#'
|
|
1153
|
+
#' @param col A column vector.
|
|
1154
|
+
#' @param spec The user's filter string.
|
|
1155
|
+
#' @return A logical vector as long as `col`.
|
|
1156
|
+
match_filter <- function(col, spec) {
|
|
1157
|
+
spec <- trimws(as.character(spec)[1L])
|
|
1158
|
+
if (!nzchar(spec)) return(rep(TRUE, length(col)))
|
|
1159
|
+
if (identical(toupper(spec), "NA")) return(is.na(col))
|
|
1160
|
+
if (is.logical(col)) {
|
|
1161
|
+
wanted <- toupper(spec)
|
|
1162
|
+
if (wanted %in% c("TRUE", "T")) return(!is.na(col) & col)
|
|
1163
|
+
if (wanted %in% c("FALSE", "F")) return(!is.na(col) & !col)
|
|
1164
|
+
}
|
|
1165
|
+
if (is.numeric(col)) {
|
|
1166
|
+
range <- strsplit(spec, "\\.\\.", perl = TRUE)[[1L]]
|
|
1167
|
+
if (length(range) == 2L) {
|
|
1168
|
+
lo <- suppressWarnings(as.numeric(trimws(range[1L])))
|
|
1169
|
+
hi <- suppressWarnings(as.numeric(trimws(range[2L])))
|
|
1170
|
+
if (!is.na(lo) && !is.na(hi)) return(!is.na(col) & col >= lo & col <= hi)
|
|
1171
|
+
}
|
|
1172
|
+
parts <- regmatches(spec, regexec("^\\s*(>=|<=|!=|==|=|>|<)\\s*(-?[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\s*$", spec))[[1L]]
|
|
1173
|
+
if (length(parts) == 3L) {
|
|
1174
|
+
target <- as.numeric(parts[3L])
|
|
1175
|
+
hit <- switch(parts[2L], ">=" = col >= target, "<=" = col <= target,
|
|
1176
|
+
"!=" = col != target, "==" = col == target, "=" = col == target,
|
|
1177
|
+
">" = col > target, "<" = col < target)
|
|
1178
|
+
return(!is.na(hit) & hit)
|
|
1179
|
+
}
|
|
1180
|
+
target <- suppressWarnings(as.numeric(spec))
|
|
1181
|
+
if (!is.na(target)) return(!is.na(col) & col == target)
|
|
1182
|
+
}
|
|
1183
|
+
text <- as_search_text(col)
|
|
1184
|
+
if (startsWith(spec, "=")) return(!is.na(col) & tolower(text) == tolower(substring(spec, 2L)))
|
|
1185
|
+
if (startsWith(spec, "!")) return(is.na(col) | !contains_ci(text, substring(spec, 2L)))
|
|
1186
|
+
!is.na(col) & contains_ci(text, spec)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
#' Narrow a frame by the viewer's search box and its per-column filters.
|
|
1190
|
+
#'
|
|
1191
|
+
#' Extracted from view_payload so `colstats` can answer about exactly the rows
|
|
1192
|
+
#' the grid is displaying. A statistics panel that quietly profiled the whole
|
|
1193
|
+
#' frame while the grid showed a filtered subset would be worse than no panel:
|
|
1194
|
+
#' both numbers look authoritative and only one answers the question asked.
|
|
1195
|
+
#'
|
|
1196
|
+
#' @param obj A data.frame.
|
|
1197
|
+
#' @param query Free-text search across every column.
|
|
1198
|
+
#' @param filters Named list of per-column filter specs.
|
|
1199
|
+
#' @return list(obj, count = filters applied, query = the trimmed query).
|
|
1200
|
+
view_filter <- function(obj, query = NULL, filters = NULL) {
|
|
1201
|
+
count <- 0L
|
|
1202
|
+
if (is.list(filters) && length(filters) && length(names(filters))) {
|
|
1203
|
+
for (nm in intersect(names(filters), names(obj))) {
|
|
1204
|
+
spec <- filters[[nm]]
|
|
1205
|
+
if (length(spec) && nzchar(trimws(as.character(spec)[1L]))) {
|
|
1206
|
+
obj <- obj[match_filter(obj[[nm]], spec), , drop = FALSE]
|
|
1207
|
+
count <- count + 1L
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
query <- if (is.character(query) && length(query)) trimws(query[1L]) else ""
|
|
1212
|
+
if (nzchar(query) && nrow(obj)) {
|
|
1213
|
+
hits <- lapply(obj, function(col) contains_ci(as_search_text(col), query))
|
|
1214
|
+
keep <- Reduce(`|`, hits, init = rep(FALSE, nrow(obj)))
|
|
1215
|
+
obj <- obj[keep, , drop = FALSE]
|
|
1216
|
+
}
|
|
1217
|
+
list(obj = obj, count = count, query = query)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
#' Sorting happens HERE, not in the client: the client only ever holds one
|
|
1221
|
+
#' page, so a client-side sort would order 200 rows of a million-row frame and
|
|
1222
|
+
#' call it sorted. It runs on the full frame BEFORE any windowing, so a column
|
|
1223
|
+
#' window still sees globally ordered rows.
|
|
1224
|
+
#'
|
|
1225
|
+
#' The reply always states both what exists (`nrow`/`ncol`) and what it
|
|
1226
|
+
#' actually contains (`shown`/`shownCols`, effective `limit`/`colLimit`,
|
|
1227
|
+
#' clamp flags) — nothing downstream should have to guess.
|
|
1228
|
+
#'
|
|
1229
|
+
#' @param obj Any object; coerced to data.frame or described as "not a table".
|
|
1230
|
+
#' @param shown_name Display name echoed in the frame.
|
|
1231
|
+
#' @param offset Rows to skip before the page (default 0).
|
|
1232
|
+
#' @param limit Page size (default 200, hard-capped at MAX_VIEW_ROWS).
|
|
1233
|
+
#' @param sort Column name to order by; unknown names are ignored.
|
|
1234
|
+
#' @param desc Descending order when TRUE.
|
|
1235
|
+
#' @param col_offset Columns to skip before the window (default 0).
|
|
1236
|
+
#' @param col_limit Column window size (default 30, capped at MAX_VIEW_COLS).
|
|
1237
|
+
#' @param query Case-insensitive text searched across every column.
|
|
1238
|
+
#' @param filters Named column filter strings. Numeric comparisons and ranges
|
|
1239
|
+
#' are interpreted without eval; all other values use text matching.
|
|
1240
|
+
#' @return Named list of frame fields (no type/id — the caller owns those).
|
|
1241
|
+
view_payload <- function(obj, shown_name, offset = NULL, limit = NULL,
|
|
1242
|
+
sort = NULL, desc = FALSE,
|
|
1243
|
+
col_offset = NULL, col_limit = NULL,
|
|
1244
|
+
query = NULL, filters = NULL) {
|
|
1245
|
+
if (!is.data.frame(obj)) obj <- tryCatch(as.data.frame(obj, stringsAsFactors = FALSE),
|
|
1246
|
+
error = function(e) NULL)
|
|
1247
|
+
if (is.null(obj)) return(list(name = shown_name, error = "not a table"))
|
|
1248
|
+
total_rows <- nrow(obj)
|
|
1249
|
+
offset <- max(0L, as_count(offset, 0L))
|
|
1250
|
+
limit_req <- max(1L, as_count(limit, 200L))
|
|
1251
|
+
limit <- min(limit_req, MAX_VIEW_ROWS)
|
|
1252
|
+
col_offset <- max(0L, as_count(col_offset, 0L))
|
|
1253
|
+
col_limit_req <- max(1L, as_count(col_limit, 30L))
|
|
1254
|
+
col_limit <- min(col_limit_req, MAX_VIEW_COLS)
|
|
1255
|
+
|
|
1256
|
+
# Search and per-column filters. Shared with the statistics card, so a
|
|
1257
|
+
# profile of "the rows you are looking at" cannot disagree with the rows the
|
|
1258
|
+
# grid is actually showing.
|
|
1259
|
+
narrowed <- view_filter(obj, query, filters)
|
|
1260
|
+
obj <- narrowed$obj
|
|
1261
|
+
filter_count <- narrowed$count
|
|
1262
|
+
query <- narrowed$query
|
|
1263
|
+
if (is.character(sort) && length(sort) == 1L && sort %in% names(obj)) {
|
|
1264
|
+
obj <- obj[order(obj[[sort]], decreasing = isTRUE(desc)), , drop = FALSE]
|
|
1265
|
+
}
|
|
1266
|
+
cidx <- seq.int(from = col_offset + 1L,
|
|
1267
|
+
length.out = max(0L, min(col_limit, ncol(obj) - col_offset)))
|
|
1268
|
+
idx <- seq.int(from = offset + 1L,
|
|
1269
|
+
length.out = max(0L, min(limit, nrow(obj) - offset)))
|
|
1270
|
+
page <- obj[idx, cidx, drop = FALSE]
|
|
1271
|
+
source_names <- names(obj)[cidx]
|
|
1272
|
+
display_names <- make.unique(substr(source_names, 1L, MAX_VIEW_LABEL_CHARS))
|
|
1273
|
+
names(page) <- display_names
|
|
1274
|
+
# Row names would ride along as a `_row` field in the JSON; the offset
|
|
1275
|
+
# already says where the page sits, so they are noise.
|
|
1276
|
+
rownames(page) <- NULL
|
|
1277
|
+
# A single cell can dwarf the row/column caps (logs, embedded documents,
|
|
1278
|
+
# accidental blobs). The viewer is a preview, so clip display values before
|
|
1279
|
+
# JSON encoding. This also gives a hard upper bound when the page has only
|
|
1280
|
+
# one row and row shedding cannot help.
|
|
1281
|
+
clipped_cells <- 0L
|
|
1282
|
+
page[] <- lapply(page, function(col) {
|
|
1283
|
+
if (is.factor(col)) col <- as.character(col)
|
|
1284
|
+
if (is.list(col) && !is.data.frame(col)) {
|
|
1285
|
+
col <- vapply(col, function(value) {
|
|
1286
|
+
tryCatch(as.character(jsonlite::toJSON(value, auto_unbox = TRUE,
|
|
1287
|
+
null = "null", na = "null")),
|
|
1288
|
+
error = function(e) paste(capture.output(str(value,
|
|
1289
|
+
max.level = 1L, give.attr = FALSE)), collapse = " "))
|
|
1290
|
+
}, character(1))
|
|
1291
|
+
}
|
|
1292
|
+
if (is.character(col)) {
|
|
1293
|
+
too_long <- !is.na(col) & nchar(col) > MAX_VIEW_CELL_CHARS
|
|
1294
|
+
clipped_cells <<- clipped_cells + sum(too_long)
|
|
1295
|
+
col[too_long] <- paste0(substr(col[too_long], 1L, MAX_VIEW_CELL_CHARS), "…")
|
|
1296
|
+
}
|
|
1297
|
+
col
|
|
1298
|
+
})
|
|
1299
|
+
# Payload guard: the caps above bound CELLS, not bytes — 500 rows of 20 KB
|
|
1300
|
+
# strings is still a 10 MB frame that would stall the socket. Measure the
|
|
1301
|
+
# page as it will actually ship and shed rows until it fits, reporting the
|
|
1302
|
+
# shrunken window rather than silently serving it.
|
|
1303
|
+
page_json <- jsonlite::toJSON(page, auto_unbox = TRUE, null = "null",
|
|
1304
|
+
na = "null", digits = NA)
|
|
1305
|
+
bytes <- nchar(page_json, type = "bytes")
|
|
1306
|
+
if (bytes > MAX_VIEW_BYTES && nrow(page) > 1L) {
|
|
1307
|
+
keep <- max(1L, as.integer(floor(MAX_VIEW_BYTES / (bytes / nrow(page)))))
|
|
1308
|
+
if (keep < nrow(page)) {
|
|
1309
|
+
page <- page[seq_len(keep), , drop = FALSE]
|
|
1310
|
+
limit <- keep
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
# `columns` describes the windowed SET only, but each description covers the
|
|
1314
|
+
# WHOLE column — the sparkline must show the full distribution, not a page's.
|
|
1315
|
+
list(name = shown_name, nrow = nrow(obj), ncol = ncol(obj), totalRows = total_rows,
|
|
1316
|
+
filtered = nzchar(query) || filter_count > 0L, filterCount = filter_count,
|
|
1317
|
+
offset = offset, limit = limit,
|
|
1318
|
+
colOffset = col_offset, colLimit = col_limit,
|
|
1319
|
+
limitClamped = limit < limit_req,
|
|
1320
|
+
colLimitClamped = col_limit < col_limit_req,
|
|
1321
|
+
clippedCells = clipped_cells,
|
|
1322
|
+
columns = Map(function(index, display)
|
|
1323
|
+
describe_column(obj[[index]], display), cidx, display_names),
|
|
1324
|
+
rows = page, shown = nrow(page), shownCols = ncol(page))
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
#' The data viewer: column descriptions plus one row × column window.
|
|
1328
|
+
#'
|
|
1329
|
+
#' @param id Request id.
|
|
1330
|
+
#' @param name Name of an object in the global environment, or an expression.
|
|
1331
|
+
#' @param offset,limit,sort,desc,col_offset,col_limit Windowing and ordering —
|
|
1332
|
+
#' see view_payload.
|
|
1333
|
+
#' @param label Display name when it differs from the fetch name (View()).
|
|
1334
|
+
emit_view <- function(id, name, offset = NULL, limit = NULL, sort = NULL,
|
|
1335
|
+
desc = FALSE, col_offset = NULL, col_limit = NULL,
|
|
1336
|
+
label = NULL, query = NULL, filters = NULL) {
|
|
1337
|
+
# `parse(text = NULL)` falls back to `parse(file = "")` — the CONSOLE. In
|
|
1338
|
+
# the interactive worker that read BLOCKS on the protocol stream and wedges
|
|
1339
|
+
# the whole session (a batch Rscript merely got instant EOF, which is why
|
|
1340
|
+
# this guard did not exist from day one). Wire-supplied, so an error frame,
|
|
1341
|
+
# never a stop.
|
|
1342
|
+
if (!is.character(name) || length(name) != 1L || is.na(name) || !nzchar(name)) {
|
|
1343
|
+
emit(list(type = "view", id = id, name = label %||% name, error = "bad name"))
|
|
1344
|
+
return(invisible(NULL))
|
|
1345
|
+
}
|
|
1346
|
+
shown <- if (is.null(label)) name else label
|
|
1347
|
+
obj <- tryCatch(eval(parse(text = name), globalenv()), error = function(e) NULL)
|
|
1348
|
+
if (is.null(obj)) {
|
|
1349
|
+
emit(list(type = "view", id = id, name = shown, error = "not found"))
|
|
1350
|
+
return(invisible(NULL))
|
|
1351
|
+
}
|
|
1352
|
+
# A Stop pressed mid-sort of a big frame must cancel the page, not the worker.
|
|
1353
|
+
tryCatch(
|
|
1354
|
+
emit(c(list(type = "view", id = id),
|
|
1355
|
+
view_payload(obj, shown, offset, limit, sort, desc,
|
|
1356
|
+
col_offset, col_limit, query, filters))),
|
|
1357
|
+
interrupt = function(i) emit(list(type = "view", id = id, name = shown,
|
|
1358
|
+
error = "interrupted"))
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
#' Describe a file WITHOUT reading it into the session.
|
|
1364
|
+
#'
|
|
1365
|
+
#' The import wizard's eyes. Answers with the detected format, the delimited
|
|
1366
|
+
#' settings, a per-column type guess (including the date format, the timezone
|
|
1367
|
+
#' and any day/month ambiguity) and a text preview — but assigns nothing and
|
|
1368
|
+
#' changes nothing. Inspecting a file the user has not yet agreed to import
|
|
1369
|
+
#' must not put an object in their environment, and re-inspecting it under
|
|
1370
|
+
#' different settings must not put twenty.
|
|
1371
|
+
#'
|
|
1372
|
+
#' Every failure is an `error` FIELD rather than a thrown condition: pointing
|
|
1373
|
+
#' the wizard at a 2 GB binary is a normal thing to do by accident, and it
|
|
1374
|
+
#' must cost a message, not the session.
|
|
1375
|
+
#'
|
|
1376
|
+
#' @param id Request id.
|
|
1377
|
+
#' @param path File to inspect; `~` is expanded.
|
|
1378
|
+
#' @param opts Overrides from the wizard (delim, quote, encoding, header,
|
|
1379
|
+
#' skip, sheet, naStrings, tz). Anything absent is detected.
|
|
1380
|
+
#' @return Invisibly NULL. Emits one `sniff` frame.
|
|
1381
|
+
emit_sniff <- function(id, path = NULL, opts = NULL) {
|
|
1382
|
+
fail <- function(msg) {
|
|
1383
|
+
emit(list(type = "sniff", id = id, path = path, error = msg))
|
|
1384
|
+
invisible(NULL)
|
|
1385
|
+
}
|
|
1386
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
1387
|
+
# A URL is a source too. The confinement root governs the FILESYSTEM, and a
|
|
1388
|
+
# remote read touches none of it — but a confined deployment is confined on
|
|
1389
|
+
# purpose, so remote sources are refused there rather than quietly allowed.
|
|
1390
|
+
remote <- exists("is_url", inherits = TRUE) && is_url(path)
|
|
1391
|
+
if (remote) {
|
|
1392
|
+
if (!is.null(confine_root)) return(fail("remote sources are disabled in this deployment"))
|
|
1393
|
+
p <- path
|
|
1394
|
+
} else {
|
|
1395
|
+
p <- path.expand(path)
|
|
1396
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
1397
|
+
if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
|
|
1398
|
+
}
|
|
1399
|
+
if (!exists("sniff_file", inherits = TRUE)) {
|
|
1400
|
+
return(fail("this kernel is too old for the import wizard - restart CarmaR"))
|
|
1401
|
+
}
|
|
1402
|
+
res <- tryCatch(
|
|
1403
|
+
sniff_file(p, if (is.list(opts)) opts else list()),
|
|
1404
|
+
error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
|
|
1405
|
+
interrupt = function(i) structure(class = "carmar_fail", list(msg = "interrupted"))
|
|
1406
|
+
)
|
|
1407
|
+
if (inherits(res, "carmar_fail")) return(fail(res$msg))
|
|
1408
|
+
emit(c(list(type = "sniff", id = id), res))
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
#' Read a file into the session and answer with a first view of it.
|
|
1412
|
+
#'
|
|
1413
|
+
#' rio when installed (one verb, ~30 formats); otherwise the extension picks a
|
|
1414
|
+
#' base/readxl reader. Every failure — missing file, bad format, absent readxl,
|
|
1415
|
+
#' a Stop mid-read — comes back as an `error` field, because a bad file must
|
|
1416
|
+
#' never cost the session.
|
|
1417
|
+
#'
|
|
1418
|
+
#' @param id Request id.
|
|
1419
|
+
#' @param path File to read; `~` is expanded.
|
|
1420
|
+
#' @param name Name to assign; defaults to the file name, made syntactic.
|
|
1421
|
+
#' @return Invisibly NULL. Emits one `import` frame.
|
|
1422
|
+
emit_import <- function(id, path = NULL, name = NULL) {
|
|
1423
|
+
fail <- function(msg) {
|
|
1424
|
+
emit(list(type = "import", id = id, path = path, error = msg))
|
|
1425
|
+
invisible(NULL)
|
|
1426
|
+
}
|
|
1427
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
1428
|
+
p <- path.expand(path)
|
|
1429
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
1430
|
+
if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
|
|
1431
|
+
read_by_ext <- function(f) {
|
|
1432
|
+
ext <- tolower(tools::file_ext(f))
|
|
1433
|
+
if (ext %in% c("xls", "xlsx")) {
|
|
1434
|
+
if (!requireNamespace("readxl", quietly = TRUE)) stop("readxl is not installed")
|
|
1435
|
+
return(as.data.frame(readxl::read_excel(f)))
|
|
1436
|
+
}
|
|
1437
|
+
switch(ext,
|
|
1438
|
+
csv = utils::read.csv(f, stringsAsFactors = FALSE),
|
|
1439
|
+
tsv = utils::read.delim(f, stringsAsFactors = FALSE),
|
|
1440
|
+
txt = utils::read.delim(f, stringsAsFactors = FALSE),
|
|
1441
|
+
rds = readRDS(f),
|
|
1442
|
+
stop("unsupported file type: .", ext))
|
|
1443
|
+
}
|
|
1444
|
+
obj <- tryCatch(
|
|
1445
|
+
if (requireNamespace("rio", quietly = TRUE)) rio::import(p) else read_by_ext(p),
|
|
1446
|
+
error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
|
|
1447
|
+
interrupt = function(i) structure(class = "carmar_fail", list(msg = "interrupted"))
|
|
1448
|
+
)
|
|
1449
|
+
if (inherits(obj, "carmar_fail")) return(fail(obj$msg))
|
|
1450
|
+
nm <- if (is.character(name) && length(name) == 1L && nzchar(name)) name
|
|
1451
|
+
else make.names(tools::file_path_sans_ext(basename(p)))
|
|
1452
|
+
assign(nm, obj, envir = globalenv())
|
|
1453
|
+
# The reply is view-shaped so the client can open the viewer straight from
|
|
1454
|
+
# it. An RDS holding a model still assigns; the payload then says "not a
|
|
1455
|
+
# table" and the obj inspector is the right next stop.
|
|
1456
|
+
emit(c(list(type = "import", id = id, assigned = nm, path = p),
|
|
1457
|
+
view_payload(obj, nm)))
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
#' Remove objects — the Environment pane's broom, and single-object delete.
|
|
1461
|
+
#'
|
|
1462
|
+
#' @param id Request id.
|
|
1463
|
+
#' @param names Character vector, or NULL for everything.
|
|
1464
|
+
emit_rm <- function(id, names = NULL) {
|
|
1465
|
+
target <- if (is.null(names) || !length(names)) ls(globalenv(), all.names = TRUE) else names
|
|
1466
|
+
removed <- intersect(target, ls(globalenv(), all.names = TRUE))
|
|
1467
|
+
if (length(removed)) rm(list = removed, envir = globalenv())
|
|
1468
|
+
emit(list(type = "removed", id = id, names = as.list(removed),
|
|
1469
|
+
remaining = length(ls(globalenv(), all.names = FALSE))))
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
#' Is this source a complete R expression?
|
|
1473
|
+
#'
|
|
1474
|
+
#' The console needs R's own parser to decide, not a brace counter: `f(1,` and
|
|
1475
|
+
#' `"unterminated` and `x +` are all incomplete for different reasons, and only
|
|
1476
|
+
#' the parser knows which. Drives the continuation prompt.
|
|
1477
|
+
#'
|
|
1478
|
+
#' @param id Request id.
|
|
1479
|
+
#' @param source Source text.
|
|
1480
|
+
#' @return Invisibly NULL. Emits one `parse` frame.
|
|
1481
|
+
emit_parse <- function(id, source) {
|
|
1482
|
+
# NULL would send parse() to the console — see the guard in emit_view.
|
|
1483
|
+
if (!is.character(source) || length(source) != 1L || is.na(source)) source <- ""
|
|
1484
|
+
complete <- TRUE
|
|
1485
|
+
message_ <- NULL
|
|
1486
|
+
tryCatch(
|
|
1487
|
+
parse(text = source),
|
|
1488
|
+
error = function(e) {
|
|
1489
|
+
msg <- conditionMessage(e)
|
|
1490
|
+
# R says "unexpected end of input" only when more input would help.
|
|
1491
|
+
complete <<- !grepl("unexpected end of input|unexpected INCOMPLETE_STRING", msg)
|
|
1492
|
+
message_ <<- msg
|
|
1493
|
+
}
|
|
1494
|
+
)
|
|
1495
|
+
emit(list(type = "parse", id = id, complete = complete, message = message_))
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
#' Reformat R source with a real formatter, if the session has one.
|
|
1499
|
+
#'
|
|
1500
|
+
#' "Beautify" through a language model is a guess dressed as a tool: a cautious
|
|
1501
|
+
#' model hands back what you gave it, which reads as the feature being broken.
|
|
1502
|
+
#' Formatting is a solved, deterministic problem — styler does it exactly, for
|
|
1503
|
+
#' free, offline. The source arrives as a JSON string, so nothing is escaped
|
|
1504
|
+
#' into a literal and no code is constructed from text.
|
|
1505
|
+
#'
|
|
1506
|
+
#' `available = FALSE` is not an error: it tells the caller to fall back.
|
|
1507
|
+
emit_format <- function(id, source) {
|
|
1508
|
+
txt <- if (is.character(source)) paste(source, collapse = "\n") else ""
|
|
1509
|
+
lines <- strsplit(txt, "\n", fixed = TRUE)[[1]]
|
|
1510
|
+
engine <- if (requireNamespace("styler", quietly = TRUE)) "styler"
|
|
1511
|
+
else if (requireNamespace("formatR", quietly = TRUE)) "formatR"
|
|
1512
|
+
else NA_character_
|
|
1513
|
+
if (is.na(engine)) {
|
|
1514
|
+
emit(list(type = "format", id = id, available = FALSE))
|
|
1515
|
+
return(invisible(NULL))
|
|
1516
|
+
}
|
|
1517
|
+
out <- tryCatch({
|
|
1518
|
+
if (identical(engine, "styler")) {
|
|
1519
|
+
paste(as.character(styler::style_text(lines)), collapse = "\n")
|
|
1520
|
+
} else {
|
|
1521
|
+
paste(formatR::tidy_source(text = lines, output = FALSE, comment = TRUE,
|
|
1522
|
+
arrow = TRUE, width.cutoff = 80L)$text.tidy,
|
|
1523
|
+
collapse = "\n")
|
|
1524
|
+
}
|
|
1525
|
+
}, error = function(e) NULL)
|
|
1526
|
+
if (is.null(out)) {
|
|
1527
|
+
# Unparseable code is the user's, not the formatter's, problem to report.
|
|
1528
|
+
emit(list(type = "format", id = id, available = TRUE, engine = engine,
|
|
1529
|
+
error = "this code could not be parsed, so it was left alone"))
|
|
1530
|
+
} else {
|
|
1531
|
+
emit(list(type = "format", id = id, available = TRUE, engine = engine, text = out))
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
#' Loaded namespaces by default; the larger installed inventory is opt-in.
|
|
1536
|
+
emit_packages <- function(id, scope = "loaded") {
|
|
1537
|
+
scope <- if (is.character(scope) && length(scope) == 1L &&
|
|
1538
|
+
identical(scope, "installed")) "installed" else "loaded"
|
|
1539
|
+
loaded <- loadedNamespaces()
|
|
1540
|
+
attached <- sub("^package:", "", grep("^package:", search(), value = TRUE))
|
|
1541
|
+
rss_mb <- tryCatch({
|
|
1542
|
+
if (!identical(.Platform$OS.type, "unix")) NA_real_ else {
|
|
1543
|
+
kb <- suppressWarnings(as.numeric(trimws(system2(
|
|
1544
|
+
"ps", c("-o", "rss=", "-p", as.character(Sys.getpid())),
|
|
1545
|
+
stdout = TRUE, stderr = FALSE
|
|
1546
|
+
))[1L]))
|
|
1547
|
+
if (is.finite(kb)) round(kb / 1024, 1L) else NA_real_
|
|
1548
|
+
}
|
|
1549
|
+
}, error = function(e) NA_real_)
|
|
1550
|
+
record <- function(name, version = NULL, lib = NULL, priority = NULL) {
|
|
1551
|
+
if (is.null(lib)) {
|
|
1552
|
+
path <- find.package(name, quiet = TRUE)
|
|
1553
|
+
lib <- if (nzchar(path)) dirname(path) else ""
|
|
1554
|
+
}
|
|
1555
|
+
if (is.null(version)) {
|
|
1556
|
+
version <- tryCatch(as.character(utils::packageVersion(name)), error = function(e) "")
|
|
1557
|
+
}
|
|
1558
|
+
if (is.null(priority)) {
|
|
1559
|
+
priority <- tryCatch(utils::packageDescription(name, fields = "Priority"),
|
|
1560
|
+
error = function(e) "")
|
|
1561
|
+
}
|
|
1562
|
+
priority <- if (length(priority) && !is.na(priority[1L])) as.character(priority[1L]) else ""
|
|
1563
|
+
lib_norm <- normalizePath(lib, winslash = "/", mustWork = FALSE)
|
|
1564
|
+
protected <- identical(priority, "base")
|
|
1565
|
+
is_attached <- name %in% attached
|
|
1566
|
+
list(name = name, version = as.character(version), lib = lib,
|
|
1567
|
+
loaded = is_attached, attached = is_attached,
|
|
1568
|
+
namespaceLoaded = name %in% loaded,
|
|
1569
|
+
writable = nzchar(lib) && file.access(lib, 2L) == 0L,
|
|
1570
|
+
protected = protected, priority = priority)
|
|
1571
|
+
}
|
|
1572
|
+
if (identical(scope, "installed")) {
|
|
1573
|
+
inst <- utils::installed.packages()[, c("Package", "Version", "LibPath", "Priority"), drop = FALSE]
|
|
1574
|
+
ord <- order(!inst[, "Package"] %in% attached,
|
|
1575
|
+
!inst[, "Package"] %in% loaded,
|
|
1576
|
+
tolower(inst[, "Package"]))
|
|
1577
|
+
inst <- inst[ord, , drop = FALSE]
|
|
1578
|
+
packages <- lapply(seq_len(nrow(inst)), function(i) record(
|
|
1579
|
+
unname(inst[i, "Package"]), unname(inst[i, "Version"]),
|
|
1580
|
+
unname(inst[i, "LibPath"]), unname(inst[i, "Priority"])
|
|
1581
|
+
))
|
|
1582
|
+
} else {
|
|
1583
|
+
loaded <- loaded[order(!loaded %in% attached, tolower(loaded))]
|
|
1584
|
+
packages <- lapply(loaded, record)
|
|
1585
|
+
}
|
|
1586
|
+
emit(list(type = "packages", id = id, scope = scope,
|
|
1587
|
+
memoryMb = rss_mb,
|
|
1588
|
+
packages = packages))
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
#' Apply one explicit package operation and report its result to the pane.
|
|
1592
|
+
emit_package_action <- function(id, action, name, lib = NULL) {
|
|
1593
|
+
valid_name <- is.character(name) && length(name) == 1L &&
|
|
1594
|
+
grepl("^[A-Za-z][A-Za-z0-9.]*$", name)
|
|
1595
|
+
if (!valid_name) {
|
|
1596
|
+
emit(list(type = "package_action", id = id, error = "invalid package name"))
|
|
1597
|
+
return(invisible(NULL))
|
|
1598
|
+
}
|
|
1599
|
+
action <- if (is.character(action) && length(action) == 1L) action else ""
|
|
1600
|
+
allowed <- c("load", "attach", "detach", "unload", "install", "update", "remove")
|
|
1601
|
+
if (!action %in% allowed) {
|
|
1602
|
+
emit(list(type = "package_action", id = id, error = "unknown package action"))
|
|
1603
|
+
return(invisible(NULL))
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
perform <- function() {
|
|
1607
|
+
attached <- sub("^package:", "", grep("^package:", search(), value = TRUE))
|
|
1608
|
+
package_path <- find.package(name, quiet = TRUE)
|
|
1609
|
+
installed <- length(package_path) == 1L && nzchar(package_path)
|
|
1610
|
+
target_lib <- if (installed) dirname(package_path) else ""
|
|
1611
|
+
if (is.character(lib) && length(lib) == 1L && nzchar(lib)) {
|
|
1612
|
+
requested <- normalizePath(lib, winslash = "/", mustWork = FALSE)
|
|
1613
|
+
known <- normalizePath(.libPaths(), winslash = "/", mustWork = FALSE)
|
|
1614
|
+
if (!requested %in% known) stop("the selected package library is not active in this R session")
|
|
1615
|
+
target_lib <- lib
|
|
1616
|
+
}
|
|
1617
|
+
priority <- if (installed) tryCatch(
|
|
1618
|
+
utils::packageDescription(name, lib.loc = target_lib, fields = "Priority"),
|
|
1619
|
+
error = function(e) "") else ""
|
|
1620
|
+
protected <- installed && identical(unname(priority), "base")
|
|
1621
|
+
|
|
1622
|
+
if (action %in% c("detach", "unload", "update", "remove") && protected) {
|
|
1623
|
+
stop("R system packages are protected")
|
|
1624
|
+
}
|
|
1625
|
+
if (identical(action, "load")) {
|
|
1626
|
+
loadNamespace(name)
|
|
1627
|
+
return(paste(name, "namespace loaded"))
|
|
1628
|
+
}
|
|
1629
|
+
if (identical(action, "attach")) {
|
|
1630
|
+
suppressPackageStartupMessages(library(name, character.only = TRUE))
|
|
1631
|
+
return(paste(name, "attached to the search path"))
|
|
1632
|
+
}
|
|
1633
|
+
if (identical(action, "detach")) {
|
|
1634
|
+
if (!name %in% attached) return(paste(name, "is not attached"))
|
|
1635
|
+
detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
|
|
1636
|
+
return(paste(name, "detached; its namespace remains loaded"))
|
|
1637
|
+
}
|
|
1638
|
+
if (identical(action, "unload")) {
|
|
1639
|
+
was_attached <- name %in% attached
|
|
1640
|
+
if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
|
|
1641
|
+
if (!name %in% loadedNamespaces()) return(paste(name, "namespace is not loaded"))
|
|
1642
|
+
tryCatch(unloadNamespace(name), error = function(e) {
|
|
1643
|
+
if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
|
|
1644
|
+
stop(e)
|
|
1645
|
+
})
|
|
1646
|
+
return(paste(name, if (was_attached) "detached and unloaded" else "namespace unloaded"))
|
|
1647
|
+
}
|
|
1648
|
+
if (identical(action, "remove")) {
|
|
1649
|
+
if (!installed) return(paste(name, "is not installed"))
|
|
1650
|
+
if (file.access(target_lib, 2L) != 0L) stop("the package library is not writable")
|
|
1651
|
+
was_attached <- name %in% attached
|
|
1652
|
+
was_loaded <- name %in% loadedNamespaces()
|
|
1653
|
+
tryCatch({
|
|
1654
|
+
if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
|
|
1655
|
+
if (name %in% loadedNamespaces()) unloadNamespace(name)
|
|
1656
|
+
utils::remove.packages(name, lib = target_lib)
|
|
1657
|
+
}, error = function(e) {
|
|
1658
|
+
try(if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
|
|
1659
|
+
else if (was_loaded) loadNamespace(name), silent = TRUE)
|
|
1660
|
+
stop(e)
|
|
1661
|
+
})
|
|
1662
|
+
return(paste(name, "removed from", target_lib))
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
repos <- getOption("repos")
|
|
1666
|
+
if (!length(repos) || is.na(repos["CRAN"]) || identical(unname(repos["CRAN"]), "@CRAN@")) {
|
|
1667
|
+
repos <- c(CRAN = "https://cloud.r-project.org")
|
|
1668
|
+
}
|
|
1669
|
+
if (identical(action, "install")) {
|
|
1670
|
+
if (installed && protected) stop("R system packages are protected")
|
|
1671
|
+
writable <- .libPaths()[file.access(.libPaths(), 2L) == 0L]
|
|
1672
|
+
if (!length(writable)) stop("this R session has no writable package library")
|
|
1673
|
+
target_lib <- writable[1L]
|
|
1674
|
+
suppressWarnings(utils::install.packages(name, lib = target_lib, repos = repos,
|
|
1675
|
+
dependencies = NA, quiet = TRUE))
|
|
1676
|
+
if (!requireNamespace(name, quietly = TRUE)) stop("installation did not produce a loadable package")
|
|
1677
|
+
return(paste(name, as.character(utils::packageVersion(name)), "installed"))
|
|
1678
|
+
}
|
|
1679
|
+
if (!installed) stop("the package is not installed")
|
|
1680
|
+
if (file.access(target_lib, 2L) != 0L) stop("the package library is not writable")
|
|
1681
|
+
available <- utils::available.packages(repos = repos)
|
|
1682
|
+
if (!name %in% rownames(available)) stop("the package is not available from the configured repositories")
|
|
1683
|
+
current <- utils::packageVersion(name, lib.loc = target_lib)
|
|
1684
|
+
latest <- numeric_version(available[name, "Version"])
|
|
1685
|
+
if (current >= latest) return(paste(name, as.character(current), "is current"))
|
|
1686
|
+
was_attached <- name %in% attached
|
|
1687
|
+
was_loaded <- name %in% loadedNamespaces()
|
|
1688
|
+
updated <- tryCatch({
|
|
1689
|
+
if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
|
|
1690
|
+
if (name %in% loadedNamespaces()) unloadNamespace(name)
|
|
1691
|
+
suppressWarnings(utils::install.packages(name, lib = target_lib, repos = repos,
|
|
1692
|
+
dependencies = NA, quiet = TRUE))
|
|
1693
|
+
installed_version <- utils::packageVersion(name, lib.loc = target_lib)
|
|
1694
|
+
if (installed_version < latest) stop("the package update did not complete")
|
|
1695
|
+
if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
|
|
1696
|
+
else if (was_loaded) loadNamespace(name)
|
|
1697
|
+
installed_version
|
|
1698
|
+
}, error = function(e) {
|
|
1699
|
+
try(if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
|
|
1700
|
+
else if (was_loaded) loadNamespace(name), silent = TRUE)
|
|
1701
|
+
stop(e)
|
|
1702
|
+
})
|
|
1703
|
+
paste(name, as.character(updated), "updated")
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
result <- tryCatch(list(message = perform()), error = function(e) list(error = conditionMessage(e)))
|
|
1707
|
+
emit(c(list(type = "package_action", id = id, action = action, name = name), result))
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
#' Project and renv state, without paths or lockfile contents on the wire.
|
|
1711
|
+
emit_project_status <- function(id) {
|
|
1712
|
+
result <- tryCatch(carmar_project_status(), error = function(e)
|
|
1713
|
+
list(error = conditionMessage(e)))
|
|
1714
|
+
emit(c(list(type = "project_status", id = id), result))
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
#' One explicit, visible environment action: install renv or restore its lock.
|
|
1718
|
+
emit_project_action <- function(id, action) {
|
|
1719
|
+
result <- tryCatch(carmar_project_action(action), error = function(e)
|
|
1720
|
+
list(ok = FALSE, error = conditionMessage(e)))
|
|
1721
|
+
emit(c(list(type = "project_action", id = id, action = action), result))
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
#' Package metadata and documentation index for the Help pane.
|
|
1725
|
+
emit_package_help <- function(id, name) {
|
|
1726
|
+
valid_name <- is.character(name) && length(name) == 1L &&
|
|
1727
|
+
grepl("^[A-Za-z][A-Za-z0-9.]*$", name)
|
|
1728
|
+
if (!valid_name) {
|
|
1729
|
+
emit(list(type = "package_help", id = id, error = "invalid package name"))
|
|
1730
|
+
return(invisible(NULL))
|
|
1731
|
+
}
|
|
1732
|
+
result <- tryCatch({
|
|
1733
|
+
desc <- utils::packageDescription(name)
|
|
1734
|
+
index <- do.call(utils::help, list(package = name))
|
|
1735
|
+
table <- tryCatch(index$info[[2L]], error = function(e) NULL)
|
|
1736
|
+
topics <- if (is.null(table) || !NROW(table)) list() else {
|
|
1737
|
+
columns <- colnames(table)
|
|
1738
|
+
item_col <- intersect(c("Item", "Topic"), columns)[1L]
|
|
1739
|
+
title_col <- intersect(c("Title", "Description"), columns)[1L]
|
|
1740
|
+
if (is.na(item_col)) list() else lapply(seq_len(NROW(table)), function(i) {
|
|
1741
|
+
list(topic = unname(table[i, item_col]),
|
|
1742
|
+
title = if (is.na(title_col)) "" else unname(table[i, title_col]))
|
|
1743
|
+
})
|
|
1744
|
+
}
|
|
1745
|
+
list(title = unname(desc[["Title"]] %||% name),
|
|
1746
|
+
version = unname(desc[["Version"]] %||% ""),
|
|
1747
|
+
description = unname(desc[["Description"]] %||% ""),
|
|
1748
|
+
license = unname(desc[["License"]] %||% ""),
|
|
1749
|
+
topics = topics)
|
|
1750
|
+
}, error = function(e) list(error = conditionMessage(e)))
|
|
1751
|
+
emit(c(list(type = "package_help", id = id, name = name), result))
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
#' R's own help page, rendered to HTML, for the Help pane.
|
|
1755
|
+
emit_help <- function(id, topic) {
|
|
1756
|
+
html <- tryCatch({
|
|
1757
|
+
# `stats::sd` is a qualified NAME, not a help topic — help() finds nothing
|
|
1758
|
+
# for it, so F1 on a namespaced call answered "no help found" for a
|
|
1759
|
+
# function whose page plainly exists. Split it and name the package.
|
|
1760
|
+
args <- if (is.character(topic) && length(topic) == 1L &&
|
|
1761
|
+
grepl("^[A-Za-z.][A-Za-z0-9._]*:::?[A-Za-z.][A-Za-z0-9._]*$", topic)) {
|
|
1762
|
+
parts <- strsplit(topic, ":::?")[[1]]
|
|
1763
|
+
list(parts[2L], package = parts[1L], help_type = "text")
|
|
1764
|
+
} else {
|
|
1765
|
+
list(topic, help_type = "text", try.all.packages = TRUE)
|
|
1766
|
+
}
|
|
1767
|
+
# help() substitutes its argument, so `help(topic)` with `topic` holding
|
|
1768
|
+
# "lm" looks up a topic literally called "topic". do.call passes the value.
|
|
1769
|
+
paths <- do.call(utils::help, args)
|
|
1770
|
+
if (length(paths) == 0L) return(NULL)
|
|
1771
|
+
# .getHelpFile is internal and not exported in every R (it is not in 4.6.1),
|
|
1772
|
+
# so the Rd path is attempted and the rendered TEXT help is the fallback.
|
|
1773
|
+
# A Help pane showing R's own text beats a Help pane showing nothing.
|
|
1774
|
+
rd <- tryCatch(utils:::.getHelpFile(paths[1L]), error = function(e) NULL)
|
|
1775
|
+
if (!is.null(rd)) {
|
|
1776
|
+
paste(utils::capture.output(tools::Rd2HTML(rd)), collapse = "\n")
|
|
1777
|
+
} else {
|
|
1778
|
+
txt <- paste(utils::capture.output(base::print(paths)), collapse = "\n")
|
|
1779
|
+
if (!nzchar(trimws(txt))) NULL
|
|
1780
|
+
else paste0("<pre class=\"carmar-help-text\">",
|
|
1781
|
+
gsub("<", "<", gsub("&", "&", txt), fixed = TRUE), "</pre>")
|
|
1782
|
+
}
|
|
1783
|
+
}, error = function(e) NULL)
|
|
1784
|
+
emit(list(type = "help", id = id, topic = topic, html = html))
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
#' What to show when the pointer rests on a name — RStudio's F1, without F1.
|
|
1788
|
+
#'
|
|
1789
|
+
#' Three things, in the order a reader wants them: what it IS (a function and
|
|
1790
|
+
#' its signature, or a value and its shape), what it is FOR (the help page's
|
|
1791
|
+
#' title), and one paragraph of description. Deliberately not the whole help
|
|
1792
|
+
#' page: a tooltip that fills the screen is a worse Help pane, and the Help
|
|
1793
|
+
#' pane already exists.
|
|
1794
|
+
#'
|
|
1795
|
+
#' Everything is best-effort and every failure is silent — a hover that throws,
|
|
1796
|
+
#' or that blocks on a slow help lookup, is worse than a hover that says
|
|
1797
|
+
#' nothing. `name` may be namespaced (`dplyr::filter`).
|
|
1798
|
+
#'
|
|
1799
|
+
#' @param id Request id.
|
|
1800
|
+
#' @param name Symbol to describe.
|
|
1801
|
+
#' @return Invisibly NULL. Emits one `hover` frame.
|
|
1802
|
+
emit_hover <- function(id, name) {
|
|
1803
|
+
none <- function() {
|
|
1804
|
+
emit(list(type = "hover", id = id, name = name, found = FALSE))
|
|
1805
|
+
invisible(NULL)
|
|
1806
|
+
}
|
|
1807
|
+
if (!is.character(name) || length(name) != 1L || !nzchar(name)) return(none())
|
|
1808
|
+
if (!grepl("^[A-Za-z._][A-Za-z0-9._]*(:::?[A-Za-z._][A-Za-z0-9._]*)?$", name)) return(none())
|
|
1809
|
+
|
|
1810
|
+
parts <- strsplit(name, ":::?")[[1]]
|
|
1811
|
+
pkg <- if (length(parts) == 2L) parts[1L] else NULL
|
|
1812
|
+
sym <- parts[length(parts)]
|
|
1813
|
+
|
|
1814
|
+
# inherits = FALSE, and it matters: get() walks the enclosing environments by
|
|
1815
|
+
# default, so `mean` is "found in globalenv" and every base function gets
|
|
1816
|
+
# labelled .GlobalEnv. Only what the USER bound counts as theirs.
|
|
1817
|
+
obj <- tryCatch({
|
|
1818
|
+
if (!is.null(pkg)) getExportedValue(pkg, sym)
|
|
1819
|
+
else get(sym, envir = globalenv(), inherits = FALSE)
|
|
1820
|
+
}, error = function(e) NULL)
|
|
1821
|
+
# Not a user object: look along the search path (base, attached packages).
|
|
1822
|
+
where <- NULL
|
|
1823
|
+
if (is.null(obj)) {
|
|
1824
|
+
obj <- tryCatch(get(sym), error = function(e) NULL)
|
|
1825
|
+
if (!is.null(obj)) {
|
|
1826
|
+
w <- tryCatch(find(sym)[1L], error = function(e) NA_character_)
|
|
1827
|
+
if (!is.na(w)) where <- sub("^package:", "", w)
|
|
1828
|
+
}
|
|
1829
|
+
} else if (!is.null(pkg)) where <- pkg
|
|
1830
|
+
else where <- ".GlobalEnv"
|
|
1831
|
+
|
|
1832
|
+
if (is.null(obj)) {
|
|
1833
|
+
# A name that is not bound can still have a help page (an S4 generic, a
|
|
1834
|
+
# dataset promise); if it has none either, there is nothing to say.
|
|
1835
|
+
doc <- help_summary(sym, pkg)
|
|
1836
|
+
if (is.null(doc$title) && is.null(doc$description)) return(none())
|
|
1837
|
+
emit(c(list(type = "hover", id = id, name = name, found = TRUE,
|
|
1838
|
+
kind = "topic", package = where), doc))
|
|
1839
|
+
return(invisible(NULL))
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
kind <- if (is.function(obj)) "function" else "value"
|
|
1843
|
+
signature <- NULL
|
|
1844
|
+
detail <- NULL
|
|
1845
|
+
if (is.function(obj)) {
|
|
1846
|
+
signature <- tryCatch({
|
|
1847
|
+
txt <- paste(utils::capture.output(base::print(args(obj))), collapse = " ")
|
|
1848
|
+
txt <- sub("^function\\s*", paste0(sym, " "), txt)
|
|
1849
|
+
txt <- sub("\\s*NULL\\s*$", "", txt)
|
|
1850
|
+
trimws(gsub("\\s+", " ", txt))
|
|
1851
|
+
}, error = function(e) NULL)
|
|
1852
|
+
if (!is.null(signature) && nchar(signature) > 300L) {
|
|
1853
|
+
signature <- paste0(substr(signature, 1L, 297L), "...")
|
|
1854
|
+
}
|
|
1855
|
+
} else {
|
|
1856
|
+
detail <- tryCatch({
|
|
1857
|
+
cls <- paste(class(obj), collapse = "/")
|
|
1858
|
+
dims <- if (!is.null(dim(obj))) paste(dim(obj), collapse = " x ")
|
|
1859
|
+
else if (is.atomic(obj) || is.list(obj)) paste0("length ", length(obj))
|
|
1860
|
+
else NULL
|
|
1861
|
+
paste(c(cls, dims), collapse = " · ")
|
|
1862
|
+
}, error = function(e) NULL)
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
doc <- help_summary(sym, pkg)
|
|
1866
|
+
# Drop the NULLs before they ship. `list(detail = NULL)` survives as a NULL
|
|
1867
|
+
# element and jsonlite renders it as `{}` — which arrived in the tooltip as
|
|
1868
|
+
# the string "[object Object]". An absent field must be absent.
|
|
1869
|
+
frame <- list(type = "hover", id = id, name = name, found = TRUE, kind = kind,
|
|
1870
|
+
package = where, signature = signature, detail = detail)
|
|
1871
|
+
frame <- c(frame, doc)
|
|
1872
|
+
emit(Filter(Negate(is.null), frame))
|
|
1873
|
+
invisible(NULL)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
#' Title and first description paragraph from a help topic, or NULLs.
|
|
1877
|
+
#'
|
|
1878
|
+
#' Parsed out of R's own TEXT rendering rather than the Rd tree: the internal
|
|
1879
|
+
#' that reads an .Rd file (`utils:::.getHelpFile`) is not exported and is not
|
|
1880
|
+
#' present in every R, so a tooltip built on it would work on one machine and
|
|
1881
|
+
#' not the next. The text layout — a header line, the title, then a
|
|
1882
|
+
#' "Description:" block — has been stable for decades.
|
|
1883
|
+
help_summary <- function(sym, pkg = NULL) {
|
|
1884
|
+
empty <- list(title = NULL, description = NULL)
|
|
1885
|
+
txt <- tryCatch({
|
|
1886
|
+
paths <- if (is.null(pkg)) do.call(utils::help, list(sym, help_type = "text"))
|
|
1887
|
+
else do.call(utils::help, list(sym, package = pkg, help_type = "text"))
|
|
1888
|
+
if (length(paths) == 0L) return(empty)
|
|
1889
|
+
utils::capture.output(tools::Rd2txt(utils:::.getHelpFile(paths[1L]),
|
|
1890
|
+
options = list(underline_titles = FALSE)))
|
|
1891
|
+
}, error = function(e) NULL)
|
|
1892
|
+
if (is.null(txt) || !length(txt)) return(empty)
|
|
1893
|
+
|
|
1894
|
+
lines <- trimws(txt)
|
|
1895
|
+
# The first non-empty line after the "name package:pkg R Documentation"
|
|
1896
|
+
# header is the title.
|
|
1897
|
+
start <- which(grepl("R Documentation", txt, fixed = TRUE))
|
|
1898
|
+
idx <- if (length(start)) start[1L] + 1L else 1L
|
|
1899
|
+
while (idx <= length(lines) && !nzchar(lines[idx])) idx <- idx + 1L
|
|
1900
|
+
title <- if (idx <= length(lines)) lines[idx] else NULL
|
|
1901
|
+
|
|
1902
|
+
desc <- NULL
|
|
1903
|
+
d <- which(grepl("^Description:", lines))
|
|
1904
|
+
if (length(d)) {
|
|
1905
|
+
i <- d[1L] + 1L
|
|
1906
|
+
while (i <= length(lines) && !nzchar(lines[i])) i <- i + 1L
|
|
1907
|
+
para <- character()
|
|
1908
|
+
while (i <= length(lines) && nzchar(lines[i])) { para <- c(para, lines[i]); i <- i + 1L }
|
|
1909
|
+
if (length(para)) desc <- paste(para, collapse = " ")
|
|
1910
|
+
}
|
|
1911
|
+
if (!is.null(desc) && nchar(desc) > 400L) desc <- paste0(substr(desc, 1L, 397L), "...")
|
|
1912
|
+
list(title = title, description = desc)
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
#' Working directory, and moving it — the Files pane's anchor.
|
|
1916
|
+
emit_wd <- function(id, path = NULL) {
|
|
1917
|
+
if (is.character(path) && length(path) == 1L && nzchar(path)) {
|
|
1918
|
+
if (!within_root(path)) {
|
|
1919
|
+
emit(list(type = "stream", id = id, kind = "warning", text = outside_root_msg()))
|
|
1920
|
+
} else {
|
|
1921
|
+
tryCatch(setwd(path.expand(path)), error = function(e)
|
|
1922
|
+
emit(list(type = "stream", id = id, kind = "warning", text = conditionMessage(e))))
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
emit(list(type = "wd", id = id, path = getwd()))
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
# The file system lives in fileio.R (shared with the supervisor). Each op is
|
|
1929
|
+
# `emit(fs_x(...))`: fileio.R returns the frame, this process sends it. The
|
|
1930
|
+
# worker still answers them when the supervisor forwards (CARMAR_FILE_OPS=worker,
|
|
1931
|
+
# the transition flag), so nothing here may diverge from fileio.R.
|
|
1932
|
+
emit_files <- function(id, path = NULL, all = FALSE) emit(fs_files(id, path, all, wd = getwd()))
|
|
1933
|
+
emit_mkdir <- function(id, path = NULL) emit(fs_mkdir(id, path, wd = getwd()))
|
|
1934
|
+
emit_renamepath <- function(id, path = NULL, to = NULL) emit(fs_renamepath(id, path, to, wd = getwd()))
|
|
1935
|
+
emit_deletepath <- function(id, paths = NULL, recursive = FALSE) emit(fs_deletepath(id, paths, recursive, wd = getwd()))
|
|
1936
|
+
emit_copypath <- function(id, path = NULL, to = NULL) emit(fs_copypath(id, path, to, wd = getwd()))
|
|
1937
|
+
emit_revealpath <- function(id, path = NULL) emit(fs_revealpath(id, path, wd = getwd()))
|
|
1938
|
+
emit_readfile <- function(id, path = NULL) emit(fs_readfile(id, path, wd = getwd()))
|
|
1939
|
+
emit_writefile <- function(id, path = NULL, text = "", expected = NULL, encoding = NULL, base64 = NULL)
|
|
1940
|
+
emit(fs_writefile(id, path, text, expected, encoding, base64, wd = getwd()))
|
|
1941
|
+
emit_writefiles_atomic <- function(id, files = NULL) emit(fs_writefiles_atomic(id, files, wd = getwd()))
|
|
1942
|
+
|
|
1943
|
+
#' Check whether CarmaR's current R session is usable without collecting user
|
|
1944
|
+
#' material.
|
|
1945
|
+
#'
|
|
1946
|
+
#' This reply is deliberately a FACT ALLOW-LIST. It never contains getwd(),
|
|
1947
|
+
#' R.home(), .libPaths(), environment values, notebook text, or file names.
|
|
1948
|
+
#' The browser turns these booleans and bounded version strings into the support
|
|
1949
|
+
#' report, so adding a field here cannot accidentally export a project path or
|
|
1950
|
+
#' credential.
|
|
1951
|
+
#'
|
|
1952
|
+
#' @param id Request id.
|
|
1953
|
+
#' @return Invisibly NULL. Emits one `doctor` frame.
|
|
1954
|
+
emit_doctor <- function(id) {
|
|
1955
|
+
can_create_in <- function(dir) {
|
|
1956
|
+
if (!is.character(dir) || length(dir) != 1L || !dir.exists(dir)) return(FALSE)
|
|
1957
|
+
probe <- tempfile(".carmar-doctor-", tmpdir = dir)
|
|
1958
|
+
on.exit(unlink(probe, force = TRUE), add = TRUE)
|
|
1959
|
+
isTRUE(tryCatch(file.create(probe, showWarnings = FALSE), error = function(e) FALSE))
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
q_configured <- trimws(Sys.getenv("CARMAR_QUARTO", ""))
|
|
1963
|
+
q_path <- if (nzchar(q_configured)) q_configured else unname(Sys.which("quarto"))
|
|
1964
|
+
q_available <- is.character(q_path) && length(q_path) == 1L && nzchar(q_path) && file.exists(q_path)
|
|
1965
|
+
q_version <- ""
|
|
1966
|
+
if (q_available) {
|
|
1967
|
+
raw_version <- tryCatch(
|
|
1968
|
+
suppressWarnings(system2(q_path, "--version", stdout = TRUE, stderr = TRUE, timeout = 5L)),
|
|
1969
|
+
error = function(e) character())
|
|
1970
|
+
# A version NUMBER only. Never relay arbitrary command output: a wrapper
|
|
1971
|
+
# could print its path, command line, or environment on stderr.
|
|
1972
|
+
hit <- regmatches(paste(raw_version, collapse = " "),
|
|
1973
|
+
regexpr("[0-9]+(?:\\.[0-9]+){1,3}", paste(raw_version, collapse = " "),
|
|
1974
|
+
perl = TRUE))
|
|
1975
|
+
if (length(hit) && nzchar(hit[[1L]])) q_version <- hit[[1L]]
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
required_names <- c("jsonlite", "httpuv", "processx", "knitr", "evaluate")
|
|
1979
|
+
required <- lapply(required_names, function(name) requireNamespace(name, quietly = TRUE))
|
|
1980
|
+
names(required) <- required_names
|
|
1981
|
+
sys <- Sys.info()
|
|
1982
|
+
libs <- .libPaths()
|
|
1983
|
+
rscript_name <- if (identical(unname(sys[["sysname"]]), "Windows")) "Rscript.exe" else "Rscript"
|
|
1984
|
+
|
|
1985
|
+
emit(list(
|
|
1986
|
+
type = "doctor", id = id,
|
|
1987
|
+
runtime = list(
|
|
1988
|
+
r_version = paste(R.version$major, R.version$minor, sep = "."),
|
|
1989
|
+
worker_mode = if (identical(WORKER_MODE, "interactive")) "interactive" else "batch",
|
|
1990
|
+
os = unname(sys[["sysname"]] %||% ""),
|
|
1991
|
+
os_release = unname(sys[["release"]] %||% ""),
|
|
1992
|
+
architecture = unname(sys[["machine"]] %||% ""),
|
|
1993
|
+
locale = Sys.getlocale("LC_CTYPE") %||% "",
|
|
1994
|
+
rscript_available = file.exists(file.path(R.home("bin"), rscript_name))
|
|
1995
|
+
),
|
|
1996
|
+
permissions = list(
|
|
1997
|
+
working_directory_writable = can_create_in(getwd()),
|
|
1998
|
+
temporary_directory_writable = can_create_in(tempdir()),
|
|
1999
|
+
library_writable = any(vapply(libs, can_create_in, logical(1))),
|
|
2000
|
+
library_count = length(libs)
|
|
2001
|
+
),
|
|
2002
|
+
quarto = list(
|
|
2003
|
+
available = q_available,
|
|
2004
|
+
configured = nzchar(q_configured),
|
|
2005
|
+
version = q_version
|
|
2006
|
+
),
|
|
2007
|
+
required_packages = required,
|
|
2008
|
+
deployment = list(
|
|
2009
|
+
loopback = Sys.getenv("CARMAR_BIND", "127.0.0.1") %in% c("", "127.0.0.1", "localhost", "::1"),
|
|
2010
|
+
confinement = nzchar(Sys.getenv("CARMAR_ROOT", "")),
|
|
2011
|
+
audit = nzchar(Sys.getenv("CARMAR_LOG", "")),
|
|
2012
|
+
ai_local_only = identical(Sys.getenv("CARMAR_AI_LOCAL_ONLY", ""), "1"),
|
|
2013
|
+
ai_policy = nzchar(Sys.getenv("CARMAR_AI_POLICY", "")) || nzchar(Sys.getenv("CARMAR_AI_PROVIDERS", "")),
|
|
2014
|
+
origin_required = !identical(Sys.getenv("CARMAR_REQUIRE_ORIGIN", "1"), "0"),
|
|
2015
|
+
trusted_proxy = identical(Sys.getenv("CARMAR_TRUST_PROXY", ""), "1"),
|
|
2016
|
+
unauthenticated = identical(Sys.getenv("CARMAR_ALLOW_UNAUTHENTICATED", ""), "1")
|
|
2017
|
+
),
|
|
2018
|
+
proxy = list(
|
|
2019
|
+
http = nzchar(Sys.getenv("HTTP_PROXY", "")) || nzchar(Sys.getenv("http_proxy", "")),
|
|
2020
|
+
https = nzchar(Sys.getenv("HTTPS_PROXY", "")) || nzchar(Sys.getenv("https_proxy", "")),
|
|
2021
|
+
bypass = nzchar(Sys.getenv("NO_PROXY", "")) || nzchar(Sys.getenv("no_proxy", ""))
|
|
2022
|
+
)
|
|
2023
|
+
))
|
|
2024
|
+
invisible(NULL)
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
#' Open a fresh PNG device whose pages land in `dir`.
|
|
2028
|
+
#'
|
|
2029
|
+
#' `%03d` in the filename makes R write one file per page, so a cell that draws
|
|
2030
|
+
#' three figures yields three files with no bookkeeping here.
|
|
2031
|
+
#'
|
|
2032
|
+
#' @param dir Directory to write pages into.
|
|
2033
|
+
#' @param seq Index of the top-level expression, to keep filenames unique.
|
|
2034
|
+
#' @return Invisibly NULL.
|
|
2035
|
+
open_plot_device <- function(dir, seq, dims = NULL) {
|
|
2036
|
+
width <- if (!is.null(dims$width)) dims$width else PLOT_WIDTH
|
|
2037
|
+
height <- if (!is.null(dims$height)) dims$height else PLOT_HEIGHT
|
|
2038
|
+
res <- if (!is.null(dims$res)) dims$res else PLOT_RES
|
|
2039
|
+
if (plot_is_svg(dims)) {
|
|
2040
|
+
# A vector device is sized in INCHES and has no resolution at all — which
|
|
2041
|
+
# is the whole point: its cost does not grow with dpi. The client sends
|
|
2042
|
+
# pixels because that is what a raster device wants, so convert back.
|
|
2043
|
+
file <- file.path(dir, sprintf("e%03d-%%03d.svg", seq))
|
|
2044
|
+
if (identical(SVG_DEVICE, "svglite")) {
|
|
2045
|
+
svglite::svglite(file, width = width / res, height = height / res)
|
|
2046
|
+
} else {
|
|
2047
|
+
grDevices::svg(file, width = width / res, height = height / res)
|
|
2048
|
+
}
|
|
2049
|
+
return(invisible(NULL))
|
|
2050
|
+
}
|
|
2051
|
+
filename <- file.path(dir, sprintf("e%03d-%%03d.png", seq))
|
|
2052
|
+
if (identical(RASTER_DEVICE, "ragg")) {
|
|
2053
|
+
# Headless, and the reason the worker no longer hangs on macOS: no quartz,
|
|
2054
|
+
# so no AppKit event loop for R to block pumping mid-run.
|
|
2055
|
+
ragg::agg_png(filename = filename, width = width, height = height, res = res)
|
|
2056
|
+
} else {
|
|
2057
|
+
grDevices::png(filename = filename, width = width, height = height,
|
|
2058
|
+
res = res, type = PLOT_TYPE)
|
|
2059
|
+
}
|
|
2060
|
+
invisible(NULL)
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
#' The file extension the open device is writing, as a regex.
|
|
2064
|
+
#'
|
|
2065
|
+
#' Harvesting used to hard-code `\\.png$`, so an SVG device produced pages
|
|
2066
|
+
#' that were never collected and the chunk finished with no plots at all.
|
|
2067
|
+
plot_pattern <- function(dims) if (plot_is_svg(dims)) "\\.svg$" else "\\.png$"
|
|
2068
|
+
|
|
2069
|
+
#' Emit one `plot` frame for a finished page file.
|
|
2070
|
+
#'
|
|
2071
|
+
#' Report the size the device was ACTUALLY opened at. Reporting the defaults
|
|
2072
|
+
#' while honouring the request is worse than ignoring the request: the client
|
|
2073
|
+
#' lays out a 900×620 box around a 1400×500 image.
|
|
2074
|
+
#' `res` travels with the image because the client cannot infer it: a
|
|
2075
|
+
#' 1400×900 PNG at res 96 and the same PNG at res 192 are the same pixels
|
|
2076
|
+
#' describing different physical sizes, and the viewer needs the second
|
|
2077
|
+
#' number to display it at its true size instead of upscaling it.
|
|
2078
|
+
#'
|
|
2079
|
+
#' @param id Cell id.
|
|
2080
|
+
#' @param f Path to a finalized PNG page.
|
|
2081
|
+
#' @return Invisibly NULL.
|
|
2082
|
+
emit_plot_frame <- function(id, f, dims = NULL) {
|
|
2083
|
+
# SVG travels base64 too, in the same envelope as PNG. It costs 33% and buys
|
|
2084
|
+
# a zero-line client change: lib/output-pane.js already understands
|
|
2085
|
+
# `image/svg+xml` (isSvg — vector Save SVG, rasterise-on-demand) and already
|
|
2086
|
+
# builds `data:<mime>;base64,<data>`. Sending raw text instead would have
|
|
2087
|
+
# meant an encoding field plus edits to output-pane.js AND knit.js, and an
|
|
2088
|
+
# older client would have rendered nothing at all.
|
|
2089
|
+
svg <- grepl("\\.svg$", f)
|
|
2090
|
+
emit(list(type = "plot", id = id, mime = if (svg) "image/svg+xml" else "image/png",
|
|
2091
|
+
width = if (!is.null(dims$width)) dims$width else PLOT_WIDTH,
|
|
2092
|
+
height = if (!is.null(dims$height)) dims$height else PLOT_HEIGHT,
|
|
2093
|
+
res = if (!is.null(dims$res)) dims$res else PLOT_RES,
|
|
2094
|
+
data = jsonlite::base64_enc(readBin(f, "raw", file.info(f)$size))))
|
|
2095
|
+
invisible(NULL)
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
#' Emit pages the device has finished, leaving the device open.
|
|
2099
|
+
#'
|
|
2100
|
+
#' The `%03d` png device finalizes a page's file only when the NEXT page
|
|
2101
|
+
#' begins (or the device closes), so every file on disk is a complete figure
|
|
2102
|
+
#' and the page still open on the device has no file yet. That page stays
|
|
2103
|
+
#' amendable: `abline()` after `seq_heatmap()` draws onto a live plot instead
|
|
2104
|
+
#' of erroring, and `layout()`/`par()` state survives across statements.
|
|
2105
|
+
#'
|
|
2106
|
+
#' @param id Cell id.
|
|
2107
|
+
#' @param dir Directory the device writes pages into.
|
|
2108
|
+
#' @param seen Character vector of filenames already emitted.
|
|
2109
|
+
#' @return The updated `seen` vector.
|
|
2110
|
+
#' Is this PNG a device's untouched blank page rather than a real plot?
|
|
2111
|
+
#'
|
|
2112
|
+
#' grDevices does not create its first file until something is drawn, so a
|
|
2113
|
+
#' non-zero size meant "a plot happened". ragg writes a fully-formed BLANK page
|
|
2114
|
+
#' the instant the device opens, so size > 0 no longer distinguishes "drew a
|
|
2115
|
+
#' plot" from "opened the device and drew nothing" — which made every cell that
|
|
2116
|
+
#' does not plot emit an empty figure. A blank page for a given width/height/res
|
|
2117
|
+
#' is byte-identical every time, so keep one reference per size and skip any
|
|
2118
|
+
#' harvested file that matches it. Only ragg needs this; other devices return
|
|
2119
|
+
#' FALSE immediately. A real plot never equals the blank, and a plot call that
|
|
2120
|
+
#' drew literally nothing visible is a blank the reader is better off not shown.
|
|
2121
|
+
.blank_png_cache <- new.env(parent = emptyenv())
|
|
2122
|
+
is_blank_plot <- function(f, dims) {
|
|
2123
|
+
if (!identical(RASTER_DEVICE, "ragg")) return(FALSE)
|
|
2124
|
+
w <- if (!is.null(dims$width)) dims$width else PLOT_WIDTH
|
|
2125
|
+
h <- if (!is.null(dims$height)) dims$height else PLOT_HEIGHT
|
|
2126
|
+
r <- if (!is.null(dims$res)) dims$res else PLOT_RES
|
|
2127
|
+
key <- paste(w, h, r, sep = "x")
|
|
2128
|
+
blank <- .blank_png_cache[[key]]
|
|
2129
|
+
if (is.null(blank)) {
|
|
2130
|
+
ref <- tempfile(fileext = ".png")
|
|
2131
|
+
blank <- tryCatch({
|
|
2132
|
+
ragg::agg_png(ref, width = w, height = h, res = r)
|
|
2133
|
+
grDevices::dev.off()
|
|
2134
|
+
if (file.exists(ref)) readBin(ref, "raw", file.info(ref)$size) else raw(0)
|
|
2135
|
+
}, error = function(e) raw(0))
|
|
2136
|
+
if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
|
|
2137
|
+
unlink(ref)
|
|
2138
|
+
.blank_png_cache[[key]] <- blank
|
|
2139
|
+
}
|
|
2140
|
+
if (!length(blank)) return(FALSE)
|
|
2141
|
+
sz <- file.info(f)$size
|
|
2142
|
+
isTRUE(sz == length(blank)) && identical(readBin(f, "raw", sz), blank)
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
harvest_finished <- function(id, dir, seen, dims = NULL) {
|
|
2146
|
+
files <- list.files(dir, pattern = plot_pattern(dims), full.names = TRUE)
|
|
2147
|
+
# Only files with bytes join `seen`: a file still empty here gets another
|
|
2148
|
+
# look on the next harvest instead of being remembered as already emitted.
|
|
2149
|
+
# A ragg blank page (opened, nothing drawn) has bytes but is not a plot.
|
|
2150
|
+
drawn <- Filter(function(f) file.info(f)$size > 0L && !is_blank_plot(f, dims),
|
|
2151
|
+
sort(setdiff(files, seen)))
|
|
2152
|
+
invisible(lapply(drawn, function(f) emit_plot_frame(id, f, dims)))
|
|
2153
|
+
c(seen, drawn)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
#' Close any open device and emit one `plot` frame per page produced.
|
|
2157
|
+
#'
|
|
2158
|
+
#' @param id Cell id.
|
|
2159
|
+
#' @param dir Directory the device wrote into.
|
|
2160
|
+
#' @param seen Character vector of filenames already emitted.
|
|
2161
|
+
#' @return The updated `seen` vector.
|
|
2162
|
+
harvest_plots <- function(id, dir, seen, dims = NULL) {
|
|
2163
|
+
if (!is.null(grDevices::dev.list())) {
|
|
2164
|
+
try(grDevices::dev.off(), silent = TRUE)
|
|
2165
|
+
}
|
|
2166
|
+
files <- list.files(dir, pattern = plot_pattern(dims), full.names = TRUE)
|
|
2167
|
+
fresh <- setdiff(files, seen)
|
|
2168
|
+
# A device always creates its first file, even with nothing drawn into it —
|
|
2169
|
+
# empty (0 bytes) for grDevices, a byte-identical blank page for ragg.
|
|
2170
|
+
drawn <- Filter(function(f) file.info(f)$size > 0L && !is_blank_plot(f, dims),
|
|
2171
|
+
sort(fresh))
|
|
2172
|
+
invisible(lapply(drawn, function(f) emit_plot_frame(id, f, dims)))
|
|
2173
|
+
c(seen, fresh)
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
#' Emit a data.frame as structured rows the notebook can put in a real table.
|
|
2177
|
+
#'
|
|
2178
|
+
#' Printing a data.frame gives the caller aligned text; a notebook needs
|
|
2179
|
+
#' columns and types. Capped to a small preview with the true row count
|
|
2180
|
+
#' reported, so a million-row frame never becomes a million-row JSON payload.
|
|
2181
|
+
#'
|
|
2182
|
+
#' @param id Cell id.
|
|
2183
|
+
#' @param df A data.frame.
|
|
2184
|
+
#' @param source The NAME the frame was printed under, when the printed
|
|
2185
|
+
#' expression was a bare symbol, else NULL. It is the page's licence to offer
|
|
2186
|
+
#' "open all N rows" in the data viewer: a symbol is a lookup, so fetching it
|
|
2187
|
+
#' again through `view` runs nothing. An expression is not, so it gets no
|
|
2188
|
+
#' name and no offer -- re-evaluating `head(df) |> transform(...)` to reach
|
|
2189
|
+
#' row 300 would run the user's code a second time, unasked.
|
|
2190
|
+
#' @return Invisibly NULL.
|
|
2191
|
+
emit_dataframe <- function(id, df, source = NULL) {
|
|
2192
|
+
# A returned value is a NOTEBOOK PREVIEW, not the data viewer. Bounding rows
|
|
2193
|
+
# alone is insufficient: one list-column cell may contain a fitted model and
|
|
2194
|
+
# one character cell may contain megabytes. Serialising either recursively
|
|
2195
|
+
# blocks the R session before the browser receives a single result frame.
|
|
2196
|
+
# Keep the preview deliberately small, flatten complex cells to descriptions,
|
|
2197
|
+
# clip text, then enforce a final wire-size ceiling. The full object viewer
|
|
2198
|
+
# remains paginated independently for assigned objects.
|
|
2199
|
+
# 200, not the 10 this sent until 7.70. Ten was fewer than the page's own
|
|
2200
|
+
# ROWS_PER_PAGE of 25, so lib/output-pane.js's row navigator could never
|
|
2201
|
+
# appear at all: the paging it implements had been unreachable since it was
|
|
2202
|
+
# written, and Carmar_docs' guide documented a "Preview 500 of 4000" note the
|
|
2203
|
+
# kernel could not emit. Owner, 2026-09-10: "They are not paginated".
|
|
2204
|
+
#
|
|
2205
|
+
# 200 and not 500, bounded by three ceilings rather than taste. The 256 KB
|
|
2206
|
+
# wire cap below: a 9-column numeric frame is ~200 B/row (200 rows ~ 40 KB),
|
|
2207
|
+
# a 20-column mixed frame ~560 B/row (~112 KB) -- at 500 that ordinary middle
|
|
2208
|
+
# case is 280 KB, so shedding would fire on frames nobody would call large.
|
|
2209
|
+
# And lib/knit.js embeds every preview row verbatim into an exported report,
|
|
2210
|
+
# so this constant multiplies published document weight 20x at 200 and 50x
|
|
2211
|
+
# at 500. The page keeps its own ROW_CAP of 500 as the belt to this brace.
|
|
2212
|
+
preview_rows <- 200L
|
|
2213
|
+
preview_cols <- 20L
|
|
2214
|
+
cell_chars <- 160L
|
|
2215
|
+
preview_bytes <- 256L * 1024L
|
|
2216
|
+
total_rows <- nrow(df)
|
|
2217
|
+
data_cols <- ncol(df)
|
|
2218
|
+
|
|
2219
|
+
# R stores default 1..n row names compactly. Calling rownames(df) expands that
|
|
2220
|
+
# ALTREP to millions of strings, and the old identical(... seq_len(n)) check
|
|
2221
|
+
# built a second vector just to decide there was nothing to show. type=1 asks
|
|
2222
|
+
# R whether names are automatic without materialising them; explicit names
|
|
2223
|
+
# are sliced from the raw attribute only AFTER the 10-row preview is taken.
|
|
2224
|
+
explicit_rownames <- .row_names_info(df, type = 1L) > 0L
|
|
2225
|
+
total_cols <- data_cols + as.integer(explicit_rownames)
|
|
2226
|
+
data_col_cap <- max(0L, preview_cols - as.integer(explicit_rownames))
|
|
2227
|
+
shown_cols <- if (data_cols && data_col_cap) {
|
|
2228
|
+
seq_len(min(data_cols, data_col_cap))
|
|
2229
|
+
} else integer(0)
|
|
2230
|
+
source_types <- if (length(shown_cols)) {
|
|
2231
|
+
unname(vapply(df[shown_cols], function(col) class(col)[1L], character(1)))
|
|
2232
|
+
} else character(0)
|
|
2233
|
+
|
|
2234
|
+
cell_preview <- function(value) {
|
|
2235
|
+
text <- if (is.null(value)) {
|
|
2236
|
+
"NULL"
|
|
2237
|
+
} else if (is.data.frame(value) || is.matrix(value)) {
|
|
2238
|
+
paste(dim(value), collapse = " x ")
|
|
2239
|
+
} else if (is.function(value)) {
|
|
2240
|
+
paste0("function(", formals_string(value), ")")
|
|
2241
|
+
} else if (is.environment(value)) {
|
|
2242
|
+
"<environment>"
|
|
2243
|
+
} else if (is.atomic(value)) {
|
|
2244
|
+
shown <- as.character(utils::head(value, 3L))
|
|
2245
|
+
shown <- substr(shown, 1L, 48L)
|
|
2246
|
+
paste0(paste(shown, collapse = ", "), if (length(value) > 3L) ", ..." else "")
|
|
2247
|
+
} else {
|
|
2248
|
+
paste0("<", class(value)[1L], ">")
|
|
2249
|
+
}
|
|
2250
|
+
substr(text, 1L, cell_chars)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
safe_column <- function(col, rows) {
|
|
2254
|
+
shown <- utils::head(col, rows)
|
|
2255
|
+
if (is.factor(shown)) return(as.character(shown))
|
|
2256
|
+
if (inherits(shown, "Date") || inherits(shown, "POSIXt")) return(format(shown))
|
|
2257
|
+
if (is.atomic(shown) && is.null(dim(shown)) && !is.complex(shown) && !is.raw(shown)) {
|
|
2258
|
+
if (is.character(shown)) {
|
|
2259
|
+
long <- !is.na(shown) & nchar(shown, type = "chars") > cell_chars
|
|
2260
|
+
shown[long] <- paste0(substr(shown[long], 1L, cell_chars - 3L), "...")
|
|
2261
|
+
}
|
|
2262
|
+
return(shown)
|
|
2263
|
+
}
|
|
2264
|
+
if (is.list(shown)) return(vapply(shown, cell_preview, character(1)))
|
|
2265
|
+
if (!is.null(dim(shown)) && nrow(shown)) {
|
|
2266
|
+
return(vapply(seq_len(nrow(shown)), function(i) cell_preview(shown[i, , drop = TRUE]), character(1)))
|
|
2267
|
+
}
|
|
2268
|
+
rep(paste0("<", class(shown)[1L], ">"), rows)
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
head_df <- utils::head(df[shown_cols], preview_rows)
|
|
2272
|
+
if (ncol(head_df)) head_df[] <- lapply(head_df, safe_column, rows = nrow(head_df))
|
|
2273
|
+
# Informative row names are data (car names in mtcars, coefficient terms in
|
|
2274
|
+
# model summaries), but only their preview travels. Never cbind them onto the
|
|
2275
|
+
# full frame: that copies every column before head() and makes display time
|
|
2276
|
+
# scale with the entire object rather than the preview cells the user will see.
|
|
2277
|
+
if (explicit_rownames) {
|
|
2278
|
+
raw_names <- attr(df, "row.names", exact = TRUE)
|
|
2279
|
+
shown_names <- as.character(utils::head(raw_names, nrow(head_df)))
|
|
2280
|
+
head_df <- cbind(data.frame(rowname = shown_names, stringsAsFactors = FALSE),
|
|
2281
|
+
head_df)
|
|
2282
|
+
source_types <- c("character", source_types)
|
|
2283
|
+
}
|
|
2284
|
+
preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
|
|
2285
|
+
na = "null", digits = NA)
|
|
2286
|
+
# ESTIMATE first, halve only as the fallback. Halving alone (200 -> 100 -> 50)
|
|
2287
|
+
# was invisible from a 10-row head and wasteful from a 200-row one: a
|
|
2288
|
+
# text-heavy frame that would fit 74 rows lands on 50, and the number it
|
|
2289
|
+
# lands on is one the reader cannot predict from anything on screen. This is
|
|
2290
|
+
# view_payload's rule (further up this file), so the two previews shed alike.
|
|
2291
|
+
bytes <- nchar(preview_json, type = "bytes")
|
|
2292
|
+
if (bytes > preview_bytes && nrow(head_df) > 1L) {
|
|
2293
|
+
keep <- max(1L, as.integer(floor(preview_bytes / (bytes / nrow(head_df)))))
|
|
2294
|
+
if (keep < nrow(head_df)) {
|
|
2295
|
+
head_df <- utils::head(head_df, keep)
|
|
2296
|
+
preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
|
|
2297
|
+
na = "null", digits = NA)
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
# The estimate assumes rows cost about the same; one enormous cell breaks
|
|
2301
|
+
# that, so the original loop stays underneath it and almost never runs.
|
|
2302
|
+
while (nchar(preview_json, type = "bytes") > preview_bytes && nrow(head_df) > 1L) {
|
|
2303
|
+
head_df <- utils::head(head_df, max(1L, as.integer(floor(nrow(head_df) / 2L))))
|
|
2304
|
+
preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
|
|
2305
|
+
na = "null", digits = NA)
|
|
2306
|
+
}
|
|
2307
|
+
emit(list(
|
|
2308
|
+
type = "dataframe", id = id,
|
|
2309
|
+
# I(): a one-column frame must ship columns/types as arrays, not scalars.
|
|
2310
|
+
columns = I(names(head_df)),
|
|
2311
|
+
types = I(source_types),
|
|
2312
|
+
nrow = total_rows, ncol = total_cols,
|
|
2313
|
+
shown = nrow(head_df), shownCols = ncol(head_df),
|
|
2314
|
+
truncated = total_rows > nrow(head_df) || total_cols > ncol(head_df),
|
|
2315
|
+
# jsonlite drops a NULL, so a frame with no name is byte-identical to what
|
|
2316
|
+
# this emitted before the field existed.
|
|
2317
|
+
source = source,
|
|
2318
|
+
rows = head_df
|
|
2319
|
+
))
|
|
2320
|
+
invisible(NULL)
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
#' Files named by an htmltools dependency slot, whatever shape it arrived in.
|
|
2324
|
+
#'
|
|
2325
|
+
#' `script`/`stylesheet` can be a character vector, a list of strings, or a
|
|
2326
|
+
#' list with a `src` field — htmltools allows all three and widgets use all
|
|
2327
|
+
#' three.
|
|
2328
|
+
#'
|
|
2329
|
+
#' @param x The dependency slot.
|
|
2330
|
+
#' @return Character vector of relative file paths.
|
|
2331
|
+
dep_files <- function(x) {
|
|
2332
|
+
if (is.null(x)) return(character(0))
|
|
2333
|
+
if (is.list(x)) {
|
|
2334
|
+
if (!is.null(x$src)) return(as.character(x$src))
|
|
2335
|
+
return(unlist(lapply(x, function(e) {
|
|
2336
|
+
if (is.list(e)) as.character(e$src) else as.character(e)
|
|
2337
|
+
}), use.names = FALSE))
|
|
2338
|
+
}
|
|
2339
|
+
as.character(x)
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
#' An htmlwidget as ONE self-contained HTML page — no pandoc.
|
|
2343
|
+
#'
|
|
2344
|
+
#' `htmlwidgets::saveWidget(selfcontained = TRUE)` needs pandoc, which plain R
|
|
2345
|
+
#' installs do not have. But a widget is just rendered tags plus dependencies,
|
|
2346
|
+
#' and every dependency is a file on disk — so read them and inline them.
|
|
2347
|
+
#'
|
|
2348
|
+
#' @param w An htmlwidget.
|
|
2349
|
+
#' @return A single HTML string.
|
|
2350
|
+
widget_standalone_html <- function(w, fit = FALSE) {
|
|
2351
|
+
tags <- if (inherits(w, "htmlwidget")) htmltools::as.tags(w, standalone = FALSE)
|
|
2352
|
+
else htmltools::as.tags(w)
|
|
2353
|
+
rendered <- htmltools::renderTags(tags)
|
|
2354
|
+
deps <- htmltools::resolveDependencies(rendered$dependencies)
|
|
2355
|
+
slurp <- function(path) paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
|
|
2356
|
+
inline_one <- function(d) {
|
|
2357
|
+
root <- if (!is.null(d$package)) system.file(d$src$file, package = d$package) else d$src$file
|
|
2358
|
+
if (is.null(root) || !nzchar(root)) return("")
|
|
2359
|
+
css <- vapply(dep_files(d$stylesheet), function(f) {
|
|
2360
|
+
p <- file.path(root, f)
|
|
2361
|
+
if (file.exists(p)) sprintf("<style>%s</style>", slurp(p)) else ""
|
|
2362
|
+
}, character(1))
|
|
2363
|
+
js <- vapply(dep_files(d$script), function(f) {
|
|
2364
|
+
p <- file.path(root, f)
|
|
2365
|
+
# "</script" inside a JS bundle would end the inline tag mid-file; the
|
|
2366
|
+
# standard escape is valid everywhere in JS.
|
|
2367
|
+
if (file.exists(p)) sprintf("<script>%s</script>",
|
|
2368
|
+
gsub("</script", "<\\\\/script", slurp(p), fixed = TRUE)) else ""
|
|
2369
|
+
}, character(1))
|
|
2370
|
+
head_html <- if (!is.null(d$head)) paste(as.character(d$head), collapse = "\n") else ""
|
|
2371
|
+
paste(c(css, js, head_html), collapse = "\n")
|
|
2372
|
+
}
|
|
2373
|
+
# A widget fills its frame (height:100%); a plain HTML fragment is as tall
|
|
2374
|
+
# as its content, and SAYS so: the frame is sandboxed (allow-scripts, no
|
|
2375
|
+
# same-origin), so the page cannot measure it — the document posts its own
|
|
2376
|
+
# scrollHeight to the parent and lib/output-pane.js sizes the iframe.
|
|
2377
|
+
base_css <- if (fit) {
|
|
2378
|
+
paste0("<style>html,body{margin:0;padding:0;overflow:hidden;}",
|
|
2379
|
+
"body{font:13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,",
|
|
2380
|
+
"Helvetica,Arial,sans-serif;color:#1d1d1f;background:transparent;}",
|
|
2381
|
+
"table{border-collapse:collapse;}</style>")
|
|
2382
|
+
} else {
|
|
2383
|
+
"<style>html,body{margin:0;padding:0;height:100%;}</style>"
|
|
2384
|
+
}
|
|
2385
|
+
fit_js <- if (fit) paste0(
|
|
2386
|
+
"<script>(function(){var r=function(){try{parent.postMessage({carmarHtmlHeight:",
|
|
2387
|
+
"Math.ceil(document.documentElement.getBoundingClientRect().height)},'*')}catch(e){}};",
|
|
2388
|
+
"window.addEventListener('load',r);if(window.ResizeObserver){new ResizeObserver(r)",
|
|
2389
|
+
".observe(document.documentElement)}setTimeout(r,0);setTimeout(r,250)})()</script>") else ""
|
|
2390
|
+
paste0(
|
|
2391
|
+
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">",
|
|
2392
|
+
base_css,
|
|
2393
|
+
paste(vapply(deps, inline_one, character(1)), collapse = "\n"),
|
|
2394
|
+
rendered$head,
|
|
2395
|
+
"</head><body>", rendered$html, fit_js, "</body></html>"
|
|
2396
|
+
)
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
#' Everything that is HTML by nature, as ONE htmltools object — or NULL.
|
|
2400
|
+
#'
|
|
2401
|
+
#' The dispatch that turns a value into a rich frame instead of printed text.
|
|
2402
|
+
#' htmlwidgets were the only rich output until 2026-09-03; a `gt` table, a
|
|
2403
|
+
#' `kable(format = "html")`, an `htmltools::tags` tree, `knitr::asis_output()`
|
|
2404
|
+
#' and `display_html()` all printed their own markup as text. Each is now a
|
|
2405
|
+
#' standalone document in a sandboxed frame — the widget path, generalised —
|
|
2406
|
+
#' so a chunk can produce what a Jupyter display() can: any HTML, its own
|
|
2407
|
+
#' scripts included, with no reach into the notebook.
|
|
2408
|
+
#'
|
|
2409
|
+
#' @param v A value.
|
|
2410
|
+
#' @return `list(x = <htmltools object>, kind = "widget" | "html")`, or NULL
|
|
2411
|
+
#' when `v` is not HTML.
|
|
2412
|
+
rich_html_of <- function(v) {
|
|
2413
|
+
if (inherits(v, "htmlwidget")) return(list(x = v, kind = "widget"))
|
|
2414
|
+
if (inherits(v, "carmar_html")) return(list(x = htmltools::HTML(unclass(v)), kind = "html"))
|
|
2415
|
+
if (inherits(v, c("shiny.tag", "shiny.tag.list", "html"))) return(list(x = v, kind = "html"))
|
|
2416
|
+
if (inherits(v, "knit_asis")) return(list(x = htmltools::HTML(paste(as.character(v), collapse = "\n")), kind = "html"))
|
|
2417
|
+
if (inherits(v, "knitr_kable") && identical(attr(v, "format"), "html")) {
|
|
2418
|
+
return(list(x = htmltools::HTML(paste(as.character(v), collapse = "\n")), kind = "html"))
|
|
2419
|
+
}
|
|
2420
|
+
if (inherits(v, "gt_tbl") && requireNamespace("gt", quietly = TRUE)) {
|
|
2421
|
+
html <- tryCatch(gt::as_raw_html(v, inline_css = TRUE), error = function(e) NULL)
|
|
2422
|
+
if (!is.null(html)) return(list(x = htmltools::HTML(html), kind = "html"))
|
|
2423
|
+
}
|
|
2424
|
+
NULL
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
#' Emit one rich value (see `rich_html_of`) as a `widget` frame.
|
|
2428
|
+
#' @param id Cell id.
|
|
2429
|
+
#' @param rich The list `rich_html_of()` returned.
|
|
2430
|
+
#' @return Invisibly NULL.
|
|
2431
|
+
emit_rich <- function(id, rich) {
|
|
2432
|
+
if (identical(rich$kind, "widget")) return(emit_widget(id, rich$x))
|
|
2433
|
+
if (!requireNamespace("htmltools", quietly = TRUE)) {
|
|
2434
|
+
cat("<html output: the htmltools package is required to display it>\n")
|
|
2435
|
+
return(invisible(NULL))
|
|
2436
|
+
}
|
|
2437
|
+
html <- tryCatch(widget_standalone_html(rich$x, fit = TRUE), error = function(e) NULL)
|
|
2438
|
+
if (is.null(html)) {
|
|
2439
|
+
cat("<html output: could not render it>\n")
|
|
2440
|
+
return(invisible(NULL))
|
|
2441
|
+
}
|
|
2442
|
+
if (nchar(html, type = "bytes") > MAX_WIDGET_BYTES) {
|
|
2443
|
+
cat(sprintf("<html output: %.1f MB is too large to display here>\n",
|
|
2444
|
+
nchar(html, type = "bytes") / 1e6))
|
|
2445
|
+
return(invisible(NULL))
|
|
2446
|
+
}
|
|
2447
|
+
emit(list(type = "widget", id = id, kind = "html", class = class(rich$x)[1L], html = html))
|
|
2448
|
+
invisible(NULL)
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
MAX_WIDGET_BYTES <- 15e6
|
|
2452
|
+
|
|
2453
|
+
#' Emit an htmlwidget (plotly, leaflet, DT, ...) as a `widget` frame.
|
|
2454
|
+
#'
|
|
2455
|
+
#' Falls back to printing a note rather than failing the cell: a widget that
|
|
2456
|
+
#' cannot inline is an inconvenience, not an error in the user's code.
|
|
2457
|
+
#'
|
|
2458
|
+
#' @param id Cell id.
|
|
2459
|
+
#' @param w The htmlwidget.
|
|
2460
|
+
#' @return Invisibly NULL.
|
|
2461
|
+
emit_widget <- function(id, w) {
|
|
2462
|
+
if (!requireNamespace("htmltools", quietly = TRUE)) {
|
|
2463
|
+
cat("<htmlwidget: the htmltools package is required to display it>\n")
|
|
2464
|
+
return(invisible(NULL))
|
|
2465
|
+
}
|
|
2466
|
+
html <- tryCatch(widget_standalone_html(w), error = function(e) NULL)
|
|
2467
|
+
if (is.null(html)) {
|
|
2468
|
+
cat("<htmlwidget: could not render it standalone>\n")
|
|
2469
|
+
return(invisible(NULL))
|
|
2470
|
+
}
|
|
2471
|
+
if (nchar(html, type = "bytes") > MAX_WIDGET_BYTES) {
|
|
2472
|
+
cat(sprintf("<htmlwidget: %.1f MB inlined is too large to display here>\n",
|
|
2473
|
+
nchar(html, type = "bytes") / 1e6))
|
|
2474
|
+
return(invisible(NULL))
|
|
2475
|
+
}
|
|
2476
|
+
emit(list(type = "widget", id = id, kind = "widget", class = class(w)[1L], html = html))
|
|
2477
|
+
invisible(NULL)
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
#' Coerce a matrix to a data.frame so it can be shown as a real table.
|
|
2481
|
+
#'
|
|
2482
|
+
#' Half of what R hands back is a matrix, not a data.frame — `coef(summary(fit))`,
|
|
2483
|
+
#' `cor()`, `table()`. Printing those as aligned text when a data.frame would
|
|
2484
|
+
#' become a sortable, exportable table is an arbitrary distinction to the reader.
|
|
2485
|
+
#' Row names carry meaning in exactly these cases (term names, factor levels), so
|
|
2486
|
+
#' they become a leading column rather than being dropped.
|
|
2487
|
+
#'
|
|
2488
|
+
#' @param m A matrix.
|
|
2489
|
+
#' @return A data.frame.
|
|
2490
|
+
matrix_to_df <- function(m) {
|
|
2491
|
+
# as.data.frame.matrix, NOT the generic. On a `table`-class object — which is
|
|
2492
|
+
# what summary.data.frame() and table() both return — the generic dispatches
|
|
2493
|
+
# to as.data.frame.table and produces a LONG tally (Var1, Var2, Freq),
|
|
2494
|
+
# destroying the layout the reader expects. The .matrix method keeps it wide.
|
|
2495
|
+
df <- as.data.frame.matrix(m, stringsAsFactors = FALSE, optional = TRUE)
|
|
2496
|
+
# optional = TRUE leaves a dimnames-less matrix with NULL column names, and
|
|
2497
|
+
# a dataframe frame without keys shipped "columns":null — the client cannot
|
|
2498
|
+
# read a row object without keys, so synthesize the V1..Vn R itself shows.
|
|
2499
|
+
if (is.null(names(df))) names(df) <- sprintf("V%d", seq_len(ncol(df)))
|
|
2500
|
+
# Promoting row names to a column is emit_dataframe's job and only its job —
|
|
2501
|
+
# doing it in both places produced two `rowname` columns, because cbind()
|
|
2502
|
+
# keeps the row names it just copied.
|
|
2503
|
+
#
|
|
2504
|
+
# What has to happen HERE is deciding whether this matrix's row names mean
|
|
2505
|
+
# anything, because as.data.frame.matrix() destroys the evidence: the blank
|
|
2506
|
+
# names that summary.data.frame() produces come out the other side as "X",
|
|
2507
|
+
# "X.1", "X.2" — synthesised placeholders indistinguishable from real labels.
|
|
2508
|
+
# So the judgement is made against `m` and the answer written onto the frame.
|
|
2509
|
+
rn <- rownames(m)
|
|
2510
|
+
informative <- !is.null(rn) &&
|
|
2511
|
+
!identical(rn, as.character(seq_len(nrow(m)))) &&
|
|
2512
|
+
any(nzchar(trimws(rn)))
|
|
2513
|
+
rownames(df) <- if (informative) rn else NULL
|
|
2514
|
+
df
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
#' Evaluate one cell's source, streaming its output, and report the outcome.
|
|
2518
|
+
#'
|
|
2519
|
+
#' Warnings and messages are forwarded as they occur rather than at the end of
|
|
2520
|
+
#' the cell, so a slow loop that warns halfway through reports halfway through.
|
|
2521
|
+
#' Interrupts are caught, not fatal: the session survives so the next cell
|
|
2522
|
+
#' still sees the variables the interrupted one defined.
|
|
2523
|
+
#'
|
|
2524
|
+
#' @param id Cell identifier echoed back in the done frame.
|
|
2525
|
+
#' @param source R source text.
|
|
2526
|
+
#' One frame's local variables: name, class, and a short shape.
|
|
2527
|
+
#'
|
|
2528
|
+
#' Read-only and defensive. `mget` with `ifnotfound` never forces a promise
|
|
2529
|
+
#' that would error, the value summary is a class-and-dimension line rather
|
|
2530
|
+
#' than a print (printing a frame's locals could take minutes and megabytes),
|
|
2531
|
+
#' and everything is capped. A debugger that hangs the session it is
|
|
2532
|
+
#' debugging has made things worse.
|
|
2533
|
+
frame_vars <- function(env, max_vars = 40L) {
|
|
2534
|
+
if (!is.environment(env)) return(list())
|
|
2535
|
+
names_here <- tryCatch(ls(envir = env, all.names = FALSE), error = function(e) character(0))
|
|
2536
|
+
if (!length(names_here)) return(list())
|
|
2537
|
+
if (length(names_here) > max_vars) names_here <- names_here[seq_len(max_vars)]
|
|
2538
|
+
lapply(names_here, function(nm) {
|
|
2539
|
+
v <- tryCatch(mget(nm, envir = env, ifnotfound = list(NULL))[[1L]],
|
|
2540
|
+
error = function(e) NULL)
|
|
2541
|
+
cls <- tryCatch(paste(class(v), collapse = "/"), error = function(e) "?")
|
|
2542
|
+
shape <- tryCatch({
|
|
2543
|
+
if (is.null(v)) "NULL"
|
|
2544
|
+
else if (is.data.frame(v)) sprintf("%d x %d", nrow(v), ncol(v))
|
|
2545
|
+
else if (is.function(v)) "function"
|
|
2546
|
+
else if (is.atomic(v) && length(v) == 1L) {
|
|
2547
|
+
t <- paste(format(v), collapse = " ")
|
|
2548
|
+
if (nchar(t) > 60L) paste0(substr(t, 1L, 57L), "...") else t
|
|
2549
|
+
} else sprintf("length %d", length(v))
|
|
2550
|
+
}, error = function(e) "?")
|
|
2551
|
+
list(name = nm, class = cls, value = shape)
|
|
2552
|
+
})
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
# ── the debugger (stage 5 slice 2) ──────────────────────────────────────────
|
|
2556
|
+
# Native browser(), not a reimplementation: the worker runs under
|
|
2557
|
+
# `R --interactive`, so the console IS the stdin pipe and a Browse prompt
|
|
2558
|
+
# reads the step commands the supervisor sends as raw lines. Everything here
|
|
2559
|
+
# only decides WHERE to call browser() and reports state in frames; the
|
|
2560
|
+
# stepping engine is R's own.
|
|
2561
|
+
|
|
2562
|
+
# srcname -> integer vector of 1-based breakpoint lines.
|
|
2563
|
+
break_registry <- new.env(parent = emptyenv())
|
|
2564
|
+
# Traces currently armed: list(name, env) per traced function, so they can be
|
|
2565
|
+
# removed before re-arming (a cleared breakpoint must actually stop firing).
|
|
2566
|
+
armed_traces <- list()
|
|
2567
|
+
|
|
2568
|
+
#' Arm function-body breakpoints for everything the registry names.
|
|
2569
|
+
#'
|
|
2570
|
+
#' Re-run after every cell: a redefined function sheds its trace with the old
|
|
2571
|
+
#' object, so arming is idempotent re-derivation from the registry, never an
|
|
2572
|
+
#' increment. findLineNum matches functions whose srcref FILENAME equals the
|
|
2573
|
+
#' registered srcname — which is why run_cell parses chunk source under
|
|
2574
|
+
#' srcfilecopy(srcname, ...).
|
|
2575
|
+
#'
|
|
2576
|
+
#' @return Number of function locations armed.
|
|
2577
|
+
apply_fn_breaks <- function() {
|
|
2578
|
+
for (tr in armed_traces) {
|
|
2579
|
+
tryCatch(suppressMessages(untrace(tr$name, where = tr$env)),
|
|
2580
|
+
error = function(e) NULL)
|
|
2581
|
+
}
|
|
2582
|
+
armed_traces <<- list()
|
|
2583
|
+
if (!identical(WORKER_MODE, "interactive")) return(0L)
|
|
2584
|
+
files <- ls(break_registry, all.names = TRUE)
|
|
2585
|
+
for (file in files) {
|
|
2586
|
+
for (line in break_registry[[file]]) {
|
|
2587
|
+
hits <- tryCatch(
|
|
2588
|
+
utils::findLineNum(sprintf("%s#%d", file, line),
|
|
2589
|
+
envir = globalenv(), lastenv = globalenv()),
|
|
2590
|
+
error = function(e) list())
|
|
2591
|
+
for (h in hits) {
|
|
2592
|
+
ok <- tryCatch({
|
|
2593
|
+
suppressMessages(trace(
|
|
2594
|
+
h$name,
|
|
2595
|
+
tracer = bquote({
|
|
2596
|
+
.carmar_debug_entered(.(file), .(line), "breakpoint")
|
|
2597
|
+
browser()
|
|
2598
|
+
}),
|
|
2599
|
+
at = h$at, where = h$env, print = FALSE))
|
|
2600
|
+
TRUE
|
|
2601
|
+
}, error = function(e) FALSE)
|
|
2602
|
+
if (ok) armed_traces[[length(armed_traces) + 1L]] <<- list(name = h$name, env = h$env)
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
length(armed_traces)
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
#' Set or clear the breakpoints of one source file, then re-arm.
|
|
2610
|
+
#'
|
|
2611
|
+
#' @param id Request id.
|
|
2612
|
+
#' @param file The srcname the client runs its chunks under ("chunk:<id>",
|
|
2613
|
+
#' or a script path).
|
|
2614
|
+
#' @param lines 1-based lines; empty or absent clears the file.
|
|
2615
|
+
#' @return Invisibly NULL. Emits one `debug_breaks` frame.
|
|
2616
|
+
emit_debug_breaks <- function(id, file, lines = NULL) {
|
|
2617
|
+
if (!is.character(file) || length(file) != 1L || !nzchar(file)) {
|
|
2618
|
+
emit(list(type = "debug_breaks", id = id, error = "bad file"))
|
|
2619
|
+
return(invisible(NULL))
|
|
2620
|
+
}
|
|
2621
|
+
if (!identical(WORKER_MODE, "interactive")) {
|
|
2622
|
+
emit(list(type = "debug_breaks", id = id, file = file,
|
|
2623
|
+
error = "the debugger needs an interactive worker (this one is batch)"))
|
|
2624
|
+
return(invisible(NULL))
|
|
2625
|
+
}
|
|
2626
|
+
lines <- suppressWarnings(as.integer(unlist(lines)))
|
|
2627
|
+
lines <- sort(unique(lines[is.finite(lines) & lines >= 1L]))
|
|
2628
|
+
if (length(lines)) assign(file, lines, envir = break_registry)
|
|
2629
|
+
else if (exists(file, envir = break_registry)) rm(list = file, envir = break_registry)
|
|
2630
|
+
armed <- apply_fn_breaks()
|
|
2631
|
+
emit(list(type = "debug_breaks", id = id, file = file,
|
|
2632
|
+
lines = I(as.integer(lines)), functions = armed))
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
#' The live call stack at a debug pause, trimmed like capture_trace.
|
|
2636
|
+
#'
|
|
2637
|
+
#' Bottom harness frames are dropped up to the last eval boundary; top frames
|
|
2638
|
+
#' are dropped through the trace/browser machinery and this file's own debug
|
|
2639
|
+
#' functions, so the first and last things a reader sees are their own calls.
|
|
2640
|
+
#'
|
|
2641
|
+
#' @param calls The sys.calls() of the caller.
|
|
2642
|
+
#' @param frames The sys.frames() of the caller.
|
|
2643
|
+
#' @return A list of list(call, vars, file, line) — innermost LAST.
|
|
2644
|
+
debug_stack <- function(calls, frames, max_frames = 40L) {
|
|
2645
|
+
if (!length(calls)) return(list())
|
|
2646
|
+
texts <- vapply(calls, function(cl) {
|
|
2647
|
+
d <- tryCatch(paste(deparse(cl), collapse = " "), error = function(e) "<call>")
|
|
2648
|
+
if (nchar(d) > 300L) paste0(substr(d, 1L, 297L), "...") else d
|
|
2649
|
+
}, character(1))
|
|
2650
|
+
boundary <- which(startsWith(texts, "withVisible(eval(") |
|
|
2651
|
+
startsWith(texts, "eval(e, globalenv())") |
|
|
2652
|
+
startsWith(texts, "eval(wrapped, globalenv())"))
|
|
2653
|
+
from <- if (length(boundary)) max(boundary) + 1L else 1L
|
|
2654
|
+
to <- length(texts)
|
|
2655
|
+
# Trace/browser plumbing above the user's own frames. Trimming it is not
|
|
2656
|
+
# cosmetic: frame_vars below reads every frame it keeps, and a machinery
|
|
2657
|
+
# frame's environment holds the TRACER EXPRESSION as an unforced promise —
|
|
2658
|
+
# mget() forces what it reads, so describing that frame re-entered the
|
|
2659
|
+
# tracer recursively (a second paused frame, a browser "Called from: mget").
|
|
2660
|
+
ours <- "^(\\.carmar_debug_entered|\\.carmar_debug_where|\\.doTrace|browser\\(|eval\\(expr|eval\\.parent|Reduce\\(|\\{)"
|
|
2661
|
+
while (to >= from && grepl(ours, texts[[to]])) to <- to - 1L
|
|
2662
|
+
if (to < from) return(list())
|
|
2663
|
+
idx <- seq.int(from, to)
|
|
2664
|
+
if (length(idx) > max_frames) idx <- utils::tail(idx, max_frames)
|
|
2665
|
+
lapply(idx, function(k) {
|
|
2666
|
+
ref <- utils::getSrcref(calls[[k]])
|
|
2667
|
+
list(call = texts[[k]],
|
|
2668
|
+
vars = if (k <= length(frames)) frame_vars(frames[[k]]) else list(),
|
|
2669
|
+
file = if (is.null(ref)) NULL else {
|
|
2670
|
+
f <- attr(ref, "srcfile")
|
|
2671
|
+
if (is.null(f) || is.null(f$filename) || !nzchar(f$filename)) NULL else f$filename
|
|
2672
|
+
},
|
|
2673
|
+
line = if (is.null(ref)) NULL else as.integer(ref[[1L]]))
|
|
2674
|
+
})
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
#' The call stack at the moment of an error, as frames a UI can click.
|
|
2678
|
+
#'
|
|
2679
|
+
#' Stage 5 slice 1. Two details make this useful rather than decorative:
|
|
2680
|
+
#'
|
|
2681
|
+
#' IT RUNS BEFORE THE UNWIND. Called from a `withCallingHandlers` error
|
|
2682
|
+
#' handler, so `sys.calls()` still holds the stack. From `tryCatch` it would
|
|
2683
|
+
#' be empty, which is why R users are told to call `traceback()` afterwards.
|
|
2684
|
+
#'
|
|
2685
|
+
#' THE HARNESS IS TRIMMED. The bottom frames are this file's own
|
|
2686
|
+
#' (`run_cell`, `withCallingHandlers`, the `lapply` over expressions) and
|
|
2687
|
+
#' showing them teaches the reader that their error came from CarmaR. Frames
|
|
2688
|
+
#' are dropped up to and including the last `eval(e, globalenv())`, which is
|
|
2689
|
+
#' the boundary between our code and theirs.
|
|
2690
|
+
#'
|
|
2691
|
+
#' @return A list of list(call, file, line) — innermost LAST, the order
|
|
2692
|
+
#' `traceback()` prints and the order people read a stack in.
|
|
2693
|
+
capture_trace <- function(max_frames = 40L) {
|
|
2694
|
+
calls <- sys.calls()
|
|
2695
|
+
if (!length(calls)) return(list())
|
|
2696
|
+
texts <- vapply(calls, function(cl) {
|
|
2697
|
+
d <- tryCatch(paste(deparse(cl), collapse = " "), error = function(e) "<call>")
|
|
2698
|
+
if (nchar(d) > 300L) paste0(substr(d, 1L, 297L), "...") else d
|
|
2699
|
+
}, character(1))
|
|
2700
|
+
# Everything up to the user's own evaluation belongs to the harness.
|
|
2701
|
+
# startsWith, not grepl(fixed = TRUE): with fixed = TRUE the `^` is a
|
|
2702
|
+
# LITERAL caret and the pattern never matches, which silently shipped the
|
|
2703
|
+
# whole boot stack to the browser.
|
|
2704
|
+
boundary <- which(startsWith(texts, "withVisible(eval(") |
|
|
2705
|
+
startsWith(texts, "eval(e, globalenv())"))
|
|
2706
|
+
# No user evaluation on the stack means the error is the harness's own — a
|
|
2707
|
+
# parse error, where R stopped before any expression ran. Those frames are
|
|
2708
|
+
# worker.R's boot and dispatch loop: nothing the user can go to, and
|
|
2709
|
+
# walking them is NOT safe. frame_vars() reads each variable with mget(),
|
|
2710
|
+
# and mget FORCES a promise; the `local()` frame that is this whole file
|
|
2711
|
+
# holds `expr` — the entire worker program — as a promise local() never
|
|
2712
|
+
# forces. Reading it evaluated the program a second time, and that nested
|
|
2713
|
+
# copy sat in its own dispatch loop reading the console: every syntax
|
|
2714
|
+
# error hung the interactive session until Stop (measured 2026-09-07; the
|
|
2715
|
+
# batch worker hid it, since its nested loop drained stdin and exited).
|
|
2716
|
+
# An empty trace is the honest answer, and the only safe one.
|
|
2717
|
+
if (!length(boundary)) return(list())
|
|
2718
|
+
from <- max(boundary) + 1L
|
|
2719
|
+
# And the handler frames at the very top are ours too.
|
|
2720
|
+
to <- length(texts)
|
|
2721
|
+
while (to >= from && grepl("^(capture_trace|\\.handleSimpleError|h\\(simpleError|stop\\()", texts[[to]])) {
|
|
2722
|
+
to <- to - 1L
|
|
2723
|
+
}
|
|
2724
|
+
if (to < from) return(list())
|
|
2725
|
+
idx <- seq.int(from, to)
|
|
2726
|
+
if (length(idx) > max_frames) idx <- utils::tail(idx, max_frames)
|
|
2727
|
+
frames <- sys.frames()
|
|
2728
|
+
lapply(idx, function(k) {
|
|
2729
|
+
ref <- utils::getSrcref(calls[[k]])
|
|
2730
|
+
list(call = texts[[k]],
|
|
2731
|
+
# The frame's own variables, so "what was `n` at the time?" has an
|
|
2732
|
+
# answer without re-running anything. `ls()` and `class()` only — no
|
|
2733
|
+
# user code is evaluated here, and the values are capped hard because
|
|
2734
|
+
# a frame can hold a 2 GB data frame and this rides the same stdout
|
|
2735
|
+
# the cell's output does.
|
|
2736
|
+
vars = if (k <= length(frames)) frame_vars(frames[[k]]) else list(),
|
|
2737
|
+
file = if (is.null(ref)) NULL else {
|
|
2738
|
+
f <- attr(ref, "srcfile")
|
|
2739
|
+
if (is.null(f) || is.null(f$filename) || !nzchar(f$filename)) NULL else f$filename
|
|
2740
|
+
},
|
|
2741
|
+
line = if (is.null(ref)) NULL else as.integer(ref[[1L]]))
|
|
2742
|
+
})
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
#' Return a package name only when R or its message identifies one safely.
|
|
2746
|
+
#'
|
|
2747
|
+
#' `packageNotFoundError` carries a structured `$package` field in current R.
|
|
2748
|
+
#' The narrow message fallback covers older R releases. In both cases the
|
|
2749
|
+
#' allow-list is deliberately the same one used by `package_action`, so an
|
|
2750
|
+
#' error message can never become code or an arbitrary install target.
|
|
2751
|
+
carmar_missing_package <- function(error) {
|
|
2752
|
+
valid <- function(value) {
|
|
2753
|
+
is.character(value) && length(value) == 1L && !is.na(value) &&
|
|
2754
|
+
grepl("^[A-Za-z][A-Za-z0-9.]*$", value)
|
|
2755
|
+
}
|
|
2756
|
+
package <- tryCatch(error$package, error = function(e) NULL)
|
|
2757
|
+
if (valid(package)) return(package)
|
|
2758
|
+
|
|
2759
|
+
message <- tryCatch(conditionMessage(error), error = function(e) "")
|
|
2760
|
+
match <- regexec(
|
|
2761
|
+
"(?:there is no package called|package) [‘’'\"]([A-Za-z][A-Za-z0-9.]*)[‘’'\"](?: is not installed)?",
|
|
2762
|
+
message, perl = TRUE, ignore.case = TRUE)
|
|
2763
|
+
pieces <- regmatches(message, match)[[1L]]
|
|
2764
|
+
if (length(pieces) >= 2L && valid(pieces[[2L]])) pieces[[2L]] else NULL
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
#' @return Invisibly NULL. Emits exactly one `done` frame.
|
|
2768
|
+
run_cell <- function(id, source, dims = NULL, srcname = NULL) {
|
|
2769
|
+
# The whole run — prologue, evaluation, epilogue — under one rule: a
|
|
2770
|
+
# signal-borne interrupt that carries no stop flag for THIS run was sent for
|
|
2771
|
+
# a run that has ended, and is resumed as if it had never come (measured
|
|
2772
|
+
# 2026-09-16: the ladder's late rung reached the next cell's prologue and
|
|
2773
|
+
# reported it interrupted without running it, or its epilogue and reported a
|
|
2774
|
+
# finished cell interrupted). A flagged interrupt is this cell's Stop and
|
|
2775
|
+
# unwinds to the exiting handlers inside as ever. The handler is a CALLING
|
|
2776
|
+
# one established outside the cell's own tryCatch, so the cell's exiting
|
|
2777
|
+
# handlers, being inner, are still the ones that take a real Stop.
|
|
2778
|
+
withCallingHandlers(run_cell_impl(id, source, dims, srcname),
|
|
2779
|
+
interrupt = function(i) if (carmar_interrupt_is_stray(id)) carmar_resume_if_stray(i))
|
|
2780
|
+
}
|
|
2781
|
+
run_cell_impl <- function(id, source, dims = NULL, srcname = NULL) {
|
|
2782
|
+
stopifnot(is.character(source), length(source) == 1L)
|
|
2783
|
+
# First: a signal that arrived between commands must not end THIS cell.
|
|
2784
|
+
carmar_drain_stray_interrupt(id)
|
|
2785
|
+
# The srcname is the chunk's identity in the debugger: functions defined
|
|
2786
|
+
# here carry it in their srcrefs, which is what lets findLineNum resolve
|
|
2787
|
+
# "chunk:<id>#line" to a function and a step. Wire-supplied; anything not a
|
|
2788
|
+
# plain single string means "no debugging identity", never an error.
|
|
2789
|
+
if (!is.character(srcname) || length(srcname) != 1L || is.na(srcname) ||
|
|
2790
|
+
!nzchar(srcname)) srcname <- NULL
|
|
2791
|
+
# `dims` crosses the wire unvalidated (serve.R checks only id and source),
|
|
2792
|
+
# and `$` on an atomic vector THROWS — before the eval's own tryCatch, so a
|
|
2793
|
+
# malformed dims from an older bundle or a hand-built frame used to escape
|
|
2794
|
+
# run_cell entirely and wedge the whole exec route. A dims that is not a
|
|
2795
|
+
# list is no dims.
|
|
2796
|
+
if (!is.null(dims) && !is.list(dims)) dims <- NULL
|
|
2797
|
+
# The print shadow needs to know which run an explicit print(df) belongs to;
|
|
2798
|
+
# cleared on every exit so a print outside a run falls back to base.
|
|
2799
|
+
RUN_STATE$id <- id
|
|
2800
|
+
on.exit(RUN_STATE$id <- NULL, add = TRUE)
|
|
2801
|
+
# THE FRAME MUST NOT GO INTO THE USER'S LOG FILE. emit() is cat() with no
|
|
2802
|
+
# `file=`, so it writes to the CURRENT sink — and `sink("log.txt")` in one
|
|
2803
|
+
# chunk with `sink(NULL)` in the next is ordinary R, as is a package that
|
|
2804
|
+
# leaves a sink open when it errors. Every subsequent done/plot/stream and
|
|
2805
|
+
# every command reply then lands in that file: the supervisor fails a route
|
|
2806
|
+
# only when the worker DIES, so a healthy worker that has stopped answering
|
|
2807
|
+
# produces a cell that spins forever and, behind it, a wedged queue.
|
|
2808
|
+
#
|
|
2809
|
+
# Restoring the depth this run STARTED at, not sink(NULL): a chunk may
|
|
2810
|
+
# legitimately open a sink the user means to keep across cells, and closing
|
|
2811
|
+
# it for them would be a different bug. This only undoes what this cell
|
|
2812
|
+
# left open. (docs/stability-plan.md S4/S1.)
|
|
2813
|
+
sink_depth_at_entry <- sink.number()
|
|
2814
|
+
on.exit({
|
|
2815
|
+
extra <- sink.number() - sink_depth_at_entry
|
|
2816
|
+
if (isTRUE(extra > 0L)) for (i in seq_len(extra)) try(sink(NULL), silent = TRUE)
|
|
2817
|
+
}, add = TRUE, after = FALSE)
|
|
2818
|
+
status <- "ok"
|
|
2819
|
+
detail <- NULL
|
|
2820
|
+
missing_package <- NULL
|
|
2821
|
+
plot_dir <- tempfile("carmar-plots-")
|
|
2822
|
+
dir.create(plot_dir)
|
|
2823
|
+
seen <- character(0)
|
|
2824
|
+
# Asked for vector, cannot give it: SAY SO. Falling back to a raster the
|
|
2825
|
+
# reader did not choose, silently, is how "my plots are blurry" becomes an
|
|
2826
|
+
# unanswerable bug report — and this R may well be one where svg() opens
|
|
2827
|
+
# cleanly and writes nothing (see detect_svg_device).
|
|
2828
|
+
if (isTRUE(dims$format == "svg") && is.null(SVG_DEVICE)) {
|
|
2829
|
+
emit(list(type = "stream", id = id, kind = "warning",
|
|
2830
|
+
text = paste("Vector output is not available in this R —",
|
|
2831
|
+
"install the svglite package (or XQuartz, for Cairo)",
|
|
2832
|
+
"and restart. Drawing at", dims$res %||% PLOT_RES,
|
|
2833
|
+
"dpi instead.\n")))
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
# Where the error happened, captured WHILE it happens. By the time
|
|
2837
|
+
# tryCatch's handler runs the stack has already unwound, so a traceback
|
|
2838
|
+
# taken there is empty — this is the whole reason for the extra calling
|
|
2839
|
+
# handler below, and the reason `at_line` is tracked around each top-level
|
|
2840
|
+
# expression rather than derived afterwards.
|
|
2841
|
+
at_line <- NA_integer_
|
|
2842
|
+
trace_frames <- list()
|
|
2843
|
+
|
|
2844
|
+
# Breakpoint lines registered for this chunk, if any. Top-level hits pause
|
|
2845
|
+
# via a braced browser() below; hits inside function bodies were armed as
|
|
2846
|
+
# traces by apply_fn_breaks and fire on the call.
|
|
2847
|
+
bp_lines <- if (!is.null(srcname) && identical(WORKER_MODE, "interactive") &&
|
|
2848
|
+
exists(srcname, envir = break_registry)) {
|
|
2849
|
+
break_registry[[srcname]]
|
|
2850
|
+
} else integer(0)
|
|
2851
|
+
|
|
2852
|
+
tryCatch(
|
|
2853
|
+
withRestarts(
|
|
2854
|
+
withCallingHandlers(
|
|
2855
|
+
{
|
|
2856
|
+
# keep.source so every top-level expression knows its own line, which
|
|
2857
|
+
# is what "jump from the error to the source" actually needs. The
|
|
2858
|
+
# srcfile names the chunk so the debugger can address its lines.
|
|
2859
|
+
exprs <- if (is.null(srcname)) parse(text = source, keep.source = TRUE)
|
|
2860
|
+
else parse(text = source, keep.source = TRUE,
|
|
2861
|
+
srcfile = srcfilecopy(srcname, source))
|
|
2862
|
+
srcrefs <- attr(exprs, "srcref")
|
|
2863
|
+
# ONE device for the whole cell, not one per statement. Base graphics
|
|
2864
|
+
# are stateful across statements — `layout()` then two plots, `par()`
|
|
2865
|
+
# then a plot, `seq_heatmap()` then `abline()` — and a per-statement
|
|
2866
|
+
# device reset broke every one of those idioms ("plot.new has not
|
|
2867
|
+
# been called yet"). Finished pages still stream out mid-cell via
|
|
2868
|
+
# harvest_finished(); only the page being drawn waits for cell end.
|
|
2869
|
+
dev_seq <- 1L
|
|
2870
|
+
open_plot_device(plot_dir, dev_seq, dims)
|
|
2871
|
+
invisible(lapply(seq_along(exprs), function(.i) {
|
|
2872
|
+
e <- exprs[[.i]]
|
|
2873
|
+
at_line <<- if (!is.null(srcrefs) && length(srcrefs) >= .i &&
|
|
2874
|
+
!is.null(srcrefs[[.i]])) {
|
|
2875
|
+
as.integer(srcrefs[[.i]][[1L]])
|
|
2876
|
+
} else {
|
|
2877
|
+
NA_integer_
|
|
2878
|
+
}
|
|
2879
|
+
# Stop, read at the boundary (carmar_stop_requested): the line after a
|
|
2880
|
+
# busy call that no signal could interrupt does not run.
|
|
2881
|
+
carmar_stop_if_requested(id)
|
|
2882
|
+
# Where R is, for the page's progress bar (RStudio's green line
|
|
2883
|
+
# down the chunk): one frame per top-level expression, BEFORE it
|
|
2884
|
+
# runs, naming the lines it spans — so a 40-second model fit shows
|
|
2885
|
+
# which lines it is on instead of a box-wide pulse. Not output:
|
|
2886
|
+
# the client paints it live and never keeps it.
|
|
2887
|
+
emit(list(type = "progress", id = id, index = .i, total = length(exprs),
|
|
2888
|
+
line = if (is.na(at_line)) NULL else at_line,
|
|
2889
|
+
end = if (is.na(at_line)) NULL
|
|
2890
|
+
else as.integer(srcrefs[[.i]][[3L]])))
|
|
2891
|
+
# A top-level breakpoint pauses BEFORE its expression, inside a
|
|
2892
|
+
# constructed block, so R's own stepper takes over from there: `n`
|
|
2893
|
+
# runs the expression whole, `s` steps into its calls, `c` finishes
|
|
2894
|
+
# the block. The block's value and visibility are the expression's
|
|
2895
|
+
# own, so autoprint below is unchanged.
|
|
2896
|
+
res <- if (!is.na(at_line) && at_line %in% bp_lines) {
|
|
2897
|
+
wrapped <- as.call(list(
|
|
2898
|
+
as.name("{"),
|
|
2899
|
+
bquote(.carmar_debug_entered(.(srcname), .(at_line), "breakpoint")),
|
|
2900
|
+
quote(browser()),
|
|
2901
|
+
e))
|
|
2902
|
+
withVisible(eval(wrapped, globalenv()))
|
|
2903
|
+
} else {
|
|
2904
|
+
withVisible(eval(e, globalenv()))
|
|
2905
|
+
}
|
|
2906
|
+
if (isTRUE(res$visible)) {
|
|
2907
|
+
v <- res$value
|
|
2908
|
+
rich <- rich_html_of(v)
|
|
2909
|
+
if (!is.null(rich)) emit_rich(id, rich)
|
|
2910
|
+
# `e` is the expression that produced this. A bare symbol names an
|
|
2911
|
+
# object the viewer can fetch again for free; anything else does
|
|
2912
|
+
# not, and must not be offered as though it could.
|
|
2913
|
+
else if (is.data.frame(v)) emit_dataframe(id, v,
|
|
2914
|
+
source = if (is.symbol(e)) as.character(e) else NULL)
|
|
2915
|
+
else if (is.matrix(v) && nrow(v) > 0L && ncol(v) > 0L) emit_dataframe(id, matrix_to_df(v),
|
|
2916
|
+
source = if (is.symbol(e)) as.character(e) else NULL)
|
|
2917
|
+
else base::print(v)
|
|
2918
|
+
}
|
|
2919
|
+
# Re-arm function breakpoints after EVERY top-level expression, not
|
|
2920
|
+
# after the cell: the RStudio-typical chunk defines a function and
|
|
2921
|
+
# calls it three lines later, and a trace armed at cell end would
|
|
2922
|
+
# miss that first call entirely. No-op while no breakpoint is set.
|
|
2923
|
+
if (length(ls(break_registry, all.names = TRUE))) {
|
|
2924
|
+
tryCatch(apply_fn_breaks(), error = function(err) NULL)
|
|
2925
|
+
}
|
|
2926
|
+
seen <<- harvest_finished(id, plot_dir, seen, dims)
|
|
2927
|
+
# User code may close our device (an explicit dev.off() in the
|
|
2928
|
+
# cell). Reopen under a FRESH sequence number: reusing e001-*.png
|
|
2929
|
+
# would overwrite files already emitted, and `seen` would silently
|
|
2930
|
+
# swallow the replacements.
|
|
2931
|
+
if (is.null(grDevices::dev.list())) {
|
|
2932
|
+
dev_seq <<- dev_seq + 1L
|
|
2933
|
+
open_plot_device(plot_dir, dev_seq, dims)
|
|
2934
|
+
}
|
|
2935
|
+
}))
|
|
2936
|
+
# …and after the last expression: a Stop that arrived while it ran ends the cell interrupted.
|
|
2937
|
+
carmar_stop_if_requested(id)
|
|
2938
|
+
},
|
|
2939
|
+
# The stray-signal rule (see run_cell), established INSIDE the cell's own
|
|
2940
|
+
# tryCatch so it is asked before the exiting handler below can unwind.
|
|
2941
|
+
interrupt = function(i) if (carmar_interrupt_is_stray(id)) carmar_resume_if_stray(i),
|
|
2942
|
+
warning = function(w) {
|
|
2943
|
+
emit(list(type = "stream", id = id, kind = "warning",
|
|
2944
|
+
text = conditionMessage(w)))
|
|
2945
|
+
invokeRestart("muffleWarning")
|
|
2946
|
+
},
|
|
2947
|
+
message = function(m) {
|
|
2948
|
+
emit(list(type = "stream", id = id, kind = "message",
|
|
2949
|
+
text = sub("\n$", "", conditionMessage(m))))
|
|
2950
|
+
invokeRestart("muffleMessage")
|
|
2951
|
+
},
|
|
2952
|
+
# The traceback, taken BEFORE the stack unwinds. A calling handler runs
|
|
2953
|
+
# inside the erroring frame; tryCatch's handler runs after the unwind,
|
|
2954
|
+
# where sys.calls() is empty. This one only RECORDS — it does not handle
|
|
2955
|
+
# the error, so the tryCatch below still decides the cell's fate.
|
|
2956
|
+
error = function(e) {
|
|
2957
|
+
trace_frames <<- capture_trace()
|
|
2958
|
+
}
|
|
2959
|
+
),
|
|
2960
|
+
# The debugger's Stop. `Q` at a Browse prompt would abort past the
|
|
2961
|
+
# dispatch loop and zombie the worker; invoking this restart instead
|
|
2962
|
+
# unwinds the browser and the rest of the cell, lands here, and the cell
|
|
2963
|
+
# reports itself stopped like any interrupt.
|
|
2964
|
+
carmar_abort_cell = function() {
|
|
2965
|
+
status <<- "interrupted"
|
|
2966
|
+
detail <<- "Stopped from the debugger"
|
|
2967
|
+
}
|
|
2968
|
+
),
|
|
2969
|
+
error = function(e) {
|
|
2970
|
+
status <<- "error"
|
|
2971
|
+
detail <<- conditionMessage(e)
|
|
2972
|
+
missing_package <<- carmar_missing_package(e)
|
|
2973
|
+
# A parse error has no stack and no expression index; its position comes
|
|
2974
|
+
# from the message instead, exactly as the analyzer reads it.
|
|
2975
|
+
emit(list(type = "traceback", id = id,
|
|
2976
|
+
message = conditionMessage(e),
|
|
2977
|
+
# 1-based line WITHIN this chunk's source. NA when the failure
|
|
2978
|
+
# happened outside any top-level expression (a parse error).
|
|
2979
|
+
line = if (is.na(at_line)) NULL else at_line,
|
|
2980
|
+
call = tryCatch(paste(deparse(conditionCall(e)), collapse = " "),
|
|
2981
|
+
error = function(x) NULL),
|
|
2982
|
+
frames = trace_frames))
|
|
2983
|
+
},
|
|
2984
|
+
interrupt = function(i) {
|
|
2985
|
+
status <<- "interrupted"
|
|
2986
|
+
detail <<- "Execution interrupted"
|
|
2987
|
+
}
|
|
2988
|
+
)
|
|
2989
|
+
|
|
2990
|
+
# An interrupted or failed cell may still have drawn something, and it always
|
|
2991
|
+
# leaves a device open — close it before the next cell inherits it.
|
|
2992
|
+
seen <- tryCatch(harvest_plots(id, plot_dir, seen, dims),
|
|
2993
|
+
error = function(e) seen, interrupt = function(i) seen)
|
|
2994
|
+
unlink(plot_dir, recursive = TRUE)
|
|
2995
|
+
|
|
2996
|
+
flush(stdout())
|
|
2997
|
+
# `cwd`: the supervisor answers file ops itself and resolves a relative path
|
|
2998
|
+
# against the directory the WORKER is in — setwd() in this very run may have
|
|
2999
|
+
# moved it, so every done frame says where R now stands.
|
|
3000
|
+
emit(list(type = "done", id = id, status = status, message = detail,
|
|
3001
|
+
missingPackage = missing_package, cwd = getwd()))
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
#' Has the supervisor asked to stop run `id`? (host/engine-stdio.mjs `requestStop`)
|
|
3005
|
+
#'
|
|
3006
|
+
#' Stop is a SIGINT, and a SIGINT cannot always reach R: libc's system() ignores
|
|
3007
|
+
#' it in R while the child runs, so after the group signal ends the child R walks
|
|
3008
|
+
#' on to the next line unless a second signal happens to land in the instant
|
|
3009
|
+
#' between system() returning and that line starting — on Linux it never did
|
|
3010
|
+
#' (`beatrina check`, 2026-09-16). So the supervisor also writes a flag file,
|
|
3011
|
+
#' `stop-<id>` in CARMAR_STOP_DIR, and the cell reads it at every top-level
|
|
3012
|
+
#' expression boundary. The flag is removed when read; no directory, no flag.
|
|
3013
|
+
#'
|
|
3014
|
+
#' @param id The wire id of the running cell.
|
|
3015
|
+
#' @return TRUE when a stop was requested (and the flag consumed), else FALSE.
|
|
3016
|
+
carmar_stop_requested <- function(id) {
|
|
3017
|
+
dir <- Sys.getenv("CARMAR_STOP_DIR", "")
|
|
3018
|
+
if (!nzchar(dir) || !is.character(id) || length(id) != 1L || is.na(id)) return(FALSE)
|
|
3019
|
+
flag <- file.path(dir, paste0("stop-", id))
|
|
3020
|
+
if (!file.exists(flag)) return(FALSE)
|
|
3021
|
+
unlink(flag)
|
|
3022
|
+
TRUE
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
#' Raise R's own interrupt condition when a stop was requested, so the cell ends
|
|
3026
|
+
#' through the same `interrupt` handler a signal reaches.
|
|
3027
|
+
#' @param id The wire id of the running cell.
|
|
3028
|
+
#' @return NULL, invisibly, when no stop was requested.
|
|
3029
|
+
carmar_stop_if_requested <- function(id) {
|
|
3030
|
+
if (carmar_stop_requested(id)) {
|
|
3031
|
+
signalCondition(structure(class = c("interrupt", "condition"),
|
|
3032
|
+
list(message = "Execution interrupted", call = NULL)))
|
|
3033
|
+
}
|
|
3034
|
+
invisible(NULL)
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
#' Is a stop flag for run `id` on disk? Read without consuming it.
|
|
3038
|
+
#' @param id The wire id of the running cell.
|
|
3039
|
+
#' @return TRUE when the supervisor has asked this run to stop.
|
|
3040
|
+
carmar_stop_pending <- function(id) {
|
|
3041
|
+
dir <- Sys.getenv("CARMAR_STOP_DIR", "")
|
|
3042
|
+
if (!nzchar(dir) || !is.character(id) || length(id) != 1L || is.na(id)) return(FALSE)
|
|
3043
|
+
file.exists(file.path(dir, paste0("stop-", id)))
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
#' Is an interrupt raised inside run `id` NOT this run's Stop?
|
|
3047
|
+
#'
|
|
3048
|
+
#' Under a supervisor that writes stop flags (CARMAR_STOP_DIR set), every real
|
|
3049
|
+
#' Stop writes `stop-<id>` BEFORE it sends a signal, so a signal-borne interrupt
|
|
3050
|
+
#' with no flag for this run was sent for another — one that has already ended.
|
|
3051
|
+
#' Without a flag folder (an older supervisor) nothing can be told apart, and
|
|
3052
|
+
#' every interrupt is this run's, as it always was.
|
|
3053
|
+
#' @param id The wire id of the running cell.
|
|
3054
|
+
#' @return TRUE only when a flag folder exists and holds no flag for `id`.
|
|
3055
|
+
carmar_interrupt_is_stray <- function(id) {
|
|
3056
|
+
nzchar(Sys.getenv("CARMAR_STOP_DIR", "")) && !carmar_stop_pending(id)
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
#' Resume from an interrupt that belongs to a run that has already ended.
|
|
3060
|
+
#'
|
|
3061
|
+
#' A Stop is a SIGINT, and a SIGINT sent for one run can reach R after that run
|
|
3062
|
+
#' has finished — measured 2026-09-16 (tools/beatrina rigs, LEARNINGS.md): the
|
|
3063
|
+
#' supervisor's second ladder rung, spawned 60 ms after the first, landed 3 ms
|
|
3064
|
+
#' after the NEXT command had been written. What R does with such a signal
|
|
3065
|
+
#' depends only on where it is raised, and every place is wrong in its own way:
|
|
3066
|
+
#' inside `readLines` after the line is in hand it threw the command away
|
|
3067
|
+
#' (the session then sits silent behind a command that never ran — the 8-hour
|
|
3068
|
+
#' soak's lost session); between parsing and dispatch it escaped every handler,
|
|
3069
|
+
#' reached the `options(error=)` guard and ended R (the session's variables with
|
|
3070
|
+
#' it); in a cell's prologue it reported the cell interrupted without running it.
|
|
3071
|
+
#'
|
|
3072
|
+
#' R raises a signal-borne interrupt from `R_CheckUserInterrupt` with a `resume`
|
|
3073
|
+
#' restart (`?conditions`: "the interrupt condition can be resumed"), so a
|
|
3074
|
+
#' CALLING handler that invokes it continues the interrupted computation as if
|
|
3075
|
+
#' the signal had never come. That is exactly right for a signal that is not for
|
|
3076
|
+
#' the code it interrupted, and every window between two commands is such a
|
|
3077
|
+
#' place: a Stop for a run that is still running lands inside that run, never
|
|
3078
|
+
#' here. The console reader's own interrupt (raised while it waits, with no
|
|
3079
|
+
#' `resume`) falls through to the exiting handler as before — nothing was
|
|
3080
|
+
#' consumed, so nothing is lost by going round again.
|
|
3081
|
+
#'
|
|
3082
|
+
#' @param i The interrupt condition.
|
|
3083
|
+
#' @return Does not return when a `resume` restart exists; NULL otherwise.
|
|
3084
|
+
carmar_resume_if_stray <- function(i) {
|
|
3085
|
+
if (!is.null(findRestart("resume"))) invokeRestart("resume")
|
|
3086
|
+
invisible(NULL)
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
#' Raise and discard any interrupt still pending from a signal that arrived
|
|
3090
|
+
#' between commands, so it cannot end the cell about to run. A signal that is
|
|
3091
|
+
#' pending when a cell starts was sent before the cell existed; the one signal
|
|
3092
|
+
#' that IS for this cell — a Stop pressed after it started — travels with a flag
|
|
3093
|
+
#' the supervisor writes first, so a flagged interrupt is honoured, not dropped.
|
|
3094
|
+
#'
|
|
3095
|
+
#' R checks for a pending interrupt every ~1000 evaluations (eval.c) and at the
|
|
3096
|
+
#' back edge of a compiled loop every 1023; a loop of 1200 trivial iterations is
|
|
3097
|
+
#' therefore certain to raise one if it is there, and costs tens of microseconds
|
|
3098
|
+
#' when it is not. Caught with an EXITING handler, not resumed: a pending
|
|
3099
|
+
#' interrupt raised from the event check is offered without a `resume` restart
|
|
3100
|
+
#' (measured 2026-09-16 — the resuming version of this function reported the
|
|
3101
|
+
#' cell interrupted at the zero-millisecond offset, 7 times in 8), and the loop
|
|
3102
|
+
#' has nothing worth resuming anyway.
|
|
3103
|
+
#' @param id The wire id of the cell about to run.
|
|
3104
|
+
#' @return NULL, invisibly.
|
|
3105
|
+
carmar_drain_stray_interrupt <- function(id) {
|
|
3106
|
+
raised <- tryCatch({
|
|
3107
|
+
n <- 0L
|
|
3108
|
+
for (k in seq_len(1200L)) n <- n + 1L
|
|
3109
|
+
FALSE
|
|
3110
|
+
}, interrupt = function(i) TRUE)
|
|
3111
|
+
if (isTRUE(raised) && carmar_stop_pending(id)) {
|
|
3112
|
+
signalCondition(structure(class = c("interrupt", "condition"),
|
|
3113
|
+
list(message = "Execution interrupted", call = NULL)))
|
|
3114
|
+
}
|
|
3115
|
+
invisible(NULL)
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
#' Turn one line into a command, or NULL for a line that is not one.
|
|
3119
|
+
#'
|
|
3120
|
+
#' Wrapped in the resume handler because this is the window a late Stop signal
|
|
3121
|
+
#' used to escape from: the line is consumed, no cell is running yet, and an
|
|
3122
|
+
#' interrupt raised in `fromJSON` had no handler above it. A command must be an
|
|
3123
|
+
#' object with a string `type`. Anything else — a bare number, an array,
|
|
3124
|
+
#' `{"type":[1,2]}` — used to reach `cmd$type` on an atomic vector and take the
|
|
3125
|
+
#' whole worker down with it, ending the session.
|
|
3126
|
+
#' @param line One line as read_command returned it.
|
|
3127
|
+
#' @return The command list, or NULL.
|
|
3128
|
+
carmar_parse_command <- function(line) {
|
|
3129
|
+
withCallingHandlers({
|
|
3130
|
+
cmd <- tryCatch(jsonlite::fromJSON(line), error = function(e) NULL)
|
|
3131
|
+
if (!is.list(cmd) || !is.character(cmd$type) || length(cmd$type) != 1L) return(NULL)
|
|
3132
|
+
resolve_cmdfile(cmd)
|
|
3133
|
+
}, interrupt = carmar_resume_if_stray)
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
#' Read one command line from stdin, tolerating an interrupt that lands while
|
|
3137
|
+
#' the worker is idle (a Stop pressed with nothing running must not kill R).
|
|
3138
|
+
#'
|
|
3139
|
+
#' In interactive mode this reads through R's CONSOLE (stdin()), not a file
|
|
3140
|
+
#' connection on fd 0 — deliberately. The console is the reader a native
|
|
3141
|
+
#' browser() prompt uses, so dispatch reads and debugger reads share one
|
|
3142
|
+
#' buffer and cannot steal bytes from each other. A SIGINT landing in a
|
|
3143
|
+
#' console read raises an interrupt condition exactly as it does on the file
|
|
3144
|
+
#' connection (measured), so the same handler serves both.
|
|
3145
|
+
#'
|
|
3146
|
+
#' @param con Open text connection on stdin, or stdin() in interactive mode.
|
|
3147
|
+
#' @return A single line, character(0) at EOF, or NA_character_ if interrupted.
|
|
3148
|
+
read_command <- function(con) {
|
|
3149
|
+
# Two interrupts can land here and they must be told apart. The console
|
|
3150
|
+
# reader's own, raised while it waits (no `resume` restart): nothing has been
|
|
3151
|
+
# consumed, and NA sends the loop round again. And a signal that arrived just
|
|
3152
|
+
# as the line was being read, raised only at R's next interrupt check — AFTER
|
|
3153
|
+
# readLines has the line, with `resume` on offer: unwinding there threw the
|
|
3154
|
+
# command away, and the supervisor then waited forever for a reply that could
|
|
3155
|
+
# not come (the 8-hour soak of 2026-09-16). Resuming lets readLines return the
|
|
3156
|
+
# line it has. What no handler here can save is a line the reader itself was
|
|
3157
|
+
# half-way through when its interrupt fired — the supervisor's `ack` deadline
|
|
3158
|
+
# (host/worker-plane.mjs) is the answer to that one.
|
|
3159
|
+
line <- tryCatch(
|
|
3160
|
+
withCallingHandlers(readLines(con, n = 1L, warn = FALSE), interrupt = carmar_resume_if_stray),
|
|
3161
|
+
interrupt = function(i) NA_character_
|
|
3162
|
+
)
|
|
3163
|
+
# Strip the supervisor's comment tag; a line without it (older supervisor,
|
|
3164
|
+
# test harness writing bare NDJSON) passes through untouched.
|
|
3165
|
+
if (length(line) == 1L && !is.na(line) && nzchar(CMD_PREFIX) &&
|
|
3166
|
+
startsWith(line, CMD_PREFIX)) {
|
|
3167
|
+
line <- substring(line, nchar(CMD_PREFIX) + 1L)
|
|
3168
|
+
}
|
|
3169
|
+
line
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
#' Resolve a `cmdfile` stub to the command it spilled.
|
|
3173
|
+
#'
|
|
3174
|
+
#' The interactive console reader wedges on lines past ~40 KB, so the
|
|
3175
|
+
#' supervisor writes oversized commands to a 0600 temp file and sends a stub
|
|
3176
|
+
#' naming it. The file is consumed exactly once and deleted before the
|
|
3177
|
+
#' command runs, success or failure.
|
|
3178
|
+
#'
|
|
3179
|
+
#' @param cmd A parsed command list.
|
|
3180
|
+
#' @return The command to dispatch — `cmd` itself, or the spilled one.
|
|
3181
|
+
resolve_cmdfile <- function(cmd) {
|
|
3182
|
+
if (!identical(cmd$type, "cmdfile")) return(cmd)
|
|
3183
|
+
path <- cmd$path
|
|
3184
|
+
if (!is.character(path) || length(path) != 1L || !file.exists(path)) return(NULL)
|
|
3185
|
+
text <- tryCatch(paste(readLines(path, warn = FALSE, encoding = "UTF-8"),
|
|
3186
|
+
collapse = "\n"),
|
|
3187
|
+
error = function(e) NULL)
|
|
3188
|
+
unlink(path)
|
|
3189
|
+
if (is.null(text)) return(NULL)
|
|
3190
|
+
inner <- tryCatch(jsonlite::fromJSON(text), error = function(e) NULL)
|
|
3191
|
+
if (!is.list(inner) || !is.character(inner$type) || length(inner$type) != 1L) return(NULL)
|
|
3192
|
+
inner
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
`%||%` <- function(a, b) if (is.null(a)) b else a
|
|
3196
|
+
|
|
3197
|
+
# `View(df)` must do what it does in RStudio. Overriding it on the SEARCH PATH
|
|
3198
|
+
# rather than in globalenv keeps the user's environment clean (the Environment
|
|
3199
|
+
# pane shows their objects, not ours) while still shadowing utils::View.
|
|
3200
|
+
carmar_tools <- new.env()
|
|
3201
|
+
# Debug hooks live on the SEARCH PATH, not in this file's private scope, for a
|
|
3202
|
+
# reason of visibility: a trace() tracer evaluates in the traced function's
|
|
3203
|
+
# frame, whose lexical chain ends at globalenv() and then the search path —
|
|
3204
|
+
# the worker's private local() is not on it. Same for expressions typed at a
|
|
3205
|
+
# Browse prompt, which is how .carmar_debug_where is called.
|
|
3206
|
+
carmar_tools$.carmar_debug_entered <- function(file, line, reason = "breakpoint") {
|
|
3207
|
+
# Snapshot FIRST, then drop this function's own frame. Taking sys.calls()
|
|
3208
|
+
# lazily inside the emit arguments would extend the stack through
|
|
3209
|
+
# debug_stack/frame_vars themselves, defeat the machinery trimming, and put
|
|
3210
|
+
# tracer-holding frames back in reach of frame_vars — the recursion bug.
|
|
3211
|
+
calls <- sys.calls()
|
|
3212
|
+
frames <- sys.frames()
|
|
3213
|
+
n <- length(calls)
|
|
3214
|
+
emit(list(type = "debug", event = "paused",
|
|
3215
|
+
file = file, line = as.integer(line), reason = reason,
|
|
3216
|
+
stack = debug_stack(calls[-n], frames[-n]),
|
|
3217
|
+
locals = frame_vars(parent.frame())))
|
|
3218
|
+
invisible(NULL)
|
|
3219
|
+
}
|
|
3220
|
+
carmar_tools$.carmar_debug_where <- function() {
|
|
3221
|
+
calls <- sys.calls()
|
|
3222
|
+
frames <- sys.frames()
|
|
3223
|
+
n <- length(calls)
|
|
3224
|
+
emit(list(type = "debug", event = "where",
|
|
3225
|
+
stack = debug_stack(calls[-n], frames[-n]),
|
|
3226
|
+
locals = frame_vars(parent.frame())))
|
|
3227
|
+
invisible(NULL)
|
|
3228
|
+
}
|
|
3229
|
+
#' Show HTML in the chunk's result — a string of markup, an htmltools tree,
|
|
3230
|
+
#' a gt table, a kable, an htmlwidget. The one verb for "this is HTML":
|
|
3231
|
+
#' Jupyter's display(HTML(...)). Outside a running chunk it prints the markup.
|
|
3232
|
+
#' @param x HTML as a character vector (joined with newlines) or any value
|
|
3233
|
+
#' `rich_html_of()` understands.
|
|
3234
|
+
#' @return Invisibly `x`.
|
|
3235
|
+
carmar_tools$display_html <- function(x) {
|
|
3236
|
+
rich <- if (is.character(x)) {
|
|
3237
|
+
list(x = htmltools::HTML(paste(x, collapse = "\n")), kind = "html")
|
|
3238
|
+
} else {
|
|
3239
|
+
rich_html_of(x)
|
|
3240
|
+
}
|
|
3241
|
+
if (is.null(rich)) {
|
|
3242
|
+
stop("display_html(): not HTML — pass a character string of markup, an htmltools tag, ",
|
|
3243
|
+
"a gt table, a kable(format = \"html\") or an htmlwidget (got ", class(x)[1L], ")",
|
|
3244
|
+
call. = FALSE)
|
|
3245
|
+
}
|
|
3246
|
+
if (is.null(RUN_STATE$id)) { cat(as.character(rich$x), "\n"); return(invisible(x)) }
|
|
3247
|
+
emit_rich(RUN_STATE$id, rich)
|
|
3248
|
+
invisible(x)
|
|
3249
|
+
}
|
|
3250
|
+
carmar_tools$View <- function(x, title = NULL) {
|
|
3251
|
+
# The LABEL is what the user typed; the FETCH NAME is where the viewer reads
|
|
3252
|
+
# it from. Conflating them titled `View(mtcars)` as `.carmar_view`, because
|
|
3253
|
+
# mtcars lives in the datasets package rather than the global environment.
|
|
3254
|
+
label <- if (!is.null(title)) title else deparse(substitute(x))
|
|
3255
|
+
assign(".carmar_view", x, envir = globalenv())
|
|
3256
|
+
emit_view("view-request", ".carmar_view", label = label)
|
|
3257
|
+
invisible(NULL)
|
|
3258
|
+
}
|
|
3259
|
+
attach(carmar_tools, name = "carmar:tools", warn.conflicts = FALSE)
|
|
3260
|
+
|
|
3261
|
+
# Interactive mode reads the console; batch mode opens fd 0 as a connection.
|
|
3262
|
+
# The console read is what lets browser() prompts and the dispatch loop share
|
|
3263
|
+
# one input stream — see read_command.
|
|
3264
|
+
con <- if (identical(WORKER_MODE, "interactive")) stdin()
|
|
3265
|
+
else file("stdin", open = "rt", blocking = TRUE)
|
|
3266
|
+
|
|
3267
|
+
# Die-fast guard for the interactive worker. A batch worker that suffers an
|
|
3268
|
+
# uncaught escape simply ends its script and exits, and the supervisor treats
|
|
3269
|
+
# the dead process as fatal. An interactive worker would instead abort to R's
|
|
3270
|
+
# raw top-level prompt — alive, but with the dispatch loop gone: a zombie the
|
|
3271
|
+
# supervisor cannot tell from a healthy idle worker. This restores parity.
|
|
3272
|
+
# Errors raised AT a Browse prompt also fire this handler, but quit() is a
|
|
3273
|
+
# no-op inside a browser context (measured), so a debug-console typo cannot
|
|
3274
|
+
# kill the session.
|
|
3275
|
+
if (identical(WORKER_MODE, "interactive")) {
|
|
3276
|
+
options(error = function() quit(save = "no", status = 70L))
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
# macOS 26 registers command-line R as a foreground application after Aqua or
|
|
3280
|
+
# AppKit initializes, even when the process was launched by CarmaR's UIElement
|
|
3281
|
+
# helper. The supervisor loads the packaged marker before sourcing this file
|
|
3282
|
+
# (preventing an initial Dock flash); repeat the transition here, after all
|
|
3283
|
+
# worker initialization and immediately before `ready`, so any framework that
|
|
3284
|
+
# promoted R during startup cannot leave this document kernel in the Dock.
|
|
3285
|
+
# Existing workers cannot be changed by a newly installed build; every fresh
|
|
3286
|
+
# or restarted worker reaches this point automatically.
|
|
3287
|
+
# OPT-IN — see macos_background_boot in kernel.R. The background transform hides
|
|
3288
|
+
# the Dock icon but wedges R's console read on a GUI-launched macOS 26 worker,
|
|
3289
|
+
# so it is off unless CARMAR_MARK_BACKGROUND=1 is set.
|
|
3290
|
+
if (identical(unname(Sys.info()[["sysname"]]), "Darwin") &&
|
|
3291
|
+
identical(Sys.getenv("CARMAR_MARK_BACKGROUND", "0"), "1") &&
|
|
3292
|
+
is.loaded("carmar_mark_background")) {
|
|
3293
|
+
invisible(try(.C("carmar_mark_background", result = integer(1)), silent = TRUE))
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
# The ready frame says WHICH R this is. A session that silently uses the wrong
|
|
3297
|
+
# installation looks identical to one using the right one until `library(tna)`
|
|
3298
|
+
# fails — so the home and the library count are reported up front.
|
|
3299
|
+
# The command vocabulary, announced.
|
|
3300
|
+
#
|
|
3301
|
+
# An unknown command is SILENTLY IGNORED by the dispatch loop (no else, no
|
|
3302
|
+
# error), which is the right call for a protocol that must not die on a
|
|
3303
|
+
# stray frame — but it means a client asking an older kernel for a command
|
|
3304
|
+
# it has never heard of waits out its own timeout and then guesses why. The
|
|
3305
|
+
# import wizard's `choose` allowed 320 s for a human at a file dialog, so an
|
|
3306
|
+
# old kernel turned "Import Data…" into five minutes of nothing.
|
|
3307
|
+
#
|
|
3308
|
+
# Advertising the vocabulary lets a client know instantly. Kernels older than
|
|
3309
|
+
# this simply omit the field, and clients fall back to probing.
|
|
3310
|
+
#
|
|
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.
|
|
3318
|
+
emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(),
|
|
3319
|
+
# I(): a single library path must still ship as an array.
|
|
3320
|
+
home = R.home(), libs = I(.libPaths()),
|
|
3321
|
+
# "interactive" means a native browser() can pause this worker — the
|
|
3322
|
+
# debugger exists. "batch" (Windows, or no R binary beside Rscript)
|
|
3323
|
+
# means it cannot, and clients must not offer breakpoints.
|
|
3324
|
+
mode = WORKER_MODE,
|
|
3325
|
+
features = I(c("document_exec_v1", "command_ack_v1")),
|
|
3326
|
+
commands = I(c("exec", "env", "obj", "struct", "parse", "format", "doctor",
|
|
3327
|
+
"complete", "packages", "package_action", "package_help",
|
|
3328
|
+
"project_status", "project_action",
|
|
3329
|
+
"help", "hover", "wd", "files", "sniff",
|
|
3330
|
+
"import", "readfile", "writefile", "writefiles_atomic", "view", "colstats",
|
|
3331
|
+
"mkdir", "renamepath", "deletepath", "copypath", "revealpath",
|
|
3332
|
+
"rm",
|
|
3333
|
+
if (identical(WORKER_MODE, "interactive")) "debug_breaks"))))
|
|
3334
|
+
# (No package count here on purpose: installed.packages() reads every
|
|
3335
|
+
# package's DESCRIPTION — 0.3–1.8 s on a big library — and no client ever
|
|
3336
|
+
# consumed the number. The packages PANE asks the `packages` op on demand.)
|
|
3337
|
+
|
|
3338
|
+
# ── the command loop ─────────────────────────────────────────────────────────
|
|
3339
|
+
#
|
|
3340
|
+
# One iteration is one command: read, parse, acknowledge, dispatch. Every part
|
|
3341
|
+
# of it has an interrupt handler with a meaning — the reader's (idle Stop), the
|
|
3342
|
+
# parser's (a late signal, resumed), the dispatcher's (Stop as the cell's own
|
|
3343
|
+
# outcome) — and the loop around them has the last word: an interrupt that
|
|
3344
|
+
# escapes an iteration ends that iteration, never the loop, and one that escapes
|
|
3345
|
+
# the loop itself re-enters it through the `carmar_serve_again` restart from a
|
|
3346
|
+
# global calling handler. Before 2026-09-16 the parse step had no handler at all,
|
|
3347
|
+
# and a late Stop signal raised there unwound the whole worker script into R's
|
|
3348
|
+
# top-level prompt — where the `options(error=)` guard ended the process, or,
|
|
3349
|
+
# without it, R sat alive and silent behind commands it read as comments.
|
|
3350
|
+
#
|
|
3351
|
+
# Every command is handled in the worker because every one of them needs the
|
|
3352
|
+
# SESSION — the environment, the search path, the working directory. Even
|
|
3353
|
+
# `files` and `import`: import assigns into the environment, and both must
|
|
3354
|
+
# see the same wd and `~` the user's own code sees. The cost is that they
|
|
3355
|
+
# queue behind a running cell, which is also the correct ordering.
|
|
3356
|
+
#
|
|
3357
|
+
# A handler that throws must cost its own command and nothing else: the
|
|
3358
|
+
# session behind it holds the user's unsaved work, and losing it to a
|
|
3359
|
+
# malformed `view` request is not a trade anyone would accept. The failure
|
|
3360
|
+
# comes back as an `error` FIELD on a frame carrying the request's id, so
|
|
3361
|
+
# the caller sees a reason instead of waiting out a timeout.
|
|
3362
|
+
#
|
|
3363
|
+
# `interrupt` IS caught below, but only as a last resort: Stop is delivered
|
|
3364
|
+
# as an interrupt condition and the handlers that can be interrupted (exec,
|
|
3365
|
+
# view) catch it themselves, with meaning. The outer handler exists for the
|
|
3366
|
+
# narrow window where one lands outside those — an uncaught interrupt ends
|
|
3367
|
+
# the worker script, and the supervisor treats a dead worker as fatal.
|
|
3368
|
+
carmar_dispatch <- function(cmd) {
|
|
3369
|
+
if (identical(cmd$type, "exec")) {
|
|
3370
|
+
if (is.null(cmd$document)) run_cell(cmd$id, cmd$source, cmd$dims, cmd$srcname)
|
|
3371
|
+
else run_document_cell(cmd$id, cmd$source, cmd$document, cmd$dims, cmd$srcname)
|
|
3372
|
+
}
|
|
3373
|
+
if (identical(cmd$type, "debug_breaks")) emit_debug_breaks(cmd$id, cmd$file, cmd$lines)
|
|
3374
|
+
if (identical(cmd$type, "env")) emit_env(cmd$id)
|
|
3375
|
+
if (identical(cmd$type, "obj")) emit_obj(cmd$id, cmd$name)
|
|
3376
|
+
if (identical(cmd$type, "struct")) emit_struct(cmd$id, cmd$name, cmd$path)
|
|
3377
|
+
if (identical(cmd$type, "parse")) emit_parse(cmd$id, cmd$source)
|
|
3378
|
+
if (identical(cmd$type, "format")) emit_format(cmd$id, cmd$source)
|
|
3379
|
+
if (identical(cmd$type, "doctor")) emit_doctor(cmd$id)
|
|
3380
|
+
if (identical(cmd$type, "complete")) emit_complete(cmd$id, cmd$line, cmd$cursor,
|
|
3381
|
+
fn = cmd$fn, data = cmd$data)
|
|
3382
|
+
if (identical(cmd$type, "packages")) emit_packages(cmd$id, cmd$scope)
|
|
3383
|
+
if (identical(cmd$type, "package_action")) emit_package_action(cmd$id, cmd$action, cmd$name, cmd$lib)
|
|
3384
|
+
if (identical(cmd$type, "package_help")) emit_package_help(cmd$id, cmd$name)
|
|
3385
|
+
if (identical(cmd$type, "project_status")) emit_project_status(cmd$id)
|
|
3386
|
+
if (identical(cmd$type, "project_action")) emit_project_action(cmd$id, cmd$action)
|
|
3387
|
+
if (identical(cmd$type, "help")) emit_help(cmd$id, cmd$topic)
|
|
3388
|
+
if (identical(cmd$type, "hover")) emit_hover(cmd$id, cmd$name)
|
|
3389
|
+
if (identical(cmd$type, "wd")) emit_wd(cmd$id, cmd$path)
|
|
3390
|
+
if (identical(cmd$type, "files")) emit_files(cmd$id, cmd$path, isTRUE(cmd$all))
|
|
3391
|
+
if (identical(cmd$type, "sniff")) emit_sniff(cmd$id, cmd$path, cmd$opts)
|
|
3392
|
+
if (identical(cmd$type, "import")) emit_import(cmd$id, cmd$path, cmd$name)
|
|
3393
|
+
if (identical(cmd$type, "readfile")) emit_readfile(cmd$id, cmd$path)
|
|
3394
|
+
if (identical(cmd$type, "writefile")) emit_writefile(cmd$id, cmd$path, cmd$text, cmd$expected, cmd$encoding, cmd$base64)
|
|
3395
|
+
if (identical(cmd$type, "writefiles_atomic")) emit_writefiles_atomic(cmd$id, cmd$files)
|
|
3396
|
+
if (identical(cmd$type, "mkdir")) emit_mkdir(cmd$id, cmd$path)
|
|
3397
|
+
if (identical(cmd$type, "renamepath")) emit_renamepath(cmd$id, cmd$path, cmd$to)
|
|
3398
|
+
if (identical(cmd$type, "deletepath")) emit_deletepath(cmd$id, cmd$paths, isTRUE(cmd$recursive))
|
|
3399
|
+
if (identical(cmd$type, "copypath")) emit_copypath(cmd$id, cmd$path, cmd$to)
|
|
3400
|
+
if (identical(cmd$type, "revealpath")) emit_revealpath(cmd$id, cmd$path)
|
|
3401
|
+
# `rows` is the pre-paging spelling of `limit`; old clients keep working.
|
|
3402
|
+
if (identical(cmd$type, "view")) emit_view(cmd$id, cmd$name,
|
|
3403
|
+
offset = cmd$offset,
|
|
3404
|
+
limit = cmd$limit %||% cmd$rows,
|
|
3405
|
+
sort = cmd$sort,
|
|
3406
|
+
desc = isTRUE(cmd$desc),
|
|
3407
|
+
col_offset = cmd$colOffset,
|
|
3408
|
+
col_limit = cmd$colLimit,
|
|
3409
|
+
query = cmd$query,
|
|
3410
|
+
filters = cmd$filters)
|
|
3411
|
+
if (identical(cmd$type, "colstats")) emit_colstats(cmd$id, cmd$name, cmd$column,
|
|
3412
|
+
query = cmd$query,
|
|
3413
|
+
filters = cmd$filters)
|
|
3414
|
+
if (identical(cmd$type, "rm")) emit_rm(cmd$id, cmd$names)
|
|
3415
|
+
invisible(NULL)
|
|
3416
|
+
}
|
|
3417
|
+
# An exec that dies OUTSIDE run_cell's own handlers must still end in a
|
|
3418
|
+
# `done`: the browser settles a cell only on its done frame, and the
|
|
3419
|
+
# supervisor retires the route (and frees the worker queue) on the same
|
|
3420
|
+
# signal — an `{"type":"exec", error}` frame satisfied neither, so one
|
|
3421
|
+
# malformed command wedged every later run behind it, permanently.
|
|
3422
|
+
#
|
|
3423
|
+
# The interrupt handler is the same guarantee for Stop: run_cell catches
|
|
3424
|
+
# interrupts around the eval, but one landing in its epilogue (unlink,
|
|
3425
|
+
# flush, the emit itself) used to escape this loop, end the worker script,
|
|
3426
|
+
# and take the supervisor's event loop down with it.
|
|
3427
|
+
carmar_fail_frame <- function(cmd, text, outcome = "error") {
|
|
3428
|
+
id_ok <- is.character(cmd$id) && length(cmd$id) == 1L
|
|
3429
|
+
if (identical(cmd$type, "exec")) {
|
|
3430
|
+
if (id_ok) emit(list(type = "done", id = cmd$id, status = outcome,
|
|
3431
|
+
message = text))
|
|
3432
|
+
} else {
|
|
3433
|
+
emit(list(type = cmd$type, id = if (id_ok) cmd$id else NULL,
|
|
3434
|
+
error = text))
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
#' Serve one command. Returns "eof" when the supervisor has gone, "shutdown"
|
|
3439
|
+
#' when it said so, NULL after a command or a line that was not one.
|
|
3440
|
+
#' @param con The command connection (the console in interactive mode).
|
|
3441
|
+
carmar_serve_one <- function(con) {
|
|
3442
|
+
line <- read_command(con)
|
|
3443
|
+
if (length(line) == 0L) return("eof") # EOF: supervisor went away
|
|
3444
|
+
if (is.na(line) || !nzchar(trimws(line))) return(NULL)
|
|
3445
|
+
cmd <- carmar_parse_command(line)
|
|
3446
|
+
if (is.null(cmd)) return(NULL)
|
|
3447
|
+
if (identical(cmd$type, "shutdown")) return("shutdown")
|
|
3448
|
+
# The acknowledgement: the supervisor wrote this command to an idle worker and
|
|
3449
|
+
# can time the reply to this frame. A command line the console reader was
|
|
3450
|
+
# half-way through when a late signal landed is torn beyond what any handler
|
|
3451
|
+
# here can mend (readline discards the half it had; the rest arrives as a
|
|
3452
|
+
# line that is not a command), and without this frame the supervisor could
|
|
3453
|
+
# only wait forever — an exec has no deadline, by design. With it, a command
|
|
3454
|
+
# that is never acknowledged is failed with a sentence and the queue moves on.
|
|
3455
|
+
if (is.character(cmd$id) && length(cmd$id) == 1L && !is.na(cmd$id)) emit(list(type = "ack", id = cmd$id))
|
|
3456
|
+
tryCatch(carmar_dispatch(cmd),
|
|
3457
|
+
error = function(e) carmar_fail_frame(cmd, paste("command failed:", conditionMessage(e))),
|
|
3458
|
+
# Cleanup interrupts mean the same thing as interrupts during eval.
|
|
3459
|
+
interrupt = function(i) carmar_fail_frame(cmd, "Execution interrupted", "interrupted"))
|
|
3460
|
+
NULL
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
#' The loop. An interrupt that escapes an iteration's own handlers costs that
|
|
3464
|
+
#' iteration and nothing else.
|
|
3465
|
+
#' @param con The command connection.
|
|
3466
|
+
#' @return "eof" or "shutdown", invisibly.
|
|
3467
|
+
carmar_serve <- function(con) {
|
|
3468
|
+
repeat {
|
|
3469
|
+
outcome <- tryCatch(carmar_serve_one(con), interrupt = function(i) NULL)
|
|
3470
|
+
if (identical(outcome, "eof") || identical(outcome, "shutdown")) return(invisible(outcome))
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
# The last word. A global calling handler runs only for an interrupt no handler
|
|
3475
|
+
# above it took (R walks the stack from the innermost handler outwards and stops
|
|
3476
|
+
# at the first exiting one), so it is reached only from the few evaluations the
|
|
3477
|
+
# loop spends between two iterations — and from there it re-enters the loop,
|
|
3478
|
+
# with the session intact. With no loop to re-enter (boot, shutdown) it ends
|
|
3479
|
+
# the process, as the `options(error=)` guard does for an error: a worker at
|
|
3480
|
+
# R's top-level prompt is a zombie the supervisor cannot tell from an idle one.
|
|
3481
|
+
globalCallingHandlers(interrupt = function(i) {
|
|
3482
|
+
if (!is.null(findRestart("carmar_serve_again"))) invokeRestart("carmar_serve_again")
|
|
3483
|
+
quit(save = "no", status = 70L)
|
|
3484
|
+
})
|
|
3485
|
+
repeat {
|
|
3486
|
+
outcome <- withRestarts(carmar_serve(con), carmar_serve_again = function() NULL)
|
|
3487
|
+
if (!is.null(outcome)) break
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
# stdin() is R's console, not ours to close. And an interactive R does not
|
|
3491
|
+
# exit when the script does — it would sit at the top-level prompt waiting for
|
|
3492
|
+
# input until the supervisor's grace timeout killed it — so the shutdown that
|
|
3493
|
+
# broke the loop ends the process here, explicitly.
|
|
3494
|
+
if (identical(WORKER_MODE, "interactive")) quit(save = "no") else close(con)
|
|
3495
|
+
|
|
3496
|
+
})
|