Speed up WebCRT and record audited commands from the device echo.

Performance: event-driven stdout pump (no polling thread per socket, no 200ms
coalescing stall), keystrokes written immediately, blocking SSH reads, bounded
deque queue with batch drain, incremental UTF-8 decode, buffered session log,
non-blocking session teardown, shorter login/settle waits, xterm WebGL renderer
and idle-time direct writes. Zero-latency fake device: echo p50 11ms -> 2.3ms,
command RTT 219ms -> 20ms, bulk output 2.1 -> 8.3 MB/s.

Audit: track the device's own input row server-side (webcrt_cmd_audit) and
resolve each Enter from the echoed line feed, so Tab, backspace, cursor-key
edits, history recall, paste, Ctrl-C and pagers record what actually ran.
Password lines are redacted; pending Enters are flushed on close.

Hop: stop closing the hop session when the target prints the same prompt as the
hop (system-view); recognise the Huawei "closed by the remote host" message.
Generic NEs that show a network prompt now get the network key map.

Adds bench_webcrt.py, fake_ssh_device.py and e2e_webcrt_input_scenarios.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 07:34:30 +08:00
parent 0d894efe5e
commit 45de8144a3
14 changed files with 2060 additions and 241 deletions

View file

@ -34,6 +34,7 @@ from .webcrt_channel import (
_normalize_encoding,
_prime_interactive_channel,
_session_log_text,
looks_like_network_prompt,
prepare_bootstrap_output,
uses_network_cli_keymap,
)
@ -284,14 +285,16 @@ def _finish_connect(
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
# (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
# Keep this short: live echo already streamed login; long settle blocks WS stdin.
# Login already ended on a CLI prompt: only a short look for trailing MOTD bytes.
prompted_early = _looks_like_cli_prompt(pre_log)
try:
early = _capture_raw_channel(conn, duration=0.2)
early = _capture_raw_channel(conn, duration=0.06 if prompted_early else 0.2)
except Exception:
early = ""
if early:
sess.push_connect_echo(early)
seed = f"{pre_log}{early}"
already_prompted = _looks_like_cli_prompt(seed) or _looks_like_cli_prompt(pre_log)
already_prompted = _looks_like_cli_prompt(seed) or prompted_early
saw_password_change = bool(
re.search(
r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:",
@ -356,7 +359,7 @@ def _finish_connect(
primed = ""
combined = f"{seed}{primed}"
# Brief settle only — do not burn seconds here while the terminal shows a prompt.
settle_sec = 0.12 if already_prompted or _looks_like_cli_prompt(combined) else 0.3
settle_sec = 0.05 if already_prompted or _looks_like_cli_prompt(combined) else 0.3
try:
more = _capture_raw_channel(conn, duration=settle_sec)
if more:
@ -395,7 +398,7 @@ def _finish_connect(
if not echoed.strip():
bootstrap = prepare_bootstrap_output(combined)
try:
leftover = _capture_raw_channel(conn, duration=0.1)
leftover = _capture_raw_channel(conn, duration=0.05)
except Exception:
leftover = ""
if (
@ -407,7 +410,7 @@ def _finish_connect(
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
else:
try:
leftover = _capture_raw_channel(conn, duration=0.08)
leftover = _capture_raw_channel(conn, duration=0.04)
except Exception:
leftover = ""
if (
@ -421,6 +424,19 @@ def _finish_connect(
sess.conn = conn
sess.cli_hop_guard = bool(hop_guard)
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
# Inventory type "generic" (e.g. a quick-connect to a Cisco box) disables the network
# key map, so Home/Delete reach IOS/VRP as raw escape sequences. The login prompt tells
# us what it really is.
if not sess.cli_keymap and str(sess.device_type or "").strip().lower() in (
"",
"generic",
"generic_termserver",
"generic_telnet",
):
last_line = str(echoed or combined or "").replace("\r\n", "\n").replace("\r", "\n").rstrip(" \n").split("\n")[-1]
if looks_like_network_prompt(prompt_hint or last_line) or looks_like_network_prompt(last_line):
sess.cli_keymap = True
sess._tracker.linux_like = False
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
# Nudge Enter on WS attach only when we still need a shell prompt.
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
@ -432,6 +448,8 @@ def _finish_connect(
and not _looks_like_login_prompt(final_view)
)
sess.open_session_log()
# Prime the audit tracker with the prompt printed during login (reader starts later).
sess.seed_audit_row(echoed or bootstrap or combined)
log_seed = echoed or bootstrap
if log_seed:
sess.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n")
@ -439,7 +457,7 @@ def _finish_connect(
# Drop late prompt echoes that race into the queue right after reader start.
if not prompt_hint and final_view:
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
settle_deadline = time.time() + (0.2 if already_prompted or saw_password_change else 0.35)
settle_deadline = time.time() + (0.08 if already_prompted or saw_password_change else 0.35)
while time.time() < settle_deadline:
try:
chunk = sess.out_queue.get_nowait()
@ -456,6 +474,8 @@ def _finish_connect(
# A slow VM may emit its FIRST prompt after the reader starts.
# Only drop duplicates when a prompt was already delivered during login.
if _looks_like_cli_prompt(final_view) and _is_prompt_only_echo(text, prompt_hint):
# Dropped from the browser stream: keep the audit row in step with it.
sess.reset_audit_row(prompt_hint)
continue
if _looks_like_cli_prompt(text):
sess.needs_live_prompt = False
@ -696,8 +716,7 @@ def mark_attached(session_id: str) -> tuple[WebcrtSession, int]:
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
# Allow re-attach after brief WS drop (React StrictMode remount / network blip).
# Bump generation so the previous WS pump stops and does not steal echo bytes.
sess.attach_gen += 1
attach_gen = sess.attach_gen
attach_gen = sess.bump_attach()
sess.attached = True
sess.detach_deadline = None
sess.touch()