asherah 0.9.1-aarch64-linux → 0.10.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.
data/lib/asherah/error.rb CHANGED
@@ -1,30 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asherah
4
- # Asherah Error converts the error code to error message
5
- module Error
6
- ConfigError = Class.new(StandardError)
7
- NotInitialized = Class.new(StandardError)
8
- AlreadyInitialized = Class.new(StandardError)
9
- GetSessionFailed = Class.new(StandardError)
10
- EncryptFailed = Class.new(StandardError)
11
- DecryptFailed = Class.new(StandardError)
12
- BadConfig = Class.new(StandardError)
13
-
14
- CODES = {
15
- -100 => NotInitialized,
16
- -101 => AlreadyInitialized,
17
- -102 => GetSessionFailed,
18
- -103 => EncryptFailed,
19
- -104 => DecryptFailed,
20
- -105 => BadConfig
21
- }.freeze
22
-
23
- def self.check_result!(result, message)
24
- return unless result.negative?
25
-
26
- error_class = Error::CODES.fetch(result, StandardError)
27
- raise error_class, "#{message} (#{result})"
28
- end
4
+ # Base error class. Also serves as a namespace for specific error types
5
+ # compatible with the canonical godaddy/asherah-ruby gem.
6
+ class Error < StandardError
7
+ ConfigError = Class.new(self)
8
+ NotInitialized = Class.new(self)
9
+ AlreadyInitialized = Class.new(self)
10
+ GetSessionFailed = Class.new(self)
11
+ EncryptFailed = Class.new(self)
12
+ DecryptFailed = Class.new(self)
13
+ BadConfig = Class.new(self)
14
+ Timeout = Class.new(self)
29
15
  end
