scout-essentials 1.8.7 → 1.9.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +26 -12
  3. data/README.md +83 -112
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +226 -0
  6. data/doc/StartHere.md +122 -0
  7. data/doc/developer/AnnotationSystem.md +184 -0
  8. data/doc/developer/Architecture.md +147 -0
  9. data/doc/developer/Configuration.md +238 -0
  10. data/doc/developer/CoreUtilities.md +265 -0
  11. data/doc/developer/DesignPrinciples.md +129 -0
  12. data/doc/developer/ErrorHandling.md +203 -0
  13. data/doc/developer/LockingAndConcurrency.md +157 -0
  14. data/doc/developer/PathResolution.md +200 -0
  15. data/doc/developer/PersistenceAndResources.md +119 -0
  16. data/doc/developer/StreamingModel.md +236 -0
  17. data/doc/user/AnnotatingData.md +202 -0
  18. data/doc/user/CachingResults.md +183 -0
  19. data/doc/user/CommandLineOptions.md +189 -0
  20. data/doc/user/Cookbook.md +211 -0
  21. data/doc/user/HandlingStreams.md +236 -0
  22. data/doc/user/LoggingAndProgress.md +158 -0
  23. data/doc/user/ProducingResources.md +177 -0
  24. data/doc/user/RemoteData.md +157 -0
  25. data/doc/user/RunningCommands.md +218 -0
  26. data/doc/user/WorkingWithFiles.md +217 -0
  27. data/lib/scout/cmd.rb +343 -40
  28. data/lib/scout/concurrent_stream.rb +14 -1
  29. data/lib/scout/indiferent_hash.rb +1 -1
  30. data/lib/scout/log/fingerprint.rb +13 -8
  31. data/lib/scout/log/progress/report.rb +1 -1
  32. data/lib/scout/log.rb +4 -1
  33. data/lib/scout/misc/digest.rb +6 -5
  34. data/lib/scout/misc/format.rb +24 -0
  35. data/lib/scout/named_array.rb +1 -1
  36. data/lib/scout/open/stream.rb +2 -2
  37. data/lib/scout/open/util.rb +8 -4
  38. data/lib/scout/open.rb +3 -3
  39. data/lib/scout/path/find.rb +3 -2
  40. data/lib/scout/persist.rb +14 -10
  41. data/lib/scout/resource/produce.rb +9 -1
  42. data/research/annotations-data-analysis.md +206 -0
  43. data/research/behavior-probes.md +1925 -0
  44. data/research/commands-streaming-analysis.md +272 -0
  45. data/research/design-philosophy-analysis.md +383 -0
  46. data/research/doc-audit-findings.md +294 -0
  47. data/research/ecosystem-attribution.md +118 -0
  48. data/research/implementation-inventory-core.md +1029 -0
  49. data/research/implementation-inventory-open.md +417 -0
  50. data/research/implementation-inventory-path-persist-resource.md +774 -0
  51. data/research/io-paths-analysis.md +228 -0
  52. data/research/persistence-resources-analysis.md +244 -0
  53. data/research/synthesis-report.md +80 -0
  54. data/scout-essentials.gemspec +37 -15
  55. data/test/scout/open/test_remote.rb +1 -2
  56. data/test/scout/test_cmd.rb +411 -0
  57. metadata +36 -14
  58. data/doc/Annotation.md +0 -352
  59. data/doc/CMD.md +0 -363
  60. data/doc/ConcurrentStream.md +0 -163
  61. data/doc/IndiferentHash.md +0 -240
  62. data/doc/Log.md +0 -235
  63. data/doc/NamedArray.md +0 -174
  64. data/doc/Open.md +0 -331
  65. data/doc/Path.md +0 -217
  66. data/doc/Persist.md +0 -214
  67. data/doc/Resource.md +0 -229
  68. data/doc/SimpleOPT.md +0 -236
  69. data/doc/TmpFile.md +0 -154
