nl 0.2.4 → 0.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 125daafb96b4c4e1147421c6482219e9158ad31d0827859a61c86d97f506c62a
4
- data.tar.gz: ed544d00ae8fa14525e75e913bf3e6e36931c520d9dbdc86d79a10dcbf859310
3
+ metadata.gz: 06d7f10231a9e4b6ce47149f17099f6c72eaad8c72101ac768d3413ee1a8b12b
4
+ data.tar.gz: 9ecf3d32020db7cebc41de402a6e47b524aa3146db8d935cdba467d31faeda6d
5
5
  SHA512:
6
- metadata.gz: d9af220cb3e60bb8a30124c50304a8916679ca5f2b559ecc17828312a522ad73244e5ab9ff22b337641be93a9907f86790b0c6a9e2118533abd853e687d77349
7
- data.tar.gz: d35efcdb26611ca0eba7d422280c96caafee84b39e988a453dd3c3ef23a05294a5d767e1b9f0cd515e0d3224ba04ff8a345b7edd759348dbd095d20d47cc2506
6
+ metadata.gz: 4ec97708c16968c722830441a0755419ebb38e5740c11a996d4ea7e6f9f07dec1803877159eb248096b069c4c1808398777fd13aff5c6aa92a8dafc47d901bf7
7
+ data.tar.gz: 112cfdc2829d1e6f135995315cf1fa3ef3b3aa81e1ba5ac47da3011e029113bb04a6a6c2e4a369346a38cb08a8735bb021d52b1eba0a0d540d31559f6337e71c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.3.0 (2026-09-01)
6
+
7
+ - Fix encoding of nested attributes and mark them with `NLA_F_NESTED`.
8
+ - Support encoding and decoding of nested type-value attributes.
9
+ - Support multiplexing requests via `Family.async`
10
+ - Support receiving notifications (unsolicited messages).
11
+ - Rename `Genl::Connection#open` to `Genl::Connection#family`.
12
+
5
13
  ## v0.2.4 (2026-08-22)
6
14
 
7
15
  ## v0.2.3 (2026-03-24)
