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.
@@ -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 'You can now run: gknit ~/galaaz-blogs/oh_my/oh_my.Rmd'
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
 
@@ -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
- R.bridge.eval_r("install.packages <- function(...) { Sys.sleep(2); invisible(NULL) }")
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', '--bridge_timeout_sec', '1', rel_rmd,
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 include('no RET for')
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)
@@ -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