kobako 0.19.0-aarch64-linux → 0.20.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +20 -0
  4. data/crates/kobako-runtime/CHANGELOG.md +7 -0
  5. data/crates/kobako-runtime/README.md +1 -1
  6. data/crates/kobako-wasmtime/CHANGELOG.md +7 -0
  7. data/crates/kobako-wasmtime/README.md +1 -1
  8. data/data/kobako.wasm +0 -0
  9. data/lib/kobako/3.3/kobako.so +0 -0
  10. data/lib/kobako/3.4/kobako.so +0 -0
  11. data/lib/kobako/4.0/kobako.so +0 -0
  12. data/lib/kobako/catalog/extensions.rb +50 -43
  13. data/lib/kobako/catalog/handles.rb +7 -16
  14. data/lib/kobako/catalog/services.rb +22 -36
  15. data/lib/kobako/catalog/snippets.rb +11 -10
  16. data/lib/kobako/context.rb +202 -0
  17. data/lib/kobako/errors.rb +26 -4
  18. data/lib/kobako/execution.rb +52 -0
  19. data/lib/kobako/extension.rb +24 -10
  20. data/lib/kobako/pool.rb +1 -7
  21. data/lib/kobako/sandbox.rb +43 -182
  22. data/lib/kobako/sandbox_options.rb +23 -2
  23. data/lib/kobako/transport/dispatcher.rb +16 -15
  24. data/lib/kobako/unresolved.rb +17 -0
  25. data/lib/kobako/usage.rb +6 -6
  26. data/lib/kobako/version.rb +1 -1
  27. data/lib/kobako.rb +1 -0
  28. data/sig/kobako/catalog/extensions.rbs +4 -6
  29. data/sig/kobako/catalog/handles.rbs +0 -2
  30. data/sig/kobako/catalog/services.rbs +3 -3
  31. data/sig/kobako/catalog/snippets.rbs +2 -0
  32. data/sig/kobako/context.rbs +36 -0
  33. data/sig/kobako/errors.rbs +12 -3
  34. data/sig/kobako/execution.rbs +24 -0
  35. data/sig/kobako/extension.rbs +4 -2
  36. data/sig/kobako/runtime.rbs +37 -9
  37. data/sig/kobako/sandbox.rbs +5 -24
  38. data/sig/kobako/sandbox_options.rbs +9 -2
  39. data/sig/kobako/transport/dispatcher.rbs +3 -3
  40. data/sig/kobako/unresolved.rbs +4 -0
  41. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c5d8b9dc269e7579dbcfc7718f24dc7e9d715504e249d55ed82b6e1f8116c5c
4
- data.tar.gz: 9c2027efa1ba39ab0f00efa45efe7b78b2f41f88fc13a40d1ddbffa3669c251d
3
+ metadata.gz: b09a439fa6c5bfc4598a565bc0ec6cfd026c65c48a39e9c9c0ae73c53ffb00d9
4
+ data.tar.gz: 0b1a09a6af7b5eabf918667c9efcc434952d693322fda2f71cf0195df174d21a
5
5
  SHA512:
