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,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ require_relative "dispatch"
6
+ require_relative "logger"
7
+
8
+ module Flezha
9
+ # The concurrency host: what runs `poll -> dispatch -> complete`, and on what.
10
+ #
11
+ # Two members in v1, and the seam between them may never observe or affect
12
+ # per-cell ordering. Exclusivity is the core's handout guarantee — one
13
+ # outstanding activation per Cell — and an activation is completed only
14
+ # after its dispatch has returned, so ordering holds for any executor that
15
+ # does not complete early. That invariant is this seam's entire contract.
16
+ module Executor
17
+ # What `pool_supervise` answers: spawn a base worker, stop, spawn a grown
18
+ # one; anything else is nothing to do this wait.
19
+ POOL_SPAWN_BASE = 1
20
+ POOL_STOP = 2
21
+ POOL_SPAWN_GROWN = 3
22
+
23
+ module_function
24
+
25
+ # One fetch loop, and the one place the three answers `poll` can give are
26
+ # interpreted.
27
+ #
28
+ # Both executors fetch, and the deferred Ractor one will too — so what a
29
+ # timeout, an activation and a shutdown each mean lives here rather than in
30
+ # one correct copy per executor. The block is yielded each activation. The
31
+ # loop ends when `stopping` answers true, when the core says it is shutting
32
+ # down, and after `retire_ms` of timed-out polls when that is positive: the
33
+ # governor's retire time, for a grown worker.
34
+ def fetch(core, poll_ms, stopping, retire_ms = 0)
35
+ idle = 0
36
+ until stopping.call
37
+ activation = core.poll(poll_ms)
38
+ return :shutting_down if activation == :shutting_down
39
+
40
+ if activation.nil?
41
+ idle += poll_ms
42
+ return :retired if retire_ms.positive? && idle >= retire_ms
43
+ else
44
+ idle = 0
45
+ yield activation
46
+ end
47
+ end
48
+ :stopped
49
+ end
50
+
51
+ # When a drain of `seconds` must be finished by.
52
+ def deadline(seconds) = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
53
+
54
+ # How long is left of it, never negative — `Thread#join(0)` polls rather
55
+ # than blocking, which is what a passed deadline should do.
56
+ def remaining(deadline)
57
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
58
+ end
59
+
60
+ # Say what the drain did not finish. Silence here is the failure mode the
61
+ # bound exists to avoid reintroducing: a runner that leaves without
62
+ # reporting what it abandoned looks identical to one that drained.
63
+ def report_undrained(count, seconds)
64
+ return if count.zero?
65
+
66
+ Flezha.log.warn(
67
+ "#{count} activation(s) were still running after the #{seconds}s drain; the " \
68
+ "runner is exiting anyway. The core re-hands unanswered work on the next bind — find " \
69
+ "the handler that does not return."
70
+ )
71
+ end
72
+
73
+ # What both executors share: the core's pool governor sizes the pool over
74
+ # this machine's processor count, and its supervisor's answers are what
75
+ # this host spawns. A host only brings the threads.
76
+ module Pooled
77
+ attr_reader :base, :ceiling
78
+
79
+ private
80
+
81
+ def open_pool(core)
82
+ @core = core
83
+ @base, @ceiling, @poll_ms, @retire_ms, drain_ms = core.pool_open(Etc.nprocessors)
84
+ @drain_s = drain_ms / 1000.0
85
+ end
86
+
87
+ # The base workers into the seats the governor reserved, then its supervisor.
88
+ def start_pool
89
+ @base.times { start_worker(false) }
90
+ @supervisor = Thread.new { supervise }
91
+ @supervisor.name = "flezha-supervisor"
92
+ end
93
+
94
+ # One governor answer per wait, at most a second apart, and the core's
95
+ # notices written on each, so an idle runner's notices still reach its log.
96
+ def supervise
97
+ until @stopping
98
+ answer = @core.pool_supervise
99
+ Flezha.log_notices(@core.take_notices)
100
+ break if answer == POOL_STOP || @stopping
101
+
102
+ start_worker(answer == POOL_SPAWN_GROWN) if [POOL_SPAWN_BASE, POOL_SPAWN_GROWN].include?(answer)
103
+ end
104
+ rescue StandardError => e
105
+ Flezha.log.error("the pool supervisor ended on #{e.class}: #{e.message}")
106
+ end
107
+
108
+ # A seat the governor reserved; a thread that cannot start gives it back.
109
+ def start_worker(grown)
110
+ spawn_member(grown)
111
+ rescue ThreadError => e
112
+ @core.pool_left
113
+ Flezha.log.warn("could not start a worker thread: #{e.message}")
114
+ end
115
+
116
+ # A worker that dispatches on itself: the thread executor's every worker,
117
+ # and a grown seat under either, because a parked completion needs a
118
+ # thread and a stalled scheduler cannot supply one.
119
+ def run_worker(grown)
120
+ Executor.fetch(@core, @poll_ms, -> { @stopping }, grown ? @retire_ms : 0) do |activation|
121
+ Dispatch.dispatch(activation, @core, @deps)
122
+ end
123
+ rescue StandardError => e
124
+ # A worker that dies silently is a pool that shrinks with no report;
125
+ # the governor replaces it, but only this line says why it left.
126
+ Flezha.log.error("a worker thread ended on #{e.class}: #{e.message}")
127
+ ensure
128
+ @core.pool_left
129
+ end
130
+ end
131
+ end
132
+
133
+ # The default executor: a base pool of Ruby threads each looping
134
+ # `poll -> dispatch -> complete` on itself, plus the core pool governor's
135
+ # supervisor, whose answers grow the pool under starvation and restore it
136
+ # when a worker left.
137
+ #
138
+ # This is the Rust host's model rather than Java's hand-off, and the reason
139
+ # is the reason behind both shipped hosts: Java hands off because a parked
140
+ # virtual thread costs a stack and nothing else — which is also exactly why
141
+ # Java's fetchers never wait on user code. An MRI thread is a
142
+ # real OS thread with a real stack, so hand-off would buy nothing and cost a
143
+ # thread per activation up to the core's in-flight cap.
144
+ #
145
+ # Concurrency is bounded by the core's `FLEZHA_MAX_INFLIGHT_ACTIVATIONS`,
146
+ # never by the pool size. The pool is a servicing detail.
147
+ class ThreadExecutor
148
+ include Executor::Pooled
149
+
150
+ def initialize(core, deps)
151
+ open_pool(core)
152
+ @deps = deps
153
+ @workers = []
154
+ @supervisor = nil
155
+ @lock = Mutex.new
156
+ @stopping = false
157
+ end
158
+
159
+ def name = "threads"
160
+
161
+ # How many workers are alive now. Reported in the startup line and read by
162
+ # the pool-growth tests.
163
+ def size = @lock.synchronize { @workers.count(&:alive?) }
164
+
165
+ def start
166
+ start_pool
167
+ self
168
+ end
169
+
170
+ # Stop fetching without the core's word. A local run ends while the
171
+ # process-wide poll core keeps running — there is no controller session to
172
+ # shut down, and shutting the core would stop every later run in the
173
+ # process — so the flag is raised first and the workers leave on their next
174
+ # poll timeout instead of on the drain deadline.
175
+ def stop_fetching
176
+ @stopping = true
177
+ end
178
+
179
+ # Stop fetching and drain. The core says when: a worker leaves on the poll
180
+ # that reports the core shutting down, which the core reports only once
181
+ # the shutdown sequence has shipped every Cell's state through these
182
+ # same workers; a worker inside a handler finishes it, completes it, and
183
+ # only then observes it. Bounded by the governor's drain bound — a handler
184
+ # that never returns must not keep the runner alive forever — and the flag
185
+ # is raised after the join so a worker spawned late still leaves.
186
+ def stop
187
+ deadline = Executor.deadline(@drain_s)
188
+ @lock.synchronize { @workers.dup }.each { |worker| worker.join(Executor.remaining(deadline)) }
189
+ @stopping = true
190
+ @supervisor&.join(Executor.remaining(deadline))
191
+ undrained = @lock.synchronize { @workers.count(&:alive?) }
192
+ Executor.report_undrained(undrained, @drain_s)
193
+ @lock.synchronize { @workers.clear }
194
+ Flezha.log_notices(@core.take_notices)
195
+ undrained
196
+ end
197
+
198
+ private
199
+
200
+ def spawn_member(grown)
201
+ @lock.synchronize do
202
+ @workers.select!(&:alive?)
203
+ worker = Thread.new { run_worker(grown) }
204
+ worker.name = "flezha-worker-#{@workers.size}"
205
+ @workers << worker
206
+ end
207
+ end
208
+ end
209
+
210
+ # The opt-in executor for I/O-heavy handlers: real-thread fetchers hand
211
+ # activations to fibers through a `Thread::Queue`, which is scheduler-aware,
212
+ # so a handler's I/O yields instead of blocking a thread.
213
+ #
214
+ # The fetcher stays a real thread under this executor too. `poll` blocks in
215
+ # native code, and `docs/internal/sdk/poll_core.md` is explicit that fetching on a
216
+ # runtime's shared I/O pool starves everything else that pool serves.
217
+ #
218
+ # Completion happens on the fiber that dispatched, not on the fetcher: the
219
+ # ABI accepts a completion from any thread, and the ordering guarantee is the
220
+ # core's handout, not this executor's.
221
+ class FiberExecutor
222
+ include Executor::Pooled
223
+
224
+ # The queue depth between the fetchers and the fibers. Bounded so a fetcher
225
+ # cannot run arbitrarily far ahead of the handlers; the core's in-flight
226
+ # cap bounds the total either way.
227
+ QUEUE_DEPTH = 64
228
+
229
+ def initialize(core, deps, scheduler: nil)
230
+ open_pool(core)
231
+ @deps = deps
232
+ @scheduler = scheduler
233
+ @queue = Thread::SizedQueue.new(QUEUE_DEPTH)
234
+ @threads = []
235
+ @stopping = false
236
+ end
237
+
238
+ def name = "fibers"
239
+ def size = @threads.count(&:alive?)
240
+
241
+ def start
242
+ @dispatcher = Thread.new { run_dispatcher }
243
+ @dispatcher.name = "flezha-fibers"
244
+ start_pool
245
+ self
246
+ end
247
+
248
+ # Stop fetching without the core's word; see the thread executor's.
249
+ def stop_fetching
250
+ @stopping = true
251
+ end
252
+
253
+ # The fetchers leave on the core's word, as the thread executor's workers
254
+ # do; the flag is raised after their join.
255
+ def stop
256
+ deadline = Executor.deadline(@drain_s)
257
+ @threads.dup.each { |thread| thread.join(Executor.remaining(deadline)) }
258
+ @stopping = true
259
+ @supervisor&.join(Executor.remaining(deadline))
260
+ # Closing the queue is what ends the dispatcher: a scheduler-aware pop
261
+ # answers nil on a closed queue rather than parking forever.
262
+ @queue.close
263
+ # Bounded for the reason the thread executor's join is: closing a
264
+ # scheduler runs its outstanding fibers to completion, and a fiber that
265
+ # never returns would hold the drain open forever.
266
+ @dispatcher&.join(Executor.remaining(deadline))
267
+ undrained = [@dispatcher, *@threads].count { |thread| thread&.alive? }
268
+ Executor.report_undrained(undrained, @drain_s)
269
+ Flezha.log_notices(@core.take_notices)
270
+ undrained
271
+ end
272
+
273
+ private
274
+
275
+ # A fetcher for a base seat; a grown seat is a worker that dispatches on
276
+ # itself, since a scheduler stalled in a parked completion cannot rescue itself.
277
+ def spawn_member(grown)
278
+ @threads.select!(&:alive?)
279
+ thread = Thread.new { grown ? run_worker(true) : run_fetcher }
280
+ thread.name = "flezha-fetcher-#{@threads.size}"
281
+ @threads << thread
282
+ end
283
+
284
+ def run_fetcher
285
+ Executor.fetch(@core, @poll_ms, -> { @stopping }) { |activation| @queue.push(activation) }
286
+ rescue ClosedQueueError
287
+ nil
288
+ rescue StandardError => e
289
+ Flezha.log.error("a fetcher thread ended on #{e.class}: #{e.message}")
290
+ ensure
291
+ @core.pool_left
292
+ end
293
+
294
+ # The pop runs in a scheduled fiber, so a quiet queue yields to the event
295
+ # loop and a handler fiber is resumed as soon as its I/O is ready. That loop
296
+ # is the scheduler's close, which Ruby runs when this thread ends and which
297
+ # runs every fiber to completion, the pop's included: a drain here means
298
+ # what it means under the thread executor. Closing the scheduler by hand as
299
+ # well would be a second close, which the scheduler refuses.
300
+ def run_dispatcher
301
+ Fiber.set_scheduler(build_scheduler)
302
+ Fiber.schedule { pop_and_dispatch }
303
+ rescue StandardError => e
304
+ Flezha.log.error("the fiber dispatcher ended on #{e.class}: #{e.message}")
305
+ end
306
+
307
+ def pop_and_dispatch
308
+ while (activation = @queue.pop)
309
+ Fiber.schedule { Dispatch.dispatch(activation, @core, @deps) }
310
+ end
311
+ rescue StandardError => e
312
+ Flezha.log.error("the fiber dispatcher ended on #{e.class}: #{e.message}")
313
+ end
314
+
315
+ def build_scheduler
316
+ return @scheduler.call if @scheduler
317
+
318
+ begin
319
+ require "async"
320
+ rescue LoadError
321
+ raise Error.new("runtime.executor_unavailable",
322
+ executor: "fibers",
323
+ reason: "it needs a Fiber::Scheduler; add the `async` gem, or pass `scheduler:` " \
324
+ "a callable answering one")
325
+ end
326
+ Async::Scheduler.new
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "builder"
6
+ require_relative "errors"
7
+
8
+ module Flezha
9
+ # The `HostClassDecl` the core is handed, and the verdict it answers.
10
+ #
11
+ # The topology is judged in ONE place, `flz_core::host::register::check`:
12
+ # this module records what the author declared, submits it, and the core's
13
+ # refusal reaches the author as the `Flezha::Error` it raises.
14
+ module Graph
15
+ # The Ruby SDK's byte in the core's `sdk_lang` allocation table.
16
+ SDK_LANG_RUBY = 5
17
+
18
+ # This SDK's own released version, stamped into every durable record it
19
+ # writes; the core packs it. `Flezha::VERSION` is the single literal
20
+ # `lib/flezha/version.rb` holds and the gemspec already reads, so the gem
21
+ # cannot ship a version its records disagree with.
22
+ SDK_VERSION = Flezha::VERSION
23
+
24
+ # MessagePack, shared with every SDK whose codec is MessagePack.
25
+ FORMAT_ID_MSGPACK = 5
26
+
27
+ module_function
28
+
29
+ # Judge one declaration and answer what the core derived from it: the
30
+ # channel names, kinds, consumers, output names and acknowledgement
31
+ # granularity it read off the record.
32
+ #
33
+ # Judge, register and declare one class, in the one order that works.
34
+ #
35
+ # `validate` writes the derived channel names and consumer edges back into
36
+ # the spec, so a caller that skipped it would register a spec whose edges
37
+ # route nowhere. Both the runner and local mode reach the core through
38
+ # here, so there is one such order rather than two that agree.
39
+ def declare(spec, core, registry)
40
+ validate(spec, core)
41
+ registry.add(spec)
42
+ core.declare_class(JSON.generate(to_host_class_decl(spec)))
43
+ end
44
+
45
+ def validate(spec, core)
46
+ derived = submit(spec, core)
47
+ bind_edges(spec, derived)
48
+ derived
49
+ end
50
+
51
+ # The record, over the ABI, and the verdict back. A refusal raises the
52
+ # core's `Flezha::Error`: its code is the first issue's, and
53
+ # `details["issues"]` lists every issue as `{code, message, details}`.
54
+ def submit(spec, core)
55
+ JSON.parse(core.check_class(JSON.generate(to_host_class_decl(spec))))
56
+ end
57
+
58
+ # The consumers the CORE derived, each carrying the closures of the chain
59
+ # that produced it: the builder held them aside per chain, and the derived
60
+ # edge names the chain. Which edge survives a re-declaration is therefore
61
+ # the core's answer, not a rule this SDK repeats.
62
+ def bind_edges(spec, derived)
63
+ derived["channels"].each do |channel|
64
+ target = spec.channels[channel["index"]]
65
+ target.consumers = channel["consumers"].map { |edge| target.chain_edges[edge["chain"]] }
66
+ target.derived_kind = channel["kind"]
67
+ end
68
+ end
69
+
70
+ # Drop the keys whose value is nil, so the JSON omits them.
71
+ def compact(hash) = hash.reject { |_key, value| value.nil? }
72
+
73
+ # The channel as the record carries it: the kind the author declared and
74
+ # the sequences declared off its handle. The consumers, the plugin kind and
75
+ # the output name are the core's to DERIVE from those chains.
76
+ def channel_json(channel)
77
+ compact(
78
+ "name" => channel.name,
79
+ "kind" => channel.kind.to_s,
80
+ "producer" => channel.producer,
81
+ "chains" => channel.chains,
82
+ "retry_max" => channel.retry_max,
83
+ "retry_backoff_ms" => channel.retry_backoff_ms,
84
+ "overflow" => channel.overflow.to_s,
85
+ "keyed" => channel.keyed,
86
+ "codec" => channel.codec,
87
+ "schema" => channel.schema,
88
+ "description" => channel.description
89
+ )
90
+ end
91
+
92
+ def tasklet_json(node)
93
+ definition = node.definition
94
+ compact(
95
+ "name" => node.name,
96
+ "cell_keys" => definition.cell_keys || [],
97
+ "idle_ttl_ms" => definition.idle_ttl_ms,
98
+ "max_cells" => definition.max_cells,
99
+ "cells_for_overridden" => definition.cells_for?,
100
+ "compensate_overridden" => definition.compensate?,
101
+ "clock" => definition.clock.to_s,
102
+ "mailbox_capacity" => definition.mailbox_capacity,
103
+ "max_timers_per_cell" => definition.max_timers_per_cell,
104
+ # A tasklet with no state journals nothing, so a logical clock over
105
+ # it never advances. Ruby has no per-tasklet Change type; having a
106
+ # state IS the same statement.
107
+ "change_is_nochange" => !definition.stateful?,
108
+ "schema_version" => definition.schema_version || 1,
109
+ "min_readable_schema_version" => definition.min_readable_schema_version,
110
+ "migrates" => definition.migrate_state? || definition.migrate_change?
111
+ )
112
+ end
113
+
114
+ # The declaration the core deserializes. The settings tree goes as the
115
+ # author wrote it: the core judges it and refuses a misplaced setting by
116
+ # name. Every key is pinned by
117
+ # `test/fixtures/host_class_decl_keys.txt`, which this gem's own test
118
+ # reads: a key the core learns that Ruby cannot spell fails that test.
119
+ def to_host_class_decl(spec)
120
+ compact(
121
+ "factory_id" => spec.factory_id,
122
+ "description" => spec.description,
123
+ "max_duration_ms" => spec.max_duration_ms,
124
+ "run" => spec.run,
125
+ "durable" => spec.durable,
126
+ "sdk_lang" => SDK_LANG_RUBY,
127
+ "sdk_version" => SDK_VERSION,
128
+ "change_format_id" => FORMAT_ID_MSGPACK,
129
+ "compact_after_bytes" => spec.tasklets.filter_map { |node| node.definition.compact_after_bytes }.min,
130
+ "channels" => spec.channels.map { |channel| channel_json(channel) },
131
+ "tasklets" => spec.tasklets.map { |node| tasklet_json(node) }
132
+ )
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flezha
4
+ # The replay-stable identifier of a cell.
5
+ #
6
+ # The algorithm is fixed by the SDK contract rather than by a library, so
7
+ # every SDK derives the same value from the same cell: FNV-1a over
8
+ # `identity | change seq | per-invocation ordinal`, where the identity is
9
+ # the bytes the core hands with the cell's first activation.
10
+ module Identity
11
+ FNV128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58d
12
+ FNV128_PRIME = 0x0000000001000000000000000000013b
13
+ MASK128 = (1 << 128) - 1
14
+
15
+ module_function
16
+
17
+ def fnv1a128(seed, bytes)
18
+ hash = seed
19
+ bytes.each_byte do |byte|
20
+ hash = (hash ^ byte) & MASK128
21
+ hash = (hash * FNV128_PRIME) & MASK128
22
+ end
23
+ hash
24
+ end
25
+
26
+ # Thirty-two lowercase hex characters. Repeated calls in one invocation
27
+ # differ; the n-th call of a replayed invocation equals the n-th call of the
28
+ # original, which is what makes it the replacement for a random UUID.
29
+ def stable_id(identity, change_seq, ordinal)
30
+ hash = fnv1a128(FNV128_OFFSET_BASIS, identity)
31
+ hash = fnv1a128(hash, [change_seq & 0xffffffffffffffff].pack("Q>"))
32
+ hash = fnv1a128(hash, [ordinal & 0xffffffff].pack("N"))
33
+ format("%032x", hash)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flezha
4
+ # One value a graph released on a declared output. `metadata` is what a
5
+ # deployed run would have put on the wire, `flz.*` stamps included.
6
+ LocalOutput = Struct.new(:channel, :payload, :metadata, keyword_init: true)
7
+
8
+ # Running a declared relay graph in this process, with no controller.
9
+ #
10
+ # `LocalRelay.start` takes the relay class you deploy and runs the
11
+ # `channels` graph it already declares — no local-mode member on the class,
12
+ # no second declaration. What runs is the deployed machinery: entry decode,
13
+ # edge transforms and keying, one Cell per resolved key with its own
14
+ # mailbox, the retry ladder, and inter-tasklet delivery performed by the
15
+ # runtime.
16
+ #
17
+ # run = Flezha::LocalRelay.start(WordCount)
18
+ # run.on_output("totals") { |output| puts output.payload }
19
+ # run.feed("lines", "the quick brown fox")
20
+ # run.run_until_idle(5_000)
21
+ # run.close
22
+ #
23
+ # State is held in memory and nothing is durable. Durability, plugins,
24
+ # placement and migration, the dead-letter queue, observability export,
25
+ # authz and multi-runner scale all need a controller; the run says so through
26
+ # the SDK's logger every time it starts. The "Local mode" section of
27
+ # https://github.com/flezha/flezha/blob/main/docs/public/topics/core/running_and_testing.md#local-mode
28
+ # walks through it.
29
+ class LocalRelay
30
+ # Bounded so a stopped run's drain thread leaves promptly.
31
+ OUTPUT_POLL_MS = 100
32
+
33
+ # Register +klass+'s declared graph and start it locally. Raises naming
34
+ # the capability when the declaration needs a controller.
35
+ def self.start(klass)
36
+ core = Core.create
37
+ begin
38
+ spec = klass.spec
39
+ registry = Registry.new
40
+ Graph.declare(spec, core, registry)
41
+ # Not `start`, which is the one call that dials a controller.
42
+ core.local_start(spec.factory_id, "#{spec.factory_id}-local")
43
+ deps = DispatchDeps.new(registry: registry, bridge: core)
44
+ executor = ThreadExecutor.new(core, deps)
45
+ executor.start
46
+ rescue Exception # rubocop:disable Lint/RescueException
47
+ core.free
48
+ raise
49
+ end
50
+ new(core, executor)
51
+ end
52
+
53
+ def initialize(core, executor)
54
+ @core = core
55
+ @executor = executor
56
+ @sinks = {}
57
+ @closed = false
58
+ @drain = Thread.new { drain_outputs }
59
+ @drain.name = "flz-local-output"
60
+ end
61
+
62
+ # Deliver every value released on the output +channel+ to the block, in
63
+ # release order. An output channel takes the producing tasklet's name
64
+ # unless the declaration named it.
65
+ def on_output(channel, &callback)
66
+ # Registered with the core first: a channel the declaration does not
67
+ # carry is refused there, naming it, rather than silently accepted.
68
+ @core.local_on_sink(channel)
69
+ @sinks[channel] = callback
70
+ self
71
+ end
72
+
73
+ # Feed one value's wire bytes to a declared entry channel.
74
+ def feed(channel, payload)
75
+ @core.local_feed(channel, payload.to_s)
76
+ self
77
+ end
78
+
79
+ # Block until the graph has nothing left to do. A budget that elapses
80
+ # first is reported, never passed off as done.
81
+ def run_until_idle(budget_ms)
82
+ @core.local_run_until_idle(budget_ms)
83
+ end
84
+
85
+ # End the run and release its Cells. Idempotent.
86
+ def close
87
+ return if @closed
88
+
89
+ @closed = true
90
+ begin
91
+ # `join` re-raises whatever the drain thread died of, so the teardown
92
+ # below is an `ensure`: a failed delivery must not strand the context.
93
+ @drain.join(OUTPUT_POLL_MS / 1000.0 * 20)
94
+ # Stop the run first, then take what it released and the drain thread
95
+ # had not reached: a value can be queued for up to one poll interval,
96
+ # and ending the run while it sits there is how an author loses output
97
+ # the graph did produce. Stopping drops the sender, so this ends at the
98
+ # first empty read rather than waiting.
99
+ @core.local_stop
100
+ while (output = @core.local_next_output(0))
101
+ deliver(output)
102
+ end
103
+ ensure
104
+ # Not `Core#shutdown`: that stops the process-wide poll core, and a
105
+ # process may start another local run after this one. The workers
106
+ # leave on the flag instead, which is why it is raised before the
107
+ # drain rather than after it.
108
+ @executor.stop_fetching
109
+ undrained = @executor.stop
110
+ # As the runner does: a worker the bounded drain gave up on may yet
111
+ # call the core, and freeing the context under it is a use-after-free.
112
+ @core.free if undrained.zero?
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ # One released value per pass, handed to whoever registered its channel.
119
+ def drain_outputs
120
+ until @closed
121
+ begin
122
+ output = @core.local_next_output(OUTPUT_POLL_MS)
123
+ rescue StandardError
124
+ # The run ended under us; the close path owns the teardown.
125
+ return
126
+ end
127
+ deliver(output) if output
128
+ end
129
+ end
130
+
131
+ def deliver(output)
132
+ unless output[:target] == :plugin
133
+ # The core queues what an output released and nothing else; an edge
134
+ # output is an in-process mailbox push that never leaves.
135
+ raise "local mode released a #{output[:target]} output"
136
+ end
137
+
138
+ channel = output[:plugin_channel]
139
+ callback = @sinks[channel]
140
+ return if callback.nil?
141
+
142
+ callback.call(LocalOutput.new(channel: channel, payload: output[:payload],
143
+ metadata: (output[:metadata] || []).to_h))
144
+ end
145
+ end
146
+ end