async-container 0.18.3 → 0.20.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.
@@ -3,17 +3,22 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2018-2024, by Samuel Williams.
5
5
 
6
- require 'fiber'
7
- require 'async/clock'
6
+ require "fiber"
7
+ require "async/clock"
8
8
 
9
- require_relative 'error'
9
+ require_relative "error"
10
10
 
11
11
  module Async
12
12
  module Container
13
13
  # Manages a group of running processes.
14
14
  class Group
15
15
  # Initialize an empty group.
16
- def initialize
16
+ #
17
+ # @parameter health_check_interval [Numeric | Nil] The (biggest) interval at which health checks are performed.
18
+ def initialize(health_check_interval: 1.0)
19
+ @health_check_interval = health_check_interval
20
+
21
+ # The running fibers, indexed by IO:
17
22
  @running = {}
18
23
 
19
24
  # This queue allows us to wait for processes to complete, without spawning new processes as a result.
@@ -57,15 +62,43 @@ module Async
57
62
  def wait
58
63
  self.resume
59
64
 
60
- while self.running?
61
- self.wait_for_children
65
+ with_health_checks do |duration|
66
+ self.wait_for_children(duration)
67
+ end
68
+ end
69
+
70
+ private def with_health_checks
71
+ if @health_check_interval
72
+ health_check_clock = Clock.start
73
+
74
+ while self.running?
75
+ duration = [@health_check_interval - health_check_clock.total, 0].max
76
+
77
+ yield duration
78
+
79
+ if health_check_clock.total > @health_check_interval
80
+ self.health_check!
81
+ health_check_clock.reset!
82
+ end
83
+ end
84
+ else
85
+ while self.running?
86
+ yield nil
87
+ end
88
+ end
89
+ end
90
+
91
+ # Perform a health check on all running processes.
92
+ def health_check!
93
+ @running.each_value do |fiber|
94
+ fiber.resume(:health_check!)
62
95
  end
63
96
  end
64
97
 
65
98
  # Interrupt all running processes.
66
99
  # This resumes the controlling fiber with an instance of {Interrupt}.
67
100
  def interrupt
68
- Console.logger.debug(self, "Sending interrupt to #{@running.size} running processes...")
101
+ Console.info(self, "Sending interrupt to #{@running.size} running processes...")
69
102
  @running.each_value do |fiber|
70
103
  fiber.resume(Interrupt)
71
104
  end
@@ -74,7 +107,7 @@ module Async
74
107
  # Terminate all running processes.
75
108
  # This resumes the controlling fiber with an instance of {Terminate}.
76
109
  def terminate
77
- Console.logger.debug(self, "Sending terminate to #{@running.size} running processes...")
110
+ Console.info(self, "Sending terminate to #{@running.size} running processes...")
78
111
  @running.each_value do |fiber|
79
112
  fiber.resume(Terminate)
80
113
  end
@@ -83,6 +116,7 @@ module Async
83
116
  # Stop all child processes using {#terminate}.
84
117
  # @parameter timeout [Boolean | Numeric | Nil] If specified, invoke a graceful shutdown using {#interrupt} first.
85
118
  def stop(timeout = 1)
119
+ Console.info(self, "Stopping all processes...", timeout: timeout)
86
120
  # Use a default timeout if not specified:
87
121
  timeout = 1 if timeout == true
88
122
 
@@ -105,7 +139,7 @@ module Async
105
139
  end
106
140
 
107
141
  # Terminate all children:
108
- self.terminate
142
+ self.terminate if any?
109
143
 
110
144
  # Wait for all children to exit:
111
145
  self.wait
@@ -118,15 +152,19 @@ module Async
118
152
  @running[io] = Fiber.current
119
153
 
120
154
  while @running.key?(io)
155
+ # Wait for some event on the channel:
121
156
  result = Fiber.yield
122
157
 
123
158
  if result == Interrupt
