daytona 0.197.0 → 0.199.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,29 @@ module Daytona
17
17
  include Instrumentation
18
18
 
19
19
  DEFAULT_TIMEOUT = 60
20
+ def self.with_events(*method_names)
21
+ method_names.each do |method_name|
22
+ original = instance_method(method_name)
23
+
24
+ visibility = if private_method_defined?(method_name, false)
25
+ :private
26
+ elsif protected_method_defined?(method_name, false)
27
+ :protected
28
+ else
29
+ :public
30
+ end
31
+
32
+ define_method(method_name) do |*args, **kwargs, &blk|
33
+ ensure_subscribed
34
+ original.bind_call(self, *args, **kwargs, &blk)
35
+ end
36
+
37
+ case visibility
38
+ when :private then private method_name
39
+ when :protected then protected method_name
40
+ end
41
+ end
42
+ end
20
43
 
21
44
  SANDBOX_METRIC_FIELD_BY_NAME = {
22
45
  'daytona.sandbox.cpu.utilization' => :cpu_used_pct,
@@ -117,6 +140,9 @@ module Daytona
117
140
  # Not returned by list results; call #refresh on each item to populate.
118
141
  attr_reader :build_info
119
142
 
143
+ # @return [String, nil] When the sandbox will be automatically destroyed (nil if no TTL is set)
144
+ attr_reader :auto_destroy_at
145
+
120
146
  # @return [String] The creation timestamp of the sandbox
121
147
  attr_reader :created_at
122
148
 
@@ -129,6 +155,9 @@ module Daytona
129
155
  # @return [String] The version of the daemon running in the sandbox
130
156
  attr_reader :daemon_version
131
157
 
158
+ # @return [String] The toolbox proxy URL used to reach the sandbox's toolbox API
159
+ attr_reader :toolbox_proxy_url
160
+
132
161
  # @return [Daytona::Config]
133
162
  attr_reader :config
134
163
 
@@ -150,19 +179,27 @@ module Daytona
150
179
  # @return [Daytona::CodeInterpreter]
151
180
  attr_reader :code_interpreter
152
181
 
182
+ # Internal — obtain sandboxes via Daytona::Daytona#create, Daytona::Daytona#get,
183
+ # or Daytona::Daytona#list rather than constructing directly.
184
+ #
153
185
  # @params config [Daytona::Config]
154
186
  # @params sandbox_api [DaytonaApiClient::SandboxApi]
155
187
  # @params sandbox_dto [DaytonaApiClient::Sandbox, DaytonaApiClient::SandboxListItem]
156
188
  # @params otel_state [Daytona::OtelState, nil]
157
- def initialize(sandbox_dto:, config:, sandbox_api:, otel_state: nil, analytics_api_url_provider: nil) # rubocop:disable Metrics/MethodLength
189
+ def initialize(sandbox_dto:, config:, sandbox_api:, subscription_manager:, otel_state: nil, analytics_api_url_provider: nil) # rubocop:disable Metrics/MethodLength
190
+ @state_waiters = []
191
+ @waiter_mutex = Mutex.new
158
192
  process_response(sandbox_dto)
159
193
  @config = config
160
194
  @sandbox_api = sandbox_api
161
195
  @otel_state = otel_state
162
196
  @analytics_api_url_provider = analytics_api_url_provider
197
+ @subscription_manager = subscription_manager
198
+ @sub_id = nil
163
199
 
164
200
  # Create toolbox API clients with dynamic configuration
165
- toolbox_api_config = build_toolbox_api_config
201
+ @toolbox_api_config = build_toolbox_api_config
202
+ toolbox_api_config = @toolbox_api_config
166
203
 
167
204
  # Helper to create API client with authentication header
168
205
  create_authenticated_client = lambda do
@@ -205,6 +242,8 @@ module Daytona
205
242
  @info_api = info_api
206
243
  @server_api = server_api
207
244
  @system_api = system_api
245
+
246
+ ensure_subscribed
208
247
  end
209
248
 
210
249
  # Archives the sandbox, making it inactive and preserving its state. When sandboxes are
@@ -318,20 +357,73 @@ module Daytona
318
357
  @auto_pause_interval = interval
319
358
  end
320
359
 
360
+ # Sets the TTL (time to live) for the Sandbox.
361
+ # The TTL is re-anchored from the current time. When it elapses the Sandbox is destroyed,
362
+ # regardless of its current state. Use 0 to disable.
363
+ #
364
+ # @param minutes [Integer]
365
+ # @return [void]
366
+ # @raise [Daytona::Sdk::Error]
367
+ def ttl_minutes=(minutes)
368
+ raise Sdk::Error, 'TTL must be a non-negative integer' if minutes.negative?
369
+
370
+ sandbox_api.set_ttl(id, minutes)
371
+ end
372
+
321
373
  # Creates an SSH access token for the sandbox.
322
374
  #
323
375
  # @param expires_in_minutes [Integer] TThe number of minutes the SSH access token will be valid for
324
376
  # @return [DaytonaApiClient::SshAccessDto]
325
- def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
377
+ def create_ssh_access(expires_in_minutes)
378
+ sandbox_api.create_ssh_access(id, { expires_in_minutes: })
379
+ end
326
380
 
381
+ # Deletes the Sandbox.
382
+ #
383
+ # By default returns as soon as the deletion request is accepted (matching
384
+ # origin/main behavior). Pass +wait: true+ to block until the Sandbox
385
+ # reaches the +destroyed+ state.
386
+ #
387
+ # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
388
+ # Only meaningful when +wait+ is true.
389
+ # @param wait [Boolean] When +true+, block until the Sandbox is destroyed.
327
390
  # @return [void]
328
- def delete
329
- sandbox_api.delete_sandbox(id)
330
- refresh
331
- rescue DaytonaApiClient::ApiError => e
332
- raise unless e.code == 404
391
+ def delete(timeout = DEFAULT_TIMEOUT, wait: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
392
+ begin
393
+ process_response(sandbox_api.delete_sandbox(id))
394
+ rescue DaytonaApiClient::ApiError => e
395
+ raise unless e.code == 404
396
+
397
+ apply_state(DaytonaApiClient::SandboxState::DESTROYED)
398
+ end
399
+
400
+ if wait
401
+ refresh_data_safe
402
+ else
403
+ begin
404
+ refresh
405
+ rescue DaytonaApiClient::ApiError => e
406
+ raise unless e.code == 404
407
+
408
+ apply_state(DaytonaApiClient::SandboxState::DESTROYED)
409
+ end
410
+ return
411
+ end
412
+ return if state.to_s == DaytonaApiClient::SandboxState::DESTROYED.to_s
333
413
 
334
- @state = 'destroyed'
414
+ with_timeout(
415
+ timeout:,
416
+ message: "Sandbox #{id} failed to be destroyed within the #{timeout} seconds timeout period",
417
+ setup: nil
418
+ ) do
419
+ wait_for_state(
420
+ target_states: [DaytonaApiClient::SandboxState::DESTROYED],
421
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED],
422
+ safe_refresh: true
423
+ )
424
+ end
425
+ ensure
426
+ unsubscribe_from_events
335
427
  end
336
428
 
337
429
  # Gets the user's home directory path for the logged in user inside the Sandbox.
@@ -428,7 +520,9 @@ module Daytona
428
520
  #
429
521
  # @param port [Integer]
430
522
  # @return [DaytonaApiClient::PortPreviewUrl]
431
- def preview_url(port) = sandbox_api.get_port_preview_url(id, port)
523
+ def preview_url(port)
524
+ sandbox_api.get_port_preview_url(id, port)
525
+ end
432
526
 
433
527
  # Creates a signed preview URL for the sandbox at the specified port.
434
528
  #
@@ -460,7 +554,9 @@ module Daytona
460
554
  # Refresh the Sandbox data from the API.
461
555
  #
462
556
  # @return [void]
463
- def refresh = process_response(sandbox_api.get_sandbox(id))
557
+ def refresh
558
+ process_response(sandbox_api.get_sandbox(id))
559
+ end
464
560
 
465
561
  # Refreshes the sandbox activity to reset the timer for automated lifecycle management actions.
466
562
  #
@@ -482,7 +578,9 @@ module Daytona
482
578
  #
483
579
  # @param token [String]
484
580
  # @return [void]
485
- def revoke_ssh_access(token) = sandbox_api.revoke_ssh_access(id, token:)
581
+ def revoke_ssh_access(token)
582
+ sandbox_api.revoke_ssh_access(id, token:)
583
+ end
486
584
 
487
585
  # Starts the Sandbox and waits for it to be ready.
488
586
  #
@@ -493,7 +591,12 @@ module Daytona
493
591
  timeout:,
494
592
  message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
495
593
  setup: proc { process_response(sandbox_api.start_sandbox(id)) }
496
- ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
594
+ ) do
595
+ wait_for_state(
596
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
597
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
598
+ )
599
+ end
497
600
  end
