Files
2026-07-13 13:12:00 +08:00

592 lines
28 KiB
Python

"""Shared tmux control-mode (``tmux -C``) ↔ WebSocket bridge.
Alternative transport to :mod:`omnigent.terminals.ws_bridge`. Where the PTY
bridge forks a full ``tmux attach`` client and streams the rendered screen,
this bridge attaches a *control-mode* client and consumes tmux's line protocol:
- ``%output <pane-id> <octal-escaped-bytes>`` — the raw bytes the program in a
pane just produced, forwarded to the browser xterm.js as binary frames. The
browser terminal therefore owns the character grid, scrollback, and text
selection (tmux's own status line / copy-mode chrome is never streamed to a
control client), which is what gives native scrolling and copy in the web UI.
- ``%begin <t> <n> <flags>`` … ``%end``/``%error <t> <n> <flags>`` — bracketed
reply blocks for commands the bridge sends, correlated by command number.
- ``%exit`` / ``%window-close`` / ``%layout-change`` — lifecycle + structure.
Design notes learned from the protocol (see ``control_bridge`` spike):
- Attach with ``-C`` (NOT ``-CC``): ``-CC`` requires the parent be a real
terminal and the client exits immediately when stdin/stdout are pipes. ``-C``
gives the same protocol with echo already off, which is what we want for a
programmatic consumer reading/writing pipes.
- A control client only receives ``%output`` produced *after* it attaches, so
the bridge seeds the browser terminal with ``capture-pane -e -p`` (escapes
preserved) once on connect, then streams subsequent ``%output``.
- Browser input bytes are injected with ``send-keys -H <hh> <hh> ...``
(space-separated hex, one token per byte). Feeding raw ESC/control bytes into
a ``send-keys -l`` command line corrupts the line-based command parser and
the client exits; the hex channel is byte-exact for ESC sequences, control
chars, and UTF-8 multibyte alike.
The browser-facing wire protocol is identical to the PTY bridge (binary frames
out = raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary
frames in = input bytes), so the two transports are interchangeable behind the
same ``/attach`` WebSocket and a client cannot tell which one served it.
Known limitation vs the PTY bridge: tmux's own overlays (``display-popup``,
copy-mode, status line) are NOT delivered to a control-mode client, so the
native cost-approval popup (:mod:`omnigent.native_cost_popup`) does not render
in a control-mode browser terminal. That popup is a secondary convenience for
users working in a real native TTY; the web ApprovalCard (SSE-driven) remains
the primary approval surface and is unaffected. The harnesses' own input,
paste, and readiness logic run tmux commands directly against the socket
(``send-keys``/``load-buffer``/``capture-pane``) and are independent of the
attach transport, so they behave identically under either bridge.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import re
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from fastapi import WebSocket, WebSocketDisconnect
# Reuse the PTY bridge's application close codes AND its coalescing forwarder so
# both transports speak the same dialect to the frontend and merge burst output
# the same way (see ws_bridge for the authoritative definitions).
# ``_forward_pty_to_ws`` is queue-driven and transport-agnostic — it drains
# everything already queued into one bounded ``send_bytes`` — so the control
# reader can feed it decoded ``%output`` payloads exactly like the PTY reader
# feeds raw PTY reads. Under a burst the browser send lags tmux's firehose, a
# backlog forms, and the forwarder collapses thousands of tiny per-line frames
# into a few large ones. ``_coalesce_limit_after_input`` keeps the frame right
# after a keystroke small so the echo stays on xterm's synchronous paint path.
from omnigent.terminals.ws_bridge import (
WS_CLOSE_INTERNAL_ERROR,
WS_CLOSE_TERMINAL_DETACHED,
WS_CLOSE_TERMINAL_NOT_FOUND,
_coalesce_limit_after_input,
_forward_pty_to_ws,
_monotonic,
)
_logger = logging.getLogger(__name__)
__all__ = [
"bridge_tmux_control_to_websocket",
"unescape_control_output",
]
# tmux octal-escapes bytes < 0x20 and backslash in %output values as ``\ooo``.
_OCTAL_ESCAPE_RE: Final = re.compile(rb"\\([0-7]{3})")
# ``capture-pane -p`` joins rows with a bare LF. Match an LF not already
# preceded by CR so the CRLF rewrite is idempotent (a future tmux emitting
# CRLF is left untouched).
_CAPTURE_ROW_SEP_RE: Final = re.compile(rb"(?<!\r)\n")
# tmux's client→server protocol rejects a single command larger than its 16KB
# imsg cap. ``send-keys -H`` expands each input byte to 3 chars ("xx "), so cap
# the bytes per send-keys invocation well under the limit. 1024 bytes ≈ 3KB
# packed; tmux applies successive invocations in order so the pane sees one
# contiguous stream. Matches terminal.py's literal-send chunking rationale.
_SEND_KEYS_HEX_BYTES_PER_CALL: Final[int] = 1024
# Raw-read chunk for the control client's stdout. The reader uses
# ``stdout.read(n)`` (not ``readline()``) and parses lines from its own buffer:
# one wakeup can pull many ``%output`` lines so the forwarder coalesces them,
# and raw reads sidestep ``readline()``'s line-length cap (an oversized line
# would otherwise raise ``LimitOverrunError`` and crash the reader on a tmux
# build that chunks ``%output`` more coarsely than 3.6b's few-KB lines).
_CONTROL_READ_CHUNK: Final[int] = 256 * 1024
# StreamReader buffer cap. ``read(n)`` returns as soon as any bytes arrive and
# doesn't enforce a line limit, but the 64 KiB default would still bound a
# single read; raise it so a large burst can be pulled in one wakeup.
_CONTROL_STDOUT_BUFFER_LIMIT: Final[int] = 16 * 1024 * 1024
# When the control reader ends with a send backlog still queued (a
# burst-then-exit program), how long to let the forwarder finish draining that
# sentinel-terminated backlog before teardown cancels it. Bounds teardown so a
# stuck-slow client can't hang the close; a normal drain completes well within.
_FORWARD_DRAIN_TIMEOUT_S: Final[float] = 5.0
def unescape_control_output(value: bytes) -> bytes:
"""Un-escape a ``%output`` value back to raw pane bytes.
tmux escapes bytes below ASCII space and the backslash itself as a
three-digit octal sequence ``\\ooo``; every other byte passes through
verbatim. The decoded result is a raw terminal byte stream (not guaranteed
valid UTF-8) suitable to write straight into xterm.js.
:param value: The escaped bytes following ``%output <pane-id> `` on one
protocol line, e.g. ``rb"\\033[31mRED\\033[0m\\015\\012"``.
:returns: The raw bytes, e.g. ``b"\\x1b[31mRED\\x1b[0m\\r\\n"``.
"""
return _OCTAL_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), value)
def _hex_send_keys_commands(target: str, data: bytes) -> list[bytes]:
"""Build ``send-keys -H`` control-mode command line(s) for raw input bytes.
:param target: The tmux target the keys are sent to, e.g. ``"main"``.
:param data: Raw input bytes from the browser (keystrokes, paste, mouse
reports, ESC sequences).
:returns: One or more newline-terminated command lines, each carrying at
most :data:`_SEND_KEYS_HEX_BYTES_PER_CALL` bytes as space-separated hex.
"""
commands: list[bytes] = []
for start in range(0, len(data), _SEND_KEYS_HEX_BYTES_PER_CALL):
chunk = data[start : start + _SEND_KEYS_HEX_BYTES_PER_CALL]
hexs = " ".join(f"{b:02x}" for b in chunk)
commands.append(f"send-keys -t {target} -H {hexs}\n".encode())
return commands
async def _run_tmux_capture(socket_path: str, tmux_target: str) -> bytes | None:
"""Capture the current pane screen (with escapes) to seed the browser view.
A control client only receives ``%output`` produced after it attaches, so
the pane's pre-attach content must be seeded explicitly. ``-e`` preserves
SGR/color escapes so the seed paints identically to the live pane.
``capture-pane -p`` separates rows with a **bare LF** (``\\n``, no carriage
return). Written verbatim into xterm.js each LF moves the cursor down but
not to column 0, so every row starts where the previous one ended — the
whole seed staircases to the right. We rewrite each row separator to
``\\r\\n`` so the grid paints flush-left, matching the live ``%output``
stream (which already carries CRLF). Home + clear (``\\x1b[H\\x1b[2J``) is
prepended so the seed lands on a clean screen at the top-left.
``capture-pane`` records only the cell contents, not the cursor. Writing
the seed leaves the browser cursor wherever the last row ended, not where
the application actually parked it (e.g. inside a prompt input box). We
query ``#{cursor_x}`` / ``#{cursor_y}`` and append a CUP escape so the
cursor is restored to its real position, and honor ``#{cursor_flag}`` so a
hidden cursor stays hidden.
**Scrollback**, conditioned on the screen mode:
- **Primary screen** (a shell, the polly REPL): capture from the start of
history (``-S -``) so the browser recovers the full scrollback, not just
the visible screen. The extra history lines scroll into xterm's own
scrollback as they're written; the cursor CUP is screen-relative so it
still lands correctly on the visible grid.
- **Alternate screen** (claude, codex, vim): capture the visible screen
only. The alternate buffer has no scrollback, and ``-S -`` would leak the
stale *primary*-screen history from before the app switched buffers —
lines that were never part of the app's UI — corrupting the seed. tmux's
``#{alternate_on}`` distinguishes the two.
:param socket_path: tmux server socket path.
:param tmux_target: The ``-t`` target, e.g. ``"main"``.
:returns: The captured bytes to write into xterm, or ``None`` on failure
(the caller proceeds without a seed rather than aborting the attach).
"""
tmux = shutil.which("tmux")
if tmux is None:
return None
meta = await _capture_pane_metadata(tmux, socket_path, tmux_target)
# Only extend the capture into history when on the primary screen; on the
# alternate screen ``-S -`` leaks stale primary history (see docstring).
capture_args = ["capture-pane", "-e", "-p", "-t", tmux_target]
if meta is not None and not meta.alternate_on:
capture_args += ["-S", "-"]
try:
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
socket_path,
*capture_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
except (OSError, ValueError):
return None
if proc.returncode != 0:
return None
# ``capture-pane -p`` emits one LF per row — INCLUDING a trailing LF after
# the final row. Writing that trailing separator paints the last row and
# then advances the cursor past it, which on a full-height pane scrolls the
# whole screen up by one line (the "extra line" / off-by-one). Strip the
# single trailing newline so the last row is painted with no line break
# after it; the cursor-restore escape then lands on the correct row.
body = stdout[:-1] if stdout.endswith(b"\n") else stdout
# Normalize the remaining bare-LF row separators to CRLF (see docstring) and
# paint onto a cleared screen from the home cursor so the seed can't
# staircase.
normalized = _CAPTURE_ROW_SEP_RE.sub(b"\r\n", body)
cursor = _cursor_restore_escape(meta)
return b"\x1b[H\x1b[2J" + normalized + cursor
@dataclass(frozen=True)
class _PaneMetadata:
"""Pane state needed to reconstruct the seed: cursor + screen mode.
:param cursor_x: 0-based cursor column from ``#{cursor_x}``.
:param cursor_y: 0-based cursor row from ``#{cursor_y}``.
:param cursor_visible: Whether ``#{cursor_flag}`` reported the cursor shown.
:param alternate_on: Whether the pane is on the alternate screen
(``#{alternate_on}`` == 1).
"""
cursor_x: int
cursor_y: int
cursor_visible: bool
alternate_on: bool
async def _capture_pane_metadata(
tmux: str, socket_path: str, tmux_target: str
) -> _PaneMetadata | None:
"""Query cursor position, cursor visibility, and alt-screen state.
One ``display-message`` fetches every field the seed needs. Returns
``None`` on any failure — the caller degrades gracefully (skips the
history extension and the cursor restore) rather than aborting the attach.
:param tmux: Absolute path to the tmux binary.
:param socket_path: tmux server socket path.
:param tmux_target: The ``-t`` target, e.g. ``"main"``.
:returns: The parsed :class:`_PaneMetadata`, or ``None`` if unavailable.
"""
try:
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
socket_path,
"display-message",
"-p",
"-t",
tmux_target,
"#{cursor_x},#{cursor_y},#{cursor_flag},#{alternate_on}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
except (OSError, ValueError):
return None
if proc.returncode != 0:
return None
try:
x_str, y_str, flag_str, alt_str = stdout.decode().strip().split(",")
return _PaneMetadata(
cursor_x=int(x_str),
cursor_y=int(y_str),
cursor_visible=flag_str.strip() == "1",
alternate_on=alt_str.strip() == "1",
)
except (ValueError, UnicodeDecodeError):
return None
def _cursor_restore_escape(meta: _PaneMetadata | None) -> bytes:
"""Build the escape that restores the pane cursor after a seed.
:param meta: Pane metadata from :func:`_capture_pane_metadata`, or ``None``.
:returns: A CUP escape (``\\x1b[{row};{col}H``, 1-based) plus a show/hide
escape matching the pane's cursor visibility, or ``b""`` when *meta* is
``None`` (a missing cursor restore is cosmetic, never fatal).
"""
if meta is None:
return b""
# tmux cursor_x/y are 0-based; CUP is 1-based.
cup = f"\x1b[{meta.cursor_y + 1};{meta.cursor_x + 1}H".encode()
visibility = b"\x1b[?25h" if meta.cursor_visible else b"\x1b[?25l"
return cup + visibility
async def bridge_tmux_control_to_websocket(
websocket: WebSocket,
*,
socket_path: str,
tmux_target: str,
read_only: bool,
on_client_interaction: Callable[[], None] | None = None,
) -> None:
"""Bridge a tmux control-mode client to an already-accepted *websocket*.
Drop-in alternative to
:func:`omnigent.terminals.ws_bridge.bridge_tmux_pty_to_websocket` with the
same signature and browser wire protocol. Caller must have called
``websocket.accept()``. On exit (any branch) the control client is torn
down and the websocket closed best-effort with the shared 4404/4405 codes.
:param websocket: An accepted FastAPI :class:`WebSocket`.
:param socket_path: Filesystem path to the tmux server socket.
:param tmux_target: The ``-t`` target string identifying the session.
:param read_only: When ``True``, attach with ``-r`` *and* drop inbound
binary input frames at the application layer (defense in depth).
:param on_client_interaction: Optional callback fired on every client
interaction (connect, disconnect, each input/resize frame) so the
idle watcher can discount client-driven repaints. See the PTY bridge
for the full rationale.
"""
# Attaching reflows the pane to this client's size — stamp it as a client
# interaction so the idle watcher discounts the resulting repaint.
if on_client_interaction is not None:
on_client_interaction()
tmux = shutil.which("tmux")
if tmux is None:
_logger.error("tmux not found on PATH; cannot control-attach target=%s", tmux_target)
with contextlib.suppress(RuntimeError):
await websocket.close(code=WS_CLOSE_INTERNAL_ERROR, reason="tmux not found")
return
# Seed the browser terminal with the current screen BEFORE attaching so no
# pre-attach content is missing. Failure is non-fatal — a live pane redraw
# will repaint it shortly.
seed = await _run_tmux_capture(socket_path, tmux_target)
if seed:
with contextlib.suppress(RuntimeError, WebSocketDisconnect):
await websocket.send_bytes(seed)
argv = [tmux, "-S", socket_path, "-f", "/dev/null", "-C", "attach"]
if read_only:
argv.append("-r")
argv += ["-t", tmux_target]
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
# Raise the stdout StreamReader buffer above the 64 KiB default so a
# single ``read`` can pull a whole output burst (see
# _CONTROL_STDOUT_BUFFER_LIMIT).
limit=_CONTROL_STDOUT_BUFFER_LIMIT,
)
except (OSError, ValueError):
_logger.exception("control-attach spawn failed target=%s", tmux_target)
with contextlib.suppress(RuntimeError):
await websocket.close(code=WS_CLOSE_INTERNAL_ERROR, reason="control attach failed")
return
assert proc.stdin is not None and proc.stdout is not None
stdin = proc.stdin
stdout = proc.stdout
# Decoded ``%output`` payloads flow reader → forwarder through this queue
# (``None`` = EOF sentinel). The forwarder coalesces everything queued into
# one bounded ``send_bytes``, so when the browser send lags tmux's firehose
# a backlog of tiny per-line payloads collapses into a few large frames.
output_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
# Monotonic stamp of the last forwarded browser input; the forwarder reads
# it to shrink the frame cap right after a keystroke (keeps the echo on
# xterm's synchronous paint path — see the PTY bridge).
last_client_input_at: float | None = None
def _current_ws_coalesce_limit() -> int:
"""Per-frame cap: small right after input, larger for output floods."""
return _coalesce_limit_after_input(last_client_input_at)
async def _send_command(line: bytes) -> None:
"""Write one newline-terminated control command, ignoring a dead pipe."""
if stdin.is_closing():
return
try:
stdin.write(line)
await stdin.drain()
except (ConnectionResetError, BrokenPipeError, OSError):
return
def _handle_control_line(line: bytes) -> bool:
"""Route one protocol line; return ``True`` to keep reading.
Queues decoded ``%output`` payloads for the forwarder and detects the
lifecycle lines that end the stream (session gone / ``%exit`` /
window-close). Pure parsing — the actual browser send is the
forwarder's job.
:param line: One control-protocol line, without its trailing newline.
:returns: ``True`` to continue reading, ``False`` to stop.
"""
if line.startswith(b"%output "):
# %output %<pane-id> <escaped-bytes>
parts = line.split(b" ", 2)
if len(parts) == 3:
output_chunks.put_nowait(unescape_control_output(parts[2]))
return True
if line.startswith(b"%exit"):
return False
if line.startswith(b"%window-close"):
# The single-pane session's only window closing means the pane is
# gone. Let the exit path decide detach-vs-gone via a liveness
# probe. (%pane-mode-changed — copy-mode enter/leave — is
# deliberately NOT a close trigger.)
return False
# %begin/%end/%error reply blocks and other notifications
# (%layout-change, %session-changed, %window-*) need no browser
# forwarding — the browser xterm renders purely from %output.
return True
async def _read_control() -> None:
"""Read raw control-stream chunks, parse lines, queue %output.
Reads with ``stdout.read()`` rather than ``readline()`` so one wakeup
can pull many buffered ``%output`` lines at once — letting the
forwarder coalesce them — and so an oversized line can't raise
``LimitOverrunError``. Always enqueues the ``None`` EOF sentinel on exit
so the forwarder terminates.
"""
buffer = b""
try:
while True:
data = await stdout.read(_CONTROL_READ_CHUNK)
if not data:
# tmux control client closed its stdout — server/session gone.
return
buffer += data
# Parse all COMPLETE lines; keep any trailing partial for next read.
*lines, buffer = buffer.split(b"\n")
for raw_line in lines:
if not _handle_control_line(raw_line.rstrip(b"\r")):
return
finally:
output_chunks.put_nowait(None)
async def _ws_to_control() -> None:
"""Read browser frames; resize via refresh-client -C, input via -H hex."""
nonlocal last_client_input_at
try:
while True:
msg = await websocket.receive()
if on_client_interaction is not None:
on_client_interaction()
if msg.get("type") == "websocket.disconnect":
return
text = msg.get("text")
data = msg.get("bytes")
if text is not None:
try:
ctl = json.loads(text)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(ctl, dict) and ctl.get("type") == "resize":
try:
cols = int(ctl["cols"])
rows = int(ctl["rows"])
except (KeyError, TypeError, ValueError):
continue
await _send_command(f"refresh-client -C {cols}x{rows}\n".encode())
elif data is not None and not read_only:
# Stamp before sending so the next %output (the echo) takes
# the small interactive frame cap.
last_client_input_at = _monotonic()
for cmd in _hex_send_keys_commands(tmux_target, data):
await _send_command(cmd)
except WebSocketDisconnect:
return
# Do NOT prime a default size. A control client leaves the window size
# untouched until it issues its first ``refresh-client -C``, so priming
# 80x24 here would shrink the window on attach and then grow it again the
# instant the browser reports its real dimensions — two spurious SIGWINCHes
# per attach (visible as a resize bounce every time the terminal is
# re-mounted, e.g. toggling transcript mode). Instead we wait for the
# browser's first resize message; tmux dedupes a ``refresh-client -C`` that
# matches the current window size, so a re-attach at an unchanged size emits
# no resize at all.
# Reader parses the control stream and queues %output; forwarder coalesces
# queued payloads into bounded WebSocket frames; ws task drives input.
read_task = asyncio.create_task(_read_control(), name="tmux-control-read")
forward_task = asyncio.create_task(
_forward_pty_to_ws(
websocket, output_chunks, max_coalesce_bytes=_current_ws_coalesce_limit
),
name="tmux-control-forward",
)
ws_task = asyncio.create_task(_ws_to_control(), name="tmux-ws-to-control")
# "Control side ended" == the reader finished (session gone / %exit /
# window-close) — the signal the close-code logic keys on. The forwarder
# finishing is downstream (it drains, then sees the EOF sentinel).
control_ended_first = False
try:
done, pending = await asyncio.wait(
{read_task, forward_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
)
control_ended_first = read_task in done
# When the reader finished first it already queued every remaining
# %output plus the None EOF sentinel, so the forwarder will drain the
# backlog and exit on its own. Await it (bounded) BEFORE cancelling so a
# burst-then-exit program's tail isn't dropped mid-drain — the exact
# loss the old inline-send loop couldn't have (it flushed each frame
# before reading the next line). Bounded so a wedged/stuck-slow send
# can't hang teardown; the timeout then falls through to cancel.
if control_ended_first and not forward_task.done():
# Suppress everything here (TimeoutError → drain took too long, fall
# through to cancel; any other error → the forwarder itself raised,
# which asyncio.shield propagates out of wait_for instead of
# TimeoutError). Letting either escape would skip the cancel/log
# bookkeeping below (the finally still runs). A real forwarder error
# is still surfaced by the exception-logging loop, since forward_task
# is then done() with it stored. ``Exception`` (not BaseException)
# so a CancelledError of the outer bridge still propagates.
with contextlib.suppress(Exception):
await asyncio.wait_for(
asyncio.shield(forward_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
)
for task in pending:
if task.done():
continue
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
for task in {read_task, forward_task, ws_task}:
if task.done() and not task.cancelled():
exc = task.exception()
if exc is not None:
_logger.warning("control-attach: bridge task crashed: %r", exc)
finally:
# Detach reflows the pane back to remaining clients — stamp it.
if on_client_interaction is not None:
on_client_interaction()
# Detach the control client: an empty command line detaches cleanly.
await _send_command(b"\n")
with contextlib.suppress(ProcessLookupError):
proc.terminate()
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(proc.wait(), timeout=2.0)
if proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
with contextlib.suppress(RuntimeError):
if control_ended_first:
# The control client ended: distinguish a genuine session-gone
# (%exit with a dead/absent pane) from a mere detach. Reuse the
# PTY bridge's pane-dead probe for a single source of truth.
from omnigent.terminals.ws_bridge import (
_check_pane_dead_definitive,
_tmux_session_alive,
)
pane_dead = await _check_pane_dead_definitive(socket_path, tmux_target)
if pane_dead is True or (
pane_dead is None and not await _tmux_session_alive(socket_path, tmux_target)
):
await websocket.close(
code=WS_CLOSE_TERMINAL_NOT_FOUND,
reason="terminal session ended",
)
else:
await websocket.close(
code=WS_CLOSE_TERMINAL_DETACHED,
reason="terminal detached",
)
else:
await websocket.close()