daytona 0.148.0 → 0.149.0.alpha.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2beee2143c5d47184a4d33fb2e598358b85b7f891a2d86b927fdac5b1242a36
4
- data.tar.gz: 4ad6f822f59d29259b515690ac269d6a847ac78635f2e514298a6cf0b06723ab
3
+ metadata.gz: 0111eaa3f2e1edf3951f4fb4c4ab90039a9bd264602f1f0da5c88b89982d6376
4
+ data.tar.gz: 29fb26a2e6c4f55e3263e5e6f774d0e8625d4bca444d23aae3bf9e3e53731eaf
5
5
  SHA512:
6
- metadata.gz: 37435c7af9bfb1c90eedc910f59f0e20f96c9801da2750661ad2c0355ebc2e3f9b5f7e3b2fb7d7d21101a5f15b1ebb617bebbe769d17e4896ff861a1d217b1c6
7
- data.tar.gz: 0333f8765e0e6924f250d85dadf2071aab3dc98c4220dc0d9330bd0eae6e775e08aad00f5839dde87af7e2096a3da6783dcddd495c799d12bb81a6f53f775470
6
+ metadata.gz: d2b03e16b5d7d31005cae3d297525415335143717d624b1fd842dfde1ddacebb267189a52077070ef0dd887c3e9d8814a54d2176be154dafb2b4e2050fff3446
7
+ data.tar.gz: da5ddce3d882d5f7fff7ad2d6f8e0b105fee37401742656c675743c8a41e51fb69c6c6f53815a29a9271818dc181685384c874e30c91ed309710cfc4a75a7cd3
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 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 EventSubscriber
12
+ # @param api_url [String]
13
+ # @param token [String]
14
+ # @param organization_id [String, nil]
15
+ def initialize(api_url:, token:, organization_id: nil)
16
+ @api_url = api_url
17
+ @token = token
18
+ @organization_id = organization_id
19
+ @client = nil
20
+ @connected = false
21
+ @failed = false
22
+ @fail_error = nil
23
+ @listeners = {}
24
+ @registered_events = Set.new
25
+ @mutex = Mutex.new
26
+ @disconnect_timer = nil
27
+ @last_event_at = Time.now
28
+ @reconnecting = false
29
+ @close_requested = false
30
+ @max_reconnects = 10
31
+ end
32
+
33
+ # Idempotent: ensure a connection attempt is in progress or already established.
34
+ # Non-blocking. Starts a background Thread to connect if not already connected
35
+ # and no attempt is currently running.
36
+ # @return [void]
37
+ def ensure_connected
38
+ return if @connected
39
+ return if @connect_thread&.alive?
40
+
41
+ @connect_thread = Thread.new do
42
+ connect
43
+ rescue StandardError
44
+ # Callers check connected? when they need it
45
+ end
46
+ end
47
+
48
+ # Establish the Socket.IO connection.
49
+ # @return [void]
50
+ # @raise [StandardError] on connection failure
51
+ def connect
52
+ return if @connected
53
+
54
+ # Close any existing stale connection before creating a fresh one
55
+ @client&.close rescue nil # rubocop:disable Style/RescueModifier
56
+
57
+ @client = SocketIOClient.new(
58
+ api_url: @api_url,
59
+ token: @token,
60
+ organization_id: @organization_id,
61
+ on_event: method(:handle_event),
62
+ on_disconnect: method(:handle_disconnect)
63
+ )
64
+
65
+ @close_requested = false
66
+ @client.connect
67
+ @connected = true
68
+ @failed = false
69
+ @fail_error = nil
70
+ rescue StandardError => e
71
+ @failed = true
72
+ @fail_error = "WebSocket connection failed: #{e.message}"
73
+ raise
74
+ end
75
+
76
+ # Subscribe to specific events for a resource.
77
+ # @param resource_id [String] The ID of the resource (e.g. sandbox ID, volume ID).
78
+ # @param events [Array<String>] List of Socket.IO event names to listen for.
79
+ # @yield [event_name, data] Called with raw event name and data hash.
80
+ # @return [Proc] Unsubscribe function.
81
+ DISCONNECT_DELAY = 30
82
+
83
+ def subscribe(resource_id, events:, &handler)
84
+ # Cancel any pending delayed disconnect
85
+ @disconnect_timer&.kill
86
+ @disconnect_timer = nil
87
+
88
+ # Register any new events with the Socket.IO client (idempotent)
89
+ register_events(events)
90
+
91
+ @mutex.synchronize do
92
+ @listeners[resource_id] ||= []
93
+ @listeners[resource_id] << handler
94
+ end
95
+
96
+ lambda {
97
+ should_schedule = false
98
+ @mutex.synchronize do
99
+ @listeners[resource_id]&.delete(handler)
100
+ @listeners.delete(resource_id) if @listeners[resource_id] && @listeners[resource_id].empty?
101
+ should_schedule = @listeners.empty?
102
+ end
103
+
104
+ # Schedule delayed disconnect when no resources are listening anymore
105
+ if should_schedule
106
+ @disconnect_timer = Thread.new do
107
+ sleep(DISCONNECT_DELAY)
108
+ empty = @mutex.synchronize { @listeners.empty? }
109
+ disconnect if empty
110
+ end
111
+ end
112
+ }
113
+ end
114
+
115
+ # @return [Boolean]
116
+ def connected?
117
+ @connected
118
+ end
119
+
120
+ # @return [Boolean]
121
+ def failed?
122
+ @failed
123
+ end
124
+
125
+ # @return [String, nil]
126
+ attr_reader :fail_error
127
+
128
+ # Disconnect and clean up.
129
+ def disconnect
130
+ @close_requested = true
131
+ @client&.close
132
+ @connected = false
133
+ @mutex.synchronize { @listeners.clear }
134
+ @registered_events.clear
135
+ end
136
+
137
+ private
138
+
139
+ # Register Socket.IO event handlers (idempotent - each event is registered once).
140
+ # The SocketIOClient dispatches all events via the on_event callback, so we just
141
+ # need to track which events we care about for filtering in handle_event.
142
+ def register_events(events)
143
+ events.each { |evt| @registered_events.add(evt) }
144
+ end
145
+
146
+ def handle_event(event_name, data)
147
+ @last_event_at = Time.now
148
+
149
+ # Only dispatch events that have been registered
150
+ return unless @registered_events.include?(event_name)
151
+
152
+ resource_id = extract_id_from_event(data)
153
+ return unless resource_id
154
+
155
+ dispatch(resource_id, event_name, data)
156
+ end
157
+
158
+ # Extract resource ID from an event payload.
159
+ # Handles two payload shapes:
160
+ # - Wrapper: {sandbox: {id: ...}, ...} -> nested resource ID
161
+ # - Direct: {id: ...} -> top-level ID
162
+ def extract_id_from_event(data)
163
+ return nil unless data.is_a?(Hash)
164
+
165
+ %w[sandbox volume snapshot runner].each do |key|
166
+ nested = data[key]
167
+ next unless nested.is_a?(Hash)
168
+
169
+ sid = nested['id']
170
+ return sid if sid.is_a?(String)
171
+ end
172
+
173
+ top_id = data['id']
174
+ return top_id if top_id.is_a?(String)
175
+
176
+ nil
177
+ end
178
+
179
+ def dispatch(resource_id, event_name, data)
180
+ handlers = @mutex.synchronize { @listeners[resource_id]&.dup || [] }
181
+ handlers.each do |handler|
182
+ handler.call(event_name, data)
183
+ rescue StandardError
184
+ # Don't let handler errors break other handlers
185
+ end
186
+ end
187
+
188
+ def handle_disconnect
189
+ @connected = false
190
+ return if @close_requested
191
+
192
+ Thread.new { reconnect_loop }
193
+ end
194
+
195
+ def reconnect_loop
196
+ return if @reconnecting
197
+
198
+ @reconnecting = true
199
+
200
+ @max_reconnects.times do |attempt|
201
+ return if @close_requested
202
+
203
+ delay = [2**attempt, 30].min
204
+ sleep(delay)
205
+ return if @close_requested
206
+
207
+ connect
208
+ @reconnecting = false
209
+ return
210
+ rescue StandardError
211
+ # Continue retrying
212
+ end
213
+
214
+ # All attempts failed
215
+ @failed = true
216
+ @fail_error = "WebSocket reconnection failed after #{@max_reconnects} attempts"
217
+ @reconnecting = false
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 Daytona Platforms Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'timeout'
7
+
8
+ require 'websocket-client-simple'
9
+ require 'json'
10
+ require 'uri'
11
+
12
+ module Daytona
13
+ # Minimal Engine.IO/Socket.IO v4 client over raw WebSocket.
14
+ # Supports connect with auth, heartbeat, and event reception.
15
+ #
16
+ # Engine.IO v4 heartbeat protocol (WebSocket transport):
17
+ # - Server sends PING (type 2) every pingInterval ms
18
+ # - Client must respond with PONG (type 3) within pingTimeout ms
19
+ # - Client monitors for missing server PINGs to detect dead connections
20
+ class SocketIOClient
21
+ # Engine.IO v4 packet types
22
+ EIO_OPEN = '0'
23
+ EIO_CLOSE = '1'
24
+ EIO_PING = '2'
25
+ EIO_PONG = '3'
26
+ EIO_MESSAGE = '4'
27
+
28
+ # Socket.IO v4 packet types (inside Engine.IO messages)
29
+ SIO_CONNECT = '0'
30
+ SIO_DISCONNECT = '1'
31
+ SIO_EVENT = '2'
32
+ SIO_CONNECT_ERROR = '4'
33
+
34
+ attr_reader :connected
35
+
36
+ # @param api_url [String] The API URL (e.g., "https://app.daytona.io/api")
37
+ # @param token [String] Auth token (API key or JWT)
38
+ # @param organization_id [String, nil] Organization ID for room joining
39
+ # @param on_event [Proc] Called with (event_name, data_hash) for each Socket.IO event
40
+ # @param on_disconnect [Proc] Called when the connection is lost
41
+ # @param connect_timeout [Numeric] Connection timeout in seconds
42
+ def initialize(api_url:, token:, organization_id: nil, on_event: nil, on_disconnect: nil, connect_timeout: 5)
43
+ @api_url = api_url
44
+ @token = token
45
+ @organization_id = organization_id
46
+ @on_event = on_event
47
+ @on_disconnect = on_disconnect
48
+ @connect_timeout = connect_timeout
49
+ @connected = false
50
+ @mutex = Mutex.new
51
+ @write_mutex = Mutex.new
52
+ @health_thread = nil
53
+ @ping_interval = 25
54
+ @ping_timeout = 20
55
+ @last_server_activity = Time.now
56
+ @ws = nil
57
+ @close_requested = false
58
+ end
59
+
60
+ # Establish the WebSocket connection and perform Socket.IO handshake.
61
+ # @return [Boolean] true if connection succeeded
62
+ # @raise [StandardError] on connection failure
63
+ def connect
64
+ ws_url = build_ws_url
65
+ connected_queue = Queue.new
66
+
67
+ # Capture self because websocket-client-simple uses instance_exec for callbacks
68
+ client = self
69
+
70
+ @ws = WebSocket::Client::Simple.connect(ws_url)
71
+
72
+ @ws.on :message do |msg|
73
+ client.send(:handle_raw_message, msg.data.to_s, connected_queue)
74
+ end
75
+
76
+ @ws.on :error do |_e|
77
+ client.instance_variable_get(:@mutex).synchronize do
78
+ client.instance_variable_set(:@connected, false)
79
+ end
80
+ connected_queue.push(:error) unless client.connected?
81
+ end
82
+
83
+ @ws.on :close do
84
+ mutex = client.instance_variable_get(:@mutex)
85
+ was_connected = mutex.synchronize do
86
+ prev = client.instance_variable_get(:@connected)
87
+ client.instance_variable_set(:@connected, false)
88
+ prev
89
+ end
90
+ on_disconnect = client.instance_variable_get(:@on_disconnect)
91
+ close_requested = client.instance_variable_get(:@close_requested)
92
+ on_disconnect&.call if was_connected && !close_requested
93
+ end
94
+
95
+ # Wait for connection with timeout
96
+ result = nil
97
+ begin
98
+ Timeout.timeout(@connect_timeout) { result = connected_queue.pop }
99
+ rescue Timeout::Error
100
+ close
101
+ raise "WebSocket connection timed out after #{@connect_timeout}s"
102
+ end
103
+
104
+ raise "WebSocket connection failed: #{result}" if result != :connected
105
+
106
+ @mutex.synchronize { @connected }
107
+ end
108
+
109
+ # @return [Boolean]
110
+ def connected?
111
+ @mutex.synchronize { @connected }
112
+ end
113
+
114
+ # Gracefully close the connection.
115
+ def close
116
+ @close_requested = true
117
+ @health_thread&.kill
118
+ @health_thread = nil
119
+
120
+ send_raw(EIO_CLOSE) if @ws
121
+ @ws&.close
122
+ @mutex.synchronize { @connected = false }
123
+ rescue StandardError
124
+ # Ignore errors during close
125
+ end
126
+
127
+ private
128
+
129
+ def build_ws_url
130
+ parsed = URI.parse(@api_url)
131
+ ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
132
+ host = parsed.host
133
+ port = parsed.port
134
+
135
+ query_parts = ['EIO=4', 'transport=websocket']
136
+ query_parts << "organizationId=#{URI.encode_www_form_component(@organization_id)}" if @organization_id
137
+
138
+ port_str = (parsed.scheme == 'https' && port == 443) || (parsed.scheme == 'http' && port == 80) ? '' : ":#{port}"
139
+ "#{ws_scheme}://#{host}#{port_str}/api/socket.io/?#{query_parts.join('&')}"
140
+ end
141
+
142
+ def handle_raw_message(raw, connected_queue)
143
+ return if raw.nil? || raw.empty?
144
+
145
+ # Track all server activity for health monitoring.
146
+ # If the server stops sending ANY data (pings, events, etc.)
147
+ # the health monitor will detect the dead connection.
148
+ @last_server_activity = Time.now
149
+
150
+ case raw[0]
151
+ when EIO_OPEN
152
+ # Parse open payload for ping interval
153
+ begin
154
+ payload = JSON.parse(raw[1..])
155
+ @ping_interval = (payload['pingInterval'] || 25_000) / 1000.0
156
+ @ping_timeout = (payload['pingTimeout'] || 20_000) / 1000.0
157
+ rescue JSON::ParserError
158
+ # Use default ping interval
159
+ end
160
+ # Send Socket.IO CONNECT with auth
161
+ auth = JSON.generate({ token: @token })
162
+ send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
163
+
164
+ when EIO_PING
165
+ # Server heartbeat — respond immediately with PONG
166
+ send_raw(EIO_PONG)
167
+
168
+ when EIO_PONG
169
+ # Unexpected in EIO v4 (server doesn't respond to client pings),
170
+ # but handle gracefully — activity already tracked above.
171
+ nil
172
+
173
+ when EIO_MESSAGE
174
+ handle_socketio_packet(raw[1..], connected_queue)
175
+
176
+ when EIO_CLOSE
177
+ @mutex.synchronize { @connected = false }
178
+ end
179
+ end
180
+
181
+ def handle_socketio_packet(data, connected_queue)
182
+ return if data.nil? || data.empty?
183
+
184
+ case data[0]
185
+ when SIO_CONNECT
186
+ # Connection acknowledged
187
+ @mutex.synchronize { @connected = true }
188
+ start_health_monitor
189
+ connected_queue&.push(:connected)
190
+
191
+ when SIO_CONNECT_ERROR
192
+ # Connection rejected
193
+ error_msg = begin
194
+ payload = JSON.parse(data[1..])
195
+ payload['message'] || 'Unknown error'
196
+ rescue JSON::ParserError
197
+ data[1..]
198
+ end
199
+ @mutex.synchronize { @connected = false }
200
+ connected_queue&.push("Auth rejected: #{error_msg}")
201
+
202
+ when SIO_EVENT
203
+ handle_event(data[1..])
204
+
205
+ when SIO_DISCONNECT
206
+ @mutex.synchronize { @connected = false }
207
+ end
208
+ end
209
+
210
+ def handle_event(json_str)
211
+ return unless @on_event
212
+
213
+ # Skip namespace prefix if present (e.g., "/ns,")
214
+ if json_str&.start_with?('/')
215
+ comma_idx = json_str.index(',')
216
+ json_str = json_str[(comma_idx + 1)..] if comma_idx
217
+ end
218
+
219
+ event_array = JSON.parse(json_str)
220
+ return unless event_array.is_a?(Array) && event_array.length >= 1
221
+
222
+ event_name = event_array[0]
223
+ event_data = event_array[1]
224
+
225
+ @on_event.call(event_name, event_data)
226
+ rescue JSON::ParserError
227
+ # Malformed event, ignore
228
+ end
229
+
230
+ # Monitors connection health by checking for server activity.
231
+ # In Engine.IO v4, the server sends PING every pingInterval ms.
232
+ # If no server activity (pings, events, any data) is seen within
233
+ # pingInterval + pingTimeout, the connection is considered dead.
234
+ HEALTH_CHECK_INTERVAL = 5 # seconds — check frequently for fast detection
235
+
236
+ def start_health_monitor
237
+ @health_thread&.kill
238
+ @last_server_activity = Time.now
239
+ @health_thread = Thread.new do
240
+ loop do
241
+ sleep(HEALTH_CHECK_INTERVAL)
242
+ break unless connected?
243
+
244
+ if Time.now - @last_server_activity > @ping_interval + @ping_timeout
245
+ # No server activity within expected window — connection is dead
246
+ @mutex.synchronize { @connected = false }
247
+ @on_disconnect&.call unless @close_requested
248
+ # Force-close the dead WebSocket to ensure cleanup
249
+ @ws&.close rescue nil # rubocop:disable Style/RescueModifier
250
+ break
251
+ end
252
+ rescue StandardError
253
+ break
254
+ end
255
+ end
256
+ end
257
+
258
+ def send_raw(msg)
259
+ @write_mutex.synchronize do
260
+ @ws&.send(msg)
261
+ end
262
+ rescue StandardError
263
+ # Ignore write errors
264
+ end
265
+ end
266
+ end
@@ -44,12 +44,27 @@ module Daytona
44
44
  @snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
