withruntime 0.26.1 → 0.27.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c6510ac6423ac945d6e5f55779d4f913ec4c13a4bd64992ec825637219d1373
4
- data.tar.gz: dbfd02ae2ffe411a32158234ab0d0faf8cf52263149c763cba750aed4db23a94
3
+ metadata.gz: f79ec4779408c96eaf66e899816f7329afd4af057cc9eccd445681e1dc7b6085
4
+ data.tar.gz: 9996a989720990ddf7df18b3ec982c1c0edf6be77b3bac0d3eb7625ab0645b7b
5
5
  SHA512:
6
- metadata.gz: ae5ff418881522c0748184c85262a960e4425d6eb32526482178bde4c862a682580140f373e3858dd66039857117fc73eaf4c9ea0ba7b057bb5c7932d5010952
7
- data.tar.gz: 6be184e2e55d835096df364ae1077b615485ddad59f6afe87b65c2ab6793a291962151720fbae4d223fb5c6ff5752ff08c5e32bd654229b33c58681fb1f639d6
6
+ metadata.gz: 2f5f55d187c9de060e19224c3fb91da8efa2dd93a7e65b66380837e097be183c000f4799557135cdb76adc0e0dfd890e85bfca8ec5859a6c519a811a3b2616be
7
+ data.tar.gz: 603a8941b52711efa41d7a3b9c73e65f0e59a44ce901216822781106abda51480651678ee3aa71ba4115b476c8e1fe7e6171e58b388cee2b51e92b94bb780ec5
@@ -7,7 +7,7 @@ module WithRuntime
7
7
  def initialize(sandbox)
8
8
  @lock, @credit, @send_lock = Mutex.new, ConditionVariable.new, Mutex.new
9
9
  @streams, @next, @sent, @credited, @closed = {}, 1, 0, 0, false
10
- @socket = WebSocket.new(sandbox.transport, sandbox.path("/tunnel"))
10
+ @socket = ResumableSocket.new(sandbox.transport, sandbox.path("/tunnel"))
11
11
  deadline = @socket.now + @socket.timeout
12
12
  loop do
13
13
  frame = @socket.read(deadline)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.26.1"
4
+ VERSION = "0.27.0"
5
5
  end
@@ -6,7 +6,8 @@ require "digest"
6
6
  module WithRuntime
7
7
  # One bounded RFC6455 connection, using the transport's origin, key and proxy rules.
8
8
  class WebSocket
9
- attr_reader :timeout
9
+ # The close code the server sent, once it closed.
10
+ attr_reader :timeout, :close_code
10
11
  def initialize(transport, path, query = nil)
11
12
  @transport = transport
12
13
  @timeout = transport.timeout
@@ -46,7 +47,7 @@ module WithRuntime
46
47
  status, fields = headers(deadline)