498
601
 
499
602
  # Recovers the Sandbox from a recoverable error and waits for it to be ready.
@@ -510,7 +613,12 @@ module Daytona
510
613
  timeout:,
511
614
  message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
512
615
  setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
513
- ) { wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED]) }
616
+ ) do
617
+ wait_for_state(
618
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
619
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
620
+ )
621
+ end
514
622
  rescue StandardError => e
515
623
  raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
516
624
  end
@@ -529,9 +637,9 @@ module Daytona
529
637
  refresh
530
638
  }
531
639
  ) do
532
- wait_for_states(
533
- operation: OPERATION_STOP,
534
- target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED]
640
+ wait_for_state(
641
+ target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
642
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
535
643
  )
536
644
  end
537
645
  end
@@ -572,7 +680,7 @@ module Daytona
572
680
  resize_request = DaytonaApiClient::ResizeSandbox.new(resize_attrs)
573
681
  process_response(sandbox_api.resize_sandbox(id, resize_request))
574
682
  }
575
- ) { wait_for_resize_complete }
683
+ ) { wait_for_resize_complete(timeout) }
576
684
  end
577
685
 
578
686
  # Waits for the Sandbox resize operation to complete.
@@ -580,9 +688,18 @@ module Daytona
580
688
  #
581
689
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
582
690
  # @return [void]
