daytona 0.196.0 → 0.198.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.
- checksums.yaml +4 -4
- data/lib/daytona/common/daytona.rb +19 -5
- data/lib/daytona/common/event_dispatcher.rb +326 -0
- data/lib/daytona/common/event_subscription_manager.rb +144 -0
- data/lib/daytona/common/pty.rb +22 -7
- data/lib/daytona/common/socketio_client.rb +289 -0
- data/lib/daytona/config.rb +21 -0
- data/lib/daytona/daytona.rb +66 -6
- data/lib/daytona/file_system.rb +1 -1
- data/lib/daytona/lsp_server.rb +1 -1
- data/lib/daytona/process.rb +34 -18
- data/lib/daytona/sandbox.rb +544 -114
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +6 -2
- data/project.json +4 -3
- metadata +22 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2003b4293b8ee69d171ad86402151e2924b814022649097deb51da19da6ff58
|
|
4
|
+
data.tar.gz: 30884a506c2dd332c804f995dd2d3afc654cf75b0fa66e4bf54cdd4a38909c12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f4627f864da4ae028a19d856a014da0f3513e30a5a8c7299148d89d815a82b13a2c0de3bc8fc3dc4e96ca7a455ee9ef9e291fe71dd58f57e12af8e85ebcbd3d1
|
|
7
|
+
data.tar.gz: c8b07b46dfb64043794af1d4d33db1ca3ac49c1271ebde8eae3b2c9e05ca66030e780d2fbb5cede035c564c03a69f0abba68d87ff098bac36d198fe3ab1d2636
|
|
@@ -28,6 +28,11 @@ module Daytona
|
|
|
28
28
|
# @return [Integer, nil] Auto-stop interval in minutes
|
|
29
29
|
attr_accessor :auto_stop_interval
|
|
30
30
|
|
|
31
|
+
# @return [Integer, nil] Auto-pause interval in minutes (nil = server default: 60 for
|
|
32
|
+
# non-ephemeral pause-supporting sandbox classes with auto-stop disabled; 0 = disabled).
|
|
33
|
+
# Not allowed for ephemeral sandboxes
|
|
34
|
+
attr_accessor :auto_pause_interval
|
|
35
|
+
|
|
31
36
|
# @return [Integer, nil] Auto-archive interval in minutes
|
|
32
37
|
attr_accessor :auto_archive_interval
|
|
33
38
|
|
|
@@ -69,6 +74,7 @@ module Daytona
|
|
|
69
74
|
# @param public [Boolean, nil] Whether the Sandbox should be public
|
|
70
75
|
# @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
|
|
71
76
|
# @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
|
|
77
|
+
# @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
|
|
72
78
|
# @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
|
|
73
79
|
# @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
|
|
74
80
|
# @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
|
|
@@ -87,6 +93,7 @@ module Daytona
|
|
|
87
93
|
public: nil,
|
|
88
94
|
timeout: nil,
|
|
89
95
|
auto_stop_interval: nil,
|
|
96
|
+
auto_pause_interval: nil,
|
|
90
97
|
auto_archive_interval: nil,
|
|
91
98
|
auto_delete_interval: nil,
|
|
92
99
|
volumes: nil,
|
|
@@ -104,6 +111,7 @@ module Daytona
|
|
|
104
111
|
@public = public
|
|
105
112
|
@timeout = timeout
|
|
106
113
|
@auto_stop_interval = auto_stop_interval
|
|
114
|
+
@auto_pause_interval = auto_pause_interval
|
|
107
115
|
@auto_archive_interval = auto_archive_interval
|
|
108
116
|
@auto_delete_interval = auto_delete_interval
|
|
109
117
|
@volumes = volumes
|
|
@@ -130,6 +138,7 @@ module Daytona
|
|
|
130
138
|
public:,
|
|
131
139
|
timeout:,
|
|
132
140
|
auto_stop_interval:,
|
|
141
|
+
auto_pause_interval:,
|
|
133
142
|
auto_archive_interval:,
|
|
134
143
|
auto_delete_interval:,
|
|
135
144
|
volumes:,
|
|
@@ -148,12 +157,15 @@ module Daytona
|
|
|
148
157
|
#
|
|
149
158
|
# @return [void]
|
|
150
159
|
def handle_ephemeral_auto_delete_conflict
|
|
151
|
-
return unless ephemeral
|
|
160
|
+
return unless ephemeral
|
|
161
|
+
|
|
162
|
+
if auto_delete_interval && !auto_delete_interval.zero?
|
|
163
|
+
warn(
|
|
164
|
+
"'ephemeral' and 'auto_delete_interval' cannot be used together. " \
|
|
165
|
+
'If ephemeral is true, auto_delete_interval will be ignored and set to 0.'
|
|
166
|
+
)
|
|
167
|
+
end
|
|
152
168
|
|
|
153
|
-
warn(
|
|
154
|
-
"'ephemeral' and 'auto_delete_interval' cannot be used together. " \
|
|
155
|
-
'If ephemeral is true, auto_delete_interval will be ignored and set to 0.'
|
|
156
|
-
)
|
|
157
169
|
@auto_delete_interval = 0
|
|
158
170
|
end
|
|
159
171
|
end
|
|
@@ -178,6 +190,7 @@ module Daytona
|
|
|
178
190
|
# @param public [Boolean, nil] Whether the Sandbox should be public
|
|
179
191
|
# @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
|
|
180
192
|
# @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
|
|
193
|
+
# @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
|
|
181
194
|
# @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
|
|
182
195
|
# @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
|
|
183
196
|
# @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
|
|
@@ -219,6 +232,7 @@ module Daytona
|
|
|
219
232
|
# @param public [Boolean, nil] Whether the Sandbox should be public
|
|
220
233
|
# @param timeout [Float, nil] Timeout in seconds for Sandbox to be created and started
|
|
221
234
|
# @param auto_stop_interval [Integer, nil] Auto-stop interval in minutes
|
|
235
|
+
# @param auto_pause_interval [Integer, nil] Auto-pause interval in minutes
|
|
222
236
|
# @param auto_archive_interval [Integer, nil] Auto-archive interval in minutes
|
|
223
237
|
# @param auto_delete_interval [Integer, nil] Auto-delete interval in minutes
|
|
224
238
|
# @param volumes [Array<DaytonaApiClient::SandboxVolume>, nil] List of volumes mounts to attach to the Sandbox
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 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 EventDispatcher
|
|
12
|
+
DISCONNECT_DELAY = 30
|
|
13
|
+
|
|
14
|
+
# @param api_url [String]
|
|
15
|
+
# @param token [String]
|
|
16
|
+
# @param organization_id [String, nil]
|
|
17
|
+
def initialize(api_url:, token:, organization_id: nil, source: nil, sdk_version: nil)
|
|
18
|
+
@api_url = api_url
|
|
19
|
+
@token = token
|
|
20
|
+
@organization_id = organization_id
|
|
21
|
+
@source = source
|
|
22
|
+
@sdk_version = sdk_version
|
|
23
|
+
@client = nil
|
|
24
|
+
@connected = false
|
|
25
|
+
@failed = false
|
|
26
|
+
@fail_error = nil
|
|
27
|
+
@listeners = {}
|
|
28
|
+
@registered_events = Set.new
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@disconnect_timer = nil
|
|
31
|
+
@disconnect_generation = 0
|
|
32
|
+
@reconnect_thread = nil
|
|
33
|
+
@connect_thread = nil
|
|
34
|
+
@reconnecting = false
|
|
35
|
+
@connecting = false
|
|
36
|
+
@close_requested = false
|
|
37
|
+
@closed = false
|
|
38
|
+
@max_reconnects = 100
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Idempotent: ensure a connection attempt is in progress or already established.
|
|
42
|
+
# Non-blocking. Starts a background Thread to connect if not already connected
|
|
43
|
+
# and no attempt is currently running.
|
|
44
|
+
# @return [void]
|
|
45
|
+
def ensure_connected
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
return if @closed || @connected || @connecting || @connect_thread&.alive?
|
|
48
|
+
|
|
49
|
+
@connect_thread = Thread.new do
|
|
50
|
+
connect
|
|
51
|
+
rescue StandardError
|
|
52
|
+
# Callers check connected? when they need it
|
|
53
|
+
ensure
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@connect_thread = nil if @connect_thread == Thread.current
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Establish the Socket.IO connection.
|
|
62
|
+
# @return [void]
|
|
63
|
+
# @raise [StandardError] on connection failure
|
|
64
|
+
def connect
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return if @closed || @connected || @connecting
|
|
67
|
+
|
|
68
|
+
@connecting = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Close any existing stale connection before creating a fresh one
|
|
72
|
+
@client&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
73
|
+
|
|
74
|
+
client = SocketIOClient.new(
|
|
75
|
+
api_url: @api_url,
|
|
76
|
+
token: @token,
|
|
77
|
+
organization_id: @organization_id,
|
|
78
|
+
source: @source,
|
|
79
|
+
sdk_version: @sdk_version,
|
|
80
|
+
on_event: method(:handle_event),
|
|
81
|
+
on_disconnect: method(:handle_disconnect)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
@client = client
|
|
86
|
+
@close_requested = false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
client.connect
|
|
90
|
+
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@connected = true
|
|
93
|
+
@failed = false
|
|
94
|
+
@fail_error = nil
|
|
95
|
+
schedule_delayed_disconnect_locked if @listeners.empty?
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@failed = true
|
|
100
|
+
@fail_error = "WebSocket connection failed: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
ensure
|
|
103
|
+
@mutex.synchronize { @connecting = false }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Subscribe to specific events for a resource.
|
|
107
|
+
# @param resource_id [String] The ID of the resource (e.g. sandbox ID, volume ID).
|
|
108
|
+
# @param events [Array<String>] List of Socket.IO event names to listen for.
|
|
109
|
+
# @yield [event_name, data] Called with raw event name and data hash.
|
|
110
|
+
# @return [Proc] Unsubscribe function.
|
|
111
|
+
def subscribe(resource_id, events:, &handler)
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
# No-op after disconnect
|
|
114
|
+
return -> {} if @closed
|
|
115
|
+
|
|
116
|
+
@disconnect_timer&.kill
|
|
117
|
+
@disconnect_timer = nil
|
|
118
|
+
@disconnect_generation += 1
|
|
119
|
+
|
|
120
|
+
@listeners[resource_id] ||= []
|
|
121
|
+
@listeners[resource_id] << handler
|
|
122
|
+
# Mark events in filter under lock to avoid dropping first event
|
|
123
|
+
events.each { |evt| @registered_events.add(evt) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
ensure_connected
|
|
127
|
+
|
|
128
|
+
lambda {
|
|
129
|
+
return if @close_requested || @closed
|
|
130
|
+
|
|
131
|
+
should_schedule = false
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
@listeners[resource_id]&.delete(handler)
|
|
134
|
+
unsubscribe_resource_locked(resource_id) if @listeners[resource_id] && @listeners[resource_id].empty?
|
|
135
|
+
should_schedule = @listeners.empty?
|
|
136
|
+
|
|
137
|
+
schedule_delayed_disconnect_locked if should_schedule
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Boolean]
|
|
143
|
+
def connected?
|
|
144
|
+
@connected
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def failed?
|
|
149
|
+
@failed
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [String, nil]
|
|
153
|
+
attr_reader :fail_error
|
|
154
|
+
|
|
155
|
+
# Disconnect and clean up.
|
|
156
|
+
def disconnect
|
|
157
|
+
thread_state = @mutex.synchronize do
|
|
158
|
+
begin_disconnect_locked(permanent: true, skip_thread: Thread.current)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
finalize_disconnect(thread_state)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def unsubscribe_resource_locked(resource_id)
|
|
167
|
+
@listeners.delete(resource_id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def schedule_delayed_disconnect_locked
|
|
171
|
+
@disconnect_timer&.kill
|
|
172
|
+
generation = @disconnect_generation
|
|
173
|
+
@disconnect_timer = Thread.new do
|
|
174
|
+
sleep(DISCONNECT_DELAY)
|
|
175
|
+
thread_state = @mutex.synchronize do
|
|
176
|
+
next unless generation == @disconnect_generation
|
|
177
|
+
next unless @listeners.empty?
|
|
178
|
+
|
|
179
|
+
begin_disconnect_locked(permanent: false, skip_thread: Thread.current)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
finalize_disconnect(thread_state) if thread_state
|
|
183
|
+
end
|
|
184
|
+
@disconnect_timer.abort_on_exception = false
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def handle_event(event_name, data)
|
|
188
|
+
# Read registered_events under lock for thread safety
|
|
189
|
+
registered = @mutex.synchronize { @registered_events.include?(event_name) }
|
|
190
|
+
return unless registered
|
|
191
|
+
|
|
192
|
+
resource_id = extract_id_from_event(data)
|
|
193
|
+
return unless resource_id
|
|
194
|
+
|
|
195
|
+
dispatch(resource_id, event_name, data)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Extract resource ID from an event payload.
|
|
199
|
+
# Handles two payload shapes:
|
|
200
|
+
# - Wrapper: {sandbox: {id: ...}, ...} -> nested resource ID
|
|
201
|
+
# - Direct: {id: ...} -> top-level ID
|
|
202
|
+
def extract_id_from_event(data)
|
|
203
|
+
return nil unless data.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
%w[sandbox volume snapshot runner].each do |key|
|
|
206
|
+
nested = data[key]
|
|
207
|
+
next unless nested.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
sid = nested['id']
|
|
210
|
+
return sid if sid.is_a?(String)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
top_id = data['id']
|
|
214
|
+
return top_id if top_id.is_a?(String)
|
|
215
|
+
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def dispatch(resource_id, event_name, data)
|
|
220
|
+
handlers = @mutex.synchronize { @listeners[resource_id]&.dup || [] }
|
|
221
|
+
handlers.each do |handler|
|
|
222
|
+
handler.call(event_name, data)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
# Don't let handler errors break other handlers
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def handle_disconnect
|
|
229
|
+
@connected = false
|
|
230
|
+
return if @close_requested
|
|
231
|
+
|
|
232
|
+
@reconnect_thread = Thread.new { reconnect_loop }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def reconnect_loop
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
return if @reconnecting
|
|
238
|
+
|
|
239
|
+
@reconnecting = true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
attempt = 0
|
|
243
|
+
while attempt < @max_reconnects
|
|
244
|
+
if @close_requested
|
|
245
|
+
@mutex.synchronize { @reconnecting = false }
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
delay = [2**attempt, 30].min
|
|
250
|
+
sleep(delay)
|
|
251
|
+
if @close_requested
|
|
252
|
+
@mutex.synchronize { @reconnecting = false }
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
connect
|
|
258
|
+
if @connected
|
|
259
|
+
@mutex.synchronize { @reconnecting = false }
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
rescue StandardError
|
|
263
|
+
# Continue retrying
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
attempt += 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# All attempts failed
|
|
270
|
+
@mutex.synchronize do
|
|
271
|
+
@failed = true
|
|
272
|
+
@fail_error = "WebSocket reconnection failed after #{@max_reconnects} attempts"
|
|
273
|
+
@reconnecting = false
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def begin_disconnect_locked(permanent:, skip_thread:)
|
|
278
|
+
@closed = true if permanent
|
|
279
|
+
@close_requested = true
|
|
280
|
+
|
|
281
|
+
thread_state = {
|
|
282
|
+
client: @client,
|
|
283
|
+
reconnect_thread: @reconnect_thread,
|
|
284
|
+
connect_thread: @connect_thread,
|
|
285
|
+
disconnect_timer: @disconnect_timer,
|
|
286
|
+
skip_thread:
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@client = nil
|
|
290
|
+
@reconnect_thread = nil
|
|
291
|
+
@connect_thread = nil
|
|
292
|
+
@disconnect_timer = nil
|
|
293
|
+
@connected = false
|
|
294
|
+
@connecting = false
|
|
295
|
+
@listeners.clear
|
|
296
|
+
@registered_events.clear
|
|
297
|
+
|
|
298
|
+
thread_state
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def finalize_disconnect(thread_state)
|
|
302
|
+
return unless thread_state
|
|
303
|
+
|
|
304
|
+
[thread_state[:reconnect_thread], thread_state[:connect_thread], thread_state[:disconnect_timer]].each do |thread|
|
|
305
|
+
next unless thread
|
|
306
|
+
next if thread == thread_state[:skip_thread]
|
|
307
|
+
|
|
308
|
+
stop_thread(thread)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
thread_state[:client]&.close
|
|
312
|
+
rescue StandardError
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def stop_thread(thread)
|
|
317
|
+
return unless thread
|
|
318
|
+
return if thread == Thread.current
|
|
319
|
+
|
|
320
|
+
thread.kill
|
|
321
|
+
thread.join
|
|
322
|
+
rescue StandardError
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Daytona Platforms Inc.
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module Daytona
|
|
9
|
+
class EventSubscriptionManager
|
|
10
|
+
SUBSCRIPTION_TTL = 300
|
|
11
|
+
private_constant :SUBSCRIPTION_TTL
|
|
12
|
+
|
|
13
|
+
def initialize(dispatcher = nil)
|
|
14
|
+
@dispatcher = dispatcher
|
|
15
|
+
@subscriptions = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@closed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscribe(resource_id:, handler:, events:)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return nil if @closed || @dispatcher.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unsubscribe = @dispatcher.subscribe(resource_id, events:, &handler)
|
|
26
|
+
sub_id = SecureRandom.hex(16)
|
|
27
|
+
timer_to_stop = nil
|
|
28
|
+
rollback_unsubscribe = nil
|
|
29
|
+
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
if @closed
|
|
32
|
+
# Rollback dispatcher subscription on failure
|
|
33
|
+
rollback_unsubscribe = unsubscribe
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@subscriptions[sub_id] = { unsubscribe:, timer: nil }
|
|
38
|
+
timer_to_stop = start_timer_locked(sub_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if rollback_unsubscribe
|
|
42
|
+
rollback_unsubscribe.call
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
stop_thread(timer_to_stop)
|
|
47
|
+
sub_id
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def refresh(sub_id)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
# Reject after shutdown to prevent use-after-close
|
|
53
|
+
return false if @closed
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
timer_to_stop = nil
|
|
57
|
+
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
return false unless @subscriptions.key?(sub_id)
|
|
60
|
+
|
|
61
|
+
timer_to_stop = start_timer_locked(sub_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
stop_thread(timer_to_stop)
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unsubscribe(sub_id)
|
|
69
|
+
subscription = nil
|
|
70
|
+
timer = nil
|
|
71
|
+
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
subscription = @subscriptions.delete(sub_id)
|
|
74
|
+
if subscription
|
|
75
|
+
timer = subscription[:timer]
|
|
76
|
+
subscription[:timer] = nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return unless subscription
|
|
81
|
+
|
|
82
|
+
stop_thread(timer)
|
|
83
|
+
subscription[:unsubscribe].call
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def shutdown
|
|
87
|
+
subscriptions = nil
|
|
88
|
+
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
@closed = true
|
|
91
|
+
subscriptions = @subscriptions.values
|
|
92
|
+
@subscriptions = {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
subscriptions.each do |subscription|
|
|
96
|
+
stop_thread(subscription[:timer])
|
|
97
|
+
subscription[:unsubscribe].call
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def start_timer_locked(sub_id)
|
|
104
|
+
subscription = @subscriptions[sub_id]
|
|
105
|
+
return unless subscription
|
|
106
|
+
|
|
107
|
+
previous_timer = cancel_timer_locked(sub_id)
|
|
108
|
+
timer = Thread.new do
|
|
109
|
+
sleep(SUBSCRIPTION_TTL)
|
|
110
|
+
|
|
111
|
+
unsubscribe = @mutex.synchronize do
|
|
112
|
+
current_subscription = @subscriptions[sub_id]
|
|
113
|
+
next unless current_subscription && current_subscription[:timer] == Thread.current
|
|
114
|
+
|
|
115
|
+
@subscriptions.delete(sub_id)&.dig(:unsubscribe)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
unsubscribe&.call
|
|
119
|
+
end
|
|
120
|
+
timer.abort_on_exception = false
|
|
121
|
+
subscription[:timer] = timer
|
|
122
|
+
previous_timer
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cancel_timer_locked(sub_id)
|
|
126
|
+
subscription = @subscriptions[sub_id]
|
|
127
|
+
return unless subscription
|
|
128
|
+
|
|
129
|
+
timer = subscription[:timer]
|
|
130
|
+
subscription[:timer] = nil
|
|
131
|
+
timer
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def stop_thread(thread)
|
|
135
|
+
return unless thread
|
|
136
|
+
return if thread == Thread.current
|
|
137
|
+
|
|
138
|
+
thread.kill
|
|
139
|
+
thread.join
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/daytona/common/pty.rb
CHANGED
|
@@ -7,6 +7,10 @@ require 'json'
|
|
|
7
7
|
require 'observer'
|
|
8
8
|
|
|
9
9
|
module Daytona
|
|
10
|
+
# Capability token advertised on PTY WebSocket connects so the daemon sends the
|
|
11
|
+
# "exited" control message; clients that don't send it only get the close frame.
|
|
12
|
+
PTY_EXIT_CONTROL_SUBPROTOCOL = 'X-Daytona-Pty-Exit-Control'
|
|
13
|
+
|
|
10
14
|
class PtySize
|
|
11
15
|
# @return [Integer] Number of terminal rows (height)
|
|
12
16
|
attr_reader :rows
|
|
@@ -68,6 +72,7 @@ module Daytona
|
|
|
68
72
|
@handle_kill = handle_kill
|
|
69
73
|
@exit_code = nil
|
|
70
74
|
@error = nil
|
|
75
|
+
@exited = false
|
|
71
76
|
@logger = Sdk.logger
|
|
72
77
|
|
|
73
78
|
@status = Status::INIT
|
|
@@ -77,21 +82,23 @@ module Daytona
|
|
|
77
82
|
# Check if connected to the PTY session
|
|
78
83
|
#
|
|
79
84
|
# @return [Boolean] true if connected, false otherwise
|
|
80
|
-
|
|
85
|
+
# A PTY that has exited is not usable even while the socket briefly stays
|
|
86
|
+
# open before the close frame, so treat it as disconnected.
|
|
87
|
+
def connected? = websocket.open? && !@exited
|
|
81
88
|
|
|
82
89
|
# Wait for the PTY connection to be established
|
|
83
90
|
#
|
|
84
91
|
# @param timeout [Float] Maximum time in seconds to wait for connection. Defaults to 10.0
|
|
85
92
|
# @return [void]
|
|
86
93
|
# @raise [Daytona::Sdk::Error] If connection timeout is exceeded
|
|
87
|
-
def wait_for_connection(timeout: DEFAULT_TIMEOUT)
|
|
88
|
-
return if status == Status::CONNECTED
|
|
94
|
+
def wait_for_connection(timeout: DEFAULT_TIMEOUT) # rubocop:disable Metrics/CyclomaticComplexity
|
|
95
|
+
return if status == Status::CONNECTED || @exited
|
|
89
96
|
|
|
90
97
|
start_time = Time.now
|
|
91
98
|
|
|
92
|
-
sleep(SLEEP_INTERVAL) until status == Status::CONNECTED || (Time.now - start_time) > timeout
|
|
99
|
+
sleep(SLEEP_INTERVAL) until status == Status::CONNECTED || @exited || (Time.now - start_time) > timeout
|
|
93
100
|
|
|
94
|
-
raise Sdk::Error, 'PTY connection timeout' unless status == Status::CONNECTED
|
|
101
|
+
raise Sdk::Error, 'PTY connection timeout' unless status == Status::CONNECTED || @exited
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
# Send input to the PTY session
|
|
@@ -127,8 +134,9 @@ module Daytona
|
|
|
127
134
|
#
|
|
128
135
|
# @param on_data [Proc, nil] Optional callback to handle output data
|
|
129
136
|
# @return [Daytona::PtyResult] Result containing exit code and error information
|
|
130
|
-
def wait(timeout: nil, &on_data)
|
|
137
|
+
def wait(timeout: nil, &on_data) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize
|
|
131
138
|
timeout ||= Float::INFINITY
|
|
139
|
+
return PtyResult.new(exit_code:, error:) if @exited
|
|
132
140
|
return unless status == Status::CONNECTED
|
|
133
141
|
|
|
134
142
|
start_time = Time.now
|
|
@@ -239,11 +247,17 @@ module Daytona
|
|
|
239
247
|
|
|
240
248
|
# @param data [WebSocket::Frame::Data]
|
|
241
249
|
# @return [void]
|
|
242
|
-
def process_websocket_control_message(data) # rubocop:disable Metrics/MethodLength
|
|
250
|
+
def process_websocket_control_message(data) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
243
251
|
case data[:status]
|
|
244
252
|
when WebSocketControlStatus::CONNECTED
|
|
245
253
|
logger.debug('[control] connected')
|
|
246
254
|
@status = Status::CONNECTED
|
|
255
|
+
when WebSocketControlStatus::EXITED
|
|
256
|
+
logger.debug("[control] exited: code=#{data[:exitCode]}")
|
|
257
|
+
@exit_code = data[:exitCode]
|
|
258
|
+
@error = data[:exitReason] if data[:exitReason]
|
|
259
|
+
@exited = true
|
|
260
|
+
@status = Status::CLOSED
|
|
247
261
|
when WebSocketControlStatus::ERROR
|
|
248
262
|
logger.debug("[control] error: #{error.inspect}")
|
|
249
263
|
@status = Status::ERROR
|
|
@@ -302,6 +316,7 @@ module Daytona
|
|
|
302
316
|
module WebSocketControlStatus
|
|
303
317
|
ALL = [
|
|
304
318
|
CONNECTED = 'connected',
|
|
319
|
+
EXITED = 'exited',
|
|
305
320
|
ERROR = 'error'
|
|
306
321
|
].freeze
|
|
307
322
|
end
|