aw 0.2.1 → 1.0.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 +4 -4
- data/LICENSE.md +1 -1
- data/README.md +64 -66
- data/lib/aw/child_error.rb +25 -0
- data/lib/aw/fork.rb +168 -56
- data/lib/aw.rb +61 -33
- metadata +15 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdae0f9011185309d30daa5f7259d2e23b72e906c86d7d0a04a0bb1e3a23605c
|
|
4
|
+
data.tar.gz: fb0b8786d878675b8b550cb0809eade95eb9ac2676df58d23e3e5ee94ba2add5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f389d3857089097b5170e9dcd49e6365479ae906a428f36d46d11d25f37f4f1109d31f2f96a6c1b66c720b82adec41adc644142b3fe9b1625d9be46584be7ce5
|
|
7
|
+
data.tar.gz: b4d6b184b4620f48e22282e6c1145673ade09ac8d9c85fbe90df45368c600b9ffbf3cab715c8a064efe226f103f9d1d9e9c20b17d1a64a7d2b6bcaf3a5b2a9e0
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
|
@@ -2,118 +2,116 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/fixrb/aw/tags)
|
|
4
4
|
[](https://rubydoc.info/github/fixrb/aw/main)
|
|
5
|
-
[](https://github.com/fixrb/aw/actions?query=workflow%3Aruby+branch%3Amain)
|
|
6
|
-
[](https://github.com/fixrb/aw/actions?query=workflow%3Arubocop+branch%3Amain)
|
|
7
5
|
[](https://github.com/fixrb/aw/raw/main/LICENSE.md)
|
|
8
6
|
|
|
9
|
-
> Aw, fork
|
|
7
|
+
> Aw, fork❗
|
|
10
8
|
|
|
11
|
-

|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Runs a block in a forked child process and brings back what it returned, or re-raises what it raised. What the block does to the process stays in the child; what it does outside, to files or to other processes, does not.
|
|
14
12
|
|
|
15
13
|
## Installation
|
|
16
14
|
|
|
17
|
-
Add this line to your application's Gemfile:
|
|
18
|
-
|
|
19
15
|
```ruby
|
|
16
|
+
# Gemfile
|
|
20
17
|
gem "aw"
|
|
21
18
|
```
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
```sh
|
|
26
|
-
bundle install
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Or install it yourself as:
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
gem install aw
|
|
33
|
-
```
|
|
20
|
+
Aw requires Ruby 3.3 or later and `fork(2)`: it runs on Linux, macOS and the BSDs with CRuby, and declines elsewhere (Windows, JRuby) with a `NotImplementedError`. `Aw.supported?` tells beforehand.
|
|
34
21
|
|
|
35
22
|
## Usage
|
|
36
23
|
|
|
37
|
-
To make __Aw__ available:
|
|
38
|
-
|
|
39
24
|
```ruby
|
|
40
25
|
require "aw"
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
There are two methods:
|
|
44
|
-
|
|
45
|
-
- `fork!`
|
|
46
|
-
- `fork?`
|
|
47
|
-
|
|
48
|
-
### Method `.fork!`
|
|
49
26
|
|
|
50
|
-
Executes a block of code in a sub-process, and returns the result:
|
|
51
|
-
|
|
52
|
-
```ruby
|
|
53
27
|
Aw.fork! { 6 * 7 } # => 42
|
|
54
28
|
```
|
|
55
29
|
|
|
56
|
-
|
|
30
|
+
Side effects of the block on the process stay in the child: mutations, globals, constants, the current directory, `ENV`, a crash.
|
|
57
31
|
|
|
58
32
|
```ruby
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
arr # => ["foo"]
|
|
33
|
+
list = ["foo"]
|
|
34
|
+
Aw.fork! { list << "bar" } # => ["foo", "bar"]
|
|
35
|
+
list # => ["foo"]
|
|
64
36
|
```
|
|
65
37
|
|
|
66
|
-
|
|
38
|
+
What the block raises is re-raised in the parent, with its message, backtrace and cause, including `SystemExit` from an `exit`:
|
|
67
39
|
|
|
68
40
|
```ruby
|
|
69
|
-
Aw.fork! {
|
|
41
|
+
Aw.fork! { raise ArgumentError, "boom" } # raises ArgumentError: boom
|
|
70
42
|
```
|
|
71
43
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
> `NoMethodError` (undefined method `+' for nil:NilClass)
|
|
75
|
-
|
|
76
|
-
### Method `.fork?`
|
|
44
|
+
Left uncaught, it ends the parent as it would have ended the child: an `exit 0`, or a `SignalException` the block raised (`KILL` included), terminates the parent with that status or that signal.
|
|
77
45
|
|
|
78
|
-
|
|
46
|
+
`fork?` only tells whether the block returned:
|
|
79
47
|
|
|
80
48
|
```ruby
|
|
81
|
-
Aw.fork? { 6 * 7 }
|
|
49
|
+
Aw.fork? { 6 * 7 } # => true
|
|
50
|
+
Aw.fork? { raise "boom" } # => false
|
|
51
|
+
Aw.fork? { exit 0 } # => false
|
|
82
52
|
```
|
|
83
53
|
|
|
84
|
-
|
|
54
|
+
It reports the child only: an exception raised in the parent while waiting, such as an `Interrupt` or a `Timeout::Error`, propagates.
|
|
85
55
|
|
|
86
|
-
|
|
87
|
-
arr = ["foo"] # => ["foo"]
|
|
88
|
-
|
|
89
|
-
Aw.fork? { arr << "FUU" } # => true
|
|
56
|
+
## What comes back, and what cannot
|
|
90
57
|
|
|
91
|
-
|
|
92
|
-
```
|
|
58
|
+
The result travels through a pipe with `Marshal`, so it must be a marshalable value: numbers, strings, symbols, arrays, hashes, and instances of named classes whose state is marshalable. A `Proc`, an `IO`, an instance of an anonymous class, or an exception carrying such a thing (a `KeyError` raised by a hash that holds a `Proc` carries that hash) cannot come back; `fork!` then raises `Aw::ChildError`, which says what the block returned or raised and why Marshal refused it. Neither can an instance of a class the parent does not know: a class defined or required inside the block exists in the child only, and the parent cannot load it (`Aw::ChildError: … undefined class/module Foo`). A constant the parent registered for autoload is the exception: `Marshal` autoloads it, and so runs that file, in the parent. What the parent raises while loading the result, short of an interruption, is an `Aw::ChildError` too, never mistaken for an exception of the block: a `marshal_load` of one of its classes, a failed autoload, a structure too deep or too large for the parent. `fork?` transfers neither the value nor the exception, and is not concerned.
|
|
93
59
|
|
|
94
|
-
|
|
60
|
+
`Aw::ChildError` is also raised when the child died without delivering a result: killed by a signal that Ruby does not turn into an exception (`KILL`, `SEGV`), left on its own with `exit!` or `exec`, or left the block with a `break`, a `return` or a `throw`. Its `status` is the child's `Process::Status`, and tells the cases apart: `signaled?` for a crash, `exitstatus` 70 (`EX_SOFTWARE`) for a block that left without returning or raising, 0 for a result that cannot be transferred or loaded, and the block's own code for an `exit!` or an `exec`: a block calling `exit! 70` is reported as one that left without returning, and `exit! 0` as a child that did not deliver a result.
|
|
95
61
|
|
|
96
62
|
```ruby
|
|
97
|
-
Aw.fork
|
|
98
|
-
|
|
63
|
+
Aw.fork! { -> {} }
|
|
64
|
+
# raises Aw::ChildError: the block returned an instance of Proc, which cannot be transferred from the child: …
|
|
99
65
|
|
|
100
|
-
|
|
66
|
+
Aw.fork! { Process.kill(:KILL, Process.pid) }
|
|
67
|
+
# raises Aw::ChildError: the child did not deliver a result (pid 42 SIGKILL (signal 9))
|
|
68
|
+
```
|
|
101
69
|
|
|
102
|
-
|
|
70
|
+
The value that comes back is a copy: an object returns as a new object, and its frozen state does not travel (a frozen string or array returns unfrozen, while a `Range` or a `Data` returns frozen, as `Marshal` makes them). An exception comes back with its message, its backtrace as strings (which starts in the child and ends in the parent, since the stack was copied; `backtrace_locations` is `nil`) and the cause it had in the child, never the exception the parent may be handling.
|
|
71
|
+
|
|
72
|
+
## Guarantees
|
|
73
|
+
|
|
74
|
+
- The child leaves with `exit!` after flushing its standard output and error, whatever the block does to leave (returning, raising, `exit`, `break`, `return`, `throw`, a redefined `exit!`, a replaced stream whose `flush` throws): the parent's `at_exit` handlers run once, in the parent. An `at_exit` registered by the block itself never runs.
|
|
75
|
+
- The child's standard output and error go where the parent's go. Any other buffered `IO` opened by the parent and written by the block, `STDOUT` included when the block replaced `$stdout`, is not flushed, and that output is lost: flush it in the block, and in the parent before the call, or what the parent had buffered is written twice.
|
|
76
|
+
- The block runs with the usual interrupt handling, whatever mask the caller set with `Thread.handle_interrupt`: a `Timeout` inside the block, a `Thread#raise` or a signal reach it. A signal that Ruby turns into an exception (`INT`, `TERM`, `HUP`) is then re-raised in the parent, like any exception of the block.
|
|
77
|
+
- The message is length-prefixed and binary, whatever the default encodings: a process the block spawned and left running does not delay the result, as long as it does not write to the pipe. When the child delivers no result, though, the parent waits for the end of the pipe, so a process the block forked without `exec` and left running delays the `Aw::ChildError` until it exits (`spawn` and `system` do not hold the pipe).
|
|
78
|
+
- Aw has no timeout of its own: `fork!` and `fork?` wait until the child exits, whatever keeps it alive (a block that loops or sleeps, a child stopped by `SIGSTOP`, a standard stream whose `flush` blocks, a thread that goes on after the result was written). Wrap code you do not trust in a `Timeout` of your own.
|
|
79
|
+
- If the parent is interrupted while waiting (`Interrupt`, `Timeout`, `Thread#kill`), the child is killed and reaped before the interruption proceeds, with no window between the fork and that guard. A second interruption during the reaping itself can still leave a zombie.
|
|
80
|
+
- No file descriptor is left open after a call.
|
|
81
|
+
- Forks can be nested. When an outer fork is killed by an interrupted parent, its own children are orphaned, since `SIGKILL` leaves no time to reap them.
|
|
82
|
+
- A fork costs a few milliseconds; the pages of the parent are shared copy-on-write, and the cost grows with the size of the parent (about 2 ms at 50 MB, 20 ms at 700 MB).
|
|
83
|
+
|
|
84
|
+
## Things to know about fork
|
|
85
|
+
|
|
86
|
+
- Only the thread that calls `fork!` exists in the child; other threads of the parent are not copied. A block that relies on a background thread of the parent will not find it.
|
|
87
|
+
- The child is blocking, whatever fiber `fork!` was called from: a fiber scheduler the parent set (`async`, say) does not see the block, whose sleeps and IO would otherwise hand the child over to the parent's other fibers, copied by the fork. The parent's side, reading the result and waiting for the child, goes through the scheduler as usual. A `Fiber.yield` in the block raises a `FiberError`, since the fork makes the current fiber the root one.
|
|
88
|
+
- On macOS, the child of a process that loaded Objective-C frameworks from several threads may crash when it first uses an Objective-C class; `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` in the environment the parent is started with is Apple's switch for that.
|
|
89
|
+
- Aw reaps its own child: a parent that reaps children on its own, with `trap(:CHLD) { Process.wait(-1, Process::WNOHANG) }` or `trap(:CHLD, "IGNORE")`, can take the child's status first, and the call then raises `Errno::ECHILD`.
|
|
90
|
+
- The file descriptors of the parent are shared with the child, offsets included: a block that writes to a file, a socket or a database connection the parent opened changes it for the parent too. Open such connections in the block.
|
|
91
|
+
- `$stdout` replaced by a `StringIO` in the parent is not shared with the child; capture output inside the block and return it.
|
|
92
|
+
- SimpleCov stores the coverage of a process from an `at_exit` handler, which `exit!` skips: the code the child runs is not in the parent's report. With `merge_subprocesses true` (`enable_for_subprocesses true` before SimpleCov 1.3), call `SimpleCov.result` in an `ensure` of the block to store the child's share; the parent merges it.
|
|
93
|
+
- The parent deserializes, with `Marshal`, what the child wrote; the child ran your block, which could reach the pipe and write anything. Aw isolates the side effects of code you trust; it is not a sandbox for code you do not. A hostile block can make `fork!` return or raise anything, make `fork?` answer `true`, choose the message of an `Aw::ChildError`, make the parent hold as much memory as it writes, run the parent's `marshal_load` methods and autoloads, and, from a continuation the parent captured (`callcc`), run the parent's code in the child: only the `status` of an `Aw::ChildError` comes from the system. Running as the parent's user, it can also signal the parent and leave processes behind; Aw kills and reaps the child only.
|
|
94
|
+
|
|
95
|
+
## Changes in 1.0
|
|
96
|
+
|
|
97
|
+
- An exception *returned* by the block is returned, not raised; only what the block raises is re-raised.
|
|
98
|
+
- A child that died, or a result that cannot be marshaled or loaded, is an `Aw::ChildError` with the child's status and the reason, instead of an `ArgumentError` (`marshal data too short`, `undefined class/module`), or a `nil` from `fork?`.
|
|
99
|
+
- `fork?` answers whether the block returned: `exit 0` in the block is now `false`, and `exit!`, a `break`, a `return` or a `throw` an `Aw::ChildError` (0.2.1 answered `true` or `false`).
|
|
100
|
+
- The child always leaves with `exit!`, after flushing its standard streams (0.2.1 lost what `fork!` had buffered): `fork?`, and `fork!` on a result it could not dump, no longer run the parent's `at_exit` handlers or print a backtrace in the child, and an `at_exit` registered by the block never runs.
|
|
101
|
+
- A process the block left running no longer delays the result.
|
|
102
|
+
- The pipe is closed on every path; the parent kills and reaps the child when interrupted (0.2.1 left it running).
|
|
103
|
+
- `Aw.supported?`; `NotImplementedError` raised before any pipe is opened (0.2.1 leaked one); `ArgumentError` without a block (0.2.1 raised a `LocalJumpError` from `fork!`, and `fork?` forked a child that went on running the caller's code).
|
|
104
|
+
- `require "aw"` no longer loads `English` (`$CHILD_STATUS` and the other aliases); `Aw::Fork`, private, is now a module.
|
|
105
|
+
- Ruby 3.3 or later.
|
|
103
106
|
|
|
104
107
|
## Versioning
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
Aw follows [Semantic Versioning 2.0](https://semver.org/).
|
|
107
110
|
|
|
108
111
|
## License
|
|
109
112
|
|
|
110
113
|
The [gem](https://rubygems.org/gems/aw) is available as open source under the terms of the [MIT License](https://github.com/fixrb/aw/raw/main/LICENSE.md).
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
## Sponsors
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
This project is sponsored by:<br />
|
|
116
|
-
<a href="https://sashite.com/"><img
|
|
117
|
-
src="https://github.com/fixrb/aw/raw/main/img/sashite.png"
|
|
118
|
-
alt="Sashité" /></a>
|
|
119
|
-
</p>
|
|
117
|
+
This project is sponsored by [Sashité](https://sashite.com/)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aw
|
|
4
|
+
# Raised in the parent when the child process did not deliver a result:
|
|
5
|
+
# it was killed, it exited on its own, the block left without returning
|
|
6
|
+
# or raising, what the block returned or raised cannot be marshaled, or
|
|
7
|
+
# the parent cannot load it (an instance of a class it does not know, or
|
|
8
|
+
# a `marshal_load` that raises).
|
|
9
|
+
#
|
|
10
|
+
# The status tells the cases apart: `signaled?` for a crash, `exitstatus`
|
|
11
|
+
# 70 for a block that left without returning or raising, 0 for a result
|
|
12
|
+
# that cannot be transferred or loaded, the block's own code for an
|
|
13
|
+
# `exit!` or an `exec` of the block.
|
|
14
|
+
class ChildError < ::StandardError
|
|
15
|
+
# @return [Process::Status] the status of the child
|
|
16
|
+
attr_reader :status
|
|
17
|
+
|
|
18
|
+
# @param message [String]
|
|
19
|
+
# @param status [Process::Status]
|
|
20
|
+
def initialize(message, status:)
|
|
21
|
+
@status = status
|
|
22
|
+
super("#{message} (#{status})")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/aw/fork.rb
CHANGED
|
@@ -1,77 +1,189 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "child_error"
|
|
4
4
|
|
|
5
5
|
module Aw
|
|
6
|
-
# The
|
|
6
|
+
# The fork itself: one child, one pipe, one marshaled message back.
|
|
7
|
+
#
|
|
8
|
+
# The child runs the block and writes `[:return, value]`, `[:raise,
|
|
9
|
+
# exception]` or, when neither can be marshaled, `[:opaque, description]`
|
|
10
|
+
# to the pipe as a length-prefixed `Marshal` message, then leaves with
|
|
11
|
+
# `exit!`. The parent reads the message, reaps the child, and demands
|
|
12
|
+
# both a decodable message and a successful exit status.
|
|
7
13
|
#
|
|
8
14
|
# @api private
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
module Fork
|
|
16
|
+
HEADER = "Q>"
|
|
17
|
+
HEADER_SIZE = 8
|
|
18
|
+
CHUNK = 65_536
|
|
19
|
+
|
|
20
|
+
# Exit status of a child that failed to deliver, as opposed to a block
|
|
21
|
+
# that called `exit!` itself. EX_SOFTWARE in sysexits(3).
|
|
22
|
+
FAILURE = 70
|
|
23
|
+
LEFT = "the block left without returning or raising (break, return or throw), " \
|
|
24
|
+
"so the child delivered no result"
|
|
25
|
+
|
|
26
|
+
# Captured once, so that a block redefining `Process.exit!` in the child
|
|
27
|
+
# cannot make the child fall through to the parent's `at_exit` handlers.
|
|
28
|
+
EXIT = ::Process.method(:exit!)
|
|
29
|
+
|
|
30
|
+
private_constant :HEADER, :HEADER_SIZE, :CHUNK, :FAILURE, :LEFT, :EXIT
|
|
31
|
+
|
|
32
|
+
# @param block [Proc] the code to run in the child
|
|
33
|
+
# @return [Array(Symbol, Object)] `[:return, value]` or `[:raise, exception]`
|
|
34
|
+
# @raise [NotImplementedError] when this Ruby cannot fork
|
|
35
|
+
# @raise [Aw::ChildError] when no result came back
|
|
36
|
+
def self.run(block)
|
|
37
|
+
raise ::NotImplementedError, "fork(2) is not available on this Ruby" unless Aw.supported?
|
|
38
|
+
|
|
39
|
+
# Binary: no transcoding of the message, whatever the default encodings.
|
|
40
|
+
reader, writer = ::IO.pipe(binmode: true)
|
|
41
|
+
pid = nil
|
|
42
|
+
# No interruption of the parent, not even a Thread#kill, between the
|
|
43
|
+
# fork and the ensure that reaps the child. The child lifts the mask
|
|
44
|
+
# again, see `child`.
|
|
45
|
+
::Thread.handle_interrupt(::Object => :never) do
|
|
46
|
+
pid = ::Process.fork { child(block, reader, writer) }
|
|
47
|
+
writer.close
|
|
48
|
+
end
|
|
49
|
+
data = read(reader)
|
|
50
|
+
_, status = ::Process.wait2(pid)
|
|
51
|
+
pid = nil
|
|
52
|
+
|
|
53
|
+
deliver(data, status)
|
|
54
|
+
ensure
|
|
55
|
+
[writer, reader].each { |io| io&.close }
|
|
56
|
+
reap(pid)
|
|
20
57
|
end
|
|
21
58
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
59
|
+
# Never returns: the child writes its result and leaves with `exit!`,
|
|
60
|
+
# whatever the block does, including a `break`, a `return` or a `throw`,
|
|
61
|
+
# which the ensure catches too. The interrupt mask inherited from the
|
|
62
|
+
# parent is lifted first, so that a `Timeout` or a signal inside the
|
|
63
|
+
# block behaves as usual. The child is blocking: a fiber scheduler the
|
|
64
|
+
# parent set does not see the block, which would otherwise hand the
|
|
65
|
+
# child over to the parent's other fibers.
|
|
66
|
+
def self.child(block, reader, writer)
|
|
67
|
+
::Fiber.blocking do
|
|
68
|
+
::Thread.handle_interrupt(::Object => :immediate) do
|
|
69
|
+
reader.close
|
|
70
|
+
data = dump(evaluate(block))
|
|
71
|
+
writer.write([data.bytesize].pack(HEADER), data)
|
|
72
|
+
leave(0)
|
|
73
|
+
ensure
|
|
74
|
+
leave(FAILURE)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
26
78
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
79
|
+
# Flushes the standard streams of the child and leaves with the status.
|
|
80
|
+
# The exit is in an ensure of its own, so that a `throw` from the
|
|
81
|
+
# `flush` of a stream the block replaced cannot skip it.
|
|
82
|
+
def self.leave(status)
|
|
83
|
+
flush
|
|
84
|
+
ensure
|
|
85
|
+
EXIT.call(status)
|
|
86
|
+
end
|
|
31
87
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
::
|
|
88
|
+
# Calls the block and tells how it ended: `[:return, value]` or
|
|
89
|
+
# `[:raise, exception]`.
|
|
90
|
+
def self.evaluate(block)
|
|
91
|
+
[:return, block.call]
|
|
92
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
93
|
+
[:raise, e]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Marshals the message or, when it cannot be marshaled, a description
|
|
97
|
+
# of it.
|
|
98
|
+
def self.dump(message)
|
|
99
|
+
::Marshal.dump(message)
|
|
100
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
101
|
+
::Marshal.dump([:opaque, opaque(message, e)])
|
|
102
|
+
end
|
|
44
103
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
# A description of what could not be transferred, built defensively:
|
|
105
|
+
# the value comes from the block and its methods may raise.
|
|
106
|
+
def self.opaque(message, error)
|
|
107
|
+
kind, value = message
|
|
108
|
+
label = value.class.name || "an anonymous class"
|
|
109
|
+
subject = kind == :raise ? "raised #{label} (#{value.message})" : "returned an instance of #{label}"
|
|
110
|
+
"the block #{subject}, which cannot be transferred from the child: #{error.message}"
|
|
111
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
112
|
+
"the block #{kind == :raise ? "raised" : "returned"} something that cannot be transferred from the child"
|
|
48
113
|
end
|
|
49
114
|
|
|
50
|
-
|
|
115
|
+
# Flushes the standard streams of the child. A stream the block closed or
|
|
116
|
+
# replaced may not flush: that must not cost the result.
|
|
117
|
+
def self.flush
|
|
118
|
+
[$stdout, $stderr].each do |stream|
|
|
119
|
+
stream.flush
|
|
120
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
51
124
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
125
|
+
# Reads the announced number of bytes, chunk by chunk so that a corrupt
|
|
126
|
+
# header cannot make the parent allocate a huge buffer at once.
|
|
54
127
|
#
|
|
55
|
-
# @return [
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
128
|
+
# @return [String, nil] the message, or nil when it is missing or short
|
|
129
|
+
def self.read(reader)
|
|
130
|
+
header = reader.read(HEADER_SIZE)
|
|
131
|
+
return nil if header.nil? || header.bytesize < HEADER_SIZE
|
|
132
|
+
|
|
133
|
+
size = header.unpack1(HEADER)
|
|
134
|
+
data = ::String.new
|
|
135
|
+
while data.bytesize < size
|
|
136
|
+
chunk = reader.read([size - data.bytesize, CHUNK].min)
|
|
137
|
+
return nil if chunk.nil?
|
|
138
|
+
|
|
139
|
+
data << chunk
|
|
140
|
+
end
|
|
141
|
+
data
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns `[kind, value]` from a child that delivered a result and
|
|
145
|
+
# exited normally; raises a ChildError that says why otherwise.
|
|
146
|
+
def self.deliver(data, status)
|
|
147
|
+
raise ChildError.new(LEFT, status:) if status.exitstatus == FAILURE
|
|
148
|
+
raise ChildError.new("the child did not deliver a result", status:) unless status.success? && data
|
|
69
149
|
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
kind, value = load(data, status)
|
|
151
|
+
raise ChildError.new(value, status:) if kind == :opaque
|
|
72
152
|
|
|
73
|
-
|
|
153
|
+
[kind, value]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Loads the message and accepts only the three forms the child writes, as
|
|
157
|
+
# arrays: a Struct or a Data with the same members is refused. What
|
|
158
|
+
# `Marshal.load` raises, short of an interruption, is a ChildError, never
|
|
159
|
+
# an exception of the block: a `marshal_load` of the parent, an autoload,
|
|
160
|
+
# a structure too deep or too large for the parent.
|
|
161
|
+
def self.load(data, status)
|
|
162
|
+
message = ::Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
163
|
+
rescue ::StandardError, ::ScriptError, ::SystemStackError, ::NoMemoryError => e
|
|
164
|
+
raise ChildError.new("the child delivered an undecodable message (#{e.message})", status:)
|
|
165
|
+
else
|
|
166
|
+
case message
|
|
167
|
+
in ::Array[:return, _] | ::Array[:raise, ::Exception] then message
|
|
168
|
+
in ::Array[:opaque, ::String => text] if text.encoding.ascii_compatible? then message # rubocop:disable Lint/DuplicateBranch
|
|
169
|
+
else raise ChildError.new("the child delivered an unexpected message", status:)
|
|
74
170
|
end
|
|
75
171
|
end
|
|
172
|
+
|
|
173
|
+
# Reaps a child the parent gave up waiting for, killing it first if it
|
|
174
|
+
# is still running. The child is checked before being killed: a wait2
|
|
175
|
+
# interrupted right after it reaped would otherwise lead to a kill on a
|
|
176
|
+
# pid that may already belong to another process.
|
|
177
|
+
def self.reap(pid)
|
|
178
|
+
return if pid.nil?
|
|
179
|
+
return if ::Process.wait2(pid, ::Process::WNOHANG)
|
|
180
|
+
|
|
181
|
+
::Process.kill(:KILL, pid)
|
|
182
|
+
::Process.wait(pid)
|
|
183
|
+
rescue ::Errno::ESRCH, ::Errno::ECHILD
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private_class_method :child, :leave, :evaluate, :dump, :opaque, :flush, :read, :deliver, :load, :reap
|
|
76
188
|
end
|
|
77
189
|
end
|
data/lib/aw.rb
CHANGED
|
@@ -1,45 +1,73 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
3
|
+
require_relative "aw/child_error"
|
|
4
|
+
require_relative "aw/fork"
|
|
5
|
+
|
|
6
|
+
# Runs a block in a forked child process and brings back what it returned,
|
|
7
|
+
# or re-raises what it raised. Side effects of the block on the process
|
|
8
|
+
# (mutations, globals, constants, the current directory, `ENV`, a crash)
|
|
9
|
+
# stay in the child; what it does outside, to files or to other processes,
|
|
10
|
+
# does not.
|
|
7
11
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
12
|
+
# The result travels through a pipe with `Marshal`, so it must be a
|
|
13
|
+
# marshalable value; a `Proc`, an `IO` or an instance of an anonymous class
|
|
14
|
+
# cannot come back. `fork(2)` is required: Windows, JRuby and any other
|
|
15
|
+
# implementation without it are not supported, and {Aw.supported?} says so.
|
|
10
16
|
#
|
|
11
|
-
# @
|
|
17
|
+
# @example
|
|
18
|
+
# list = []
|
|
19
|
+
# Aw.fork! { list << 1 } # => [1]
|
|
20
|
+
# list # => []
|
|
12
21
|
module Aw
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# Aw.fork! { 6 * 7 } # => 42
|
|
17
|
-
#
|
|
18
|
-
# @example Computes `nil + 1` in a sub-process and raises `NoMethodError` to the current process.
|
|
19
|
-
# Aw.fork! { nil + 1 } # => raise NoMethodError (undefined method `+' for nil:NilClass)
|
|
20
|
-
#
|
|
21
|
-
# @raise [Exception] Exceptions raised in a block of code are propagated.
|
|
22
|
-
# @return [#object_id] Returns the value that has been returned in the block.
|
|
23
|
-
def self.fork!(&)
|
|
24
|
-
read, write = ::IO.pipe
|
|
25
|
-
Fork.new(read, write).call(&)
|
|
22
|
+
# @return [Boolean] whether this Ruby can fork
|
|
23
|
+
def self.supported?
|
|
24
|
+
::Process.respond_to?(:fork)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
# Runs the block
|
|
29
|
-
# thrown. Otherwise when an exception is raised, `false` is returned.
|
|
27
|
+
# Runs the block in a child process and returns its value.
|
|
30
28
|
#
|
|
31
|
-
# @
|
|
32
|
-
#
|
|
29
|
+
# @yieldreturn [Object] a marshalable value
|
|
30
|
+
# @return [Object] the value returned by the block, loaded from the child
|
|
31
|
+
# @raise [ArgumentError] when no block is given
|
|
32
|
+
# @raise [NotImplementedError] when this Ruby cannot fork
|
|
33
|
+
# @raise [Exception] whatever the block raised, re-raised in the parent
|
|
34
|
+
# @raise [Aw::ChildError] when the child delivered no result (it died, the block called `exit!` or
|
|
35
|
+
# left with `break`, `return` or `throw`), or its result cannot be transferred or loaded
|
|
36
|
+
def self.fork!(&block)
|
|
37
|
+
raise ::ArgumentError, "a block must be provided" if block.nil?
|
|
38
|
+
|
|
39
|
+
kind, value = Fork.run(block)
|
|
40
|
+
# The cause is the one the exception had in the child, never the
|
|
41
|
+
# exception the parent may be handling.
|
|
42
|
+
raise value, cause: value.cause if kind == :raise
|
|
43
|
+
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs the block in a child process and tells whether it returned.
|
|
33
48
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
49
|
+
# Only what happens in the child is reported: an exception raised in the
|
|
50
|
+
# parent while waiting, such as an `Interrupt` or a `Timeout::Error`,
|
|
51
|
+
# propagates. Neither the value nor the exception of the block travels,
|
|
52
|
+
# so this works whatever they are.
|
|
36
53
|
#
|
|
37
|
-
# @
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
# @yieldreturn [Object] ignored
|
|
55
|
+
# @return [Boolean] true when the block returned, false when it raised
|
|
56
|
+
# @raise [ArgumentError] when no block is given
|
|
57
|
+
# @raise [NotImplementedError] when this Ruby cannot fork
|
|
58
|
+
# @raise [Aw::ChildError] when the child died, or the block called `exit!` or left with `break`,
|
|
59
|
+
# `return` or `throw`
|
|
60
|
+
def self.fork?(&block)
|
|
61
|
+
raise ::ArgumentError, "a block must be provided" if block.nil?
|
|
62
|
+
|
|
63
|
+
returned = lambda do
|
|
64
|
+
yield
|
|
65
|
+
true
|
|
66
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
_, value = Fork.run(returned)
|
|
71
|
+
value.equal?(true)
|
|
42
72
|
end
|
|
43
73
|
end
|
|
44
|
-
|
|
45
|
-
require_relative File.join("aw", "fork")
|
metadata
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
|
|
13
|
+
description: Runs a block in a forked child process and brings back what it returned,
|
|
14
|
+
or re-raises what it raised. Side effects on the process stay in the child.
|
|
15
15
|
email: contact@cyril.email
|
|
16
16
|
executables: []
|
|
17
17
|
extensions: []
|
|
@@ -20,13 +20,17 @@ files:
|
|
|
20
20
|
- LICENSE.md
|
|
21
21
|
- README.md
|
|
22
22
|
- lib/aw.rb
|
|
23
|
+
- lib/aw/child_error.rb
|
|
23
24
|
- lib/aw/fork.rb
|
|
24
|
-
homepage: https://
|
|
25
|
+
homepage: https://fixrb.dev/gems/aw/
|
|
25
26
|
licenses:
|
|
26
27
|
- MIT
|
|
27
28
|
metadata:
|
|
29
|
+
bug_tracker_uri: https://github.com/fixrb/aw/issues
|
|
30
|
+
documentation_uri: https://rubydoc.info/gems/aw
|
|
28
31
|
rubygems_mfa_required: 'true'
|
|
29
|
-
|
|
32
|
+
source_code_uri: https://github.com/fixrb/aw
|
|
33
|
+
post_install_message:
|
|
30
34
|
rdoc_options: []
|
|
31
35
|
require_paths:
|
|
32
36
|
- lib
|
|
@@ -34,15 +38,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
34
38
|
requirements:
|
|
35
39
|
- - ">="
|
|
36
40
|
- !ruby/object:Gem::Version
|
|
37
|
-
version: 3.
|
|
41
|
+
version: 3.3.0
|
|
38
42
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
39
43
|
requirements:
|
|
40
44
|
- - ">="
|
|
41
45
|
- !ruby/object:Gem::Version
|
|
42
46
|
version: '0'
|
|
43
47
|
requirements: []
|
|
44
|
-
rubygems_version: 3.
|
|
45
|
-
signing_key:
|
|
48
|
+
rubygems_version: 3.5.22
|
|
49
|
+
signing_key:
|
|
46
50
|
specification_version: 4
|
|
47
|
-
summary:
|
|
51
|
+
summary: Aw, fork❗
|
|
48
52
|
test_files: []
|