45
45
  @snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
46
46
  otel_state:)
47
+ # Event subscriber for real-time sandbox updates
48
+ @event_subscriber = nil
49
+
50
+ # Create and start WebSocket event subscriber connection in the background (non-blocking).
51
+ token = config.api_key || config.jwt_token
52
+ return unless token
53
+
54
+ @event_subscriber = EventSubscriber.new(
55
+ api_url: config.api_url,
56
+ token: token,
57
+ organization_id: config.organization_id
58
+ )
59
+ @event_subscriber.ensure_connected
47
60
  end
48
61
 
49
62
  # Shuts down OTel providers, flushing any pending telemetry data.
50
63
  #
51
64
  # @return [void]
52
65
  def close
66
+ @event_subscriber&.disconnect
67
+ @event_subscriber = nil
53
68
  ::Daytona.shutdown_otel(@otel_state)
54
69
  @otel_state = nil
55
70
  end
@@ -265,7 +280,8 @@ module Daytona
265
280
  config:,
266
281
  sandbox_api:,
267
282
  code_toolbox:,
268
- otel_state: @otel_state
283
+ otel_state: @otel_state,
284
+ event_subscriber: @event_subscriber
269
285
  )
270
286
  end
271
287
 
@@ -119,12 +119,13 @@ module Daytona
119
119
  # @params sandbox_api [DaytonaApiClient::SandboxApi]
