Files
notes/WebSocket Lifecycle Sequence Diagram.md
2025-11-25 21:38:17 -05:00

25 KiB

sequenceDiagram
    participant Client
    participant HTTPServer
    participant WebSocketServer
    participant ClientMQ
    participant ClientMQWorker
    participant MQWorker
    participant PingInterval as Ping Interval<br/>(20s)

    Note over Client,ClientMQWorker: === Connection Establishment ===
    Client->>HTTPServer: HTTP Upgrade Request
    HTTPServer->>WebSocketServer: upgrade event
    WebSocketServer->>WebSocketServer: handleUpgrade()
    WebSocketServer->>WebSocketServer: Generate connection.id (32 chars)
    WebSocketServer->>WebSocketServer: Get remoteAddress (from X-Forwarded-For)
    WebSocketServer->>WebSocketServer: Setup event handlers<br/>(message, close, pong, error)
    WebSocketServer->>ClientMQAdapter: connectDelegate(socket, headers)
    ClientMQAdapter->>ClientMQAdapter: Set __connectionStart<br/>Set __connectionId<br/>Set __meta {headers}
    ClientMQAdapter->>ClientMQAdapter: Register in _socketsById
    ClientMQAdapter->>ClientMQAdapter: Set __connected = true
    ClientMQAdapter->>ClientMQ: emit('connect', connectionId)
    ClientMQ->>ClientMQ: Register adapter in _adaptersById
    ClientMQ->>ClientMQWorker: emit('connect', connectionId)
    rect rgb(255, 255, 200)
        Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION
        ClientMQWorker->>ClientMQWorker: _connectionsTotal++<br/>_connectionsOpen++
    end
    alt Connection limit exceeded (>2000)
        ClientMQWorker->>ClientMQ: close(connectionId, 1013, 'concurrency-limit-exceeded')
        ClientMQ->>ClientMQAdapter: close(connectionId, code, reason)
        Note over ClientMQAdapter: See "Server-Initiated Close" below
    end

    Note over Client,ClientMQWorker: === Normal Message Flow (Client Request) ===
    Client->>WebSocketServer: Message (binary or base64)
    WebSocketServer->>ClientMQAdapter: messageDelegate(socket, data)
    ClientMQAdapter->>ClientMQAdapter: Decode message (MQ protocol)
    alt Unsupported message format
        ClientMQAdapter->>ClientMQAdapter: Drop message, log warning
    else Socket not connected
        ClientMQAdapter->>ClientMQAdapter: Drop message, log warning
    else Message type = 'Request'
        ClientMQAdapter->>MQWorker: processRequest(connectionId, type, payload, meta)
        alt Request timeout (>15s)
            MQWorker-->>ClientMQAdapter: TimeoutError
            ClientMQAdapter->>ClientMQAdapter: Encode mq.Error {reason: 'timeout'}
            ClientMQAdapter->>Client: Response (mq.Error)
        else Request failed
            MQWorker-->>ClientMQAdapter: Error
            ClientMQAdapter->>ClientMQAdapter: Encode mq.Error {reason: 'failed'}
            ClientMQAdapter->>Client: Response (mq.Error)
        else Request succeeded
            MQWorker-->>ClientMQAdapter: {type, payload, wallTime}
            ClientMQAdapter->>ClientMQAdapter: Build Response message<br/>(messageType: 'Response', requestId, type, payload)
            alt Socket not open
                ClientMQAdapter->>ClientMQAdapter: Drop response, log debug
            else Socket open
                ClientMQAdapter->>Client: Response (binary or base64)
                ClientMQAdapter->>ClientMQ: emit('request', connectionId, data)
                ClientMQ->>ClientMQWorker: emit('request', connectionId, data)
                ClientMQWorker->>ClientMQWorker: Handle subscription updates<br/>(JoinRoom, LeaveRoom, etc.)
            end
        end
    else Message type = 'Response'
        alt Request handler not found
            ClientMQAdapter->>ClientMQAdapter: Log warning, ignore
        else Request handler exists
            ClientMQAdapter->>ClientMQAdapter: Resolve pending request promise
        end
    else Message type = 'Event'
        ClientMQAdapter->>ClientMQAdapter: Log warning (unsupported)
    end

    Note over Client,ClientMQWorker: === Server-Initiated Request ===
    ClientMQWorker->>ClientMQ: sendRequest(connectionId, type, message)
    ClientMQ->>ClientMQAdapter: sendRequest(connectionId, type, message)
    ClientMQAdapter->>ClientMQAdapter: Build request (requestId: 'S' + counter)
    ClientMQAdapter->>ClientMQAdapter: Register in _requests map
    alt Socket not found
        ClientMQAdapter-->>ClientMQWorker: Error: 'Websocket connection not found'
    else Socket not connected
        ClientMQAdapter-->>ClientMQWorker: Error: 'Websocket connection not opened'
    else Socket valid
        ClientMQAdapter->>Client: Request (binary or base64)
        ClientMQAdapter->>ClientMQAdapter: Start timeout (15s)
        Client->>WebSocketServer: Response message
        WebSocketServer->>ClientMQAdapter: messageDelegate (Response)
        ClientMQAdapter->>ClientMQAdapter: Resolve request promise
        ClientMQAdapter-->>ClientMQWorker: Response message
        alt Response timeout
            ClientMQAdapter->>ClientMQAdapter: Log warning, cleanup after 2x timeout
        end
    end

    Note over Client,ClientMQWorker: === Heartbeat/Ping Flow ===
    loop Every 20 seconds
        PingInterval->>ClientMQAdapter: Ping interval trigger
        ClientMQAdapter->>ClientMQAdapter: Iterate all sockets
        alt Previous ping still pending
            ClientMQAdapter->>ClientMQAdapter: Skip ping, log warning
        else Socket has ping() method
            alt No pong received (lastPong < lastPing)
                ClientMQAdapter->>ClientMQAdapter: Detect timeout
                ClientMQAdapter->>ClientMQ: emit('timeout', connectionId)
                ClientMQ->>ClientMQWorker: emit('timeout', connectionId)
                ClientMQWorker->>ClientMQ: close(connectionId, 3334, 'timeout')
                Note over ClientMQAdapter: See "Server-Initiated Close" below
            else Pong received
                ClientMQAdapter->>Client: ping()
                Client->>WebSocketServer: pong()
                WebSocketServer->>ClientMQAdapter: pongDelegate(socket)
                ClientMQAdapter->>ClientMQAdapter: Set __lastPong = Date.now()<br/>Calculate RTT
                ClientMQAdapter->>MQWorker: processRoundTripTimeMeasurement(...)
            end
        else Socket has no ping() method
            alt Last ping > 2x heartbeatInterval ago
                ClientMQAdapter->>ClientMQAdapter: Detect timeout
                ClientMQAdapter->>ClientMQ: emit('timeout', connectionId)
                Note over ClientMQAdapter: See timeout flow above
            end
        end
    end

    Note over Client,ClientMQWorker: === Server-Initiated Close ===
    alt Close reason: timeout
        ClientMQWorker->>ClientMQ: close(connectionId, 3334, 'timeout')
    else Close reason: drain
        ClientMQWorker->>ClientMQ: stop(3333, 'drain')
        ClientMQ->>ClientMQAdapter: close(connectionId, code, reason) [for each]
    else Close reason: connection limit
        ClientMQWorker->>ClientMQ: close(connectionId, 1013, 'concurrency-limit-exceeded')
    else Close reason: heartbeat terminate
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
    else Close reason: send error
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
    end
    ClientMQ->>ClientMQAdapter: close(connectionId, code, reason)
    alt Socket not found
        ClientMQAdapter->>ClientMQAdapter: Log warning, return false
    else Connection already closed (>5s ago)
        ClientMQAdapter->>ClientMQAdapter: Force disconnect
        ClientMQAdapter->>ClientMQAdapter: Set __forcefullyClosed = true
        ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')
    else Connection valid
        ClientMQAdapter->>ClientMQAdapter: Set __connectionEnded = now()
        ClientMQAdapter->>Client: socket.close(code, reason)
        Client->>WebSocketServer: close event
        WebSocketServer->>ClientMQAdapter: disconnectDelegate(socket, code, description)
        alt Multiple close events
            ClientMQAdapter->>ClientMQAdapter: Log warning, return early
        else Socket already disconnected
            alt Previously forcefully closed
                ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')
            else Already disconnected
                ClientMQAdapter->>ClientMQ: emit('disconnect-already-disconnected')
            end
        else Normal disconnect
            ClientMQAdapter->>ClientMQAdapter: Remove from _socketsById
            ClientMQAdapter->>ClientMQAdapter: Set __connected = false
            ClientMQAdapter->>ClientMQAdapter: Calculate connection duration
            ClientMQAdapter->>ClientMQ: emit('disconnect', connectionId, code, description)
            ClientMQ->>ClientMQ: Remove from _adaptersById
            ClientMQ->>ClientMQWorker: emit('disconnect', connectionId, code, description)
            rect rgb(255, 255, 200)
                Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION
                ClientMQWorker->>ClientMQWorker: _connectionsOpen--
            end
            ClientMQWorker->>ClientMQWorker: Clean up room subscriptions
            ClientMQWorker->>ClientMQWorker: Clean up conversation subscriptions
            ClientMQWorker->>MQWorker: publish('pcast.ConnectionDisconnected', ...)
        end
    end

    Note over Client,ClientMQWorker: === Client-Initiated Close ===
    Client->>WebSocketServer: close connection
    WebSocketServer->>WebSocketServer: close event (reasonCode, description)
    WebSocketServer->>ClientMQAdapter: disconnectDelegate(socket, code, description)
    Note over ClientMQAdapter: Same disconnect flow as above

    Note over Client,ClientMQWorker: === Error Handling ===
    alt Socket error
        Client->>WebSocketServer: error event
        WebSocketServer->>WebSocketServer: Log error
    else Handler error
        WebSocketServer->>WebSocketServer: Log error, continue
    else Send error (not opened)
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
        ClientMQWorker->>MQWorker: request('pcast.ConnectionDisconnected', ...)
        Note over ClientMQAdapter: See "Server-Initiated Close" above
    else Send error (not found/closed)
        ClientMQWorker->>ClientMQWorker: Return {status: 'closed'}
    end

