holder 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c71f17499d6b8c123a250db4d8d4a76e3c6e6aadbd5d0bb76469a62f815d6211
4
+ data.tar.gz: 0301ea4c0f61ae3c70e26c6fd55b3688d6799697c3bf57ec1e4facfd05852c74
5
+ SHA512:
6
+ metadata.gz: df00fbb37ca6d2ec85f599d0c38bb50280711ef849801704023c6032f4b70b06adb63223fb1b4362e98295a5b6525496f6466a5796d50132f14ae4f6f7e1a5a1
7
+ data.tar.gz: 5133ecde07e6be99b797821a55bdf26f8d2ca128b7a127a0afbafc85860190569f66509d73df2c916a9fbe96b2940bd3ca699f9930a8ca6caba0fc39f6d0192f
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,170 @@
1
+ # ===========================================================================
2
+ # RuboCop Configuration
3
+ #
4
+ # Base: Stock RuboCop defaults
5
+ # AI guardrails: rubocop-claude plugin (all Claude/ cops + stricter metrics)
6
+ # Performance: rubocop-performance (with chain-hostile cops disabled)
7
+ #
8
+ # Philosophy: idiomatic Ruby, pipeline-style chaining, strict for AI agents,
9
+ # readable for humans.
10
+ # ===========================================================================
11
+
12
+ plugins:
13
+ - rubocop-claude
14
+ - rubocop-performance
15
+ - rubocop-rake
16
+ - rubocop-rspec
17
+
18
+ AllCops:
19
+ NewCops: enable
20
+ TargetRubyVersion: 4.0
21
+ Exclude:
22
+ - bin/*
23
+ - vendor/**/*
24
+ - lib/core_ext/**/*
25
+ - rakelib/project.rb
26
+ - rakelib/project_version.rb
27
+ - rakelib/version_tag.rb
28
+ - rakelib/github_release.rb
29
+ - rakelib/strict_shell.rb
30
+
31
+ # ===========================================================================
32
+ # Overrides from stock — personal style preferences
33
+ # ===========================================================================
34
+
35
+ # Double quotes everywhere. One less decision to make.
36
+ Style/StringLiterals:
37
+ EnforcedStyle: double_quotes
38
+
39
+ Style/StringLiteralsInInterpolation:
40
+ EnforcedStyle: double_quotes
41
+
42
+ # Frozen string literal is transitional cruft. Ruby 3.4 has chilled strings,
43
+ # full default freeze is coming in a future Ruby.
44
+ Style/FrozenStringLiteralComment:
45
+ EnforcedStyle: never
46
+
47
+ # Pipeline style. Chaining multi-line blocks is the whole point.
48
+ Style/MultilineBlockChain:
49
+ Enabled: false
50
+
51
+ # Block delimiters are a taste call. Pipeline code uses braces for chaining,
52
+ # do/end for side effects. No cop captures this nuance.
53
+ Style/BlockDelimiters:
54
+ Enabled: false
55
+
56
+ # Write arrays like arrays.
57
+ Style/WordArray:
58
+ Enabled: false
59
+
60
+ Style/SymbolArray:
61
+ Enabled: false
62
+
63
+ # Argument indentation: consistent 2-space indent, not aligned to first arg.
64
+ Layout/FirstArgumentIndentation:
65
+ EnforcedStyle: consistent
66
+
67
+ # Dot-aligned chaining. Dots form a visual column.
68
+ Layout/MultilineMethodCallIndentation:
69
+ EnforcedStyle: aligned
70
+
71
+ # ===========================================================================
72
+ # Overrides from rubocop-claude — loosen where pipeline style conflicts
73
+ # ===========================================================================
74
+
75
+ Claude/NoOverlyDefensiveCode:
76
+ MaxSafeNavigationChain: 2
77
+
78
+ Style/SafeNavigation:
79
+ MaxChainLength: 2
80
+
81
+ # Allow `return a, b` for tuple-style returns.
82
+ Style/RedundantReturn:
83
+ AllowMultipleReturnValues: true
84
+
85
+ # ===========================================================================
86
+ # Overrides from rubocop-performance — disable chain-hostile cops
87
+ # ===========================================================================
88
+
89
+ Performance/ChainArrayAllocation:
90
+ Enabled: false
91
+
92
+ Performance/MapMethodChain:
93
+ Enabled: false
94
+
95
+ # ===========================================================================
96
+ # Additional tightening — not set by stock or rubocop-claude
97
+ # ===========================================================================
98
+
99
+ # Short blocks push toward small chained steps instead of fat lambdas.
100
+ Metrics/BlockLength:
101
+ Max: 8
102
+ CountAsOne:
103
+ - array
104
+ - hash
105
+ - heredoc
106
+ - method_call
107
+ AllowedMethods:
108
+ - command
109
+ - test
110
+ Exclude:
111
+ - "holder.gemspec"
112
+ - "rakelib/**/*"
113
+ - "spec/**/*"
114
+
115
+ # An integration example's subject is a behaviour, not a class -- "tearing down
116
+ # a group whose leader has already exited" -- and several of them legitimately
117
+ # drive the same class from different angles. So those files describe in prose.
118
+ # Unit specs still describe their class, and their paths still have to match it.
119
+ RSpec/DescribeClass:
120
+ Exclude:
121
+ - "spec/integration/**/*"
122
+
123
+ # Gemspec and rake files use patterns that trigger Claude cops legitimately.
124
+ Claude/NoFancyUnicode:
125
+ Exclude:
126
+ - "holder.gemspec"
127
+
128
+ Claude/MysteryRegex:
129
+ Exclude:
130
+ - "rakelib/**/*"
131
+
132
+ # Zeitwerk loaders are mutable by design — only flag literal mutable values.
133
+ Style/MutableConstant:
134
+ EnforcedStyle: literals
135
+
136
+ # Anonymous forwarding (*, **, &) breaks TruffleRuby, JRuby, and
137
+ # Ruby < 3.2. Named args are explicit and portable.
138
+ Style/ArgumentsForwarding:
139
+ Enabled: false
140
+
141
+ # Explicit begin/rescue/end is clearer than implicit method-body rescue.
142
+ Style/RedundantBegin:
143
+ Enabled: false
144
+
145
+ # Compact class names are fine for small files and tests.
146
+ Style/ClassAndModuleChildren:
147
+ Enabled: false
148
+
149
+ # Classes get rdoc.
150
+ Style/Documentation:
151
+ Enabled: true
152
+ Exclude:
153
+ - "spec/**/*"
154
+
155
+ # Trailing commas in multiline literals and arguments.
156
+ Style/TrailingCommaInArrayLiteral:
157
+ EnforcedStyleForMultiline: comma
158
+
159
+ Style/TrailingCommaInHashLiteral:
160
+ EnforcedStyleForMultiline: comma
161
+
162
+ Style/TrailingCommaInArguments:
163
+ EnforcedStyleForMultiline: comma
164
+
165
+ # Permit targeted inline `# rubocop:disable` directives. The HolderTest case class
166
+ # carries a few by-design exemptions (a large test class, churn helper metrics)
167
+ # that are clearer as local directives than as blanket per-file config excludes.
168
+ Style/DisableCopsWithinSourceCodeDirective:
169
+ Enabled: false
170
+
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.1
data/CLAUDE.md ADDED
@@ -0,0 +1,89 @@
1
+ # HOLDER
2
+
3
+ ## Description
4
+ Holder is a robust, yet ergonomic process supervisor written in ruby.
5
+ Its goal is to allow ruby to have more control in orchestrating continuous, external shellouts, like a short lived nginx server.
6
+
7
+ It leans on ruby's own open3 library, whilst handling the parts that open3 doesn't.
8
+
9
+ ## Rules
10
+
11
+ 1. **Commit often, and always forward**. Commit work frequently, and never rewrite history unless explicitly told to do so. "Rewriting history" is defined as any non-append only operation, such as amending, destructive rebasing (e.g. squashing), force pushing, and the like.
12
+
13
+ 2. **Idiomatic, current-generation code only.** Always write idiomatic Ruby, and idiomatic usage of whatever gems/frameworks this project depends on. This is a greenfield project on the latest version of every tool — use the newest APIs and patterns, never deprecated or legacy-compatibility ones.
14
+
15
+ 3. **Cross-reference the bundled framework docs before writing framework code.** Any line in a framework-related file (Rails, Sinatra, Hanami, etc., whichever this project uses) must first be checked against the bundled reference docs to confirm it follows the latest idiomatic pattern. Never write framework code from memory alone. Also use ruby-lsp to assist with writing.
16
+
17
+ 5. **Self-documenting code over comments.** Communicate intent primarily through naming:
18
+ - Well-named local variables (noun) and methods (verb) can often articulate the same thing a one line comment can.
19
+ - Proper abstractions and entities with good names usually tell a story better than comments. Distill:
20
+ - the _behaviors_ you need (methods/modules)
21
+ - the _performer_ of those behaviors (classes/objects)
22
+ - the _requirements_ of those behaviors (arguments/parameters)
23
+ - the _recipient_ (if any) of those behaviors (return value)
24
+ - If you have truly exhausted the above, then a comment is recommended.
25
+
26
+ 6. **Avoid vague "-or"/"-er" names** (e.g. `LineProcessor`, `DataManager`). Only use one when Ruby or its frameworks spec it out as a defined architectural role — like Rails does with `Controller` or `Serializer` — with its own responsibilities and conventions, not just a common-sounding name you've assigned yourself. Despite this gem being called "holder", it does not have a class with the same name - it leans on a properly named domain model.
27
+
28
+ 7. **"Done" means done**, as defined in the next section.
29
+
30
+ 8. **Specs are tiered by cost, and the tier decides everything else**, as defined in the Testing section.
31
+
32
+ ## Testing
33
+
34
+ RSpec only. Two tiers, and which one a spec belongs to is decided by what it
35
+ costs to run, never by what it is about.
36
+
37
+ **`spec/unit`** — pure logic. Spawns no child, sleeps for nothing, asserts no
38
+ duration. Held to a hard one-second budget that an example may lower and may
39
+ never raise: an example that wants longer is an integration example in the wrong
40
+ directory. These describe a **class**, and the file path has to match it
41
+ (`describe Holder::Tenant` lives at `spec/unit/holder/tenant_*_spec.rb`).
42
+
43
+ **`spec/integration`** — real children, real signals, host-dependent fixtures.
44
+ These describe a **behaviour in prose** (`RSpec.describe "tearing down a process
45
+ group"`), because the subject is a behaviour and several files legitimately
46
+ drive the same class from different angles.
47
+
48
+ Rules that follow from the split:
49
+
50
+ - **The watchdog is a backstop, not an assertion.** Its job is to stop a wedged
51
+ example, and its default is deliberately tight enough to also bound teardown
52
+ itself — most teardown examples assert liveness only *after* `terminate`
53
+ returns, so the budget is the only thing bounding how long `terminate` may
54
+ take. Do not loosen it to make something pass. An example whose own scripted
55
+ work approaches the budget states a bigger one with `it "...", timeout: 8`,
56
+ **and** asserts explicitly whatever the budget had been bounding implicitly.
57
+ - **A timing contract is an expectation, never a budget.** "Teardown returns
58
+ within `PUMP_GRACE + 2`" is `expect(elapsed { ... }).to be < ...`.
59
+ - **Never loosen a cop to make a spec pass.** A `RSpec/ExampleLength`,
60
+ `MultipleExpectations` or `SpecFilePathFormat` offence means the spec is not
61
+ fine-grained enough: split the example per contract, lift staging into a named
62
+ helper, or split the file. `:aggregate_failures` is for facets of one
63
+ genuinely expensive staged scenario, not for dodging a split.
64
+ - **Nothing survives an example.** Every child, pipe, tmpfile and path is
65
+ registered with `cleanup` so it is undone even when the example fails.
66
+ - Support classes and helper modules live in `spec/support` and are
67
+ Zeitwerk-autoloaded, exactly as the gem's own constants are.
68
+
69
+ **A ported or refactored spec has never been seen to fail, so it has not been
70
+ tested.** Before trusting one, re-introduce the regression it exists to catch
71
+ and watch it go red.
72
+
73
+ ## Definition of "done"
74
+
75
+ A task is not done until every loose end is taken care of. Do not stop with outstanding follow-ups, deferred fixes, or "still pending" items — finish them as part of the task.
76
+
77
+ State completion without caveats. A task is either **done and production ready** or it is **not done** — there is no third state. Do not hedge, do not attach "known limitations" or "runner-ups" to something you call complete. If something remains, the task is not done.
78
+
79
+ **Blocked is a specific claim, not a synonym for difficult.** You are genuinely blocked only when completing the task requires a decision, credential, permission, or resource outside your access — not because the problem is hard, ambiguous, or would take a long time to work through. If you can make progress by reasoning further, researching, or trying an alternative approach, you are not blocked.
80
+
81
+ If you are genuinely blocked, you are to do the following before stopping "not done" work:
82
+
83
+ 1. Record the reason in `_claude/blocklog.md`.
84
+ 2. Spin up 3 subagents, each given the same problem statement independently, without seeing each other's reasoning until after they've each proposed a path forward.
85
+ 3. Communicate the issue at hand, and what you think the paths forward are.
86
+ 4. Record each subagent's proposed path and reasoning in `_claude/blocklog.md`.
87
+ 5. If at least 2/3 subagents independently agree on the same path forward, take that path. If the agreement is because they were fed the same framing or assumptions rather than reaching it independently, treat that as no consensus. Otherwise:
88
+ - Stop working.
89
+ - Report to user that you are stopping because the work is **not done** and you are **blocked**.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 David Gillis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # Holder
2
+
3
+ A process supervisor that ensures no child is left behind.
4
+
5
+ Spawning a child process in Ruby is easy; tearing it down cleanly is not. A child
6
+ that ignores `SIGTERM`, a shell that backgrounds grandchildren into the same
7
+ process group, a redirect pump blocked on an IO that never reaches EOF — any of
8
+ these can leave you with orphaned processes, leaked file descriptors, or dangling
9
+ threads.
10
+
11
+ Holder wraps `Open3` and gives you a single `Handle` that owns the whole lifecycle:
12
+ it launches the child in its own process group, lets you talk to its streams, and
13
+ on teardown signals the **entire group**, escalates to `SIGKILL` if the grace
14
+ period lapses, reaps the child, joins the redirect pumps, and closes every pipe it
15
+ owns. Teardown is idempotent, thread-safe, and callable from any thread.
16
+
17
+ ## Requirements
18
+
19
+ - Ruby >= 4.0.1
20
+ - A Unix-like OS (teardown relies on POSIX process groups and signals)
21
+
22
+ ## Installation
23
+
24
+ Add the gem to your application's Gemfile:
25
+
26
+ ```
27
+ $ bundle add holder
28
+ ```
29
+
30
+ Or install it directly:
31
+
32
+ ```
33
+ $ gem install holder
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ A `Holder::Tenant` describes a command to run; `#run` launches it and returns a
39
+ `Holder::Handle`. `#run` has two forms.
40
+
41
+ ### Block form — scoped, self-cleaning
42
+
43
+ The handle is yielded to the block and torn down automatically when the block
44
+ exits, **even if it raises**. `#run` returns the block's value. This is ideal for
45
+ a process whose lifetime should be tied to a scope — a server you need up only
46
+ while a test runs, say:
47
+
48
+ ```ruby
49
+ require "holder"
50
+
51
+ # Bring nginx up only while your integration suite runs. nginx forks a master
52
+ # plus worker processes; signalling just the master would orphan the workers, so
53
+ # Holder tears down the whole process group. On block exit — or if the block
54
+ # raises — everything is reaped and the port is freed.
55
+ Holder::Tenant.new("nginx", "-g", "daemon off;", "-c", "/etc/nginx/test.conf").run do |nginx|
56
+ sleep 0.5 # wait for nginx to bind the port
57
+ MyApp::IntegrationSuite.run(base_url: "http://localhost:8080")
58
+ end
59
+ ```
60
+
61
+ ### No-block form — you own teardown
62
+
63
+ `#run` returns the handle; call `#wait` to block until it exits on its own, or
64
+ `#terminate`/`#interrupt` to stop it whenever you like. Good for a long-running
65
+ process you read from until you've seen enough:
66
+
67
+ ```ruby
68
+ # Follow a growing log file in the background, then stop it when you're done.
69
+ handle = Holder::Tenant.new("tail", "-F", "/var/log/app.log").run
70
+
71
+ handle.stdout.each_line do |line|
72
+ break if line.include?("migration complete")
73
+ end
74
+
75
+ handle.terminate # SIGTERM the group, escalate to SIGKILL after the grace period, reap
76
+ ```
77
+
78
+ ### Redirecting streams
79
+
80
+ Pass an **IO object** (not a path or fd number) as `in:`, `out:`, or `err:`:
81
+
82
+ ```ruby
83
+ # Feed an IO as the child's stdin (drained to EOF, then closed for you)
84
+ sorted = File.open("names.txt") do |input|
85
+ Holder::Tenant.new("sort", in: input).run { |handle| handle.stdout.read }
86
+ end
87
+
88
+ # Send the child's stdout/stderr to your own IOs
89
+ File.open("build.log", "w") do |log|
90
+ Holder::Tenant.new("make", "build", out: log, err: log).run.wait
91
+ end
92
+ ```
93
+
94
+ A stream you redirect is talked to through *your* IO, so the matching accessor on
95
+ the handle is `nil`; a stream you leave alone is exposed as a pipe
96
+ (`handle.stdin` / `handle.stdout` / `handle.stderr`).
97
+
98
+ Anything other than an IO or `nil` is rejected by `#run`, before anything is
99
+ spawned:
100
+
101
+ ```ruby
102
+ Holder::Tenant.new("cat", in: 0).run # => ArgumentError
103
+ Holder::Tenant.new("cat", out: "log.txt").run # => ArgumentError
104
+ Holder::Tenant.new("cat", err: StringIO.new).run # => ArgumentError
105
+ ```
106
+
107
+ ### Group teardown
108
+
109
+ The child always runs in its own process group (this is not overridable), so
110
+ teardown reaches backgrounded grandchildren too. Signalling only the wrapper
111
+ process would orphan anything it spawned; Holder signals the whole group:
112
+
113
+ ```ruby
114
+ # A dev script that boots a server and a file watcher alongside it. Both — and
115
+ # the shell that launched them — are reaped when the block exits.
116
+ Holder::Tenant.new("sh", "-c", "npm run watch & npm start").run do |handle|
117
+ # ... run against the server ...
118
+ end
119
+ ```
120
+
121
+ ### Forwarding spawn options
122
+
123
+ Any keyword other than `in:`/`out:`/`err:` is forwarded to the underlying spawn
124
+ (`chdir`, `umask`, `unsetenv_others`, ...):
125
+
126
+ ```ruby
127
+ Holder::Tenant.new("git", "log", "--oneline", "-5", out: $stdout, chdir: "/path/to/repo").run.wait
128
+ ```
129
+
130
+ ## API
131
+
132
+ ### `Holder::Tenant`
133
+
134
+ - `Tenant.new(*command, in: nil, out: nil, err: nil, **spawn_opts)` — describe a
135
+ command. `in:`/`out:`/`err:` take an IO or `nil`; `spawn_opts` are forwarded to
136
+ the spawn.
137
+ - `#run` — launch, return a `Handle` (caller owns teardown).
138
+ - `#run { |handle| ... }` — launch, yield the handle, tear it down on block exit,
139
+ and return the block's value.
140
+
141
+ ### `Holder::Handle`
142
+
143
+ | Member | Description |
144
+ | --- | --- |
145
+ | `#pid` | the child's process id |
146
+ | `#stdin` / `#stdout` / `#stderr` | the pipe for a stream you did **not** redirect, otherwise `nil` |
147
+ | `#wait(drain_grace: 2)` | block until the child exits on its own, reap group leftovers, give a redirected `out:` up to `drain_grace` seconds to finish draining, and return the child's `Process::Status` |
148
+ | `#terminate(grace: 5)` | `SIGTERM` the group, wait `grace` seconds, then `SIGKILL`; reap and close pipes. Returns the `Process::Status` |
149
+ | `#interrupt(grace: 5)` | same as `#terminate` but the first signal is `SIGINT` |
150
+ | `#pump_error` | the unexpected error a redirect pump hit (e.g. `ENOSPC`) — or a `StalledSinkError` when `#wait` gave up on an `out:` sink that would not drain — else `nil`; available after teardown. When both pumps fail, the output-losing (drain) error wins |
151
+
152
+ `#terminate`, `#interrupt`, and `#wait` are idempotent, thread-safe, and may be
153
+ called from any thread; calling `#terminate` twice returns the same status.
154
+ Concurrent teardowns share one escalation clock — the earliest deadline any
155
+ caller asked for wins, so a `terminate(grace: 0)` is never stuck behind another
156
+ caller's longer grace.
157
+
158
+ ### `Holder::Error`
159
+
160
+ Base error class for the gem. Its one subclass, `Holder::StalledSinkError`, is
161
+ what `#pump_error` reports when `#wait` gave up on an `out:` sink that would
162
+ not drain.
163
+
164
+ ## How teardown works
165
+
166
+ 1. Signal the whole process group with the first signal (`TERM` or `INT`) — always,
167
+ even if the leader has already exited, since orphaned grandchildren keep the
168
+ group's pgid alive.
169
+ 2. Wait up to `grace` seconds for the group to die; if it doesn't, send `SIGKILL`
170
+ and wait again.
171
+ 3. Reap the child.
172
+ 4. Join the redirect pumps, giving a healthy one `Handle::PUMP_GRACE` seconds to
173
+ finish draining; a pump still blocked on a misbehaving IO is interrupted so it
174
+ can never wedge teardown.
175
+ 5. Close every pipe the handle owns. Caller-provided `in:`/`out:`/`err:` IOs are
176
+ **not** closed — you opened them, so you close them.
177
+
178
+ `#wait` differs in two ways: it sends no first signal (group leftovers found
179
+ after the leader's own exit are killed outright), and it is far more patient
180
+ with the `out:` drain — a child that exited on its own gets `drain_grace`
181
+ seconds (default 2, versus teardown's 1-second `PUMP_GRACE`) for its redirected
182
+ output to finish draining, and a healthy-but-slower sink can be granted more.
183
+ A sink that still hasn't drained by then cannot wedge `#wait`: the drain is cut
184
+ off and the discarded output surfaces as a `StalledSinkError` in `#pump_error`
185
+ — never a silent truncation.
186
+
187
+ ## Development
188
+
189
+ After checking out the repo, run `bundle install` to install dependencies. Then:
190
+
191
+ - `rake spec` — run the whole suite
192
+ - `rake spec:unit` — the fast tier only (`spec/unit`): pure logic, no child
193
+ processes, and a hard 1-second per-example budget. This is the edit-run loop.
194
+ - `rake spec:integration` — the slow tier only (`spec/integration`): real
195
+ children, real signals, timing budgets
196
+ - `rake rubocop` — run the linter
197
+ - `rake` — spec then rubocop (the default task)
198
+ - `rake zeitwerk:validate` — verify the gem follows Zeitwerk naming conventions
199
+
200
+ Specs are tiered by cost, not by subject. A `spec/unit` example must stay
201
+ sub-second and spawn nothing; an example that needs a real child belongs in
202
+ `spec/integration`, where the watchdog is a hang catcher rather than an
203
+ assertion — anything a budget would have bounded implicitly is stated as an
204
+ explicit expectation instead.
205
+
206
+ ## License
207
+
208
+ The gem is available as open source under the terms of the
209
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ namespace :spec do
8
+ desc "Run the fast tier only -- pure logic, no child processes"
9
+ RSpec::Core::RakeTask.new(:unit) { |task| task.pattern = "spec/unit/**/*_spec.rb" }
10
+
11
+ desc "Run the integration tier only -- real children, timing budgets"
12
+ RSpec::Core::RakeTask.new(:integration) { |task| task.pattern = "spec/integration/**/*_spec.rb" }
13
+ end
14
+
15
+ require "rubocop/rake_task"
16
+ RuboCop::RakeTask.new
17
+
18
+ require "gempilot/version_task"
19
+ Gempilot::VersionTask.new
20
+
21
+ namespace :zeitwerk do
22
+ desc "Verify all files follow Zeitwerk naming conventions"
23
+ task :validate do
24
+ ruby "-e", <<~RUBY
25
+ require 'holder'
26
+ Holder::LOADER.eager_load(force: true)
27
+ puts 'Zeitwerk: All files loaded successfully.'
28
+ RUBY
29
+ end
30
+ end
31
+
32
+ task default: [:spec, :rubocop]
@@ -0,0 +1,5 @@
1
+ module Holder
2
+ ##
3
+ # Base error class for holder.
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,233 @@
1
+ module Holder
2
+ # A live process handle.
3
+ #
4
+ # A regular class, not a Data: a handle is stateful (it gets torn down) and
5
+ # has internals to hide. Data.define exposes every field as a public reader,
6
+ # which is the leak we're closing -- so the wait_thread, pump threads, owned
7
+ # pipes, and mutex live in ivars with no readers.
8
+ #
9
+ # Public surface: stdin/stdout/stderr (only the streams you did NOT redirect;
10
+ # a redirected stream is talked to via your own IO, so its accessor is nil),
11
+ # pid, and terminate/interrupt/wait.
12
+ #
13
+ # Teardown is plain method calls guarded by a mutex, so it's idempotent,
14
+ # thread-safe, and callable from any thread. terminate and interrupt are the
15
+ # same robust teardown differing only in the first signal: send it, wait
16
+ # GRACE for the group to die, then guarantee death with SIGKILL, reap the
17
+ # child, join the pumps, and close the pipes we own. Concurrent teardowns
18
+ # share one escalation clock -- the earliest deadline any caller asked for
19
+ # wins -- so a terminate(grace: 0) is never wedged behind another caller's
20
+ # longer grace. wait is the passive counterpart: no first signal, leftovers
21
+ # killed outright, and a redirected out: sink drained for a caller-tunable
22
+ # drain_grace (defaulting well past a teardown's PUMP_GRACE) -- bounded so a
23
+ # sink that never drains can't wedge wait, with a cut-short drain surfaced
24
+ # as a StalledSinkError in pump_error rather than a silent truncation.
25
+ class Handle
26
+ # seconds to wait after the first signal before escalating to SIGKILL
27
+ GRACE = 5
28
+
29
+ # seconds to wait for a pump to drain after the process exits
30
+ # before interrupting it (see reap_pumps)
31
+ PUMP_GRACE = 1
32
+
33
+ # interval between process-group liveness polls while awaiting the grace window
34
+ POLL = 0.02
35
+
36
+ # default for wait's drain_grace: seconds wait keeps delivering a redirected
37
+ # out: sink's buffered output -- patient well past a teardown's PUMP_GRACE --
38
+ # before giving up on a sink that never drains, so a wedged sink cannot block
39
+ # wait forever
40
+ WAIT_DRAIN_GRACE = 2
41
+
42
+ # Whether an EPERM from a group signal means the group is gone. macOS/BSD
43
+ # report a zombie-only group (its last survivor a defunct process) as EPERM,
44
+ # so there it means gone. Linux never reports EPERM for a group we own -- there
45
+ # EPERM means the group is alive but unsignalable (e.g. a child that dropped
46
+ # privilege), which must surface rather than be mistaken for an empty group.
47
+ EPERM_MEANS_GONE = !RUBY_PLATFORM.include?("linux")
48
+
49
+ attr_reader :stdin, :stdout, :stderr, :pid, :pump_error
50
+
51
+ def initialize(stdin:, stdout:, stderr:, wait_thread:, pump_threads:, owned_ios:, drain_pump: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
52
+ @stdin = stdin
53
+ @stdout = stdout
54
+ @stderr = stderr
55
+ @wait_thread = wait_thread
56
+ @pump_threads = pump_threads
57
+ @drain_pump = drain_pump
58
+ @owned_ios = owned_ios
59
+ @pid = wait_thread.pid
60
+ @pump_error = nil
61
+ @mutex = Mutex.new
62
+ @escalation_tick = ConditionVariable.new
63
+ @kill_deadline = nil
64
+ end
65
+
66
+ def terminate(grace: GRACE) = teardown("TERM", grace)
67
+
68
+ def interrupt(grace: GRACE) = teardown("INT", grace)
69
+
70
+ # Wait for the process to exit on its own, then reap its group and finalize.
71
+ # A redirected out: sink gets drain_grace seconds to finish draining (see
72
+ # deliver_redirected_output).
73
+ def wait(drain_grace: WAIT_DRAIN_GRACE)
74
+ # Block on the waiter OUTSIDE the mutex: Thread#value is itself
75
+ # thread-safe, and holding the mutex across the block would wedge a
76
+ # concurrent terminate -- it could not acquire the mutex to signal until
77
+ # the process exited on its own. finalize runs under the mutex and is
78
+ # idempotent, so a terminate signal that finalizes first is harmless.
79
+ status = @wait_thread.value
80
+ begin
81
+ @mutex.synchronize { kill_group_leftovers }
82
+ deliver_redirected_output(drain_grace)
83
+ ensure
84
+ # finalize even if reaping the group raised (an unsignalable group surfaces
85
+ # EPERM), so the owned pipes are always closed.
86
+ @mutex.synchronize { finalize }
87
+ end
88
+ status
89
+ end
90
+
91
+ private
92
+
93
+ # The child exited on its own, so bytes still buffered toward an out: sink are
94
+ # real output the caller asked for -- join the drain pump so it delivers them,
95
+ # patient well past a teardown's PUMP_GRACE by default, and a caller with a
96
+ # slower sink can raise drain_grace further. But a sink that never drains (a
97
+ # full pipe whose reader never reads) would wedge wait forever, so once the
98
+ # grace passes kill the pump and record the discarded output as a
99
+ # StalledSinkError in pump_error -- truncation surfaces, never passes as a
100
+ # clean delivery. Runs outside the mutex, so a concurrent terminate can still
101
+ # cut a wedged sink short even sooner.
102
+ def deliver_redirected_output(drain_grace)
103
+ return if @drain_pump.nil? || @drain_pump.join(drain_grace)
104
+
105
+ @drain_pump.kill
106
+ @drain_pump.join
107
+ @mutex.synchronize { @pump_error ||= StalledSinkError.new(drain_grace) }
108
+ end
109
+
110
+ def teardown(signal, grace)
111
+ @mutex.synchronize do
112
+ begin
113
+ escalate(signal, grace)
114
+ ensure
115
+ # Close our pipes and reap the pumps even when escalate raised -- a group
116
+ # we cannot signal surfaces EPERM, and the handle's resources must still
117
+ # be released rather than leaked.
118
+ finalize
119
+ @kill_deadline = nil
120
+ end
121
+ @wait_thread.value
122
+ end
123
+ end
124
+
125
+ # Send the first signal, grant the whole GROUP grace to exit, then guarantee
126
+ # death with SIGKILL and reap the leader.
127
+ def escalate(signal, grace)
128
+ # Always signal the group, even if the leader has already exited: an orphaned
129
+ # grandchild keeps the leader's pgid, so the group outlives the leader and
130
+ # still needs the signal. signal_group rescues the empty-group case.
131
+ signal_group(signal)
132
+ # Grace is measured against the whole GROUP, not just the leader: poll
133
+ # membership so a grandchild the leader backgrounded gets the same grace to
134
+ # shut down cleanly, then KILL whatever is left. The membership guard narrows
135
+ # -- but cannot close -- the window in which a recycled pgid could be
136
+ # signalled: probing liveness cannot prove the group is still ours, an
137
+ # inherent hazard of addressing processes by id.
138
+ await_group_exit(grace)
139
+ signal_group("KILL") if group_alive?
140
+ @wait_thread.join
141
+ end
142
+
143
+ def signal_group(signal)
144
+ begin
145
+ Process.kill("-#{signal}", @pid)
146
+ rescue Errno::ESRCH
147
+ # An empty group -- the leader is reaped and no members survive; nothing
148
+ # left to signal.
149
+ rescue Errno::EPERM
150
+ # macOS/BSD: a zombie-only group, also nothing left to signal. Linux: the
151
+ # group is alive but unsignalable -- surface it rather than silently
152
+ # treating the group as reaped.
153
+ raise unless EPERM_MEANS_GONE
154
+ end
155
+ end
156
+
157
+ # Block until the process group is empty or the shared kill deadline
158
+ # passes. Concurrent teardowns tighten one clock -- the earliest requested
159
+ # deadline wins -- and each tick releases the mutex, so a second caller is
160
+ # never wedged behind an in-flight grace window: it gets in, tightens the
161
+ # deadline, and every caller escalates together.
162
+ def await_group_exit(grace)
163
+ requested = monotonic + grace
164
+ @kill_deadline = [@kill_deadline || requested, requested].min
165
+ while group_alive?
166
+ deadline = @kill_deadline
167
+ break if deadline.nil? || monotonic >= deadline
168
+
169
+ @escalation_tick.wait(@mutex, POLL)
170
+ end
171
+ end
172
+
173
+ # wait sent no first signal, so group leftovers get no grace: a grandchild
174
+ # that outlived the leader is killed outright (no child left behind), which
175
+ # also releases any pipe end it held open so wait's drain can finish.
176
+ # Unless a terminate/interrupt is mid-grace -- then honor its window rather
177
+ # than cutting it short, and fire its overdue KILL ourselves only if that
178
+ # teardown never escalated (its thread may have died mid-flight).
179
+ def kill_group_leftovers
180
+ while group_alive?
181
+ deadline = @kill_deadline
182
+ break signal_group("KILL") if deadline.nil? || monotonic >= deadline
183
+
184
+ @escalation_tick.wait(@mutex, POLL)
185
+ end
186
+ end
187
+
188
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+
190
+ # True while the process group still has a member. getpriority with PRIO_PGRP
191
+ # probes the group directly by its (positive) group id and sends no signal,
192
+ # raising ESRCH once the group is empty. On Linux reading a group's priority
193
+ # needs no permission, so a live-but-unsignalable group correctly reads as
194
+ # alive and its EPERM surfaces where we actually signal it, in signal_group;
195
+ # macOS/BSD instead report a zombie-only group as EPERM, which means gone here.
196
+ def group_alive?
197
+ begin
198
+ Process.getpriority(Process::PRIO_PGRP, @pid)
199
+ true
200
+ rescue Errno::ESRCH, Errno::EPERM
201
+ false
202
+ end
203
+ end
204
+
205
+ def finalize
206
+ @pump_error ||= reap_pumps
207
+ @owned_ios.each { |io| io.close unless io.closed? }
208
+ end
209
+
210
+ # Join the pumps, but never let one wedge teardown. Once the process has
211
+ # exited, a healthy pump finishes draining in milliseconds, so it joins
212
+ # within PUMP_GRACE and is never touched. A pump still blocked after that
213
+ # is stuck on a misbehaving user IO -- an in: source that never reaches EOF
214
+ # ($stdin, an open socket), or an out: sink that never drains -- and its
215
+ # remaining work is moot now that the child is gone. Thread#kill unblocks
216
+ # copy_stream's read/write and runs the pump's ensure (which closes its
217
+ # pipe); a killed pump's #value is nil, so it's ignored here. Of the
218
+ # genuine captured errors, the drain pump's outranks the feed pump's: a
219
+ # failed drain lost output the caller redirected, while a failed feed
220
+ # merely starved a child that is already gone.
221
+ def reap_pumps
222
+ errors = @pump_threads.filter_map do |pump|
223
+ unless pump.join(PUMP_GRACE)
224
+ pump.kill
225
+ pump.join
226
+ end
227
+ error = pump.value
228
+ [pump, error] if error
229
+ end.to_h
230
+ errors[@drain_pump] || errors.values.first
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,13 @@
1
+ module Holder
2
+ ##
3
+ # Recorded in <tt>Handle#pump_error</tt> when +wait+ gave up on a redirected
4
+ # +out:+ sink that would not drain within its +drain_grace+: the sink's pump
5
+ # was interrupted and the child's still-buffered output discarded, so the
6
+ # redirect is incomplete. A healthy sink that is merely slower than the
7
+ # default can be granted more patience via <tt>wait(drain_grace:)</tt>.
8
+ class StalledSinkError < Error
9
+ def initialize(drain_grace)
10
+ super("out: sink still undrained after #{drain_grace}s; remaining output discarded")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ module Holder
2
+ ##
3
+ # Type matcher for the +in:+/+out:+/+err:+ redirect arguments. Defined under
4
+ # +Holder+ -- NOT core <tt>::IO</tt> -- and matches only real IO instances
5
+ # (<tt>$stdin</tt>/<tt>$stdout</tt>/<tt>$stderr</tt>, File, pipe ends). Integer
6
+ # fds, path strings, StringIO, Tempfile and other duck types are rejected at
7
+ # validation, so a stray <tt>run(in: 0)</tt> raises up front instead of slipping
8
+ # through and detonating later inside a pump.
9
+ module StreamType
10
+ def self.===(other) = other.is_a?(::IO)
11
+ end
12
+ end
@@ -0,0 +1,127 @@
1
+ require "open3"
2
+
3
+ module Holder
4
+ ##
5
+ # Builds and launches a child process, returning a Handle that owns its teardown.
6
+ #
7
+ # +in:+, +out:+, and +err:+ each accept an IO (or +nil+) to redirect that stream
8
+ # to; any other keyword (+chdir+, +umask+, ...) is forwarded to the spawn. The
9
+ # child always runs in its own process group so the whole group can be torn down
10
+ # together -- this is not overridable.
11
+ class Tenant
12
+ attr_reader :handle, :pgroup, :kwargs
13
+ private attr_reader :args
14
+ private attr_writer :handle
15
+
16
+ def initialize(*args, in: nil, out: nil, err: nil, **kwargs)
17
+ @args = args
18
+ @io_defs = { in:, out:, err: }
19
+ @pgroup = true
20
+ @kwargs = kwargs
21
+ end
22
+
23
+ def run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
24
+ # Validate the three streams up front. The one-line pattern match checks each
25
+ # is an IO or nil (in: is a keyword, so it can't be named as a local); we then
26
+ # read the values out by key. A bad stream raises ArgumentError here rather
27
+ # than slipping through and detonating later inside a pump.
28
+ unless @io_defs in { in: StreamType | nil, out: StreamType | nil, err: StreamType | nil }
29
+ raise ArgumentError, "in:, out:, and err: must each be an IO object or nil"
30
+ end
31
+
32
+ u_sin, u_sout, u_serr = @io_defs.values_at(:in, :out, :err)
33
+
34
+ # Open3 ALWAYS pipes stdin and stdout, so in:/out: are always pumped; err is
35
+ # the only stream that can go direct, and only via popen2. So the entire
36
+ # 8-case dispatch is just this, and the only thing handed to spawn directly
37
+ # is a redirected err:. Any extra kwargs (chdir, umask, unsetenv_others,
38
+ # close_others, ...) are forwarded to spawn; they go in first so our
39
+ # pgroup: true and err redirect win on collision and the group-teardown
40
+ # invariant is never overridden.
41
+ meth = u_serr ? :popen2 : :popen3
42
+ spawn_redirects = u_serr ? { err: u_serr } : {}
43
+
44
+ # Each form uses its matching Open3 form: block -> Open3's block form (its
45
+ # close+reap is a backstop under our kill); no-block -> Open3's non-block
46
+ # form (the handle owns teardown).
47
+ if block_given?
48
+ Open3.public_send(meth, *args, **kwargs, pgroup:, **spawn_redirects) do |*streams|
49
+ self.handle = build_handle(streams, meth, u_sin, u_sout, u_serr)
50
+ begin
51
+ yield handle
52
+ ensure
53
+ handle.terminate
54
+ end
55
+ end
56
+ else
57
+ streams = Open3.public_send(meth, *args, **kwargs, pgroup:, **spawn_redirects)
58
+ self.handle = build_handle(streams, meth, u_sin, u_sout, u_serr)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def build_handle(streams, meth, u_sin, u_sout, u_serr)
65
+ block_keys = meth == :popen3 ? %i[sin sout serr wait] : %i[sin sout wait]
66
+ piped = block_keys.zip(streams).to_h
67
+ sin_pipe = piped[:sin]
68
+ sout_pipe = piped[:sout]
69
+ serr_pipe = piped[:serr] # nil for popen2 (stderr isn't piped there)
70
+
71
+ # feed a redirected in: into the stdin pipe, then close it so the child
72
+ # reads EOF and can exit on its own (Open3.capture3's `i.close`)
73
+ feed_pump = pump(u_sin, sin_pipe, close_after: true) if u_sin
74
+ # drain a redirected out: from the stdout pipe into the caller's IO; the
75
+ # handle knows it apart so wait can deliver it to the last byte
76
+ drain_pump = pump(sout_pipe, u_sout) if u_sout
77
+ # a redirected err: went direct via popen2 -- no pump
78
+
79
+ Handle.new(
80
+ # hide the internal pipe for any stream you redirected; you talk to your IO
81
+ stdin: u_sin ? nil : sin_pipe,
82
+ stdout: u_sout ? nil : sout_pipe,
83
+ stderr: u_serr ? nil : serr_pipe,
84
+ wait_thread: piped.fetch(:wait),
85
+ pump_threads: [feed_pump, drain_pump].compact,
86
+ drain_pump:,
87
+ owned_ios: [sin_pipe, sout_pipe, serr_pipe].compact,
88
+ )
89
+ end
90
+
91
+ # The pump's value is its failure report: nil for a clean copy (or one cut
92
+ # short by teardown races), otherwise the first error worth surfacing --
93
+ # copying outranks closing, since a failed copy is the earlier cause. The
94
+ # close lives in an ensure so a pump killed mid-copy still closes its pipe;
95
+ # a killed pump never reaches the trailing expression, so its value stays
96
+ # nil as reap_pumps expects.
97
+ def pump(src, destination, close_after: false)
98
+ Thread.new do
99
+ error = nil
100
+ begin
101
+ error = copy_error(src, destination)
102
+ ensure
103
+ error ||= (close_error(destination) if close_after)
104
+ end
105
+ error
106
+ end
107
+ end
108
+
109
+ def copy_error(src, destination)
110
+ IO.copy_stream(src, destination)
111
+ nil
112
+ rescue IOError, Errno::EPIPE, Errno::EBADF
113
+ nil # source/dest torn down underneath us (the EPIPE Open3.capture3 swallows)
114
+ rescue StandardError => e
115
+ e # unexpected (e.g. ENOSPC writing a redirect); capture so teardown finishes
116
+ end
117
+
118
+ def close_error(destination)
119
+ destination.close unless destination.closed?
120
+ nil
121
+ rescue IOError, Errno::EPIPE, Errno::EBADF
122
+ nil # already torn down underneath us, same as a copy hitting a closed pipe
123
+ rescue StandardError => e
124
+ e # a close that lost buffered writes; capture so teardown finishes
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,3 @@
1
+ module Holder
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/holder.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "zeitwerk"
2
+
3
+ ##
4
+ # Top-level namespace for the holder gem.
5
+ #
6
+ # All constants under +Holder+ (Error, StalledSinkError, Tenant, Handle,
7
+ # StreamType) live in their own files under <tt>lib/holder/</tt> and are
8
+ # autoloaded by Zeitwerk -- there are no +require+ statements for local files.
9
+ module Holder
10
+ LOADER = Zeitwerk::Loader.for_gem
11
+ LOADER.setup
12
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: holder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Gillis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: zeitwerk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ email:
27
+ - david@flipmine.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - ".rspec"
33
+ - ".rubocop.yml"
34
+ - ".ruby-version"
35
+ - CLAUDE.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - lib/holder.rb
40
+ - lib/holder/error.rb
41
+ - lib/holder/handle.rb
42
+ - lib/holder/stalled_sink_error.rb
43
+ - lib/holder/stream_type.rb
44
+ - lib/holder/tenant.rb
45
+ - lib/holder/version.rb
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 4.0.1
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.16
65
+ specification_version: 4
66
+ summary: A process supervisor that ensures no child is left behind
67
+ test_files: []