@@ -0,0 +1,189 @@
1
+ # Command-Line Options
2
+
3
+ `SOPT` is scout-essentials' option parser: a small registry of inputs,
4
+ shortcuts and descriptions plus a **destructive** consumer that edits
5
+ `ARGV` in place. It is deliberately minimal — no subcommands, no coercion
6
+ beyond booleans, no config-file layer.
7
+
8
+ ## Declaring inputs
9
+
10
+ Options are declared as a single string. Each entry is one line:
11
+
12
+ ```
13
+ -[short]--[long][*] description
14
+ ```
15
+
16
+ ```ruby
17
+ require 'scout-essentials'
18
+
19
+ SOPT.parse <<~OPT
20
+ -o--organism* Organism code
21
+ -t--tissue* Tissue of origin
22
+ -d--dry-run Do not write anything
23
+ OPT
24
+ ```
25
+
26
+ The `*` suffix is the **only** type marker: it marks the option as taking a
27
+ string value. Without it the option is a boolean (`lib/scout/simple_opt/parse.rb:53`).
28
+ It does **not** mean "required".
29
+
30
+ `SOPT.setup(str)` parses the same grammar from a fuller usage document —
31
+ summary, synopsis (a line starting with `$`), description and options — and
32
+ then immediately calls `SOPT.consume`, so it both registers and consumes
33
+ `ARGV` in one step (`lib/scout/simple_opt/setup.rb`).
34
+
35
+ The registry lives in module-level accessors (`simple_opt/accessor.rb`):
36
+ `inputs`, `input_types`, `input_shortcuts`, `shortcuts`, `input_descriptions`,
37
+ `input_defaults`.
38
+
39
+ ## Consuming the command line
40
+
41
+ ```ruby
42
+ options = SOPT.consume # defaults to ARGV, which it MUTATES
43
+ ```
44
+
45
+ `SOPT.consume(args = ARGV)` walks `args` left to right and **deletes**
46
+ every token it recognises:
47
+
48
+ - a matching `--long` or `-s` is removed and, if the input takes a value,
49
+ the following token (or `=value`) is removed with it;
50
+ - unknown flags and free-standing words are left alone;
51
+ - `--` stops the whole loop: everything after it is left in `args`.
52
+
53
+ ```ruby
54
+ # probe_02 (tmp/rewrite_C/probe_02_sopt.rb)
55
+ argv = ['-o', 'Human', '--tissue', 'Liver', 'positional', '-d']
56
+ SOPT.consume(argv)
57
+ # => {:organism=>"Human", :tissue=>"Liver", :"dry-run"=>true}
58
+ # argv is now ["positional"]
59
+
60
+ argv = ['-d', '--', '--not-an-option', '-x']
61
+ # => {:"dry-run"=>true}; argv unchanged from "--" onwards
62
+ ```
63
+
64
+ Because the array is edited in place, `SOPT.consume` is how a program
65
+ separates its own options from the positional arguments it will still read.
66
+
67
+ ### Boolean parsing
68
+
69
+ Booleans are true unless the value is one of `F`, `false`, `FALSE`, `no`
70
+ (`simple_opt/get.rb:45`):
71
+
72
+ - `--dry-run=false`, `--dry-run=F` → `false`
73
+ - `--dry-run` → `true`
74
+ - `--dry-run false` → `false`, and Ruby logs a warning telling you to use
75
+ `=` instead — this convenience swallows the next token, so use `=`.
76
+
77
+ A word that merely *follows* a boolean flag and is not one of those four is
78
+ **not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV`
79
+ (probe_02).
80
+
81
+ ## State and reuse
82
+
83
+ `SOPT.consume` writes two things:
84
+
85
+ - the return value (an `IndiferentHash` with symbol keys), which is also
86
+ stashed as `@@current_options`; `SOPT.current_options = hash` lets you
87
+ seed it;
88
+ - `SOPT::GOT_OPTIONS`, a module-level hash that is **merged into, never
89
+ reset**. Every `consume` call accumulates there, which is how
90
+ sub-commands that each declare their own inputs still end up with a
91
+ global picture of what was given (probe_02: two separate `consume` calls
92
+ on disjoint inputs both appear in `GOT_OPTIONS`).
93
+
94
+ `SOPT.get(opt_str)` is just `parse` followed by `consume(ARGV)`.
95
+
96
+ ### `SOPT.require` — the only enforcement
97
+
98
+ Nothing about a declared option is required. If you want to fail on a
99
+ missing option, call it yourself:
100
+
101
+ ```ruby
102
+ SOPT.require(options, :organism, :tissue)
103
+ # raises ParameterException: Parameter 'tissue' not given
104
+ ```
105
+
106
+ `ParameterException < ScoutException < StandardError` (probe_02), so plain
107
+ `rescue` works. There is **no** variant that extracts a subset of the
108
+ options — `SOPT.get` always parses a fresh string and consumes the whole
109
+ `ARGV`.
110
+
111
+ ## Help text
112
+
113
+ `SOPT.doc` renders a man-page-style document:
114
+
115
+ ```text
116
+ myprog(1) -- <summary>
117
+ =========================
118
+
119
+ ## SYNOPSYS
120
+
121
+ myprog [--organism=<string>] [--tissue=<string>] [--dry-run[=false]]
122
+
123
+ ## OPTIONS
124
+
125
+ -o,--organism=<string> Organism code
126
+ ...
127
+ ```
128
+
129
+ The header really is `## SYNOPSYS` — the misspelling is in the source
130
+ (`simple_opt/doc.rb:112`) and callers grep for it; do not "fix" it in your
131
+ matching code. `SOPT.usage` prints the doc and calls `exit 0` (probe_07 traps
132
+ `SystemExit` and reports status 0).
133
+
134
+ `SOPT.input_doc` (used by `doc`) is also the public way to format an
135
+ explicit option list, and `SOPT.input_array_doc` formats
136
+ `[[name, type, description, default, options], ...]` arrays — that is the
137
+ form `Workflow`-level code uses to pass shortcut choices through.
138
+
139
+ ## Shortcuts and `fix_shortcut`
140
+
141
+ Every declared long name automatically gets a short form: the first letter
142
+ of the long name, if it is free (`simple_opt/doc.rb:33`,
143
+ `fix_shortcut(name[0], name)`). When it is taken, `SOPT.fix_shortcut`
144
+ searches for a free one:
145
+
146
+ 1. an existing shortcut already bound to that exact long name is reused;
147
+ 2. if the long name contains `-` or `_`, the initials of its parts
148
+ (`--max-cpu` → `-m` if free, else the accumulated initials);
149
+ 3. if it contains digits, the first letter plus the number;
150
+ 4. otherwise it walks forward through the letters.
151
+
152
+ If no shortcut can be found, `fix_shortcut` returns `nil` and the option
153
+ simply has no short form. **Collisions are silent**: declaring `-a--alpha`
154
+ and `-a--also` yields `{"a"=>"alpha", "al"=>"also"}` — the second entry
155
+ gets a longer shortcut rather than an error (probe_02). Live probe
156
+ (`tmp/rewrite_C/probe_07_sopt_extra.rb`): registering `t` while `-t` is
157
+ bound to `tissue` yields `"th" => "threshold"`; `another_one` gets the
158
+ initials `"ao"`; `alpha2` gets `"a2"`.
159
+
160
+ `SOPT.delete_inputs(['organism'])` removes an input from `inputs`,
161
+ `input_shortcuts`, `shortcuts`, `input_types`, `input_defaults` and
162
+ `input_descriptions` (`simple_opt/accessor.rb:39`). `input_shortcuts` is the
163
+ reverse map `{'organism'=>'o'}` (probe_07).
164
+
165
+ `SOPT.reset` clears **only** `shortcuts` and the internal `all` registry;
166
+ `inputs` and the other per-input tables survive (probe_02). Call
167
+ `SOPT.delete_inputs(SOPT.inputs.dup)` if you actually want an empty slate.
168
+
169
+ ## Quirks to design around
170
+
171
+ - Repeating the same boolean flag just re-sets it to `true`; string
172
+ options keep the last value (the hash is overwritten).
173
+ - `--opt=value` and `--opt value` are equivalent; `--opt=` (empty value)
174
+ yields `""`.
175
+ - `-x` unknown: left in `ARGV`, ignored.
176
+ - `--` is not removed from `ARGV` either; it only stops scanning.
177
+ - Options are keyed by their **long** name, symbolised. Shortcuts never
178
+ appear in the result.
179
+ - `SOPT.consume` returns the current options *and* keeps them in
180
+ `GOT_OPTIONS`; the two objects are not the same object, and `GOT_OPTIONS`
181
+ is the one that survives later `SOPT.reset` calls.
182
+
183
+ ## Where to go next
184
+
185
+ - [StartHere](../StartHere.md)
186
+ - [Logging and Progress](LoggingAndProgress.md) — `Log.warn` used by the
187
+ boolean heuristic.
188
+ - [Architecture](../developer/Architecture.md) — which repos own the
189
+ higher-level CLI layers (attribution table).
@@ -0,0 +1,211 @@
1
+ # Cookbook
2
+
3
+ Short, self-contained recipes that combine the scout-essentials modules.
4
+ Every snippet here was executed; the backing probes are listed under each
5
+ heading and live in `tmp/rewrite_C/` (older probes in `tmp/rewrite_A/`,
6
+ `tmp/rewrite_B/` are referenced by their P-numbers, which map to
7
+ `research/behavior-probes.md`).
8
+
9
+ The theme of the library: plain objects annotated with provenance, paths
10
+ resolved from a declaration, work cached on disk, streams piped without
11
+ holding everything in memory.
12
+
13
+ ## Get-or-build a file: `claim` + `produce_and_find`
14
+
15
+ ```ruby
16
+ require 'scout-essentials'
17
+
18
+ module Data
19
+ extend Resource
20
+ self.pkgdir = 'cookbook_probe'
21
+ end
22
+
23
+ Data.claim Data.tmp['list'], :string, "S001\nS002\nS003\n"
24
+ found = Data.tmp['list'].produce_and_find
25
+ Open.read(found) # => "S001\nS002\nS003\n"
26
+ Data.tmp['list'].produce_and_find # second call returns the same path, no work
27
+ ```
28
+
29
+ `Resource.claim(path, type, contents, block)` registers *how* a file is
30
+ produced (a type like `:string`/`:proc`, literal contents, or a block); the
31
+ claimed `Path` is annotated and its `produce` materialises it into the
32
+ resource tree (`lib/scout/resource/path.rb:2`,
33
+ `lib/scout/resource/produce.rb`). `produce_and_find` produces when needed and
34
+ returns `self.find`. Probe: `tmp/rewrite_C/probe_03_cookbook.rb`
35
+ (`claim` + first/second call returning the same path, `Open.read` content).
36
+
37
+ ## Cache invalidation with `:update` and `:check`
38
+
39
+ `Persist.persist` normally returns the cached value untouched. Two options
40
+ change that (`lib/scout/persist.rb`):
41
+
42
+ ```ruby
43
+ a = Persist.persist('expensive', :marshal, :update => false) { "computed-once" }
44
+ b = Persist.persist('expensive', :marshal, :update => false) { "recomputed" }
45
+ a == b # => true, block skipped both times
46
+ c = Persist.persist('expensive', :marshal, :update => true) { "forced-recompute" }
47
+ c # => "forced-recompute" — block re-run
48
+
49
+ d = Persist.persist('dependent', :marshal,
50
+ :check => 'tmp/src.txt') { "from-#{Open.read('tmp/src.txt')}" }
51
+ ```
52
+
53
+ `:update => true` always re-runs the block; `:check` names a file whose mtime
54
+ invalidates the entry. Probe: probe_03 (a == b, block not re-run; `:update`
55
+ re-runs; `:check` path resolves).
56
+
57
+ ## Fetch a remote file
58
+
59
+ ```ruby
60
+ require 'scout-essentials'
61
+
62
+ url = 'https://example.org/data.tsv'
63
+ Open.wget(url, :auto) # downloads; cached under Open.remote_cache_dir
64
+ data = Open.wget(url) # serves from cache on the next call
65
+
66
+ Open.scp('user@host:/path/file', 'local_copy', :target => 'user@host')
67
+ ```
68
+
69
+ `Open.wget` shells out to `wget` (`lib/scout/open/remote.rb`); `Open.read` on
70
+ an `http(s)://` or `ssh:` URL fetches transparently. Retries and rate limits
71
+ are controlled by `Open.wait(lag, key)`. See
72
+ [RemoteData.md](RemoteData.md) and
73
+ [Working with Files](WorkingWithFiles.md).
74
+
75
+ ## Annotate, serialise, restore
76
+
77
+ ```ruby
78
+ require 'scout-essentials'
79
+
80
+ module SampleInfo
81
+ extend Annotation
82
+ annotation :organism, :tissue
83
+ end
84
+
85
+ sample = SampleInfo.setup('S001', :organism => 'Human', :tissue => 'Liver')
86
+ sample.annotation_hash # {:organism=>"Human", :tissue=>"Liver"}
87
+ sample.serialize # {:organism=>"Human", :tissue=>"Liver",
88
+ # :annotation_types=>[SampleInfo], :annotated_array=>false,
89
+ # :literal=>"S001"}
90
+
91
+ restored = Annotation.setup('S003', 'SampleInfo',
92
+ 'organism' => 'Human', 'tissue' => 'Liver')
93
+ restored.tissue # => "Liver"
94
+ ```
95
+
96
+ `Annotation.setup(obj, "A|B", hash)` is the module-level deserialiser; the
97
+ `"A|B"` string is split on `|` and each name is looked up as a constant. An
98
+ unknown type name is **warned about and skipped** (probe_03 prints
99
+ `Annotation NoSuchAnnotation not defined` on STDERR, then
100
+ `Annotation.setup('S004', 'NoSuchAnnotation', ...)` returns the plain
101
+ un-annotated string). `serialize` produces a plain `Hash` with `:literal`,
102
+ `:annotation_types` (module objects) and `:annotated_array` keys. There is no
103
+ TSV serialisation of annotations in this repo — `Annotation.tsv` belongs to
104
+ scout-gear (see the [attribution table](../developer/Architecture.md)).
105
+
106
+ ## Stream a pipeline without buffering
107
+
108
+ ```ruby
109
+ require 'scout-essentials'
110
+
111
+ stream = Open.open_pipe do |sin|
112
+ ['S001', 'S002'].each { |s| sin.write "1\t#{s}\n" }
113
+ sin.close
114
+ end
115
+ Open.consume_stream(stream) # => "1\tS001\n1\tS002\n"
116
+ ```
117
+
118
+ `Open.open_pipe` builds an IO from a block and returns it unread; `consume_stream`
119
+ drains it. `tee_stream` splits one stream into two consumers:
120
+
121
+ ```ruby
122
+ Open.write('tmp/in.txt', "1\n2\n3\n")
123
+ main, copy = Open.tee_stream(
124
+ CMD.cmd('gzip -c', :pipe => true, :in => Open.open('tmp/in.txt'))
125
+ )
126
+ Open.consume_stream(main, true, 'tmp/in.txt.gz') # writes the file
127
+ copy.join # waits for the copy thread
128
+ File.exist?('tmp/in.txt.gz') # => true
129
+ ```
130
+
131
+ Consumers follow the rescue contract for the deliberate-abort signals:
132
+
133
+ ```ruby
134
+ begin
135
+ data = Open.consume_stream(stream)
136
+ rescue Aborted, AbortedStream
137
+ Log.warn "aborted mid-stream"
138
+ end
139
+ ```
140
+
141
+ Probe: probe_03 (open_pipe data, tee_stream producing a real gzip file).
142
+ See [Handling Streams](HandlingStreams.md) and the
143
+ [Streaming Model](../developer/StreamingModel.md).
144
+
145
+ ## Progress bars
146
+
147
+ ```ruby
148
+ require 'scout-essentials'
149
+
150
+ items = (1..100).to_a
151
+
152
+ Log::ProgressBar.with_obj_bar(items, 100) do |bar|
153
+ items.each { bar.tick }
154
+ end
155
+
156
+ Log::ProgressBar.with_bar(20, :desc => 'Counting') do |bar|
157
+ 20.times { bar.tick }
158
+ end
159
+
160
+ bar = Log::ProgressBar.new_bar(10, :desc => 'Half-way')
161
+ 10.times { bar.tick }
162
+ Log::ProgressBar.remove_bar(bar)
163
+ ```
164
+
165
+ The block of `with_obj_bar` receives **only the bar**; the object is never
166
+ yielded back (`log/progress/util.rb:167-170`). The *second* argument selects
167
+ the bar: a String is the description, a Numeric the max, a Hash the options,
168
+ an existing bar object is reused, and `true` guesses the max from the first
169
+ argument via `guess_obj_max` — which returns `nil` even for a plain `Array`
170
+ in a bare scout-essentials process: the first `when TSV` arm raises
171
+ `NameError` (the constant is not defined here) and the surrounding
172
+ `rescue Exception` turns that into `nil` (live check;
173
+ `log/progress/util.rb:101-137`). Pass a Numeric max or a `:max` hash for
174
+ deterministic sizing. There is no `Log.bar` helper; use
175
+ `Log::ProgressBar.new_bar` / `remove_bar`.
176
+
177
+ ## Command-line usage with SOPT
178
+
179
+ ```ruby
180
+ require 'scout-essentials'
181
+
182
+ SOPT.parse <<~OPT
183
+ -o--organism* Organism code
184
+ -t--tissue* Tissue of origin
185
+ -d--dry-run Skip writing
186
+ OPT
187
+
188
+ argv = ['-o', 'Human', 'positional', '-d']
189
+ options = SOPT.consume(argv) # argv is mutated: matched args removed
190
+ argv # => ["positional"]
191
+
192
+ SOPT.require(options, :organism) # ParameterException when nil
193
+ ```
194
+
195
+ The `*` marks an option as **taking a string value**; without it the option is
196
+ a boolean. Probe: probe_02 (options hash, `argv_left == ["positional"]`,
197
+ `SOPT.require` raising). See
198
+ [Command-Line Options](CommandLineOptions.md).
199
+
200
+ ## Recipe index
201
+
202
+ | Task | Tool | Page |
203
+ | --- | --- | --- |
204
+ | Resolve a path | `Path.setup`, `Resource` | [Working with Files](WorkingWithFiles.md) |
205
+ | Run a command | `CMD.cmd` | [Running Commands](RunningCommands.md) |
206
+ | Cache a computation | `Persist.persist` | [Caching Results](CachingResults.md) |
207
+ | Build a file on demand | `Resource.claim` + `produce_and_find` | [Producing Resources](ProducingResources.md) |
208
+ | Annotate objects | `Annotation` | [Annotating Data](AnnotatingData.md) |
209
+ | Pipe data | `Open.open_pipe`, `CMD.cmd(:pipe)` | [Handling Streams](HandlingStreams.md) |
210
+ | Log and show progress | `Log`, `Log::ProgressBar` | [Logging and Progress](LoggingAndProgress.md) |
211
+ | Parse options | `SOPT` | [Command-Line Options](CommandLineOptions.md) |
@@ -0,0 +1,236 @@
1
+ # Handling Streams
2
+
3
+ When `CMD.cmd(..., :pipe => true)` or `Open.open_pipe` hands you an IO, that
4
+ object has been extended with `ConcurrentStream`: it carries the producer
5
+ thread(s) and pid(s) that feed it, plus callbacks and an abort protocol. This
6
+ page is the user-facing contract; the internals are in
7
+ [Streaming Model](../developer/StreamingModel.md).
8
+
9
+ ## Anatomy
10
+
11
+ ```ruby
12
+ io = CMD.cmd('grep x', :pipe => true)
13
+ io.threads # => [input thread, stderr thread, ...] (producer side)
14
+ io.pids # => [pid]
15
+ io.callback # => nil or a Proc run on successful join (see :post)
16
+ io.abort_callback # => nil or a Proc run on abort(exception)
17
+ io.std_err # => "" (filled by :save_stderr)
18
+ io.log # => last stderr line (pipe mode, when :log => true)
19
+ io.lock, io.lockfile, io.pair, io.next, io.filename, io.autojoin, io.no_fail
20
+ ```
21
+
22
+ Attributes come from `ConcurrentStream.setup` (concurrent_stream.rb:12-70) and
23
+ `CMD.cmd` sets `:pids`, `:threads`, `:autojoin` (default `no_fail`) and
24
+ `:no_fail` on the returned stream. `exit_status` exists but is *not* reliable
25
+ after a normal read+join — it stays `nil` because only `join_pids` sets it, and
26
+ that method empties `pids` when it runs; see
27
+ [Running Commands](RunningCommands.md) for the probe.
28
+
29
+ ## `close` vs `join` vs abort
30
+
31
+ - **`join`** is the finishing move: `join_threads`, `join_pids`, raise
32
+ `stream_exception` if one is set, run `join_callback` (the composed callback),
33
+ close, release the lock, and mark `joined?`. It never joins `@pair` — the
34
+ other end of an internal pipe is the producer's business.
35
+ - **`close`** on an `autojoin` stream performs `super` (the plain IO close) and
36
+ then joins if the stream is at EOF; with `autojoin` off it just closes.
37
+ Closing early while a producer still writes raises `IOError`/`Errno::EPIPE`
38
+ in the producer, which the stream converts to an abort — so for an early,
39
+ deliberate stop use `abort`, not `close`.
40
+ - **`abort(exception)`** is the safe early close: it marks the stream aborted,
41
+ extends it with the `AbortedStream` marker, runs `abort_callback`, interrupts
42
+ and joins every producer thread, sends `SIGINT` to every pid, clears the
43
+ callbacks, **propagates to `@pair`** (aborting the other pipe end), closes and
44
+ unlocks. It is idempotent (a second call only logs). Threads get
45
+ `Aborted.new` raised in them, so producer bodies should rescue `Aborted`.
46
+ - **`force_close` does not exist** in this repo. The only reference is a dead
47
+ `respond_to?` guard inside `Open.grep` (open/util.rb:26); a plain IO has no
48
+ such method (probe `tmp/rewrite_B/probe_13_force_close.rb`: `IO#respond_to?
49
+ (:force_close) => false`). The early-close tool is `abort`.
50
+
51
+ Consumers should follow this rescue contract:
52
+
53
+ ```ruby
54
+ begin
55
+ data = Open.consume_stream(stream, false, dst) # or .each / .read
56
+ rescue Aborted, AbortedStream
57
+ # deliberate stop: log and move on; the stream is already aborted+closed
58
+ rescue ConcurrentStreamProcessFailed => e
59
+ # a producer failed; e.pid / e.msg available
60
+ end
61
+ ```
62
+
63
+ `AbortedStream` is a *marker module* (`concurrent_stream.rb:4-9`) —
64
+ `AbortedStream.setup(obj, exception)` extends an object so later code can read
65
+ `obj.exception` and recover the real cause; `sensible_write` uses exactly that.
66
+
67
+ ## Callbacks
68
+
69
+ - `stream.add_callback(&block)` **composes**: it wraps the existing callback so
70
+ the *new* block runs *after* the old one (probe
71
+ `tmp/rewrite_B/probe_19_streaming_apis.rb`: `add_callback order:
72
+ [:first, :second]`). `ConcurrentStream.setup(stream, &block)` also composes in
73
+ that order, which is why calling setup twice on the same stream is safe.
74
+ - `callback` / `abort_callback` are plain accessors over single chained procs
75
+ (built by setup when you pass `:callback` / `:abort_callback` options or a
76
+ block). There is **no `add_abort_callback`** — assign `abort_callback = proc
77
+ { |exception| ... }` (only the last one wins unless you compose by hand).
78
+ - `join` runs the composed `callback`; `abort` runs `abort_callback` with the
79
+ exception and then discards both callbacks.
80
+
81
+ ```ruby
82
+ s = Open.open_pipe { |sin| sin.puts 'x' }
83
+ s.add_callback { puts 'a' }
84
+ s.add_callback { puts 'b' } # runs after 'a'
85
+ s.join # prints "a\nb"
86
+ ```
87
+
88
+ ## Helpers in `Open`
89
+
90
+ ### `Open.consume_stream(io, in_thread = false, into = nil, into_close = true, &block)`
91
+
92
+ Pumps the stream to completion and returns the last chunk read. `Path` inputs
93
+ are ignored, closed streams are joined and skipped. With `into` (an IO, or a
94
+ String/Path file path whose parent dirs are created) it writes every chunk
95
+ there; `into_close` (default true) closes `into` when it responds to `close`.
96
+ On `Aborted` or any exception it aborts the source, closes `into`, **removes the
97
+ partial output file** and re-raises. With `in_thread: true` the whole drain runs
98
+ in a new thread that is pushed onto `io.threads`. The block runs after a
99
+ successful drain. Verified: `probe_19_streaming_apis.rb` (`consume_stream
100
+ return: "data"`, `consume_stream into path: "into-file\n"`).
101
+
102
+ ### `Open.sensible_write(path, content, options = {}, &block)`
103
+
104
+ Atomic write into a lock-protected tmp file followed by a rename. Key
105
+ behaviours for streams:
106
+
107
+ - When the content is a stream, an `Aborted` raised while copying is
108
+ **swallowed** (`Log.low "Aborted sensible_write"`), the stream is aborted and
109
+ the target is deleted; the partial tmp file is always removed in `ensure`.
110
+ - A non-Aborted exception recovers the *original* upstream cause from an
111
+ `AbortedStream`-marked content (`content.exception`) and re-raises that,
112
+ deleting the target. See
113
+ [Streaming Model](../developer/StreamingModel.md) for the marker.
114
+ - After a successful copy the content stream is joined (but not if it is a
115
+ `Path` or already joined).
116
+
117
+ Verified: P25/P33 in `research/behavior-probes.md` ("Aborted in
118
+ sensible_write: NOT raised (swallowed); partial tmp left: 0").
119
+
120
+ ### `Open.open_pipe(do_fork = false, close = true, &block)`
121
+
122
+ Creates a pipe and returns the **read end** (`sout`), with the block executed in
123
+ a producer thread that writes the other end (`sin`).
124
+
125
+ - **Block arity**: the block always receives `sin` whether it declares a
126
+ parameter or not (arity 0 blocks simply ignore it). Verified:
127
+ `probe_20_open_pipe_arity.rb` (`arity-0 block: "arity0\n"`, `arity-1: "w\n"`).
128
+ - **No block** raises `RuntimeError "No block given"`.
129
+ - **Fork mode** (`do_fork: true`) runs the block in a child process instead of
130
+ a thread: the child purges registered input pipes, closes `sout`, yields,
131
+ `exit! 0`; the parent closes `sin` and sets the pid on `sout` via
132
+ `ConcurrentStream.setup(sout, :pids => [pid])` — no threads, no callbacks.
133
+ `close: false` in the child leaves `sin` open after the block returns
134
+ (verified: `fork mode: "from-fork\n"`, `fork noclose: "fork-noclose\n"`).
135
+ - **Thread mode** pairs the two ends (`pair`), runs the block through
136
+ `ConcurrentStream.process_stream` (close+join on exit, abort on error), and
137
+ registers the thread on both ends. An exception in the block aborts the
138
+ stream and re-raises at the consumer.
139
+
140
+ ### `Open.pipe`
141
+
142
+ Takes **no arguments** and returns the raw `[sout, sin]` pair from `IO.pipe`
143
+ (plus registering `sin` in `OPEN_PIPE_IN`). Calling it with a positional
144
+ argument raises `ArgumentError` (probe `probe_19_streaming_apis.rb`:
145
+ `Open.pipe positional: ArgumentError`). There is no multi-command helper here —
146
+ chain commands by feeding one stream into `:in` of the next `CMD.cmd`.
147
+
148
+ ### `Open.tee_stream(stream)` / `tee_stream_thread_multiple(stream, num)`
149
+
150
+ Returns an **Array** of streams (`num` copies, default 2): the first is the
151
+ "main" copy with `autojoin: true`, the rest have no autojoin. A splitter thread
152
+ reads the source once and writes every chunk to all copies; the main copy's
153
+ callback joins the source and closes the extra write ends, and its
154
+ `abort_callback` propagates an abort to the source and the other copies.
155
+ Verified: P31/P33 and `probe_19_streaming_apis.rb` (`tee_stream count: 2`,
156
+ `tee[0].autojoin => true`, `tee[1].autojoin => nil`).
157
+
158
+ ```ruby
159
+ main, copy = Open.tee_stream(CMD.cmd('gzip -c', :pipe => true, :in => input))
160
+ # write copy to disk while main feeds the next command, then join copy
161
+ ```
162
+
163
+ ### `Open.line_monitor_stream(stream, &block)`
164
+
165
+ Builds a tee, then a monitor thread reads the monitor copy line by line calling
166
+ `block.call(line)` — the block therefore runs **concurrently** with whoever
167
+ consumes the returned stream, not after. Failures in the block abort the monitor
168
+ and are re-raised into the returned stream (`out.raise $!` when supported). The
169
+ returned stream is the second copy, annotated from the source and set up with
170
+ the monitor thread. Verified: P33 and `probe_19_streaming_apis.rb`.
171
+
172
+ ### `Open.read_stream(stream, size)`
173
+
174
+ Blocking read of exactly `size` bytes (plain `stream.read(missing)` loop,
175
+ `lib/scout/open/stream.rb:401`), raising `ClosedStream` if EOF is reached
176
+ first. Useful for binary framing; `probe_19_streaming_apis.rb` shows
177
+ `read_stream(4) => "0123"`.
178
+
179
+ ### `Open.sort_stream(stream, header_hash: '#', cmd_args: nil, memory: false)`
180
+
181
+ Streams `header_hash`-prefixed lines straight through, then sorts the rest.
182
+ `memory: false` (default) pipes the remainder into `env LC_ALL=C sort
183
+ <cmd_args>` (`-u` by default when `cmd_args` is nil) and consumes that stream
184
+ into the output; `memory: true` reads the whole remainder, sorts it in Ruby and
185
+ writes it out. Everything runs inside `ConcurrentStream.process_stream`, so the
186
+ source is closed+joined and aborted on error. Verified:
187
+ probe_19_streaming_apis.rb: feeding `"# header\nc\na\nb\n"` returns
188
+ `"# header\na\nb\nc\n"` — the header passes through untouched, the rest is
189
+ sorted.
190
+
191
+ ### `Open.collapse_stream(s, line: nil, sep: "\t", header: nil, compact: false, &block)`
192
+
193
+ Merges consecutive lines sharing the same first field, joining the other
194
+ columns with `|` (or dropping empty parts when `compact: true`). An optional
195
+ block receives the accumulated column array and its return value becomes the
196
+ row payload. Verified: `probe_19_streaming_apis.rb`.
197
+
198
+ ## `Open.open` block form and `DontClose`
199
+
200
+ ```ruby
201
+ res = Open.open(file) do |io|
202
+ next io.read if io.is_a?(String) # IO/StringIO pass straight through
203
+ raise DontClose.new(io.read) # payload escapes, io still closes
204
+ end
205
+ ```
206
+
207
+ `Open.open` yields and **always closes and joins** the IO afterwards (the
208
+ `ensure` at open.rb:70-77). Raising `DontClose` with a payload makes the block
209
+ form *return* the payload instead of the IO, while still closing — it is an
210
+ early-return mechanism, not a way to keep the handle. Verified:
211
+ `probe_21_dontclose.rb` (`DontClose returns payload: "payload"`, `closed after
212
+ DontClose: true`). Any other exception aborts, joins and re-raises the stream.
213
+
214
+ ## Progress bars
215
+
216
+ Pass `:progress_bar` (a `Log::ProgressBar`, `lib/scout/log/progress.rb`; the option key is `:progress_bar` — there is no `:bar` key)
217
+ to `CMD.cmd` and each stderr line ticks it (`bar.process(line)` at cmd.rb:643):
218
+ `probe_24_bar.rb` counts 2 ticks for a 2-line stderr, in both pipe and
219
+ non-pipe mode. With `:log => true` (`CMD.cmd_log`) the stderr text is also
220
+ recorded in `stream.log`. `Log::ProgressBar` itself supports `:process =>
221
+ proc{|elem| elem.length}` so a tick can be weighted per element.
222
+
223
+ ## Where the diagnostics go
224
+
225
+ Log output (including severity-logged stderr lines) goes to the Log logfile /
226
+ STDERR. **`std_err` is the per-stream capture**, filled by `:save_stderr` — see
227
+ [Running Commands](RunningCommands.md#save-stderr--capture-stderr-instead-of-logging-it).
228
+ There is no "paired stderr stream" object; in pipe mode stderr is drained by a
229
+ thread inside `CMD.cmd`.
230
+
231
+ ## Related
232
+
233
+ - [Running Commands](RunningCommands.md) — how these streams are produced.
234
+ - [Streaming Model](../developer/StreamingModel.md) — setup, join/abort
235
+ internals, exception propagation.
236
+ - [Working with Files](WorkingWithFiles.md) — `Open.read/write` on top.