124
159
  channel.interrupt!
125
160
  elsif result == Terminate
126
161
  channel.terminate!
162
+ elsif result
163
+ yield result
127
164
  elsif message = channel.receive
128
165
  yield message
129
166
  else
167
+ # Wait for the channel to exit:
130
168
  return channel.wait
131
169
  end
132
170
  end
@@ -137,14 +175,24 @@ module Async
137
175
  protected
138
176
 
139
177
  def wait_for_children(duration = nil)
140
- Console.debug(self, "Waiting for children...", duration: duration)
178
+ Console.debug(self, "Waiting for children...", duration: duration, running: @running)
179
+
141
180
  if !@running.empty?
142
181
  # Maybe consider using a proper event loop here:
182
+ if ready = self.select(duration)
183
+ ready.each do |io|
184
+ @running[io].resume
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ # Wait for a child process to exit OR a signal to be received.
191
+ def select(duration)
192
+ ::Thread.handle_interrupt(SignalException => :immediate) do
143
193
  readable, _, _ = ::IO.select(@running.keys, nil, nil, duration)
144
194
 
145
- readable&.each do |io|
146
- @running[io].resume
147
- end
195
+ return readable
148
196
  end
149
197
  end
150
198
 
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
  # Copyright, 2022, by Anton Sozontov.
6
6
 
7
- require_relative 'forked'
8
- require_relative 'threaded'
7
+ require_relative "forked"
8
+ require_relative "threaded"
9
9
 
10
10
  module Async
11
11
  module Container
@@ -3,9 +3,9 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2020-2024, by Samuel Williams.
5
5
 
6
- require_relative 'client'
6
+ require_relative "client"
7
7
 
8
- require 'console/logger'
8
+ require "console"
9
9
 
10
10
  module Async
11
11
  module Container
@@ -24,8 +24,8 @@ module Async
24
24
  end
25
25
 
26
26
  # Send a message to the console.
27
- def send(level: :debug, **message)
28
- @logger.send(level, self) {message}
27
+ def send(level: :info, **message)
28
+ @logger.public_send(level, self) {message}
29
29
  end
30
30
 
31
31
  # Send an error message to the console.
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2022, by Samuel Williams.
4
+ # Copyright, 2020-2024, by Samuel Williams.
5
5
  # Copyright, 2020, by Juan Antonio Martín Lucas.
6
6
 
7
- require_relative 'client'
7
+ require_relative "client"
8
8
 
9
- require 'json'
9
+ require "json"
10
10
 
11
11
  module Async
12
12
  module Container
@@ -14,7 +14,7 @@ module Async
14
14
  # Implements a process readiness protocol using an inherited pipe file descriptor.
15
15
  class Pipe < Client
16
16
  # The environment variable key which contains the pipe file descriptor.
17
- NOTIFY_PIPE = 'NOTIFY_PIPE'
17
+ NOTIFY_PIPE = "NOTIFY_PIPE"
18
18
 
19
19
  # Open a notification client attached to the current {NOTIFY_PIPE} if possible.
20
20
  def self.open!(environment = ENV)
@@ -22,7 +22,7 @@ module Async
22
22
  self.new(::IO.for_fd(descriptor.to_i))
23
23
  end
24
24
  rescue Errno::EBADF => error
25
- Console.logger.error(self) {error}
25
+ Console.error(self) {error}
26
26
 
27
27
  return nil
28
28
  end
@@ -4,14 +4,13 @@
4
4
  # Copyright, 2020-2024, by Samuel Williams.
5
5
  # Copyright, 2020, by Olle Jonsson.
6
6
 
7
- require 'tmpdir'
8
- require 'securerandom'
7
+ require "tmpdir"
8
+ require "securerandom"
9
9
 
10
10
  module Async
11
11
  module Container
12
12
  module Notify
13
13
  class Server
