Class: Clacky::Server::HttpServer::WebSocketConnection

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/server/http_server.rb

Overview

Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.

IMPORTANT: send_raw is called from the Agent thread via broadcast() →send_json(). A blocking socket write with no deadline can pin the Agent thread indefinitely when the client’s receive buffer fills up (silent disconnects such as Wi-Fi handoff or NAT timeout, where TCP keepalive defaults are measured in hours). Thread#raise on blocking native socket writes is best-effort and unreliable, so instead we bound every write with an explicit deadline using IO.select + write_nonblock and declare the connection dead on timeout.

Constant Summary collapse

SEND_DEADLINE =

Maximum time a single send_raw call is allowed to spend writing. 5 seconds is generous for healthy LAN/Internet clients and short enough that a stuck Agent becomes responsive again quickly.

5.0
SEND_SLOW_WARN =

Warn threshold — any individual send_raw that exceeds this is logged so we can spot sluggish clients before they fully hang.

1.0

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(socket, version) ⇒ WebSocketConnection

Returns a new instance of WebSocketConnection.



5669
5670
5671
5672
5673
5674
5675
# File 'lib/clacky/server/http_server.rb', line 5669

def initialize(socket, version)
  @socket     = socket
  @version    = version
  @send_mutex = Mutex.new
  @closed     = false
  WebSocketConnection.apply_keepalive(socket)
end

Instance Attribute Details

#session_idObject

Returns the value of attribute session_id.



5658
5659
5660
# File 'lib/clacky/server/http_server.rb', line 5658

def session_id
  @session_id
end

Class Method Details

.apply_keepalive(socket) ⇒ Object

Enable TCP keepalive on the underlying socket so silently dead peers are detected in minutes instead of the OS default of hours. Best-effort: any failure is logged at debug level and ignored.



5789
5790
5791
5792
5793
5794
5795
5796
5797
5798
5799
5800
5801
5802
5803
5804
5805
5806
5807
5808
5809
5810
5811
5812
5813
5814
5815
# File 'lib/clacky/server/http_server.rb', line 5789

def self.apply_keepalive(socket)
  return unless socket.respond_to?(:setsockopt)

  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)

  # TCP-level keepalive tuning — constants vary by platform and are
  # only set when available. Values chosen to detect dead peers in
  # roughly 60-90 seconds total.
  if defined?(Socket::IPPROTO_TCP)
    # Idle time before first probe (Linux: TCP_KEEPIDLE, macOS: TCP_KEEPALIVE)
    idle_const = if Socket.const_defined?(:TCP_KEEPIDLE)
                   Socket::TCP_KEEPIDLE
                 elsif Socket.const_defined?(:TCP_KEEPALIVE)
                   Socket::TCP_KEEPALIVE
                 end
    socket.setsockopt(Socket::IPPROTO_TCP, idle_const, 60) if idle_const

    if Socket.const_defined?(:TCP_KEEPINTVL)
      socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPINTVL, 10)
    end
    if Socket.const_defined?(:TCP_KEEPCNT)
      socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_KEEPCNT, 3)
    end
  end
rescue StandardError => e
  Clacky::Logger.debug("[WS] failed to set keepalive: #{e.class}: #{e.message}")
end

Instance Method Details

#closed?Boolean

Returns true if the underlying socket has been detected as dead.

Returns:

  • (Boolean)


5678
5679
5680
# File 'lib/clacky/server/http_server.rb', line 5678

def closed?
  @closed
end

#force_close!Object

Force-close the connection (used by the interrupt watchdog when an Agent thread is stuck on an unresponsive socket write).



5684
5685
5686
5687
5688
5689
# File 'lib/clacky/server/http_server.rb', line 5684

def force_close!
  @closed = true
  @socket.close
rescue StandardError
  # best effort
end

#send_json(data) ⇒ Object

Send a JSON-serializable object over the WebSocket. Returns true on success, false if the connection is dead.



5693
5694
5695
5696
5697
5698
# File 'lib/clacky/server/http_server.rb', line 5693

def send_json(data)
  send_raw(:text, JSON.generate(data))
rescue => e
  Clacky::Logger.debug("WS send error (connection dead): #{e.message}")
  false
end

#send_raw(type, data) ⇒ Object

Send a raw WebSocket frame. Returns true on success, false on broken/closed/sluggish socket.

Uses write_nonblock with an overall deadline so the caller (typically the Agent thread) never blocks longer than SEND_DEADLINE, even if the client silently stopped reading.



5706
5707
5708
5709
5710
5711
5712
5713
5714
5715
5716
5717
5718
5719
5720
5721
5722
5723
5724
5725
5726
5727
5728
5729
5730
5731
5732
5733
5734
5735
5736
5737
5738
5739
5740
5741
5742
5743
5744
5745
5746
5747
5748
5749
5750
5751
# File 'lib/clacky/server/http_server.rb', line 5706

def send_raw(type, data)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  @send_mutex.synchronize do
    return false if @closed

    outgoing = WebSocket::Frame::Outgoing::Server.new(
      version: @version,
      data: data,
      type: type
    )
    bytes = outgoing.to_s

    unless write_with_deadline(bytes, SEND_DEADLINE)
      # Deadline exceeded — treat as a dead connection so broadcast
      # purges it and the Agent thread is freed immediately.
      @closed = true
      begin
        @socket.close
      rescue StandardError
        # ignore
      end
      Clacky::Logger.warn(
        "[WS] send_raw deadline exceeded — closing sluggish connection " \
        "(bytes=#{bytes.bytesize}, deadline=#{SEND_DEADLINE}s)"
      )
      return false
    end
  end

  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
  if elapsed > SEND_SLOW_WARN
    Clacky::Logger.warn(
      "[WS] send_raw slow: #{elapsed.round(2)}s (type=#{type})"
    )
  end
  true
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
  @closed = true
  Clacky::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
  false
rescue => e
  @closed = true
  Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
  false
end