siding 0.0.1

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.
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "fileutils"
6
+ require "rbconfig"
7
+
8
+ require_relative "error"
9
+ require_relative "version"
10
+ require_relative "project_key"
11
+ require_relative "runtime"
12
+ require_relative "logger"
13
+ require_relative "protocol"
14
+ require_relative "life_cycle"
15
+ require_relative "load_manifest"
16
+ require_relative "staleness"
17
+ require_relative "restarter"
18
+ require_relative "worker"
19
+
20
+ module Siding
21
+ class Server
22
+ BOOTSTRAP = <<~RUBY
23
+ begin
24
+ Process.setsid
25
+ rescue SystemCallError
26
+ begin
27
+ Process.setpgrp
28
+ rescue SystemCallError
29
+ nil
30
+ end
31
+ end
32
+ require ARGV.shift
33
+ Siding::Server.start(app_root: ARGV.shift, digest: ARGV.shift)
34
+ RUBY
35
+
36
+ class << self
37
+ def spawn(project_key:, runtime:, env:)
38
+ log = File.open(runtime.boot_log_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600)
39
+ begin
40
+ Process.spawn(
41
+ spawn_env(project_key, env),
42
+ RbConfig.ruby, "-e", BOOTSTRAP, "--",
43
+ __FILE__, project_key.app_root, project_key.digest,
44
+ chdir: project_key.app_root,
45
+ in: File::NULL, out: log, err: log
46
+ )
47
+ ensure
48
+ log.close
49
+ end
50
+ end
51
+
52
+ def spawn_env(project_key, env)
53
+ vars = { "SIDING_SERVER" => "1" }
54
+ gemfile = File.join(project_key.app_root, "Gemfile")
55
+ vars["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
56
+ vars["RAILS_ENV"] = project_key.app_env
57
+ vars["RACK_ENV"] = project_key.app_env
58
+ vars
59
+ end
60
+
61
+ def start(app_root:, digest:)
62
+ key = ProjectKey.for(app_root)
63
+ if key.digest != digest
64
+ warn("siding: project key mismatch (client #{digest}, server #{key.digest})")
65
+ exit 1
66
+ end
67
+
68
+ new(project_key: key, runtime: Runtime.for(key)).run
69
+ end
70
+ end
71
+
72
+ DRAIN_INTERVAL = 0.05
73
+ SELECT_INTERVAL = 1.0
74
+ DEFAULT_IDLE_TIMEOUT = 900.0
75
+
76
+ attr_reader :project_key, :runtime, :logger, :manifest, :events, :restarter
77
+
78
+ def initialize(project_key:, runtime:, logger: nil, env: ENV)
79
+ @project_key = project_key
80
+ @runtime = runtime
81
+ @logger = logger || Logger.new(log_path: runtime.log_path)
82
+ @env = env
83
+ @events = Staleness::Events.new(path: runtime.events_path)
84
+ @workers = {}
85
+ @workers_mutex = Mutex.new
86
+ @shutting_down = false
87
+ @withdrawn = false
88
+ @served = 0
89
+ @idle_timeout = self.class.idle_timeout_from(env)
90
+ touch
91
+ end
92
+
93
+ def self.idle_timeout_from(env)
94
+ timeout = env["SIDING_IDLE_TIMEOUT"].to_f
95
+ timeout.positive? ? timeout : DEFAULT_IDLE_TIMEOUT
96
+ end
97
+
98
+ def run
99
+ boot_application
100
+ listen
101
+ publish
102
+ install_signal_handlers
103
+ start_restarter
104
+ accept_loop
105
+ ensure
106
+ drain_workers
107
+ shutdown
108
+ end
109
+
110
+ def start_restarter
111
+ @restarter = Restarter.new(manifest: manifest, project_key: project_key, runtime: runtime,
112
+ logger: logger,
113
+ busy: -> { busy? },
114
+ superseded: -> { !published_by_us? },
115
+ on_superseded: -> { retire })
116
+ @restarter.start
117
+ end
118
+
119
+ def busy?
120
+ @shutting_down || @workers_mutex.synchronize { !@workers.empty? }
121
+ end
122
+
123
+ def retire
124
+ logger.debug("standing down; a replacement has taken over #{project_key.label}")
125
+ @shutting_down = true
126
+ wake
127
+ end
128
+
129
+ private
130
+
131
+ def boot_application
132
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ @manifest = LoadManifest.around_boot(app_root: project_key.app_root) do
134
+ require "bundler/setup" if File.file?(File.join(project_key.app_root, "Gemfile"))
135
+ require File.join(project_key.app_root, "config", "environment")
136
+ end
137
+ @boot_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
138
+ @booted_at = Time.now.to_f
139
+ touch
140
+ logger.debug("booted #{manifest.revision_label} in #{(@boot_seconds * 1000).round}ms, watching #{manifest.file_entries.size} files")
141
+ end
142
+
143
+ def listen
144
+ staging = "#{runtime.socket_path}.#{Process.pid}"
145
+ File.unlink(staging) if File.exist?(staging)
146
+ @server_socket = UNIXServer.new(staging)
147
+ File.chmod(0o600, staging)
148
+ File.rename(staging, runtime.socket_path)
149
+ end
150
+
151
+ def publish
152
+ payload = {
153
+ "pid" => Process.pid,
154
+ "protocol_version" => Protocol::VERSION,
155
+ "tool_version" => Siding::VERSION,
156
+ "app_root" => project_key.app_root,
157
+ "app_env" => project_key.app_env,
158
+ "booted_at" => @booted_at,
159
+ "boot_seconds" => @boot_seconds&.round(3),
160
+ "revision_label" => manifest.revision_label
161
+ }
162
+ staging = "#{runtime.server_info_path}.#{Process.pid}"
163
+ File.write(staging, JSON.generate(payload))
164
+ File.chmod(0o600, staging)
165
+ File.rename(staging, runtime.server_info_path)
166
+ end
167
+
168
+ def install_signal_handlers
169
+ @wake_read, @wake_write = IO.pipe
170
+ LifeCycle::SERVER_SIGNALS.each do |signal|
171
+ previous = trap(signal) do
172
+ @forced = true if @shutting_down
173
+ @shutting_down = true
174
+ wake
175
+ end
176
+ LifeCycle.remember_signal_handler(signal, previous)
177
+ end
178
+ end
179
+
180
+ def wake
181
+ @wake_write.write_nonblock(".")
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ def accept_loop
187
+ until @shutting_down
188
+ ready = IO.select([@server_socket, @wake_read], nil, nil, SELECT_INTERVAL)
189
+ if ready.nil?
190
+ expire_if_idle
191
+ next
192
+ end
193
+ break if ready[0].include?(@wake_read)
194
+
195
+ connection = accept
196
+ next if connection.nil?
197
+
198
+ handle(connection)
199
+ end
200
+ end
201
+
202
+ def expire_if_idle
203
+ return if busy?
204
+ return if idle_seconds < idle_timeout
205
+
206
+ logger.debug("idle for #{idle_seconds.round}s; leaving")
207
+ @shutting_down = true
208
+ unpublish
209
+ close_quietly(@server_socket)
210
+ end
211
+
212
+ def idle_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_activity
213
+ def idle_timeout = @idle_timeout
214
+
215
+ def touch
216
+ @last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ @last_activity_at = Time.now
218
+ end
219
+
220
+ def accept
221
+ @server_socket.accept_nonblock
222
+ rescue IO::WaitReadable, Errno::EINTR
223
+ nil
224
+ end
225
+
226
+ def handle(connection)
227
+ return connection.close unless Protocol.server_handshake(connection)
228
+
229
+ message = Protocol.read_message(connection)
230
+ return connection.close if message.nil?
231
+
232
+ case message.type
233
+ when Protocol::RUN then dispatch(connection, message)
234
+ when Protocol::STATUS then respond_status(connection)
235
+ when Protocol::STOP then stop(connection)
236
+ else connection.close
237
+ end
238
+ rescue Protocol::ProtocolError => e
239
+ logger.debug("dropping connection: #{e.message}")
240
+ close_quietly(connection)
241
+ end
242
+
243
+ def dispatch(connection, message)
244
+ restarter&.busy!
245
+ touch
246
+ @served += 1
247
+ verdict = Staleness.validate(manifest, env: invocation_env(message))
248
+ return supersede(connection, verdict) if verdict.reboot?
249
+
250
+ serve(connection, message, verdict)
251
+ end
252
+
253
+ def invocation_env(message)
254
+ env = message["env"]
255
+ env.is_a?(Hash) ? env : ENV
256
+ end
257
+
258
+ def serve(connection, message, verdict)
259
+ events.record(verdict)
260
+
261
+ pid = quiesced do
262
+ LifeCycle.prepare_for_fork
263
+
264
+ fork do
265
+ @server_socket.close
266
+ @wake_read.close
267
+ @wake_write.close
268
+ Worker.new(connection: connection, message: message, project_key: project_key, manifest: manifest, verdict: verdict, boot_seconds: @boot_seconds).run
269
+ end
270
+ end
271
+
272
+ Protocol.write_message(connection, Protocol::STARTED, pid: pid)
273
+ reap(pid, connection)
274
+ rescue LifeCycle::HookError => e
275
+ Protocol.write_message(connection, Protocol::BOOT_FAILED, output: "#{e.message}\n")
276
+ close_quietly(connection)
277
+ end
278
+
279
+ def supersede(connection, verdict)
280
+ logger.debug("superseded: #{verdict.summary} (#{verdict.reasons.join(', ')})")
281
+ events.record(verdict, resolution: "rebuild")
282
+ @shutting_down = true
283
+ unpublish
284
+
285
+ Protocol.write_message(connection, Protocol::BOOTING,
286
+ reason: verdict.summary,
287
+ restart: true,
288
+ replacement_pid: restarter&.replacement_pid,
289
+ estimated_seconds: estimated_boot_seconds)
290
+ close_quietly(connection)
291
+ end
292
+
293
+ def quiesced(&block)
294
+ return yield if restarter.nil?
295
+
296
+ restarter.around_fork(&block)
297
+ end
298
+
299
+ def estimated_boot_seconds = @boot_seconds&.round(1)
300
+
301
+ def reap(pid, connection)
302
+ @workers_mutex.synchronize { @workers[pid] = connection }
303
+
304
+ Thread.new do
305
+ _, status = Process.waitpid2(pid)
306
+ Protocol.write_message(connection, Protocol::FINISHED,
307
+ exit_code: status.exitstatus || 0, signal: status.termsig)
308
+ rescue StandardError => e
309
+ logger.debug("lost track of worker #{pid}: #{e.message}")
310
+ ensure
311
+ @workers_mutex.synchronize { @workers.delete(pid) }
312
+ # The idle clock starts when the last worker finishes, not when it started: a two-hour test
313
+ # run is not two hours of idleness.
314
+ touch
315
+ restarter&.idle!
316
+ close_quietly(connection)
317
+ end
318
+ end
319
+
320
+ def respond_status(connection)
321
+ touch
322
+ Protocol.write_message(connection, Protocol::STATUS_REPORT,
323
+ pid: Process.pid,
324
+ workers: @workers_mutex.synchronize { @workers.keys },
325
+ tool_version: Siding::VERSION,
326
+ revision_label: manifest&.revision_label,
327
+ booted_at: @booted_at,
328
+ boot_seconds: @boot_seconds&.round(3),
329
+ served: @served,
330
+ last_activity_at: @last_activity_at.to_f,
331
+ idle_timeout: idle_timeout,
332
+ watch: restarter&.watch_mode,
333
+ events: notable_events)
334
+ close_quietly(connection)
335
+ end
336
+
337
+ def notable_events
338
+ events.to_a.reject { |event| event.resolution == "fresh" }.last(10).map(&:to_h)
339
+ end
340
+
341
+ def stop(connection)
342
+ @shutting_down = true
343
+ unpublish
344
+ terminate_workers
345
+ Protocol.write_message(connection, Protocol::FINISHED, exit_code: 0, signal: nil)
346
+ close_quietly(connection)
347
+ end
348
+
349
+ def terminate_workers
350
+ @workers_mutex.synchronize { @workers.keys }.each do |pid|
351
+ Process.kill("TERM", -pid)
352
+ rescue SystemCallError
353
+ nil
354
+ end
355
+ end
356
+
357
+ # The `draining` state, which is where every departure passes through. Workers outlive
358
+ # the accept loop.
359
+ def drain_workers
360
+ sleep(DRAIN_INTERVAL) until @forced || @workers_mutex.synchronize { @workers.empty? }
361
+ rescue StandardError
362
+ nil
363
+ end
364
+
365
+ def shutdown
366
+ restarter&.stop
367
+ @server_socket&.close
368
+ unpublish
369
+ logger.close
370
+ end
371
+
372
+ def unpublish
373
+ return if @withdrawn
374
+
375
+ @withdrawn = true
376
+ return unless published_by_us?
377
+
378
+ [runtime.socket_path, runtime.server_info_path].each do |path|
379
+ File.unlink(path)
380
+ rescue SystemCallError
381
+ nil
382
+ end
383
+ end
384
+
385
+ def published_by_us?
386
+ pid = JSON.parse(File.read(runtime.server_info_path))["pid"]
387
+ pid.nil? || pid == Process.pid
388
+ rescue StandardError
389
+ true
390
+ end
391
+
392
+ def close_quietly(io)
393
+ io.close unless io.nil? || io.closed?
394
+ rescue IOError
395
+ nil
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "load_manifest"
7
+
8
+ module Siding
9
+ module Staleness
10
+ FRESH = :fresh
11
+ RELOADABLE = :reloadable
12
+ REBOOT = :reboot
13
+
14
+ DEPENDENCIES_CHANGED = :dependencies_changed
15
+ SOURCE_CHANGED = :source_changed
16
+ SOURCE_ADDED_OR_REMOVED = :source_added_or_removed
17
+ ENVIRONMENT_CHANGED = :environment_changed
18
+
19
+ MAX_TRIGGER_PATHS = 25
20
+
21
+ Verdict = Struct.new(:strategy, :reasons, :trigger_paths, :revision_label, keyword_init: true) do
22
+ def fresh? = strategy == FRESH
23
+ def stale? = !fresh?
24
+ def reloadable? = strategy == RELOADABLE
25
+ def reboot? = strategy == REBOOT
26
+
27
+ def resolution
28
+ case strategy
29
+ when FRESH then "fresh"
30
+ when RELOADABLE then "reloaded_in_worker"
31
+ else "rebuild"
32
+ end
33
+ end
34
+
35
+ def summary
36
+ return "up to date" if fresh?
37
+
38
+ detail = trigger_paths.first
39
+ reason = reasons.first
40
+ detail ? "#{reason}: #{File.basename(detail)}" : reason.to_s
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ def validate(manifest, env: ENV)
47
+ reasons = []
48
+ triggers = []
49
+ changed_scopes = []
50
+
51
+ bundle_token = LoadManifest.token_for(manifest.bundle_files)
52
+ if bundle_token != manifest.bundle_token
53
+ reasons << DEPENDENCIES_CHANGED
54
+ triggers.concat(manifest.bundle_files)
55
+ changed_scopes << LoadManifest::REBOOT
56
+ end
57
+
58
+ files = manifest.file_entries.map do |entry|
59
+ stamp = LoadManifest.stamp_for(entry.path)
60
+ unless stamp == stamp_of(entry)
61
+ reasons << SOURCE_CHANGED
62
+ triggers << entry.path
63
+ changed_scopes << entry.scope
64
+ end
65
+ [entry.path, stamp]
66
+ end
67
+
68
+ directories = manifest.directory_entries.map do |entry|
69
+ digest = LoadManifest.entry_digest_for(entry.path, recursive: entry.recursive)
70
+ unless digest == entry.entry_digest
71
+ reasons << SOURCE_ADDED_OR_REMOVED
72
+ triggers << entry.path
73
+ changed_scopes << entry.scope
74
+ end
75
+ [entry.path, digest]
76
+ end
77
+
78
+ envs = manifest.env_entries.map do |entry|
79
+ digest = LoadManifest.digest_env_value(env[entry.key])
80
+ unless digest == entry.value_digest
81
+ reasons << ENVIRONMENT_CHANGED
82
+ triggers << entry.key
83
+ # Always reboot-class: a value consumed at boot is already inside a constant, and no
84
+ # reloader can reach in there and change it.
85
+ changed_scopes << LoadManifest::REBOOT
86
+ end
87
+ [entry.key, digest]
88
+ end
89
+
90
+ Verdict.new(
91
+ strategy: strategy_for(changed_scopes),
92
+ reasons: reasons.uniq,
93
+ trigger_paths: triggers.first(MAX_TRIGGER_PATHS),
94
+ revision_label: LoadManifest.label_for(bundle_token:, files:, directories:, envs:)
95
+ )
96
+ end
97
+
98
+ def strategy_for(changed_scopes)
99
+ return FRESH if changed_scopes.empty?
100
+ return RELOADABLE if changed_scopes.all? { |scope| scope == LoadManifest::RELOADABLE }
101
+
102
+ REBOOT
103
+ end
104
+
105
+ def stamp_of(entry) = "#{entry.size}:#{format('%.6f', entry.mtime)}"
106
+
107
+ class Events
108
+ LIMIT = 50
109
+
110
+ # When the file is rewritten rather than appended to. Rewriting on every record would turn a
111
+ # short append into a full read-modify-write for no benefit.
112
+ COMPACT_AT = LIMIT * 4
113
+
114
+ Event = Struct.new(:at, :reason, :trigger_paths, :resolution, :developer_wait,
115
+ keyword_init: true) do
116
+ def to_h
117
+ {
118
+ "at" => at.iso8601(3),
119
+ "reason" => reason.to_s,
120
+ "trigger_paths" => trigger_paths,
121
+ "resolution" => resolution,
122
+ "developer_wait" => developer_wait
123
+ }
124
+ end
125
+
126
+ def self.from_h(hash)
127
+ new(at: Time.iso8601(hash["at"]), reason: hash["reason"],
128
+ trigger_paths: Array(hash["trigger_paths"]), resolution: hash["resolution"],
129
+ developer_wait: hash["developer_wait"])
130
+ rescue ArgumentError, TypeError
131
+ nil
132
+ end
133
+ end
134
+
135
+ def initialize(limit: LIMIT, path: nil)
136
+ @limit = limit
137
+ @path = path
138
+ @events = load_recorded
139
+ @mutex = Mutex.new
140
+ end
141
+
142
+ def record(verdict, resolution: nil, developer_wait: nil, at: Time.now)
143
+ event = Event.new(at: at, reason: verdict.reasons.first,
144
+ trigger_paths: verdict.trigger_paths,
145
+ resolution: resolution || verdict.resolution,
146
+ developer_wait: developer_wait)
147
+ @mutex.synchronize do
148
+ @events << event
149
+ @events.shift while @events.size > @limit
150
+ end
151
+ persist(event)
152
+ event
153
+ end
154
+
155
+ def to_a = @mutex.synchronize { @events.dup }
156
+ def last = @mutex.synchronize { @events.last }
157
+ def size = @mutex.synchronize { @events.size }
158
+
159
+ private
160
+
161
+ def persist(event)
162
+ return if @path.nil?
163
+
164
+ File.open(@path, "a") { |file| file.write("#{JSON.generate(event.to_h)}\n") }
165
+ compact if File.size(@path) > COMPACT_AT * 512
166
+ rescue SystemCallError, JSON::GeneratorError
167
+ nil
168
+ end
169
+
170
+ def compact
171
+ lines = File.readlines(@path).last(@limit)
172
+ File.write(@path, lines.join)
173
+ rescue SystemCallError
174
+ nil
175
+ end
176
+
177
+ def load_recorded
178
+ return [] if @path.nil? || !File.file?(@path)
179
+
180
+ File.readlines(@path).last(@limit).filter_map do |line|
181
+ parsed = JSON.parse(line)
182
+ Event.from_h(parsed) if parsed.is_a?(Hash)
183
+ rescue JSON::ParserError
184
+ nil
185
+ end
186
+ rescue SystemCallError
187
+ []
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siding
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ require "watchcat"
4
+
5
+ module Siding
6
+ class Watch
7
+ EVENTS = :events
8
+ POLL = :poll
9
+
10
+ RootEntry = Struct.new(:path, :recursive)
11
+
12
+ class << self
13
+ def mode_from(env, logger: nil)
14
+ value = env["SIDING_WATCH"]
15
+ return POLL if value == "poll"
16
+ return EVENTS if value.nil? || value == "events"
17
+
18
+ logger&.debug("restarter watch: unrecognized SIDING_WATCH=#{value.inspect}; using events")
19
+ EVENTS
20
+ end
21
+
22
+ def roots_for(manifest)
23
+ recursive_paths = manifest.directory_entries.map(&:path).uniq
24
+
25
+ file_dirs = manifest.file_entries.map { |entry| File.dirname(entry.path) }
26
+ bundle_dirs = manifest.bundle_files.map { |path| File.dirname(path) }
27
+ non_recursive_paths = (file_dirs + bundle_dirs).uniq.reject do |path|
28
+ under_recursive_root?(path, recursive_paths)
29
+ end
30
+
31
+ entries = recursive_paths.map { |path| RootEntry.new(path, true) } + non_recursive_paths.map { |path| RootEntry.new(path, false) }
32
+ entries.uniq(&:path).select { |entry| File.directory?(entry.path) }
33
+ end
34
+
35
+ def start(manifest:, env:, logger:, &on_change)
36
+ mode = mode_from(env, logger: logger)
37
+ entries = roots_for(manifest)
38
+ return nil if entries.empty?
39
+
40
+ begin
41
+ executor = build_executor(entries, on_change, force_polling: mode == POLL)
42
+ rescue StandardError => e
43
+ logger.debug("restarter watch: could not start watchcat (#{e.class}: #{e.message}); using poll")
44
+ return Watcher.unavailable("#{e.class}: #{e.message}")
45
+ end
46
+
47
+ Watcher.active(executor, mode)
48
+ end
49
+
50
+ private
51
+
52
+ def under_recursive_root?(path, recursive_paths)
53
+ recursive_paths.any? { |root| path == root || path.start_with?("#{root}#{File::SEPARATOR}") }
54
+ end
55
+
56
+ def build_executor(entries, on_change, force_polling:)
57
+ recursive = entries.select(&:recursive).map(&:path)
58
+ non_recursive = entries.reject(&:recursive).map(&:path)
59
+ callback = proc { on_change.call }
60
+
61
+ filters = { ignore_access: true }
62
+
63
+ if recursive.any?
64
+ executor = Watchcat.watch(recursive, recursive: true, force_polling:, filters:, &callback)
65
+ # `force_polling` is fixed at construction and applies to the whole executor, so a
66
+ # second call to add the non-recursive roots inherits it -- there is no per-path knob.
67
+ executor.watch(non_recursive, recursive: false) if non_recursive.any?
68
+ else
69
+ executor = Watchcat.watch(non_recursive, recursive: false, force_polling:, filters:, &callback)
70
+ end
71
+
72
+ executor
73
+ end
74
+ end
75
+
76
+ class Watcher
77
+ def self.active(executor, mode)
78
+ label = mode == POLL ? "poll (watchcat)" : "events (watchcat)"
79
+ new(executor:, label:, attempted: true)
80
+ end
81
+
82
+ def self.unavailable(reason) = new(executor: nil, label: "poll -- watchcat could not start: #{reason}", attempted: false)
83
+
84
+ def initialize(executor:, label:, attempted:)
85
+ @executor = executor
86
+ @label = label
87
+ @attempted = attempted
88
+ end
89
+
90
+ def watching?
91
+ @attempted && alive?
92
+ end
93
+
94
+ def mode_label
95
+ return @label if @attempted && alive?
96
+ return @label unless @attempted
97
+
98
+ "poll -- watchcat watcher thread died"
99
+ end
100
+
101
+ def stop
102
+ @executor&.stop
103
+ end
104
+
105
+ private
106
+
107
+ def alive?
108
+ return true if @executor.nil?
109
+ @executor.alive?
110
+ rescue StandardError
111
+ true
112
+ end
113
+ end
114
+ end
115
+ end