galaaz 2.1.3 → 2.1.5

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,11 +304,23 @@ 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
 
303
317
  def add_arrow
318
+ # R 'arrow' needs a matching libarrow. Compiling Arrow C++ + Boost from the
319
+ # CRAN source tarball is fragile (especially Arch/Omarchy). Distro libarrow
320
+ # (e.g. Arch extra/arrow) often mismatches the CRAN package major and then
321
+ # pkg-config configure fails. Apache's version-matched prebuilt libarrow is
322
+ # the reliable automated path — HTTPS from Apache, no interactive steps.
323
+ prepare_arrow_cran_env!
304
324
  pkgs = read_pkg_list('arrow.txt')
305
325
  install_cran!(pkgs)
306
326
  if RUBY_ENGINE == 'ruby'
@@ -316,6 +336,17 @@ module Galaaz
316
336
  0
317
337
  end
318
338
 
339
+ # Env for install.packages("arrow") — see https://arrow.apache.org/docs/r/articles/install.html
340
+ def prepare_arrow_cran_env!
341
+ ENV['LIBARROW_BINARY'] = 'true'
342
+ ENV['NOT_CRAN'] = 'true'
343
+ # Do not fall back to the Boost/C++ source build that breaks on Arch.
344
+ ENV['LIBARROW_BUILD'] = 'false'
345
+ # Avoid linking against a mismatched system libarrow (e.g. pacman 24.x vs CRAN 25.x).
346
+ ENV['ARROW_USE_PKG_CONFIG'] = 'false'
347
+ puts 'galaaz add arrow: LIBARROW_BINARY=true (Apache prebuilt libarrow; no source build)'
348
+ end
349
+
319
350
  def add_tex
320
351
  unless command_present?('pandoc')
321
352
  warn 'galaaz add tex: pandoc not on PATH — install via your package manager (e.g. pacman -S pandoc)'
@@ -386,20 +417,21 @@ module Galaaz
386
417
  abort_unless(ok, 'git clone failed (is the ledger repo public/reachable?)')
387
418
  end
388
419
 
389
- gemfile = File.join(dest, 'Gemfile')
390
- text = File.read(gemfile)
391
- rewritten = text.gsub(/gem\s+["']galaaz["']\s*,\s*path:\s*["'][^"']+["']/, 'gem "galaaz"')
392
- if rewritten != text
393
- File.write(gemfile, rewritten)
394
- puts 'galaaz add ledger: Gemfile now uses RubyGems galaaz (no path:)'
395
- end
420
+ ensure_ledger_galaaz_gemfile!(File.join(dest, 'Gemfile'))
396
421
 
397
422
  Dir.chdir(dest) do
398
423
  need_cmd!('bundle')
399
- abort_unless(system('bundle', 'install'), 'bundle install failed')
400
- # Ensure gatekeeper for the bundled/installed gem path when possible.
401
- system('galaaz', 'setup') || warn('galaaz add ledger: galaaz setup returned non-zero; check gatekeeper')
424
+ # Lockfile often still pins path: ../galaaz @ an old version — pull latest RubyGems.
425
+ puts 'galaaz add ledger: bundle update galaaz'
426
+ unless system('bundle', 'update', 'galaaz')
427
+ abort_unless(system('bundle', 'install'), 'bundle install failed')
428
+ end
429
+ # Setup the gem Bundler will load (not PATH galaaz, which may differ).
430
+ puts 'galaaz add ledger: bundle exec galaaz setup'
431
+ system('bundle', 'exec', 'galaaz', 'setup') ||
432
+ warn('galaaz add ledger: bundle exec galaaz setup returned non-zero; check gatekeeper')
402
433
  abort_unless(system('bin/rails', 'db:prepare'), 'rails db:prepare failed')
434
+ abort_unless(system('bin/rails', 'db:migrate'), 'rails db:migrate failed')
403
435
  abort_unless(system({ 'SEED_PROFILE' => 'fast' }, 'bin/rails', 'db:seed'), 'rails db:seed failed')
404
436
  end
405
437
 
@@ -410,6 +442,21 @@ module Galaaz
410
442
  0
411
443
  end
412
444
 
445
+ # Ledger repo may ship `gem "galaaz", path: "..."`. Standalone installs use RubyGems
446
+ # (CRuby or JRuby), unpinned, so `bundle update galaaz` takes the newest published gem.
447
+ def ensure_ledger_galaaz_gemfile!(gemfile)
448
+ text = File.read(gemfile)
449
+ line = 'gem "galaaz"'
450
+ rewritten = text.gsub(/^\s*gem\s+["']galaaz["'].*$/, line)
451
+ if rewritten == text && text !~ /^\s*gem\s+["']galaaz["']/
452
+ rewritten = text + "\n#{line}\n"
453
+ end
454
+ if rewritten != text
455
+ File.write(gemfile, rewritten)
456
+ puts 'galaaz add ledger: Gemfile → gem "galaaz" (RubyGems latest; not path:)'
457
+ end
458
+ end
459
+
413
460
  # ---- helpers ----
414
461
 
415
462
  class CliError < StandardError; end
@@ -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.