galaaz 2.1.2 → 2.1.4
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -3
- data/README.md +71 -4
- data/bin/gknit +11 -1
- data/blogs/gknit/gknit.Rmd +2 -1
- data/blogs/gknit/gknit.md +2 -1
- data/blogs/manual/manual.Rmd +66 -3
- data/blogs/manual/manual.md +71 -4
- data/ext/new_bridge/galaaz_gatekeeper_phase1.cpp +13 -3
- data/lib/R_interface/new_bridge_adapter.rb +14 -1
- data/lib/R_interface/r.rb +60 -53
- data/lib/R_interface/r_job.rb +429 -0
- data/lib/galaaz/cli.rb +15 -1
- data/lib/gknit/diagnostics.rb +3 -1
- data/lib/new_bridge/session_client.rb +45 -0
- data/specs/gknit_install_timeout_report_spec.rb +27 -7
- data/specs/r_job_spec.rb +81 -0
- data/version.rb +1 -1
- metadata +10 -5
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module R
|
|
9
|
+
# Background R work in a **child Rscript process** (not the NewBridge gatekeeper).
|
|
10
|
+
#
|
|
11
|
+
# Layer B — installs:
|
|
12
|
+
# job = R::Job.install('caret')
|
|
13
|
+
# job.raise_if_failed!
|
|
14
|
+
#
|
|
15
|
+
# Layer C — long arbitrary R (bridge stays free). Default +wait: true+ awaits
|
|
16
|
+
# before returning; with a block, await, yield the job, return the block value:
|
|
17
|
+
#
|
|
18
|
+
# coef = R::Job.eval(<<~R) { |job| job.load_rds }
|
|
19
|
+
# fit <- lm(mpg ~ wt, data = mtcars)
|
|
20
|
+
# saveRDS(coef(fit), result_path)
|
|
21
|
+
# R
|
|
22
|
+
#
|
|
23
|
+
# job = R::Job.script('train.R', 'arg1') # awaits, returns Job
|
|
24
|
+
# job = R::Job.eval(code, wait: false) # start only
|
|
25
|
+
#
|
|
26
|
+
class Job
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
class Failed < Error; end
|
|
29
|
+
class Timeout < Error; end
|
|
30
|
+
|
|
31
|
+
DEFAULT_REPOS = 'https://cloud.r-project.org'
|
|
32
|
+
DEFAULT_LIB = File.expand_path('~/R/x86_64-pc-linux-gnu-library/galaaz')
|
|
33
|
+
JOBS_ROOT = File.expand_path('~/.local/share/galaaz/jobs')
|
|
34
|
+
DEFAULT_RESULT_NAME = 'result.rds'
|
|
35
|
+
|
|
36
|
+
attr_reader :id, :kind, :dir, :log_path, :meta_path, :pid, :packages, :lib_dir,
|
|
37
|
+
:script_path, :script_args
|
|
38
|
+
|
|
39
|
+
def self.jobs_root
|
|
40
|
+
ENV['GALAAZ_JOBS_DIR'] && !ENV['GALAAZ_JOBS_DIR'].empty? ? File.expand_path(ENV['GALAAZ_JOBS_DIR']) : JOBS_ROOT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.default_lib_dir
|
|
44
|
+
DEFAULT_LIB
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Install one or more CRAN packages in a child Rscript, then optionally await.
|
|
48
|
+
#
|
|
49
|
+
# With a block: always await, +raise_if_failed!+, yield the job, return the
|
|
50
|
+
# block's value (File.open-style). Without a block: return the +Job+ (awaits
|
|
51
|
+
# when +wait:+ is true, the default).
|
|
52
|
+
#
|
|
53
|
+
# @param packages [String, Array<String>]
|
|
54
|
+
# @param lib_dir [String] install library (added to .libPaths in the child)
|
|
55
|
+
# @param repos [String] CRAN mirror
|
|
56
|
+
# @param wait [Boolean] if true (default), block until the job finishes
|
|
57
|
+
# @param timeout [nil, Numeric] await wall-clock seconds; nil = wait forever
|
|
58
|
+
# @return [R::Job, Object] Job, or the block's return value
|
|
59
|
+
def self.install(*packages, lib_dir: default_lib_dir, repos: DEFAULT_REPOS, wait: true, timeout: nil, &block)
|
|
60
|
+
pkgs = packages.flatten.map(&:to_s).reject(&:empty?)
|
|
61
|
+
raise ArgumentError, 'R::Job.install requires at least one package name' if pkgs.empty?
|
|
62
|
+
|
|
63
|
+
FileUtils.mkdir_p(lib_dir)
|
|
64
|
+
FileUtils.mkdir_p(jobs_root)
|
|
65
|
+
|
|
66
|
+
with_install_lock do
|
|
67
|
+
job = new(kind: 'install', packages: pkgs, lib_dir: lib_dir)
|
|
68
|
+
job.send(:spawn_install!, repos: repos)
|
|
69
|
+
finish_job(job, wait: wait, timeout: timeout, &block)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run arbitrary R code in a child Rscript (Layer C).
|
|
74
|
+
#
|
|
75
|
+
# The child starts with +lib_dir+ on +.libPaths+, +setwd(job.dir)+, and:
|
|
76
|
+
# GALAAZ_JOB_DIR — job directory
|
|
77
|
+
# result_path — default +file.path(GALAAZ_JOB_DIR, "result.rds")+
|
|
78
|
+
# Persist outputs with +saveRDS(..., result_path)+ (or any path under +job.dir+),
|
|
79
|
+
# then load on the bridge with +job.load_rds+.
|
|
80
|
+
#
|
|
81
|
+
# With a block: await, raise on failure, yield job, return block value.
|
|
82
|
+
#
|
|
83
|
+
# @param code [String] R source
|
|
84
|
+
# @param lib_dir [String]
|
|
85
|
+
# @param wait [Boolean]
|
|
86
|
+
# @param timeout [nil, Numeric]
|
|
87
|
+
# @return [R::Job, Object]
|
|
88
|
+
def self.eval(code, lib_dir: default_lib_dir, wait: true, timeout: nil, &block)
|
|
89
|
+
raise ArgumentError, 'R::Job.eval requires code' if code.nil? || code.to_s.strip.empty?
|
|
90
|
+
|
|
91
|
+
FileUtils.mkdir_p(lib_dir)
|
|
92
|
+
FileUtils.mkdir_p(jobs_root)
|
|
93
|
+
|
|
94
|
+
job = new(kind: 'eval', packages: [], lib_dir: lib_dir)
|
|
95
|
+
job.send(:spawn_eval!, code)
|
|
96
|
+
finish_job(job, wait: wait, timeout: timeout, &block)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Run an R script file in a child Rscript (Layer C).
|
|
100
|
+
#
|
|
101
|
+
# With a block: await, raise on failure, yield job, return block value.
|
|
102
|
+
#
|
|
103
|
+
# @param path [String] path to +.R+ file
|
|
104
|
+
# @param args [Array<String>] trailing args (+commandArgs(trailingOnly=TRUE)+)
|
|
105
|
+
# @param lib_dir [String]
|
|
106
|
+
# @param wait [Boolean]
|
|
107
|
+
# @param timeout [nil, Numeric]
|
|
108
|
+
# @return [R::Job, Object]
|
|
109
|
+
def self.script(path, *args, lib_dir: default_lib_dir, wait: true, timeout: nil, &block)
|
|
110
|
+
script = File.expand_path(path.to_s)
|
|
111
|
+
raise ArgumentError, "R::Job.script: file not found: #{script}" unless File.file?(script)
|
|
112
|
+
|
|
113
|
+
FileUtils.mkdir_p(lib_dir)
|
|
114
|
+
FileUtils.mkdir_p(jobs_root)
|
|
115
|
+
|
|
116
|
+
job = new(kind: 'script', packages: [], lib_dir: lib_dir, script_path: script, script_args: args.map(&:to_s))
|
|
117
|
+
job.send(:spawn_script!)
|
|
118
|
+
finish_job(job, wait: wait, timeout: timeout, &block)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @api private
|
|
122
|
+
def self.finish_job(job, wait:, timeout:, &block)
|
|
123
|
+
job.wait(timeout: timeout) if wait || block
|
|
124
|
+
if block
|
|
125
|
+
job.raise_if_failed!
|
|
126
|
+
return yield(job)
|
|
127
|
+
end
|
|
128
|
+
job
|
|
129
|
+
end
|
|
130
|
+
private_class_method :finish_job
|
|
131
|
+
|
|
132
|
+
def initialize(kind:, packages:, lib_dir:, script_path: nil, script_args: [])
|
|
133
|
+
@kind = kind.to_s
|
|
134
|
+
@packages = packages
|
|
135
|
+
@lib_dir = File.expand_path(lib_dir)
|
|
136
|
+
@script_path = script_path
|
|
137
|
+
@script_args = script_args || []
|
|
138
|
+
@id = "#{@kind}-#{Time.now.utc.strftime('%Y%m%dT%H%M%S')}-#{Process.pid}-#{SecureRandom.hex(3)}"
|
|
139
|
+
@dir = File.join(self.class.jobs_root, @id)
|
|
140
|
+
FileUtils.mkdir_p(@dir)
|
|
141
|
+
@log_path = File.join(@dir, 'job.log')
|
|
142
|
+
@meta_path = File.join(@dir, 'meta.json')
|
|
143
|
+
@exit_path = File.join(@dir, 'exit_code')
|
|
144
|
+
@pid = nil
|
|
145
|
+
@exit_status = nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def status
|
|
149
|
+
return :ok if finished? && exit_code == 0
|
|
150
|
+
return :failed if finished? && exit_code != 0
|
|
151
|
+
return :running if alive?
|
|
152
|
+
|
|
153
|
+
:unknown
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def alive?
|
|
157
|
+
return false unless @pid
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
Process.kill(0, @pid)
|
|
161
|
+
true
|
|
162
|
+
rescue Errno::ESRCH
|
|
163
|
+
false
|
|
164
|
+
rescue Errno::EPERM
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def finished?
|
|
170
|
+
return true unless @exit_status.nil?
|
|
171
|
+
return true if File.file?(@exit_path) && !alive?
|
|
172
|
+
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def exit_code
|
|
177
|
+
return @exit_status.exitstatus if @exit_status.respond_to?(:exitstatus)
|
|
178
|
+
return File.read(@exit_path).to_i if File.file?(@exit_path)
|
|
179
|
+
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Default RDS path written by child when using +result_path+ / +saveRDS(..., result_path)+.
|
|
184
|
+
def result_path(name = DEFAULT_RESULT_NAME)
|
|
185
|
+
File.join(@dir, name.to_s)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Await process completion.
|
|
189
|
+
# @param timeout [nil, Numeric] nil = forever
|
|
190
|
+
# @return [Symbol] final status (:ok or :failed)
|
|
191
|
+
def wait(timeout: nil)
|
|
192
|
+
return status if finished?
|
|
193
|
+
|
|
194
|
+
if timeout.nil?
|
|
195
|
+
reap!
|
|
196
|
+
else
|
|
197
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Float(timeout)
|
|
198
|
+
loop do
|
|
199
|
+
reap_nonblock!
|
|
200
|
+
break if finished?
|
|
201
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
202
|
+
kill!
|
|
203
|
+
raise Timeout, "R::Job #{@id} still running after #{timeout}s (pid=#{@pid}, log=#{@log_path})"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
sleep 0.2
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
write_exit_file!
|
|
211
|
+
status
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def raise_if_failed!
|
|
215
|
+
st = status
|
|
216
|
+
return self if st == :ok
|
|
217
|
+
|
|
218
|
+
if st == :running
|
|
219
|
+
raise Error, "R::Job #{@id} is still running (pid=#{@pid}, log=#{@log_path})"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
tail = File.readable?(@log_path) ? File.read(@log_path).lines.last(40).join : ''
|
|
223
|
+
raise Failed, "R::Job #{@id} failed (exit=#{exit_code}, log=#{@log_path})\n--- log tail ---\n#{tail}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Short sync +readRDS+ on the **bridge** after a successful job.
|
|
227
|
+
# Returns a normal Galaaz R object (same as +R.readRDS(...)+).
|
|
228
|
+
# @param name [String] file under +job.dir+ (default +result.rds+)
|
|
229
|
+
def load_rds(name = DEFAULT_RESULT_NAME)
|
|
230
|
+
raise_if_failed!
|
|
231
|
+
path = result_path(name)
|
|
232
|
+
raise Error, "R::Job #{@id}: result not found: #{path}" unless File.file?(path)
|
|
233
|
+
|
|
234
|
+
R.readRDS(path)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def kill!(signal = 'TERM')
|
|
238
|
+
return unless @pid
|
|
239
|
+
|
|
240
|
+
# Spawned with pgroup: true — kill the whole process group (Rscript + make/gcc).
|
|
241
|
+
targets = [-@pid, @pid]
|
|
242
|
+
targets.each do |t|
|
|
243
|
+
begin
|
|
244
|
+
Process.kill(signal, t)
|
|
245
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
sleep 0.2
|
|
250
|
+
targets.each do |t|
|
|
251
|
+
begin
|
|
252
|
+
Process.kill('KILL', t)
|
|
253
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
reap_nonblock!
|
|
258
|
+
write_exit_file!
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def self.with_install_lock
|
|
262
|
+
FileUtils.mkdir_p(jobs_root)
|
|
263
|
+
lock_path = File.join(jobs_root, 'install.lock')
|
|
264
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lf|
|
|
265
|
+
lf.flock(File::LOCK_EX)
|
|
266
|
+
yield
|
|
267
|
+
ensure
|
|
268
|
+
lf.flock(File::LOCK_UN) rescue nil
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def spawn_install!(repos:)
|
|
275
|
+
# Stale 00LOCK-* dirs remain after kill!-on-timeout and block the next install.
|
|
276
|
+
@packages.each do |pkg|
|
|
277
|
+
lock = File.join(@lib_dir, "00LOCK-#{pkg}")
|
|
278
|
+
FileUtils.rm_rf(lock) if File.exist?(lock)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
pkg_list = @packages.map { |p| "'#{p.gsub("'", "\\\\'")}'" }.join(', ')
|
|
282
|
+
lib_esc = @lib_dir.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")
|
|
283
|
+
repos_esc = repos.gsub("'", "\\\\'")
|
|
284
|
+
r_code = <<~R
|
|
285
|
+
lib <- '#{lib_esc}'
|
|
286
|
+
dir.create(lib, recursive = TRUE, showWarnings = FALSE)
|
|
287
|
+
.libPaths(c(lib, .libPaths()))
|
|
288
|
+
Sys.setenv(MAKEFLAGS = '-j1')
|
|
289
|
+
options(Ncpus = 1L, repos = '#{repos_esc}')
|
|
290
|
+
pkgs <- c(#{pkg_list})
|
|
291
|
+
message('R::Job install starting: ', paste(pkgs, collapse = ', '))
|
|
292
|
+
message('lib = ', lib)
|
|
293
|
+
install.packages(pkgs, lib = lib, dependencies = NA)
|
|
294
|
+
missing <- pkgs[!pkgs %in% rownames(installed.packages(lib.loc = lib))]
|
|
295
|
+
if (length(missing)) {
|
|
296
|
+
message('Still missing after install: ', paste(missing, collapse = ', '))
|
|
297
|
+
quit(status = 1)
|
|
298
|
+
}
|
|
299
|
+
message('R::Job install OK')
|
|
300
|
+
quit(status = 0)
|
|
301
|
+
R
|
|
302
|
+
spawn_rscript!(r_code)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def spawn_eval!(code)
|
|
306
|
+
preamble = child_preamble_r
|
|
307
|
+
r_code = <<~R
|
|
308
|
+
#{preamble}
|
|
309
|
+
message('R::Job eval starting')
|
|
310
|
+
tryCatch({
|
|
311
|
+
#{indent_r(code, 2)}
|
|
312
|
+
}, error = function(e) {
|
|
313
|
+
message('R::Job eval error: ', conditionMessage(e))
|
|
314
|
+
quit(status = 1)
|
|
315
|
+
})
|
|
316
|
+
message('R::Job eval OK')
|
|
317
|
+
quit(status = 0)
|
|
318
|
+
R
|
|
319
|
+
spawn_rscript!(r_code)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def spawn_script!
|
|
323
|
+
preamble = child_preamble_r
|
|
324
|
+
script_esc = @script_path.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")
|
|
325
|
+
# Persist user script path in job dir for forensics; execution uses absolute path.
|
|
326
|
+
FileUtils.cp(@script_path, File.join(@dir, File.basename(@script_path))) rescue nil
|
|
327
|
+
r_code = <<~R
|
|
328
|
+
#{preamble}
|
|
329
|
+
message('R::Job script starting: #{script_esc}')
|
|
330
|
+
tryCatch({
|
|
331
|
+
source('#{script_esc}', local = FALSE)
|
|
332
|
+
}, error = function(e) {
|
|
333
|
+
message('R::Job script error: ', conditionMessage(e))
|
|
334
|
+
quit(status = 1)
|
|
335
|
+
})
|
|
336
|
+
message('R::Job script OK')
|
|
337
|
+
quit(status = 0)
|
|
338
|
+
R
|
|
339
|
+
spawn_rscript!(r_code, extra_args: @script_args)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def child_preamble_r
|
|
343
|
+
lib_esc = @lib_dir.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")
|
|
344
|
+
dir_esc = @dir.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")
|
|
345
|
+
<<~R
|
|
346
|
+
lib <- '#{lib_esc}'
|
|
347
|
+
job_dir <- '#{dir_esc}'
|
|
348
|
+
dir.create(lib, recursive = TRUE, showWarnings = FALSE)
|
|
349
|
+
dir.create(job_dir, recursive = TRUE, showWarnings = FALSE)
|
|
350
|
+
.libPaths(c(lib, .libPaths()))
|
|
351
|
+
setwd(job_dir)
|
|
352
|
+
Sys.setenv(MAKEFLAGS = '-j1', GALAAZ_JOB_DIR = job_dir)
|
|
353
|
+
options(Ncpus = 1L)
|
|
354
|
+
result_path <- file.path(job_dir, '#{DEFAULT_RESULT_NAME}')
|
|
355
|
+
R
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def indent_r(code, spaces)
|
|
359
|
+
pad = ' ' * spaces
|
|
360
|
+
code.to_s.gsub("\r\n", "\n").lines.map { |line| "#{pad}#{line}" }.join
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def spawn_rscript!(r_code, extra_args: [])
|
|
364
|
+
wrapper_path = File.join(@dir, 'job.R')
|
|
365
|
+
File.write(wrapper_path, r_code)
|
|
366
|
+
log_io = File.open(@log_path, File::WRONLY | File::CREAT | File::TRUNC)
|
|
367
|
+
rscript = ENV['GALAAZ_RSCRIPT'] && !ENV['GALAAZ_RSCRIPT'].empty? ? ENV['GALAAZ_RSCRIPT'] : 'Rscript'
|
|
368
|
+
env = {
|
|
369
|
+
'MAKEFLAGS' => '-j1',
|
|
370
|
+
'GALAAZ_JOB_DIR' => @dir,
|
|
371
|
+
'R_LIBS_USER' => @lib_dir
|
|
372
|
+
}
|
|
373
|
+
cmd = [rscript, '--vanilla', wrapper_path, *extra_args.map(&:to_s)]
|
|
374
|
+
@pid = spawn(env, *cmd, out: log_io, err: log_io, pgroup: true, chdir: @dir)
|
|
375
|
+
log_io.close
|
|
376
|
+
write_meta!
|
|
377
|
+
$stderr.puts "[RUBY] R::Job #{@id} started pid=#{@pid} log=#{@log_path}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def write_meta!
|
|
381
|
+
File.write(@meta_path, JSON.pretty_generate(
|
|
382
|
+
id: @id,
|
|
383
|
+
kind: @kind,
|
|
384
|
+
packages: @packages,
|
|
385
|
+
lib_dir: @lib_dir,
|
|
386
|
+
dir: @dir,
|
|
387
|
+
script_path: @script_path,
|
|
388
|
+
script_args: @script_args,
|
|
389
|
+
pid: @pid,
|
|
390
|
+
log_path: @log_path,
|
|
391
|
+
result_path: result_path,
|
|
392
|
+
started_at: Time.now.utc.iso8601
|
|
393
|
+
))
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def write_exit_file!
|
|
397
|
+
code = exit_code
|
|
398
|
+
File.write(@exit_path, code.nil? ? "-1\n" : "#{code}\n") unless code.nil?
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def reap!
|
|
402
|
+
return if @exit_status
|
|
403
|
+
|
|
404
|
+
_pid, @exit_status = Process.wait2(@pid)
|
|
405
|
+
rescue Errno::ECHILD
|
|
406
|
+
@exit_status = read_fake_status_from_file
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def reap_nonblock!
|
|
410
|
+
return if @exit_status
|
|
411
|
+
|
|
412
|
+
pid, st = Process.wait2(@pid, Process::WNOHANG)
|
|
413
|
+
@exit_status = st if pid
|
|
414
|
+
rescue Errno::ECHILD
|
|
415
|
+
@exit_status = read_fake_status_from_file if File.file?(@exit_path)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def read_fake_status_from_file
|
|
419
|
+
return nil unless File.file?(@exit_path)
|
|
420
|
+
|
|
421
|
+
code = File.read(@exit_path).to_i
|
|
422
|
+
# Minimal stand-in with exitstatus
|
|
423
|
+
Object.new.tap do |o|
|
|
424
|
+
o.define_singleton_method(:exitstatus) { code }
|
|
425
|
+
o.define_singleton_method(:success?) { code.zero? }
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
data/lib/galaaz/cli.rb
CHANGED
|
@@ -285,6 +285,14 @@ module Galaaz
|
|
|
285
285
|
end
|
|
286
286
|
|
|
287
287
|
def add_knit
|
|
288
|
+
unless command_present?('pandoc')
|
|
289
|
+
raise CliError,
|
|
290
|
+
'pandoc not on PATH (rmarkdown/gknit need pandoc >= 2.8). ' \
|
|
291
|
+
'Omarchy: omarchy-pkg-add pandoc Arch: pacman -S pandoc Debian: apt install pandoc'
|
|
292
|
+
end
|
|
293
|
+
ver = `pandoc --version 2>/dev/null`.lines.first.to_s.strip
|
|
294
|
+
puts "galaaz add knit: #{ver.empty? ? 'pandoc OK' : ver}"
|
|
295
|
+
|
|
288
296
|
pkgs = read_pkg_list('knit.txt')
|
|
289
297
|
install_cran!(pkgs)
|
|
290
298
|
extras_path = File.join(root, 'r_requires', 'knit-extras.txt')
|
|
@@ -296,7 +304,13 @@ module Galaaz
|
|
|
296
304
|
end
|
|
297
305
|
mark_profile!('knit')
|
|
298
306
|
puts 'galaaz add knit: OK'
|
|
299
|
-
puts '
|
|
307
|
+
puts 'Example knits (after blogs init → ~/galaaz-blogs):'
|
|
308
|
+
puts ' gknit ~/galaaz-blogs/oh_my/oh_my.Rmd'
|
|
309
|
+
puts ' gknit ~/galaaz-blogs/galaaz_ggplot/galaaz_ggplot.Rmd'
|
|
310
|
+
puts ' gknit ~/galaaz-blogs/gknit/gknit.Rmd'
|
|
311
|
+
puts 'Docs: https://rbotafogo.github.io/galaaz/'
|
|
312
|
+
puts ' https://github.com/rbotafogo/galaaz'
|
|
313
|
+
puts 'Omarchy menu: Guide (what\'s next) · Knit demo (oh_my)'
|
|
300
314
|
0
|
|
301
315
|
end
|
|
302
316
|
|
data/lib/gknit/diagnostics.rb
CHANGED
|
@@ -9,7 +9,9 @@ module GknitDiagnostics
|
|
|
9
9
|
/invalid connection/i,
|
|
10
10
|
/galaaz_callback_call timeout/i,
|
|
11
11
|
/NewBridge::SessionClient::/i,
|
|
12
|
-
/Unsupported graphics device/i
|
|
12
|
+
/Unsupported graphics device/i,
|
|
13
|
+
/install job timed out/i,
|
|
14
|
+
/R::Job .+ still running after/i
|
|
13
15
|
].freeze
|
|
14
16
|
|
|
15
17
|
def self.reset!
|
|
@@ -109,6 +109,8 @@ module NewBridge
|
|
|
109
109
|
@r_stderr_mx = Mutex.new
|
|
110
110
|
@r_stderr_limit = 64 * 1024
|
|
111
111
|
@r_exit_status = nil
|
|
112
|
+
@r_wait_thr = nil
|
|
113
|
+
@r_thr = nil
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
# Whether to use Unix domain sockets for this client when +use_unix+ is nil.
|
|
@@ -190,6 +192,7 @@ module NewBridge
|
|
|
190
192
|
@r_thr = Thread.new do
|
|
191
193
|
launch = @r_cmd.is_a?(Array) ? @r_cmd.dup : [@r_cmd]
|
|
192
194
|
_stdin, stdout_err, wait_thr = Open3.popen2e(env, *launch, '--slave', '--no-save', '-e', r_script)
|
|
195
|
+
@r_wait_thr = wait_thr
|
|
193
196
|
# When the R process exits or during shutdown, the underlying pipe can
|
|
194
197
|
# close while this background thread is still blocked reading.
|
|
195
198
|
# Treat that as a normal shutdown and avoid JRuby "stream closed in
|
|
@@ -216,12 +219,40 @@ module NewBridge
|
|
|
216
219
|
tail = details.length > 2000 ? details[-2000, 2000] : details
|
|
217
220
|
msg += " stderr_tail=#{tail}"
|
|
218
221
|
end
|
|
222
|
+
kill_runtime_process!
|
|
219
223
|
raise RProcessError, msg
|
|
220
224
|
end
|
|
221
225
|
|
|
226
|
+
# Hard-stop the R child (e.g. after an eval timeout while install.packages is still compiling).
|
|
227
|
+
# Without this, R keeps building packages and can OOM the host shell/VM.
|
|
228
|
+
def kill_runtime_process!
|
|
229
|
+
thr = @r_wait_thr
|
|
230
|
+
pid = begin
|
|
231
|
+
thr&.pid
|
|
232
|
+
rescue StandardError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
if pid
|
|
236
|
+
begin
|
|
237
|
+
Process.kill('TERM', pid)
|
|
238
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
sleep 0.2
|
|
242
|
+
begin
|
|
243
|
+
Process.kill('KILL', pid)
|
|
244
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
ensure
|
|
249
|
+
@r_wait_thr = nil
|
|
250
|
+
end
|
|
251
|
+
|
|
222
252
|
# Stop the client and attempt to join threads cleanly.
|
|
223
253
|
# Best-effort: socket/server are closed, reader thread is joined, runtime thread is joined.
|
|
224
254
|
def stop
|
|
255
|
+
kill_runtime_process!
|
|
225
256
|
@sock&.close rescue nil
|
|
226
257
|
@server&.close rescue nil
|
|
227
258
|
if @unix_path && File.socket?(@unix_path)
|
|
@@ -232,6 +263,20 @@ module NewBridge
|
|
|
232
263
|
@r_thr&.join(15)
|
|
233
264
|
end
|
|
234
265
|
|
|
266
|
+
# Kill R + reopen listener/runtime. Clears pending queues.
|
|
267
|
+
def restart!(accept_timeout: 120)
|
|
268
|
+
stop
|
|
269
|
+
@pending_mx.synchronize { @pending.clear }
|
|
270
|
+
@callbacks_mx.synchronize { @callbacks.clear }
|
|
271
|
+
@r_stderr_mx.synchronize { @r_stderr = +'' }
|
|
272
|
+
@r_exit_status = nil
|
|
273
|
+
@reader = nil
|
|
274
|
+
@r_thr = nil
|
|
275
|
+
@sock = nil
|
|
276
|
+
@server = nil
|
|
277
|
+
start(accept_timeout: accept_timeout)
|
|
278
|
+
end
|
|
279
|
+
|
|
235
280
|
# Evaluate one REQ on the connected R runtime and wait for the matching RET.
|
|
236
281
|
#
|
|
237
282
|
# @param code [String] R expression or snippet to be evaluated by the gatekeeper.
|
|
@@ -15,12 +15,13 @@ describe 'gknit install timeout reporting' do
|
|
|
15
15
|
path = ([*extra, path].join(File::PATH_SEPARATOR)) unless extra.empty?
|
|
16
16
|
ENV.to_h.merge(
|
|
17
17
|
'JAVA_OPTS' => '--add-opens=java.base/java.nio=ALL-UNNAMED',
|
|
18
|
-
'PATH' => path
|
|
18
|
+
'PATH' => path,
|
|
19
|
+
# Job await limit (bridge --bridge_timeout_sec no longer drives CRAN installs).
|
|
20
|
+
'GALAAZ_INSTALL_TIMEOUT_SEC' => '1'
|
|
19
21
|
)
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
it 'continues processing and prints timeout in final report' do
|
|
23
|
-
skip('Temporarily skipped: installation timeout behavior will be reviewed in a dedicated pass')
|
|
24
25
|
root = File.expand_path('..', __dir__)
|
|
25
26
|
workdir = Dir.mktmpdir('gknit_install_timeout_', root)
|
|
26
27
|
|
|
@@ -34,11 +35,28 @@ describe 'gknit install timeout reporting' do
|
|
|
34
35
|
---
|
|
35
36
|
|
|
36
37
|
```{ruby setup_stub}
|
|
37
|
-
|
|
38
|
+
# Deterministic slow "install": child Rscript sleeps instead of CRAN work.
|
|
39
|
+
# Use ::R — bare R in chunks is RC::R (gknit scope), not the Galaaz module.
|
|
40
|
+
module ::R
|
|
41
|
+
class Job
|
|
42
|
+
def self.install(*packages, lib_dir: default_lib_dir, repos: DEFAULT_REPOS, wait: true, timeout: nil, &block)
|
|
43
|
+
pkgs = packages.flatten.map(&:to_s).reject(&:empty?)
|
|
44
|
+
raise ArgumentError, 'R::Job.install requires at least one package name' if pkgs.empty?
|
|
45
|
+
|
|
46
|
+
FileUtils.mkdir_p(lib_dir)
|
|
47
|
+
FileUtils.mkdir_p(jobs_root)
|
|
48
|
+
with_install_lock do
|
|
49
|
+
job = new(kind: 'install', packages: pkgs, lib_dir: lib_dir)
|
|
50
|
+
job.send(:spawn_eval!, 'Sys.sleep(30)')
|
|
51
|
+
finish_job(job, wait: wait, timeout: timeout, &block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
38
56
|
```
|
|
39
57
|
|
|
40
58
|
```{ruby timeout_install}
|
|
41
|
-
R.install_and_loads('definitely_fake_pkg_for_timeout_spec')
|
|
59
|
+
::R.install_and_loads('definitely_fake_pkg_for_timeout_spec')
|
|
42
60
|
```
|
|
43
61
|
|
|
44
62
|
```{ruby after_timeout}
|
|
@@ -49,18 +67,20 @@ describe 'gknit install timeout reporting' do
|
|
|
49
67
|
rel_rmd = File.basename(workdir) + '/' + File.basename(rmd_path)
|
|
50
68
|
out, err, st = Open3.capture3(
|
|
51
69
|
self.class.gknit_env,
|
|
52
|
-
'bin/gknit', '--output_format', 'github_document',
|
|
70
|
+
'bin/gknit', '--output_format', 'github_document',
|
|
71
|
+
'--install_timeout_sec', '1',
|
|
72
|
+
rel_rmd,
|
|
53
73
|
chdir: root
|
|
54
74
|
)
|
|
55
75
|
|
|
56
76
|
md_path = File.join(workdir, 'install_timeout.md')
|
|
57
|
-
expect(st.success?).to be(true)
|
|
77
|
+
expect(st.success?).to be(true), "gknit failed (status=#{st.exitstatus}):\n#{err}\n#{out}"
|
|
58
78
|
expect(File.exist?(md_path)).to be(true)
|
|
59
79
|
md = File.read(md_path)
|
|
60
80
|
expect(md).to include('AFTER_TIMEOUT_CHUNK_OK')
|
|
61
81
|
expect(err).to include('gknit internal errors detected:')
|
|
62
82
|
expect(err).to include('chunk=timeout_install')
|
|
63
|
-
expect(err).to
|
|
83
|
+
expect(err).to match(/install job timed out/i)
|
|
64
84
|
expect(out).not_to be_nil
|
|
65
85
|
ensure
|
|
66
86
|
FileUtils.rm_rf(workdir) if workdir && File.directory?(workdir)
|
data/specs/r_job_spec.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fast R::Job coverage (child Rscript; bridge stays free).
|
|
4
|
+
# Run: bin/run_rspec specs/r_job_spec.rb
|
|
5
|
+
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'tmpdir'
|
|
8
|
+
require 'galaaz'
|
|
9
|
+
|
|
10
|
+
describe 'R::Job' do
|
|
11
|
+
around do |example|
|
|
12
|
+
Dir.mktmpdir('galaaz_jobs_') do |jobs|
|
|
13
|
+
old = ENV['GALAAZ_JOBS_DIR']
|
|
14
|
+
ENV['GALAAZ_JOBS_DIR'] = jobs
|
|
15
|
+
begin
|
|
16
|
+
example.run
|
|
17
|
+
ensure
|
|
18
|
+
ENV['GALAAZ_JOBS_DIR'] = old
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'awaits eval in a block and load_rds on the bridge' do
|
|
24
|
+
coef = R::Job.eval(<<~R) { |job| job.load_rds }
|
|
25
|
+
fit <- lm(mpg ~ wt, data = mtcars)
|
|
26
|
+
saveRDS(unname(coef(fit)), result_path)
|
|
27
|
+
R
|
|
28
|
+
|
|
29
|
+
expect(coef).to be_a(R::Vector)
|
|
30
|
+
expect(coef.length).to eq(2)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'returns a Job without a block and supports wait: false' do
|
|
34
|
+
job = R::Job.eval('Sys.sleep(0.2); saveRDS(42L, result_path)', wait: false, timeout: 30)
|
|
35
|
+
expect(job).to be_a(R::Job)
|
|
36
|
+
expect(job.alive? || !job.finished?).to eq(true)
|
|
37
|
+
|
|
38
|
+
job.wait(timeout: 30)
|
|
39
|
+
job.raise_if_failed!
|
|
40
|
+
expect(job.status).to eq(:ok)
|
|
41
|
+
expect(job.load_rds.to_s).to include('42')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'kills the child process group on await timeout' do
|
|
45
|
+
expect {
|
|
46
|
+
R::Job.eval('Sys.sleep(30)', wait: true, timeout: 1)
|
|
47
|
+
}.to raise_error(R::Job::Timeout, /still running after/)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'runs a script with trailing args and load_rds' do
|
|
51
|
+
Dir.mktmpdir('galaaz_job_script_') do |dir|
|
|
52
|
+
path = File.join(dir, 'train.R')
|
|
53
|
+
File.write(path, <<~R)
|
|
54
|
+
args <- commandArgs(trailingOnly = TRUE)
|
|
55
|
+
n <- as.integer(args[[1]])
|
|
56
|
+
saveRDS(n, result_path)
|
|
57
|
+
R
|
|
58
|
+
|
|
59
|
+
n = R::Job.script(path, '7') { |job| job.load_rds }
|
|
60
|
+
expect(n.to_s).to include('7')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'clears a stale 00LOCK before install' do
|
|
65
|
+
Dir.mktmpdir('galaaz_job_lib_') do |lib|
|
|
66
|
+
lock = File.join(lib, '00LOCK-definitely_not_a_cran_pkg_xyzzy')
|
|
67
|
+
FileUtils.mkdir_p(lock)
|
|
68
|
+
File.write(File.join(lock, 'marker'), 'stale')
|
|
69
|
+
|
|
70
|
+
job = R::Job.install(
|
|
71
|
+
'definitely_not_a_cran_pkg_xyzzy',
|
|
72
|
+
lib_dir: lib,
|
|
73
|
+
wait: true,
|
|
74
|
+
timeout: 120
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(File.exist?(lock)).to eq(false)
|
|
78
|
+
expect(job.status).to eq(:failed)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|