47
48
  accept = [Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")].pack("m0")
48
49
  unless status.match?(/\AHTTP\/1\.[01] 101 /) && fields["sec-websocket-accept"] == accept && fields["upgrade"].to_s.downcase == "websocket"
49
- raise Error.new("The server refused the WebSocket upgrade.", code: "websocket_refused")
50
+ raise Error.new("The server refused the WebSocket upgrade.", code: "websocket_refused", status: status[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i)
50
51
  end
51
52
  rescue *Transport::NETWORK_ERRORS => error
52
53
  close
@@ -119,7 +120,10 @@ module WithRuntime
119
120
  raise Error.new("WebSocket frame exceeds its bound.", code: "stream_overflow")
120
121
  end
121
122
  data = exact(length, deadline)
122
- return nil if op == 8
123
+ if op == 8
124
+ @close_code = data.bytesize >= 2 ? data.unpack1("n") : 1005
125
+ return nil
126
+ end
123
127
  if op == 9
124
128
  send_frame(10, data)
125
129
  next
@@ -150,6 +154,137 @@ module WithRuntime
150
154
  end
151
155
  end
152
156
 
157
+ # A terminal's or tunnel's WebSocket that outlives a restart of the API behind it
158
+ # (packages/cloud/src/api/tunnels.ts, "Hand-off"): on {"type":"restart"} it holds what is
159
+ # sent and answers; on {"type":"resume"} and close 1012 it opens the socket again with
160
+ # ?resume=<token>, sends {"type":"replay"} first, then what it held. Output continues from
161
+ # the byte it stopped at, and the reader sees no close.
162
+ class ResumableSocket
163
+ # RFC 6455 close code 1012, Service Restart.
164
+ SERVICE_RESTART = 1012
165
+ # How long reconnecting may take: within the token's 30 s, after which the API ends it.
166
+ RESUME_SECONDS = 25
167
+ attr_reader :timeout, :resumes, :replays
168
+ def initialize(transport, path, query = nil)
169
+ @transport, @path = transport, path
170
+ @socket = WebSocket.new(transport, path, query)
171
+ @timeout = @socket.timeout
172
+ # Orders sends, the restart's answer and the switch to a new socket. close never takes
173
+ # it, so it can close a socket under a send blocked holding it.
174
+ @send_lock = Mutex.new
175
+ @paused, @held, @closed, @resumes, @replays = false, [], false, 0, 0
176
+ end
177
+ def now = @socket.now
178
+ def send_frame(op, bytes)
179
+ @send_lock.synchronize do
180
+ raise IOError, "WebSocket closed" if @closed
181
+ next @held << [op, bytes.b] if @paused
182
+ @socket.send_frame(op, bytes)
183
+ end
184
+ end
185
+ # The next message the reader sees; the restart's own are not.
186
+ def read(deadline = nil)
187
+ loop do
188
+ socket = @socket
189
+ message = socket.read(deadline)
190
+ unless message
191
+ socket.close
192
+ token = @token
193
+ next if token && socket.close_code == SERVICE_RESTART && !@closed && resume(token)
194
+ said, @said = @said, nil
195
+ return said
196
+ end
197
+ if message[0] == 1 && message[1].bytesize <= 4096
198
+ reply = begin
199
+ JSON.parse(message[1])
200
+ rescue JSON::ParserError
201
+ nil
202
+ end
203
+ if reply.is_a?(Hash) && reply["type"] == "restart"
204
+ @send_lock.synchronize do
205
+ @paused = true
206
+ socket.send_frame(1, JSON.generate(type: "restart"))
207
+ end
208
+ next
209
+ end
210
+ if reply.is_a?(Hash) && reply["type"] == "resume" && reply["token"]
211
+ @token, @replay = reply["token"], reply["replay"]
212
+ next
213
+ end
214
+ end
215
+ return message
216
+ end
217
+ end
218
+ def close
219
+ @closed = true
220
+ @attempt&.close
221
+ # First unblocks a send in flight; then, under its lock, the socket a resume may have
222
+ # swapped in meanwhile.
223
+ @socket.close
224
+ @send_lock.synchronize do
225
+ @held.clear
226
+ @socket.close
227
+ end
228
+ end
229
+
230
+ private
231
+
232
+ # Opens the socket again where it stopped; false when it cannot.
233
+ def resume(token)
234
+ replay = @replay
235
+ @token = @replay = nil
236
+ deadline = now + RESUME_SECONDS
237
+ wait = 0.05
238
+ while !@closed && now < deadline
239
+ socket = nil
240
+ begin
241
+ socket = @attempt = WebSocket.new(@transport, @path, { "resume" => token })
242
+ # A close that came while it opened.
243
+ break socket.close if @closed
244
+ # A server that never answers must not hold this past the deadline.
245
+ message = socket.read(deadline)
246
+ reply = message && message[0] == 1 ? JSON.parse(message[1]) : nil
247
+ if reply.is_a?(Hash) && reply["type"] == "ready" && reply["resumed"]
248
+ return @send_lock.synchronize do
249
+ if @closed
250
+ socket.close
251
+ next false
252
+ end
253
+ @socket = socket
254
+ @resumes += 1
255
+ if replay && !replay.empty?
256
+ @replays += 1
257
+ socket.send_frame(1, JSON.generate(type: "replay", data: replay))
258
+ end
259
+ @held.each { |op, bytes| socket.send_frame(op, bytes) }
260
+ @held.clear
261
+ @paused = false
262
+ true
263
+ end
264
+ end
265
+ socket.close
266
+ if reply.is_a?(Hash) && reply["type"] == "error"
267
+ # Refused for good (the sandbox stopped, say): the reader reads the error.
268
+ @said = message
269
+ return false
270
+ end
271
+ rescue Error => error
272
+ socket&.close
273
+ # A 4xx other than 429: the token or the sandbox is not good, and asking again
274
+ # changes nothing.
275
+ return false if error.status.between?(400, 499) && error.status != 429
276
+ rescue JSON::ParserError, IOError, SystemCallError
277
+ socket&.close
278
+ ensure
279
+ @attempt = nil
280
+ end
281
+ sleep wait
282
+ wait = [wait * 2, 1].min
283
+ end
284
+ false
285
+ end
286
+ end
287
+
153
288
  # Producer never waits for a reader. The stream owner decides how overflow is reported.
154
289
  class StreamBuffer
155
290
  def initialize
@@ -193,7 +328,7 @@ module WithRuntime
193
328
  class Terminal
194
329
  attr_reader :process_id, :exit_code
195
330
  def initialize(sandbox, **options)
196
- @socket = WebSocket.new(sandbox.transport, sandbox.path("/terminal"), Fields.body(options))
331
+ @socket = ResumableSocket.new(sandbox.transport, sandbox.path("/terminal"), Fields.body(options))
197
332
  @buffer = StreamBuffer.new
198
333
  deadline = @socket.now + @socket.timeout
199
334
  loop do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: withruntime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.1
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Runtime
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
14
14
  commands and terminals, files and watches, images, durable volumes and backups,