flezha 0.1.0-aarch64-linux

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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flezha
4
+ # The SDK's diagnostics, through a logger the host supplies.
5
+ #
6
+ # The default writes to standard error at `warn` and above, which is what the
7
+ # other SDKs' defaults amount to: a runner that says nothing about a wedge is
8
+ # the failure mode the diagnostics exist to close, and one that narrates
9
+ # every activation is noise. No observability dependency — a host that wants
10
+ # spans or structured fields supplies its own logger.
11
+ class ConsoleLogger
12
+ ORDER = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
13
+
14
+ def initialize(level = :warn)
15
+ @level = ORDER.fetch(level.to_sym, 2)
16
+ end
17
+
18
+ ORDER.each_key do |level|
19
+ define_method(level) { |message| write(level, message) }
20
+ end
21
+
22
+ private
23
+
24
+ def write(level, message)
25
+ return if ORDER[level] < @level
26
+
27
+ $stderr.puts("[flezha] #{message}")
28
+ rescue StandardError
29
+ # A logger must never take the process down. The case that reaches here
30
+ # is a closed standard stream during shutdown — the harness closes the
31
+ # runner's pipes to stop it — where writing raises EPIPE, and an
32
+ # unhandled stream error would turn a clean drain into a crash.
33
+ nil
34
+ end
35
+ end
36
+
37
+ # Per-Ractor rather than a bare global, for the reason every handle in this
38
+ # SDK is: a Ractor executor must be addable without restructuring, and a
39
+ # shared mutable global is exactly what would have to be unpicked.
40
+ def self.log
41
+ Ractor.current[:flezha_logger] ||= ConsoleLogger.new(ENV.fetch("FLEZHA_LOG_LEVEL", "warn"))
42
+ end
43
+
44
+ def self.log=(logger)
45
+ Ractor.current[:flezha_logger] = logger
46
+ end
47
+
48
+ NOTICE_LEVELS = { "info" => :info, "warn" => :warn }.freeze
49
+
50
+ # Write the core's notices, as `take_notices` answers them, through the SDK's
51
+ # logger; an error, and the local-mode banner (`always`), at `error`, which no
52
+ # level hides.
53
+ def self.log_notices(taken)
54
+ taken.each { |level, _target, text| log.public_send(NOTICE_LEVELS.fetch(level, :error), text) }
55
+ end
56
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+ require_relative "version"
7
+
8
+ module Flezha
9
+ # The compiled veneer over `flz_core`'s C ABI, and the two rules that hold
10
+ # around it: one context per process, and no core call from a process that
11
+ # did not start it.
12
+ #
13
+ # `Flezha::Native` itself is defined by the extension (`ruby_glue.rs`). This
14
+ # wrapper adds no ABI surface — every method below forwards to exactly one
15
+ # veneer function — it adds the fork guard, which is a host rule rather than
16
+ # a core one.
17
+ class Core
18
+ # An absent extension is an installation defect rather than a refusal, and
19
+ # nothing is left to render a catalogue message with, so it stays a
20
+ # `LoadError`.
21
+ def self.load_extension
22
+ # Ruby derives `Init_flz_core_ffi` from the basename, which is what the
23
+ # veneer exports. A precompiled gem carries one library per Ruby minor,
24
+ # under `lib/flezha/<minor>/`; a source build puts its one in `lib/flezha/`.
25
+ per_minor = "flezha/#{RUBY_VERSION[/\A\d+\.\d+/]}/flz_core_ffi"
26
+ begin
27
+ require per_minor
28
+ rescue LoadError => e
29
+ # Only an absent file falls through; one that is there and will not
30
+ # load is the error to report.
31
+ raise unless e.path == per_minor
32
+
33
+ require "flezha/flz_core_ffi"
34
+ end
35
+ rescue LoadError => e
36
+ raise LoadError,
37
+ "the flezha native extension could not be loaded (#{e.message}). Install a " \
38
+ "precompiled platform gem, or build the source gem with cargo and git."
39
+ end
40
+
41
+ # The JSON error document the catalogue renders for `code` and `details`:
42
+ # what `Flezha::Error` is built from, for a refusal this SDK makes itself.
43
+ def self.error_document(code, details)
44
+ load_extension
45
+ Native.error_document(code.to_s, JSON.generate(details))
46
+ end
47
+
48
+ # Create the process context, negotiating the activation schema version
49
+ # before any controller connection is attempted.
50
+ def self.create
51
+ load_extension
52
+ # The first take starts holding the core's notices for this host's
53
+ # drains; anything an earlier context left is written now.
54
+ Flezha.log_notices(Native.take_notices)
55
+ Native.context_new(ACTIVATION_SCHEMA_VERSION)
56
+ new
57
+ end
58
+
59
+ # The entry points that take no context and therefore no guard: they read
60
+ # a process-global clock, which a fork does not break. Reachable before
61
+ # `create`, which is what lets a runner answer before it boots.
62
+ def self.context_free
63
+ load_extension
64
+ Native
65
+ end
66
+
67
+ def initialize
68
+ # Recorded at construction rather than at `start`, so a fork between the
69
+ # two is caught by the same guard.
70
+ @owner_pid = Process.pid
71
+ end
72
+
73
+ # The process that owns the core. A fork after this point does not carry
74
+ # the core's Rust threads — transport, flusher, the runner loop — so the
75
+ # child would connect to nothing and serve nothing, silently.
76
+ def guard!
77
+ return if Process.pid == @owner_pid
78
+
79
+ raise Error.new("runtime.forked_process", owner: @owner_pid, pid: Process.pid)
80
+ end
81
+
82
+ # Every ABI entry point a running host reaches, in the veneer's own
83
+ # spelling.
84
+ #
85
+ # Generated rather than written out fifteen times over: the fork guard then
86
+ # provably applies to all of them — there is no site left to forget it at —
87
+ # and `native_test.rb` asserts this list is exactly what the veneer
88
+ # exports, so an entry point added there and not here is a red build rather
89
+ # than a method that quietly does not exist.
90
+ ENTRY_POINTS = %i[
91
+ declare_class undeclare_class check_class start poll complete abandon shutdown
92
+ pool_open pool_supervise pool_left
93
+ local_start local_on_sink local_feed local_next_output local_run_until_idle local_stop
94
+ watch_stdin_eof spawn_request spawn_result part effect_begin effect_end effect_release
95
+ admin_call send_message relays monotonic_nanos last_error take_notices
96
+ ].freeze
97
+
98
+ ENTRY_POINTS.each do |entry|
99
+ define_method(entry) do |*args|
100
+ guard!
101
+ Native.public_send(entry, *args)
102
+ end
103
+ end
104
+
105
+ # The one entry point not named after its own: `context_free` releases what
106
+ # `context_new` created, and `create` is what made this object.
107
+ def free
108
+ guard!
109
+ Native.context_free
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "builder"
4
+ require_relative "declarative"
5
+ require_relative "errors"
6
+ require_relative "tasklet"
7
+
8
+ module Flezha
9
+ # The base an authored relay subclasses. Its class body declares the
10
+ # factory id and one body: a `channels` graph, or, for the single-tasklet
11
+ # short form, that tasklet's `run` instance method, which emits through its
12
+ # `ctx` as a tasklet's does. `spec` resolves the declaration once.
13
+ class Relay
14
+ extend Declarative
15
+ # A short form's tasklet settings, handed to the tasklet it generates.
16
+ extend TaskletSettings
17
+
18
+ # The channels graph. The block receives the builder.
19
+ block_setting :channels
20
+ # The short form's entry channel name, its codec and its optional plugin
21
+ # output. A full form names its channels in `channels` instead.
22
+ SHORT_FORM_KEYS = %i[input codec output].freeze
23
+ setting(*SHORT_FORM_KEYS)
24
+ # The declarations only a short form reads.
25
+ SHORT_FORM_ONLY = (SHORT_FORM_KEYS + TaskletSettings::NAMES).freeze
26
+ # The class settings, the tree's own data: `run :steps`,
27
+ # `run steps: { each: :per_input }`, `durable false`,
28
+ # `durable local: { sleep_after: 2_000 }`. A finished instance keeps
29
+ # nothing unless the level sets `keep_finished` to hours, or to `true` for
30
+ # the controller's window (72 hours by default), so `flz relay info` can
31
+ # still show its Cells and result. Either form takes them; the core reads
32
+ # the tree when the class registers and refuses a misplaced setting by name.
33
+ setting :run, :durable, :max_duration_ms, :description
34
+ # `factory_id` is deliberately NOT inherited: two relays cannot share
35
+ # one, and a subclass silently taking its parent's would be refused by the
36
+ # core at declare time rather than here.
37
+
38
+ class << self
39
+ def factory_id(id = nil)
40
+ return @factory_id if id.nil?
41
+
42
+ @factory_id = id.to_s
43
+ end
44
+
45
+ def spec
46
+ @spec ||= build_spec
47
+ end
48
+
49
+ # The tasklet a short form generates, or `nil` for a full form. Named
50
+ # after the factory id, verbatim: a tasklet's name is part of every
51
+ # cell's state path, so the full form that replaces this one keeps
52
+ # its cells by naming its tasklet the same.
53
+ def tasklet
54
+ return nil unless short_form?
55
+
56
+ check_declaration!
57
+ @tasklet ||= Class.new(Tasklet).tap do |generated|
58
+ relay = self
59
+ generated.tasklet_name(@factory_id)
60
+ generated.define_singleton_method(:body) { relay }
61
+ hand_down(generated, TaskletSettings::NAMES)
62
+ end
63
+ end
64
+
65
+ # The refusals a class earns before anything is built, raised by
66
+ # `Runner#register_factory` at the call the author wrote and again when
67
+ # the spec is built.
68
+ def check_declaration!
69
+ # A factory id is written, never derived from the class.
70
+ raise Error.new("topology.missing_factory_id", relay: to_s) unless @factory_id
71
+
72
+ if short_form?
73
+ raise Error.new("topology.short_form_with_topology", factory_id: @factory_id) if channels
74
+ elsif channels
75
+ refuse_written("topology.short_form_field_on_full_form", SHORT_FORM_ONLY)
76
+ else
77
+ raise Error.new("topology.missing_body", declaration: "relay '#{@factory_id}'",
78
+ body: "`channels`, or one tasklet's `run` as the short form")
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # A short form carries its one tasklet's `run` body instead of a
85
+ # `channels` graph.
86
+ def short_form? = method_defined?(:run)
87
+
88
+ # Refuse the declarations among +names+ this class wrote, naming each.
89
+ def refuse_written(code, names)
90
+ names = written(names).keys
91
+ return if names.empty?
92
+
93
+ raise Error.new(code, factory_id: @factory_id, fields: names.map { |name| "`#{name}`" })
94
+ end
95
+
96
+ def build_builder
97
+ check_declaration!
98
+ builder = ChannelBuilder.new(@factory_id)
99
+ short_form? ? wire_short_form(builder) : channels.call(builder)
100
+ builder
101
+ end
102
+
103
+ # `input -> tasklet`, and on to the plugin output when one is declared.
104
+ def wire_short_form(builder)
105
+ edge = builder.input(input || "input", codec: codec).into(tasklet)
106
+ edge.output(output) if output
107
+ end
108
+
109
+ # The graph and the settings tree as written: the core judges the tree
110
+ # and refuses a misplaced setting by name.
111
+ def build_spec
112
+ # Sealed before the channels are read: filing every declared sequence
113
+ # on its channel is what can still create one.
114
+ builder = build_builder.seal
115
+ ClassSpec.new(
116
+ factory_id: @factory_id, description: description, max_duration_ms: max_duration_ms,
117
+ run: Flezha.tree_json(run), durable: Flezha.tree_json(durable),
118
+ tasklets: builder.tasklets, channels: builder.channels
119
+ )
120
+ end
121
+ end
122
+ end
123
+
124
+ # A settings tree as its JSON: symbols are strings, `nil` stays absent.
125
+ def self.tree_json(tree)
126
+ case tree
127
+ when Hash then tree.to_h { |name, member| [name.to_s, tree_json(member)] }
128
+ when Symbol then tree.to_s
129
+ else tree
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "native"
5
+
6
+ module Flezha
7
+ # One outcome of a `call`, as its Response channel delivers it: the `request`
8
+ # the call end sent, decoded back with the call's codec; the answer as
9
+ # `value`, or the `error` the call ended in (a missed deadline is
10
+ # `call.timeout`); `target`, the callee's instance id; and `created`, true
11
+ # when this call started the callee.
12
+ Response = Data.define(:request, :value, :error, :target, :created) do
13
+ def ok? = error.nil?
14
+
15
+ # The Response a controller's envelope carries, read by the core's
16
+ # decoder; a payload that is no envelope raises `call.malformed_response`.
17
+ # `decode` reads the request's and the answer's bytes.
18
+ def self.decode(payload, decode)
19
+ Core.load_extension unless defined?(Native)
20
+ target, created, request, value, error = Native.response_decode(payload)
21
+ new(
22
+ request: decode.call(request), value: error ? nil : decode.call(value),
23
+ error: error && Error.from_document(error), target:, created:
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codec"
4
+ require_relative "tasklet"
5
+
6
+ module Flezha
7
+ # The single-cell tasklet `ChannelBuilder#route_latest` expands to. Its input is
8
+ # what the merge's two mappings make of a value, `{ document: }` or
9
+ # `{ traffic: key }`; the traffic item itself never reaches it.
10
+ #
11
+ # The state is `{ document:, version:, delivered: { key => version } }` and
12
+ # rides the change journal like any tasklet's: `{ retain: document }`
13
+ # replaces the document and advances the version, and
14
+ # `{ delivered: key, version: }` records one key's delivery.
15
+ class RouteLatest < Tasklet
16
+ tasklet_name "route_latest"
17
+
18
+ # Version 0 is "nothing retained", so the first document lands at 1 and
19
+ # every key's absent entry is already behind it.
20
+ def init_state(_cell_key) = { document: nil, version: 0, delivered: {} }
21
+
22
+ def apply_change(state, change)
23
+ if change.key?(:retain)
24
+ state[:document] = change[:retain]
25
+ state[:version] += 1
26
+ else
27
+ # A Symbol, because a Hash key decodes as one: a state restored from a
28
+ # snapshot reads the key the live state wrote.
29
+ state[:delivered][change[:delivered].to_sym] = change[:version]
30
+ end
31
+ end
32
+
33
+ def run(input, ctx)
34
+ state = ctx.state
35
+ if input.key?(:document)
36
+ # The document is opaque: equal is the class codec's bytes, so a
37
+ # re-poll of an unchanged document is inert.
38
+ document = input[:document]
39
+ ctx.change({ retain: document }) unless Codec.encode(document) == Codec.encode(state[:document])
40
+ return nil
41
+ end
42
+
43
+ key = input[:traffic]
44
+ version = state[:version]
45
+ return nil if version.zero? || state[:delivered].fetch(key.to_sym, 0) >= version
46
+
47
+ # Emit first: a failed emit rides the channel's retry ladder, and
48
+ # recording the delivery before it would make the retry silent.
49
+ ctx.emit([key, state[:document]])
50
+ ctx.change({ delivered: key, version: version })
51
+ nil
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "dispatch"
6
+ require_relative "executor"
7
+ require_relative "graph"
8
+ require_relative "logger"
9
+ require_relative "native"
10
+ require_relative "relay"
11
+
12
+ module Flezha
13
+ # The runner: what an author starts, and everything it owns while running.
14
+ #
15
+ # Classes are registered explicitly, by naming them. There is no autoload, no
16
+ # load-path walk and no package scan: what runs is what the author said runs,
17
+ # and a class that is defined but not registered never reaches the core.
18
+ #
19
+ # ## Startup order
20
+ #
21
+ # declare classes -> warm up -> (fork, if the user forks) -> start
22
+ #
23
+ # `Process.warmup` compacts the heap and promotes the declared object graph
24
+ # to the old generation, which is what makes a subsequent fork
25
+ # copy-on-write-friendly. It runs after the last declaration and before the
26
+ # first `poll`. The core must NOT be started before a fork: it owns native
27
+ # threads that `fork(2)` does not carry, and `Core#guard!` raises naming that
28
+ # rule rather than letting a child serve nothing.
29
+ class Runner
30
+ attr_reader :namespace
31
+
32
+ def initialize(namespace)
33
+ @namespace = namespace
34
+ @classes = []
35
+ @registry = Registry.new
36
+ @core = nil
37
+ @executor = nil
38
+ @executor_kind = (ENV["FLEZHA_RUBY_EXECUTOR"] || "threads").to_sym
39
+ @executor_options = {}
40
+ @yjit = nil
41
+ @stopped = Thread::Queue.new
42
+ @stopping = false
43
+ @closing = Mutex.new
44
+ @teardown = []
45
+ end
46
+
47
+ # Register a relay class, short form or full. After start the class is
48
+ # declared at once: the executor serves it and the controller learns of it.
49
+ def register_factory(klass)
50
+ # A tasklet is registered by the relay that wires it.
51
+ unless klass.is_a?(Class) && klass < Relay
52
+ raise Error.new("topology.no_relay_exported", module: klass.to_s)
53
+ end
54
+
55
+ klass.check_declaration!
56
+ Graph.declare(klass.spec, @core, @registry) if @core
57
+ @classes << klass
58
+ self
59
+ end
60
+
61
+ # Undo a registration — the inverse of {#register_factory}, taking what it
62
+ # took. After start the core refuses a class with live instances on this
63
+ # runner; otherwise the executor stops serving it and the controller
64
+ # learns of it.
65
+ def unregister_factory(klass)
66
+ factory = klass.respond_to?(:factory_id) && klass.factory_id || klass.to_s
67
+ raise Error.new("runtime.class_not_registered", factory: factory) unless @classes.include?(klass)
68
+
69
+ if @core
70
+ @core.undeclare_class(factory)
71
+ @registry.remove(factory)
72
+ end
73
+ @classes.delete(klass)
74
+ self
75
+ end
76
+
77
+ # Which executor dispatches: `:threads` (the default) or `:fibers`.
78
+ def executor(kind, **options)
79
+ @executor_kind = kind.to_sym
80
+ @executor_options = options
81
+ self
82
+ end
83
+
84
+ def logger(logger)
85
+ Flezha.log = logger
86
+ self
87
+ end
88
+
89
+ # Administer this namespace from ordinary application code: start and stop
90
+ # relays, enable and disable plugins, read what is running.
91
+ #
92
+ # The controller judges every call as the identity this connection proved,
93
+ # so no credential is held here and nothing expires. A user grants a
94
+ # namespace's applications the authority once, with
95
+ # `flz role bind runner@<namespace> app <namespace>`; until then every call
96
+ # is refused.
97
+ def control = Control.new(@core || Core.context_free)
98
+
99
+ # Send `message` to the entry channel `channel` of `relay_id`, through
100
+ # the controller under the same role check as `control`, answering its
101
+ # verdict: `{"to" => relay, "verdict" => "delivered" | "absorbed" |
102
+ # "suspended"}`. `msg_id:` is stamped as `flz.msg_id`, so a relay whose
103
+ # input log still holds that id absorbs the repeat; `key:` reaches the
104
+ # partition group member whose range owns it.
105
+ #
106
+ # `local: true`: in process, without the controller: the message is on no
107
+ # disk, is lost if the runner stops before handling it, and a relay's
108
+ # input log does not cover it. It answers `nil`.
109
+ #
110
+ # Named `send_to` because `send` is every Ruby object's own.
111
+ def send_to(relay_id, message, channel, msg_id: nil, key: nil, local: false)
112
+ JSON.parse((@core || Core.context_free).send_message(relay_id, channel, message.b, msg_id, key, local))
113
+ end
114
+
115
+ # Call the entry channel `channel` of `relay_id` with `value`, through the
116
+ # controller under the same role check as `send_to`, answering what the
117
+ # relay answers: a JSON value, a String for text, binary bytes for the
118
+ # binary formats, or `{"accepted" => true}` from a channel no output
119
+ # answers. `value` is encoded as an output naming no codec encodes it.
120
+ def call(relay_id, channel, value)
121
+ payload = [Codec.plain_payload(value, "a call to '#{relay_id}'")].pack("m0")
122
+ args = JSON.generate([relay_id, channel, payload, { "payload" => "base64" }])
123
+ reply = JSON.parse((@core || Core.context_free).admin_call("Relay.call_channel", args))
124
+ return { "accepted" => true } if reply["accepted"]
125
+
126
+ %w[json text].include?(reply["answer_codec"]) ? reply["answer"] : reply["answer"].unpack1("m0")
127
+ end
128
+
129
+ # The ids of the relay instances bound on this runner now, read without
130
+ # the controller. Refused with `runtime.runner_not_started` before `boot`.
131
+ def relays = (@core || raise(Error.new("runtime.runner_not_started"))).relays
132
+
133
+ # Override the YJIT decision. `nil` (the default) means "decide", `false`
134
+ # means "leave it off".
135
+ def yjit(enabled)
136
+ @yjit = enabled
137
+ self
138
+ end
139
+
140
+ # Declare, warm up, connect and start serving — everything up to and
141
+ # including the first poll, and nothing that blocks.
142
+ #
143
+ # Separate from `run` so a host with its own supervision tree can own the
144
+ # waiting, and so the startup ORDER is a thing a test can observe rather
145
+ # than a comment.
146
+ def start(core: nil)
147
+ jit = resolve_yjit
148
+
149
+ # Before anything else: a native library built against a different
150
+ # activation schema is refused here, with no controller connection
151
+ # attempted.
152
+ @core = core || Core.create
153
+
154
+ declare_all
155
+ # After the last declaration and before the first poll.
156
+ Process.warmup
157
+
158
+ # The core's termination handler also runs the handler installed before
159
+ # it. Ruby's default raises in the main thread and ends the process before
160
+ # any state is shipped, so it gives way to the system default, which is
161
+ # never run; a trap the application set is kept.
162
+ %w[TERM INT].each do |signal|
163
+ previous = Signal.trap(signal, "SYSTEM_DEFAULT")
164
+ Signal.trap(signal, previous) unless previous == "DEFAULT"
165
+ end
166
+ begin
167
+ @core.start(@namespace)
168
+ rescue StandardError
169
+ # The core holds what it said while connecting until a take; no
170
+ # supervisor is left to take it.
171
+ Flezha.log_notices(@core.take_notices)
172
+ raise
173
+ end
174
+ observe_shutdown
175
+
176
+ @executor = build_executor
177
+ @executor.start
178
+
179
+ Flezha.log.info(
180
+ "runner up on namespace #{@namespace}: #{@executor.name} executor, " \
181
+ "#{@executor.size} workers, yjit #{jit}"
182
+ )
183
+ self
184
+ end
185
+
186
+ # Declare, warm up, connect and serve until shutdown: `start`, then
187
+ # `await_termination`.
188
+ def run(core: nil)
189
+ start(core: core)
190
+ await_termination
191
+ end
192
+
193
+ # Wait for shutdown, then close. A host that called `start` can work
194
+ # between the two, making control calls for one, and still stop the way
195
+ # `run` does.
196
+ def await_termination
197
+ @stopped.pop
198
+ close
199
+ self
200
+ end
201
+
202
+ # Stop fetching, drain what is in flight, release the core and wake every
203
+ # thread in `await_termination`. Idempotent: the waiter it wakes finds the
204
+ # core already released. Not for a trap handler, since it takes a lock.
205
+ def close
206
+ @closing.synchronize { release_core }
207
+ stop
208
+ nil
209
+ end
210
+
211
+ private
212
+
213
+ # Wakes `await_termination`. Trap-safe: a queue push and no lock.
214
+ def stop
215
+ return if @stopping
216
+
217
+ @stopping = true
218
+ @stopped.push(:stop)
219
+ end
220
+
221
+ def release_core
222
+ return unless @core
223
+
224
+ Flezha.log.info("draining")
225
+ @core.shutdown
226
+ undrained = @executor ? @executor.stop : 0
227
+ @teardown.each(&:call)
228
+ @teardown.clear
229
+ # The context is released only when nothing is still running against it.
230
+ # A worker the bounded drain gave up on is inside user code and may yet
231
+ # call the core; freeing the context under it would be a use-after-free
232
+ # in native code. The process is on its way out either way, so the
233
+ # kernel reclaims what this skips.
234
+ @core.free if undrained.zero?
235
+ @core = nil
236
+ end
237
+
238
+ def declare_all
239
+ # The verdict before the registration: the core judges the record and
240
+ # this SDK renders it, so an author meets a declaration refusal rather
241
+ # than a native failure at the first connection.
242
+ @classes.each { |klass| Graph.declare(klass.spec, @core, @registry) }
243
+ raise Error.new("runtime.no_relays") if @registry.empty?
244
+ end
245
+
246
+ def build_executor
247
+ deps = DispatchDeps.new(registry: @registry, bridge: @core)
248
+ case @executor_kind
249
+ when :threads then ThreadExecutor.new(@core, deps, **@executor_options)
250
+ when :fibers then FiberExecutor.new(@core, deps, **@executor_options)
251
+ else
252
+ raise Error.new("usage.unknown_word", noun: "executor", value: @executor_kind.to_s,
253
+ allowed: %w[threads fibers])
254
+ end
255
+ end
256
+
257
+ # Shutdown is observed three ways, because the harness, a user and the
258
+ # controller each have their own: end of file on standard input, a signal,
259
+ # and the core's own stop — which every worker sees as `:shutting_down`
260
+ # from its next poll.
261
+ #
262
+ # The first two are the core's, on a thread that holds no GVL and no Ruby
263
+ # object: it installs the SIGINT and SIGTERM handler when the runner starts,
264
+ # and the watch below is the same exit by another name. The harness manages
265
+ # a runner as an Erlang Port and stops it by closing the port, which is this
266
+ # process's standard input.
267
+ def observe_shutdown
268
+ @core.watch_stdin_eof unless $stdin.tty?
269
+ end
270
+
271
+ # YJIT is on by default because handler bodies are the one thing the GVL
272
+ # bounds, so the JIT's gain lands exactly where the ceiling is. The
273
+ # user's decision wins, and the resulting state is reported in the
274
+ # startup line so a measurement can never be ambiguous about it.
275
+ def resolve_yjit
276
+ return "disabled (runner override)" if @yjit == false
277
+ return "unavailable (this Ruby was built without it)" unless defined?(RubyVM::YJIT)
278
+ return "enabled (already on)" if RubyVM::YJIT.enabled?
279
+ return "disabled (RUBY_YJIT_ENABLE set)" if ENV.key?("RUBY_YJIT_ENABLE")
280
+ return "disabled (FLEZHA_YJIT=0)" if ENV["FLEZHA_YJIT"] == "0"
281
+
282
+ RubyVM::YJIT.enable
283
+ RubyVM::YJIT.enabled? ? "enabled" : "unavailable (enable refused)"
284
+ end
285
+ end
286
+
287
+ # Start here: `Flezha.create_runner("default").register_factory(MyRelay).run`.
288
+ def self.create_runner(namespace) = Runner.new(namespace)
289
+ end