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.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +1 -1
  3. data/README.md +64 -66
  4. data/lib/aw/child_error.rb +25 -0
  5. data/lib/aw/fork.rb +168 -56
  6. data/lib/aw.rb +61 -33
  7. metadata +15 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e73149e7b499dddb359e9995c9cfe6b1af71d2bb63789f9bf0e13d029893b10
4
- data.tar.gz: debe86862bee16a1902fb2c1b6448ba7d5bff733fa5ed0470cce4af5a2d30cb4
3
+ metadata.gz: bdae0f9011185309d30daa5f7259d2e23b72e906c86d7d0a04a0bb1e3a23605c
4
+ data.tar.gz: fb0b8786d878675b8b550cb0809eade95eb9ac2676df58d23e3e5ee94ba2add5
5
5
  SHA512:
6
- metadata.gz: 92954fad1db8bae390956d76c27cffb0fe399c55f2df5b16f102a2a69345a09b62d64a0694864036745f4cb4ceb89a7e143547d035da743e3abd22c0d1ffa14a
7
- data.tar.gz: a1eb7a51f4bd7c068ace563f093e2ba94235067bd773a7921f6f175cf9a2a559ba045b9b54fe6f021b5b114ad33bdab48bbc0843ef617ea9fe42a386ae52ef79
6
+ metadata.gz: f389d3857089097b5170e9dcd49e6365479ae906a428f36d46d11d25f37f4f1109d31f2f96a6c1b66c720b82adec41adc644142b3fe9b1625d9be46584be7ce5
7
+ data.tar.gz: b4d6b184b4620f48e22282e6c1145673ade09ac8d9c85fbe90df45368c600b9ffbf3cab715c8a064efe226f103f9d1d9e9c20b17d1a64a7d2b6bcaf3a5b2a9e0
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2015-2024 Cyril Kato
3
+ Copyright (c) 2015-2026 Cyril Kato
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,118 +2,116 @@
2
2
 