@@ -0,0 +1,199 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative '../datagram'
4
+ require_relative '../error'
5
+ require_relative '../exchange'
6
+ require_relative '../notification_router'
7
+ require_relative '../sequence_allocator'
8
+
9
+ module Nl
10
+ module Async
11
+ # Owns the single receive loop for a socket and routes replies by sequence.
12
+ class Dispatcher
13
+ Pending = Data.define(:exchange, :protocol, :reply_class, :sink)
14
+ private_constant :Pending
15
+
16
+ def initialize(socket, executor: :thread, notifications:)
17
+ @socket = socket
18
+ @sequences = SequenceAllocator.new
19
+ @notifications = notifications
20
+ @driver = executor.respond_to?(:start) ? executor : Async.driver(executor)
21
+ @mutex = Mutex.new
22
+ @send_mutex = Mutex.new
23
+ @pending = {}
24
+ @closed = false
25
+ @task = @driver.start { receive_loop }
26
+ end
27
+
28
+ def exchange(protocol, kind, request_class, reply_class, args, &block)
29
+ operation = exchange_async(protocol, kind, request_class, reply_class, args)
30
+ if kind == :dump
31
+ if block
32
+ operation.each(&block)
33
+ nil
34
+ else
35
+ operation.to_a
36
+ end
37
+ else
38
+ operation.await
39
+ end
40
+ end
41
+
42
+ def exchange_async(protocol, kind, request_class, reply_class, args, stream_capacity: nil)
43
+ request = protocol.build_request(kind, request_class, args)
44
+
45
+ key = nil
46
+ seq = pid = nil
47
+ operation = sink = nil
48
+ @send_mutex.synchronize do
49
+ @mutex.synchronize do
50
+ raise ClosedError, 'dispatcher is closed' if @closed
51
+
52
+ pid = @socket.local_port_id
53
+ seq = @sequences.next { @pending.key?([it, pid]) }
54
+ key = [seq, pid]
55
+ mailbox = Mailbox.new(capacity: stream_capacity)
56
+ operation, sink = if kind == :dump
57
+ Stream.build(mailbox:, on_close: -> { discard(key) })
58
+ else
59
+ Future.build(mailbox:, on_close: -> { discard(key) })
60
+ end
61
+ exchange = Exchange.new(kind:, expects_reply: !reply_class.nil?)
62
+ @pending[key] = Pending.new(exchange:, protocol:, reply_class:, sink:)
63
+ end
64
+
65
+ begin
66
+ protocol.send_message(@socket, request, seq:, pid:)
67
+ rescue Exception => error
68
+ fail_pending(key, error)
69
+ raise
70
+ end
71
+ end
72
+ operation
73
+ end
74
+
75
+ def async_capable? #: true
76
+ true
77
+ end
78
+
79
+ def receive_notification(protocol, timeout: nil)
80
+ @notifications.channel(protocol).pop(timeout:)
81
+ end
82
+
83
+ def close
84
+ task = @mutex.synchronize do
85
+ next if @closed
86
+
87
+ @closed = true
88
+ @socket.close unless @socket.closed?
89
+ @task
90
+ end
91
+ fail_all(ClosedError.new('dispatcher is closed'))
92
+ @driver.stop(task) if task && !task.equal?(::Thread.current)
93
+ nil
94
+ end
95
+
96
+ private def receive_loop
97
+ loop do
98
+ break if @mutex.synchronize { @closed }
99
+
100
+ @socket.wait_readable
101
+ data = @socket.recvmsg_nonblock(exception: false)
102
+ next if data == :wait_readable
103
+
104
+ dispatch_datagram(IO::Buffer.for(data.first))
105
+ rescue Errno::ENOBUFS => e
106
+ @notifications.lose_all(NotificationLossError.new('kernel receive buffer overflowed'))
107
+ fail_all(e)
108
+ end
109
+ rescue Exception => e
110
+ fail_receive_loop(e)
111
+ end
112
+
113
+ private def fail_receive_loop(error)
114
+ failed = @mutex.synchronize do
115
+ next false if @closed
116
+
117
+ @closed = true
118
+ @socket.close unless @socket.closed?
119
+ true
120
+ end
121
+ return unless failed
122
+
123
+ fail_all(error)
124
+ @notifications.close
125
+ end
126
+
127
+ private def dispatch_datagram(buffer)
128
+ Datagram.each_frame(buffer) do |header, payload|
129
+ key = [header.seq, header.pid]
130
+ pending = @mutex.synchronize { @pending[key] }
131
+ unless pending
132
+ @notifications.route(header, payload) if header.seq.zero?
133
+ next
134
+ end
135
+
136
+ begin
137
+ message = pending.protocol.decode_frame(header, payload, pending.reply_class)
138
+ dispatch_outcome(key, pending, pending.exchange.accept(message))
139
+ rescue Exception => error
140
+ fail_pending(key, error) if pending
141
+ end
142
+ end
143
+ end
144
+
145
+ private def dispatch_outcome(key, pending, outcome)
146
+ return unless outcome
147
+
148
+ case outcome
149
+ when Exchange::Item
150
+ begin
151
+ pending.sink.push(outcome.value)
152
+ rescue Mailbox::FullError
153
+ fail_pending(key, StreamOverflowError.new("reply buffer exceeded for sequence #{key.first}"))
154
+ end
155
+ when Exchange::Complete
156
+ complete_pending(key, pending, pending.exchange.result)
157
+ when Exchange::Failure
158
+ fail_pending(key, outcome.exception)
159
+ end
160
+ end
161
+
162
+ private def complete_pending(key, pending, value)
163
+ removed = @mutex.synchronize { @pending.delete(key) }
164
+ return unless removed
165
+ return if pending.exchange.cancelled?
166
+
167
+ if pending.exchange.kind == :dump
168
+ pending.sink.finish
169
+ elsif pending.exchange.expects_reply?
170
+ pending.sink.succeed(value)
171
+ else
172
+ pending.sink.finish
173
+ end
174
+ end
175
+
176
+ private def discard(key)
177
+ @mutex.synchronize do
178
+ if pending = @pending[key]
179
+ pending.exchange.cancel
180
+ end
181
+ end
182
+ end
183
+
184
+ private def fail_pending(key, error)
185
+ pending = @mutex.synchronize { @pending.delete(key) }
186
+ pending&.sink&.fail(error) unless pending&.exchange&.cancelled?
187
+ end
188
+
189
+ private def fail_all(error)
190
+ pending = @mutex.synchronize do
191
+ old = @pending.values
192
+ @pending.clear
193
+ old
194
+ end
195
+ pending.each { it.sink.fail(error) unless it.exchange.cancelled? }
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,39 @@
1
+ # rbs_inline: enabled
2
+
3
+ module Nl
4
+ module Async
5
+ module Drivers
6
+ class Thread
7
+ def start(&block)
8
+ ::Thread.new(&block)
9
+ end
10
+
11
+ def stop(task)
12
+ task.join
13
+ end
14
+ end
15
+
16
+ class Fiber
17
+ def start(&block)
18
+ raise ArgumentError, 'Fiber.scheduler is not installed' unless ::Fiber.scheduler
19
+
20
+ ::Fiber.schedule(&block)
21
+ end
22
+
23
+ def stop(_task)
24
+ # Scheduled fibers have no general join operation. Closing the socket
25
+ # makes the receive loop terminate at its next scheduling point.
26
+ end
27
+ end
28
+ end
29
+
30
+ def self.driver(name)
31
+ case name
32
+ when :thread then Drivers::Thread.new
33
+ when :fiber then Drivers::Fiber.new
34
+ else
35
+ raise ArgumentError, "unknown async executor: #{name.inspect}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,83 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative '../error'
4
+
5
+ module Nl
6
+ module Async
7
+ # A thread-safe, scheduler-aware, single-consumer mailbox.
8
+ class Mailbox
9
+ class FullError < StandardError; end
10
+
11
+ def initialize(capacity: nil)
12
+ raise ArgumentError, 'capacity must be positive' if capacity && capacity <= 0
13
+
14
+ @mutex = Mutex.new
15
+ @condition = ConditionVariable.new
16
+ @queue = []
17
+ @closed = false
18
+ @capacity = capacity
19
+ end
20
+
21
+ # Adds a value without executing consumer code.
22
+ # @rbs (untyped) -> bool
23
+ def push(value)
24
+ push_value(value, enforce_capacity: true)
25
+ end
26
+
27
+ # Adds a completion/error event even when the value capacity is full.
28
+ def push_terminal(value)
29
+ push_value(value, enforce_capacity: false)
30
+ end
31
+
32
+ private def push_value(value, enforce_capacity:)
33
+ @mutex.synchronize do
34
+ return false if @closed
35
+ if enforce_capacity && @capacity && @queue.length >= @capacity
36
+ raise FullError, 'mailbox capacity exceeded'
37
+ end
38
+
39
+ wake = @queue.empty?
40
+ @queue << value
41
+ @condition.signal if wake
42
+ end
43
+ true
44
+ end
45
+
46
+ # Removes the next value, suspending through the current thread or Fiber scheduler if empty.
47
+ # @rbs (?timeout: Numeric?) -> untyped
48
+ def pop(timeout: nil)
49
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
50
+
51
+ @mutex.synchronize do
52
+ loop do
53
+ return @queue.shift unless @queue.empty?
54
+ raise ClosedError if @closed
55
+
56
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+ raise TimeoutError if remaining && remaining <= 0
58
+
59
+ @condition.wait(@mutex, remaining)
60
+ end
61
+ end
62
+ end
63
+
64
+ def close
65
+ @mutex.synchronize do
66
+ return nil if @closed
67
+
68
+ @closed = true
69
+ @condition.broadcast
70
+ end
71
+ nil
72
+ end
73
+
74
+ def closed?
75
+ @mutex.synchronize { @closed }
76
+ end
77
+
78
+ def empty?
79
+ @mutex.synchronize { @queue.empty? }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,279 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative '../error'
4
+
5
+ module Nl
6
+ module Async
7
+ module Events
8
+ Item = Data.define(:value)
9
+ Done = Data.define
10
+ Error = Data.define(:exception)
11
+ end
12
+ private_constant :Events
13
+
14
+ class ConcurrentConsumptionError < StandardError; end
15
+ class StreamOverflowError < StandardError; end
16
+
17
+ # Future for the result of an already-started, single-reply operation.
18
+ # @rbs generic out Result
19
+ class Future
20
+ def self.build(mailbox: Mailbox.new, on_close: nil)
21
+ operation = new(mailbox:, on_close:)
22
+ [operation, Sink.new(mailbox)]
23
+ end
24
+
25
+ def initialize(mailbox:, on_close:)
26
+ @mailbox = mailbox
27
+ @on_close = on_close
28
+ @mutex = Mutex.new
29
+ @state = :open
30
+ @value = nil
31
+ @error = nil
32
+ end
33
+
34
+ # @rbs (?timeout: Numeric?) -> Result
35
+ def await(timeout: nil)
36
+ return terminal_result unless @mutex.synchronize { @state == :open }
37
+
38
+ event = @mailbox.pop(timeout:)
39
+ case event
40
+ when Events::Item
41
+ settle_done(event.value)
42
+ when Events::Done
43
+ settle_done(nil)
44
+ when Events::Error
45
+ settle_failed(event.exception)
46
+ end
47
+ @mailbox.close
48
+ terminal_result
49
+ rescue ClosedError
50
+ terminal_result
51
+ end
52
+ alias value await
53
+
54
+ def ready? #: bool
55
+ @mutex.synchronize { @state != :open } || !@mailbox.empty?
56
+ end
57
+
58
+ # Stops local delivery. The dispatcher still drains the Netlink exchange.
59
+ def close #: nil
60
+ closed, callback = @mutex.synchronize do
61
+ next [false, nil] if @state != :open
62
+
63
+ @state = :closed
64
+ [true, @on_close]
65
+ end
66
+ if closed
67
+ callback&.call
68
+ @mailbox.close
69
+ end
70
+ nil
71
+ end
72
+
73
+ private def settle_done(value)
74
+ @mutex.synchronize do
75
+ if @state == :open
76
+ @state = :done
77
+ @value = value
78
+ end
79
+ end
80
+ end
81
+
82
+ private def settle_failed(error)
83
+ @mutex.synchronize do
84
+ if @state == :open
85
+ @state = :failed
86
+ @error = error
87
+ end
88
+ end
89
+ end
90
+
91
+ private def terminal_result
92
+ state, value, error = @mutex.synchronize { [@state, @value, @error] }
93
+ case state
94
+ when :done then value
95
+ when :failed then raise error
96
+ else raise ClosedError, 'future is closed'
97
+ end
98
+ end
99
+
100
+ class Sink
101
+ def initialize(mailbox)
102
+ @mailbox = mailbox
103
+ end
104
+
105
+ def succeed(value) = @mailbox.push(Events::Item.new(value))
106
+ def finish = @mailbox.push_terminal(Events::Done.new)
107
+ def fail(exception) = @mailbox.push_terminal(Events::Error.new(exception))
108
+ end
109
+ end
110
+
111
+ # Single-pass consumer handle for a multipart Netlink operation.
112
+ # @rbs generic out Item
113
+ class Stream
114
+ include Enumerable #[Item]
115
+
116
+ def self.build(mailbox: Mailbox.new, on_close: nil)
117
+ operation = new(mailbox:, on_close:)
118
+ [operation, Sink.new(mailbox)]
119
+ end
120
+
121
+ def initialize(mailbox:, on_close:)
122
+ @mailbox = mailbox
123
+ @on_close = on_close
124
+ @mutex = Mutex.new
125
+ @owner = nil
126
+ @state = :open
127
+ @error = nil
128
+ end
129
+
130
+ # @rbs () { (Item) -> void } -> self
131
+ # | () -> Enumerator[Item, self]
132
+ def each
133
+ return enum_for(__method__) unless block_given?
134
+
135
+ state, error = current_state
136
+ case state
137
+ when :completed then return self
138
+ when :closed then raise ClosedError, 'stream is closed'
139
+ when :failed then raise error
140
+ end
141
+
142
+ claim_consumer!
143
+ begin
144
+ loop do
145
+ case event = @mailbox.pop
146
+ when Events::Item
147
+ yield event.value
148
+ when Events::Done
149
+ completed = mark_completed
150
+ @mailbox.close
151
+ terminal_result unless completed
152
+ break
153
+ when Events::Error
154
+ failed = mark_failed(event.exception)
155
+ @mailbox.close
156
+ raise event.exception if failed
157
+
158
+ terminal_result
159
+ end
160
+ end
161
+ self
162
+ rescue ClosedError
163
+ terminal_result
164
+ ensure
165
+ close if open?
166
+ end
167
+ end
168
+
169
+ # @rbs (?timeout: Numeric?) -> Item
170
+ def next(timeout: nil)
171
+ state, error = current_state
172
+ case state
173
+ when :completed then raise StopIteration
174
+ when :closed then raise ClosedError, 'stream is closed'
175
+ when :failed then raise error
176
+ end
177
+
178
+ claim_consumer!
179
+ case event = @mailbox.pop(timeout:)
180
+ when Events::Item
181
+ event.value
182
+ when Events::Done
183
+ completed = mark_completed
184
+ @mailbox.close
185
+ raise StopIteration if completed
186
+
187
+ terminal_result(next_item: true)
188
+ when Events::Error
189
+ failed = mark_failed(event.exception)
190
+ @mailbox.close
191
+ raise event.exception if failed
192
+
193
+ terminal_result(next_item: true)
194
+ end
195
+ rescue ClosedError
196
+ terminal_result(next_item: true)
197
+ end
198
+
199
+ # Stops local delivery. The dispatcher still drains the Netlink exchange.
200
+ def close #: nil
201
+ closed, callback = @mutex.synchronize do
202
+ next [false, nil] if @state != :open
203
+
204
+ @state = :closed
205
+ [true, @on_close]
206
+ end
207
+ if closed
208
+ callback&.call
209
+ @mailbox.close
210
+ end
211
+ nil
212
+ end
213
+
214
+ def closed? #: bool
215
+ @mutex.synchronize { @state != :open }
216
+ end
217
+
218
+ private def current_state
219
+ @mutex.synchronize { [@state, @error] }
220
+ end
221
+
222
+ private def open?
223
+ @mutex.synchronize { @state == :open }
224
+ end
225
+
226
+ private def claim_consumer!
227
+ owner = Fiber.current
228
+ @mutex.synchronize do
229
+ @owner ||= owner
230
+ unless @owner.equal?(owner)
231
+ raise ConcurrentConsumptionError, 'a stream can only be consumed by one fiber'
232
+ end
233
+ end
234
+ end
235
+
236
+ private def mark_completed
237
+ @mutex.synchronize do
238
+ next false unless @state == :open
239
+
240
+ @state = :completed
241
+ true
242
+ end
243
+ end
244
+
245
+ private def mark_failed(error)
246
+ @mutex.synchronize do
247
+ next false unless @state == :open
248
+
249
+ @state = :failed
250
+ @error = error
251
+ true
252
+ end
253
+ end
254
+
255
+ private def terminal_result(next_item: false)
256
+ state, error = current_state
257
+ case state
258
+ when :completed
259
+ raise StopIteration if next_item
260
+ self
261
+ when :failed
262
+ raise error
263
+ else
264
+ raise ClosedError, 'stream is closed'
265
+ end
266
+ end
267
+
268
+ class Sink
269
+ def initialize(mailbox)
270
+ @mailbox = mailbox
271
+ end
272
+
273
+ def push(value) = @mailbox.push(Events::Item.new(value))
274
+ def finish = @mailbox.push_terminal(Events::Done.new)
275
+ def fail(exception) = @mailbox.push_terminal(Events::Error.new(exception))
276
+ end
277
+ end
278
+ end
279
+ end
data/lib/nl/async.rb ADDED
@@ -0,0 +1,15 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative 'error'
4
+
5
+ module Nl
6
+ # Asynchronous request handling support.
7
+ module Async
8
+ class UnavailableError < StandardError; end
9
+ end
10
+ end
11
+
12
+ require_relative 'async/mailbox'
13
+ require_relative 'async/operation'
14
+ require_relative 'async/driver'
15
+ require_relative 'async/dispatcher'