120
120
  # @params sandbox_dto [DaytonaApiClient::Sandbox]
121
121
  # @params otel_state [Daytona::OtelState, nil]
122
- def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
122
+ def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, otel_state: nil, event_subscriber: nil) # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
123
123
  process_response(sandbox_dto)
124
124
  @code_toolbox = code_toolbox
125
125
  @config = config
126
126
  @sandbox_api = sandbox_api
127
127
  @otel_state = otel_state
128
+ @event_subscriber = event_subscriber
128
129
 
129
130
  # Create toolbox API clients with dynamic configuration
130
131
  toolbox_api_config = build_toolbox_api_config
@@ -165,6 +166,9 @@ module Daytona
165
166
  )
166
167
  @lsp_api = lsp_api
167
168
  @info_api = info_api
169
+
170
+ # Subscribe to real-time events for this sandbox
171
+ subscribe_to_events
168
172
  end
169
173
 
170
174
  # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
@@ -224,14 +228,26 @@ module Daytona
224
228
  # @return [DaytonaApiClient::SshAccessDto]
225
229
  def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
226
230
 
231
+ # Deletes the Sandbox and waits for it to reach the 'destroyed' state.
232
+ #
233
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
227
234
  # @return [void]
228
- def delete
229
- sandbox_api.delete_sandbox(id)
230
- refresh
231
- rescue DaytonaApiClient::ApiError => e
232
- raise unless e.code == 404
235
+ def delete(timeout = DEFAULT_TIMEOUT)
236
+ process_response(sandbox_api.delete_sandbox(id))
233
237
 
