daytona 0.199.0 → 0.200.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/daytona/common/event_subscription_manager.rb +110 -60
- data/lib/daytona/common/socketio_client.rb +77 -6
- data/lib/daytona/sdk/version.rb +1 -1
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 714770a5cd03bbfb27825fd9c018333d0377db70c0ce32b7ed484ff62946b2da
|
|
4
|
+
data.tar.gz: 1ea5e87028fffadb41659ceb7d76e6ec635ce2801e27f53bf1319d02b66ea6a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f8bec4d5a367c8c1f2cc48bab297903dd2b75f03460c0c72728842a639da4dd00c781ec5668f64aef5375af1955f3e1cb6221577dfa4d3fd237c97fbb6056e2
|
|
7
|
+
data.tar.gz: 19a3b2b8bafb2c5a9fc14777411de38abe6a6ac17b1c0d132af9334142ab915ef3aa325aa8184e1ac999df7e0c867a47e8d3691975b09be3ca6a98a80584b8b8
|
|
@@ -6,14 +6,29 @@
|
|
|
6
6
|
require 'securerandom'
|
|
7
7
|
|
|
8
8
|
module Daytona
|
|
9
|
+
# Tracks dispatcher subscriptions with TTL auto-expiry.
|
|
10
|
+
#
|
|
11
|
+
# All subscriptions share ONE lazily started expiry worker thread instead of a
|
|
12
|
+
# dedicated sleeping thread per subscription, so listing many sandboxes costs
|
|
13
|
+
# one thread total, not one thread each
|
|
14
|
+
# (https://github.com/daytona/clients/issues/108). #refresh only bumps the
|
|
15
|
+
# subscription deadline — it never creates or kills threads.
|
|
9
16
|
class EventSubscriptionManager
|
|
10
17
|
SUBSCRIPTION_TTL = 300
|
|
11
18
|
private_constant :SUBSCRIPTION_TTL
|
|
12
19
|
|
|
13
|
-
def initialize(dispatcher = nil)
|
|
20
|
+
def initialize(dispatcher = nil, ttl_seconds: SUBSCRIPTION_TTL)
|
|
14
21
|
@dispatcher = dispatcher
|
|
22
|
+
@ttl = ttl_seconds
|
|
15
23
|
@subscriptions = {}
|
|
24
|
+
# Deadline-ordered [expires_at, sub_id] entries. Entries are lazily
|
|
25
|
+
# invalidated: a popped entry is discarded when its subscription is gone,
|
|
26
|
+
# or re-queued at the current deadline when it was refreshed meanwhile.
|
|
27
|
+
# Invariant: every live subscription has at least one queue entry.
|
|
28
|
+
@expiry_queue = []
|
|
16
29
|
@mutex = Mutex.new
|
|
30
|
+
@cond = ConditionVariable.new
|
|
31
|
+
@worker = nil
|
|
17
32
|
@closed = false
|
|
18
33
|
end
|
|
19
34
|
|
|
@@ -24,7 +39,6 @@ module Daytona
|
|
|
24
39
|
|
|
25
40
|
unsubscribe = @dispatcher.subscribe(resource_id, events:, &handler)
|
|
26
41
|
sub_id = SecureRandom.hex(16)
|
|
27
|
-
timer_to_stop = nil
|
|
28
42
|
rollback_unsubscribe = nil
|
|
29
43
|
|
|
30
44
|
@mutex.synchronize do
|
|
@@ -34,8 +48,11 @@ module Daytona
|
|
|
34
48
|
next
|
|
35
49
|
end
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
expires_at = monotonic_now + @ttl
|
|
52
|
+
@subscriptions[sub_id] = { unsubscribe:, expires_at: }
|
|
53
|
+
push_entry_locked(expires_at, sub_id)
|
|
54
|
+
ensure_worker_locked
|
|
55
|
+
@cond.signal
|
|
39
56
|
end
|
|
40
57
|
|
|
41
58
|
if rollback_unsubscribe
|
|
@@ -43,44 +60,36 @@ module Daytona
|
|
|
43
60
|
return nil
|
|
44
61
|
end
|
|
45
62
|
|
|
46
|
-
stop_thread(timer_to_stop)
|
|
47
63
|
sub_id
|
|
48
64
|
end
|
|
49
65
|
|
|
50
66
|
def refresh(sub_id)
|
|
51
67
|
@mutex.synchronize do
|
|
52
|
-
# Reject after shutdown to prevent use-after-close
|
|
53
68
|
return false if @closed
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
timer_to_stop = nil
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
return false unless
|
|
70
|
+
subscription = @subscriptions[sub_id]
|
|
71
|
+
return false unless subscription
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
# No queue entry or worker wake-up needed: when the old deadline pops,
|
|
74
|
+
# the worker sees the newer expires_at and re-queues the subscription.
|
|
75
|
+
subscription[:expires_at] = monotonic_now + @ttl
|
|
76
|
+
true
|
|
62
77
|
end
|
|
63
|
-
|
|
64
|
-
stop_thread(timer_to_stop)
|
|
65
|
-
true
|
|
66
78
|
end
|
|
67
79
|
|
|
68
80
|
def unsubscribe(sub_id)
|
|
69
|
-
subscription =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
subscription = @mutex.synchronize do
|
|
82
|
+
removed = @subscriptions.delete(sub_id)
|
|
83
|
+
# The stale queue entry is discarded when the worker pops it — except
|
|
84
|
+
# when it was the last subscription: drop the leftovers and wake the
|
|
85
|
+
# worker so it exits now instead of idling until the old deadline.
|
|
86
|
+
if removed && @subscriptions.empty?
|
|
87
|
+
@expiry_queue.clear
|
|
88
|
+
@cond.signal
|
|
77
89
|
end
|
|
90
|
+
removed
|
|
78
91
|
end
|
|
79
|
-
|
|
80
|
-
return unless subscription
|
|
81
|
-
|
|
82
|
-
stop_thread(timer)
|
|
83
|
-
subscription[:unsubscribe].call
|
|
92
|
+
subscription&.dig(:unsubscribe)&.call
|
|
84
93
|
end
|
|
85
94
|
|
|
86
95
|
def shutdown
|
|
@@ -90,55 +99,96 @@ module Daytona
|
|
|
90
99
|
@closed = true
|
|
91
100
|
subscriptions = @subscriptions.values
|
|
92
101
|
@subscriptions = {}
|
|
102
|
+
@expiry_queue.clear
|
|
103
|
+
@cond.broadcast
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
subscriptions.each
|
|
96
|
-
stop_thread(subscription[:timer])
|
|
97
|
-
subscription[:unsubscribe].call
|
|
98
|
-
end
|
|
106
|
+
subscriptions.each { |subscription| subscription[:unsubscribe].call }
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
private
|
|
102
110
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
def monotonic_now
|
|
112
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
113
|
+
end
|
|
106
114
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
# Caller must hold @mutex.
|
|
116
|
+
def push_entry_locked(expires_at, sub_id)
|
|
117
|
+
index = @expiry_queue.bsearch_index { |entry| entry[0] > expires_at } || @expiry_queue.length
|
|
118
|
+
@expiry_queue.insert(index, [expires_at, sub_id])
|
|
119
|
+
end
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
# Caller must hold @mutex. The alive? check is a safety net: a worker that
|
|
122
|
+
# crashed for any reason leaves a dead-but-truthy reference, and a plain nil
|
|
123
|
+
# check would then block replacements forever, silently disabling expiry.
|
|
124
|
+
def ensure_worker_locked
|
|
125
|
+
return if @worker&.alive?
|
|
114
126
|
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
@worker = Thread.new { expiry_loop }
|
|
128
|
+
@worker.name = 'daytona-subscription-expiry'
|
|
129
|
+
@worker.abort_on_exception = false
|
|
130
|
+
end
|
|
117
131
|
|
|
118
|
-
|
|
132
|
+
def expiry_loop
|
|
133
|
+
loop do
|
|
134
|
+
expired = collect_expired
|
|
135
|
+
return if expired.nil?
|
|
136
|
+
|
|
137
|
+
expired.each do |subscription|
|
|
138
|
+
subscription[:unsubscribe].call
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# One failing dispatcher unsubscribe must not kill the shared worker
|
|
141
|
+
# and silently stop expiry for every other subscription.
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
119
144
|
end
|
|
120
|
-
timer.abort_on_exception = false
|
|
121
|
-
subscription[:timer] = timer
|
|
122
|
-
previous_timer
|
|
123
145
|
end
|
|
124
146
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
# Blocks until at least one subscription expires. Returns nil when the
|
|
148
|
+
# worker should exit (shutdown, or no live subscriptions remain).
|
|
149
|
+
def collect_expired
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
expired = []
|
|
152
|
+
|
|
153
|
+
while expired.empty?
|
|
154
|
+
return nil if @closed
|
|
155
|
+
|
|
156
|
+
if @expiry_queue.empty?
|
|
157
|
+
# No live subscriptions remain (see queue invariant above):
|
|
158
|
+
# exit and let the next subscribe start a fresh worker.
|
|
159
|
+
@worker = nil
|
|
160
|
+
return nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
now = monotonic_now
|
|
164
|
+
deadline = @expiry_queue.first[0]
|
|
165
|
+
if deadline > now
|
|
166
|
+
@cond.wait(@mutex, deadline - now)
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
drain_due_entries_locked(now, expired)
|
|
171
|
+
end
|
|
128
172
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
timer
|
|
173
|
+
expired
|
|
174
|
+
end
|
|
132
175
|
end
|
|
133
176
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
# Caller must hold @mutex.
|
|
178
|
+
def drain_due_entries_locked(now, expired)
|
|
179
|
+
while !@expiry_queue.empty? && @expiry_queue.first[0] <= now
|
|
180
|
+
_, sub_id = @expiry_queue.shift
|
|
181
|
+
subscription = @subscriptions[sub_id]
|
|
182
|
+
next unless subscription
|
|
137
183
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
184
|
+
if subscription[:expires_at] > now
|
|
185
|
+
push_entry_locked(subscription[:expires_at], sub_id)
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@subscriptions.delete(sub_id)
|
|
190
|
+
expired << subscription
|
|
191
|
+
end
|
|
142
192
|
end
|
|
143
193
|
end
|
|
144
194
|
end
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
require 'timeout'
|
|
7
7
|
|
|
8
|
+
require 'openssl'
|
|
8
9
|
require 'websocket-client-simple'
|
|
9
10
|
require 'json'
|
|
10
11
|
require 'uri'
|
|
@@ -52,6 +53,8 @@ module Daytona
|
|
|
52
53
|
@connected = false
|
|
53
54
|
@mutex = Mutex.new
|
|
54
55
|
@write_mutex = Mutex.new
|
|
56
|
+
@verify_mutex = Mutex.new
|
|
57
|
+
@tls_verified = nil
|
|
55
58
|
@health_thread = nil
|
|
56
59
|
@ping_interval = 25
|
|
57
60
|
@ping_timeout = 20
|
|
@@ -67,13 +70,24 @@ module Daytona
|
|
|
67
70
|
ws_url = build_ws_url
|
|
68
71
|
connected_queue = Queue.new
|
|
69
72
|
|
|
73
|
+
# Reset per-connection TLS state so a reconnect re-verifies its new socket
|
|
74
|
+
# instead of reusing a cached result from the previous connection.
|
|
75
|
+
@verify_mutex.synchronize { @tls_verified = nil }
|
|
76
|
+
|
|
70
77
|
# Capture self because websocket-client-simple uses instance_exec for callbacks
|
|
71
78
|
client = self
|
|
72
79
|
|
|
73
80
|
# Register callbacks inside the connect block so they are installed before the
|
|
74
81
|
# socket dials — otherwise the Engine.IO handshake can arrive before :message
|
|
75
82
|
# is bound, stalling the connection into a polling fallback/timeout.
|
|
76
|
-
|
|
83
|
+
#
|
|
84
|
+
# verify_mode is passed explicitly: websocket-client-simple builds its own
|
|
85
|
+
# SSLContext and defaults to no certificate verification (VERIFY_NONE).
|
|
86
|
+
@ws = WebSocket::Client::Simple.connect(ws_url, verify_mode: OpenSSL::SSL::VERIFY_PEER) do |ws|
|
|
87
|
+
# Capture the client before its reader thread starts so #verify_tls_peer!
|
|
88
|
+
# can reach the SSL socket even when a frame arrives before .connect returns.
|
|
89
|
+
@ws = ws
|
|
90
|
+
|
|
77
91
|
ws.on :message do |msg|
|
|
78
92
|
client.send(:handle_raw_message, msg.data.to_s, connected_queue)
|
|
79
93
|
end
|
|
@@ -104,15 +118,26 @@ module Daytona
|
|
|
104
118
|
end
|
|
105
119
|
end
|
|
106
120
|
|
|
107
|
-
# Wait for connection with timeout
|
|
108
121
|
result = nil
|
|
109
122
|
begin
|
|
110
|
-
Timeout.timeout(@connect_timeout)
|
|
123
|
+
Timeout.timeout(@connect_timeout) do
|
|
124
|
+
ensure_tls_verified!(connected_queue)
|
|
125
|
+
result = connected_queue.pop
|
|
126
|
+
if result == :open
|
|
127
|
+
send_connect_auth
|
|
128
|
+
result = connected_queue.pop
|
|
129
|
+
end
|
|
130
|
+
end
|
|
111
131
|
rescue Timeout::Error
|
|
112
132
|
close
|
|
113
133
|
raise "WebSocket connection timed out after #{@connect_timeout}s"
|
|
114
134
|
end
|
|
115
135
|
|
|
136
|
+
if result.is_a?(OpenSSL::SSL::SSLError)
|
|
137
|
+
close
|
|
138
|
+
raise result
|
|
139
|
+
end
|
|
140
|
+
|
|
116
141
|
if result != :connected
|
|
117
142
|
close
|
|
118
143
|
raise "WebSocket connection failed: #{result}"
|
|
@@ -141,6 +166,46 @@ module Daytona
|
|
|
141
166
|
|
|
142
167
|
private
|
|
143
168
|
|
|
169
|
+
# Verify the TLS peer's hostname exactly once, before any inbound frame is
|
|
170
|
+
# processed and before the auth frame is sent. websocket-client-simple
|
|
171
|
+
# verifies the certificate chain (VERIFY_PEER) during the handshake but never
|
|
172
|
+
# checks that the certificate matches the host, and its reader thread starts
|
|
173
|
+
# before #connect regains control — so a certificate that is valid for the
|
|
174
|
+
# wrong host could otherwise inject Socket.IO events (and receive the CONNECT
|
|
175
|
+
# auth frame) before verification runs. Gating both the reader path and the
|
|
176
|
+
# connect path guarantees no frame reaches the dispatcher, and no credentials
|
|
177
|
+
# are sent, until the peer is verified.
|
|
178
|
+
# @return [Boolean] true when verified (or plaintext ws://, no TLS peer to
|
|
179
|
+
# check); false on failure, after signalling the connecting thread.
|
|
180
|
+
def ensure_tls_verified!(connected_queue)
|
|
181
|
+
@verify_mutex.synchronize do
|
|
182
|
+
return @tls_verified unless @tls_verified.nil?
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
verify_tls_peer!
|
|
186
|
+
@tls_verified = true
|
|
187
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
188
|
+
@tls_verified = false
|
|
189
|
+
@mutex.synchronize { @connected = false }
|
|
190
|
+
connected_queue&.push(e)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@tls_verified
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def verify_tls_peer!
|
|
198
|
+
socket = @ws&.instance_variable_get(:@socket)
|
|
199
|
+
return unless socket.is_a?(OpenSSL::SSL::SSLSocket)
|
|
200
|
+
|
|
201
|
+
socket.post_connection_check(URI.parse(@api_url).host)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def send_connect_auth
|
|
205
|
+
auth = JSON.generate({ token: @token })
|
|
206
|
+
send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
|
|
207
|
+
end
|
|
208
|
+
|
|
144
209
|
def build_ws_url
|
|
145
210
|
parsed = URI.parse(@api_url)
|
|
146
211
|
ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
|
|
@@ -160,6 +225,11 @@ module Daytona
|
|
|
160
225
|
def handle_raw_message(raw, connected_queue)
|
|
161
226
|
return if raw.nil? || raw.empty?
|
|
162
227
|
|
|
228
|
+
# Drop every frame from a peer whose hostname is not yet verified. The
|
|
229
|
+
# reader thread can run before #connect verifies, so this is the gate that
|
|
230
|
+
# stops a wrong-host peer from injecting events or eliciting the auth frame.
|
|
231
|
+
return unless ensure_tls_verified!(connected_queue)
|
|
232
|
+
|
|
163
233
|
# Track all server activity for health monitoring.
|
|
164
234
|
# If the server stops sending ANY data (pings, events, etc.)
|
|
165
235
|
# the health monitor will detect the dead connection.
|
|
@@ -175,9 +245,10 @@ module Daytona
|
|
|
175
245
|
rescue JSON::ParserError
|
|
176
246
|
# Use default ping interval
|
|
177
247
|
end
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
248
|
+
# Signal the connecting thread instead of sending the CONNECT auth frame
|
|
249
|
+
# here: the token must not leave this process until the connecting thread
|
|
250
|
+
# has completed TLS peer verification (see #connect).
|
|
251
|
+
connected_queue&.push(:open)
|
|
181
252
|
|
|
182
253
|
when EIO_PING
|
|
183
254
|
# Server heartbeat — respond immediately with PONG
|
data/lib/daytona/sdk/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.200.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daytona Platforms Inc.
|
|
@@ -85,42 +85,42 @@ dependencies:
|
|
|
85
85
|
requirements:
|
|
86
86
|
- - '='
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: 0.
|
|
88
|
+
version: 0.200.1
|
|
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.
|
|
95
|
+
version: 0.200.1
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: daytona_api_client
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
99
99
|
requirements:
|
|
100
100
|
- - '='
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: 0.
|
|
102
|
+
version: 0.200.1
|
|
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.
|
|
109
|
+
version: 0.200.1
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
111
|
name: daytona_toolbox_api_client
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|
|
113
113
|
requirements:
|
|
114
114
|
- - '='
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: 0.
|
|
116
|
+
version: 0.200.1
|
|
117
117
|
type: :runtime
|
|
118
118
|
prerelease: false
|
|
119
119
|
version_requirements: !ruby/object:Gem::Requirement
|
|
120
120
|
requirements:
|
|
121
121
|
- - '='
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
|
-
version: 0.
|
|
123
|
+
version: 0.200.1
|
|
124
124
|
- !ruby/object:Gem::Dependency
|
|
125
125
|
name: dotenv
|
|
126
126
|
requirement: !ruby/object:Gem::Requirement
|