ace-herdr 0.4.0 → 0.5.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +18 -0
  4. data/docs/usage.md +57 -35
  5. data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli.rb +3 -0
  8. data/lib/ace/herdr/errors.rb +23 -0
  9. data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
  10. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
  11. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  12. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  13. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  14. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  15. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  16. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  17. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  18. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  19. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  20. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  21. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  22. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  24. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  25. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  26. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  27. data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
  28. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  29. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  30. data/lib/ace/herdr/native_source/selection.json +24 -0
  31. data/lib/ace/herdr/organisms/inbox.rb +293 -203
  32. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  33. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  34. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  35. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  36. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  37. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  38. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  39. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  40. data/lib/ace/herdr/version.rb +1 -1
  41. data/lib/ace/herdr.rb +5 -28
  42. metadata +68 -9
  43. data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../molecules/inbox_direct_effect_binding"
3
+ require_relative "../molecules/inbox_direct_result"
4
+
5
+ require_relative "../molecules/protected_native_control"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Organisms
10
+ # Fixed methods on the same context admission and DeliveryRecord owner.
11
+ module InboxContextDirectEffects
12
+ def enqueue_context(operation_id:, key_generation:, event_id:, attempt_id:, reverse:, payload_bytes:, payload_sha256:, payload:, original:, peer:)
13
+ authorize!(peer, purpose: "enqueue")
14
+ binding = Molecules::InboxDirectEffectBinding.build(purpose: "enqueue", event_id: event_id,
15
+ attempt_id: attempt_id, key_generation: key_generation, original: original,
16
+ selection: {"reverse" => reverse, "payload_bytes" => payload_bytes, "payload_sha256" => payload_sha256})
17
+ unless payload.is_a?(String) && payload.bytesize == payload_bytes &&
18
+ payload.encoding == Encoding::UTF_8 && payload.valid_encoding? && !payload.include?("\0") &&
19
+ Digest::SHA256.hexdigest(payload) == payload_sha256
20
+ raise ValidationError, "direct enqueue payload differs"
21
+ end
22
+ selected = @keys.selected
23
+ original_query = direct_original_query!(operation_id, binding, selected, peer, payload_sha256: payload_sha256)
24
+ unless original_query.fetch("process_binding").values_at("session", "pane") == reverse.values_at("session", "pane")
25
+ raise ValidationError, "direct reverse address differs from original launch"
26
+ end
27
+ dispatch = false
28
+ transaction do |state|
29
+ operation = operation!(state, operation_id, peer)
30
+ unless operation.values_at("purpose", "event_id", "key_generation") == ["enqueue", event_id, key_generation] && operation.fetch("original") == original.merge("attempt_id" => attempt_id) &&
31
+ selected.fetch(:snapshot) == state.fetch("key")
32
+ raise ValidationError, "direct enqueue admission differs"
33
+ end
34
+ if operation["original_binding_digest"] && operation.fetch("original_binding_digest") != original_query.fetch("original_binding_digest")
35
+ raise ValidationError, "direct original launch record changed"
36
+ end
37
+ if operation["effect_binding"]
38
+ raise ValidationError, "direct enqueue input changed" unless operation.fetch("effect_binding") == binding
39
+ raise ValidationError, "direct enqueue issuer remains unresolved" if @effect_issuers.key?(operation_id) || operation.fetch("in_flight") == 1
40
+ direct_idle_readback!(state, operation)
41
+ else
42
+ raise ValidationError, "direct enqueue admission is uncertain" unless operation.fetch("in_flight").zero?
43
+ operation["original_binding_digest"] = original_query.fetch("original_binding_digest")
44
+ operation["effect_binding"] = binding
45
+ operation["in_flight"] = 1
46
+ operation["issuer_state"] = "running"
47
+ operation["issuer_epoch"] = @epoch
48
+ @effect_issuers[operation_id] = Thread.current
49
+ dispatch = true
50
+ end
51
+ end
52
+ box = source_inbox!(selected)
53
+ if dispatch
54
+ box.enqueue(event: event_id, attempt: attempt_id, ref: reverse, payload: payload, original_context: original, original_binding_digest: original_query.fetch("original_binding_digest"))
55
+ record_direct_return!(operation_id, peer, binding)
56
+ end
57
+ result = box.verify_direct_enqueue(binding)
58
+ if dispatch
59
+ transaction do |state|
60
+ operation = operation!(state, operation_id, peer)
61
+ unless operation["effect_binding"] == binding && @effect_issuers[operation_id].equal?(Thread.current) &&
62
+ selected.fetch(:snapshot) == state.fetch("key")
63
+ raise ValidationError, "direct enqueue completion changed"
64
+ end
65
+ direct_idle_readback!(state, operation)
66
+ operation["in_flight"] = 0
67
+ end
68
+ end
69
+ immutable_effect(Molecules::InboxDirectResult.build(operation: "enqueue_context", record: result, admission_state: "idle"))
70
+ ensure
71
+ if dispatch
72
+ transaction { |_state| @effect_issuers.delete(operation_id) if @effect_issuers[operation_id].equal?(Thread.current) }
73
+ end
74
+ end
75
+
76
+ def deliver_context(operation_id:, key_generation:, event_id:, attempt_id:, expected_claim_generation:, original:, peer:)
77
+ authorize!(peer, purpose: "deliver")
78
+ binding = Molecules::InboxDirectEffectBinding.build(purpose: "deliver", event_id: event_id,
79
+ attempt_id: attempt_id, key_generation: key_generation, original: original,
80
+ selection: {"expected_claim_generation" => expected_claim_generation})
81
+ selected = @keys.selected
82
+ record = source_inbox!(selected).retained_status(event: event_id)
83
+ original_query = direct_original_query!(operation_id, binding, selected, peer, payload_sha256: record.fetch("payload_sha256"))
84
+ unless record.fetch("original_binding_digest") == original_query.fetch("original_binding_digest")
85
+ raise ValidationError, "direct retained original record differs"
86
+ end
87
+ dispatch = false
88
+ transaction do |state|
89
+ operation = operation!(state, operation_id, peer)
90
+ unless operation.values_at("purpose", "event_id", "key_generation") == ["deliver", event_id, key_generation] && operation.fetch("original") == original.merge("attempt_id" => attempt_id) &&
91
+ selected.fetch(:snapshot) == state.fetch("key")
92
+ raise ValidationError, "direct delivery admission differs"
93
+ end
94
+ direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
95
+ if operation["original_binding_digest"] && operation.fetch("original_binding_digest") != original_query.fetch("original_binding_digest")
96
+ raise ValidationError, "direct original launch record changed"
97
+ end
98
+ if operation["effect_binding"]
99
+ raise ValidationError, "direct delivery input changed" unless operation.fetch("effect_binding") == binding
100
+ raise ValidationError, "direct delivery issuer remains live" if @effect_issuers.key?(operation_id)
101
+ # Unknown replay is observation only; it cannot become known idle.
102
+ direct_idle_readback!(state, operation) if operation.fetch("in_flight").zero?
103
+ else
104
+ raise ValidationError, "direct delivery admission is uncertain" unless operation.fetch("in_flight").zero?
105
+ source_inbox!(selected).verify_direct_delivery(binding)
106
+ operation["original_binding_digest"] = original_query.fetch("original_binding_digest")
107
+ operation["effect_binding"] = binding
108
+ operation["in_flight"] = 1
109
+ operation["issuer_state"] = "running"
110
+ operation["issuer_epoch"] = @epoch
111
+ @effect_issuers[operation_id] = Thread.current
112
+ dispatch = true
113
+ end
114
+ end
115
+ box = source_inbox!(selected)
116
+ if dispatch
117
+ transaction do |state|
118
+ operation = operation!(state, operation_id, peer)
119
+ unless operation["effect_binding"] == binding && operation["issuer_state"] == "running" &&
120
+ @effect_issuers[operation_id].equal?(Thread.current)
121
+ raise ValidationError, "direct claim preparation ownership differs"
122
+ end
123
+ direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
124
+ operation["admitted_claim"] = box.prepare_direct_delivery(event: event_id,
125
+ expected_claim_generation: expected_claim_generation, expected_attempt: attempt_id,
126
+ claim_owner: Digest::SHA256.hexdigest(JSON.generate([@context_id, operation_id])),
127
+ operation_id: operation_id, key_generation: key_generation, effect_binding: binding)
128
+ end
129
+ claim = transaction { |state| copy(operation!(state, operation_id, peer)["admitted_claim"]) }
130
+ unless claim && claim.fetch("kind") == "wake"
131
+ box.deliver(event: event_id, expected_claim_generation: expected_claim_generation,
132
+ expected_attempt: attempt_id, prepared_claim: claim && claim.slice("claim_generation", "claim_owner")) do |text|
133
+ native_binding = original_query.fetch("process_binding").merge("guarded_origin" => original_query.fetch("guarded_origin"))
134
+ direct_queue_control!(original_query).inbox_prompt(binding: native_binding, text: text)
135
+ end
136
+ end
137
+ direct_guarded_wake!(operation_id, peer, binding, claim, original_query, box) if claim
138
+ record_direct_return!(operation_id, peer, binding)
139
+ end
140
+ verified = box.verify_direct_delivery(binding)
141
+ transaction do |state|
142
+ operation = operation!(state, operation_id, peer)
143
+ unless operation["effect_binding"] == binding && selected.fetch(:snapshot) == state.fetch("key")
144
+ raise ValidationError, "direct delivery completion changed"
145
+ end
146
+ if dispatch && verified.fetch("idle") && @effect_issuers[operation_id].equal?(Thread.current)
147
+ direct_idle_readback!(state, operation)
148
+ operation["in_flight"] = 0
149
+ end
150
+ Molecules::InboxDirectResult.build(operation: "deliver_context", record: verified.fetch("record"),
151
+ admission_state: operation.fetch("in_flight").zero? ? "idle" : "unknown")
152
+ end
153
+ ensure
154
+ if dispatch
155
+ transaction { |_state| @effect_issuers.delete(operation_id) if @effect_issuers[operation_id].equal?(Thread.current) }
156
+ end
157
+ end
158
+
159
+ def status_context(event_id:, attempt_id:, original:, peer:)
160
+ grant = authorize!(peer)
161
+ unless (grant.fetch("purposes") & %w[enqueue deliver]).any?
162
+ raise ValidationError, "context event observation is unauthorized"
163
+ end
164
+ token!(event_id); token!(attempt_id)
165
+ Molecules::InboxDirectEffectBinding.original!(original)
166
+ context!(original.fetch("inbox_context_id"))
167
+ record = source_inbox!(@keys.selected).retained_status(event: event_id)
168
+ raise ValidationError, "context event attempt differs" unless record.fetch("attempt_id") == attempt_id && record.fetch("original_context") == original
169
+ immutable_effect(Molecules::InboxDirectResult.build(operation: "status_context", record: record))
170
+ end
171
+
172
+ private
173
+
174
+ # Remote canonical identity read occurs outside the store transaction.
175
+ # Both sides recheck the original durable admission/key before effects.
176
+ def direct_original_query!(operation_id, binding, selected, peer, payload_sha256:)
177
+ transaction do |state|
178
+ operation = operation!(state, operation_id, peer)
179
+ unless selected.fetch(:snapshot) == state.fetch("key") &&
180
+ operation.fetch("original") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id") &&
181
+ operation.values_at("purpose", "event_id") == binding.values_at("purpose", "event_id")
182
+ raise ValidationError, "original query admission differs"
183
+ end
184
+ end
185
+ result = @completion.original!(assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
186
+ event_id: binding.fetch("event_id"), inbox_context_id: binding.fetch("inbox_context_id"), purpose: binding.fetch("purpose"),
187
+ payload_sha256: payload_sha256)
188
+ unless result.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id", "event_id", "purpose") ==
189
+ binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id", "event_id", "purpose")
190
+ raise ValidationError, "original authority association differs"
191
+ end
192
+ result
193
+ rescue NoMethodError, KeyError, TypeError
194
+ raise ValidationError, "original authority identity is unavailable"
195
+ end
196
+
197
+ def direct_queue_control!(original)
198
+ native = original.fetch("native_binding")
199
+ server = native.fetch("server_identity")
200
+ mapping = {"worker_uid" => server.fetch("uid"), "worker_gid" => server.fetch("gid"), "worker_groups" => server.fetch("groups"),
201
+ "native" => original.fetch("native_channel").merge("server_identity" => server, "socket_identity" => native.fetch("socket_identity"))}
202
+ Molecules::ProtectedNativeControl.new(mapping: mapping, kernel: @kernel)
203
+ end
204
+
205
+ def direct_guarded_wake!(operation_id, peer, binding, claim, original, box)
206
+ return unless box.guarded_wake_pending?(event: binding.fetch("event_id"), binding: binding, claim: claim)
207
+ control = direct_queue_control!(original)
208
+ native_binding = original.fetch("process_binding").merge("guarded_origin" => original.fetch("guarded_origin"))
209
+ begin
210
+ control.prompt_preflight!(native_binding)
211
+ rescue Ace::Runtime::RuntimeUnavailableError
212
+ # This readonly preflight never entered notification IO. Pending
213
+ # stays positively unissued; a fresh explicit retry owns recovery.
214
+ return
215
+ end
216
+ issuing = transaction do |state|
217
+ operation = operation!(state, operation_id, peer)
218
+ unless operation["admitted_claim"] == claim && operation["effect_binding"] == binding &&
219
+ operation["issuer_state"] == "running" && @effect_issuers[operation_id].equal?(Thread.current) &&
220
+ operation.fetch("original_binding_digest") == original.fetch("original_binding_digest")
221
+ raise ValidationError, "guarded wake issuer changed"
222
+ end
223
+ direct_delivery_predecessors!(state, operation_id, binding.fetch("event_id"), operation.fetch("key_generation"))
224
+ box.prepare_guarded_wake(event: binding.fetch("event_id"), binding: binding, claim: claim, operation_id: operation_id)
225
+ end
226
+ return unless issuing
227
+ result = control.queue_wake(binding: native_binding)
228
+ transaction do |state|
229
+ operation = operation!(state, operation_id, peer)
230
+ unless operation["admitted_claim"] == claim && operation["effect_binding"] == binding &&
231
+ operation["issuer_state"] == "running" && @effect_issuers[operation_id].equal?(Thread.current)
232
+ raise ValidationError, "guarded wake finish admission changed"
233
+ end
234
+ box.finish_guarded_wake(event: binding.fetch("event_id"), binding: binding, claim: claim, issuing: issuing, result: result)
235
+ end
236
+ end
237
+
238
+ def record_direct_return!(operation_id, peer, binding)
239
+ transaction do |state|
240
+ operation = operation!(state, operation_id, peer)
241
+ unless operation["effect_binding"] == binding && operation["issuer_state"] == "running" &&
242
+ @effect_issuers[operation_id].equal?(Thread.current)
243
+ raise ValidationError, "direct issuer return ownership differs"
244
+ end
245
+ operation["issuer_state"] = "returned"
246
+ end
247
+ end
248
+
249
+ def direct_binding?(operation)
250
+ operation.dig("effect_binding", "schema") == Molecules::InboxDirectEffectBinding::SCHEMA
251
+ end
252
+
253
+ def direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
254
+ state.fetch("operations").each do |id, other|
255
+ next if id == operation_id || other.fetch("event_id") != event_id || other.fetch("key_generation") != key_generation
256
+ if other.fetch("in_flight") == 1 || @effect_issuers.key?(id)
257
+ raise ValidationError, "direct delivery preceding issuer remains unresolved"
258
+ end
259
+ end
260
+ end
261
+
262
+ def direct_idle_readback!(state, operation)
263
+ selected = @keys.selected
264
+ raise ValidationError, "direct readback key changed" unless selected.fetch(:snapshot) == state.fetch("key")
265
+ binding = Molecules::InboxDirectEffectBinding.verify!(operation.fetch("effect_binding"), key_generation: operation.fetch("key_generation"))
266
+ box = source_inbox!(selected)
267
+ record = if binding.fetch("purpose") == "enqueue"
268
+ box.verify_direct_enqueue(binding)
269
+ else
270
+ claim = operation["admitted_claim"]
271
+ if claim && claim["kind"] == "wake"
272
+ operation_id = state.fetch("operations").find { |_id, retained| retained.equal?(operation) }&.first
273
+ box.verify_guarded_wake_admission!(binding: binding, claim: claim, operation_id: operation_id)
274
+ end
275
+ box.verify_direct_delivery(binding, require_idle: true).fetch("record")
276
+ end
277
+ unless record.fetch("original_binding_digest") == operation.fetch("original_binding_digest")
278
+ raise ValidationError, "direct readback original record changed"
279
+ end
280
+ record
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/inbox_context_completion_client"
4
+
5
+ module Ace
6
+ module Herdr
7
+ module Organisms
8
+ # Source effect methods on the same context owner and admission metadata.
9
+ module InboxContextEffects
10
+ SNAPSHOT_FIELDS = %w[event_id attempt_id payload_sha256 claim_generation state origin_target target binding].freeze
11
+
12
+ private
13
+
14
+ def source_inbox!(selected)
15
+ raise ValidationError, "fixed context Inbox owner is unavailable" unless @inbox.is_a?(Inbox)
16
+ @inbox
17
+ end
18
+
19
+ def immutable_effect(value)
20
+ case value
21
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable_effect(item)] }.freeze
22
+ when Array then value.map { |item| immutable_effect(item) }.freeze
23
+ when String then value.dup.freeze
24
+ else value.freeze
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "inbox_context_server"
5
+ require_relative "../molecules/inbox_context_service_configuration"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Organisms
10
+ # Connection lifetimes never retire admission metadata. Stop prevents
11
+ # new ingress, then waits within one deadline for existing handlers.
12
+ class InboxContextListener
13
+ MAX_HANDLERS = 8
14
+ STOP_SECONDS = 35
15
+
16
+ def initialize(configuration:, server:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
17
+ protection: Ace::Runtime::Molecules::ProtectedSocket, handler_dispatcher: nil)
18
+ unless configuration.is_a?(Molecules::InboxContextServiceConfiguration)
19
+ raise ValidationError, "context listener configuration is not held"
20
+ end
21
+ @configuration, @server, @kernel, @protection = configuration, server, kernel, protection
22
+ @handler_dispatcher = handler_dispatcher
23
+ @path = configuration.data.fetch("control_socket_path")
24
+ @credentials = configuration.data.fetch("owner_credentials")
25
+ @mutex, @handlers, @stopping = Mutex.new, {}, false
26
+ end
27
+
28
+ def serve
29
+ me = @kernel.capture(Process.pid)
30
+ unless me.values_at("uid", "gid", "groups") == @credentials.values_at("uid", "gid", "groups")
31
+ raise ValidationError, "context listener principal differs"
32
+ end
33
+ @kernel.live!(me)
34
+ @protection.root_path!(File.dirname(@path), directory: true, owner: @credentials.fetch("uid"))
35
+ verify_socket_traversal!
36
+ @lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
37
+ stat = @lock.stat
38
+ unless stat.file? && stat.uid == @credentials.fetch("uid") && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600 &&
39
+ [stat.dev, stat.ino] == File.lstat("#{@path}.lock").then { |current| [current.dev, current.ino] } &&
40
+ @lock.flock(File::LOCK_EX | File::LOCK_NB)
41
+ raise ValidationError, "context listener lease differs or is held"
42
+ end
43
+ @lock.close_on_exec = true
44
+ remove_stale_endpoint!
45
+ @listener = UNIXServer.new(@path)
46
+ File.chown(nil, @configuration.data.fetch("socket_gid"), @path)
47
+ File.chmod(0o660, @path)
48
+ @identity = @protection.socket_identity(@path)
49
+ until stopping?
50
+ socket = @listener.accept
51
+ accepted_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
52
+ @mutex.synchronize do
53
+ if @stopping || @handlers.size >= MAX_HANDLERS
54
+ socket.close
55
+ else
56
+ gate = Queue.new
57
+ thread = Thread.new(socket, accepted_deadline) do |connection, operation_deadline|
58
+ gate.pop
59
+ begin
60
+ if @handler_dispatcher
61
+ @handler_dispatcher.call(deadline: operation_deadline) { @server.handle(connection) }
62
+ else
63
+ @server.handle(connection)
64
+ end
65
+ rescue ValidationError, Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
66
+ # Wire loss preserves the owner's retained operation state.
67
+ nil
68
+ ensure
69
+ connection.close
70
+ @mutex.synchronize { @handlers.delete(Thread.current) }
71
+ end
72
+ end
73
+ @handlers[thread] = socket
74
+ gate << true
75
+ end
76
+ end
77
+ end
78
+ rescue IOError, Errno::EBADF
79
+ raise unless stopping?
80
+ ensure
81
+ stop if @listener
82
+ @listener&.close
83
+ remove_owned_endpoint!
84
+ @lock&.close
85
+ end
86
+
87
+ def stop(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_SECONDS)
88
+ unless deadline.is_a?(Numeric) && deadline.finite?
89
+ raise ValidationError, "context stop deadline differs"
90
+ end
91
+ threads = @mutex.synchronize do
92
+ @stop_deadline = @stop_deadline ? [@stop_deadline, deadline].min : deadline
93
+ @stopping = true
94
+ @listener&.close
95
+ @handlers.keys
96
+ end
97
+ threads.each do |thread|
98
+ remaining = @stop_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ break unless remaining.positive?
100
+ thread.join(remaining)
101
+ end
102
+ if @mutex.synchronize { !@handlers.empty? }
103
+ raise ValidationError, "context handlers remain unresolved at stop deadline"
104
+ end
105
+ true
106
+ end
107
+
108
+ def quiescent?
109
+ @mutex.synchronize { @handlers.empty? && (!@listener || @listener.closed?) }
110
+ end
111
+
112
+ private
113
+
114
+ def stopping? = @mutex.synchronize { @stopping }
115
+
116
+ def verify_socket_traversal!
117
+ parent = File.dirname(@path)
118
+ stat = File.lstat(parent)
119
+ unless stat.gid == @configuration.data.fetch("socket_gid") && (stat.mode & 0o7777) == 0o750
120
+ raise ValidationError, "context socket parent is not the exact shared traversal directory"
121
+ end
122
+ loop do
123
+ stat = File.lstat(parent)
124
+ unless stat.directory? && !stat.symlink? && @configuration.data.fetch("grants").all? { |grant|
125
+ bit = stat.uid == grant.fetch("uid") ? 0o100 : (grant.fetch("groups") + [grant.fetch("gid")]).include?(stat.gid) ? 0o010 : 0o001
126
+ grant.fetch("uid").zero? || (stat.mode & bit).positive?
127
+ }
128
+ raise ValidationError, "context caller cannot traverse its fixed socket ancestry"
129
+ end
130
+ break if parent == "/"
131
+ parent = File.dirname(parent)
132
+ end
133
+ end
134
+
135
+ def remove_stale_endpoint!
136
+ stat = File.lstat(@path)
137
+ raise ValidationError, "context endpoint differs" unless stat.socket? && stat.uid == @credentials.fetch("uid")
138
+ File.unlink(@path)
139
+ rescue Errno::ENOENT
140
+ nil
141
+ end
142
+
143
+ def remove_owned_endpoint!
144
+ File.unlink(@path) if @identity && @protection.socket_identity(@path) == @identity
145
+ rescue Errno::ENOENT
146
+ nil
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end