234
- @state = 'destroyed'
238
+ return if state.to_s == DaytonaApiClient::SandboxState::DESTROYED.to_s
239
+
240
+ with_timeout(
241
+ timeout:,
242
+ message: "Sandbox #{id} failed to be destroyed within the #{timeout} seconds timeout period",
243
+ setup: nil
244
+ ) do
245
+ wait_for_state(
246
+ target_states: [DaytonaApiClient::SandboxState::DESTROYED],
247
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED],
248
+ safe_refresh: true
249
+ )
250
+ end
235
251
  end
236
252
 
237
253
  # Gets the user's home directory path for the logged in user inside the Sandbox.
@@ -340,7 +356,12 @@ module Daytona
340
356
  timeout:,
341
357
  message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
342
358
  setup: proc { process_response(sandbox_api.start_sandbox(id)) }
343
- ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
359
+ ) do
360
+ wait_for_state(
361
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
362
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
363
+ )
364
+ end
344
365
  end
345
366
 
346
367
  # Recovers the Sandbox from a recoverable error and waits for it to be ready.
@@ -357,7 +378,12 @@ module Daytona
357
378
  timeout:,
358
379
  message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
359
380
  setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
360
- ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
381
+ ) do
382
+ wait_for_state(
383
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
384
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
385
+ )
386
+ end
361
387
  rescue StandardError => e
