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/kernel.R
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Kernel — owns the R worker process. Deliberately knows nothing about HTTP.
|
|
3
|
+
#
|
|
4
|
+
# The whole point of the two-process split: this code never evaluates user
|
|
5
|
+
# source, so it stays responsive while the worker is blocked inside a 40-second
|
|
6
|
+
# bootstrap. Interrupting is then a signal to a process, not a message the
|
|
7
|
+
# blocked process has to notice.
|
|
8
|
+
|
|
9
|
+
suppressPackageStartupMessages(library(jsonlite))
|
|
10
|
+
|
|
11
|
+
#' Which R should host the session?
|
|
12
|
+
#'
|
|
13
|
+
#' Not "whatever `Rscript` is first on PATH" — that is how this shipped running
|
|
14
|
+
#' Homebrew's R 4.6.1 with 176 packages while the user's own library (the CRAN
|
|
15
|
+
#' framework build RStudio uses, 1241 packages including tna and TraMineR) sat
|
|
16
|
+
#' unused. An IDE that cannot see your packages is not your IDE.
|
|
17
|
+
#'
|
|
18
|
+
#' Order: an explicit `CARMAR_RSCRIPT`, then the macOS framework R, then PATH.
|
|
19
|
+
#'
|
|
20
|
+
#' @return Path to an Rscript binary.
|
|
21
|
+
#' `explicit` is an argument rather than only an env read so serve.R can pass
|
|
22
|
+
#' the RESOLVED value (a user's rscript_path from settings, or the env var when
|
|
23
|
+
#' a deployment set one). Defaulting to the env var keeps every existing call
|
|
24
|
+
#' correct without changing it.
|
|
25
|
+
detect_rscript <- function(explicit = Sys.getenv("CARMAR_RSCRIPT", "")) {
|
|
26
|
+
if (nzchar(explicit) && file.exists(explicit)) return(explicit)
|
|
27
|
+
# The same ladder tools/app/launch.sh walks, and for the same reason: this
|
|
28
|
+
# process may itself have been started by a Finder launch, whose PATH is
|
|
29
|
+
# /usr/bin:/bin:/usr/sbin:/sbin — so Sys.which() finds a CRAN framework
|
|
30
|
+
# install and misses Homebrew, rig, conda and Posit entirely. PATH is the
|
|
31
|
+
# LAST rung, not the second.
|
|
32
|
+
known <- c(
|
|
33
|
+
"/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
|
|
34
|
+
"/opt/homebrew/bin/Rscript",
|
|
35
|
+
"/usr/local/bin/Rscript",
|
|
36
|
+
"/opt/local/bin/Rscript",
|
|
37
|
+
"/usr/bin/Rscript"
|
|
38
|
+
)
|
|
39
|
+
for (cand in known) if (file.exists(cand)) return(cand)
|
|
40
|
+
# `Versions/Current` is a symlink CRAN maintains and other installers do
|
|
41
|
+
# not; newest version first.
|
|
42
|
+
versioned <- sort(Sys.glob(
|
|
43
|
+
"/Library/Frameworks/R.framework/Versions/*/Resources/bin/Rscript"), decreasing = TRUE)
|
|
44
|
+
if (length(versioned)) return(versioned[[1]])
|
|
45
|
+
unname(Sys.which("Rscript"))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#' The full R binary beside an Rscript, for the interactive worker.
|
|
49
|
+
#'
|
|
50
|
+
#' The debugger needs `R --interactive`: only then is the CONSOLE the stdin
|
|
51
|
+
#' pipe, which is what lets a native `browser()` prompt read step commands the
|
|
52
|
+
#' supervisor sends. `Rscript` cannot do this — under it the console is the
|
|
53
|
+
#' script FILE, and a `browser()` reads the script's own remaining lines as
|
|
54
|
+
#' debug input (measured: it consumed the lines after the call and never
|
|
55
|
+
#' touched stdin).
|
|
56
|
+
#'
|
|
57
|
+
#' @param rscript Path to the Rscript binary actually chosen.
|
|
58
|
+
#' @return Path to the sibling R binary, or "" when it does not exist.
|
|
59
|
+
detect_r_binary <- function(rscript) {
|
|
60
|
+
candidate <- file.path(dirname(rscript),
|
|
61
|
+
if (.Platform$OS.type == "windows") "R.exe" else "R")
|
|
62
|
+
if (nzchar(rscript) && file.exists(candidate)) candidate else ""
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#' The first expression an interactive worker runs on macOS.
|
|
66
|
+
#'
|
|
67
|
+
#' Command-line R promotes itself to a foreground LaunchServices application
|
|
68
|
+
#' when its Aqua/AppKit support initializes. On macOS 26 that gives every
|
|
69
|
+
#' document worker a generic `exec` Dock tile. A packaged CarmaR kernel carries
|
|
70
|
+
#' a tiny native library beside kernel.R; calling it before worker.R is
|
|
71
|
+
#' sourced registers this process as BackgroundOnly first. Distributions may
|
|
72
|
+
#' carry either an architecture-specific marker (R packages) or one universal
|
|
73
|
+
#' marker (application bundles and source checkouts). The process itself
|
|
74
|
+
#' must make the transition — a UIElement parent does not confer its activation
|
|
75
|
+
#' policy on an exec'd child.
|
|
76
|
+
#'
|
|
77
|
+
#' Source checkouts and non-macOS packages may not carry the compiled library;
|
|
78
|
+
#' in those cases this is deliberately a no-op and kernel startup is unchanged.
|
|
79
|
+
#'
|
|
80
|
+
#' @param worker_path Path to the worker boot script.
|
|
81
|
+
#' @param sysname Injectable platform name for source-only tests.
|
|
82
|
+
#' @param arch Injectable R process architecture for source-only tests.
|
|
83
|
+
#' @return A one-line R expression, or "" when no marker is available.
|
|
84
|
+
macos_background_boot <- function(worker_path,
|
|
85
|
+
sysname = unname(Sys.info()[["sysname"]]),
|
|
86
|
+
arch = R.version$arch) {
|
|
87
|
+
if (!identical(sysname, "Darwin")) return("")
|
|
88
|
+
# OPT-IN, and defaulting off is a reliability decision. TransformProcessType
|
|
89
|
+
# hides the worker's Dock icon, but on a GUI-launched process (macOS 26) it
|
|
90
|
+
# leaves R's console/event handling in a state where a plain readLines(stdin)
|
|
91
|
+
# — every single command the supervisor sends — can block forever in
|
|
92
|
+
# R_checkActivityEx, wedging the whole session at 0% CPU with Stop unable to
|
|
93
|
+
# reach it. Foreground R, which is what RStudio runs, never does this. So a
|
|
94
|
+
# visible Dock icon is the price of a kernel that answers; set
|
|
95
|
+
# CARMAR_MARK_BACKGROUND=1 to restore Dock hiding once a transform that does
|
|
96
|
+
# not wedge the console read is found.
|
|
97
|
+
if (!identical(Sys.getenv("CARMAR_MARK_BACKGROUND", "0"), "1")) return("")
|
|
98
|
+
marker <- Sys.getenv("CARMAR_BACKGROUND_LIBRARY", "")
|
|
99
|
+
if (!nzchar(marker)) {
|
|
100
|
+
suffix <- if (grepl("^(aarch64|arm64)", arch, ignore.case = TRUE)) {
|
|
101
|
+
"arm64"
|
|
102
|
+
} else if (grepl("x86_64|x86-64|amd64", arch, ignore.case = TRUE)) {
|
|
103
|
+
"x86_64"
|
|
104
|
+
} else ""
|
|
105
|
+
beside <- dirname(worker_path)
|
|
106
|
+
candidates <- c(
|
|
107
|
+
if (nzchar(suffix)) file.path(
|
|
108
|
+
beside, paste0("carmar-background-", suffix, ".dylib")),
|
|
109
|
+
file.path(beside, "carmar-background.dylib"))
|
|
110
|
+
found <- candidates[file.exists(candidates)]
|
|
111
|
+
marker <- if (length(found)) found[[1L]] else candidates[[1L]]
|
|
112
|
+
}
|
|
113
|
+
if (!file.exists(marker)) return("")
|
|
114
|
+
marker <- encodeString(normalizePath(marker, mustWork = TRUE), quote = '"')
|
|
115
|
+
# TransformProcessType can report paramErr when the unbundled executable is
|
|
116
|
+
# already background-only; the call still causes the early LaunchServices
|
|
117
|
+
# registration we need. Classification, not that legacy status code, is the
|
|
118
|
+
# contract, so the result is intentionally ignored.
|
|
119
|
+
sprintf('local({dyn.load(%s);.C("carmar_mark_background",result=integer(1));invisible(NULL)})',
|
|
120
|
+
marker)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#' Start a worker process.
|
|
124
|
+
#'
|
|
125
|
+
#' @param worker_path Path to worker.R.
|
|
126
|
+
#' @param sentinel Random per-session token framing control lines. Generated
|
|
127
|
+
#' if absent; user code cannot guess it, so it cannot forge a frame.
|
|
128
|
+
#' @return A kernel handle: list(proc, sentinel, buffer).
|
|
129
|
+
#' @param env_extra Named character vector of environment variables to add for
|
|
130
|
+
#' the worker — e.g. `c(CARMAR_ROOT = "/srv/project")`, which confines every
|
|
131
|
+
#' file command to that subtree. Managed deployments set it; tests set it to
|
|
132
|
+
#' prove the confinement holds.
|
|
133
|
+
#' A UTF-8 character locale, whatever the launcher handed us.
|
|
134
|
+
#'
|
|
135
|
+
#' The desktop app is started by launchd, which supplies no locale at all, so
|
|
136
|
+
#' R falls back to C — and a C locale is not "English", it is "no character
|
|
137
|
+
#' encoding". In that state jsonlite escapes every high byte of a string whose
|
|
138
|
+
#' declared encoding is unknown, so `35 – 1158` reaches the browser as
|
|
139
|
+
#' `35 <e2><80><93> 1158`, a column called `région` mangles the same way, and R
|
|
140
|
+
#' source containing a non-ASCII identifier does not even parse.
|
|
141
|
+
#'
|
|
142
|
+
#' Both ENDS need this. The supervisor calls it for itself (it parses and
|
|
143
|
+
#' re-encodes every frame on the wire); `kernel_start` puts it in each child's
|
|
144
|
+
#' environment. Fixing only one end fixes nothing: the mangling simply moves.
|
|
145
|
+
#'
|
|
146
|
+
#' Surgical: LC_CTYPE alone, so the user's own locale keeps deciding number and
|
|
147
|
+
#' date formatting, and only when what we have is not already UTF-8 — a
|
|
148
|
+
#' deliberate de_DE.UTF-8 is never overridden.
|
|
149
|
+
#'
|
|
150
|
+
#' @return The locale name now in force for LC_CTYPE.
|
|
151
|
+
utf8_ctype <- function() {
|
|
152
|
+
current <- Sys.getlocale("LC_CTYPE")
|
|
153
|
+
if (grepl("utf-?8", current, ignore.case = TRUE)) return(current)
|
|
154
|
+
for (candidate in c("C.UTF-8", "en_US.UTF-8", "UTF-8")) {
|
|
155
|
+
if (nzchar(suppressWarnings(Sys.setlocale("LC_CTYPE", candidate)))) return(candidate)
|
|
156
|
+
}
|
|
157
|
+
current # nothing available: carry on as-is
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(),
|
|
161
|
+
env_extra = character(), interactive = FALSE) {
|
|
162
|
+
stopifnot(file.exists(worker_path), nzchar(rscript))
|
|
163
|
+
if (is.null(sentinel)) {
|
|
164
|
+
sentinel <- paste(sample(c(letters, 0:9), 24L, replace = TRUE), collapse = "")
|
|
165
|
+
}
|
|
166
|
+
# An `--interactive` R is (Unix only) per the R manual, and it is only worth
|
|
167
|
+
# having when the sibling R binary exists. Falling back to batch keeps every
|
|
168
|
+
# session working; only the debugger is absent, and the worker's ready frame
|
|
169
|
+
# says which mode it booted in so nothing has to guess.
|
|
170
|
+
r_binary <- if (interactive && .Platform$OS.type != "windows")
|
|
171
|
+
detect_r_binary(rscript) else ""
|
|
172
|
+
mode <- if (nzchar(r_binary)) "interactive" else "batch"
|
|
173
|
+
# A SECOND token, deliberately not the sentinel. Commands sent to an
|
|
174
|
+
# interactive worker are echoed back on stdout by R's console reader, so
|
|
175
|
+
# they must be recognizable for scrubbing — but if they carried the frame
|
|
176
|
+
# sentinel, their echo would parse as a control frame FROM the worker. Two
|
|
177
|
+
# independent tokens make the two directions structurally non-confusable.
|
|
178
|
+
cmdtag <- paste(sample(c(letters, 0:9), 24L, replace = TRUE), collapse = "")
|
|
179
|
+
# The supervisor is itself an R process, so its R_HOME / R_LIBS* point at the
|
|
180
|
+
# R that launched it. Inheriting those into a DIFFERENT R installation aims
|
|
181
|
+
# the child at the wrong tree and it never starts. Strip them and let the
|
|
182
|
+
# chosen binary discover its own.
|
|
183
|
+
parent_env <- Sys.getenv()
|
|
184
|
+
clean_env <- parent_env[!grepl("^R_(HOME|LIBS|LIBS_USER|LIBS_SITE|PROFILE|ENVIRON|DOC_DIR|INCLUDE_DIR|SHARE_DIR)",
|
|
185
|
+
names(parent_env))]
|
|
186
|
+
# ── the child must speak UTF-8, whatever launched the parent ────────────
|
|
187
|
+
# The desktop launcher inherits launchd's C locale, and a child R in a C
|
|
188
|
+
# locale is not merely English — it cannot represent non-ASCII at all:
|
|
189
|
+
#
|
|
190
|
+
# * a string literal in worker.R with an en dash parses to encoding
|
|
191
|
+
# "unknown", and jsonlite serialises it as `35 <e2><80><93> 1158`, which
|
|
192
|
+
# is what the variables panel showed;
|
|
193
|
+
# * a data frame with a column called `région` mangles the same way;
|
|
194
|
+
# * a source file containing a non-ASCII IDENTIFIER does not even parse.
|
|
195
|
+
#
|
|
196
|
+
# `encoding = "UTF-8"` on the pipes below fixes only the supervisor's READING
|
|
197
|
+
# of the wire. This fixes the child's PRODUCING of it, which is the half that
|
|
198
|
+
# was missing.
|
|
199
|
+
#
|
|
200
|
+
# Surgical on purpose: only LC_CTYPE (the character encoding), so a user's
|
|
201
|
+
# own locale keeps deciding number and date formatting; only when the
|
|
202
|
+
# inherited locale is not already UTF-8, so a deliberate de_DE.UTF-8 is never
|
|
203
|
+
# overridden; and LC_ALL is DROPPED when it is the non-UTF-8 culprit, because
|
|
204
|
+
# LC_ALL outranks LC_CTYPE and would otherwise win silently.
|
|
205
|
+
utf8_locale <- function(x) grepl("utf-?8", x, ignore.case = TRUE)
|
|
206
|
+
# `Sys.getenv()` is a named CHARACTER VECTOR, and `x[["missing"]]` on one
|
|
207
|
+
# throws "subscript out of bounds" — it does not return NULL the way a list
|
|
208
|
+
# does. LC_ALL is unset on most machines, so reading it directly errored on
|
|
209
|
+
# the common path and took the kernel down at startup.
|
|
210
|
+
from_env <- function(name) if (name %in% names(clean_env)) clean_env[[name]] else ""
|
|
211
|
+
lc_all <- from_env("LC_ALL")
|
|
212
|
+
# Decide from what the CHILD inherits - its ENVIRONMENT - never from
|
|
213
|
+
# Sys.getlocale(), which is the SUPERVISOR's RUNTIME locale. macOS boots a
|
|
214
|
+
# GUI-launched supervisor with a runtime C.UTF-8 that is NEVER in the env, so
|
|
215
|
+
# it never reaches the child; the child sees only these variables, and with
|
|
216
|
+
# LC_ALL/LC_CTYPE/LANG all unset it falls back to bare C. There a single
|
|
217
|
+
# multi-byte character in a command line (an en dash in a comment such as
|
|
218
|
+
# `orders 0-5`, an accent, an emoji) wedges the interactive console reader and
|
|
219
|
+
# freezes the kernel. Reading the runtime locale here let the guard skip in
|
|
220
|
+
# exactly that case, which is why the packaged app hung on the first non-ASCII
|
|
221
|
+
# chunk while a shell or RStudio (both of which export a UTF-8 locale) did not.
|
|
222
|
+
effective <- if (nzchar(lc_all)) lc_all
|
|
223
|
+
else if (nzchar(from_env("LC_CTYPE"))) from_env("LC_CTYPE")
|
|
224
|
+
else from_env("LANG")
|
|
225
|
+
if (!utf8_locale(effective)) {
|
|
226
|
+
# LC_ALL outranks LC_CTYPE, so a non-UTF-8 LC_ALL would win silently.
|
|
227
|
+
if (nzchar(lc_all) && !utf8_locale(lc_all)) {
|
|
228
|
+
clean_env <- clean_env[names(clean_env) != "LC_ALL"]
|
|
229
|
+
}
|
|
230
|
+
clean_env[["LC_CTYPE"]] <- utf8_ctype()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (length(env_extra)) {
|
|
234
|
+
stopifnot(is.character(env_extra), !is.null(names(env_extra)))
|
|
235
|
+
clean_env <- c(clean_env[setdiff(names(clean_env), names(env_extra))], env_extra)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (identical(mode, "interactive")) {
|
|
239
|
+
# TERM=dumb: readline's terminal probe writes an escape sequence
|
|
240
|
+
# ("\033[?1034h") to stdout before anything else, and a dumb terminal is
|
|
241
|
+
# the documented way to keep it out of the protocol stream.
|
|
242
|
+
clean_env[["TERM"]] <- "dumb"
|
|
243
|
+
# The worker cannot read argv for these — `R --interactive` takes no
|
|
244
|
+
# script argument, the worker is booted by a sys.source line fed through
|
|
245
|
+
# stdin below — so they travel in the environment instead.
|
|
246
|
+
clean_env[["CARMAR_SENTINEL"]] <- sentinel
|
|
247
|
+
clean_env[["CARMAR_CMD_TAG"]] <- cmdtag
|
|
248
|
+
clean_env[["CARMAR_WORKER_MODE"]] <- "interactive"
|
|
249
|
+
clean_env[["CARMAR_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
|
|
250
|
+
}
|
|
251
|
+
spawn_bin <- if (identical(mode, "interactive")) r_binary else rscript
|
|
252
|
+
spawn_args <- if (identical(mode, "interactive")) {
|
|
253
|
+
# --no-echo suppresses the "> " prompt; the input ECHO it does not suppress
|
|
254
|
+
# is scrubbed in kernel_poll by the cmdtag / pending-echo machinery.
|
|
255
|
+
# --no-readline FIRST: R ignores it after --interactive, and with readline
|
|
256
|
+
# R echoes each line read — on Linux as a scrolled "<" + tail that carries no
|
|
257
|
+
# cmdtag (host/engine-r.mjs has the measurement).
|
|
258
|
+
c("--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file")
|
|
259
|
+
} else {
|
|
260
|
+
# --vanilla, NOT --no-init-file: the user's .Rprofile/.Renviron are part of
|
|
261
|
+
# their R (library paths, repos, options). Only history and saved workspaces
|
|
262
|
+
# are suppressed, which is what a fresh session wants.
|
|
263
|
+
c("--no-save", "--no-restore", "--no-site-file", worker_path, sentinel)
|
|
264
|
+
}
|
|
265
|
+
proc <- processx::process$new(
|
|
266
|
+
spawn_bin,
|
|
267
|
+
spawn_args,
|
|
268
|
+
stdin = "|", stdout = "|", stderr = "|",
|
|
269
|
+
env = clean_env,
|
|
270
|
+
supervise = TRUE,
|
|
271
|
+
# The desktop launcher may itself inherit the C locale from launchd.
|
|
272
|
+
# Decode the protocol pipes by their actual wire encoding instead of the
|
|
273
|
+
# supervisor's locale, or non-ASCII output can be escaped before framing.
|
|
274
|
+
encoding = "UTF-8"
|
|
275
|
+
)
|
|
276
|
+
# processx's line reader waits for a complete line before releasing it. A
|
|
277
|
+
# large JSON view/plot frame can therefore sit in its internal buffer long
|
|
278
|
+
# enough to starve the supervisor. Keep our own incremental framing state
|
|
279
|
+
# in an environment so it remains mutable through R's copied list handle.
|
|
280
|
+
io <- new.env(parent = emptyenv())
|
|
281
|
+
io$out <- ""
|
|
282
|
+
io$err <- ""
|
|
283
|
+
k <- list(proc = proc, sentinel = sentinel, rscript = rscript, io = io,
|
|
284
|
+
mode = mode, cmdtag = cmdtag)
|
|
285
|
+
if (identical(mode, "interactive")) {
|
|
286
|
+
# The boot line replaces worker-boot.R: same sys.source, same speed
|
|
287
|
+
# rationale (parse the ~2,000-line file in one pass instead of feeding it
|
|
288
|
+
# through the REPL reader). worker.R itself is sourced, not the shim —
|
|
289
|
+
# the shim's only job was resolving its own directory from --file=,
|
|
290
|
+
# which CARMAR_WORKER_DIR now carries.
|
|
291
|
+
real_worker <- file.path(dirname(worker_path), "worker.R")
|
|
292
|
+
if (!file.exists(real_worker)) real_worker <- worker_path
|
|
293
|
+
worker_boot <- sprintf('sys.source("%s", envir = globalenv(), keep.source = FALSE)',
|
|
294
|
+
encodeString(normalizePath(real_worker)))
|
|
295
|
+
boot <- paste(Filter(nzchar, c(macos_background_boot(worker_path), worker_boot)),
|
|
296
|
+
collapse = ";")
|
|
297
|
+
kernel_console(k, boot)
|
|
298
|
+
}
|
|
299
|
+
k
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#' Send one RAW console line to an interactive worker.
|
|
303
|
+
#'
|
|
304
|
+
#' This is the debugger's channel: while the worker is paused at a Browse
|
|
305
|
+
#' prompt, `n` / `s` / `f` / `c` and debug-console expressions are ordinary
|
|
306
|
+
#' console lines, not NDJSON. Measured under processx pipes, R does NOT echo
|
|
307
|
+
#' console input back (it does under a shell pipe with a controlling tty —
|
|
308
|
+
#' which is only the test bench, never the shipped spawn), so nothing here
|
|
309
|
+
#' needs scrubbing; the tagged-command scrub in kernel_poll stays as the
|
|
310
|
+
#' belt for the high-volume NDJSON path.
|
|
311
|
+
#'
|
|
312
|
+
#' @param k Kernel handle (mode "interactive").
|
|
313
|
+
#' @param line One line of text, no newline.
|
|
314
|
+
#' @return Invisibly TRUE.
|
|
315
|
+
kernel_console <- function(k, line) {
|
|
316
|
+
stopifnot(identical(k$mode, "interactive"),
|
|
317
|
+
is.character(line), length(line) == 1L, !grepl("\n", line, fixed = TRUE))
|
|
318
|
+
kernel_write(k, paste0(line, "\n"))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#' Send a cell to the worker.
|
|
322
|
+
#'
|
|
323
|
+
#' @param k Kernel handle.
|
|
324
|
+
#' @param id Cell id.
|
|
325
|
+
#' @param source R source text.
|
|
326
|
+
#' @param dims Optional list(width, height, res) in pixels/dpi for this run's
|
|
327
|
+
#' graphics device — the equivalent of a chunk's `fig.width`/`fig.height`.
|
|
328
|
+
#' One hardcoded size is never right for every plot: a seqplot with a
|
|
329
|
+
#' six-column legend needs a different device than a scatter.
|
|
330
|
+
#' @return Invisibly TRUE.
|
|
331
|
+
kernel_exec <- function(k, id, source, dims = NULL) {
|
|
332
|
+
stopifnot(is.character(id), is.character(source))
|
|
333
|
+
cmd <- list(type = "exec", id = id, source = source)
|
|
334
|
+
if (!is.null(dims)) cmd$dims <- dims
|
|
335
|
+
kernel_command(k, toJSON(cmd, auto_unbox = TRUE))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
# R's interactive console reader wedges the whole process on input lines
|
|
339
|
+
# somewhere past 40,000 bytes (measured: 40,000 passed, 45,000 hung, and the
|
|
340
|
+
# hang is a hang, not an error). Anything bigger travels through a file
|
|
341
|
+
# instead. Batch workers have no such limit and never spill.
|
|
342
|
+
MAX_CONSOLE_LINE <- 32000L
|
|
343
|
+
|
|
344
|
+
#' Deliver one JSON command to the worker, however it must travel.
|
|
345
|
+
#'
|
|
346
|
+
#' Batch mode: the bare NDJSON line, exactly as always. Interactive mode: the
|
|
347
|
+
#' line is prefixed `#<cmdtag> ` — a comment, so the one failure mode where it
|
|
348
|
+
#' could reach R's raw top level (a catastrophic dispatch-loop abort) makes it
|
|
349
|
+
#' inert instead of evaluated, and so its console echo is self-identifying for
|
|
350
|
+
#' the scrubber in kernel_poll. A command too long for the console reader is
|
|
351
|
+
#' written to a 0600 temp file and replaced on the wire by a `cmdfile` stub;
|
|
352
|
+
#' the worker reads the file, deletes it, and dispatches its content.
|
|
353
|
+
#'
|
|
354
|
+
#' @param k Kernel handle.
|
|
355
|
+
#' @param json One complete JSON command, as text, no newline.
|
|
356
|
+
#' @return Invisibly TRUE.
|
|
357
|
+
kernel_command <- function(k, json) {
|
|
358
|
+
if (!identical(k$mode, "interactive")) {
|
|
359
|
+
return(kernel_write(k, paste0(json, "\n")))
|
|
360
|
+
}
|
|
361
|
+
if (nchar(json, type = "bytes") > MAX_CONSOLE_LINE) {
|
|
362
|
+
spill <- tempfile("carmar-cmd-", fileext = ".json")
|
|
363
|
+
writeLines(json, spill, useBytes = TRUE)
|
|
364
|
+
Sys.chmod(spill, mode = "0600")
|
|
365
|
+
json <- toJSON(list(type = "cmdfile", path = spill), auto_unbox = TRUE)
|
|
366
|
+
}
|
|
367
|
+
kernel_write(k, paste0("#", k$cmdtag, " ", json, "\n"))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#' Write a whole command to the worker's stdin, however long it is.
|
|
371
|
+
#'
|
|
372
|
+
#' `processx::write_input()` is a NON-BLOCKING write: it puts what fits in the
|
|
373
|
+
#' operating system's pipe buffer and RETURNS THE REST as a raw vector. Ignoring
|
|
374
|
+
#' that return value silently truncated every command larger than the buffer —
|
|
375
|
+
#' the worker then waited forever for the end of a line that was never sent, and
|
|
376
|
+
#' the browser waited for an answer that never came. Anything small worked, so
|
|
377
|
+
#' the failure looked like a size-dependent hang rather than a dropped write:
|
|
378
|
+
#' `writefile` refused a 50 KB script, and a knitted report carrying one plot
|
|
379
|
+
#' never reached R at all.
|
|
380
|
+
#'
|
|
381
|
+
#' The loop is bounded. A worker busy inside a long-running cell does not drain
|
|
382
|
+
#' its stdin, so a command sent at that moment can genuinely have nowhere to go;
|
|
383
|
+
#' after `timeout` seconds this says so instead of blocking the supervisor's
|
|
384
|
+
#' event loop for the rest of the session.
|
|
385
|
+
#'
|
|
386
|
+
#' @param k Kernel handle.
|
|
387
|
+
#' @param text One complete command line, newline-terminated.
|
|
388
|
+
#' @param timeout Seconds to keep trying before giving up.
|
|
389
|
+
#' @return Invisibly TRUE.
|
|
390
|
+
kernel_write <- function(k, text, timeout = 15) {
|
|
391
|
+
left <- k$proc$write_input(text)
|
|
392
|
+
deadline <- Sys.time() + timeout
|
|
393
|
+
while (length(left) > 0L) {
|
|
394
|
+
if (Sys.time() > deadline) {
|
|
395
|
+
stop(sprintf("R is not reading its input — %d bytes of this command could not be sent.",
|
|
396
|
+
length(left)), call. = FALSE)
|
|
397
|
+
}
|
|
398
|
+
Sys.sleep(0.01)
|
|
399
|
+
left <- k$proc$write_input(left)
|
|
400
|
+
}
|
|
401
|
+
invisible(TRUE)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#' Send any command to the worker.
|
|
405
|
+
#'
|
|
406
|
+
#' `kernel_exec` is this with type "exec"; the IDE's panes (environment,
|
|
407
|
+
#' packages, help, working directory, parse-completeness) all ride the same
|
|
408
|
+
#' channel, so there is one queue and one ordering.
|
|
409
|
+
#'
|
|
410
|
+
#' @param k Kernel handle.
|
|
411
|
+
#' @param cmd Named list with at least `type` and `id`.
|
|
412
|
+
#' @return Invisibly TRUE.
|
|
413
|
+
kernel_send <- function(k, cmd) {
|
|
414
|
+
stopifnot(is.list(cmd), !is.null(cmd$type))
|
|
415
|
+
kernel_command(k, toJSON(cmd, auto_unbox = TRUE))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#' Send a raw line to the worker, bypassing JSON encoding.
|
|
419
|
+
#'
|
|
420
|
+
#' Exists for the security suite: the frames worth testing are exactly the ones
|
|
421
|
+
#' `toJSON` cannot produce — a bare number, an array, `{"type":5}`. A worker
|
|
422
|
+
#' that dies on those takes the user's session with it, so they have to be
|
|
423
|
+
#' sendable.
|
|
424
|
+
#'
|
|
425
|
+
#' @param k Kernel handle.
|
|
426
|
+
#' @param line One line of text; a newline is appended if absent.
|
|
427
|
+
#' @return Invisibly TRUE.
|
|
428
|
+
kernel_send_raw <- function(k, line) {
|
|
429
|
+
stopifnot(is.character(line), length(line) == 1L)
|
|
430
|
+
# Interactive mode still tags the line: the point of these tests is what the
|
|
431
|
+
# worker's PARSER does with malformed JSON, and the tag is what routes the
|
|
432
|
+
# line to that parser (and keeps its echo scrubbable) rather than leaving it
|
|
433
|
+
# to be misread as console input.
|
|
434
|
+
kernel_command(k, sub("\n$", "", line))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#' Interrupt whatever the worker is doing.
|
|
438
|
+
#'
|
|
439
|
+
#' SIGINT rather than SIGKILL: the worker catches it, reports `interrupted`,
|
|
440
|
+
#' and keeps its global environment, so the session is not lost.
|
|
441
|
+
#'
|
|
442
|
+
#' @param k Kernel handle.
|
|
443
|
+
#' @return Invisibly TRUE.
|
|
444
|
+
kernel_interrupt <- function(k) {
|
|
445
|
+
# The worker is the leader of its own process group (processx setpgid), so
|
|
446
|
+
# signalling the GROUP reaches the children too. processx's interrupt()
|
|
447
|
+
# signals the R pid alone — and R inside system()/system2() waits on a
|
|
448
|
+
# child that never heard, while libc's system() ignores SIGINT in the
|
|
449
|
+
# caller for the duration: measured, `system("sleep 60")` could not be
|
|
450
|
+
# stopped at all and wedged every later Stop. A terminal's ^C goes to the
|
|
451
|
+
# whole foreground group; so does this. Windows has no groups here.
|
|
452
|
+
if (.Platform$OS.type != "windows") {
|
|
453
|
+
pid <- tryCatch(k$proc$get_pid(), error = function(e) NA_integer_)
|
|
454
|
+
if (length(pid) == 1L && !is.na(pid) && pid > 0L) {
|
|
455
|
+
try(system2("kill", c("-INT", "--", paste0("-", pid)), stdout = FALSE, stderr = FALSE),
|
|
456
|
+
silent = TRUE)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
try(k$proc$interrupt(), silent = TRUE)
|
|
460
|
+
invisible(TRUE)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#' Ask the worker to exit, then make sure it did.
|
|
464
|
+
#'
|
|
465
|
+
#' @param k Kernel handle.
|
|
466
|
+
#' @param grace Seconds to wait before killing.
|
|
467
|
+
#' @return Invisibly TRUE.
|
|
468
|
+
kernel_stop <- function(k, grace = 2) {
|
|
469
|
+
try(kernel_command(k, "{\"type\":\"shutdown\"}"), silent = TRUE)
|
|
470
|
+
k$proc$wait(timeout = grace * 1000)
|
|
471
|
+
if (k$proc$is_alive()) k$proc$kill()
|
|
472
|
+
invisible(TRUE)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#' Stop a detached task and its descendants, including separate process groups.
|
|
476
|
+
#' processx's inherited tree marker also finds orphaned descendants. A plain
|
|
477
|
+
#' process kill only reaches that process's own group; an engine started by
|
|
478
|
+
#' processx has a different group. Always finish with the root-group kill.
|
|
479
|
+
#' @param k Kernel handle for the task being stopped.
|
|
480
|
+
#' @return Invisibly TRUE.
|
|
481
|
+
kernel_kill_tree <- function(k) {
|
|
482
|
+
try(k$proc$kill_tree(close_connections = FALSE), silent = TRUE)
|
|
483
|
+
try(k$proc$kill(), silent = TRUE)
|
|
484
|
+
invisible(TRUE)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#' Drain whatever the worker has produced since the last call.
|
|
488
|
+
#'
|
|
489
|
+
#' Blocks at most `timeout_ms`, so a caller can poll this from an event loop
|
|
490
|
+
#' without ever stalling. Lines carrying the sentinel are control frames;
|
|
491
|
+
#' everything else on stdout is the user's own output.
|
|
492
|
+
#'
|
|
493
|
+
#' @param k Kernel handle.
|
|
494
|
+
#' @param timeout_ms Maximum block, milliseconds.
|
|
495
|
+
#' @return List of events, each with `$type` in "stdout"/"stderr"/"stream"/
|
|
496
|
+
#' "done"/"ready".
|
|
497
|
+
kernel_poll <- function(k, timeout_ms = 50L) {
|
|
498
|
+
k$proc$poll_io(timeout_ms)
|
|
499
|
+
# Drain available bytes, not complete processx lines. Control frames can be
|
|
500
|
+
# hundreds of KB; incremental draining prevents the child from blocking on
|
|
501
|
+
# a full pipe while the parent waits for that same frame's newline.
|
|
502
|
+
take_lines <- function(field, chunk, flush = FALSE) {
|
|
503
|
+
incoming <- if (length(chunk)) paste0(chunk, collapse = "") else ""
|
|
504
|
+
data <- paste0(k$io[[field]], incoming)
|
|
505
|
+
nl <- gregexpr("\n", data, fixed = TRUE)[[1L]]
|
|
506
|
+
if (length(nl) == 1L && nl[[1L]] < 0L) {
|
|
507
|
+
if (flush && nzchar(data)) { k$io[[field]] <- ""; return(data) }
|
|
508
|
+
k$io[[field]] <- data
|
|
509
|
+
return(character())
|
|
510
|
+
}
|
|
511
|
+
last <- nl[[length(nl)]]
|
|
512
|
+
# `last = nchar(data)` is NOT redundant. substring()'s default last is
|
|
513
|
+
# 1000000L, so a two-argument substring() SILENTLY TRUNCATES at exactly one
|
|
514
|
+
# million characters — see the note on the payload slice below, which this
|
|
515
|
+
# is the other half of. Here it would drop the tail of any partial line
|
|
516
|
+
# longer than 1 MB still waiting for its newline.
|
|
517
|
+
complete <- if (last > 1L) substring(data, 1L, last - 1L) else ""
|
|
518
|
+
k$io[[field]] <- if (last < nchar(data))
|
|
519
|
+
substring(data, last + 1L, nchar(data)) else ""
|
|
520
|
+
# Appending a marker preserves a final empty line, which base strsplit()
|
|
521
|
+
# would otherwise discard (cat("\n") is real stdout).
|
|
522
|
+
lines <- strsplit(paste0(complete, "\001"), "\n", fixed = TRUE)[[1L]]
|
|
523
|
+
lines[[length(lines)]] <- sub("\001$", "", lines[[length(lines)]])
|
|
524
|
+
sub("\r$", "", lines)
|
|
525
|
+
}
|
|
526
|
+
# DRAIN the pipe, do not sip from it. read_output() returns whatever one
|
|
527
|
+
# read yields — around 64 KB — and this function is called once per event
|
|
528
|
+
# loop tick, so a big frame arrived at one chunk per tick. Measured: a
|
|
529
|
+
# 2.3 MB plot payload needs ~370 reads, which at the loop's cadence took
|
|
530
|
+
# SIXTEEN SECONDS to deliver something R had drawn in 0.27 s. The dpi was
|
|
531
|
+
# never the cost; the number of ticks was, and it scales with payload, so
|
|
532
|
+
# every large plot, every wide data frame and every long print paid it.
|
|
533
|
+
# ... and yet BOUNDED. An unbounded drain has the opposite failure: a worker
|
|
534
|
+
# producing faster than this loop consumes (`repeat cat("x\n")`) keeps the
|
|
535
|
+
# pipe permanently non-empty, `kernel_poll` never returns, httpuv::service()
|
|
536
|
+
# is never reached — and the Stop the user is pressing can never be
|
|
537
|
+
# delivered. Capping the bytes taken per poll keeps both properties: a big
|
|
538
|
+
# frame still crosses in a couple of ticks instead of hundreds, and the
|
|
539
|
+
# event loop always gets its turn, so interrupts stay deliverable.
|
|
540
|
+
drain <- function(read, max_bytes = 4e6) {
|
|
541
|
+
parts <- character(0)
|
|
542
|
+
total <- 0
|
|
543
|
+
repeat {
|
|
544
|
+
piece <- tryCatch(read(), error = function(e) "")
|
|
545
|
+
if (!length(piece) || !nzchar(piece)) break
|
|
546
|
+
parts[[length(parts) + 1L]] <- piece
|
|
547
|
+
total <- total + nchar(piece, type = "bytes")
|
|
548
|
+
if (total >= max_bytes) break
|
|
549
|
+
}
|
|
550
|
+
if (length(parts)) paste0(parts, collapse = "") else ""
|
|
551
|
+
}
|
|
552
|
+
out <- take_lines("out", drain(function() k$proc$read_output()), !k$proc$is_alive())
|
|
553
|
+
err <- take_lines("err", drain(function() k$proc$read_error()), !k$proc$is_alive())
|
|
554
|
+
|
|
555
|
+
# ── echo scrubbing (interactive workers only) ─────────────────────────────
|
|
556
|
+
# Under processx pipes R does not echo console input back (measured — the
|
|
557
|
+
# echo seen under a shell pipe comes with a controlling tty, which the
|
|
558
|
+
# shipped spawn never has). This scrub is the belt in case some platform's
|
|
559
|
+
# console does: a line carrying the command tag is our own NDJSON coming
|
|
560
|
+
# back, never user output. Found ANYWHERE in the line for the same reason
|
|
561
|
+
# the sentinel is: a cell ending in cat("x") with no newline leaves the
|
|
562
|
+
# cursor mid-line and an echo would land glued to that text. Anything
|
|
563
|
+
# before the tag is real user output and is kept.
|
|
564
|
+
if (identical(k$mode, "interactive") && length(out)) {
|
|
565
|
+
tag <- paste0("#", k$cmdtag)
|
|
566
|
+
scrubbed <- lapply(out, function(line) {
|
|
567
|
+
at <- regexpr(tag, line, fixed = TRUE)
|
|
568
|
+
if (at < 1L) return(line)
|
|
569
|
+
prefix <- substring(line, 1L, at - 1L)
|
|
570
|
+
if (nzchar(prefix)) prefix else NULL
|
|
571
|
+
})
|
|
572
|
+
out <- as.character(unlist(scrubbed))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# The sentinel is found ANYWHERE in the line, not only at its start.
|
|
576
|
+
#
|
|
577
|
+
# The worker deliberately does not capture output (sink() would kill
|
|
578
|
+
# streaming), so user code controls where the cursor is when the next
|
|
579
|
+
# control frame is written. A cell ending in `cat("done")` — no trailing
|
|
580
|
+
# newline — leaves the cursor mid-line, and the frame lands glued to that
|
|
581
|
+
# text: `donesentinel{"type":"done",...}`. startsWith() then missed it, the
|
|
582
|
+
# frame was reported as ordinary stdout, and the cell never finished: no
|
|
583
|
+
# `done` ever arrived and the run hung until the user pressed Stop.
|
|
584
|
+
#
|
|
585
|
+
# Splitting at the sentinel instead recovers both halves — the text before
|
|
586
|
+
# it is the user's output, the rest is the frame. This does not weaken the
|
|
587
|
+
# forgery guarantee: that rests on the sentinel being 24 unguessable
|
|
588
|
+
# characters, not on its position in the line.
|
|
589
|
+
# Consecutive plain lines COALESCE into one event. One frame per line made
|
|
590
|
+
# text volume the supervisor's unit of work: a chunk printing 100k lines
|
|
591
|
+
# meant 100k JSON encodes and 100k socket sends in a single-threaded event
|
|
592
|
+
# loop — seconds of stall during which heartbeats and Stop sat unserved.
|
|
593
|
+
# The client accumulates stdout text and joins on "\n", so a multi-line
|
|
594
|
+
# event is byte-identical to the same lines delivered one at a time; only
|
|
595
|
+
# lines carrying a control frame still need individual treatment.
|
|
596
|
+
has_sentinel <- if (length(out)) grepl(k$sentinel, out, fixed = TRUE) else logical(0)
|
|
597
|
+
from_stdout <- if (!length(out)) list() else {
|
|
598
|
+
runs <- rle(has_sentinel)
|
|
599
|
+
ends <- cumsum(runs$lengths)
|
|
600
|
+
starts <- ends - runs$lengths + 1L
|
|
601
|
+
unlist(lapply(seq_along(runs$values), function(r) {
|
|
602
|
+
lines <- out[starts[[r]]:ends[[r]]]
|
|
603
|
+
if (!runs$values[[r]]) {
|
|
604
|
+
return(list(list(type = "stdout", text = paste(lines, collapse = "\n"))))
|
|
605
|
+
}
|
|
606
|
+
unlist(lapply(lines, function(line) parse_control_line(k, line)),
|
|
607
|
+
recursive = FALSE)
|
|
608
|
+
}), recursive = FALSE)
|
|
609
|
+
}
|
|
610
|
+
from_stderr <- if (length(err) && !any(grepl(k$sentinel, err, fixed = TRUE))) {
|
|
611
|
+
list(list(type = "stderr", text = paste(err, collapse = "\n")))
|
|
612
|
+
} else if (length(err)) {
|
|
613
|
+
# Notebook evaluation captures stdout with evaluate. Its private protocol
|
|
614
|
+
# frames therefore use stderr for the duration of that command, through
|
|
615
|
+
# and including done. The same unguessable sentinel authenticates either
|
|
616
|
+
# pipe; ordinary stderr remains stderr, never a control frame.
|
|
617
|
+
unlist(lapply(err, function(line) {
|
|
618
|
+
if (grepl(k$sentinel, line, fixed = TRUE)) parse_control_line(k, line)
|
|
619
|
+
else list(list(type = "stderr", text = line))
|
|
620
|
+
}), recursive = FALSE)
|
|
621
|
+
} else list()
|
|
622
|
+
|
|
623
|
+
c(from_stdout, from_stderr)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#' One stdout line KNOWN to carry the sentinel: split it into the user text
|
|
627
|
+
#' before the frame and the frame itself.
|
|
628
|
+
parse_control_line <- function(k, line) {
|
|
629
|
+
at <- regexpr(k$sentinel, line, fixed = TRUE)
|
|
630
|
+
if (at < 1L) return(list(list(type = "stdout", text = line)))
|
|
631
|
+
# `nchar(line)` is NOT redundant: substring()'s default `last` is
|
|
632
|
+
# 1000000L, so the two-argument form silently truncates at exactly one
|
|
633
|
+
# million characters. Every control frame bigger than 1 MB — which means
|
|
634
|
+
# EVERY PLOT above roughly 750 KB of PNG — came out chopped mid-string,
|
|
635
|
+
# failed to parse, and fell through to the branch below that reports the
|
|
636
|
+
# line as ordinary stdout. The plot vanished with no error anywhere: the
|
|
637
|
+
# cell finished "ok", the sentinel and the raw JSON were printed into the
|
|
638
|
+
# output, and the figure simply never appeared. Measured: a 0.25 MB frame
|
|
639
|
+
# arrived, a 0.75 MB frame did not.
|
|
640
|
+
payload <- substring(line, at + nchar(k$sentinel), nchar(line))
|
|
641
|
+
parsed <- tryCatch(fromJSON(payload), error = function(e) NULL)
|
|
642
|
+
if (is.null(parsed)) return(list(list(type = "stdout", text = line)))
|
|
643
|
+
# A control frame must be a JSON OBJECT. Valid JSON that is not one — an
|
|
644
|
+
# array, a bare number, a string — parses CLEANLY into an atomic vector or
|
|
645
|
+
# a data.frame, and the supervisor's first act on a frame is `e$type`,
|
|
646
|
+
# which then raises "$ operator is invalid for atomic vectors". The pumps
|
|
647
|
+
# in serve.R's event loop route that condition out of `repeat`, so the
|
|
648
|
+
# process exits and its shutdown block (chat kill, job kill, runtime-record
|
|
649
|
+
# unlink) never runs. The sentinel is deliberately left in the interactive
|
|
650
|
+
# worker's environment, so a chunk that prints one reaches this line with
|
|
651
|
+
# text of its own choosing. Not an object means not a frame, which is the
|
|
652
|
+
# same answer a parse failure already gets, for the same reason.
|
|
653
|
+
if (!is.list(parsed) || is.data.frame(parsed) || is.null(names(parsed))) {
|
|
654
|
+
return(list(list(type = "stdout", text = line)))
|
|
655
|
+
}
|
|
656
|
+
# The EXACT bytes ride along with the parsed frame, because the supervisor
|
|
657
|
+
# relays this to a browser and re-encoding it there is lossy in three ways
|
|
658
|
+
# at once: jsonlite's default `digits = 4` rounded every number the worker
|
|
659
|
+
# sent (1.2e-5 arrived as 0, and exports wrote that zero), the default
|
|
660
|
+
# `null` rendering turned an absent field into `{}` rather than null, and
|
|
661
|
+
# the simplification above collapses `bins: [500]` back to `bins: 500` —
|
|
662
|
+
# undoing the very I() wrapping worker.R applies to prevent it.
|
|
663
|
+
#
|
|
664
|
+
# The parse stays: routing reads $type and $id, and a faithful re-encode
|
|
665
|
+
# from a nested list measures 225 ms/frame against 4 ms, far too slow for
|
|
666
|
+
# a single-threaded event loop. So the supervisor routes on the parse and
|
|
667
|
+
# forwards the ORIGINAL text. See relay_frame() in serve.R.
|
|
668
|
+
attr(parsed, "raw") <- payload
|
|
669
|
+
prefix <- substring(line, 1L, at - 1L)
|
|
670
|
+
if (nzchar(prefix)) list(list(type = "stdout", text = prefix), parsed) else list(parsed)
|
|
671
|
+
}
|