583
- def wait_for_resize_complete(_timeout = DEFAULT_TIMEOUT)
584
- wait_for_states(operation: OPERATION_RESIZE, target_states: [DaytonaApiClient::SandboxState::STARTED,
585
- DaytonaApiClient::SandboxState::STOPPED])
691
+ def wait_for_resize_complete(timeout = DEFAULT_TIMEOUT)
692
+ with_timeout(
693
+ timeout:,
694
+ message: "Sandbox #{id} resize did not complete within the #{timeout} seconds timeout period",
695
+ setup: nil
696
+ ) do
697
+ error_states = [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
698
+ exclude = [DaytonaApiClient::SandboxState::RESIZING] + error_states
699
+ target_states = DaytonaApiClient::SandboxState.all_vars.reject { |s| exclude.include?(s) }
700
+
701
+ wait_for_state(target_states:, error_states:)
702
+ end
586
703
  end
587
704
 
588
705
  # Creates a new Language Server Protocol (LSP) server instance.
@@ -601,15 +718,26 @@ module Daytona
601
718
  #
602
719
  # @param token [String]
603
720
  # @return [DaytonaApiClient::SshAccessValidationDto]
604
- def validate_ssh_access(token) = sandbox_api.validate_ssh_access(token)
721
+ def validate_ssh_access(token)
722
+ sandbox_api.validate_ssh_access(token)
723
+ end
605
724
 
606
725
  # Waits for the Sandbox to reach the 'started' state. Polls the Sandbox status until it
607
726
  # reaches the 'started' state or encounters an error.
608
727
  #
609
728
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
610
729
  # @return [void]
611
- def wait_for_sandbox_start(_timeout = DEFAULT_TIMEOUT)
612
- wait_for_states(operation: OPERATION_START, target_states: [DaytonaApiClient::SandboxState::STARTED])
730
+ def wait_for_sandbox_start(timeout = DEFAULT_TIMEOUT)
731
+ with_timeout(
732
+ timeout:,
733
+ message: "Sandbox #{id} failed to start within the #{timeout} seconds timeout period",
734
+ setup: nil
735
+ ) do
736
+ wait_for_state(
737
+ target_states: [DaytonaApiClient::SandboxState::STARTED],
738
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
739
+ )
740
+ end
613
741
  end
614
742
 
615
743
  # Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
@@ -618,9 +746,17 @@ module Daytona
618
746
  #
619
747
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
620
748
  # @return [void]
621
- def wait_for_sandbox_stop(_timeout = DEFAULT_TIMEOUT)
622
- wait_for_states(operation: OPERATION_STOP, target_states: [DaytonaApiClient::SandboxState::STOPPED,
623
- DaytonaApiClient::SandboxState::DESTROYED])
749
+ def wait_for_sandbox_stop(timeout = DEFAULT_TIMEOUT)
750
+ with_timeout(
751
+ timeout:,
752
+ message: "Sandbox #{id} failed to stop within the #{timeout} seconds timeout period",
753
+ setup: nil
754
+ ) do
755
+ wait_for_state(
756
+ target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
757
+ error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
758
+ )
759
+ end
624
760
  end
625
761
 
626
762
  # Forks the Sandbox, creating a new Sandbox with an identical filesystem.
@@ -643,12 +779,11 @@ module Daytona
643
779
  sandbox_dto: forked_dto,
644
780
  config:,
645
781
  sandbox_api:,
646
- code_toolbox:,
782
+ subscription_manager: @subscription_manager,
647
783
  otel_state:,
648
784
  analytics_api_url_provider: @analytics_api_url_provider
649
785
  )
