exec_service 0.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb132c5eeab64f1edb3663b8f609e75711e3f5b2e890e435c4730463b428383f
4
- data.tar.gz: c9c05cb63d481e46d9702c8033b41f375518256d8ff3af476d1a56c10d1cb05e
3
+ metadata.gz: 33a8ad6fafa08f5efe31aba23468497bace9859453d3a12d23189c73eb57cc4e
4
+ data.tar.gz: c38f315c6ed811d2e284847473c184e01dba57847e05266c9c88b4706214626a
5
5
  SHA512:
6
- metadata.gz: 8c599381f5f3fcb5571e1fd85c7f7e1efc0534aee121e0ef80c6a846741a727c141b6f215ced7c2e7acc7470ae63b24e5b54fa8067b644df54dc00777316d5c1
7
- data.tar.gz: 188571ba3dbcb56090f0ce3ebf19c09c82dda3de8972122d2b055646dab37658e282b170a8347b656590b287d7bab48ecc2b6f68d171aeeacdacbfd977a5cb11
6
+ metadata.gz: ab846b2e66984966fdddff8a9ed7c815473484f80508eeeedc42fc2bc48e3756529e332efb497d8913a62db3412c1063589c37a98f91e28c3404bf8622494938
7
+ data.tar.gz: 82f3c1943930fffe8d0b96d48087e648e0b39ff8dba5601b1c595e6dde362ca16cc987cc924a381534676ba3553285af24f942550433914ec8c56bf857207d18
data/.yardopts ADDED
@@ -0,0 +1,10 @@
1
+ --no-private
2
+ --title=ExecService
3
+ --markup=markdown
4
+ --markup-provider redcarpet
5
+ --main=README.md
6
+ ./lib/exec_service.rb
7
+ -
8
+ README.md
9
+ LICENSE.md
10
+ CHANGELOG.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Release History
2
+
3
+ ### v0.1.0 / 2026-04-29
4
+
5
+ * ADDED: Initial extraction from toys-core
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # License
2
+
3
+ Copyright 2026 Daniel Azuma
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
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,9 +1,103 @@
1
- # Placeholder for exec_service
1
+ # ExecService
2
2
 
3
- This is a placeholder gem, which was generated on 2026-04-28 to
4
- reserve the gem "exec_service".
5
- The actual gem is planned for release in the near future.
3
+ `ExecService` is a full-featured Ruby service class for running and managing
4
+ subprocesses. It provides a rich interface for spawning processes, controlling
5
+ and monitoring those processes, setting up and redirecting streams, and
6
+ interpreting results. It also provides shortcuts for common cases such as
7
+ invoking Ruby in a subprocess or capturing output in a string. Use
8
+ `ExecService` when existing interfaces such as the `system()` method or the
9
+ `Open3` library are not sufficiently powerful or expressive for your needs.
6
10
 