14
- NOTIFY_SOCKET = 'NOTIFY_SOCKET'
15
14
  MAXIMUM_MESSAGE_SIZE = 4096
16
15
 
17
16
  def self.load(message)
@@ -22,13 +21,17 @@ module Async
22
21
  pairs = lines.map do |line|
23
22
  key, value = line.split("=", 2)
24
23
 
25
- if value == '0'
24
+ key = key.downcase.to_sym
25
+
26
+ if value == "0"
26
27
  value = false
27
- elsif value == '1'
28
+ elsif value == "1"
28
29
  value = true
30
+ elsif key == :errno and value =~ /\A\-?\d+\z/
31
+ value = Integer(value)
29
32
  end
30
33
 
31
- next [key.downcase.to_sym, value]
34
+ next [key, value]
32
35
  end
33
36
 
34
37
  return Hash[pairs]
@@ -75,7 +78,11 @@ module Async
75
78
 
76
79
  message = Server.load(data)
77
80
 
78
- yield message
81
+ if block_given?
82
+ yield message
83
+ else
84
+ return message
85
+ end
79
86
  end
80
87
  end
81
88
  end
@@ -3,7 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2020-2024, by Samuel Williams.
5
5
 
6
- require_relative 'client'
6
+ require_relative "client"
7
+ require "socket"
7
8
 
8
9
  module Async
9
10
  module Container
@@ -12,7 +13,7 @@ module Async
12
13
  # See <https://www.freedesktop.org/software/systemd/man/sd_notify.html> for more details of the underlying protocol.
13
14
  class Socket < Client
14
15
  # The name of the environment variable which contains the path to the notification socket.
15
- NOTIFY_SOCKET = 'NOTIFY_SOCKET'
16
+ NOTIFY_SOCKET = "NOTIFY_SOCKET"
16
17
 
17
18
  # The maximum allowed size of the UDP message.
18
19
  MAXIMUM_MESSAGE_SIZE = 4096
@@ -31,6 +32,9 @@ module Async
31
32
  @address = Addrinfo.unix(path, ::Socket::SOCK_DGRAM)
32
33
  end
33
34
 
35
+ # @attribute [String] The path to the UNIX socket used for sending messages to the controller.
36
+ attr :path
37
+
34
38
  # Dump a message in the format requied by `sd_notify`.
35
39
  # @parameter message [Hash] Keys and values should be string convertible objects. Values which are `true`/`false` are converted to `1`/`0` respectively.
36
40
  def dump(message)
@@ -56,7 +60,7 @@ module Async
56
60
  data = dump(message)
57
61
 
58
62
  if data.bytesize > MAXIMUM_MESSAGE_SIZE
59
- raise ArgumentError, "Message length #{message.bytesize} exceeds #{MAXIMUM_MESSAGE_SIZE}: #{message.inspect}"
63
+ raise ArgumentError, "Message length #{data.bytesize} exceeds #{MAXIMUM_MESSAGE_SIZE}: #{message.inspect}"
60
64
  end
61
65
 
62
66
  @address.connect do |peer|
@@ -69,7 +73,7 @@ module Async
69
73
  def error!(text, **message)
70
74
  message[:errno] ||= -1
71
75
 
72
- send(status: text, **message)
76
+ super
73
77
  end
74
78
  end
75
79
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2022, by Samuel Williams.
4
+ # Copyright, 2020-2024, by Samuel Williams.
5
5
 
6
- require_relative 'notify/pipe'
7
- require_relative 'notify/socket'
8
- require_relative 'notify/console'
6
+ require_relative "notify/pipe"
7
+ require_relative "notify/socket"
8
+ require_relative "notify/console"
9
9
 
10
10
  module Async
11
11
  module Container
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require 'async/reactor'
6
+ require "async/reactor"
7
7
 
8
8
  module Async
9
9
  module Container
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2022, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
 
