daytona 0.197.0 → 0.198.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: e838c81b167b038d7935b94479648df5ebecb486e34465986baaeed2da7a0a5c
4
- data.tar.gz: 1dce18d22149936bf16af4beda9dcf52bb1de63a0b358c7e965f2dc663bd5933
3
+ metadata.gz: e2003b4293b8ee69d171ad86402151e2924b814022649097deb51da19da6ff58
4
+ data.tar.gz: 30884a506c2dd332c804f995dd2d3afc654cf75b0fa66e4bf54cdd4a38909c12
5
5
  SHA512:
6
- metadata.gz: e55a677df194a0dd60144888407f1186a03b26de14d9c3b4faf6d8611859c5d7ad059b73a1a3ce9cb41f22300c69b029139659817a27a7622e351073e00cb432
7
- data.tar.gz: e62f6b6366d05fa9bd5343752064c1e50cdd4e0a93e101d7ec09791773e81feda4df1a856646c9961b09bdef2d96b2b7c7320afdca1e1f5189434f83556036a4
6
+ metadata.gz: f4627f864da4ae028a19d856a014da0f3513e30a5a8c7299148d89d815a82b13a2c0de3bc8fc3dc4e96ca7a455ee9ef9e291fe71dd58f57e12af8e85ebcbd3d1
7
+ data.tar.gz: c8b07b46dfb64043794af1d4d33db1ca3ac49c1271ebde8eae3b2c9e05ca66030e780d2fbb5cede035c564c03a69f0abba68d87ff098bac36d198fe3ab1d2636
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require_relative 'socketio_client'
7
+
8
+ module Daytona
9
+ # Manages a Socket.IO connection and dispatches events to per-resource handlers.
10
+ # Generic — works for sandboxes, volumes, snapshots, runners, etc.
11
+ class EventDispatcher
12
+ DISCONNECT_DELAY = 30
13
+
14
+ # @param api_url [String]
15
+ # @param token [String]
16
+ # @param organization_id [String, nil]
17
+ def initialize(api_url:, token:, organization_id: nil, source: nil, sdk_version: nil)
18
+ @api_url = api_url
19
+ @token = token
20
+ @organization_id = organization_id
21
+ @source = source
22
+ @sdk_version = sdk_version
23
+ @client = nil
24
+ @connected = false
25
+ @failed = false
26
+ @fail_error = nil
27
+ @listeners = {}
28
+ @registered_events = Set.new
29
+ @mutex = Mutex.new
30
+ @disconnect_timer = nil
31
+ @disconnect_generation = 0
32
+ @reconnect_thread = nil
33
+ @connect_thread = nil
34
+ @reconnecting = false
35
+ @connecting = false
36
+ @close_requested = false
37
+ @closed = false
38
+ @max_reconnects = 100
39
+ end
40
+
41
+ # Idempotent: ensure a connection attempt is in progress or already established.
42
+ # Non-blocking. Starts a background Thread to connect if not already connected
43
+ # and no attempt is currently running.
44
+ # @return [void]
45
+ def ensure_connected
46
+ @mutex.synchronize do
47
+ return if @closed || @connected || @connecting || @connect_thread&.alive?
48
+
49
+ @connect_thread = Thread.new do
50
+ connect
51
+ rescue StandardError
52
+ # Callers check connected? when they need it
53
+ ensure
54
+ @mutex.synchronize do
55
+ @connect_thread = nil if @connect_thread == Thread.current
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Establish the Socket.IO connection.
62
+ # @return [void]
63
+ # @raise [StandardError] on connection failure
64
+ def connect
65
+ @mutex.synchronize do
66
+ return if @closed || @connected || @connecting
67
+
68
+ @connecting = true
69
+ end
70
+
71
+ # Close any existing stale connection before creating a fresh one
72
+ @client&.close rescue nil # rubocop:disable Style/RescueModifier
73
+
74
+ client = SocketIOClient.new(
75
+ api_url: @api_url,
76
+ token: @token,
77
+ organization_id: @organization_id,
78
+ source: @source,
79
+ sdk_version: @sdk_version,
80
+ on_event: method(:handle_event),
81
+ on_disconnect: method(:handle_disconnect)
82
+ )
83
+
84
+ @mutex.synchronize do
85
+ @client = client
86
+ @close_requested = false
87
+ end
88
+
89
+ client.connect
90
+
91
+ @mutex.synchronize do
92
+ @connected = true
93
+ @failed = false
94
+ @fail_error = nil
95
+ schedule_delayed_disconnect_locked if @listeners.empty?
96
+ end
97
+ rescue StandardError => e
98
+ @mutex.synchronize do
99
+ @failed = true
100
+ @fail_error = "WebSocket connection failed: #{e.message}"
101
+ end
102
+ ensure
103
+ @mutex.synchronize { @connecting = false }
104
+ end
105
+
106
+ # Subscribe to specific events for a resource.
107
+ # @param resource_id [String] The ID of the resource (e.g. sandbox ID, volume ID).
108
+ # @param events [Array<String>] List of Socket.IO event names to listen for.
109
+ # @yield [event_name, data] Called with raw event name and data hash.
110
+ # @return [Proc] Unsubscribe function.
111
+ def subscribe(resource_id, events:, &handler)
112
+ @mutex.synchronize do
113
+ # No-op after disconnect
114
+ return -> {} if @closed
115
+
116
+ @disconnect_timer&.kill
117
+ @disconnect_timer = nil
118
+ @disconnect_generation += 1
119
+
120
+ @listeners[resource_id] ||= []
121
+ @listeners[resource_id] << handler
122
+ # Mark events in filter under lock to avoid dropping first event
123
+ events.each { |evt| @registered_events.add(evt) }
124
+ end
125
+
126
+ ensure_connected
127
+
128
+ lambda {
129
+ return if @close_requested || @closed
130
+
131
+ should_schedule = false
132
+ @mutex.synchronize do
133
+ @listeners[resource_id]&.delete(handler)
134
+ unsubscribe_resource_locked(resource_id) if @listeners[resource_id] && @listeners[resource_id].empty?
135
+ should_schedule = @listeners.empty?
136
+
137
+ schedule_delayed_disconnect_locked if should_schedule
138
+ end
139
+ }
140
+ end
141
+
142
+ # @return [Boolean]
143
+ def connected?
144
+ @connected
145
+ end
146
+
147
+ # @return [Boolean]
148
+ def failed?
149
+ @failed
150
+ end
151
+
152
+ # @return [String, nil]
153
+ attr_reader :fail_error
154
+
155
+ # Disconnect and clean up.
156
+ def disconnect
157
+ thread_state = @mutex.synchronize do
158
+ begin_disconnect_locked(permanent: true, skip_thread: Thread.current)
159
+ end
160
+
161
+ finalize_disconnect(thread_state)
162
+ end
163
+
164
+ private
165
+
166
+ def unsubscribe_resource_locked(resource_id)
167
+ @listeners.delete(resource_id)
168
+ end
169
+
170
+ def schedule_delayed_disconnect_locked
171
+ @disconnect_timer&.kill
172
+ generation = @disconnect_generation
173
+ @disconnect_timer = Thread.new do
174
+ sleep(DISCONNECT_DELAY)
175
+ thread_state = @mutex.synchronize do
176
+ next unless generation == @disconnect_generation
177
+ next unless @listeners.empty?
178
+
179
+ begin_disconnect_locked(permanent: false, skip_thread: Thread.current)
180
+ end
181
+
182
+ finalize_disconnect(thread_state) if thread_state
183
+ end
184
+ @disconnect_timer.abort_on_exception = false
185
+ end
186
+
187
+ def handle_event(event_name, data)
188
+ # Read registered_events under lock for thread safety
189
+ registered = @mutex.synchronize { @registered_events.include?(event_name) }
190
+ return unless registered
191
+
192
+ resource_id = extract_id_from_event(data)
193
+ return unless resource_id
194
+
195
+ dispatch(resource_id, event_name, data)
196
+ end
197
+
198
+ # Extract resource ID from an event payload.
199
+ # Handles two payload shapes:
200
+ # - Wrapper: {sandbox: {id: ...}, ...} -> nested resource ID
201
+ # - Direct: {id: ...} -> top-level ID
202
+ def extract_id_from_event(data)
203
+ return nil unless data.is_a?(Hash)
204
+
205
+ %w[sandbox volume snapshot runner].each do |key|
206
+ nested = data[key]
207
+ next unless nested.is_a?(Hash)
208
+
209
+ sid = nested['id']
210
+ return sid if sid.is_a?(String)
211
+ end
212
+
213
+ top_id = data['id']
214
+ return top_id if top_id.is_a?(String)
215
+
216
+ nil
217
+ end
218
+
219
+ def dispatch(resource_id, event_name, data)
220
+ handlers = @mutex.synchronize { @listeners[resource_id]&.dup || [] }
221
+ handlers.each do |handler|
222
+ handler.call(event_name, data)
223
+ rescue StandardError
224
+ # Don't let handler errors break other handlers
225
+ end
226
+ end
227
+
228
+ def handle_disconnect
229
+ @connected = false
230
+ return if @close_requested
231
+
232
+ @reconnect_thread = Thread.new { reconnect_loop }
233
+ end
234
+
235
+ def reconnect_loop
236
+ @mutex.synchronize do
237
+ return if @reconnecting
238
+
239
+ @reconnecting = true
240
+ end
241
+
242
+ attempt = 0
243
+ while attempt < @max_reconnects
244
+ if @close_requested
245
+ @mutex.synchronize { @reconnecting = false }
246
+ return
247
+ end
248
+
249
+ delay = [2**attempt, 30].min
250
+ sleep(delay)
251
+ if @close_requested
252
+ @mutex.synchronize { @reconnecting = false }
253
+ return
254
+ end
255
+
256
+ begin
257
+ connect
258
+ if @connected
259
+ @mutex.synchronize { @reconnecting = false }
260
+ return
261
+ end
262
+ rescue StandardError
263
+ # Continue retrying
264
+ end
265
+
266
+ attempt += 1
267
+ end
268
+
269
+ # All attempts failed
270
+ @mutex.synchronize do
271
+ @failed = true
272
+ @fail_error = "WebSocket reconnection failed after #{@max_reconnects} attempts"
273
+ @reconnecting = false
274
+ end
275
+ end
276
+
277
+ def begin_disconnect_locked(permanent:, skip_thread:)
278
+ @closed = true if permanent
279
+ @close_requested = true
280
+
281
+ thread_state = {
282
+ client: @client,
283
+ reconnect_thread: @reconnect_thread,
284
+ connect_thread: @connect_thread,
285
+ disconnect_timer: @disconnect_timer,
286
+ skip_thread:
287
+ }
288
+
289
+ @client = nil
290
+ @reconnect_thread = nil
291
+ @connect_thread = nil
292
+ @disconnect_timer = nil
293
+ @connected = false
294
+ @connecting = false
295
+ @listeners.clear
296
+ @registered_events.clear
297
+
298
+ thread_state
299
+ end
300
+
301
+ def finalize_disconnect(thread_state)
302
+ return unless thread_state
303
+
304
+ [thread_state[:reconnect_thread], thread_state[:connect_thread], thread_state[:disconnect_timer]].each do |thread|
305
+ next unless thread
306
+ next if thread == thread_state[:skip_thread]
307
+
308
+ stop_thread(thread)
309
+ end
310
+
311
+ thread_state[:client]&.close
312
+ rescue StandardError
313
+ nil
314
+ end
315
+
316
+ def stop_thread(thread)
317
+ return unless thread
318
+ return if thread == Thread.current
319
+
320
+ thread.kill
321
+ thread.join
322
+ rescue StandardError
323
+ nil
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'securerandom'
7
+
8
+ module Daytona
9
+ class EventSubscriptionManager
10
+ SUBSCRIPTION_TTL = 300
11
+ private_constant :SUBSCRIPTION_TTL
12
+
13
+ def initialize(dispatcher = nil)
14
+ @dispatcher = dispatcher
15
+ @subscriptions = {}
16
+ @mutex = Mutex.new
17
+ @closed = false
18
+ end
19
+
20
+ def subscribe(resource_id:, handler:, events:)
21
+ @mutex.synchronize do
22
+ return nil if @closed || @dispatcher.nil?
23
+ end
24
+
25
+ unsubscribe = @dispatcher.subscribe(resource_id, events:, &handler)
26
+ sub_id = SecureRandom.hex(16)
27
+ timer_to_stop = nil
28
+ rollback_unsubscribe = nil
29
+
30
+ @mutex.synchronize do
31
+ if @closed
32
+ # Rollback dispatcher subscription on failure
33
+ rollback_unsubscribe = unsubscribe
34
+ next
35
+ end
36
+
37
+ @subscriptions[sub_id] = { unsubscribe:, timer: nil }
38
+ timer_to_stop = start_timer_locked(sub_id)
39
+ end
40
+
41
+ if rollback_unsubscribe
42
+ rollback_unsubscribe.call
43
+ return nil
44
+ end
45
+
46
+ stop_thread(timer_to_stop)
47
+ sub_id
48
+ end
49
+
50
+ def refresh(sub_id)
51
+ @mutex.synchronize do
52
+ # Reject after shutdown to prevent use-after-close
53
+ return false if @closed
54
+ end
55
+
56
+ timer_to_stop = nil
57
+
58
+ @mutex.synchronize do
59
+ return false unless @subscriptions.key?(sub_id)
60
+
61
+ timer_to_stop = start_timer_locked(sub_id)
62
+ end
63
+
64
+ stop_thread(timer_to_stop)
65
+ true
66
+ end
67
+
68
+ def unsubscribe(sub_id)
69
+ subscription = nil
70
+ timer = nil
71
+
72
+ @mutex.synchronize do
73
+ subscription = @subscriptions.delete(sub_id)
74
+ if subscription
75
+ timer = subscription[:timer]
76
+ subscription[:timer] = nil
77
+ end
78
+ end
79
+
80
+ return unless subscription
81
+
82
+ stop_thread(timer)
83
+ subscription[:unsubscribe].call
84
+ end
85
+
86
+ def shutdown
87
+ subscriptions = nil
88
+
89
+ @mutex.synchronize do
90
+ @closed = true
91
+ subscriptions = @subscriptions.values
92
+ @subscriptions = {}
93
+ end
94
+
95
+ subscriptions.each do |subscription|
96
+ stop_thread(subscription[:timer])
97
+ subscription[:unsubscribe].call
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def start_timer_locked(sub_id)
104
+ subscription = @subscriptions[sub_id]
105
+ return unless subscription
106
+
107
+ previous_timer = cancel_timer_locked(sub_id)
108
+ timer = Thread.new do
109
+ sleep(SUBSCRIPTION_TTL)
110
+
111
+ unsubscribe = @mutex.synchronize do
112
+ current_subscription = @subscriptions[sub_id]
113
+ next unless current_subscription && current_subscription[:timer] == Thread.current
114
+
115
+ @subscriptions.delete(sub_id)&.dig(:unsubscribe)
116
+ end
117
+
118
+ unsubscribe&.call
119
+ end
120
+ timer.abort_on_exception = false
121
+ subscription[:timer] = timer
122
+ previous_timer
123
+ end
124
+
125
+ def cancel_timer_locked(sub_id)
126
+ subscription = @subscriptions[sub_id]
127
+ return unless subscription
128
+
129
+ timer = subscription[:timer]
130
+ subscription[:timer] = nil
131
+ timer
132
+ end
133
+
134
+ def stop_thread(thread)
135
+ return unless thread
136
+ return if thread == Thread.current
137
+
138
+ thread.kill
139
+ thread.join
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ end
144
+ end