3
3
  [![Version](https://img.shields.io/github/v/tag/fixrb/aw?label=Version&logo=github)](https://github.com/fixrb/aw/tags)
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/aw/main)
5
- [![Ruby](https://github.com/fixrb/aw/workflows/Ruby/badge.svg?branch=main)](https://github.com/fixrb/aw/actions?query=workflow%3Aruby+branch%3Amain)
6
- [![RuboCop](https://github.com/fixrb/aw/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/aw/actions?query=workflow%3Arubocop+branch%3Amain)
7
5
  [![License](https://img.shields.io/github/license/fixrb/aw?label=License&logo=github)](https://github.com/fixrb/aw/raw/main/LICENSE.md)
8
6
 
9
- > Aw, fork 😬
7
+ > Aw, fork❗
10
8
 
11
- ![Aw](https://github.com/fixrb/aw/raw/main/img/aw.jpg)
9
+ ![A pixel-art hero shouting "Aw, fork!" while his forked clone strikes a golem](https://github.com/fixrb/aw/raw/main/img/aw.png)
12
10
 
13
- Creates a sub-process to execute a block inside, and returns what it returns (or a boolean).
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
- And then execute:
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
- When the execution of a block of code causes side effects, these are limited to the sub-process:
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
- arr = ["foo"] # => ["foo"]
60
-
61
- Aw.fork! { arr << "FUU" } # => ["foo", "FUU"]
62
-
63
- arr # => ["foo"]
33
+ list = ["foo"]
34
+ Aw.fork! { list << "bar" } # => ["foo", "bar"]
35
+ list # => ["foo"]
64
36
  ```
65
37
 
66
- Exceptions raised in a block of code are propagated:
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! { nil + 1 }
41
+ Aw.fork! { raise ArgumentError, "boom" } # raises ArgumentError: boom
70
42
  ```
71
43
 
72
- results in the error:
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
- Executes a block of code in a sub-process, and returns `true` if no exception is thrown:
46
+ `fork?` only tells whether the block returned:
79
47
 
80
48
  ```ruby
81
- Aw.fork? { 6 * 7 } # => true
49
+ Aw.fork? { 6 * 7 } # => true
50
+ Aw.fork? { raise "boom" } # => false
51
+ Aw.fork? { exit 0 } # => false
82
52
  ```
83
53
 
84
- When the execution of a block of code causes side effects, these are limited to the sub-process:
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
- ```ruby
87
- arr = ["foo"] # => ["foo"]
88
-
89
- Aw.fork? { arr << "FUU" } # => true
56
+ ## What comes back, and what cannot
90
57
 
91
- arr # => ["foo"]
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
- When an exception is raised in a code block, `false` is returned:
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? { nil + 1 } # => false
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
- ## Contact
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
- * Source code: https://github.com/fixrb/aw
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
- __Aw__ follows [Semantic Versioning 2.0](https://semver.org/).
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
- <p>
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
- require "English"
3
+ require_relative "child_error"
4
4
 
5
5
  module Aw
6
- # The Fork class.
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
- class Fork
10
- # Initialize the class.
11
- #
12
- # @param read [IO] The read endpoint.
13
- # @param write [IO] The write endpoint.
14
- def initialize(read, write)
15
- # Currently, not available on all platforms.
16
- raise ::NotImplementedError, "fork()" unless ::Process.respond_to?(:fork)
17
-
18
- @read = read
19
- @write = write
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
- # @!attribute [r] read
23
- #
24
- # @return [IO] The read endpoint.
25
- attr_reader :read
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
- # @!attribute [r] write
28
- #
29
- # @return [IO] The write endpoint.
30
- attr_reader :write
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
- # Runs the block inside a sub-process, and returns the computed value.
33
- #
34
- # @example Computes `6 * 7` in a sub-process and returns `42` to the current process.
35
- # call { 6 * 7 } # => 42
36
- #
37
- # @raise [Exception] Exceptions raised in a block of code are propagated.
38
- # @return [#object_id] Returns the value that has been returned in the block.
39
- def call(&)
40
- pid = fork_and_return_pid(&)
41
- write.close
42
- result = read.read
43
- ::Process.wait(pid)
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
- # rubocop:disable Security/MarshalLoad
46
- ::Marshal.load(result).tap { |r| raise r if r.is_a?(::Exception) }
47
- # rubocop:enable Security/MarshalLoad
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
- private
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
- # Creates a sub-process to execute a block inside, and returns the
53
- # sub-process ID.
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 [Integer] The ID of the created sub-process.
56
- def fork_and_return_pid
57
- ::Process.fork do
58
- # :nocov:
59
-
60
- read.close
61
-
62
- # rubocop:disable Lint/RescueException
63
- begin
64
- result = yield
65
- rescue ::Exception
66
- result = $ERROR_INFO
67
- end
68
- # rubocop:enable Lint/RescueException
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
- ::Marshal.dump(result, write)
71
- ::Process.exit!(true)
150
+ kind, value = load(data, status)
151
+ raise ChildError.new(value, status:) if kind == :opaque
72
152
 
73
- # :nocov:
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
- # Namespace for the Aw library.
4
- #
5
- # @example Computes `6 * 7` in a sub-process and returns `42` to the current process.
6
- # Aw.fork! { 6 * 7 } # => 42
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
- # @example Computes `6 * 7` in a sub-process and returns `true` to the current process if no exception is thrown.
9
- # Aw.fork? { 6 * 7 } # => true
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
- # @api public
17
+ # @example
18
+ # list = []
19
+ # Aw.fork! { list << 1 } # => [1]
20
+ # list # => []
12
21
  module Aw
13
- # Runs the block inside a sub-process, and returns the computed value.
14
- #
15
- # @example Computes `6 * 7` in a sub-process and returns `42` to the current process.
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 inside a sub-process, and returns `true` if no exception is
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
- # @example Computes `6 * 7` in a sub-process and returns `true` to the current process.
32
- # Aw.fork? { 6 * 7 } # => true
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
- # @example Computes `nil + 1` in a sub-process and returns `false` to the current process.
35
- # Aw.fork? { nil + 1 } # => false
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
- # @return [Boolean] Returns `true` if stat is successful, `false` if not.
38
- def self.fork?(&)
39
- pid = ::Process.fork(&)
40
- _, status = ::Process.wait2(pid)
41
- status.success?
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.2.1
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: 2024-01-25 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Creates a sub-process to execute a block inside, and returns what it
14
- returns.
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://github.com/fixrb/aw
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
- post_install_message:
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.2.0
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.4.19
45
- signing_key:
48
+ rubygems_version: 3.5.22
49
+ signing_key:
46
50
  specification_version: 4
47
- summary: "Aw, fork \U0001F62C"
51
+ summary: Aw, fork❗
48
52
  test_files: []