6
- require_relative 'generic'
7
- require_relative 'thread'
6
+ require_relative "generic"
7
+ require_relative "channel"
8
+ require_relative "notify/pipe"
8
9
 
9
10
  module Async
10
11
  module Container
@@ -15,11 +16,230 @@ module Async
15
16
  false
16
17
  end
17
18
 
19
+ # Represents a running child thread from the point of view of the parent container.
20
+ class Child < Channel
21
+ # Used to propagate the exit status of a child process invoked by {Instance#exec}.
22
+ class Exit < Exception
23
+ # Initialize the exit status.
24
+ # @parameter status [::Process::Status] The process exit status.
25
+ def initialize(status)
26
+ @status = status
27
+ end
28
+
29
+ # The process exit status.
30
+ # @attribute [::Process::Status]
31
+ attr :status
32
+
33
+ # The process exit status if it was an error.
34
+ # @returns [::Process::Status | Nil]
35
+ def error
36
+ unless status.success?
37
+ status
38
+ end
39
+ end
40
+ end
41
+
42
+ # Represents a running child thread from the point of view of the child thread.
43
+ class Instance < Notify::Pipe
44
+ # Wrap an instance around the {Thread} instance from within the threaded child.
45
+ # @parameter thread [Thread] The thread intance to wrap.
46
+ def self.for(thread)
47
+ instance = self.new(thread.out)
48
+
49
+ return instance
50
+ end
51
+
52
+ def initialize(io)
53
+ @name = nil
54
+ @thread = ::Thread.current
55
+
56
+ super
57
+ end
58
+
59
+ # Set the name of the thread.
60
+ # @parameter value [String] The name to set.
61
+ def name= value
62
+ @thread.name = value
63
+ end
64
+
65
+ # Get the name of the thread.
66
+ # @returns [String]
67
+ def name
68
+ @thread.name
69
+ end
70
+
71
+ # Execute a child process using {::Process.spawn}. In order to simulate {::Process.exec}, an {Exit} instance is raised to propagage exit status.
72
+ # This creates the illusion that this method does not return (normally).
73
+ def exec(*arguments, ready: true, **options)
74
+ if ready
75
+ self.ready!(status: "(spawn)")
76
+ else
77
+ self.before_spawn(arguments, options)
78
+ end
79
+
80
+ begin
81
+ pid = ::Process.spawn(*arguments, **options)
82
+ ensure
83
+ _, status = ::Process.wait2(pid)
84
+
85
+ raise Exit, status
86
+ end
87
+ end
88
+ end
89
+
90
+ def self.fork(**options)
91
+ self.new(**options) do |thread|
92
+ ::Thread.new do
93
+ # This could be a configuration option (see forked implementation too):
94
+ ::Thread.handle_interrupt(SignalException => :immediate) do
95
+ yield Instance.for(thread)
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ # Initialize the thread.
102
+ # @parameter name [String] The name to use for the child thread.
103
+ def initialize(name: nil)
104
+ super()
105
+
106
+ @status = nil
107
+
108
+ @thread = yield(self)
109
+ @thread.report_on_exception = false
110
+ @thread.name = name
111
+
112
+ @waiter = ::Thread.new do
113
+ begin
114
+ @thread.join
115
+ rescue Exit => exit
116
+ finished(exit.error)
117
+ rescue Interrupt
118
+ # Graceful shutdown.
119
+ finished
120
+ rescue Exception => error
121
+ finished(error)
122
+ else
123
+ finished
124
+ end
125
+ end
126
+ end
127
+
128
+ # Convert the child process to a hash, suitable for serialization.
129
+ #
130
+ # @returns [Hash] The request as a hash.
131
+ def as_json(...)
132
+ {
133
+ name: @thread.name,
134
+ status: @status&.as_json,
135
+ }
136
+ end
137
+
138
+ # Convert the request to JSON.
139
+ #
140
+ # @returns [String] The request as JSON.
141
+ def to_json(...)
142
+ as_json.to_json(...)
143
+ end
144
+
145
+ # Set the name of the thread.
146
+ # @parameter value [String] The name to set.
147
+ def name= value
148
+ @thread.name = value
149
+ end
150
+
151
+ # Get the name of the thread.
152
+ # @returns [String]
153
+ def name
154
+ @thread.name
155
+ end
156
+
157
+ # A human readable representation of the thread.
158
+ # @returns [String]
159
+ def to_s
160
+ "\#<#{self.class} #{@thread.name}>"
161
+ end
162
+
163
+ # Invoke {#terminate!} and then {#wait} for the child thread to exit.
164
+ def close
165
+ self.terminate!
166
+ self.wait
167
+ ensure
168
+ super
169
+ end
170
+
171
+ # Raise {Interrupt} in the child thread.
172
+ def interrupt!
173
+ @thread.raise(Interrupt)
174
+ end
175
+
176
+ # Raise {Terminate} in the child thread.
177
+ def terminate!
178
+ @thread.raise(Terminate)
179
+ end
180
+
181
+ # Raise {Restart} in the child thread.
182
+ def restart!
183
+ @thread.raise(Restart)
184
+ end
185
+
186
+ # Wait for the thread to exit and return he exit status.
187
+ # @returns [Status]
188
+ def wait
189
+ if @waiter
190
+ @waiter.join
191
+ @waiter = nil
192
+ end
193
+
194
+ return @status
195
+ end
196
+
197
+ # A pseudo exit-status wrapper.
198
+ class Status
199
+ # Initialise the status.
200
+ # @parameter error [::Process::Status] The exit status of the child thread.
201
+ def initialize(error = nil)
202
+ @error = error
203
+ end
204
+
205
+ # Whether the status represents a successful outcome.
206
+ # @returns [Boolean]
207
+ def success?
208
+ @error.nil?
209
+ end
210
+
211
+ def as_json(...)
212
+ if @error
213
+ @error.inspect
214
+ else
215
+ true
216
+ end
217
+ end
218
+
219
+ # A human readable representation of the status.
220
+ def to_s
221
+ "\#<#{self.class} #{success? ? "success" : "failure"}>"
222
+ end
223
+ end
224
+
225
+ protected
226
+
227
+ # Invoked by the @waiter thread to indicate the outcome of the child thread.
228
+ def finished(error = nil)
229
+ if error
230
+ Console.error(self) {error}
231
+ end
232
+
233
+ @status = Status.new(error)
234
+ self.close_write
235
+ end
236
+ end
237
+
18
238
  # Start a named child thread and execute the provided block in it.
19
239
  # @parameter name [String] The name (title) of the child process.
20
240
  # @parameter block [Proc] The block to execute in the child process.
21
241
  def start(name, &block)
22
- Thread.fork(name: name, &block)
242
+ Child.fork(name: name, &block)
23
243
  end
24
244
  end
25
245
  end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Container
8
- VERSION = "0.18.3"
8
+ VERSION = "0.20.0"
9
9
  end
10
10
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2022, by Samuel Williams.
4
+ # Copyright, 2017-2024, by Samuel Williams.
5
5
 
6
- require_relative 'container/controller'
6
+ require_relative "container/controller"
7
7
 
8
8
  module Async
9
9
  module Container
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2017-2024, by Samuel Williams.
3
+ Copyright, 2017-2025, by Samuel Williams.
4
4
  Copyright, 2019, by Yuji Yaginuma.
5
5
  Copyright, 2020, by Olle Jonsson.
6
6
  Copyright, 2020, by Juan Antonio Martín Lucas.
data/releases.md ADDED
@@ -0,0 +1,6 @@
1
+ # Releases
2
+
3
+ ## Unreleased
4
+
5
+ - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points.
6
+ - Improved logging when child process fails and container startup.
data.tar.gz.sig CHANGED
Binary file