connectionsOpen

sequenceDiagram
    participant Client
    participant HTTPServer
    participant WebSocketServer
    participant ClientMQ
    participant ClientMQWorker
    participant MQWorker
    participant PingInterval as Ping Interval<br/>(20s)

    Note over Client,ClientMQWorker: === Connection Establishment ===
    Client->>HTTPServer: HTTP Upgrade Request
    HTTPServer->>WebSocketServer: upgrade event
    WebSocketServer->>WebSocketServer: handleUpgrade()
    ClientMQAdapter->>ClientMQ: emit('connect', connectionId)
    ClientMQ->>ClientMQWorker: emit('connect', connectionId)
    rect rgb(255, 255, 200)
        Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION
        ClientMQWorker->>ClientMQWorker: _connectionsTotal++<br/>_connectionsOpen++
    end
    
    Note over Client,ClientMQWorker: === Server-Initiated Close ===
    alt Close reason: timeout
        ClientMQWorker->>ClientMQ: close(connectionId, 3334, 'timeout')
    end
    ClientMQ->>ClientMQAdapter: close(connectionId, code, reason)
    alt Socket not found
        ClientMQAdapter->>ClientMQAdapter: Log warning, return false
    else Connection already closed (>5s ago)
        ClientMQAdapter->>ClientMQAdapter: Force disconnect
        ClientMQAdapter->>ClientMQAdapter: Set __forcefullyClosed = true
        ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')
    else Connection valid
        ClientMQAdapter->>ClientMQAdapter: Set __connectionEnded = now()
        ClientMQAdapter->>Client: socket.close(code, reason)
        Client->>WebSocketServer: close event
        WebSocketServer->>ClientMQAdapter: disconnectDelegate(socket, code, description)
        alt Multiple close events
            ClientMQAdapter->>ClientMQAdapter: Log warning, return early
        else Socket already disconnected
            alt Previously forcefully closed
                ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')
            else Already disconnected
                ClientMQAdapter->>ClientMQ: emit('disconnect-already-disconnected')
            end
        else Normal disconnect
            ClientMQAdapter->>ClientMQAdapter: Remove from _socketsById
            ClientMQAdapter->>ClientMQAdapter: Set __connected = false
            ClientMQAdapter->>ClientMQAdapter: Calculate connection duration
            ClientMQAdapter->>ClientMQ: emit('disconnect', connectionId, code, description)
            ClientMQ->>ClientMQ: Remove from _adaptersById
            ClientMQ->>ClientMQWorker: emit('disconnect', connectionId, code, description)
            rect rgb(255, 255, 200)
                Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION
                ClientMQWorker->>ClientMQWorker: _connectionsOpen--
            end
            ClientMQWorker->>ClientMQWorker: Clean up room subscriptions
            ClientMQWorker->>ClientMQWorker: Clean up conversation subscriptions
            ClientMQWorker->>MQWorker: publish('pcast.ConnectionDisconnected', ...)
        end
    end

    Note over Client,ClientMQWorker: === Error Handling ===
    alt Socket error
        Client->>WebSocketServer: error event
        WebSocketServer->>WebSocketServer: Log error
    else Handler error
        WebSocketServer->>WebSocketServer: Log error, continue
    else Send error (not opened)
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
        ClientMQWorker->>MQWorker: request('pcast.ConnectionDisconnected', ...)
        Note over ClientMQAdapter: See "Server-Initiated Close" above
    else Send error (not found/closed)
        ClientMQWorker->>ClientMQWorker: Return {status: 'closed'}
    end