362
388
  raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
363
389
  end
@@ -375,9 +401,9 @@ module Daytona
375
401
  refresh
376
402
  }
377
403
  ) do
378
- wait_for_states(
379
- operation: OPERATION_STOP,
380
- target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED]
404
+ wait_for_state(
405
+ target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
406
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
381
407
  )
382
408
  end
383
409
  end
@@ -420,9 +446,17 @@ module Daytona
420
446
  #
421
447
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
422
448
  # @return [void]
423
- def wait_for_resize_complete(_timeout = DEFAULT_TIMEOUT)
424
- wait_for_states(operation: OPERATION_RESIZE, target_states: [DaytonaApiClient::SandboxState::STARTED,
425
- DaytonaApiClient::SandboxState::STOPPED])
449
+ def wait_for_resize_complete(timeout = DEFAULT_TIMEOUT)
450
+ with_timeout(
451
+ timeout:,
452
+ message: "Sandbox #{id} resize did not complete within the #{timeout} seconds timeout period",
453
+ setup: nil
454
+ ) do
455
+ wait_for_state(
456
+ target_states: [DaytonaApiClient::SandboxState::STARTED, DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::ARCHIVED],
457
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
458
+ )
459
+ end
426
460
  end
427
461
 
428
462
  # Creates a new Language Server Protocol (LSP) server instance.
