kontena-websocket-client 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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