650
- forked.send(:wait_for_states, operation: OPERATION_START,
651
- target_states: [DaytonaApiClient::SandboxState::STARTED])
786
+ forked.wait_for_sandbox_start(timeout)
652
787
  return forked
653
788
  end
654
789
  end
@@ -664,14 +799,15 @@ module Daytona
664
799
  timeout:,
665
800
  message: "Sandbox #{id} snapshot failed within the #{timeout} seconds timeout period",
666
801
  setup: proc {
667
- sandbox_api.create_sandbox_snapshot(id, DaytonaApiClient::CreateSandboxSnapshot.new(name:))
668
- refresh
802
+ response = sandbox_api.create_sandbox_snapshot(id, DaytonaApiClient::CreateSandboxSnapshot.new(name:))
803
+ process_response(response)
669
804
  }
670
805
  ) { wait_for_snapshot_complete }
671
806
  end
672
807
 
673
808
  # Pauses the Sandbox, freezing all running processes.
674
- # The Sandbox will enter a 'pausing' state and transition to 'paused' when complete.
809
+ # Completes when the Sandbox has left the +pausing+ state (paused, stopped,
810
+ # archived, etc.); error states still raise.
675
811
  #
676
812
  # @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
677
813
  # @return [void]
@@ -683,10 +819,25 @@ module Daytona
683
819
  sandbox_api.pause_sandbox(id)
684
820
  refresh
685
821
  }
