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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0111eaa3f2e1edf3951f4fb4c4ab90039a9bd264602f1f0da5c88b89982d6376
4
- data.tar.gz: 29fb26a2e6c4f55e3263e5e6f774d0e8625d4bca444d23aae3bf9e3e53731eaf
3
+ metadata.gz: 0c9abcf28500a42f47b2b00a80c2b5bba8cee32e555e75ba48d14bbfbdd30d89
4
+ data.tar.gz: 0ecf156c45fa732a0b117f78e6d04ef9fff6a30a0ccf48cd6d771698fa838a03
5
5
  SHA512:
6
- metadata.gz: d2b03e16b5d7d31005cae3d297525415335143717d624b1fd842dfde1ddacebb267189a52077070ef0dd887c3e9d8814a54d2176be154dafb2b4e2050fff3446
7
- data.tar.gz: da5ddce3d882d5f7fff7ad2d6f8e0b105fee37401742656c675743c8a41e51fb69c6c6f53815a29a9271818dc181685384c874e30c91ed309710cfc4a75a7cd3
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
- # Combine everything into the final command for JavaScript
16
- " sh -c 'echo #{base64_code} | base64 --decode | node -e \"$(cat)\" #{argv} 2>&1 | grep -vE \"npm notice\"' "
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.encode64(code)
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.encode64(
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
- # Execute the bootstrapper code directly
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.encode64(code)
13
+ encoded_code = Base64.strict_encode64(code)
14
14
 
15
15
  argv = params&.argv&.join(' ') || ''
16
16
 
17
- # Execute TypeScript code using ts-node with ESM support
18
- " sh -c 'echo #{encoded_code} | base64 --decode | npx ts-node -O " \
19
- "\"{\\\"module\\\":\\\"CommonJS\\\"}\" -e \"$(cat)\" x #{argv} 2>&1 | grep -vE " \
20
- "\"npm notice\"' "
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
@@ -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
 
@@ -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/AbcSize, Metrics/MethodLength
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}=$(echo '#{Base64.encode64(value)}' | base64 -d)"
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"))
@@ -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, event_subscriber: nil) # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
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(timeout = DEFAULT_TIMEOUT)
236
- process_response(sandbox_api.delete_sandbox(id))
237
-
238
- return if state.to_s == DaytonaApiClient::SandboxState::DESTROYED.to_s
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
- 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
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
- ) do
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
- ) do
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
- wait_for_state(
405
- target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
406
- error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
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(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
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(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
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(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
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 via WebSocket events
600
- # with periodic polling safety net.
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 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).
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 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
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
- 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
564
+ sleep(IDLE_DURATION)
565
+ refresh
673
566
  end
674
567
  end
675
568
 
676
- def subscribe_to_events
677
- return unless @event_subscriber
678
-
679
- @event_subscriber.ensure_connected
569
+ IDLE_DURATION = 0.1
570
+ private_constant :IDLE_DURATION
680
571
 
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)
572
+ NO_TIMEOUT = 0
573
+ private_constant :NO_TIMEOUT
686
574
 
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
575
+ OPERATION_START = :start
576
+ private_constant :OPERATION_START
693
577
 
694
- POLL_SAFETY_INTERVAL = 1
695
- private_constant :POLL_SAFETY_INTERVAL
578
+ OPERATION_STOP = :stop
579
+ private_constant :OPERATION_STOP
696
580
 
697
- NO_TIMEOUT = 0
698
- private_constant :NO_TIMEOUT
581
+ OPERATION_RESIZE = :resize
582
+ private_constant :OPERATION_RESIZE
699
583
  end
700
584
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Daytona
4
4
  module Sdk
5
- VERSION = '0.149.0.alpha.1'
5
+ VERSION = '0.149.0.alpha.2'
6
6
  end
7
7
  end
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.1
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.1
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.1
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.1
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.1
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