@@ -448,8 +482,17 @@ module Daytona
448
482
  #
449
483
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
450
484
  # @return [void]
451
- def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
452
- wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED])
485
+ def wait_for_sandbox_start(timeout = DEFAULT_TIMEOUT)
486
+ with_timeout(
487
+ timeout:,
488
+ message: "Sandbox #{id} failed to start within the #{timeout} seconds timeout period",
489
+ setup: nil
490
+ ) do
491
+ wait_for_state(
492
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
493
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
494
+ )
495
+ end
453
496
  end
454
497
 
455
498
  # Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
@@ -458,9 +501,17 @@ module Daytona
458
501
  #
459
502
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
460
503
  # @return [void]
461
- def wait_for_sandbox_stop(_timeout = DEFAULT_TIMEOUT)
462
- wait_for_states(operation: OPERATION_STOP, target_states: [DaytonaApiClient::SandboxState::STOPPED,
463
- DaytonaApiClient::SandboxState::DESTROYED])
504
+ def wait_for_sandbox_stop(timeout = DEFAULT_TIMEOUT)
505
+ with_timeout(
506
+ timeout:,
507
+ message: "Sandbox #{id} failed to stop within the #{timeout} seconds timeout period",
508
+ setup: nil
509
+ ) do
510
+ wait_for_state(
511
+ target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
512
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
513
+ )
514
+ end
464
515
  end
465
516
 
466
517
  instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
@@ -545,40 +596,105 @@ module Daytona
545
596
  )
546
597
  end
547
598
 
548
- # Waits for the Sandbox to reach the one of the target states. Polls the Sandbox status until it
549
- # reaches the one of the target states or encounters an error. It will wait up to 60 seconds
550
- # for the Sandbox to reach one of the target states.
599
+ # Waits for the Sandbox to reach the one of the target states via WebSocket events
600
+ # with periodic polling safety net.
551
601
  #