686
- ) { wait_for_pause_complete }
822
+ ) do
823
+ error_states = [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
824
+ exclude = [DaytonaApiClient::SandboxState::PAUSING] + error_states
825
+ target_states = DaytonaApiClient::SandboxState.all_vars.reject { |s| exclude.include?(s) }
826
+
827
+ wait_for_state(target_states:, error_states:)
828
+ end
687
829
  end
688
830
 
831
+ with_events :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
832
+ :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :labels=,
833
+ :preview_url, :create_signed_preview_url, :expire_signed_preview_url,
834
+ :refresh, :refresh_activity, :revoke_ssh_access, :start, :recover, :stop,
835
+ :create_lsp_server, :validate_ssh_access, :wait_for_sandbox_start,
836
+ :wait_for_sandbox_stop, :resize, :wait_for_resize_complete,
837
+ :experimental_fork, :experimental_create_snapshot, :pause
838
+
689
839
  instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_pause_interval=, :auto_stop_interval=,
840
+ :ttl_minutes=,
690
841
  :update_network_settings, :update_secrets, :update_env,
691
842
  :create_ssh_access, :delete, :get_user_home_dir, :get_work_dir, :get_metrics, :get_metrics_latest,
692
843
  :labels=,
@@ -782,6 +933,11 @@ module Daytona
782
933
  # @return [DaytonaToolboxApiClient::Configuration]
783
934
  def build_toolbox_api_config
784
935
  DaytonaToolboxApiClient::Configuration.new.configure do |cfg|
936
+ if @toolbox_proxy_url.nil? || @toolbox_proxy_url.empty?
937
+ proxy_response = @sandbox_api.get_toolbox_proxy_url(id)
938
+ @toolbox_proxy_url = proxy_response&.url || ''
939
+ end
940
+
785
941
  proxy_url = @toolbox_proxy_url
786
942
  proxy_url += '/' unless proxy_url.end_with?('/')
787
943
  full_url = "#{proxy_url}#{id}"
@@ -795,9 +951,26 @@ module Daytona
795
951
  end
796
952
  end
797
953
 
954
+ def update_toolbox_base_url
955
+ return unless @toolbox_api_config && @toolbox_proxy_url
956
+
957
+ proxy_url = @toolbox_proxy_url
958
+ proxy_url += '/' unless proxy_url.end_with?('/')
959
+ full_url = "#{proxy_url}#{id}"
960
+ uri = URI(full_url)
961
+
962
+ @toolbox_api_config.scheme = uri.scheme
963
+ @toolbox_api_config.host = uri.authority
964
+ @toolbox_api_config.base_path = uri.path.empty? ? '/' : uri.path
965
+ end
966
+
798
967
  # @params sandbox_dto [DaytonaApiClient::Sandbox, DaytonaApiClient::SandboxListItem]
799
968
  # @return [void]
800
969
  def process_response(sandbox_dto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
970
+ return if sandbox_dto.nil?
971
+
972
+ new_state = sandbox_dto.state
973
+
801
974
  # Fields shared by both DaytonaApiClient::Sandbox and DaytonaApiClient::SandboxListItem.
802
975
  @id = sandbox_dto.id
803
976
  @organization_id = sandbox_dto.organization_id
@@ -810,7 +983,6 @@ module Daytona
810
983
  @gpu = sandbox_dto.gpu
811
984
  @memory = sandbox_dto.memory
812
985
  @disk = sandbox_dto.disk
813
- @state = sandbox_dto.state
814
986
  @desired_state = sandbox_dto.desired_state
815
987
  @error_reason = sandbox_dto.error_reason
816
988
  @backup_state = sandbox_dto.backup_state
@@ -818,23 +990,43 @@ module Daytona
818
990
  @auto_pause_interval = sandbox_dto.auto_pause_interval
819
991
  @auto_archive_interval = sandbox_dto.auto_archive_interval
820
992
  @auto_delete_interval = sandbox_dto.auto_delete_interval
993
+ @auto_destroy_at = sandbox_dto.auto_destroy_at
821
994
  @created_at = sandbox_dto.created_at
822
995
  @updated_at = sandbox_dto.updated_at
823
996
  @last_activity_at = sandbox_dto.last_activity_at
824
997
  @daemon_version = sandbox_dto.daemon_version
825
- @toolbox_proxy_url = sandbox_dto.toolbox_proxy_url
998
+
999
+ new_proxy_url = sandbox_dto.toolbox_proxy_url
1000
+ if new_proxy_url && !new_proxy_url.empty? && new_proxy_url != @toolbox_proxy_url
1001
+ @toolbox_proxy_url = new_proxy_url
1002
+ update_toolbox_base_url
1003
+ end
826
1004
 
827
1005
  # Fields only present on the full DaytonaApiClient::Sandbox DTO (not returned by list
828
1006
  # results; call #refresh on each item to populate them).
829
- return unless sandbox_dto.is_a?(DaytonaApiClient::Sandbox)
1007
+ if sandbox_dto.is_a?(DaytonaApiClient::Sandbox)
1008
+ @env = sandbox_dto.env
1009
+ @network_block_all = sandbox_dto.network_block_all
1010
+ @network_allow_list = sandbox_dto.network_allow_list
1011
+ @domain_allow_list = sandbox_dto.domain_allow_list
1012
+ @volumes = sandbox_dto.volumes
1013
+ @build_info = sandbox_dto.build_info
1014
+ @backup_created_at = sandbox_dto.backup_created_at
1015
+ end
830
1016
 
831
- @env = sandbox_dto.env
832
- @network_block_all = sandbox_dto.network_block_all
833
- @network_allow_list = sandbox_dto.network_allow_list
834
- @domain_allow_list = sandbox_dto.domain_allow_list
835
- @volumes = sandbox_dto.volumes
836
- @build_info = sandbox_dto.build_info
837
- @backup_created_at = sandbox_dto.backup_created_at
1017
+ apply_state(new_state)
1018
+ end
1019
+
1020
+ def apply_state(new_state)
1021
+ return if new_state.to_s == @state.to_s
1022
+
1023
+ @state = new_state
1024
+
1025
+ @waiter_mutex.synchronize do
1026
+ @state_waiters.each do |waiter|
1027
+ waiter[:notify].call(new_state.to_s) if waiter[:matches].call(new_state.to_s)
1028
+ end
1029
+ end
838
1030
  end
839
1031
 
840
1032
  # Monitors block not to exceed max execution time.
@@ -848,99 +1040,200 @@ module Daytona
848
1040
  start_at = Time.now
849
1041
  setup&.call
850
1042
 
851
- Timeout.timeout(
852
- setup ? [NO_TIMEOUT, timeout - (Time.now - start_at)].max : timeout,
853
- Sdk::Error,
854
- message,
855
- &
856
- )
1043
+ effective = if setup
1044
+ remaining = timeout - (Time.now - start_at)
1045
+ raise Sdk::TimeoutError, message if timeout.positive? && remaining <= 0
1046
+
1047
+ [NO_TIMEOUT, remaining].max
1048
+ else
1049
+ timeout
1050
+ end
1051
+
1052
+ Timeout.timeout(effective, Sdk::TimeoutError, message, &)
857
1053
  end
858
1054
 
859
- # Waits for the Sandbox to reach the one of the target states. Polls the Sandbox status until it
860
- # reaches the one of the target states or encounters an error. It will wait up to 60 seconds
861
- # for the Sandbox to reach one of the target states.
1055
+ # Waits for the Sandbox to reach one of the target states via WebSocket events
1056
+ # with periodic polling safety net.
862
1057
  #
863
- # @param operation [#to_s] Operation name for error message
864
- # @param target_states [Array<DaytonaApiClient::SandboxState>] List of the target states
1058
+ # @param target_states [Array<DaytonaApiClient::SandboxState>] States that indicate success.
1059
+ # @param error_states [Array<DaytonaApiClient::SandboxState>] States that indicate failure.
1060
+ # @param safe_refresh [Boolean] If true, use refresh_data_safe for polling (swallows transient errors).
865
1061
  # @return [void]
866
1062
  # @raise [Daytona::Sdk::Error]
867
- def wait_for_states(operation:, target_states:)
868
- interval = INITIAL_POLL_INTERVAL
869
- start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
870
- loop do
871
- case state
872
- when *target_states then return
873
- when DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED
874
- raise Sdk::Error, "Sandbox #{id} failed to #{operation} with state: #{state}, error reason: #{error_reason}"
1063
+ def wait_for_state(target_states:, error_states:, safe_refresh: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
1064
+ ensure_subscribed
1065
+ subscribed = !@sub_id.nil?
1066
+
1067
+ target_strings = target_states.map(&:to_s)
1068
+ error_strings = error_states.map(&:to_s)
1069
+ started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
1070
+ poll_interval = subscribed ? MAX_POLL_INTERVAL : INITIAL_POLL_INTERVAL
1071
+
1072
+ mutex = Mutex.new
1073
+ state_changed = ConditionVariable.new
1074
+ result_state = nil
1075
+
1076
+ waiter = {
1077
+ matches: lambda do |new_state|
1078
+ target_strings.include?(new_state) || error_strings.include?(new_state)
1079
+ end,
1080
+ notify: lambda do |new_state|
1081
+ mutex.synchronize do
1082
+ next if result_state
1083
+
1084
+ result_state = new_state
1085
+ state_changed.signal
1086
+ end
1087
+ end
1088
+ }
1089
+
1090
+ @waiter_mutex.synchronize do
1091
+ @state_waiters << waiter
1092
+
1093
+ current_state = state.to_s
1094
+ result_state = current_state if target_strings.include?(current_state)
1095
+ end
1096
+
1097
+ begin
1098
+ first_poll = true
1099
+ until mutex.synchronize { result_state }
1100
+ unless first_poll
1101
+ should_refresh = mutex.synchronize do
1102
+ next false if result_state
1103
+
1104
+ state_changed.wait(mutex, poll_interval)
1105
+ result_state.nil?
1106
+ end
1107
+ next unless should_refresh
1108
+ end
1109
+ first_poll = false
1110
+
1111
+ safe_refresh ? refresh_data_safe : refresh
1112
+
1113
+ mutex.synchronize do
1114
+ next if result_state
1115
+
1116
+ current = state.to_s
1117
+ result_state = current if target_strings.include?(current) || error_strings.include?(current)
1118
+ end
1119
+
1120
+ poll_interval = next_wait_for_state_poll_interval(
1121
+ current_interval: poll_interval,
1122
+ started_at: started_at,
1123
+ subscribed: subscribed
1124
+ )
875
1125
  end
876
1126
 
877
- sleep(interval)
878
- if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
879
- interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
1127
+ if result_state && error_strings.include?(result_state)
1128
+ raise Sdk::Error,
1129
+ "Sandbox #{id} entered error state: #{result_state}, error reason: #{error_reason}"
1130
+ end
1131
+ rescue Sdk::TimeoutError => e
1132
+ begin
1133
+ safe_refresh ? refresh_data_safe : refresh
1134
+ rescue StandardError
1135
+ raise e
1136
+ end
1137
+ current = state.to_s
1138
+ return if target_strings.include?(current)
1139
+
1140
+ if error_strings.include?(current)
1141
+ raise Sdk::Error,
1142
+ "Sandbox #{id} entered error state: #{current}, error reason: #{error_reason}"
1143
+ end
1144
+
1145
+ raise e
1146
+ ensure
1147
+ @waiter_mutex.synchronize do
1148
+ @state_waiters.delete(waiter)
880
1149
  end
881
- refresh
882
1150
  end
883
1151
  end
884
1152
 
885
- INITIAL_POLL_INTERVAL = 0.1
886
- private_constant :INITIAL_POLL_INTERVAL
1153
+ def refresh_data_safe
1154
+ refresh
1155
+ rescue DaytonaApiClient::ApiError => e
1156
+ apply_state(DaytonaApiClient::SandboxState::DESTROYED) if e.code == 404
1157
+ rescue StandardError # rubocop:disable Lint/SuppressedException
1158
+ end
887
1159
 
888
- MAX_POLL_INTERVAL = 1.0
889
- private_constant :MAX_POLL_INTERVAL
1160
+ def ensure_subscribed
1161
+ @waiter_mutex.synchronize do
1162
+ return unless @subscription_manager
890
1163
 
891
- BACKOFF_MULTIPLIER = 1.1
892
- private_constant :BACKOFF_MULTIPLIER
1164
+ if @sub_id
1165
+ return if @subscription_manager.refresh(@sub_id)
893
1166
 
894
- NO_TIMEOUT = 0
895
- private_constant :NO_TIMEOUT
896
-
897
- OPERATION_START = :start
898
- private_constant :OPERATION_START
1167
+ @sub_id = nil
1168
+ end
899
1169
 
900
- OPERATION_STOP = :stop
901
- private_constant :OPERATION_STOP
1170
+ subscribe_to_events
1171
+ end
1172
+ end
902
1173
 
903
- OPERATION_RESIZE = :resize
904
- private_constant :OPERATION_RESIZE
1174
+ def subscribe_to_events
1175
+ return if @sub_id || !@subscription_manager
905
1176
 
906
- def wait_for_snapshot_complete
907
- interval = INITIAL_POLL_INTERVAL
908
- start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
909
- while state == DaytonaApiClient::SandboxState::SNAPSHOTTING
910
- refresh
1177
+ @sub_id = @subscription_manager.subscribe(
1178
+ resource_id: id,
1179
+ handler: method(:handle_event),
1180
+ events: ['sandbox.state.updated', 'sandbox.created']
1181
+ )
1182
+ end
911
1183
 
912
- if [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED].include?(state)
913
- raise Sdk::Error,
914
- "Sandbox #{id} snapshot failed with state: #{state}, error reason: #{error_reason}"
915
- end
1184
+ def handle_event(event_name, data)
1185
+ return unless data.is_a?(Hash)
916
1186
 
917
- break if state != DaytonaApiClient::SandboxState::SNAPSHOTTING
1187
+ raw = data['sandbox'] || data
1188
+ return unless raw.is_a?(Hash)
918
1189
 
919
- sleep(interval)
920
- if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
921
- interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
922
- end
1190
+ if event_name == 'sandbox.created'
1191
+ process_response(DaytonaApiClient::Sandbox.build_from_hash(raw))
1192
+ else
1193
+ new_state = raw['state'] || data['newState']
1194
+ apply_state(new_state) if new_state
923
1195
  end
1196
+ rescue StandardError
1197
+ nil
924
1198
  end
925
1199
 
926
- def wait_for_pause_complete
927
- interval = INITIAL_POLL_INTERVAL
928
- start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
929
- while state == DaytonaApiClient::SandboxState::PAUSING
930
- refresh
1200
+ def unsubscribe_from_events
1201
+ return unless @sub_id
931
1202
 
932
- if [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED].include?(state)
933
- raise Sdk::Error,
934
- "Sandbox #{id} pause failed with state: #{state}, error reason: #{error_reason}"
935
- end
1203
+ @subscription_manager&.unsubscribe(@sub_id)
1204
+ @sub_id = nil
1205
+ end
936
1206
 
937
- break if state != DaytonaApiClient::SandboxState::PAUSING
1207
+ def next_wait_for_state_poll_interval(current_interval:, started_at:, subscribed:)
1208
+ return [current_interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min if subscribed
938
1209
 
939
- sleep(interval)
940
- if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time > 5
941
- interval = [interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
942
- end
943
- end
1210
+ elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at
1211
+ return current_interval if elapsed <= POLL_BACKOFF_DELAY
1212
+
1213
+ [current_interval * BACKOFF_MULTIPLIER, MAX_POLL_INTERVAL].min
1214
+ end
1215
+
1216
+ INITIAL_POLL_INTERVAL = 0.1
1217
+ private_constant :INITIAL_POLL_INTERVAL
1218
+
1219
+ MAX_POLL_INTERVAL = 1.0
1220
+ private_constant :MAX_POLL_INTERVAL
1221
+
1222
+ BACKOFF_MULTIPLIER = 1.1
1223
+ private_constant :BACKOFF_MULTIPLIER
1224
+
1225
+ POLL_BACKOFF_DELAY = 5.0
1226
+ private_constant :POLL_BACKOFF_DELAY
1227
+
1228
+ NO_TIMEOUT = 0
1229
+ private_constant :NO_TIMEOUT
1230
+
1231
+ def wait_for_snapshot_complete
1232
+ error_states = [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
1233
+ exclude = [DaytonaApiClient::SandboxState::SNAPSHOTTING] + error_states
1234
+ target_states = DaytonaApiClient::SandboxState.all_vars.reject { |s| exclude.include?(s) }
1235
+
1236
+ wait_for_state(target_states:, error_states:)
944
1237
  end
945
1238
  end
946
1239
  end