30
16
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "native"
6
+
7
+ module Asherah
8
+ # Log + metrics observability hooks.
9
+ #
10
+ # The C ABI accepts a single function pointer per hook. We marshal each
11
+ # invocation into a Ruby Hash with symbol keys and yield it to the
12
+ # user-provided +Proc+. Exceptions raised by the user's callback are caught
13
+ # and silently swallowed — propagating an exception across the FFI boundary
14
+ # would be undefined behavior (and since Rust 1.81 aborts the process).
15
+ #
16
+ # The user's callback may fire from any thread (Rust tokio worker threads,
17
+ # database driver threads). Implementations must be thread-safe and should
18
+ # not block; expensive forwarding (e.g. to a logging framework) should be
19
+ # done by enqueueing work onto a background thread you own.
20
+ module Hooks
21
+ # Map C ABI integer log level → +Logger::Severity+ constant. The Rust
22
+ # +log+ crate has a TRACE level that stdlib +Logger+ does not; it is
23
+ # surfaced as +Logger::DEBUG+ so the value is still meaningful when the
24
+ # caller dispatches via +Logger#add+.
25
+ LOG_LEVEL_TO_SEVERITY = {
26
+ Native::LOG_TRACE => Logger::DEBUG,
27
+ Native::LOG_DEBUG => Logger::DEBUG,
28
+ Native::LOG_INFO => Logger::INFO,
29
+ Native::LOG_WARN => Logger::WARN,
30
+ Native::LOG_ERROR => Logger::ERROR
31
+ }.freeze
32
+ private_constant :LOG_LEVEL_TO_SEVERITY
33
+
34
+ # Map +Logger::Severity+ → lowercase symbol for ergonomic dispatch on
35
+ # the raw block API.
36
+ SEVERITY_TO_SYMBOL = {
37
+ Logger::DEBUG => :debug,
38
+ Logger::INFO => :info,
39
+ Logger::WARN => :warn,
40
+ Logger::ERROR => :error,
41
+ Logger::FATAL => :fatal,
42
+ Logger::UNKNOWN => :unknown
43
+ }.freeze
44
+ private_constant :SEVERITY_TO_SYMBOL
45
+
46
+ METRIC_TYPE_NAMES = {
47
+ Native::METRIC_ENCRYPT => :encrypt,
48
+ Native::METRIC_DECRYPT => :decrypt,
49
+ Native::METRIC_STORE => :store,
50
+ Native::METRIC_LOAD => :load,
51
+ Native::METRIC_CACHE_HIT => :cache_hit,
52
+ Native::METRIC_CACHE_MISS => :cache_miss,
53
+ Native::METRIC_CACHE_STALE => :cache_stale
54
+ }.freeze
55
+ private_constant :METRIC_TYPE_NAMES
56
+
57
+ # Module state: pinning the active FFI::Function trampolines is required
58
+ # so the GC does not free them while the C ABI still holds the pointer.
59
+ @mutex = Mutex.new
60
+ @log_trampoline = nil
61
+ @metrics_trampoline = nil
62
+ @log_callback = nil
63
+ @metrics_callback = nil
64
+ # Re-entrancy guard. The +ffi+ gem itself can log via its own internal
65
+ # paths during marshalling, and the Rust crates we bridge to log freely.
66
+ # Without this guard a user callback that itself produces log output
67
+ # would re-enter the trampoline and recurse.
68
+ @log_in_callback = {}
69
+ @metrics_in_callback = {}
70
+
71
+ class << self
72
+ # Install a log hook. Three forms are supported:
73
+ #
74
+ # 1. A stdlib +Logger+ instance (or any Logger-compatible object that
75
+ # responds to +#add+, +#debug+, +#info+, +#warn+, +#error+):
76
+ #
77
+ # Asherah.set_log_hook(Logger.new($stdout))
78
+ #
79
+ # Each Asherah record is forwarded via
80
+ # +Logger#add(severity, message, target)+ so the logger's own filter
81
+ # rules and formatters apply. The +target+ argument is passed as
82
+ # +progname+ for routing.
83
+ #
84
+ # 2. A +Proc+ or block, yielded a Hash:
85
+ #
86
+ # Asherah.set_log_hook do |event|
87
+ # # event[:level] => :debug | :info | :warn | :error (symbol)
88
+ # # event[:severity] => Logger::DEBUG ... (Logger::Severity int)
89
+ # # event[:target] => "asherah::session"
90
+ # # event[:message] => "..."
91
+ # end
92
+ #
93
+ # 3. +nil+ to clear (equivalent to {clear_log_hook}).
94
+ #
95
+ # Replaces any previously installed log hook. Exceptions raised from
96
+ # the callback are caught and silently swallowed.
97
+ def set_log_hook(callback = nil, &block)
98
+ callback ||= block
99
+ if callback.nil?
100
+ clear_log_hook
101
+ return
102
+ end
103
+ callback = logger_to_callback(callback) if logger_like?(callback)
104
+ unless callback.respond_to?(:call)
105
+ raise ArgumentError, "log hook must be a Logger, Proc, or block"
106
+ end
107
+
108
+ @mutex.synchronize do
109
+ @log_callback = callback
110
+ # Allocate the trampoline OUTSIDE the user block so a slow user
111
+ # callback can't hold the mutex.
112
+ @log_trampoline = FFI::Function.new(
113
+ :void,
114
+ [:pointer, :int, :string, :string]
115
+ ) do |_user_data, level, target, message|
116
+ dispatch_log(level, target, message)
117
+ end
118
+ rc = Native.asherah_set_log_hook(@log_trampoline, FFI::Pointer::NULL)
119
+ raise Error, "asherah_set_log_hook failed: rc=#{rc}" if rc != 0
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Remove the active log hook. Idempotent.
125
+ def clear_log_hook
126
+ @mutex.synchronize do
127
+ Native.asherah_clear_log_hook
128
+ @log_callback = nil
129
+ @log_trampoline = nil
130
+ end
131
+ nil
132
+ end
133
+
134
+ # Install a metrics hook. +block+ receives a Hash:
135
+ #
136
+ # # Timing event:
137
+ # { type: :encrypt|:decrypt|:store|:load, duration_ns: Integer, name: nil }
138
+ # # Cache event:
139
+ # { type: :cache_hit|:cache_miss|:cache_stale, duration_ns: 0, name: String }
140
+ #
141
+ # Installing a hook implicitly enables the global metrics gate; clearing
142
+ # it disables the gate. Replaces any previously installed metrics hook.
143
+ # Pass +nil+ to clear.
144
+ def set_metrics_hook(callback = nil, &block)
145
+ callback ||= block
146
+ if callback.nil?
147
+ clear_metrics_hook
148
+ return
149
+ end
150
+ unless callback.respond_to?(:call)
151
+ raise ArgumentError, "metrics hook must be callable (Proc or block)"
152
+ end
153
+
154
+ @mutex.synchronize do
155
+ @metrics_callback = callback
156
+ @metrics_trampoline = FFI::Function.new(
157
+ :void,
158
+ [:pointer, :int, :uint64, :string]
159
+ ) do |_user_data, type, duration_ns, name|
160
+ dispatch_metric(type, duration_ns, name)
161
+ end
162
+ rc = Native.asherah_set_metrics_hook(@metrics_trampoline, FFI::Pointer::NULL)
163
+ raise Error, "asherah_set_metrics_hook failed: rc=#{rc}" if rc != 0
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Remove the active metrics hook and disable the metrics gate. Idempotent.
169
+ def clear_metrics_hook
170
+ @mutex.synchronize do
171
+ Native.asherah_clear_metrics_hook
172
+ @metrics_callback = nil
173
+ @metrics_trampoline = nil
174
+ end
175
+ nil
176
+ end
177
+
178
+ private
179
+
180
+ # Duck-typed Logger detection: anything that is NOT a +Proc+/+Method+
181
+ # and responds to the core stdlib +Logger+ API is treated as a Logger
182
+ # instance. This catches stdlib +Logger+, +ActiveSupport::Logger+,
183
+ # +SemanticLogger+, +Ougai+, etc.
184
+ def logger_like?(obj)
185
+ return false if obj.is_a?(Proc) || obj.is_a?(Method)
186
+ %i[debug info warn error add].all? { |m| obj.respond_to?(m) }
187
+ end
188
+
189
+ def logger_to_callback(logger)
190
+ lambda do |event|
191
+ severity = event[:severity] || Logger::ERROR
192
+ # Logger#add(severity, msg, progname) — progname carries target so
193
+ # custom formatters can route on it.
194
+ logger.add(severity, event[:message], event[:target])
195
+ end
196
+ end
197
+
198
+ def dispatch_log(level, target, message)
199
+ tid = Thread.current.object_id
200
+ return if @log_in_callback[tid]
201
+ @log_in_callback[tid] = true
202
+ cb = @log_callback
203
+ return if cb.nil?
204
+ begin
205
+ severity = LOG_LEVEL_TO_SEVERITY[level] || Logger::ERROR
206
+ cb.call(
207
+ level: SEVERITY_TO_SYMBOL[severity] || :error,
208
+ severity: severity,
209
+ target: target.to_s,
210
+ message: message.to_s
211
+ )
212
+ rescue StandardError, ScriptError
213
+ # swallow — exceptions across FFI are undefined behavior
214
+ ensure
215
+ @log_in_callback.delete(tid)
216
+ end
217
+ end
218
+
219
+ def dispatch_metric(type, duration_ns, name)
220
+ tid = Thread.current.object_id
221
+ return if @metrics_in_callback[tid]
222
+ @metrics_in_callback[tid] = true
223
+ cb = @metrics_callback
224
+ return if cb.nil?
225
+ begin
226
+ cb.call(
227
+ type: METRIC_TYPE_NAMES[type] || :encrypt,
228
+ duration_ns: duration_ns,
229
+ name: name
230
+ )
231
+ rescue StandardError, ScriptError
232
+ # swallow
233
+ ensure
234
+ @metrics_in_callback.delete(tid)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require "rbconfig"
5
+ require_relative "error"
6
+
7
+ module Asherah
8
+ module Native
9
+ extend FFI::Library
10
+
11
+ class AsherahBuffer < FFI::Struct
12
+ layout :data, :pointer, :len, :size_t, :capacity, :size_t
13
+ end
14
+
15
+ class << self
16
+ def library_basenames
17
+ host = RbConfig::CONFIG["host_os"]
18
+ if host =~ /mswin|mingw|cygwin/
19
+ ["asherah_ffi.dll"]
20
+ elsif host =~ /darwin/
21
+ ["libasherah_ffi.dylib"]
22
+ else
23
+ ["libasherah_ffi.so"]
24
+ end
25
+ end
26
+
27
+ def candidate_paths
28
+ names = library_basenames
29
+ paths = []
30
+
31
+ # 1. Explicit override via environment variable
32
+ env = ENV.fetch("ASHERAH_RUBY_NATIVE", "").strip
33
+ unless env.empty?
34
+ if File.directory?(env)
35
+ names.each { |name| paths << File.join(env, name) }
36
+ else
37
+ paths << env
38
+ end
39
+ end
40
+
41
+ # 2. Bundled in gem (platform-specific gem ships it here)
42
+ native_dir = File.expand_path("native", __dir__)
43
+ names.each { |name| paths << File.join(native_dir, name) }
44
+
45
+ # 3. CARGO_TARGET_DIR (development)
46
+ cargo_target = ENV.fetch("CARGO_TARGET_DIR", "").strip
47
+ unless cargo_target.empty?
48
+ %w[debug release].each do |profile|
49
+ names.each { |name| paths << File.join(cargo_target, profile, name) }
50
+ end
51
+ end
52
+
53
+ # 4. Workspace target directory (development)
54
+ root = File.expand_path("../../..", __dir__)
55
+ %w[target/release target/debug].each do |sub|
56
+ names.each { |name| paths << File.join(root, sub, name) }
57
+ end
58
+
59
+ # 5. System library path fallback
60
+ names.each { |name| paths << name }
61
+ paths.uniq
62
+ end
63
+
64
+ def resolve_library
65
+ candidate_paths.find { |path| File.exist?(path) } || candidate_paths.first
66
+ end
67
+ end
68
+
69
+ LIBRARY_PATH = resolve_library
70
+ ffi_lib LIBRARY_PATH
71
+
72
+ attach_function :asherah_last_error_message, [], :pointer
73
+ # Factory creation can hit AWS KMS / DynamoDB / TLS handshakes, so it
74
+ # must release the GVL — otherwise every other Ruby thread blocks for
75
+ # the duration of the SDK init. T5 in
76
+ # docs/review-2026-05-05-findings.md.
77
+ attach_function :asherah_factory_new_from_env, [], :pointer, blocking: true
78
+ attach_function :asherah_factory_new_with_config, [:string], :pointer, blocking: true
79
+ attach_function :asherah_apply_config_json, [:string], :int, blocking: true
80
+ attach_function :asherah_factory_free, [:pointer], :void
81
+ attach_function :asherah_factory_get_session, [:pointer, :string], :pointer
82
+ attach_function :asherah_session_free, [:pointer], :void
83
+ # encrypt/decrypt block on the metastore (MySQL/Postgres/DynamoDB) and
84
+ # KMS during cache misses. blocking: true frees the GVL so other Ruby
85
+ # threads stay schedulable while the native call is in-flight.
86
+ attach_function :asherah_encrypt_to_json,
87
+ [:pointer, :buffer_in, :size_t, :pointer], :int, blocking: true
88
+ attach_function :asherah_decrypt_from_json,
89
+ [:pointer, :buffer_in, :size_t, :pointer], :int, blocking: true
90
+ attach_function :asherah_buffer_free, [:pointer], :void
91
+
92
+ # Async callback type: void(user_data, result_data, result_len, error_message)
93
+ callback :asherah_completion_fn, [:pointer, :pointer, :size_t, :string], :void
94
+ # The async entry points only enqueue work onto the Rust tokio runtime
95
+ # before returning, so they're brief — but mark them blocking anyway so
96
+ # a queue-full backpressure stall doesn't pin the GVL.
97
+ attach_function :asherah_encrypt_to_json_async,
98
+ [:pointer, :buffer_in, :size_t, :asherah_completion_fn, :pointer], :int,
99
+ blocking: true
100
+ attach_function :asherah_decrypt_from_json_async,
101
+ [:pointer, :buffer_in, :size_t, :asherah_completion_fn, :pointer], :int,
102
+ blocking: true
103
+
104
+ # Log + metrics hooks. The C ABI does not own the callback closure — Ruby
105
+ # must keep a reference to the FFI::Function it passes here for as long as
106
+ # the hook is registered, otherwise the GC will collect it and the next
107
+ # invocation segfaults. The Asherah module pins the active hooks in module
108
+ # state.
109
+ callback :asherah_log_callback, [:pointer, :int, :string, :string], :void
110
+ callback :asherah_metrics_callback, [:pointer, :int, :uint64, :string], :void
111
+
112
+ # Hook set/clear functions must release the GVL (blocking: true) because
113
+ # they may join the async dispatcher worker thread (via Drop on the old
114
+ # AsyncLogSink/AsyncMetricsSink). That worker thread needs the GVL to
115
+ # execute FFI callbacks into Ruby — holding the GVL here deadlocks.
116
+ attach_function :asherah_set_log_hook, [:asherah_log_callback, :pointer], :int, blocking: true
117
+ attach_function :asherah_clear_log_hook, [], :int, blocking: true
118
+ attach_function :asherah_set_metrics_hook, [:asherah_metrics_callback, :pointer], :int, blocking: true
119
+ attach_function :asherah_clear_metrics_hook, [], :int, blocking: true
120
+
121
+ # Internal: integer constants that mirror the C ABI severity/event
122
+ # codes in hooks.rs. The public Ruby API exposes them as
123
+ # +Logger::Severity+ values and +Symbol+ types respectively, so
124
+ # callers should never need to reference these directly.
125
+ LOG_TRACE = 0
126
+ LOG_DEBUG = 1
127
+ LOG_INFO = 2
128
+ LOG_WARN = 3
129
+ LOG_ERROR = 4
130
+
131
+ METRIC_ENCRYPT = 0
132
+ METRIC_DECRYPT = 1
133
+ METRIC_STORE = 2
134
+ METRIC_LOAD = 3
135
+ METRIC_CACHE_HIT = 4
136
+ METRIC_CACHE_MISS = 5
137
+ METRIC_CACHE_STALE = 6
138
+
139
+ def self.last_error
140
+ ptr = asherah_last_error_message
141
+ ptr.null? ? "unknown error" : ptr.read_string
142
+ end
143
+
144
+ private_class_method :library_basenames, :candidate_paths, :resolve_library
145
+ end
146
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "native"
5
+
6
+ module Asherah
7
+ class Session
8
+ # Maximum wall time the async API will wait for the FFI callback to
9
+ # deliver a result before giving up. Without this bound, a hung tokio
10
+ # worker (or any callback-delivery race) would block the calling Ruby
11
+ # thread until the process exits — observed as 6-hour CI hangs on the
12
+ # round-trip tests. Override via ASHERAH_RUBY_ASYNC_TIMEOUT (seconds).
13
+ DEFAULT_ASYNC_TIMEOUT_SECONDS = 30
14
+
15
+ # Maximum time {#close} will wait for in-flight async operations to
16
+ # drain before forcibly freeing the session. Independent of the
17
+ # per-call async timeout above.
18
+ DEFAULT_CLOSE_DRAIN_SECONDS = 5
19
+
20
+ def self.async_timeout_seconds
21
+ val = ENV["ASHERAH_RUBY_ASYNC_TIMEOUT"]
22
+ return DEFAULT_ASYNC_TIMEOUT_SECONDS if val.nil? || val.empty?
23
+ Float(val)
24
+ rescue ArgumentError, TypeError
25
+ DEFAULT_ASYNC_TIMEOUT_SECONDS
26
+ end
27
+
28
+ def initialize(pointer)
29
+ raise Asherah::Error::GetSessionFailed, Native.last_error if pointer.null?
30
+ @pointer = pointer
31
+ @close_mu = Mutex.new
32
+ @pending_ops = 0
33
+ end
34
+
35
+ def encrypt_bytes(data)
36
+ raise ArgumentError, "data cannot be nil" if data.nil?
37
+ raise Asherah::Error::EncryptFailed, "session closed" if @pointer.null?
38
+ buf = thread_local_buffer
39
+ status = Native.asherah_encrypt_to_json(@pointer, data, data.bytesize, buf.pointer)
40
+ raise Asherah::Error::EncryptFailed, Native.last_error unless status.zero?
41
+ # `begin/ensure` so the FFI buffer is always freed (and its
42
+ # plaintext bytes wiped via `asherah_buffer_free`'s zeroize) even
43
+ # if `read_bytes` throws — without this the thread-local buffer
44
+ # would leak the previous call's plaintext until the next
45
+ # successful encrypt/decrypt on the same thread.
46
+ begin
47
+ buf[:data].read_bytes(buf[:len])
48
+ ensure
49
+ Native.asherah_buffer_free(buf.pointer)
50
+ end
51
+ end
52
+
53
+ def decrypt_bytes(json)
54
+ raise ArgumentError, "json cannot be nil" if json.nil?
55
+ raise Asherah::Error::DecryptFailed, "session closed" if @pointer.null?
56
+ buf = thread_local_buffer
57
+ status = Native.asherah_decrypt_from_json(@pointer, json, json.bytesize, buf.pointer)
58
+ raise Asherah::Error::DecryptFailed, Native.last_error unless status.zero?
59
+ # See encrypt_bytes — `ensure` guarantees the wipe runs even if
60
+ # the read_bytes call somehow raises.
61
+ begin
62
+ buf[:data].read_bytes(buf[:len])
63
+ ensure
64
+ Native.asherah_buffer_free(buf.pointer)
65
+ end
66
+ end
67
+
68
+ # True async encrypt — runs on Rust's tokio runtime, does not block the Ruby thread.
69
+ # Returns the result; internally uses a Queue to wait for the tokio callback.
70
+ def encrypt_bytes_async(data)
71
+ raise ArgumentError, "data cannot be nil" if data.nil?
72
+ raise Asherah::Error::EncryptFailed, "session closed" if @pointer.null?
73
+ @close_mu.synchronize { @pending_ops += 1 }
74
+ queue = Queue.new
75
+ session = self
76
+ callback = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :string]) do |_ud, result_ptr, result_len, error|
77
+ begin
78
+ if error
79
+ queue.push(Asherah::Error::EncryptFailed.new(error))
80
+ else
81
+ queue.push(result_ptr.read_bytes(result_len))
82
+ end
83
+ ensure
84
+ session.send(:decrement_pending_ops)
85
+ end
86
+ end
87
+ status = Native.asherah_encrypt_to_json_async(@pointer, data, data.bytesize, callback, nil)
88
+ unless status.zero?
89
+ @close_mu.synchronize { @pending_ops -= 1 }
90
+ raise Asherah::Error::EncryptFailed, Native.last_error
91
+ end
92
+ # Bound the wait so a wedged callback can't block the calling
93
+ # thread forever. We use Timeout.timeout (not Queue#pop(timeout:),
94
+ # which only landed in Ruby 3.2) so the lib remains usable on the
95
+ # 3.0/3.1 Ruby builds still in some CI/test images.
96
+ result = await_async_result(queue, "encrypt_bytes_async")
97
+ raise result if result.is_a?(Exception)
98
+ result
99
+ end
100
+
101
+ # True async decrypt — runs on Rust's tokio runtime, does not block the Ruby thread.
102
+ def decrypt_bytes_async(json)
103
+ raise ArgumentError, "json cannot be nil" if json.nil?
104
+ raise Asherah::Error::DecryptFailed, "session closed" if @pointer.null?
105
+ @close_mu.synchronize { @pending_ops += 1 }
106
+ queue = Queue.new
107
+ session = self
108
+ callback = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :string]) do |_ud, result_ptr, result_len, error|
109
+ begin
110
+ if error
111
+ queue.push(Asherah::Error::DecryptFailed.new(error))
112
+ else
113
+ queue.push(result_ptr.read_bytes(result_len))
114
+ end
115
+ ensure
116
+ session.send(:decrement_pending_ops)
117
+ end
118
+ end
119
+ status = Native.asherah_decrypt_from_json_async(@pointer, json, json.bytesize, callback, nil)
120
+ unless status.zero?
121
+ @close_mu.synchronize { @pending_ops -= 1 }
122
+ raise Asherah::Error::DecryptFailed, Native.last_error
123
+ end
124
+ result = await_async_result(queue, "decrypt_bytes_async")
125
+ raise result if result.is_a?(Exception)
126
+ result
127
+ end
128
+
129
+ def close
130
+ ptr = @close_mu.synchronize do
131
+ return if @pointer.null?
132
+ # Wait for in-flight async operations before freeing, but bound
133
+ # the wait — a wedged callback used to make this spin forever
134
+ # (and silently wedge any process trying to shut down cleanly).
135
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + DEFAULT_CLOSE_DRAIN_SECONDS
136
+ while @pending_ops > 0 && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
137
+ sleep 0.001
138
+ end
139
+ if @pending_ops > 0
140
+ warn "asherah: closing session with #{@pending_ops} async operation(s) " \
141
+ "still in flight after #{DEFAULT_CLOSE_DRAIN_SECONDS}s drain"
142
+ end
143
+ p = @pointer
144
+ @pointer = FFI::Pointer::NULL
145
+ p
146
+ end
147
+ Native.asherah_session_free(ptr)
148
+ end
149
+
150
+ def closed?
151
+ @pointer.null?
152
+ end
153
+
154
+ private
155
+
156
+ # Wait up to {Session.async_timeout_seconds} for the FFI callback
157
+ # to push a result onto +queue+. On expiry, raise
158
+ # {Asherah::Error::Timeout}; the late callback (if it eventually
159
+ # fires) still decrements the pending-op counter via its `ensure`
160
+ # block, so close() can drain cleanly.
161
+ def await_async_result(queue, label)
162
+ ::Timeout.timeout(Session.async_timeout_seconds) { queue.pop }
163
+ rescue ::Timeout::Error
164
+ raise Asherah::Error::Timeout,
165
+ "#{label} timed out after #{Session.async_timeout_seconds}s"
166
+ end
167
+
168
+ def decrement_pending_ops
169
+ @close_mu.synchronize { @pending_ops -= 1 }
170
+ end
171
+
172
+ def thread_local_buffer
173
+ Thread.current[:asherah_buffer] ||= Native::AsherahBuffer.new
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native"
4
+ require_relative "session"
5
+
6
+ module Asherah
7
+ class SessionFactory
8
+ def initialize(pointer)
9
+ raise Asherah::Error::BadConfig, Native.last_error if pointer.null?
10
+ @pointer = pointer
11
+ @close_mu = Mutex.new
12
+ end
13
+
14
+ def get_session(partition_id)
15
+ raise Asherah::Error::NotInitialized, "factory closed" if @pointer.null?
16
+ id = String(partition_id)
17
+ raise ArgumentError, "partition_id cannot be empty" if id.empty?
18
+ Session.new(Native.asherah_factory_get_session(@pointer, id))
19
+ end
20
+
21
+ def close
22
+ ptr = @close_mu.synchronize do
23
+ return if @pointer.null?
24
+ p = @pointer
25
+ @pointer = FFI::Pointer::NULL
26
+ p
27
+ end
28
+ Native.asherah_factory_free(ptr)
29
+ end
30
+
31
+ def closed?
32
+ @pointer.null?
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asherah
4
- VERSION = '0.9.1'
4
+ VERSION = "0.10.0"
5
5
  end