kontena-websocket-client 0.0.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.
@@ -0,0 +1,848 @@
1
+ # Threadsafe: while the #run method is reading/parsing incoming websocket frames, the #send/#ping/#close methods
2
+ # can be called by other threads.
3
+ # The #run (on_open), #on_message and #on_pong blocks will be called from the #run thread.
4
+ #
5
+ #
6
+ =begin example
7
+ def websocket_connect
8
+ Kontena::Websocket::Client.connect(url, ...) do |client|
9
+ on_open(client)
10
+
11
+ client.read do |message|
12
+ on_message(message)
13
+ end
14
+
15
+ on_close(client.close_code, client.close_reason) # client closed connection
16
+ end
17
+ rescue Kontena::Websocket::CloseError => exc
18
+ on_close(exc.code, exc.reason) # server closed connection
19
+ rescue Kontena::Websocket::Error => exc
20
+ on_error(exc)
21
+ ensure
22
+ # disconnected
23
+ end
24
+ end
25
+ =end
26
+ class Kontena::Websocket::Client
27
+ require_relative './client/connection'
28
+
29
+ include Kontena::Websocket::Logging
30
+
31
+ attr_reader :uri
32
+
33
+ FRAME_SIZE = 4 * 1024
34
+ CONNECT_TIMEOUT = 60.0
35
+ OPEN_TIMEOUT = 60.0
36
+ PING_INTERVAL = 60.0
37
+ PING_TIMEOUT = 10.0
38
+ PING_STRFTIME = '%FT%T.%NZ' # high-percision RFC 3339
39
+ CLOSE_TIMEOUT = 60.0
40
+ WRITE_TIMEOUT = 60.0
41
+
42
+ # Connect, send websocket handshake, and loop reading responses.
43
+ #
44
+ # Passed block is called once websocket is open.
45
+ # Raises on errors.
46
+ # Returns once websocket is closed by server.
47
+ #
48
+ # Intended to be called using a dedicated per-websocket thread.
49
+ # Other threads can then call the other threadsafe methods:
50
+ # * send
51
+ # * close
52
+ #
53
+ # @yield [client] websocket open
54
+ # @raise [Kontena::Websocket::ConnectError]
55
+ # @raise [Kontena::Websocket::ProtocolError]
56
+ # @raise [Kontena::Websocket::TimeoutError]
57
+ # @return websocket closed by server, @see #close_code #close_reason
58
+ def self.connect(url, **options, &block)
59
+ client = new(url, **options)
60
+
61
+ client.connect
62
+
63
+ begin
64
+ yield client
65
+ ensure
66
+ # ensure socket is closed and client disconnected on any of:
67
+ # * connect error
68
+ # * open error
69
+ # * read error
70
+ # * close
71
+ client.disconnect
72
+ end
73
+ end
74
+
75
+ # @param [String] url
76
+ # @param headers [Hash{String => String}]
77
+ # @param ssl_params [Hash] @see OpenSSL::SSL::SSLContext
78
+ # The DEFAULT_PARAMS includes verify_mode: OpenSSL::SSL::VERIFY_PEER.
79
+ # Use { verify_mode: OpenSSL::SSL::VERIFY_NONE } to disable Kontena::Websocket::SSLVerifyError on connect.
80
+ # @param ssl_hostname [String] override hostname for SSL SNI, certificate identity matching
81
+ # @param connect_timeout [Float] timeout for TCP handshake; XXX: each phase of the SSL handshake
82
+ # @param open_timeout [Float] expect open frame after #start
83
+ # @param ping_interval [Float] send pings every interval seconds after previous ping
84
+ # @param ping_timeout [Float] expect pong frame after #ping
85
+ # @param close_timeout [Float] expect close frame after #close
86
+ # @param write_timeout [Float] block #send when sending faster than the server is able to receive, fail if no progress is made
87
+ # @raise [ArgumentError] Invalid websocket URI
88
+ def initialize(url,
89
+ headers: {},
90
+ ssl_params: {},
91
+ ssl_hostname: nil,
92
+ connect_timeout: CONNECT_TIMEOUT,
93
+ open_timeout: OPEN_TIMEOUT,
94
+ ping_interval: PING_INTERVAL,
95
+ ping_timeout: PING_TIMEOUT,
96
+ close_timeout: CLOSE_TIMEOUT,
97
+ write_timeout: WRITE_TIMEOUT
98
+ )
99
+ @uri = URI.parse(url)
100
+ @headers = headers
101
+ @ssl_params = ssl_params
102
+ @ssl_hostname = ssl_hostname
103
+
104
+ @connect_timeout = connect_timeout
105
+ @open_timeout = open_timeout
106
+ @ping_interval = ping_interval
107
+ @ping_timeout = ping_timeout
108
+ @close_timeout = close_timeout
109
+ @write_timeout = write_timeout
110
+
111
+ unless @uri.scheme == 'ws' || @uri.scheme == 'wss'
112
+ raise ArgumentError, "Invalid websocket URL: #{@uri}"
113
+ end
114
+
115
+ @mutex = Mutex.new # for @driver
116
+
117
+ # written by #enqueue from @driver callbacks with the @mutex held
118
+ # drained by #read without the @mutex held
119
+ @message_queue = []
120
+
121
+ # sequential ping-pongs
122
+ @ping_at = Time.now # fake for first ping_interval
123
+ end
124
+
125
+ # @return [String]
126
+ def url
127
+ @uri.to_s
128
+ end
129
+
130
+ # @return [String] ws or wss
131
+ def scheme
132
+ @uri.scheme
133
+ end
134
+
135
+ # @return [Boolean]
136
+ def ssl?
137
+ @uri.scheme == 'wss'
138
+ end
139
+
140
+ # Connecting with SSL cert/host verification?
141
+ #
142
+ # @return [Boolean]
143
+ def ssl_verify?
144
+ ssl? && ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE
145
+ end
146
+
147
+ # @return [String]
148
+ def ssl_hostname
149
+ @ssl_hostname || @uri.host
150
+ end
151
+
152
+ # @return [String]
153
+ def host
154
+ @uri.host
155
+ end
156
+
157
+ # @return [Integer]
158
+ def port
159
+ @uri.port || (@uri.scheme == "ws" ? 80 : 443)
160
+ end
161
+
162
+ # Connected to server. Not necessarily open yet.
163
+ #
164
+ # @return [Boolean]
165
+ def connected?
166
+ !!@socket && !!@connection && !!@driver
167
+ end
168
+
169
+ # Client has started websocket handshake, but is not yet open.
170
+ #
171
+ # @return [Boolean]
172
+ def opening?
173
+ # XXX: also true after disconnect
174
+ !!@opening_at && !@opened
175
+ end
176
+
177
+ # Server has accepted websocket connection.
178
+ #
179
+ # @return [Boolean]
180
+ def open?
181
+ !!@open
182
+ end
183
+
184
+ # Client has sent close frame, but socket is not yet closed.
185
+ #
186
+ # @return [Boolean]
187
+ def closing?
188
+ !!@closing && !@closed
189
+ end
190
+
191
+ # Server has closed websocket connection.
192
+ #
193
+ # @return [Boolean]
194
+ def closed?
195
+ !!@closed
196
+ end
197
+
198
+ # Valid once #run returns, when closed? or closing?
199
+ #
200
+ # @return [Integer]
201
+ def close_code
202
+ @closed_code || @closing_code
203
+ end
204
+
205
+ # Valid once #run returns, when closed?
206
+ #
207
+ # @return [String]
208
+ def close_reason
209
+ @closed_reason
210
+ end
211
+
212
+ # Create @socket, @connection and open @driver.
213
+ #
214
+ # Blocks until open. Disconnects if fails.
215
+ #
216
+ # @raise [Kontena::Websocket::ConnectError]
217
+ # @raise [Kontena::Websocket::TimeoutError]
218
+ # @return once websocket is open
219
+ def connect
220
+ @connection = self.socket_connect
221
+
222
+ @driver = self.websocket_open(@connection)
223
+
224
+ # blocks
225
+ self.websocket_read until @opened
226
+
227
+ rescue
228
+ disconnect
229
+ raise
230
+ end
231
+
232
+ # @raise [RuntimeError] not connected
233
+ # @return [nil] not an ssl connection, or no peer cert
234
+ # @return [OpenSSL::X509::Certificate]
235
+ def ssl_cert
236
+ fail "not connected" unless @socket
237
+ return nil unless ssl?
238
+
239
+ return @socket.peer_cert
240
+ end
241
+
242
+ # Verify and return SSL cert. Validates even if not ssl_verify.
243
+ #
244
+ # @raise [RuntimeError] not connected
245
+ # @raise [Kontena::Websocket::SSLVerifyError]
246
+ # @return [nil] not an ssl connection
247
+ # @return [OpenSSL::X509::Certificate]
248
+ def ssl_cert!
249
+ fail "not connected" unless @socket
250
+ return nil unless ssl?
251
+
252
+ # raises Kontena::Websocket::SSLVerifyError
253
+ self.ssl_verify_cert! @socket.peer_cert, @socket.peer_cert_chain
254
+
255
+ return @socket.peer_cert
256
+ end
257
+
258
+ # Valid once open
259
+ #
260
+ # @raise [RuntimeError] not connected
261
+ # @return [Integer]
262
+ def http_status
263
+ with_driver do |driver|
264
+ driver.status
265
+ end
266
+ end
267
+
268
+ # Valid once open
269
+ #
270
+ # @raise [RuntimeError] not connected
271
+ # @return [Websocket::Driver::Headers]
272
+ def http_headers
273
+ with_driver do |driver|
274
+ driver.headers
275
+ end
276
+ end
277
+
278
+ # Read messages from websocket.
279
+ #
280
+ # If a block is given, then this loops and yields messages until closed.
281
+ # Otherwise, returns the next message, or nil if closed.
282
+ #
283
+ # @raise [Kontena::Websocket::CloseError]
284
+ def read(&block)
285
+ if block
286
+ read_yield(&block)
287
+ else
288
+ read_return
289
+ end
290
+ end
291
+
292
+ # Send message frame, either text or binary.
293
+ #
294
+ # @param message [String, Array<Integer>]
295
+ # @raise [ArgumentError] invalid type
296
+ # @raise [RuntimeError] unable to send (socket closed?)
297
+ def send(message)
298
+ case message
299
+ when String
300
+ with_driver do |driver|
301
+ fail unless driver.text(message)
302
+ end
303
+ when Array
304
+ with_driver do |driver|
305
+ fail unless driver.binary(message)
306
+ end
307
+ else
308
+ raise ArgumentError, "Invalid type: #{message.class}"
309
+ end
310
+ end
311
+
312
+ # Register pong handler.
313
+ # Called from the #read thread every ping_interval after received pong.
314
+ # XXX: called with driver @mutex locked
315
+ #
316
+ # The ping interval should be longer than the ping timeout.
317
+ # If new pings are sent before old pings get any response, then the older pings do not yield on pong.
318
+ #
319
+ # @yield [delay] received pong
320
+ # @yieldparam delay [Float] ping-pong delay in seconds
321
+ def on_pong(&block)
322
+ @on_pong = block
323
+ end
324
+
325
+ # Send ping message.
326
+ #
327
+ # This is intended to be automatically called from #run per @ping_interval.
328
+ # Calling it from the #run callbacks also works, but calling it from a different thread
329
+ # while #run is blocked on read() will ignore the @ping_timeout, unless the server happens
330
+ # to send something else.
331
+ #
332
+ # @raise [RuntimeError] not connected
333
+ def ping
334
+ with_driver do |driver|
335
+ # start ping timeout for next read
336
+ ping_at = pinging!
337
+
338
+ debug "pinging at #{ping_at}"
339
+
340
+ fail unless driver.ping(ping_at.utc.strftime(PING_STRFTIME)) do
341
+ # called from read -> @driver.parse with @mutex held!
342
+ debug "pong for #{ping_at}"
343
+
344
+ # resolve ping timeout, unless this pong is late and we already sent a new one
345
+ if ping_at == @ping_at
346
+ ping_delay = pinged!
347
+
348
+ debug "ping-pong at #{ping_at} in #{ping_delay}s"
349
+
350
+ # XXX: defer call without mutex?
351
+ @on_pong.call(ping_delay) # TODO: also pass ping_at
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ # Waiting for pong from ping
358
+ #
359
+ # @return [Boolean]
360
+ def pinging?
361
+ !!@pinging
362
+ end
363
+
364
+ # Measured ping-pong delay from previous ping
365
+ #
366
+ # nil if not pinged yet
367
+ #
368
+ # @return [Float, nil]
369
+ def ping_delay
370
+ @ping_delay
371
+ end
372
+
373
+ # Send close frame. Does not disconnect, but allows #read to return once server completes close handshake.
374
+ #
375
+ # Imposes a close timeout when called from #run blocks (run/on_message/on_pong do ...). If called from
376
+ # a different thread, then #run should eventually return, once either:
377
+ # * server sends close frame
378
+ # * server sends any other frame after the close timeout expires
379
+ # * the ping interval expires
380
+ #
381
+ # XXX: prevent #send after close?
382
+ #
383
+ # @param close [Integer]
384
+ # @param reason [String]
385
+ def close(code = 1000, reason = nil)
386
+ debug "close code=#{code}: #{reason}"
387
+
388
+ with_driver do |driver|
389
+ fail unless driver.close(reason, code) # swapped argument order
390
+
391
+ closing! code
392
+ end
393
+ end
394
+
395
+ # Clear connection state, close socket.
396
+ # Does not send websocket close frame, or wait for server to close.
397
+ def disconnect
398
+ debug "disconnect"
399
+
400
+ @open = false
401
+ @driver = nil
402
+ @connection = nil
403
+
404
+ # TODO: errors and timeout? SSLSocket.close in particular is bidirectional?
405
+ @socket.close if @socket
406
+ @socket = nil
407
+ end
408
+
409
+ #protected XXX: called by specs TODO: refactor out to separate TCP/SSL client classes
410
+
411
+ # Call into driver with locked Mutex
412
+ #
413
+ # @raise [RuntimeError] not connected
414
+ # @yield [driver]
415
+ # @yieldparam driver [Websocket::Driver]
416
+ def with_driver
417
+ @mutex.synchronize {
418
+ fail "not connected" unless @driver
419
+
420
+ yield @driver
421
+ }
422
+ end
423
+
424
+ # Connect to TCP server.
425
+ #
426
+ # @raise [Kontena::Websocket::TimeoutError] Errno::ETIMEDOUT
427
+ # @raise [Kontena::Websocket::ConnectError] Errno::*
428
+ # @raise [Kontena::Websocket::ConnectError] SocketError
429
+ # @return [TCPSocket]
430
+ def connect_tcp
431
+ debug "connect_tcp: timeout=#{@connect_timeout}"
432
+
433
+ Socket.tcp(self.host, self.port, connect_timeout: @connect_timeout)
434
+ rescue Errno::ETIMEDOUT => exc
435
+ raise Kontena::Websocket::TimeoutError, "Connect timeout after #{@connect_timeout}s" # XXX: actual delay
436
+ rescue SocketError => exc
437
+ raise Kontena::Websocket::ConnectError, exc
438
+ rescue SystemCallError => exc
439
+ raise Kontena::Websocket::ConnectError, exc
440
+ end
441
+
442
+ # @raise [ArgumentError] Failed adding cert store file/path: ...
443
+ # @return [OpenSSL::X509::Store]
444
+ def ssl_cert_store
445
+ @ssl_cert_store ||= OpenSSL::X509::Store.new.tap do |ssl_cert_store|
446
+ ca_file = @ssl_params[:ca_file] || ENV['SSL_CERT_FILE']
447
+ ca_path = @ssl_params[:ca_path] || ENV['SSL_CERT_PATH']
448
+
449
+ if ca_file || ca_path
450
+ if ca_file
451
+ debug "add cert store file: #{ca_file}"
452
+
453
+ begin
454
+ ssl_cert_store.add_file ca_file
455
+ rescue OpenSSL::X509::StoreError
456
+ raise ArgumentError, "Failed adding cert store file: #{ca_file}"
457
+ end
458
+ end
459
+
460
+ if ca_path
461
+ debug "add cert store path: #{ca_path}"
462
+
463
+ begin
464
+ # XXX: does not actually raise
465
+ ssl_cert_store.add_path ca_path
466
+ rescue OpenSSL::X509::StoreError
467
+ raise ArgumentError, "Failed adding cert store path: #{ca_path}"
468
+ end
469
+ end
470
+ else
471
+ debug "use default cert store paths"
472
+
473
+ ssl_cert_store.set_default_paths
474
+ end
475
+ end
476
+ end
477
+
478
+ # @param ssl_cert [OpenSSL::X509::Certificate]
479
+ # @raise [Kontena::Websocket::SSLVerifyError]
480
+ def ssl_verify_cert!(ssl_cert, ssl_cert_chain)
481
+ unless ssl_cert
482
+ raise Kontena::Websocket::SSLVerifyError.new(OpenSSL::X509::V_OK, ssl_cert, ssl_cert_chain), "No certificate"
483
+ end
484
+
485
+ ssl_verify_context = OpenSSL::X509::StoreContext.new(ssl_cert_store, ssl_cert, ssl_cert_chain)
486
+
487
+ unless ssl_verify_context.verify
488
+ raise Kontena::Websocket::SSLVerifyError.new(ssl_verify_context.error, ssl_cert, ssl_cert_chain), ssl_verify_context.error_string
489
+ end
490
+
491
+ unless OpenSSL::SSL.verify_certificate_identity(ssl_cert, self.ssl_hostname)
492
+ raise Kontena::Websocket::SSLVerifyError.new(OpenSSL::X509::V_OK, ssl_cert, ssl_cert_chain), "Subject does not match hostname #{self.ssl_hostname}: #{ssl_cert.subject}"
493
+ end
494
+ end
495
+
496
+ # @param verify_result [Integer] OpenSSL::SSL::SSLSocket#verify_result
497
+ # @return [Kontena::Websocket::SSLVerifyError]
498
+ def ssl_verify_error(verify_result, ssl_cert = nil, ssl_cert_chain = nil)
499
+ ssl_verify_context = OpenSSL::X509::StoreContext.new(ssl_cert_store)
500
+ ssl_verify_context.error = verify_result
501
+
502
+ Kontena::Websocket::SSLVerifyError.new(ssl_verify_context.error, ssl_cert, ssl_cert_chain, ssl_verify_context.error_string)
503
+ end
504
+
505
+ # @return [OpenSSL::SSL::SSLContext]
506
+ def ssl_context
507
+ @ssl_context ||= OpenSSL::SSL::SSLContext.new().tap do |ssl_context|
508
+ params = {
509
+ cert_store: self.ssl_cert_store,
510
+ **@ssl_params
511
+ }
512
+
513
+ if Kontena::Websocket::Client.ruby_version? '2.4'
514
+ # prefer our own ssl_verify_cert! logic
515
+ params[:verify_hostname] = false
516
+ end
517
+
518
+ ssl_context.set_params(params)
519
+ end
520
+ end
521
+
522
+ # TODO: connect_deadline to impose a single deadline on the entire process
523
+ # XXX: specs
524
+ #
525
+ # @param ssl_socket [OpenSSL::SSL::SSLSocket]
526
+ # @raise [OpenSSL::SSL::SSLError]
527
+ # @raise [Kontena::Websocket::TimeoutError]
528
+ def ssl_connect(ssl_socket)
529
+ debug "ssl_connect..."
530
+ ret = ssl_socket.connect_nonblock
531
+ rescue IO::WaitReadable
532
+ debug "ssl_connect wait read: timeout=#{@connect_timeout}"
533
+ ssl_socket.wait_readable(@connect_timeout) or raise Kontena::Websocket::TimeoutError, "SSL connect read timeout after #{@connect_timeout}s"
534
+ retry
535
+ rescue IO::WaitWritable
536
+ debug "ssl_connect wait write: timeout=#{@connect_timeout}"
537
+ ssl_socket.wait_writable(@connect_timeout) or raise Kontena::Websocket::TimeoutError, "SSL connect write timeout after #{@connect_timeout}s"
538
+ retry
539
+ else
540
+ debug "ssl_connect: #{ret}"
541
+ end
542
+
543
+ # Connect to TCP server, perform SSL handshake, verify if required.
544
+ #
545
+ # @raise [Kontena::Websocket::ConnectError] from connect_tcp
546
+ # @raise [Kontena::Websocket::SSLConnectError]
547
+ # @raise [Kontena::Websocket::SSLVerifyError] errors that only happen with ssl_verify: true
548
+ # @return [OpenSSL::SSL::SSLSocket]
549
+ def connect_ssl
550
+ tcp_socket = self.connect_tcp
551
+ ssl_context = self.ssl_context
552
+
553
+ debug "connect_ssl: #{ssl_context.inspect} hostname=#{self.ssl_hostname}"
554
+
555
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
556
+ ssl_socket.sync_close = true # close TCPSocket after SSL shutdown
557
+ ssl_socket.hostname = self.ssl_hostname # SNI
558
+
559
+ begin
560
+ self.ssl_connect(ssl_socket)
561
+ rescue OpenSSL::SSL::SSLError => exc
562
+ # SSL_connect returned=1 errno=0 state=error: certificate verify failed
563
+ if exc.message.end_with? 'certificate verify failed'
564
+ # ssl_socket.peer_cert is not set on errors :(
565
+ raise ssl_verify_error(ssl_socket.verify_result)
566
+ else
567
+ raise Kontena::Websocket::SSLConnectError, exc
568
+ end
569
+ end
570
+
571
+ # raises Kontena::Websocket::SSLVerifyError
572
+ self.ssl_verify_cert!(ssl_socket.peer_cert, ssl_socket.peer_cert_chain) if ssl_verify?
573
+
574
+ ssl_socket
575
+ end
576
+
577
+ # @return [Connection]
578
+ def socket_connect
579
+ if ssl?
580
+ @socket = self.connect_ssl
581
+ else
582
+ @socket = self.connect_tcp
583
+ end
584
+
585
+ return Connection.new(@uri, @socket,
586
+ write_timeout: @write_timeout,
587
+ )
588
+ end
589
+
590
+ # Create websocket driver using connection, and send websocket handshake.
591
+ # Registers driver handlers to set @open, @closed states, enqueue messages, or raise errors.
592
+ #
593
+ # @param connection [Kontena::Websocket::Client::Connection]
594
+ # @raise [RuntimeError] already started?
595
+ # @return [WebSocket::Driver::Client]
596
+ def websocket_open(connection)
597
+ debug "websocket open..."
598
+
599
+ driver = ::WebSocket::Driver.client(connection)
600
+
601
+ @headers.each do |k, v|
602
+ driver.set_header(k, v)
603
+ end
604
+
605
+ # these are called from read_loop -> with_driver { driver.parse } with the @mutex held
606
+ # do not recurse back into with_driver!
607
+ driver.on :error do |event|
608
+ self.on_driver_error(event)
609
+ end
610
+
611
+ driver.on :open do |event|
612
+ self.on_driver_open(event)
613
+ end
614
+
615
+ driver.on :message do |event|
616
+ self.on_driver_message(event)
617
+ end
618
+
619
+ driver.on :close do |event|
620
+ self.on_driver_close(event)
621
+ end
622
+
623
+ # not expected to emit anything, not even :error
624
+ fail unless driver.start
625
+
626
+ debug "opening"
627
+
628
+ opening!
629
+
630
+ return driver
631
+ end
632
+
633
+ # @param exc [WebSocket::Driver::ProtocolError]
634
+ def on_driver_error(exc)
635
+ # this will presumably propagate up out of #recv_loop, not this function
636
+ raise exc
637
+
638
+ rescue WebSocket::Driver::ProtocolError => exc
639
+ raise Kontena::Websocket::ProtocolError, exc
640
+ end
641
+
642
+ # Mark client as opened, calling the block passed to #run.
643
+ #
644
+ # @param event [WebSocket::Driver::OpenEvent] no attrs
645
+ def on_driver_open(event)
646
+ debug "opened"
647
+
648
+ opened!
649
+ end
650
+
651
+ # Queue up received messages
652
+ # Causes #read to return/yield once websocket_read returns with the driver mutex unlocked.
653
+ #
654
+ # @param event [WebSocket::Driver::MessageEvent] data
655
+ def on_driver_message(event)
656
+ @message_queue << event.data
657
+ end
658
+
659
+ # Mark client as closed, allowing #run to return (and disconnect from the server).
660
+ #
661
+ # @param event [WebSocket::Driver::CloseEvent] code, reason
662
+ def on_driver_close(event)
663
+ debug "closed code=#{event.code}: #{event.reason}"
664
+
665
+ closed! event.code, event.reason
666
+ end
667
+
668
+ # Start read deadline for @open_timeout
669
+ def opening!
670
+ @opening_at = Time.now
671
+ end
672
+
673
+ # Server completed open handshake.
674
+ def opened!
675
+ @opened = true
676
+ @open = true
677
+ end
678
+
679
+ # Start read deadline for @ping_timeout
680
+ #
681
+ # @return [Time] ping at
682
+ def pinging!
683
+ @pinging = true
684
+ @ping_at = Time.now
685
+ @pong_at = nil
686
+
687
+ @ping_at
688
+ end
689
+
690
+ # Stop read deadline for @ping_timeout
691
+ # @return [Float] ping delay
692
+ def pinged!
693
+ @pinging = false
694
+ @pong_at = Time.now
695
+ @ping_delay = @pong_at - @ping_at
696
+
697
+ @ping_delay
698
+ end
699
+
700
+ # Client closing connection with code.
701
+ #
702
+ # Start read deadline for @open_timeout
703
+ #
704
+ # @param code [Integer] expecting close frame from server with matching code
705
+ def closing!(code)
706
+ @open = false # fail any further sends after close
707
+ @closing = true
708
+ @closing_at = Time.now
709
+ @closing_code = code # cheked by #read_closed
710
+ end
711
+
712
+ # Server closed, completing close handshake if closing.
713
+ #
714
+ # @param code [Integer]
715
+ # @param reason [String]
716
+ def closed!(code, reason)
717
+ @open = false
718
+ @closed = true
719
+ @closed_code = code
720
+ @closed_reason = reason
721
+ end
722
+
723
+ # @raise [Kontena::Websocket::CloseError] server closed connection without client closing
724
+ # @return [Boolean] connection was closed
725
+ def read_closed
726
+ if !@closed
727
+ return false
728
+ elsif @closing && @closing_code == @closed_code
729
+ # client sent close, server responded with close
730
+ return true
731
+ else
732
+ raise Kontena::Websocket::CloseError.new(@closed_code, @closed_reason)
733
+ end
734
+ end
735
+
736
+ # @raise [Kontena::Websocket::CloseError]
737
+ # @return [String, Array<Integer>] nil when websocket closed
738
+ def read_return
739
+ # important to first drain message queue before failing on read_closed!
740
+ while @message_queue.empty? && !self.read_closed
741
+ self.websocket_read
742
+ end
743
+
744
+ # returns nil if @closed, once the message queue is empty
745
+ return @message_queue.shift
746
+ end
747
+
748
+ # @raise [Kontena::Websocket::CloseError]
749
+ # @yield [message] received websocket message payload
750
+ # @yieldparam message [String, Array<integer>] text or binary
751
+ # @return websocket closed
752
+ def read_yield
753
+ loop do
754
+ while msg = @message_queue.shift
755
+ yield msg
756
+ end
757
+
758
+ # drain queue before returning on close
759
+ return if self.read_closed
760
+
761
+ self.websocket_read
762
+ end
763
+ end
764
+
765
+ # Return read deadline for current read state
766
+ #
767
+ # @return [Symbol, Float, Float] state, at, timeout
768
+ def read_state_timeout
769
+ case
770
+ when opening? && @open_timeout
771
+ [:open, @opening_at, @open_timeout]
772
+ when pinging? && @ping_timeout
773
+ [:pong, @ping_at, @ping_timeout]
774
+ when closing? && @close_timeout
775
+ [:close, @closing_at, @close_timeout]
776
+ when @ping_interval
777
+ [:ping, @ping_at, @ping_interval]
778
+ else
779
+ [nil, nil, nil]
780
+ end
781
+ end
782
+
783
+ # Read socket with timeout, parse websocket frames via @driver, emitting on-driver_* to enqueue messages.
784
+ # Sends ping on interval timeout.
785
+ def websocket_read
786
+ read_state, state_start, state_timeout = self.read_state_timeout
787
+
788
+ if state_timeout
789
+ read_deadline = state_start + state_timeout
790
+ read_timeout = read_deadline - Time.now
791
+ else
792
+ read_timeout = nil
793
+ end
794
+
795
+ debug "read (#{read_state})"
796
+
797
+ begin
798
+ # read from socket
799
+ data = self.socket_read(read_timeout)
800
+ rescue Kontena::Websocket::TimeoutError => exc
801
+ if read_state == :ping
802
+ debug "ping on #{exc}"
803
+ self.ping
804
+ elsif read_state
805
+ raise exc.class.new("#{exc} while waiting #{state_timeout}s for #{read_state}")
806
+ else
807
+ raise
808
+ end
809
+ else
810
+ # parse data with @driver @mutex held
811
+ with_driver do |driver|
812
+ # call into the driver, causing it to emit the events registered in #start
813
+ driver.parse(data)
814
+ end
815
+ end
816
+ end
817
+
818
+ # Read from socket with timeout, parse websocket frames.
819
+ # Invokes on_driver_*, which enqueues messages.
820
+ # The websocket must be connected.
821
+ #
822
+ # @param timeout [Flaot] seconds
823
+ # @raise [Kontena::Websocket::TimeoutError] read deadline expired
824
+ # @raise [Kontena::Websocket::TimeoutError] read timeout after X.Ys
825
+ # @raise [Kontena::Websocket::SocketError]
826
+ def socket_read(timeout = nil)
827
+ if timeout && timeout <= 0.0
828
+ raise Kontena::Websocket::TimeoutError, "read deadline expired"
829
+ end
830
+
831
+ begin
832
+ data = @connection.read(FRAME_SIZE, timeout: timeout)
833
+
834
+ rescue EOFError => exc
835
+ debug "read EOF"
836
+
837
+ raise Kontena::Websocket::EOFError, 'Server closed connection without sending close frame'
838
+
839
+ rescue IOError => exc
840
+ # socket was closed => IOError: closed stream
841
+ debug "read IOError: #{exc}"
842
+
843
+ raise Kontena::Websocket::SocketError, exc
844
+
845
+ # TODO: Errno::ECONNRESET etc
846
+ end
847
+ end
848
+ end