daytona 0.149.0.alpha.1 → 0.149.0.alpha.2
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 +4 -4
- data/lib/daytona/code_toolbox/sandbox_js_code_toolbox.rb +5 -2
- data/lib/daytona/code_toolbox/sandbox_python_code_toolbox.rb +5 -6
- data/lib/daytona/code_toolbox/sandbox_ts_code_toolbox.rb +6 -5
- data/lib/daytona/daytona.rb +1 -17
- data/lib/daytona/process.rb +9 -7
- data/lib/daytona/sandbox.rb +43 -159
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +0 -2
- metadata +5 -7
- data/lib/daytona/common/event_subscriber.rb +0 -220
- data/lib/daytona/common/socketio_client.rb +0 -266
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c9abcf28500a42f47b2b00a80c2b5bba8cee32e555e75ba48d14bbfbdd30d89
|
|
4
|
+
data.tar.gz: 0ecf156c45fa732a0b117f78e6d04ef9fff6a30a0ccf48cd6d771698fa838a03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6321f21d8af76d0bf41109d6d197a1b3444c232d337eadd0254264ae0b6da505738c6e7320fe57e1fe96cb1581c3d5346a5a7144ff21c1b8d03d0492c98887e8
|
|
7
|
+
data.tar.gz: 9ecc2e24bdf89341eb537aa346311b1a6691928f693d4eda56047c206dec4df67f3cae4e39d675072c8843c357fb5c3d6ae584052ed2e6a420818d5979618e27
|
|
@@ -12,8 +12,11 @@ module Daytona
|
|
|
12
12
|
argv = ''
|
|
13
13
|
argv = params.argv.join(' ') if params&.argv && !params.argv.empty?
|
|
14
14
|
|
|
15
|
-
#
|
|
16
|
-
|
|
15
|
+
# Pipe the base64-encoded code via stdin to avoid OS ARG_MAX limits on large payloads
|
|
16
|
+
# Use /dev/stdin instead of -e "$(cat)" which would expand as a process arg and hit ARG_MAX
|
|
17
|
+
# Capture the exit code before filtering to preserve node's exit status
|
|
18
|
+
"_dtn_out=$(echo '#{base64_code}' | base64 -d | node /dev/stdin #{argv} 2>&1); _dtn_ec=$?; " \
|
|
19
|
+
"printf '%s\\n' \"$_dtn_out\" | grep -v 'npm notice'; exit $_dtn_ec"
|
|
17
20
|
end
|
|
18
21
|
end
|
|
19
22
|
end
|
|
@@ -10,22 +10,21 @@ module Daytona
|
|
|
10
10
|
# @param params [Daytona::CodeRunParams, nil] Optional parameters for code execution
|
|
11
11
|
# @return [String] The command to run the Python code
|
|
12
12
|
def get_run_command(code, params = nil)
|
|
13
|
-
encoded_code = Base64.
|
|
13
|
+
encoded_code = Base64.strict_encode64(code)
|
|
14
14
|
|
|
15
15
|
# Override plt.show() method if matplotlib is imported
|
|
16
16
|
if matplotlib_imported?(code)
|
|
17
|
-
encoded_code = Base64.
|
|
17
|
+
encoded_code = Base64.strict_encode64(
|
|
18
18
|
Base64.decode64(PYTHON_CODE_WRAPPER).gsub('{encoded_code}', encoded_code)
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
argv = params&.argv&.join(' ') || ''
|
|
23
23
|
|
|
24
|
-
#
|
|
24
|
+
# Pipe the base64-encoded code via stdin to avoid OS ARG_MAX limits on large payloads
|
|
25
|
+
# echo is a shell builtin so the base64 string doesn't hit ARG_MAX
|
|
25
26
|
# Use -u flag to ensure unbuffered output for real-time error reporting
|
|
26
|
-
|
|
27
|
-
" sh -c 'python3 -u -c \"exec(__import__(\\\"base64\\\").b64decode(\\\"\\\"\\\"#{encoded_code}\\\"\\\"\\\")" \
|
|
28
|
-
".decode())\" #{argv}' "
|
|
27
|
+
"echo '#{encoded_code}' | base64 -d | python3 -u - #{argv}"
|
|
29
28
|
end
|
|
30
29
|
|
|
31
30
|
private
|
|
@@ -10,14 +10,15 @@ module Daytona
|
|
|
10
10
|
# @param params [Daytona::CodeRunParams, nil] Optional parameters for code execution
|
|
11
11
|
# @return [String] The command to run the TypeScript code
|
|
12
12
|
def get_run_command(code, params = nil)
|
|
13
|
-
encoded_code = Base64.
|
|
13
|
+
encoded_code = Base64.strict_encode64(code)
|
|
14
14
|
|
|
15
15
|
argv = params&.argv&.join(' ') || ''
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Pipe the base64-encoded code via stdin to avoid OS ARG_MAX limits on large payloads
|
|
18
|
+
# Use /dev/stdin instead of -e "$(cat)" which would expand as a process arg and hit ARG_MAX
|
|
19
|
+
# Capture the exit code before filtering to preserve ts-node's exit status
|
|
20
|
+
"_dtn_out=$(echo '#{encoded_code}' | base64 -d | npx ts-node -O '{\"module\":\"CommonJS\"}' /dev/stdin #{argv} 2>&1); _dtn_ec=$?; " \
|
|
21
|
+
"printf '%s\\n' \"$_dtn_out\" | grep -v 'npm notice'; exit $_dtn_ec"
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
end
|
data/lib/daytona/daytona.rb
CHANGED
|
@@ -44,27 +44,12 @@ 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
|
|
60
47
|
end
|
|
61
48
|
|
|
62
49
|
# Shuts down OTel providers, flushing any pending telemetry data.
|
|
63
50
|
#
|
|
64
51
|
# @return [void]
|
|
65
52
|
def close
|
|
66
|
-
@event_subscriber&.disconnect
|
|
67
|
-
@event_subscriber = nil
|
|
68
53
|
::Daytona.shutdown_otel(@otel_state)
|
|
69
54
|
@otel_state = nil
|
|
70
55
|
end
|
|
@@ -280,8 +265,7 @@ module Daytona
|
|
|
280
265
|
config:,
|
|
281
266
|
sandbox_api:,
|
|
282
267
|
code_toolbox:,
|
|
283
|
-
otel_state: @otel_state
|
|
284
|
-
event_subscriber: @event_subscriber
|
|
268
|
+
otel_state: @otel_state
|
|
285
269
|
)
|
|
286
270
|
end
|
|
287
271
|
|
data/lib/daytona/process.rb
CHANGED
|
@@ -54,18 +54,20 @@ module Daytona
|
|
|
54
54
|
#
|
|
55
55
|
# # Command with timeout
|
|
56
56
|
# result = sandbox.process.exec("sleep 10", timeout: 5)
|
|
57
|
-
def exec(command:, cwd: nil, env: nil, timeout: nil) # rubocop:disable Metrics/
|
|
58
|
-
command = "echo '#{Base64.encode64(command)}' | base64 -d | sh"
|
|
59
|
-
|
|
57
|
+
def exec(command:, cwd: nil, env: nil, timeout: nil) # rubocop:disable Metrics/MethodLength
|
|
60
58
|
if env && !env.empty?
|
|
59
|
+
env.each_key do |key|
|
|
60
|
+
unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"Invalid environment variable name: '#{key}'"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
61
65
|
safe_env_exports = env.map do |key, value|
|
|
62
|
-
"export #{key}
|
|
63
|
-
end.join(';')
|
|
66
|
+
"export #{key}=\"$(echo '#{Base64.strict_encode64(value)}' | base64 -d)\""
|
|
67
|
+
end.join('; ')
|
|
64
68
|
command = "#{safe_env_exports}; #{command}"
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
command = "sh -c \"#{command}\""
|
|
68
|
-
|
|
69
71
|
response = toolbox_api.execute_command(DaytonaToolboxApiClient::ExecuteRequest.new(command:, cwd:, timeout:))
|
|
70
72
|
# Post-process the output to extract ExecutionArtifacts
|
|
71
73
|
artifacts = parse_output(response.result.split("\n"))
|
data/lib/daytona/sandbox.rb
CHANGED
|
@@ -119,13 +119,12 @@ 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
|
|
122
|
+
def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
|
|
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
|
|
129
128
|
|
|
130
129
|
# Create toolbox API clients with dynamic configuration
|
|
131
130
|
toolbox_api_config = build_toolbox_api_config
|
|
@@ -166,9 +165,6 @@ module Daytona
|
|
|
166
165
|
)
|
|
167
166
|
@lsp_api = lsp_api
|
|
168
167
|
@info_api = info_api
|
|
169
|
-
|
|
170
|
-
# Subscribe to real-time events for this sandbox
|
|
171
|
-
subscribe_to_events
|
|
172
168
|
end
|
|
173
169
|
|
|
174
170
|
# Archives the sandbox, making it inactive and preserving its state. When sandboxes are
|
|
@@ -228,26 +224,14 @@ module Daytona
|
|
|
228
224
|
# @return [DaytonaApiClient::SshAccessDto]
|
|
229
225
|
def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
|
|
230
226
|
|
|
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).
|
|
234
227
|
# @return [void]
|
|
235
|
-
def delete
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
228
|
+
def delete
|
|
229
|
+
sandbox_api.delete_sandbox(id)
|
|
230
|
+
refresh
|
|
231
|
+
rescue DaytonaApiClient::ApiError => e
|
|
232
|
+
raise unless e.code == 404
|
|
239
233
|
|
|
240
|
-
|
|
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
|
|
234
|
+
@state = 'destroyed'
|
|
251
235
|
end
|
|
252
236
|
|
|
253
237
|
# Gets the user's home directory path for the logged in user inside the Sandbox.
|
|
@@ -356,12 +340,7 @@ module Daytona
|
|
|
356
340
|
timeout:,
|
|
357
341
|
message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
|
|
358
342
|
setup: proc { process_response(sandbox_api.start_sandbox(id)) }
|
|
359
|
-
)
|
|
360
|
-
wait_for_state(
|
|
361
|
-
target_states: [DaytonaApiClient::SandboxState::STARTED],
|
|
362
|
-
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
363
|
-
)
|
|
364
|
-
end
|
|
343
|
+
) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
|
|
365
344
|
end
|
|
366
345
|
|
|
367
346
|
# Recovers the Sandbox from a recoverable error and waits for it to be ready.
|
|
@@ -378,12 +357,7 @@ module Daytona
|
|
|
378
357
|
timeout:,
|
|
379
358
|
message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
|
|
380
359
|
setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
|
|
381
|
-
)
|
|
382
|
-
wait_for_state(
|
|
383
|
-
target_states: [DaytonaApiClient::SandboxState::STARTED],
|
|
384
|
-
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
385
|
-
)
|
|
386
|
-
end
|
|
360
|
+
) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
|
|
387
361
|
rescue StandardError => e
|
|
388
362
|
raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
|
|
389
363
|
end
|
|
@@ -401,9 +375,9 @@ module Daytona
|
|
|
401
375
|
refresh
|
|
402
376
|
}
|
|
403
377
|
) do
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
378
|
+
wait_for_states(
|
|
379
|
+
operation: OPERATION_STOP,
|
|
380
|
+
target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED]
|
|
407
381
|
)
|
|
408
382
|
end
|
|
409
383
|
end
|
|
@@ -446,17 +420,9 @@ module Daytona
|
|
|
446
420
|
#
|
|
447
421
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
|
|
448
422
|
# @return [void]
|
|
449
|
-
def wait_for_resize_complete(
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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])
|
|
460
426
|
end
|
|
461
427
|
|
|
462
428
|
# Creates a new Language Server Protocol (LSP) server instance.
|
|
@@ -482,17 +448,8 @@ module Daytona
|
|
|
482
448
|
#
|
|
483
449
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
484
450
|
# @return [void]
|
|
485
|
-
def wait_for_sandbox_start(
|
|
486
|
-
|
|
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
|
|
451
|
+
def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
|
|
452
|
+
wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED])
|
|
496
453
|
end
|
|
497
454
|
|
|
498
455
|
# Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
|
|
@@ -501,17 +458,9 @@ module Daytona
|
|
|
501
458
|
#
|
|
502
459
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
503
460
|
# @return [void]
|
|
504
|
-
def wait_for_sandbox_stop(
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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])
|
|
515
464
|
end
|
|
516
465
|
|
|
517
466
|
instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
|
|
@@ -596,105 +545,40 @@ module Daytona
|
|
|
596
545
|
)
|
|
597
546
|
end
|
|
598
547
|
|
|
599
|
-
# Waits for the Sandbox to reach the one of the target states
|
|
600
|
-
#
|
|
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.
|
|
601
551
|
#
|
|
602
|
-
# @param
|
|
603
|
-
# @param
|
|
604
|
-
# @param safe_refresh [Boolean] If true, wrap refresh in rescue for delete operations (404s).
|
|
552
|
+
# @param operation [#to_s] Operation name for error message
|
|
553
|
+
# @param target_states [Array<DaytonaApiClient::SandboxState>] List of the target states
|
|
605
554
|
# @return [void]
|
|
606
555
|
# @raise [Daytona::Sdk::Error]
|
|
607
|
-
def
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
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}"
|
|
665
562
|
end
|
|
666
563
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
"Sandbox #{id} entered error state: #{result_state}, error reason: #{error_reason}"
|
|
670
|
-
end
|
|
671
|
-
ensure
|
|
672
|
-
unsubscribe&.call
|
|
564
|
+
sleep(IDLE_DURATION)
|
|
565
|
+
refresh
|
|
673
566
|
end
|
|
674
567
|
end
|
|
675
568
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
@event_subscriber.ensure_connected
|
|
569
|
+
IDLE_DURATION = 0.1
|
|
570
|
+
private_constant :IDLE_DURATION
|
|
680
571
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
events: ['sandbox.state.updated', 'sandbox.desired-state.updated', 'sandbox.created']
|
|
684
|
-
) do |_event_name, data|
|
|
685
|
-
next unless data.is_a?(Hash)
|
|
572
|
+
NO_TIMEOUT = 0
|
|
573
|
+
private_constant :NO_TIMEOUT
|
|
686
574
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
rescue StandardError
|
|
690
|
-
nil # Event payload may be incomplete
|
|
691
|
-
end
|
|
692
|
-
end
|
|
575
|
+
OPERATION_START = :start
|
|
576
|
+
private_constant :OPERATION_START
|
|
693
577
|
|
|
694
|
-
|
|
695
|
-
private_constant :
|
|
578
|
+
OPERATION_STOP = :stop
|
|
579
|
+
private_constant :OPERATION_STOP
|
|
696
580
|
|
|
697
|
-
|
|
698
|
-
private_constant :
|
|
581
|
+
OPERATION_RESIZE = :resize
|
|
582
|
+
private_constant :OPERATION_RESIZE
|
|
699
583
|
end
|
|
700
584
|
end
|
data/lib/daytona/sdk/version.rb
CHANGED
data/lib/daytona/sdk.rb
CHANGED
|
@@ -27,8 +27,6 @@ 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'
|
|
32
30
|
require_relative 'daytona'
|
|
33
31
|
require_relative 'file_system'
|
|
34
32
|
require_relative 'git'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: daytona
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.149.0.alpha.
|
|
4
|
+
version: 0.149.0.alpha.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daytona Platforms Inc.
|
|
@@ -85,28 +85,28 @@ dependencies:
|
|
|
85
85
|
requirements:
|
|
86
86
|
- - '='
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: 0.149.0.alpha.
|
|
88
|
+
version: 0.149.0.alpha.2
|
|
89
89
|
type: :runtime
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - '='
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: 0.149.0.alpha.
|
|
95
|
+
version: 0.149.0.alpha.2
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: daytona_toolbox_api_client
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
99
99
|
requirements:
|
|
100
100
|
- - '='
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: 0.149.0.alpha.
|
|
102
|
+
version: 0.149.0.alpha.2
|
|
103
103
|
type: :runtime
|
|
104
104
|
prerelease: false
|
|
105
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
106
106
|
requirements:
|
|
107
107
|
- - '='
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
|
-
version: 0.149.0.alpha.
|
|
109
|
+
version: 0.149.0.alpha.2
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
111
|
name: dotenv
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -171,7 +171,6 @@ files:
|
|
|
171
171
|
- lib/daytona/common/code_interpreter.rb
|
|
172
172
|
- lib/daytona/common/code_language.rb
|
|
173
173
|
- lib/daytona/common/daytona.rb
|
|
174
|
-
- lib/daytona/common/event_subscriber.rb
|
|
175
174
|
- lib/daytona/common/file_system.rb
|
|
176
175
|
- lib/daytona/common/git.rb
|
|
177
176
|
- lib/daytona/common/image.rb
|
|
@@ -180,7 +179,6 @@ files:
|
|
|
180
179
|
- lib/daytona/common/resources.rb
|
|
181
180
|
- lib/daytona/common/response.rb
|
|
182
181
|
- lib/daytona/common/snapshot.rb
|
|
183
|
-
- lib/daytona/common/socketio_client.rb
|
|
184
182
|
- lib/daytona/computer_use.rb
|
|
185
183
|
- lib/daytona/config.rb
|
|
186
184
|
- lib/daytona/daytona.rb
|
|
@@ -1,220 +0,0 @@
|
|
|
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
|
|
@@ -1,266 +0,0 @@
|
|
|
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
|