From 7b42a501b2273e7175f4cec023ed7a361ee9f25b Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 16 Sep 2026 19:58:54 -0700 Subject: [PATCH 1/6] ci(terminal): run the browser terminal suite on Linux, Windows and real Safari - _terminal-test.yml builds the debug binary, installs esbuild and Playwright Chromium + WebKit, and runs tests/frontend/test_terminal.py. Linux runs the whole file; Windows runs the slot-release regression against ConPTY. - Under CI the suite fails instead of skipping when its binary, esbuild, Playwright or psutil is missing. - The blocked-stdin regression is platform-neutral: a per-platform non-reading foreground program, all four slots back within 5 s, and no surviving PTY master or blocked program (psutil, plus /proc on Linux). - FASTLED_PLAYWRIGHT_WEBKIT_ENDPOINT attaches WebKit to a Playwright server for hosts that cannot install it. - ci/safari_terminal_smoke.py drives real Safari through safaridriver in the macOS live test: the blocked-writer slot release from Safari's WebSocket, and a typed command rendered in xterm. Refs #259 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/_terminal-test.yml | 103 ++++++++ .github/workflows/linux-x86-terminal-test.yml | 20 ++ .../workflows/windows-x86-terminal-test.yml | 23 ++ ci/safari_terminal_smoke.py | 223 ++++++++++++++++++ ci/smoke_installed_wheel.sh | 6 + tests/frontend/test_terminal.py | 195 ++++++++------- 6 files changed, 491 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/_terminal-test.yml create mode 100644 .github/workflows/linux-x86-terminal-test.yml create mode 100644 .github/workflows/windows-x86-terminal-test.yml create mode 100644 ci/safari_terminal_smoke.py diff --git a/.github/workflows/_terminal-test.yml b/.github/workflows/_terminal-test.yml new file mode 100644 index 00000000..b49cd793 --- /dev/null +++ b/.github/workflows/_terminal-test.yml @@ -0,0 +1,103 @@ +name: Terminal Test + +# End-to-end browser coverage for the interactive terminal (#259). +# +# `tests/frontend/test_terminal.py` bundles the real vendored xterm frontend and +# drives the real `fastled` server and PTY from Chromium and WebKit. `bash test` +# does not run it, and until this workflow nothing did: #255 (every WebSocket +# session died at its handshake) and #256 (disconnected writers leaked every +# terminal slot) both shipped with a green board. +# +# Under CI the suite fails, rather than skips, when the binary, esbuild, +# Playwright or psutil is missing, so a misconfigured job cannot report green. + +on: + workflow_call: + inputs: + runs-on: + required: true + type: string + pytest-args: + description: Extra pytest selection, e.g. a -k filter for a platform. + required: false + type: string + default: "" + +env: + # Keep in step with ESBUILD_VERSION in crates/fastled-cli/src/install.rs. + ESBUILD_VERSION: "0.28.0" + PLAYWRIGHT_VERSION: "1.62.0" + +jobs: + terminal-test: + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 45 + defaults: + run: + shell: bash + env: + PYTHONIOENCODING: utf-8 + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: "3.11.9" + + - name: Install Python + run: uv python install "$UV_PYTHON" + + - uses: zackees/setup-soldr@v0.9.62 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + # Same cache namespace as the unit-test job on this runner, which + # also builds the debug workspace, so the two share warm caches. + cache-preset: foundation + cache-key-suffix: ${{ inputs.runs-on }}-native + linker: platform-default + toolchain-file: rust-toolchain.toml + prebuild-deps-flags: "" + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + + - name: Build the fastled binary + run: soldr cargo build --bin fastled + + - name: Install esbuild + run: | + npm install --prefix "$RUNNER_TEMP/esbuild" --no-save --no-audit --no-fund "esbuild@$ESBUILD_VERSION" + if [ "$RUNNER_OS" = "Windows" ]; then + esbuild="$(cygpath -w "$RUNNER_TEMP/esbuild/node_modules/@esbuild/win32-x64/esbuild.exe")" + binary="$(cygpath -w "$GITHUB_WORKSPACE/target/debug/fastled.exe")" + else + esbuild="$RUNNER_TEMP/esbuild/node_modules/.bin/esbuild" + binary="$GITHUB_WORKSPACE/target/debug/fastled" + fi + "$esbuild" --version + echo "FASTLED_ESBUILD=$esbuild" >> "$GITHUB_ENV" + echo "FASTLED_TERMINAL_BINARY=$binary" >> "$GITHUB_ENV" + + - name: Install Playwright browsers + run: uv run --no-project --with "playwright==$PLAYWRIGHT_VERSION" playwright install --with-deps chromium webkit + + - name: Browser terminal tests + env: + FASTLED_TERMINAL_LOG_DIR: ${{ github.workspace }}/logs + PYTEST_ARGS: ${{ inputs.pytest-args }} + run: >- + uv run --no-project --with "playwright==$PLAYWRIGHT_VERSION" --with psutil --with pytest + pytest tests/frontend/test_terminal.py -v -rs $PYTEST_ARGS + + - name: Upload terminal server logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: terminal-test-logs-${{ inputs.runs-on }} + path: logs/* + if-no-files-found: warn diff --git a/.github/workflows/linux-x86-terminal-test.yml b/.github/workflows/linux-x86-terminal-test.yml new file mode 100644 index 00000000..ce59948e --- /dev/null +++ b/.github/workflows/linux-x86-terminal-test.yml @@ -0,0 +1,20 @@ +name: Linux x86 Terminal Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# One live run per workflow per ref: a new push to a branch or PR cancels the +# superseded run instead of queuing behind it in the shared account-wide runner +# pool. Runs on main get a unique group, so they are never cancelled or coalesced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + call: + uses: ./.github/workflows/_terminal-test.yml + with: + runs-on: ubuntu-24.04 diff --git a/.github/workflows/windows-x86-terminal-test.yml b/.github/workflows/windows-x86-terminal-test.yml new file mode 100644 index 00000000..70dde39b --- /dev/null +++ b/.github/workflows/windows-x86-terminal-test.yml @@ -0,0 +1,23 @@ +name: Windows x86 Terminal Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# One live run per workflow per ref: a new push to a branch or PR cancels the +# superseded run instead of queuing behind it in the shared account-wide runner +# pool. Runs on main get a unique group, so they are never cancelled or coalesced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + call: + uses: ./.github/workflows/_terminal-test.yml + with: + runs-on: windows-2025 + # The interactive test drives a POSIX shell; ConPTY runs the slot-release + # regression only, selected here so the job reports no skipped tests. + pytest-args: "-k disconnect_with_blocked_stdin" diff --git a/ci/safari_terminal_smoke.py b/ci/safari_terminal_smoke.py new file mode 100644 index 00000000..7837b30d --- /dev/null +++ b/ci/safari_terminal_smoke.py @@ -0,0 +1,223 @@ +"""Real Safari smoke test for the interactive terminal (macOS only, #259). + +Serves a compiled sketch with the shipped binary's headless server, then drives +Safari through safaridriver: + +1. Four WebSocket clients open PTYs, make the foreground program stop reading + stdin, send more input than the terminal queue holds and disconnect. All four + terminal slots must come back well before that program (`sleep 30`) exits; + this is the #256 regression, observed from Safari's own WebSocket. +2. The page's terminal dialog connects, a typed command runs in the shell, and + its output reaches xterm's rendered rows. + +Playwright WebKit on Linux covers the same engine in +`linux-x86-terminal-test.yml`; only this check proves Safari itself, which +CLAUDE.md makes a required target. A WebDriver screenshot of the terminal is +kept as an artifact. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +from pathlib import Path + +from safari_smoke import start_safaridriver, start_server, webdriver + +MARKER_COMMAND = "printf 'SAFARI%s\\n' 240" +MARKER = "SAFARI240" +# The W3C WebDriver key code for Enter. +ENTER = "\ue007" + +# WebDriver `execute/async` passes its completion callback as the last argument. +BLOCKED_WRITERS_SCRIPT = """ +const done = arguments[arguments.length - 1]; +(async () => { + const url = location.origin.replace('http:', 'ws:') + '/terminal/ws'; + for (let i = 0; i < 4; i++) { + await new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 15000); + let output = ''; + let blocked = false; + ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); }; + ws.onopen = () => ws.send(JSON.stringify({type: 'input', data: + "stty raw -echo; printf 'BLOCK%s\\\\n' READY240; sleep 30\\r"})); + ws.onmessage = event => { + if (!(event.data instanceof ArrayBuffer)) return; + ws.send(JSON.stringify({type: 'ack'})); + output += new TextDecoder().decode(event.data); + if (!blocked && output.includes('BLOCKREADY240')) { + blocked = true; + ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)})); + setTimeout(() => { clearTimeout(timer); ws.close(); resolve(true); }, 200); + } + }; + }); + } + const started = performance.now(); + const deadline = started + 5000; + while (performance.now() < deadline) { + const sockets = []; + const opened = await Promise.all([0, 1, 2, 3].map(() => new Promise(resolve => { + const ws = new WebSocket(url); + sockets.push(ws); + ws.onopen = () => resolve(true); + ws.onerror = () => resolve(false); + }))); + sockets.forEach(ws => ws.close()); + if (opened.every(Boolean)) return {recoveredMs: performance.now() - started}; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return {recoveredMs: null}; +})().then(done, error => done({error: String(error)})); +""" + +TERMINAL_STATE_SCRIPT = """ +const status = document.getElementById('terminal-status'); +const rows = document.querySelector('.xterm-rows'); +return { + status: status ? status.textContent : null, + rows: rows ? rows.innerText : null, + userAgent: navigator.userAgent +}; +""" + + +def find(session: str, selector: str) -> str: + value = webdriver( + "POST", + f"{session}/element", + {"using": "css selector", "value": selector}, + )["value"] + return next(iter(value.values())) + + +def wait_for(session: str, predicate, timeout: float, what: str) -> dict: + deadline = time.monotonic() + timeout + state: dict = {} + while time.monotonic() < deadline: + state = webdriver( + "POST", + f"{session}/execute/sync", + {"script": TERMINAL_STATE_SCRIPT, "args": []}, + )["value"] + if predicate(state): + return state + time.sleep(0.25) + raise RuntimeError(f"timed out waiting for {what}: {json.dumps(state)[-2000:]}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fastled", required=True) + parser.add_argument("--serve-dir", required=True) + parser.add_argument("--artifacts", required=True, type=Path) + args = parser.parse_args() + args.artifacts.mkdir(parents=True, exist_ok=True) + + server, page_url = start_server( + args.fastled, args.serve_dir, args.artifacts / "safari-terminal-server.log" + ) + driver, base = start_safaridriver() + session = None + try: + created = webdriver( + "POST", + f"{base}/session", + {"capabilities": {"alwaysMatch": {"browserName": "safari"}}}, + ) + session = f"{base}/session/{created['value']['sessionId']}" + webdriver("POST", f"{session}/timeouts", {"script": 90000}) + webdriver("POST", f"{session}/url", {"url": page_url}, timeout=120) + + # The blocked writers run before the dialog opens its own session, so + # all four slots are free for them. + blocked = webdriver( + "POST", + f"{session}/execute/async", + {"script": BLOCKED_WRITERS_SCRIPT, "args": []}, + timeout=120, + )["value"] + print(f"Safari terminal: blocked writers -> {blocked}") + if blocked.get("error"): + print(f"Safari terminal setup failed: {blocked['error']}", file=sys.stderr) + return 1 + if blocked.get("recoveredMs") is None: + print( + "Safari terminal: disconnected blocked writers leaked terminal slots", + file=sys.stderr, + ) + return 1 + + # Give the check sockets' sessions a moment to release their slots. + time.sleep(1) + webdriver( + "POST", f"{session}/element/{find(session, '#terminal-open')}/click", {} + ) + wait_for( + session, + lambda state: "Connected" in (state.get("status") or ""), + 30, + "the terminal to connect", + ) + # xterm's input textarea is deliberately off-screen, which WebDriver's + # element APIs can refuse as not interactable. Focus it and type as a user. + webdriver( + "POST", + f"{session}/execute/sync", + { + "script": "document.querySelector('.xterm-helper-textarea').focus();", + "args": [], + }, + ) + keys = [] + for key in MARKER_COMMAND + ENTER: + keys += [{"type": "keyDown", "value": key}, {"type": "keyUp", "value": key}] + webdriver( + "POST", + f"{session}/actions", + {"actions": [{"type": "key", "id": "keyboard", "actions": keys}]}, + ) + webdriver("DELETE", f"{session}/actions") + state = wait_for( + session, + # The echoed command contains `SAFARI%s`; only the output line has + # the formatted marker. + lambda state: MARKER in (state.get("rows") or ""), + 30, + f"{MARKER} in the rendered terminal", + ) + png = base64.b64decode(webdriver("GET", f"{session}/screenshot")["value"]) + (args.artifacts / "safari-terminal.png").write_bytes(png) + (args.artifacts / "safari-terminal-state.json").write_text( + json.dumps({"blocked": blocked, **state}, indent=2) + ) + print(f"Safari terminal: {state['userAgent']}") + print(f"Safari terminal: rendered {MARKER}; status={state['status']!r}") + return 0 + finally: + if session: + try: + png = base64.b64decode( + webdriver("GET", f"{session}/screenshot")["value"] + ) + shot = args.artifacts / "safari-terminal.png" + if not shot.exists(): + shot.write_bytes(png) + except (OSError, RuntimeError, ValueError, KeyError): + pass + try: + webdriver("DELETE", session, timeout=30) + except (OSError, RuntimeError): + pass + driver.terminate() + server.terminate() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/smoke_installed_wheel.sh b/ci/smoke_installed_wheel.sh index a85a195e..547ab751 100755 --- a/ci/smoke_installed_wheel.sh +++ b/ci/smoke_installed_wheel.sh @@ -99,4 +99,10 @@ if [ "${FASTLED_SAFARI_SMOKE:-0}" = "1" ]; then --fastled "$SMOKE_ROOT/venv/bin/fastled" \ --serve-dir "$SMOKE_ROOT/sketch/fastled_js" \ --artifacts "$SMOKE_ROOT/artifacts" + # The same server binary and page, with the interactive terminal driven from + # real Safari (#259). + uv run --no-project python "$GITHUB_WORKSPACE/ci/safari_terminal_smoke.py" \ + --fastled "$SMOKE_ROOT/venv/bin/fastled" \ + --serve-dir "$SMOKE_ROOT/sketch/fastled_js" \ + --artifacts "$SMOKE_ROOT/artifacts" fi diff --git a/tests/frontend/test_terminal.py b/tests/frontend/test_terminal.py index 4963446d..124c1c23 100644 --- a/tests/frontend/test_terminal.py +++ b/tests/frontend/test_terminal.py @@ -1,10 +1,12 @@ """Refs #240: real xterm -> WebSocket -> native PTY, without compiling WASM. Run with FASTLED_TERMINAL_BINARY and FASTLED_ESBUILD set to local binaries: -uv run --with playwright pytest tests/frontend/test_terminal.py -v -Install matching Playwright browsers separately; no npm is used. +uv run --with playwright --with psutil pytest tests/frontend/test_terminal.py -v +Install matching Playwright browsers separately. CI runs this file through +.github/workflows/_terminal-test.yml; see docs/interactive-terminal.md. """ +import importlib import os import re import subprocess @@ -24,6 +26,9 @@ def terminal_server(tmp_path_factory: Any) -> Any: binary = os.environ.get("FASTLED_TERMINAL_BINARY") esbuild = os.environ.get("FASTLED_ESBUILD") if not binary or not esbuild: + # CI must never report this suite green by skipping it. + if os.environ.get("CI"): + pytest.fail("CI requires FASTLED_TERMINAL_BINARY and FASTLED_ESBUILD") pytest.skip("set FASTLED_TERMINAL_BINARY and FASTLED_ESBUILD") root = tmp_path_factory.mktemp("terminal-240") # The served directory carries a space so the PTY cwd is exercised with one. @@ -108,16 +113,42 @@ def read_output() -> None: finally: process.terminate() process.wait(timeout=10) + log_dir = os.environ.get("FASTLED_TERMINAL_LOG_DIR") + if log_dir: + Path(log_dir).mkdir(parents=True, exist_ok=True) + (Path(log_dir) / "terminal-server.log").write_text("".join(lines)) +def _require(module: str) -> Any: + """Import a test-only dependency; CI fails rather than skipping without it.""" + if os.environ.get("CI"): + return importlib.import_module(module) + return pytest.importorskip(module) + + +def _launch(manager: Any, browser_name: str) -> Any: + """Launch a browser, or attach to a Playwright server for WebKit. + + Playwright cannot install WebKit on some hosts (NixOS). There, run + `playwright run-server` in the Playwright container with host networking and + point FASTLED_PLAYWRIGHT_WEBKIT_ENDPOINT at it; see + docs/interactive-terminal.md. + """ + endpoint = os.environ.get("FASTLED_PLAYWRIGHT_WEBKIT_ENDPOINT") + if browser_name == "webkit" and endpoint: + return manager.webkit.connect(endpoint) + return getattr(manager, browser_name).launch() + + +@pytest.mark.skipif(os.name == "nt", reason="drives a POSIX shell (printf, stty, cat)") @pytest.mark.parametrize("browser_name", ["chromium", "webkit"]) def test_terminal_240_interactive_browser( terminal_server: Any, browser_name: str ) -> None: - playwright = pytest.importorskip("playwright.sync_api") + playwright = _require("playwright.sync_api") url, expected_cwd, _ = terminal_server with playwright.sync_playwright() as manager: - browser = getattr(manager, browser_name).launch() + browser = _launch(manager, browser_name) page = browser.new_page(viewport={"width": 1200, "height": 900}) errors: list[str] = [] output: list[str] = [] @@ -245,106 +276,112 @@ def wait_output(text: str) -> None: browser.close() -def _descendants(pid: int) -> list[int]: - found: list[int] = [] - pending = [pid] - while pending: - parent = pending.pop() - for task in Path(f"/proc/{parent}/task").glob("*"): - try: - children = (task / "children").read_text().split() - except OSError: - continue - for child in map(int, children): - found.append(child) - pending.append(child) - return found +# A foreground program that stops reading its terminal, prints a readiness +# marker, and outlives the test by a wide margin. The marker is split in the +# typed command so the shell's echo of that command cannot satisfy the wait. +if os.name == "nt": + BLOCKING_COMMAND = "echo BLOCK^READY240 & ping -n 30 127.0.0.1 >NUL\r" + BLOCKING_PROGRAM = "ping" +else: + BLOCKING_COMMAND = "stty raw -echo; printf 'BLOCK%s\\n' READY240; sleep 30\r" + BLOCKING_PROGRAM = "sleep" + +BLOCKED_WRITERS_SCRIPT = """async command => { + const url = location.origin.replace('http:', 'ws:') + '/terminal/ws'; + for (let i = 0; i < 4; i++) { + await new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 15000); + let output = ''; + let blocked = false; + ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); }; + ws.onopen = () => ws.send(JSON.stringify({type: 'input', data: command})); + ws.onmessage = event => { + if (!(event.data instanceof ArrayBuffer)) return; + ws.send(JSON.stringify({type: 'ack'})); + output += new TextDecoder().decode(event.data); + if (!blocked && output.includes('BLOCKREADY240')) { + blocked = true; + ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)})); + setTimeout(() => { clearTimeout(timer); ws.close(); resolve(true); }, 200); + } + }; + }); + } + // Every slot must come back promptly, not just one of them, and long + // before the blocked foreground programs would exit on their own. + const started = performance.now(); + const deadline = started + 5000; + while (performance.now() < deadline) { + const sockets = []; + const opened = await Promise.all([0, 1, 2, 3].map(() => new Promise(resolve => { + const ws = new WebSocket(url); + sockets.push(ws); + ws.onopen = () => resolve(true); + ws.onerror = () => resolve(false); + }))); + sockets.forEach(ws => ws.close()); + if (opened.every(Boolean)) return performance.now() - started; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return null; +}""" -def _pty_holders(pid: int) -> list[int]: +def _pty_holders(processes: list[Any]) -> list[int]: + """Processes holding a Unix98 PTY master; Linux exposes this through /proc.""" holders = [] - for candidate in [pid, *_descendants(pid)]: + for process in processes: try: - fds = list(Path(f"/proc/{candidate}/fd").iterdir()) + fds = list(Path(f"/proc/{process.pid}/fd").iterdir()) except OSError: continue for fd in fds: try: if os.readlink(fd) == "/dev/ptmx": - holders.append(candidate) + holders.append(process.pid) break except OSError: continue return holders -@pytest.mark.skipif(os.name == "nt", reason="Unix stty regression") +def _leaks(server_pid: int) -> list[str]: + psutil = _require("psutil") + server = psutil.Process(server_pid) + descendants = server.children(recursive=True) + leaks = [ + f"PTY master held by {pid}" for pid in _pty_holders([server, *descendants]) + ] + for process in descendants: + try: + if process.name().lower().removesuffix(".exe") == BLOCKING_PROGRAM: + leaks.append(f"blocked {BLOCKING_PROGRAM} survived as {process.pid}") + except psutil.Error: + continue + return leaks + + @pytest.mark.parametrize("browser_name", ["chromium", "webkit"]) def test_terminal_240_disconnect_with_blocked_stdin( terminal_server: Any, browser_name: str ) -> None: """RED: four blocked writers leaked every slot; fifth upgrade was HTTP 429.""" - playwright = pytest.importorskip("playwright.sync_api") + playwright = _require("playwright.sync_api") url, _, server_pid = terminal_server with playwright.sync_playwright() as manager: - browser = getattr(manager, browser_name).launch() + browser = _launch(manager, browser_name) page = browser.new_page() page.goto(url) - result = page.evaluate("""async () => { - const url = location.origin.replace('http:', 'ws:') + '/terminal/ws'; - for (let i = 0; i < 4; i++) { - await new Promise((resolve, reject) => { - const ws = new WebSocket(url); - ws.binaryType = 'arraybuffer'; - const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 10000); - let output = ''; - let blocked = false; - ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); }; - ws.onopen = () => ws.send(JSON.stringify({type: 'input', data: - "stty raw -echo; printf 'BLOCK%s\\n' READY240; sleep 30\\r"})); - ws.onmessage = event => { - if (!(event.data instanceof ArrayBuffer)) return; - ws.send(JSON.stringify({type: 'ack'})); - output += new TextDecoder().decode(event.data); - if (!blocked && output.includes('BLOCKREADY240')) { - blocked = true; - ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)})); - setTimeout(() => { clearTimeout(timer); ws.close(); resolve(true); }, 200); - } - }; - }); - } - // Every slot must come back promptly, not just one of them, and long - // before the blocked `sleep 30` foreground programs would exit. - const started = performance.now(); - const deadline = started + 5000; - while (performance.now() < deadline) { - const sockets = []; - const opened = await Promise.all([0, 1, 2, 3].map(() => new Promise(resolve => { - const ws = new WebSocket(url); - sockets.push(ws); - ws.onopen = () => resolve(true); - ws.onerror = () => resolve(false); - }))); - sockets.forEach(ws => ws.close()); - if (opened.every(Boolean)) return performance.now() - started; - await new Promise(resolve => setTimeout(resolve, 100)); - } - return null; - }""") + result = page.evaluate(BLOCKED_WRITERS_SCRIPT, BLOCKING_COMMAND) assert result is not None, "disconnected blocked writers leaked terminal slots" browser.close() + # No session may outlive its client: its PTY and its foreground program + # must both be gone. deadline = time.monotonic() + 10 - holders = _pty_holders(server_pid) - while holders and time.monotonic() < deadline: + leaks = _leaks(server_pid) + while leaks and time.monotonic() < deadline: time.sleep(0.1) - holders = _pty_holders(server_pid) - assert not holders, f"sessions outlived their clients: {holders}" - sleepers = [] - for pid in _descendants(server_pid): - try: - if b"sleep" in Path(f"/proc/{pid}/cmdline").read_bytes(): - sleepers.append(pid) - except OSError: - continue - assert not sleepers, f"blocked foreground programs survived: {sleepers}" + leaks = _leaks(server_pid) + assert not leaks, f"sessions outlived their clients: {leaks}" From 6e40461229bc882ecdfc629fb6e3c1cdae3c1633 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 16 Sep 2026 20:16:06 -0700 Subject: [PATCH 2/6] test(terminal): decode server output as UTF-8 on Windows The fixture read the server's log through the locale codec, which on Windows (cp1252) fails on UTF-8 bytes before any test runs. Refs #259 Co-Authored-By: Claude Opus 5 (1M context) --- tests/frontend/test_terminal.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/frontend/test_terminal.py b/tests/frontend/test_terminal.py index 124c1c23..266fab32 100644 --- a/tests/frontend/test_terminal.py +++ b/tests/frontend/test_terminal.py @@ -88,7 +88,9 @@ def terminal_server(tmp_path_factory: Any) -> Any: env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, + # The server logs UTF-8; the Windows locale codec cannot decode it. + encoding="utf-8", + errors="replace", ) lines: list[str] = [] From 281173c3925427256e291e6a7341433ec9395198 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 16 Sep 2026 20:23:56 -0700 Subject: [PATCH 3/6] test(terminal): read and write fixture files as UTF-8, import with forward slashes Windows decoded the frontend HTML through cp1252 and would have embedded backslash paths in the fixture's JS import strings. Refs #259 Co-Authored-By: Claude Opus 5 (1M context) --- tests/frontend/test_terminal.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/frontend/test_terminal.py b/tests/frontend/test_terminal.py index 266fab32..f3b646ca 100644 --- a/tests/frontend/test_terminal.py +++ b/tests/frontend/test_terminal.py @@ -19,6 +19,8 @@ ROOT = Path(__file__).resolve().parents[2] FRONTEND = ROOT / "src/fastled/frontend" +# Forward slashes: a Windows path in a JS string literal would read as escapes. +FRONTEND_IMPORT = FRONTEND.as_posix() @pytest.fixture(scope="module") @@ -38,22 +40,24 @@ def terminal_server(tmp_path_factory: Any) -> Any: served.mkdir() launch = root / "launch project" launch.mkdir() - html = (FRONTEND / "index.html").read_text() + html = (FRONTEND / "index.html").read_text(encoding="utf-8") controls = html[html.index('