7
- If this is a problem, or if the actual gem has not been released
8
- in a timely manner, you can contact the owner at
9
- `dazuma@gmail.com`.
11
+ ## Getting started
12
+
13
+ Install `ExecService` via the
14
+ [exec_service gem](https://rubygems.org/gems/exec_service).
15
+
16
+ ```sh
17
+ % gem install exec_service
18
+ ```
19
+
20
+ or add it to your Gemfile:
21
+
22
+ ```ruby
23
+ gem "exec_service"
24
+ ```
25
+
26
+ To use the service, instantiate `ExecService`, and call the convenient methods
27
+ on it to spawn subprocesses:
28
+
29
+ ```ruby
30
+ require "exec_service"
31
+ exec_service = ExecService.new
32
+ git_version = exec_service.capture(["git", "--version"]).chomp
33
+ ```
34
+
35
+ ## Features
36
+
37
+ * Execute subprocesses in the foreground (i.e. blocking until completion) or
38
+ background (returning immediately)
39
+ * Fork (execute a proc in a subprocess) or spawn (specify a command to run)
40
+ * Environment setup for the subprocess, including
41
+ * Working directory
42
+ * Environment variables
43
+ * Optionally disabling any existing bundle
44
+ * Umask
45
+ * Process group
46
+ * Rich process control interface, including:
47
+ * Access to process state and results
48
+ * Read/write access to non-redirected streams
49
+ * Signalling
50
+ * Joining
51
+ * Robust setup and redirect of streams, including:
52
+ * Inheriting parent process streams
53
+ * Redirecting to/from files
54
+ * Redirecting to/from pipes
55
+ * Redirecting to/from arbitrary IO objects
56
+ * Redirecting error to out and vice versa
57
+ * Reading input from strings
58
+ * Capturing output streams
59
+ * Tees for output streams
60
+ * Redirecting to/from null
61
+ * Closing streams
62
+ * Rich result reporting, including exit status, signals, and exceptions
63
+ * Convenience methods for common use cases, including:
64
+ * Simple output captures
65
+ * Running Ruby processes
66
+ * Executing a string in the shell
67
+ * Customizable logging
68
+
69
+ ## Contributing
70
+
71
+ Development is done in GitHub at https://github.com/dazuma/exec_service.
72
+
73
+ * To file issues: https://github.com/dazuma/exec_service/issues.
74
+ * For questions and discussion, please do not file an issue. Instead, use the
75
+ discussions feature: https://github.com/dazuma/exec_service/discussions.
76
+ * Pull requests are welcome, but in general please open an issue first before
77
+ contributing significant changes.
78
+
79
+ The library uses [toys](https://dazuma.github.io/toys) for testing and CI. To
80
+ run the test suite, `gem install toys` and then run `toys ci`. You can also run
81
+ unit tests, rubocop, and build tests independently.
82
+
83
+ ## License
84
+
85
+ Copyright 2026 Daniel Azuma
86
+
87
+ Permission is hereby granted, free of charge, to any person obtaining a copy
88
+ of this software and associated documentation files (the "Software"), to deal
89
+ in the Software without restriction, including without limitation the rights
90
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
91
+ copies of the Software, and to permit persons to whom the Software is
92
+ furnished to do so, subject to the following conditions:
93
+
94
+ The above copyright notice and this permission notice shall be included in
95
+ all copies or substantial portions of the Software.
96
+
97
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
98
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
99
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
100
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
101
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
102
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
103
+ IN THE SOFTWARE.
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ExecService
4
+ ##
5
+ # An object that controls a subprocess. This object is returned from an
6
+ # execution running in the background, or is yielded to a control block
7
+ # for an execution running in the foreground.
8
+ # You can use this object to interact with the subcommand's streams,
9
+ # send signals to the process, and get its result.
10
+ #
11
+ class Controller
12
+ ##
13
+ # The subcommand's name.
14
+ # @return [Object]
15
+ #
16
+ attr_reader :name
17
+
18
+ ##
19
+ # The subcommand's standard input stream (which can be written to).
20
+ #
21
+ # @return [IO] if the command was configured with `in: :controller`
22
+ # @return [nil] if the command was not configured with
23
+ # `in: :controller`
24
+ #
25
+ attr_reader :in
26
+
27
+ ##
28
+ # The subcommand's standard output stream (which can be read from).
29
+ #
30
+ # @return [IO] if the command was configured with `out: :controller`
31
+ # @return [nil] if the command was not configured with
32
+ # `out: :controller`
33
+ #
34
+ attr_reader :out
35
+
36
+ ##
37
+ # The subcommand's standard error stream (which can be read from).
38
+ #
39
+ # @return [IO] if the command was configured with `err: :controller`
40
+ # @return [nil] if the command was not configured with
41
+ # `err: :controller`
42
+ #
43
+ attr_reader :err
44
+
45
+ ##
46
+ # The process ID.
47
+ #
48
+ # Exactly one of {#exception} and {#pid} will be non-nil.
49
+ #
50
+ # @return [Integer] if the process start was successful
51
+ # @return [nil] if the process could not be started.
52
+ #
53
+ attr_reader :pid
54
+
55
+ ##
56
+ # The exception raised when the process failed to start.
57
+ #
58
+ # Exactly one of {#exception} and {#pid} will be non-nil.
59
+ #
60
+ # @return [Exception] if the process failed to start.
61
+ # @return [nil] if the process start was successful.
62
+ #
63
+ attr_reader :exception
64
+
65
+ ##
66
+ # Captures the remaining data in the given stream.
67
+ # After calling this, do not read directly from the stream.
68
+ #
69
+ # @param which [:out,:err] Which stream to capture
70
+ #
71
+ # @return [self] if the stream was captured
72
+ # @return [nil] if the stream was not captured because the process has
73
+ # completed or did not start successfully
74
+ #
75
+ def capture(which)
76
+ @streams_mutex.synchronize do
77
+ return nil unless @streams_open
78
+ stream = stream_for(which, allow_in: false)
79
+ @join_threads << ::Thread.new do
80
+ data = stream.read
81
+ @captures_mutex.synchronize do
82
+ @captures[which] = data
83
+ end
84
+ ensure
85
+ stream.close
86
+ end
87
+ end
88
+ self
89
+ end
90
+
91
+ ##
92
+ # Captures the remaining data in the standard output stream.
93
+ # After calling this, do not read directly from the stream.
94
+ #
95
+ # @return [self]
96
+ #
97
+ def capture_out
98
+ capture(:out)
99
+ end
100
+
101
+ ##
102
+ # Captures the remaining data in the standard error stream.
103
+ # After calling this, do not read directly from the stream.
104
+ #
105
+ # @return [self]
106
+ #
107
+ def capture_err
108
+ capture(:err)
109
+ end
110
+
111
+ ##
112
+ # Redirects the remainder of the given stream.
113
+ #
114
+ # You can specify the stream as an IO or IO-like object, or as a file
115
+ # specified by its path. If specifying a file, you can optionally
116
+ # provide the mode and permissions for the call to `File#open`. You can
117
+ # also specify the value `:null` to indicate the null file.
118
+ #
119
+ # If the stream is redirected to an IO-like object, it is _not_ closed
120
+ # when the process is completed. (If it is redirected to a file
121
+ # specified by path, the file is closed on completion.)
122
+ #
123
+ # After calling this, do not interact directly with the stream.
124
+ #
125
+ # @param which [:in,:out,:err] Which stream to redirect
126
+ # @param io [IO,StringIO,String,:null] Where to redirect the stream
127
+ # @param io_args [Object...] The mode and permissions for opening the
128
+ # file, if redirecting to/from a file.
129
+ #
130
+ # @return [self] if the stream was redirected
131
+ # @return [nil] if the stream was not redirected because the process
132
+ # has completed or did not start successfully
133
+ #
134
+ def redirect(which, io, *io_args)
135
+ @streams_mutex.synchronize do
136
+ return nil unless @streams_open
137
+ io = ::File::NULL if io == :null
138
+ close_afterward = false
139
+ if io.is_a?(::String)
140
+ io_args = which == :in ? ["r"] : ["w"] if io_args.empty?
141
+ io = ::File.open(io, *io_args)
142
+ close_afterward = true
143
+ end
144
+ stream = stream_for(which, allow_in: true)
145
+ @join_threads << ::Thread.new do
146
+ if which == :in
147
+ ::IO.copy_stream(io, stream)
148
+ else
149
+ ::IO.copy_stream(stream, io)
150
+ end
151
+ ensure
152
+ stream.close
153
+ io.close if close_afterward
154
+ end
155
+ end
156
+ self
157
+ end
158
+
159
+ ##
160
+ # Redirects the remainder of the standard input stream.
161
+ #
162
+ # You can specify the stream as an IO or IO-like object, or as a file
163
+ # specified by its path. If specifying a file, you can optionally
164
+ # provide the mode and permissions for the call to `File#open`. You can
165
+ # also specify the value `:null` to indicate the null file.
166
+ #
167
+ # After calling this, do not interact directly with the stream.
168
+ #
169
+ # @param io [IO,StringIO,String,:null] Where to redirect the stream
170
+ # @param io_args [Object...] The mode and permissions for opening the
171
+ # file, if redirecting from a file.
172
+ #
173
+ # @return [self] if the stream was redirected
174
+ # @return [nil] if the stream was not redirected because the process
175
+ # has completed or did not start successfully
176
+ #
177
+ def redirect_in(io, *io_args)
178
+ redirect(:in, io, *io_args)
179
+ end
180
+
181
+ ##
182
+ # Redirects the remainder of the standard output stream.
183
+ #
184
+ # You can specify the stream as an IO or IO-like object, or as a file
185
+ # specified by its path. If specifying a file, you can optionally
186
+ # provide the mode and permissions for the call to `File#open`. You can
187
+ # also specify the value `:null` to indicate the null file.
188
+ #
189
+ # After calling this, do not interact directly with the stream.
190
+ #
191
+ # @param io [IO,StringIO,String,:null] Where to redirect the stream
192
+ # @param io_args [Object...] The mode and permissions for opening the
193
+ # file, if redirecting to a file.
194
+ #
195
+ # @return [self] if the stream was redirected
196
+ # @return [nil] if the stream was not redirected because the process
197
+ # has completed or did not start successfully
198
+ #
199
+ def redirect_out(io, *io_args)
200
+ redirect(:out, io, *io_args)
201
+ end
202
+
203
+ ##
204
+ # Redirects the remainder of the standard error stream.
205
+ #
206
+ # You can specify the stream as an IO or IO-like object, or as a file
207
+ # specified by its path. If specifying a file, you can optionally
208
+ # provide the mode and permissions for the call to `File#open`. You can
209
+ # also specify the value `:null` to indicate the null file.
210
+ #
211
+ # After calling this, do not interact directly with the stream.
212
+ #
213
+ # @param io [IO,StringIO,String,:null] Where to redirect the stream
214
+ # @param io_args [Object...] The mode and permissions for opening the
215
+ # file, if redirecting to a file.
216
+ #
217
+ # @return [self] if the stream was redirected
218
+ # @return [nil] if the stream was not redirected because the process
219
+ # has completed or did not start successfully
220
+ #
221
+ def redirect_err(io, *io_args)
222
+ redirect(:err, io, *io_args)
223
+ end
224
+
225
+ ##
226
+ # Send the given signal to the process. The signal can be specified
227
+ # by name or number.
228
+ #
229
+ # @param sig [Integer,String] The signal to send.
230
+ # @return [self]
231
+ #
232
+ def kill(sig)
233
+ ::Process.kill(sig, pid) if pid
234
+ self
235
+ end
236
+ alias signal kill
237
+
238
+ ##
239
+ # Determine whether the subcommand is still executing
240
+ #
241
+ # @return [boolean]
242
+ #
243
+ def executing?
244
+ @completion_thread&.status ? true : false
245
+ end
246
+
247
+ ##
248
+ # Wait for the subcommand to complete, and return a result object.
249
+ #
250
+ # @param timeout [Numeric,nil] The timeout in seconds, or `nil` to
251
+ # wait indefinitely.
252
+ # @return [ExecService::Result] The result object
253
+ # @return [nil] if a timeout occurred.
254
+ #
255
+ def result(timeout: nil)
256
+ return nil if @completion_thread && !@completion_thread.join(timeout)
257
+ # @completion_thread sets @result, so the final value is guaranteed
258
+ # to be stable once the thread has joined above.
259
+ @result
260
+ end
261
+
262
+ ##
263
+ # @private
264
+ #
265
+ def initialize(name:, controller_streams:, captures:, pid_or_exception:,
266
+ join_threads:, background_callback:, captures_mutex:)
267
+ @name = name
268
+ @in = controller_streams[:in]
269
+ @out = controller_streams[:out]
270
+ @err = controller_streams[:err]
271
+ @captures = captures
272
+ @join_threads = join_threads
273
+ @background_callback = background_callback
274
+ @captures_mutex = captures_mutex
275
+ @streams_open = false
276
+ @streams_mutex = ::Mutex.new
277
+ @pid = @exception = @completion_thread = @result = nil
278
+ case pid_or_exception
279
+ when ::Integer
280
+ @pid = pid_or_exception
281
+ @streams_open = true
282
+ @completion_thread = ::Thread.new do
283
+ _pid, status = ::Process.wait2(@pid)
284
+ cleanup(status)
285
+ end
286
+ when ::Exception
287
+ @exception = pid_or_exception
288
+ cleanup(nil)
289
+ end
290
+ end
291
+
292
+ ##
293
+ # Close the controller's input stream, if any.
294
+ #
295
+ # @private
296
+ #
297
+ def close_in_stream
298
+ @streams_mutex.synchronize do
299
+ @in&.close
300
+ end
301
+ self
302
+ end
303
+
304
+ ##
305
+ # Close the controller's output streams, if any.
306
+ #
307
+ # @private
308
+ #
309
+ def close_out_streams
310
+ @streams_mutex.synchronize do
311
+ @out&.close
312
+ @err&.close
313
+ end
314
+ self
315
+ end
316
+
317
+ private
318
+
319
+ ##
320
+ # Cleanup after the child process ends.
321
+ # Blocks any further captures/redirects, joins all stream processing
322
+ # threads, and sets the result. Also kicks off the callback if run in
323
+ # the background.
324
+ #
325
+ def cleanup(status)
326
+ @streams_mutex.synchronize do
327
+ @streams_open = false
328
+ end
329
+ @join_threads.each(&:join)
330
+ @result = Result.new(@name, @captures[:out], @captures[:err], status, @exception)
331
+ if @background_callback
332
+ ::Thread.new do
333
+ @background_callback.call(@result)
334
+ end
335
+ end
336
+ end
337
+
338
+ def stream_for(which, allow_in: false)
339
+ stream = nil
340
+ case which
341
+ when :out
342
+ stream = @out
343
+ @out = nil
344
+ when :err
345
+ stream = @err
346
+ @err = nil
347
+ when :in
348
+ if allow_in
349
+ stream = @in
350
+ @in = nil
351
+ end
352
+ else
353
+ raise ::ArgumentError, "Unknown stream #{which}"
354
+ end
355
+ raise ::ArgumentError, "Stream #{which} not available" unless stream
356
+ stream
357
+ end
358
+ end
359
+ end