WebSocket Lifecycle Sequence Diagram

sequenceDiagram
    participant Client
    participant HTTPServer
    participant WebSocketServer
    participant ClientMQWebsocketAdapter as ClientMQAdapter
    participant ClientMQ
    participant ClientMQWorker
    participant MQWorker
    participant PingInterval as Ping Interval<br/>(20s)

    Note over Client,ClientMQWorker: === Connection Establishment ===
    Client->>HTTPServer: HTTP Upgrade Request
    HTTPServer->>WebSocketServer: upgrade event
    WebSocketServer->>+ClientMQAdapter: connectDelegate(socket, headers)
    ClientMQAdapter->>ClientMQAdapter: Set __connectionStart<br/>Set __connectionId<br/>Set __meta {headers}
    ClientMQAdapter->>ClientMQAdapter: Register in _socketsById
    ClientMQAdapter->>ClientMQAdapter: Set __connected = true
    ClientMQAdapter->>-ClientMQ: emit('connect', connectionId)
    ClientMQ->>ClientMQ: Register adapter in _adaptersById
    ClientMQ->>ClientMQWorker: emit('connect', connectionId)
    rect rgb(255, 255, 200)
        Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION
        ClientMQWorker->>ClientMQWorker: _connectionsTotal++<br/>_connectionsOpen++
    end
    alt Connection limit exceeded (>2000)
        ClientMQWorker->>ClientMQ: close(connectionId, 1013, 'concurrency-limit-exceeded')
        ClientMQ->>ClientMQAdapter: close(connectionId, code, reason)
        Note over ClientMQAdapter: See "Server-Initiated Close" below
    end

    Note over Client,ClientMQWorker: === Normal Message Flow (Client Request) ===
    Client->>WebSocketServer: Message (binary or base64)
    WebSocketServer->>ClientMQAdapter: messageDelegate(socket, data)
    ClientMQAdapter->>ClientMQAdapter: Decode message (MQ protocol)
    alt Unsupported message format
        ClientMQAdapter->>ClientMQAdapter: Drop message, log warning
    else Socket not connected
        ClientMQAdapter->>ClientMQAdapter: Drop message, log warning
    else Message type = 'Request'
        ClientMQAdapter->>MQWorker: processRequest(connectionId, type, payload, meta)
        alt Request timeout (>15s)
            MQWorker-->>ClientMQAdapter: TimeoutError
            ClientMQAdapter->>ClientMQAdapter: Encode mq.Error {reason: 'timeout'}
            ClientMQAdapter->>Client: Response (mq.Error)
        else Request failed
            MQWorker-->>ClientMQAdapter: Error
            ClientMQAdapter->>ClientMQAdapter: Encode mq.Error {reason: 'failed'}
            ClientMQAdapter->>Client: Response (mq.Error)
        else Request succeeded
            MQWorker-->>ClientMQAdapter: {type, payload, wallTime}
            ClientMQAdapter->>ClientMQAdapter: Build Response message<br/>(messageType: 'Response', requestId, type, payload)
            alt Socket not open
                ClientMQAdapter->>ClientMQAdapter: Drop response, log debug
            else Socket open
                ClientMQAdapter->>Client: Response (binary or base64)
                ClientMQAdapter->>ClientMQ: emit('request', connectionId, data)
                ClientMQ->>ClientMQWorker: emit('request', connectionId, data)
                ClientMQWorker->>ClientMQWorker: Handle subscription updates<br/>(JoinRoom, LeaveRoom, etc.)
            end
        end
    else Message type = 'Response'
        alt Request handler not found
            ClientMQAdapter->>ClientMQAdapter: Log warning, ignore
        else Request handler exists
            ClientMQAdapter->>ClientMQAdapter: Resolve pending request promise
        end
    else Message type = 'Event'
        ClientMQAdapter->>ClientMQAdapter: Log warning (unsupported)
    end

    Note over Client,ClientMQWorker: === Server-Initiated Request ===
    ClientMQWorker->>ClientMQ: sendRequest(connectionId, type, message)
    ClientMQ->>ClientMQAdapter: sendRequest(connectionId, type, message)
    ClientMQAdapter->>ClientMQAdapter: Build request (requestId: 'S' + counter)
    ClientMQAdapter->>ClientMQAdapter: Register in _requests map
    alt Socket not found
        ClientMQAdapter-->>ClientMQWorker: Error: 'Websocket connection not found'
    else Socket not connected
        ClientMQAdapter-->>ClientMQWorker: Error: 'Websocket connection not opened'
    else Socket valid
        ClientMQAdapter->>Client: Request (binary or base64)
        ClientMQAdapter->>ClientMQAdapter: Start timeout (15s)
        Client->>WebSocketServer: Response message
        WebSocketServer->>ClientMQAdapter: messageDelegate (Response)
        ClientMQAdapter->>ClientMQAdapter: Resolve request promise
        ClientMQAdapter-->>ClientMQWorker: Response message
        alt Response timeout
            ClientMQAdapter->>ClientMQAdapter: Log warning, cleanup after 2x timeout
        end
    end

    Note over Client,ClientMQWorker: === Heartbeat/Ping Flow ===
    loop Every 20 seconds
        PingInterval->>ClientMQAdapter: Ping interval trigger
        ClientMQAdapter->>ClientMQAdapter: Iterate all sockets
        alt Previous ping still pending
            ClientMQAdapter->>ClientMQAdapter: Skip ping, log warning
        else Socket has ping() method
            alt No pong received (lastPong < lastPing)
                ClientMQAdapter->>ClientMQAdapter: Detect timeout
                ClientMQAdapter->>ClientMQ: emit('timeout', connectionId)
                ClientMQ->>ClientMQWorker: emit('timeout', connectionId)
                ClientMQWorker->>ClientMQ: close(connectionId, 3334, 'timeout')
                Note over ClientMQAdapter: See "Server-Initiated Close" below
            else Pong received
                ClientMQAdapter->>Client: ping()
                Client->>WebSocketServer: pong()
                WebSocketServer->>ClientMQAdapter: pongDelegate(socket)
                ClientMQAdapter->>ClientMQAdapter: Set __lastPong = Date.now()<br/>Calculate RTT
                ClientMQAdapter->>MQWorker: processRoundTripTimeMeasurement(...)
            end
        else Socket has no ping() method
            alt Last ping > 2x heartbeatInterval ago
                ClientMQAdapter->>ClientMQAdapter: Detect timeout
                ClientMQAdapter->>ClientMQ: emit('timeout', connectionId)
                Note over ClientMQAdapter: See timeout flow above
            end
        end
    end

    Note over Client,ClientMQWorker: === Server-Initiated Close ===
    alt Close reason: timeout
        ClientMQWorker->>ClientMQ: close(connectionId, 3334, 'timeout')
    else Close reason: drain
        ClientMQWorker->>ClientMQ: stop(3333, 'drain')
        ClientMQ->>ClientMQAdapter: close(connectionId, code, reason) [for each]
    else Close reason: connection limit
        ClientMQWorker->>ClientMQ: close(connectionId, 1013, 'concurrency-limit-exceeded')
    else Close reason: heartbeat terminate
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
    else Close reason: send error
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
    end
    ClientMQ->>ClientMQAdapter: close(connectionId, code, reason)
    alt Socket not found
        ClientMQAdapter->>ClientMQAdapter: Log warning, return false
    else Connection already closed (>5s ago)
        ClientMQAdapter->>ClientMQAdapter: Force disconnect
        ClientMQAdapter->>ClientMQAdapter: Set __forcefullyClosed = true
        ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')
    else Connection valid
        ClientMQAdapter->>ClientMQAdapter: Set __connectionEnded = now()
        rect rgb(200, 255, 200)
            Note over ClientMQAdapter,Client: 📤 SERVER SENDS CLOSE FRAME
            ClientMQAdapter->>WebSocketServer: socket.close(code, reason)
            WebSocketServer->>Client: Send Close Frame (code, reason)
            Note over Client: Client receives server's close frame
        end
        rect rgb(255, 200, 200)
            Note over Client,WebSocketServer: 📥 CLIENT MUST RESPOND WITH CLOSE FRAME<br/>(WebSocket Protocol Requirement)
            alt Client sends close frame
                Client->>WebSocketServer: Send Close Frame (acknowledgment)
                Note over WebSocketServer: Platform receives client's close frame
            else Client does NOT send close frame
                Note over ClientMQAdapter: ⏱️ After 5s timeout (closeTimeout)<br/>Connection marked as forcefully closed
                Note over ClientMQAdapter: Next disconnect will use<br/>disconnect-after-forcefull-close path
            end
        end
        rect rgb(200, 200, 255)
            Note over WebSocketServer,ClientMQWorker: 🔄 CLOSE EVENT & DISCONNECT PROCESSING
            WebSocketServer->>WebSocketServer: 'close' event fires<br/>(reasonCode, description)
            WebSocketServer->>ClientMQAdapter: disconnectDelegate(socket, code, description)
            alt Multiple close events
                ClientMQAdapter->>ClientMQAdapter: Log warning, return early<br/>(_connectionsOpen NOT decremented)
            else Socket already disconnected
                alt Previously forcefully closed
                    ClientMQAdapter->>ClientMQ: emit('disconnect-after-forcefull-close')<br/>(_connectionsOpen NOT decremented)
                else Already disconnected
                    ClientMQAdapter->>ClientMQ: emit('disconnect-already-disconnected')<br/>(_connectionsOpen NOT decremented)
                end
            else Normal disconnect
                ClientMQAdapter->>ClientMQAdapter: Remove from _socketsById
                ClientMQAdapter->>ClientMQAdapter: Set __connected = false
                ClientMQAdapter->>ClientMQAdapter: Calculate connection duration
                ClientMQAdapter->>ClientMQ: emit('disconnect', connectionId, code, description)
                ClientMQ->>ClientMQ: Remove from _adaptersById
                ClientMQ->>ClientMQWorker: emit('disconnect', connectionId, code, description)
                rect rgb(255, 255, 200)
                    Note over ClientMQWorker: 🔢 CONNECTION COUNTER MUTATION<br/>⚠️ Only happens after receiving client's close frame
                    ClientMQWorker->>ClientMQWorker: _connectionsOpen--
                end
                ClientMQWorker->>ClientMQWorker: Clean up room subscriptions
                ClientMQWorker->>ClientMQWorker: Clean up conversation subscriptions
                ClientMQWorker->>MQWorker: publish('pcast.ConnectionDisconnected', ...)
            end
        end
    end

    Note over Client,ClientMQWorker: === Client-Initiated Close ===
    rect rgb(255, 200, 200)
        Note over Client,WebSocketServer: 📥 CLIENT SENDS CLOSE FRAME
        Client->>WebSocketServer: Send Close Frame (code, reason)
        Note over WebSocketServer: Platform receives client's close frame
    end
    rect rgb(200, 255, 200)
        Note over WebSocketServer,Client: 📤 SERVER RESPONDS WITH CLOSE FRAME<br/>(WebSocket Protocol Requirement)
        WebSocketServer->>Client: Send Close Frame (acknowledgment)
    end
    rect rgb(200, 200, 255)
        Note over WebSocketServer,ClientMQWorker: 🔄 CLOSE EVENT & DISCONNECT PROCESSING
        WebSocketServer->>WebSocketServer: 'close' event fires<br/>(reasonCode, description)
        WebSocketServer->>ClientMQAdapter: disconnectDelegate(socket, code, description)
        Note over ClientMQAdapter: Same disconnect flow as server-initiated<br/>(_connectionsOpen decremented after close event)
    end

    Note over Client,ClientMQWorker: === Error Handling ===
    alt Socket error
        Client->>WebSocketServer: error event
        WebSocketServer->>WebSocketServer: Log error
    else Handler error
        WebSocketServer->>WebSocketServer: Log error, continue
    else Send error (not opened)
        ClientMQWorker->>ClientMQ: close(connectionId, 3335, 'unexpected-close')
        ClientMQWorker->>MQWorker: request('pcast.ConnectionDisconnected', ...)
        Note over ClientMQAdapter: See "Server-Initiated Close" above
    else Send error (not found/closed)
        ClientMQWorker->>ClientMQWorker: Return {status: 'closed'}
    end