552
- # @param operation [#to_s] Operation name for error message
553
- # @param target_states [Array<DaytonaApiClient::SandboxState>] List of the target states
602
+ # @param target_states [Array<DaytonaApiClient::SandboxState>] States that indicate success.
603
+ # @param error_states [Array<DaytonaApiClient::SandboxState>] States that indicate failure.
604
+ # @param safe_refresh [Boolean] If true, wrap refresh in rescue for delete operations (404s).
554
605
  # @return [void]
555
606
  # @raise [Daytona::Sdk::Error]
556
- def wait_for_states(operation:, target_states:)
557
- loop do
558
- case state
559
- when *target_states then return
560
- when DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED
561
- raise Sdk::Error, "Sandbox #{id} failed to #{operation} with state: #{state}, error reason: #{error_reason}"
607
+ def wait_for_state(target_states:, error_states:, safe_refresh: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
608
+ target_strings = target_states.map(&:to_s)
609
+ error_strings = error_states.map(&:to_s)
610
+
611
+ return if target_strings.include?(state.to_s)
612
+
613
+ if error_strings.include?(state.to_s)
614
+ raise Sdk::Error, "Sandbox #{id} is in error state: #{state}, error reason: #{error_reason}"
615
+ end
616
+
617
+ mutex = Mutex.new
618
+ state_changed = ConditionVariable.new
619
+ result_state = nil
620
+
621
+ unsubscribe = @event_subscriber&.subscribe(id, events: ['sandbox.state.updated']) do |_event_name, data|
622
+ next unless data.is_a?(Hash)
623
+ next if result_state # Already resolved
624
+
625
+ new_state = data['newState']
626
+ next unless new_state
627
+
628
+ if target_strings.include?(new_state) || error_strings.include?(new_state)
629
+ mutex.synchronize do
630
+ result_state = new_state
631
+ state_changed.signal
632
+ end
633
+ end
634
+ end
635
+
636
+ begin
637
+ mutex.synchronize do
638
+ until result_state
639
+ # Wait 1s for WebSocket event, then poll as safety net
640
+ state_changed.wait(mutex, POLL_SAFETY_INTERVAL)
641
+
642
+ break if result_state
643
+
644
+ # Poll: refresh data and check state
645
+
646
+ if safe_refresh
647
+ begin
648
+ refresh
649
+ rescue DaytonaApiClient::ApiError => e
650
+ @state = DaytonaApiClient::SandboxState::DESTROYED if e.code == 404
651
+ rescue StandardError
652
+ nil # ignore other refresh errors
653
+ end
654
+ else
655
+ refresh rescue nil # rubocop:disable Style/RescueModifier
656
+ end
657
+
658
+ return if target_strings.include?(state.to_s)
659
+
660
+ if error_strings.include?(state.to_s)
661
+ raise Sdk::Error,
662
+ "Sandbox #{id} is in error state: #{state}, error reason: #{error_reason}"
663
+ end
664
+ end
562
665
  end
563
666
 
564
- sleep(IDLE_DURATION)
565
- refresh
667
+ if result_state && error_strings.include?(result_state)
668
+ raise Sdk::Error,
669
+ "Sandbox #{id} entered error state: #{result_state}, error reason: #{error_reason}"
670
+ end
671
+ ensure
672
+ unsubscribe&.call
566
673
  end
567
674
  end
568
675
 
569
- IDLE_DURATION = 0.1
570
- private_constant :IDLE_DURATION
676
+ def subscribe_to_events
677
+ return unless @event_subscriber
571
678
 
572
- NO_TIMEOUT = 0
573
- private_constant :NO_TIMEOUT
679
+ @event_subscriber.ensure_connected
680
+
681
+ @event_subscriber.subscribe(
682
+ id,
683
+ events: ['sandbox.state.updated', 'sandbox.desired-state.updated', 'sandbox.created']
684
+ ) do |_event_name, data|
685
+ next unless data.is_a?(Hash)
574
686
 
575
- OPERATION_START = :start
576
- private_constant :OPERATION_START
687
+ raw = data['sandbox'] || data
688
+ process_response(DaytonaApiClient::Sandbox.build_from_hash(raw)) if raw.is_a?(Hash)
689
+ rescue StandardError
690
+ nil # Event payload may be incomplete
691
+ end
692
+ end
577
693
 
578
- OPERATION_STOP = :stop
579
- private_constant :OPERATION_STOP
694
+ POLL_SAFETY_INTERVAL = 1
695
+ private_constant :POLL_SAFETY_INTERVAL
580
696
 
581
- OPERATION_RESIZE = :resize
582
- private_constant :OPERATION_RESIZE
697
+ NO_TIMEOUT = 0
698
+ private_constant :NO_TIMEOUT
583
699
  end
584
700
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Daytona
4
4
  module Sdk
5
- VERSION = '0.148.0'
5
+ VERSION = '0.149.0.alpha.1'
6
6
  end
7
7
  end
data/lib/daytona/sdk.rb CHANGED
@@ -27,6 +27,8 @@ require_relative 'computer_use'
27
27
  require_relative 'code_toolbox/sandbox_python_code_toolbox'
28
28
  require_relative 'code_toolbox/sandbox_ts_code_toolbox'
29
29
  require_relative 'code_toolbox/sandbox_js_code_toolbox'
30
+ require_relative 'common/socketio_client'
31
+ require_relative 'common/event_subscriber'
30
32
  require_relative 'daytona'
31
33
  require_relative 'file_system'
32
34
  require_relative 'git'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daytona
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.148.0
4
+ version: 0.149.0.alpha.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daytona Platforms Inc.
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-02-27 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: opentelemetry-exporter-otlp
@@ -86,28 +85,28 @@ dependencies:
86
85
  requirements:
87
86
  - - '='
88
87
  - !ruby/object:Gem::Version
89
- version: 0.148.0
88
+ version: 0.149.0.alpha.1
90
89
  type: :runtime
91
90
  prerelease: false
92
91
  version_requirements: !ruby/object:Gem::Requirement
93
92
  requirements:
94
93
  - - '='
95
94
  - !ruby/object:Gem::Version
96
- version: 0.148.0
95
+ version: 0.149.0.alpha.1
97
96
  - !ruby/object:Gem::Dependency
98
97
  name: daytona_toolbox_api_client
99
98
  requirement: !ruby/object:Gem::Requirement
100
99
  requirements:
101
100
  - - '='
102
101
  - !ruby/object:Gem::Version
103
- version: 0.148.0
102
+ version: 0.149.0.alpha.1
104
103
  type: :runtime
105
104
  prerelease: false
106
105
  version_requirements: !ruby/object:Gem::Requirement
107
106
  requirements:
108
107
  - - '='
109
108
  - !ruby/object:Gem::Version
110
- version: 0.148.0
109
+ version: 0.149.0.alpha.1
111
110
  - !ruby/object:Gem::Dependency
112
111
  name: dotenv
113
112
  requirement: !ruby/object:Gem::Requirement
@@ -172,6 +171,7 @@ files:
172
171
  - lib/daytona/common/code_interpreter.rb
173
172
  - lib/daytona/common/code_language.rb
174
173
  - lib/daytona/common/daytona.rb
174
+ - lib/daytona/common/event_subscriber.rb
175
175
  - lib/daytona/common/file_system.rb
176
176
  - lib/daytona/common/git.rb
177
177
  - lib/daytona/common/image.rb
@@ -180,6 +180,7 @@ files:
180
180
  - lib/daytona/common/resources.rb
181
181
  - lib/daytona/common/response.rb
182
182
  - lib/daytona/common/snapshot.rb
183
+ - lib/daytona/common/socketio_client.rb
183
184
  - lib/daytona/computer_use.rb
184
185
  - lib/daytona/config.rb
185
186
  - lib/daytona/daytona.rb
@@ -207,7 +208,6 @@ metadata:
207
208
  source_code_uri: https://github.com/daytonaio/daytona
208
209
  changelog_uri: https://github.com/daytonaio/daytona/releases
209
210
  rubygems_mfa_required: 'true'
210
- post_install_message:
211
211
  rdoc_options: []
212
212
  require_paths:
213
213
  - lib
@@ -222,8 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
222
  - !ruby/object:Gem::Version
223
223
  version: '0'
224
224
  requirements: []
225
- rubygems_version: 3.4.19
226
- signing_key:
225
+ rubygems_version: 3.6.9
227
226
  specification_version: 4
228
227
  summary: Ruby SDK for Daytona
229
228
  test_files: []