6
- metadata.gz: 6d46f6cd644c63213ee5f706753e5ed1cc4d2f535bd595b8047b6206a6a16d1a13f8c44fd34f8ad1dbf99a32678a69d5bb68de644917f4092320028753c35b6d
7
- data.tar.gz: f845f7ce1364282a5be2cdf430a4d2c1a77e1181b54a3d5aa4b83a0765733d88920ef018316d5b761dcafebbddf647ac7c814ac0ff21399afa42f5a560646b91
6
+ metadata.gz: b5475e836c30518290c9b94eab8c0fe92c222efea64c93d909a099d50612e674a75733777fd3c37e884b7564dbc40fa46a3f90a04a26c9be40aa5d54dc548f1f
7
+ data.tar.gz: d76951bea7e5eae63d2c79052dcd3d2e03204afa738020f958139a7b293ae55624d94b8e11a6aa1a124f5b9094b281852e4fca6042c6dbf5d797e29dac874fc5
@@ -1 +1 @@
1
- {".":"0.19.0","wasm/kobako-core":"0.11.0","wasm/kobako-mruby":"0.11.0","wasm/kobako-io":"0.11.0","wasm/kobako-json":"0.11.0","wasm/kobako-regexp":"0.11.0","wasm/kobako-baker":"0.11.0","crates/kobako-codec":"0.11.0","crates/kobako-runtime":"0.11.0","crates/kobako-wasmtime":"0.11.0","crates/kobako":"0.11.0"}
1
+ {".":"0.20.0","wasm/kobako-core":"0.12.0","wasm/kobako-mruby":"0.12.0","wasm/kobako-io":"0.12.0","wasm/kobako-json":"0.12.0","wasm/kobako-regexp":"0.12.0","wasm/kobako-baker":"0.12.0","crates/kobako-codec":"0.12.0","crates/kobako-runtime":"0.12.0","crates/kobako-wasmtime":"0.12.0","crates/kobako":"0.12.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.20.0](https://github.com/elct9620/kobako/compare/v0.19.0...v0.20.0) (2026-07-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **bench:** mark a run in progress with a pid lock file ([3ca85fb](https://github.com/elct9620/kobako/commit/3ca85fbe9860ce32d58c1a5bf4ff85cbdc210780))
9
+ * **execution:** tell a nil-value success from a failure with #failed? ([2f3d382](https://github.com/elct9620/kobako/commit/2f3d382b24d65fabda7771429cd3adfc90a8c7e8))
10
+ * **extension:** declare a backend's kind by explicit keyword ([3f6c628](https://github.com/elct9620/kobako/commit/3f6c6283080ba4346ac8ef878e469dab8039e650))
11
+ * **sandbox:** add the gvl: hold/release scheduling option ([ca2a884](https://github.com/elct9620/kobako/commit/ca2a884415b3271c44b20d131536b3fe7c5a3077))
12
+ * **sandbox:** declare fillable Service paths defaulting to Kobako::Unresolved ([1d60f76](https://github.com/elct9620/kobako/commit/1d60f764ddff3b5b10206993e2b281c838ec0022))
13
+ * **sandbox:** fill and override declared paths per invocation with ctx.bind ([629ef7d](https://github.com/elct9620/kobako/commit/629ef7dfa35273c952685fefb4c5793b11886aee))
14
+ * **sandbox:** return a frozen Kobako::Invocation from #eval and #run ([e9f4634](https://github.com/elct9620/kobako/commit/e9f463403bea26fad32e52d560b979684f555ed1))
15
+ * **tasks:** gate the wasmtime driver against a magnus dependency ([268c3f6](https://github.com/elct9620/kobako/commit/268c3f65f53d64b465458bca5c0c9b0d0467df44))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **context:** raise ArgumentError for a spent ctx.bind ([2783bba](https://github.com/elct9620/kobako/commit/2783bba20f139554178389d4c860e0c280cab550))
21
+ * **extension:** re-assert dependencies after a failed seal ([72839ba](https://github.com/elct9620/kobako/commit/72839bac6186e81a1d4323bd3f1eb4f10abf3d64))
22
+
3
23
  ## [0.19.0](https://github.com/elct9620/kobako/compare/v0.18.0...v0.19.0) (2026-07-19)
4
24
 
5
25
 
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.11.0...kobako-runtime-v0.12.0) (2026-07-24)
4
+
5
+
6
+ ### Miscellaneous Chores
7
+
8
+ * **kobako-runtime:** Synchronize kobako crates versions
9
+
3
10
  ## [0.11.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.10.2...kobako-runtime-v0.11.0) (2026-07-19)
4
11
 
5
12
 
@@ -26,7 +26,7 @@ map the neutral types onto their own language surface.
26
26
 
27
27
  ```toml
28
28
  [dependencies]
29
- kobako-runtime = "0.11.0" # x-release-please-version
29
+ kobako-runtime = "0.12.0" # x-release-please-version
30
30
  ```
31
31
 
32
32
  ## License
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.11.0...kobako-wasmtime-v0.12.0) (2026-07-24)
4
+
5
+
6
+ ### Miscellaneous Chores
7
+
8
+ * **kobako-wasmtime:** Synchronize kobako crates versions
9
+
3
10
  ## [0.11.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.10.2...kobako-wasmtime-v0.11.0) (2026-07-19)
4
11
 
5
12
 
@@ -24,7 +24,7 @@ SDK consumes the same surface.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- kobako-wasmtime = "0.11.0" # x-release-please-version
27
+ kobako-wasmtime = "0.12.0" # x-release-please-version
28
28
  ```
29
29
 
30
30
  ## License
data/data/kobako.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../unresolved"
4
+
3
5
  module Kobako
4
6
  module Catalog
5
7
  # Kobako::Catalog::Extensions — per-Sandbox registry of installed
@@ -7,7 +9,9 @@ module Kobako
7
9
  # install time (its +source+ into +Catalog::Snippets+, its +backend+
8
10
  # path into +Catalog::Services+), asserts declared dependencies are
9
11
  # present when the Sandbox seals, and resolves each callable-backed
10
- # path to a fresh object at the start of every invocation.
12
+ # path to a fresh object for every invocation returning that map to the
13
+ # driving +Context+ rather than mutating the shared registry, so
14
+ # concurrent invocations stay shared-nothing.
11
15
  #
12
16
  # Sealing and the reject-after-install guard are governed by the owning
13
17
  # Sandbox through +Catalog::Services#sealed?+, the shared seal signal;
@@ -21,77 +25,87 @@ module Kobako
21
25
 
22
26
  # Install +extension+: validate its shape, register its +source+ as a
23
27
  # preloaded snippet, and — when it carries a +backend+ — reserve the
24
- # backend path in +services+. A callable provider reserves the path
25
- # with a placeholder the per-invocation refresh replaces; a fixed
26
- # provider binds its object directly. Raises +ArgumentError+ for the two
27
- # shapes +#validate!+ owns +source+ not a String, or a present
28
- # +backend+ that omits +path+ / +provider+. The Extension readers are
29
- # duck-typed, so an object missing them surfaces the underlying
30
- # +NoMethodError+, and a malformed +name+ or +backend.path+ surfaces
31
- # through the +snippets+ / +services+ registration it routes to.
28
+ # backend path in +services+. A static +object:+ binds directly; a
29
+ # per-invocation +provider:+ or a fillable backend reserves the path with
30
+ # +Kobako::Unresolved+ (the per-invocation result is layered onto the
31
+ # Context, a fillable stays Unresolved until filled). Raises
32
+ # +ArgumentError+ for the two shapes +#validate!+ owns +source+ not a
33
+ # String, or a present +backend+ that omits +path+ / +object+ /
34
+ # +provider+. The Extension readers are duck-typed, so an object missing
35
+ # them surfaces the underlying +NoMethodError+, and a malformed +name+ or
36
+ # +backend.path+ surfaces through the +snippets+ / +services+
37
+ # registration it routes to.
32
38
  def install(extension, snippets:, services:)
33
39
  validate!(extension)
34
40
  snippets.register(code: extension.source, name: extension.name)
35
41
  backend = extension.backend
36
- services.bind(backend.path, initial_object(backend.provider)) if backend
42
+ services.bind(backend.path, install_object(backend)) if backend
37
43
  @entries << extension
38
44
  self
39
45
  end
40
46
 
41
47
  # Assert every installed Extension's +depends_on+ names a fellow
42
- # installed Extension. Runs once, when the Sandbox first seals; an
43
- # unmet dependency raises +ArgumentError+ naming the gap, before the
44
- # guest runs. Idempotent across later invocations.
48
+ # installed Extension. Runs once, when the Sandbox first seals
49
+ # successfully; an unmet dependency raises +ArgumentError+ naming the
50
+ # gap, before the guest runs. The asserted flag flips only on success,
51
+ # so a seal that failed re-checks on the next attempt rather than
52
+ # silently passing a broken Sandbox. Idempotent across later invocations.
45
53
  def seal!
46
54
  return self if @asserted
47
55
 
48
- @asserted = true
49
56
  assert_dependencies!
57
+ @asserted = true
50
58
  self
51
59
  end
52
60
 
53
- # Resolve each callable-backed path to this invocation's object and
54
- # refresh it behind its already-sealed path in +services+. Distinct
55
- # providers yield distinct objects; one provider shared by several
56
- # Extensions is invoked once and its result shared, so provider
57
- # identity is resource identity. Fixed providers are left untouched —
58
- # they stay the object bound at install.
59
- def refresh_backends!(services)
60
- resolved = {} # : Hash[untyped, untyped]
61
- resolved.compare_by_identity
62
- @entries.each { |extension| refresh_backend(extension, services, resolved) }
63
- self
61
+ # Resolve each +provider:+-backed path to this invocation's object and
62
+ # return the +path => object+ map the driving +Context+ layers over the
63
+ # static base bindings. Distinct providers yield distinct objects; one
64
+ # provider shared by several Extensions is invoked once and its result
65
+ # shared, so provider identity is resource identity. Static and fillable
66
+ # backends are omitted — they stay the object bound at install in the
67
+ # base registry (+Kobako::Unresolved+ for a fillable).
68
+ def resolve
69
+ resolved = {} # : Hash[String, untyped]
70
+ by_provider = {} # : Hash[untyped, untyped]
71
+ by_provider.compare_by_identity
72
+ @entries.each { |extension| resolve_backend(extension, resolved, by_provider) }
73
+ resolved
64
74
  end
65
75
 
66
76
  private
67
77
 
68
- # Resolve one Extension's callable-backed path against the shared
69
- # per-invocation +resolved+ cache (keyed by provider identity) and
70
- # refresh it in +services+. A fixed provider is skipped — its object
71
- # stays as bound at install.
72
- def refresh_backend(extension, services, resolved)
78
+ # The object a backend binds at install: a static +object:+ directly, or
79
+ # +Kobako::Unresolved+ as the placeholder for a per-invocation +provider:+
80
+ # (layered onto the Context) or a fillable (neither keyword).
81
+ def install_object(backend) = backend.object.nil? ? Kobako::Unresolved : backend.object
82
+
83
+ # Resolve one Extension's +provider:+-backed path against the shared
84
+ # per-invocation identity cache (keyed by provider identity) and record
85
+ # it in +resolved+. A static or fillable backend is skipped — its object
86
+ # stays as bound at install in the base registry.
87
+ def resolve_backend(extension, resolved, by_provider)
73
88
  backend = extension.backend
74
89
  return unless backend
75
90
 
76
91
  provider = backend.provider
77
- return unless callable?(provider)
92
+ return if provider.nil?
78
93
 
79
- object = resolved.fetch(provider) { resolved[provider] = provider.call }
80
- services.refresh(backend.path, object)
94
+ resolved[backend.path.to_s] = by_provider.fetch(provider) { by_provider[provider] = provider.call }
81
95
  end
82
96
 
83
97
  # Enforce the Extension-shape checks +#preload+ / +#bind+ do not: a
84
98
  # mandatory String +source+ (the install/bind boundary) and a
85
- # +backend+ that, when present, exposes +path+ and +provider+.
99
+ # +backend+ that, when present, exposes +path+ / +object+ / +provider+.
86
100
  def validate!(extension)
87
101
  source = extension.source
88
102
  raise ArgumentError, "Extension #source must be a String, got #{source.class}" unless source.is_a?(String)
89
103
 
90
104
  backend = extension.backend
91
105
  return if backend.nil?
92
- return if backend.respond_to?(:path) && backend.respond_to?(:provider)
106
+ return if backend.respond_to?(:path) && backend.respond_to?(:object) && backend.respond_to?(:provider)
93
107
 
94
- raise ArgumentError, "Extension #backend must expose #path and #provider"
108
+ raise ArgumentError, "Extension #backend must expose #path, #object, and #provider"
95
109
  end
96
110
 
97
111
  def assert_dependencies!
@@ -110,13 +124,6 @@ module Kobako
110
124
  # of a constant token are interchangeable; a value that is neither
111
125
  # falls through unchanged to the not-installed path.
112
126
  def symbolize(name) = name.is_a?(String) ? name.to_sym : name
113
-
114
- def callable?(provider) = provider.respond_to?(:call)
115
-
116
- # A callable provider reserves its path with a placeholder that the
117
- # per-invocation refresh replaces before any dispatch; a fixed
118
- # provider is the bound object itself.
119
- def initial_object(provider) = callable?(provider) ? nil : provider
120
127
  end
121
128
  end
122
129
  end
@@ -5,10 +5,10 @@ require_relative "../handle"
5
5
  module Kobako
6
6
  module Catalog
7
7
  # Host-side mapping from opaque integer Handle IDs to Ruby objects.
8
- # The table is owned by +Kobako::Sandbox+ and injected
9
- # into the per-Sandbox +Kobako::Catalog::Services+ so guest→host dispatch
10
- # resolves Handle targets and arguments against the same table that
11
- # host→guest wire encoding allocates into.
8
+ # Each invocation's +Kobako::Context+ mints its own table and hands it to
9
+ # the dispatcher, so guest→host dispatch resolves Handle targets and
10
+ # arguments against the same table that host→guest wire encoding allocates
11
+ # into for that run.
12
12
  #
13
13
  # Lifecycle invariants:
14
14
  #
@@ -17,10 +17,9 @@ module Kobako
17
17
  # invocation is 1; ID 0 is reserved as the invalid sentinel and is
18
18
  # never returned by +#alloc+.
19
19
  #
20
- # - At every invocation boundary (via +#reset!+), every Handle issued
21
- # under the old state becomes invalid. Reset applies uniformly
22
- # regardless of allocation source (Service return or host-injected
23
- # argument).
20
+ # - A fresh table backs each invocation, so a Handle issued in one
21
+ # invocation resolves in no other uniformly regardless of
22
+ # allocation source (Service return or host-injected argument).
24
23
  #
25
24
  # - The cap is +0x7fff_ffff+ (2³¹ − 1). Allocation beyond the cap
26
25
  # raises immediately — no silent truncation, no wrap, no ID reuse.
@@ -63,14 +62,6 @@ module Kobako
63
62
  @entries[id]
64
63
  end
65
64
 
66
- # Clear all entries AND reset the counter to 1. Called at the
67
- # per-invocation boundary by +Kobako::Sandbox+. Returns +self+.
68
- def reset!
69
- @entries.clear
70
- @next_id = 1
71
- self
72
- end
73
-
74
65
  # Number of currently-bound entries. Used by tests of the Dispatcher
75
66
  # and Codec::HandleWalk#deep_wrap to observe whether each path allocates
76
67
  # exactly the Handle entries it should — the +Handles+ table itself never
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "handles"
4
3
  require_relative "../codec"
5
4
  require_relative "../errors"
6
5
 
@@ -18,23 +17,19 @@ module Kobako
18
17
  # services.lookup("MyService::KV") # => kv_object
19
18
  #
20
19
  # Per-dispatch routing is +Kobako::Transport::Dispatcher+'s
21
- # responsibility — the Dispatcher receives this registry and the
22
- # +Catalog::Handles+ as arguments from the +Runtime#on_dispatch+ Proc
23
- # that +Kobako::Sandbox#initialize+ installs. The registry holds an
24
- # injected +Catalog::Handles+ reference so dispatch target resolution
25
- # and host→guest auto-wrap share the same Sandbox-owned allocator.
20
+ # responsibility — the Dispatcher resolves a path against the invocation's
21
+ # +Kobako::Context+ (which layers per-invocation providers and +ctx.bind+
22
+ # overrides over these base bindings) and the +Catalog::Handles+, both
23
+ # passed in the per-invocation dispatch Proc the Context hands to
24
+ # +Runtime#eval+ / +#run+.
26
25
  class Services
27
26
  # Ruby constant-name pattern each +::+-separated bind-path segment
28
27
  # must match.
29
28
  NAME_PATTERN = /\A[A-Z]\w*\z/
30
29
 
31
- # Build a fresh registry. +handler+ is an internal seam that injects
32
- # a pre-configured +Catalog::Handles+; tests pass one whose +next_id+
33
- # is pinned near +MAX_ID+ to exercise the cap-exhaustion path
34
- # without 2³¹ allocations. Production callers leave it at the default.
35
- def initialize(handler: Catalog::Handles.new)
30
+ # Build a fresh registry.
31
+ def initialize
36
32
  @bindings = {} # : Hash[String, untyped]
37
- @handler = handler
38
33
  @sealed = false
39
34
  @encoded = nil # : String?
40
35
  end
@@ -56,6 +51,12 @@ module Kobako
56
51
  self
57
52
  end
58
53
 
54
+ # Returns +true+ iff a Service is bound at +path+ — the declared (Frame 1)
55
+ # key set a per-invocation +ctx.bind+ override may target.
56
+ def bound?(path)
57
+ @bindings.key?(path.to_s)
58
+ end
59
+
59
60
  # Resolve a +target+ constant path to the bound Service. Raises
60
61
  # +KeyError+ when no Service is bound at +target+.
61
62
  def lookup(target)
@@ -65,18 +66,6 @@ module Kobako
65
66
  @bindings[target_str]
66
67
  end
67
68
 
68
- # Replace the object bound at an already-registered +path+ without the
69
- # collision or seal checks +#bind+ applies. +Catalog::Extensions+ calls
70
- # this during +begin_invocation!+ to swap a callable backend provider's
71
- # per-invocation result behind a path whose Frame 1 membership was
72
- # fixed at the seal. A path that was never registered is left untouched,
73
- # so the sealed key set can never grow here.
74
- def refresh(path, object)
75
- key = path.to_s
76
- @bindings[key] = object if @bindings.key?(key)
77
- self
78
- end
79
-
80
69
  # Encode the preamble as msgpack bytes for stdin Frame 1 delivery —
81
70
  # a flat array of the bound constant paths, in bind order:
82
71
  # +["MyService::KV", "File"]+. Routes through Kobako::Codec::Encoder
@@ -84,23 +73,20 @@ module Kobako
84
73
  # Strings, so none of the kobako ext types fire. Returns a binary
85
74
  # +String+ of msgpack bytes.
86
75
  #
87
- # Once sealed, the bytes are computed once and reused for every
88
- # subsequent invocation: sealing freezes Service registration at the
89
- # first invocation, so a bind reaching the registry after the seal
90
- # raises +ArgumentError+ and never alters Frame 1.
76
+ # The bytes are pinned eagerly by #seal! at the first invocation, so
77
+ # every subsequent call is a pure read of the frozen preamble — a bind
78
+ # can never reach the registry after the seal to alter Frame 1.
91
79
  def encode
92
- return @encoded if @encoded
93
-
94
- bytes = Codec::Encoder.encode(@bindings.keys).freeze
95
- @encoded = bytes if @sealed
96
- bytes
80
+ @encoded || Codec::Encoder.encode(@bindings.keys).freeze
97
81
  end
98
82
 
99
- # Mark the registry as sealed. Called by +Sandbox+ on the first
100
- # invocation; afterwards #bind raises ArgumentError. Idempotent;
101
- # returns +self+.
83
+ # Mark the registry as sealed and pin the Frame 1 preamble bytes so
84
+ # registration can never alter them afterwards. Called by +Sandbox+ on
85
+ # the first invocation; afterwards #bind raises ArgumentError.
86
+ # Idempotent; returns +self+.
102
87
  def seal!
103
88
  @sealed = true
89
+ @encoded ||= Codec::Encoder.encode(@bindings.keys).freeze
104
90
  self
105
91
  end
106
92
 
@@ -41,17 +41,19 @@ module Kobako
41
41
  # externally via +entry_payload+ rather than asking each entry to
42
42
  # self-encode.
43
43
  #
44
- # The bytes are memoized the table is replayed verbatim on every
45
- # invocation after sealing, so Frame 3 never changes between
46
- # encodes; #register drops the memo while the table is still open.
47
- # Unlike +Catalog::Services#encode+, which gates its memo on the
48
- # seal, this one can fill eagerly and invalidate in +#register+
49
- # because every mutation funnels through that single method — there is
50
- # no out-of-sight child object to change the result behind its back.
44
+ # Before the first invocation this recomputes from the still-open
45
+ # table; #seal! then pins the bytes, so every later call is a pure
46
+ # read of the frozen memo.
51
47
  def encode
52
- return @encoded if @encoded
48
+ @encoded || Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) }).freeze
49
+ end
53
50
 
54
- @encoded = Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) }).freeze
51
+ # Freeze the Frame 3 table bytes at the first invocation so the
52
+ # replayed snippet set can never change afterwards. Called by +Sandbox+
53
+ # alongside the Service / Extension seals. Idempotent; returns +self+.
54
+ def seal!
55
+ @encoded ||= Codec::Encoder.encode(@entries.map { |entry| entry_payload(entry) }).freeze
56
+ self
55
57
  end
56
58
 
57
59
  # Register one preloaded snippet in either of two forms.
@@ -73,7 +75,6 @@ module Kobako
73
75
  # missing keywords, wrong types, malformed +name+, or
74
76
  # duplicate +code:+ +name+.
75
77
  def register(code: nil, name: nil, binary: nil)
76
- @encoded = nil
77
78
  if binary
78
79
  raise ArgumentError, "cannot combine binary: with code: / name:" if code || name
79
80
 
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "capture"
4
+ require_relative "codec"
5
+ require_relative "errors"
6
+ require_relative "execution"
7
+ require_relative "unresolved"
8
+ require_relative "outcome"
9
+ require_relative "usage"
10
+ require_relative "transport"
11
+ require_relative "catalog"
12
+
13
+ module Kobako
14
+ # Kobako::Context — the per-invocation driver behind a single +Sandbox#eval+
15
+ # / +#run+. Each Context owns a fresh +Catalog::Handles+ table and drives one
16
+ # guest invocation: it builds the dispatch handler, runs the guest, records
17
+ # the run's captures and usage, and decodes the outcome. One Context per
18
+ # invocation owns all per-invocation state, so the reusable +Sandbox+ holds
19
+ # none and overlapping evals never touch each other's state.
20
+ class Context
21
+ # Build a Context over the Sandbox-owned config — the +Runtime+, the
22
+ # sealed +Catalog::Services+ / +Catalog::Snippets+ registries, and the
23
+ # +Catalog::Extensions+ whose callable backends this Context resolves for
24
+ # its own run. The +Catalog::Handles+ table is this invocation's own, so
25
+ # guest→host dispatch and host→guest auto-wrap share one allocator scoped
26
+ # to the run; the resolved provider map is likewise per-invocation, so
27
+ # concurrent invocations never share mutable state.
28
+ def initialize(runtime:, services:, snippets:, extensions:)
29
+ @runtime = runtime
30
+ @services = services
31
+ @snippets = snippets
32
+ @extensions = extensions
33
+ @resolved = {} # : Hash[String, untyped]
34
+ @overrides = {} # : Hash[String, untyped]
35
+ @spent = false
36
+ @handler = Catalog::Handles.new
37
+ @stdout_capture = @stderr_capture = Capture::EMPTY
38
+ @usage = Usage::EMPTY
39
+ end
40
+
41
+ # Override the object bound at an already-declared +path+ for this
42
+ # invocation only — the per-eval hook the +#eval+ / +#run+ block uses to
43
+ # fill a fillable or shadow any static / per-invocation binding. +path+
44
+ # must name a declared (Frame 1) binding; overriding an undeclared path
45
+ # raises +ArgumentError+ so the Frame 1 key set stays fixed. Valid only
46
+ # while the block runs — the Context is spent once the block returns, so a
47
+ # captured +ctx+ used afterward raises +ArgumentError+ too, putting both
48
+ # misuses on one rescuable channel. Returns +self+.
49
+ def bind(path, object)
50
+ raise ArgumentError, "Kobako::Context is spent; ctx.bind is only valid inside the #eval / #run block" if @spent
51
+
52
+ key = path.to_s
53
+ raise ArgumentError, "cannot override undeclared path #{key.inspect}" unless @services.bound?(key)
54
+
55
+ @overrides[key] = object
56
+ self
57
+ end
58
+
59
+ # Resolve a Service +path+ to the object backing it this invocation,
60
+ # layering the per-eval +ctx.bind+ overrides over this Context's
61
+ # per-invocation provider results over the Sandbox's static base bindings.
62
+ # An unbound path raises +KeyError+; a fillable left unfilled resolves to
63
+ # +Kobako::Unresolved+ and is reported the same way, so an unresolved
64
+ # capability fails closed as an undefined target rather than dispatching to
65
+ # the sentinel. The Dispatcher maps either +KeyError+ to an
66
+ # undefined-target wire fault. Internal — the per-invocation dispatch
67
+ # handler is the sole caller.
68
+ def lookup(path)
69
+ key = path.to_s
70
+ object = @overrides.fetch(key) { @resolved.fetch(key) { @services.lookup(path) } }
71
+ raise KeyError, "service #{path} is declared but unresolved this invocation" if Unresolved.equal?(object)
72
+
73
+ object
74
+ end
75
+
76
+ # Execute a guest mruby source string in a fresh +mrb_state+ and return the
77
+ # decoded last expression. A given +block+ runs first, receiving this
78
+ # Context to collect +ctx.bind+ overrides before the guest drives.
79
+ def eval(code, &block)
80
+ collect_overrides(&block) if block
81
+ invoke!(:eval) do
82
+ @runtime.eval(dispatch_handler, @services.encode, code.b, @snippets.encode)
83
+ end
84
+ end
85
+
86
+ # Dispatch a +Transport::Run+ envelope into a preloaded entrypoint and
87
+ # return the decoded result. A given +block+ runs first, receiving this
88
+ # Context to collect +ctx.bind+ overrides before the guest drives.
89
+ def run(run_envelope, &block)
90
+ collect_overrides(&block) if block
91
+ invoke!(:run) do
92
+ @runtime.run(dispatch_handler, @services.encode, @snippets.encode, run_envelope.encode(@handler))
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # Run the per-eval override +block+, handing it this Context so it can call
99
+ # +ctx.bind+, then spend the Context so a captured +ctx+ used after the
100
+ # block raises. A block that raises propagates before the guest drives, so
101
+ # the guest never runs and no Execution is produced.
102
+ def collect_overrides
103
+ yield self
104
+ ensure
105
+ @spent = true
106
+ end
107
+
108
+ # Build this invocation's guest→host dispatch handler — a +Proc+ routing
109
+ # each guest→host call through the stateless +Transport::Dispatcher+,
110
+ # capturing this Context as the path resolver (its +#lookup+ layers the
111
+ # per-invocation providers over the static bindings) plus +@handler+. Handed to
112
+ # +Runtime#eval+ / +#run+ as a call argument, so the Runtime holds no
113
+ # dispatch state and the +Proc+ stays GC-rooted as a live argument for the
114
+ # synchronous call. The ext hands the +Proc+ a per-dispatch +guest_yielder+
115
+ # — a +String → String+ callable that re-enters the in-flight guest to run
116
+ # a yielded block — which the +Dispatcher+ forwards to the
117
+ # +Transport::Yielder+ it builds for the call.
118
+ def dispatch_handler
119
+ lambda do |request_bytes, guest_yielder|
120
+ Transport::Dispatcher.dispatch(request_bytes, self, @handler, guest_yielder)
121
+ end
122
+ end
123
+
124
+ # Record this invocation's usage and both output captures from the ext
125
+ # +Snapshot+. Every Snapshot carries them — value return or trap alike — so
126
+ # +#usage+ / +#stdout+ / +#stderr+ stay readable after a rescued trap.
127
+ def populate_observability!(snapshot)
128
+ @usage = Usage.new(wall_time: snapshot.wall_time, memory_peak: snapshot.memory_peak)
129
+ @stdout_capture = Capture.new(bytes: snapshot.stdout, truncated: snapshot.stdout_truncated?)
130
+ @stderr_capture = Capture.new(bytes: snapshot.stderr, truncated: snapshot.stderr_truncated?)
131
+ end
132
+
133
+ # Pick the +TrapError+ subclass to re-raise based on +err+'s actual class.
134
+ # Cap-trap subclasses (+TimeoutError+ / +MemoryLimitError+) preserve their
135
+ # named identity; everything else collapses to the base +Kobako::TrapError+,
136
+ # so #invoke! can add the verb prefix without erasing the named subclass.
137
+ def trap_class_for(err)
138
+ case err
139
+ when TimeoutError then TimeoutError
140
+ when MemoryLimitError then MemoryLimitError
141
+ else TrapError
142
+ end
143
+ end
144
+
145
+ # Build the +TrapError+-family exception for a trapped +Snapshot+ from its
146
+ # neutral trap kind, tagged with the verb — the cap subclasses
147
+ # (+TimeoutError+ / +MemoryLimitError+) keep their identity, every other
148
+ # engine fault is the base +TrapError+.
149
+ def trap_error_for(snapshot, verb)
150
+ klass = case snapshot.trap_kind
151
+ when :timeout then TimeoutError
152
+ when :memory_limit then MemoryLimitError
153
+ else TrapError
154
+ end
155
+ klass.new("Sandbox##{verb} failed: #{snapshot.trap_message}")
156
+ end
157
+
158
+ # Freeze this run's observables plus +value+ (+nil+ on a failed run) into
159
+ # the read-only +Execution+ the caller receives or the error carries.
160
+ # +failed+ records the two apart so a +nil+ +value+ from a successful run
161
+ # stays distinct from a failed one.
162
+ def build_execution(value, failed:)
163
+ Execution.new(value: value, usage: @usage, stdout: @stdout_capture, stderr: @stderr_capture, failed: failed)
164
+ end
165
+
166
+ # Decode a completed run's outcome into its +Execution+. A Capability
167
+ # Handle in the result is restored to its host object first. Decode sits in
168
+ # the rescue so a wire-violation trap or a Panic envelope both attach this
169
+ # run's Execution, just like a guest-call trap does.
170
+ def settle_outcome(snapshot, verb)
171
+ value, carried = Codec.track_handles { Outcome.decode(snapshot.outcome) }
172
+ value = Codec::HandleWalk.deep_restore(value, @handler) if carried
173
+ build_execution(value, failed: false)
174
+ rescue Kobako::TrapError => e
175
+ raise trap_class_for(e).new("Sandbox##{verb} failed: #{e.message}").with_execution(build_execution(nil,
176
+ failed: true))
177
+ rescue Kobako::SandboxError, Kobako::ServiceError => e
178
+ raise e.with_execution(build_execution(nil, failed: true))
179
+ end
180
+
181
+ # Drive one invocation and settle it into a frozen +Execution+. +verb+
182
+ # tags the TrapError message so the failing export is identifiable. This
183
+ # invocation's callable Extension backends are resolved first — before the
184
+ # guest runs — so a provider that raises propagates unwrapped and leaves
185
+ # the guest unrun. Usage and captures are recorded before the trap check,
186
+ # so a trapped Snapshot's error carries them just like a completed run's
187
+ # return value. A could-not-start fault ran no invocation at all, so it
188
+ # carries no Execution and gains only the verb prefix.
189
+ def invoke!(verb)
190
+ @resolved = @extensions.resolve
191
+ begin
192
+ snapshot = yield
193
+ rescue Kobako::TrapError => e
194
+ raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
195
+ end
196
+ populate_observability!(snapshot)
197
+ return settle_outcome(snapshot, verb) unless snapshot.trapped?
198
+
199
+ raise trap_error_for(snapshot, verb).with_execution(build_execution(nil, failed: true))
200
+ end
201
+ end
202
+ end