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 +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +170 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +89 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/Rakefile +32 -0
- data/lib/holder/error.rb +5 -0
- data/lib/holder/handle.rb +233 -0
- data/lib/holder/stalled_sink_error.rb +13 -0
- data/lib/holder/stream_type.rb +12 -0
- data/lib/holder/tenant.rb +127 -0
- data/lib/holder/version.rb +3 -0
- data/lib/holder.rb +12 -0
- metadata +67 -0
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
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]
|
data/lib/holder/error.rb
ADDED
|
@@ -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
|
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: []
|