Class: Pikuri::Subprocess
- Inherits:
-
Object
- Object
- Pikuri::Subprocess
- Defined in:
- lib/pikuri/subprocess.rb
Overview
Chokepoint for all subprocess spawning in pikuri. Forces a new process group for each invocation, tracks pgids so descendants of the direct child (commands backgrounded with &) can be cleaned up at process exit, and captures combined stdout+stderr through a single pipe.
Seam discipline
All subprocess spawning in lib/ goes through Subprocess.spawn. Direct Process.spawn / Open3.* / system / backticks anywhere in lib/ are bugs. The convention is grep-enforceable: grep -rn ‘Process.spawn|Open3|system|backtick’ lib/ should only hit this file.
Timeouts are the caller’s job
Subprocess.spawn does not implement a timeout — Ruby’s Timeout.timeout cannot kill subprocesses cleanly. Callers that need a timeout wrap their argv with coreutils’ timeout binary:
Pikuri::Subprocess.spawn(
'timeout', '--signal=TERM', '--kill-after=5s', '120s',
'bash', '-c', command,
chdir: workspace.cwd.to_s
)
When timeout and its FD-inheriting children die, the combined output pipe closes and #wait‘s io.read returns. No Ruby-side timeout machinery; the timeout binary handles SIGTERM-then- SIGKILL race-free.
Backgrounded subprocesses
When a shell command backgrounds work with &, the resulting process stays in our pgroup. #wait returns as soon as the direct child exits, but Subprocess.active keeps the pgid in the tracked set as long as any process in the group is alive (probed with kill(0, -pgid)). On pikuri exit, Subprocess.cleanup! sends SIGTERM to every tracked group. The model can opt out via nohup cmd & or setsid cmd & — both detach from our group.
State is process-global
One @active Set and one at_exit for the whole process. A Mutex guards register/prune/cleanup; v1 is single-threaded, so this is more for the at_exit/register race than for current callers.
Why Pikuri::Subprocess, not top-level
First class actually under the Pikuri:: namespace. Domain classes (Tool, Agent, URLCache) are top-level as a legacy convention — they predate the namespacing decision and an eventual refactor moves them too. For now: library-level infrastructure under Pikuri::; domain objects flat. See CLAUDE.md for the convention.
Defined Under Namespace
Classes: Result
Instance Attribute Summary collapse
-
#io ⇒ IO
readonly
Read end of the combined stdout+stderr pipe.
-
#pgid ⇒ Integer
readonly
Process group id.
-
#pid ⇒ Integer
readonly
Direct child’s pid.
Class Method Summary collapse
-
.active ⇒ Array<Integer>
Currently-tracked process groups, with dead ones pruned as a side effect.
-
.cleanup! ⇒ void
SIGTERM every tracked process group.
-
.spawn(*argv, chdir:) ⇒ Subprocess
Spawn
argvin a new process group, redirecting stderr onto stdout.
Instance Method Summary collapse
-
#initialize(io:, wait_thr:) ⇒ Subprocess
constructor
A new instance of Subprocess.
-
#wait ⇒ Result
Block until the direct child exits, read whatever remains on the combined-output pipe, return a Result.
Constructor Details
#initialize(io:, wait_thr:) ⇒ Subprocess
Returns a new instance of Subprocess.
97 98 99 100 101 102 |
# File 'lib/pikuri/subprocess.rb', line 97 def initialize(io:, wait_thr:) @io = io @wait_thr = wait_thr @pid = wait_thr.pid @pgid = wait_thr.pid # pgroup:true → pgid == pid end |
Instance Attribute Details
#io ⇒ IO (readonly)
Returns read end of the combined stdout+stderr pipe. Exposed for future live-streaming consumers; v1 callers go straight to #wait, which drains it.
94 95 96 |
# File 'lib/pikuri/subprocess.rb', line 94 def io @io end |
#pgid ⇒ Integer (readonly)
Returns process group id. Equal to #pid since the child was spawned with pgroup: true (it’s the group leader).
89 90 91 |
# File 'lib/pikuri/subprocess.rb', line 89 def pgid @pgid end |
#pid ⇒ Integer (readonly)
Returns direct child’s pid.
85 86 87 |
# File 'lib/pikuri/subprocess.rb', line 85 def pid @pid end |
Class Method Details
.active ⇒ Array<Integer>
Currently-tracked process groups, with dead ones pruned as a side effect. Useful for a future /bg REPL command or a between-turn status line.
124 125 126 127 128 129 |
# File 'lib/pikuri/subprocess.rb', line 124 def active @mutex.synchronize do @active.delete_if { |g| !alive?(g) } @active.to_a end end |
.cleanup! ⇒ void
This method returns an undefined value.
SIGTERM every tracked process group. Used by at_exit (production) and after blocks (specs). Best-effort —ignores errors from already-dead groups.
136 137 138 139 140 141 |
# File 'lib/pikuri/subprocess.rb', line 136 def cleanup! @mutex.synchronize do @active.each { |g| Process.kill('-TERM', g) rescue nil } @active.clear end end |
.spawn(*argv, chdir:) ⇒ Subprocess
Spawn argv in a new process group, redirecting stderr onto stdout. Tracked for cleanup.
77 78 79 80 81 82 |
# File 'lib/pikuri/subprocess.rb', line 77 def self.spawn(*argv, chdir:) stdin, io, wait_thr = Open3.popen2e(*argv, chdir: chdir.to_s, pgroup: true) stdin.close register(wait_thr.pid) new(io: io, wait_thr: wait_thr) end |
Instance Method Details
#wait ⇒ Result
Block until the direct child exits, read whatever remains on the combined-output pipe, return a Result. The pgid stays tracked if the group still has live members (backgrounded children); pruned if everything’s gone.
110 111 112 113 114 115 116 |
# File 'lib/pikuri/subprocess.rb', line 110 def wait output = @io.read @io.close Result.new(output: output, status: @wait_thr.value) ensure self.class.send(:prune, @pgid) end |