diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml new file mode 100644 index 0000000..66fceb7 --- /dev/null +++ b/.github/workflows/publish_to_pypi.yml @@ -0,0 +1,95 @@ +# Publishes the Python actor-debugger package to PyPI when a py-v* tag is pushed. +# +# Uses PyPI Trusted Publishing (OIDC) - no API token or secret anywhere. The trusted +# publisher on PyPI is configured as: +# project: actor-debugger +# repository: apify/actor-debugger +# workflow: publish_to_pypi.yml (this file name must match exactly) +# +# Release flow: +# (bump version in python/pyproject.toml and python/src/actor_debugger/__init__.py, commit) +# git tag py-vX.Y.Z && git push --tags +# +# Can also be started manually (Actions -> Publish to PyPI -> Run workflow); a manual run +# skips the tag/version check and publishes whatever version pyproject.toml carries on the +# chosen ref (PyPI rejects re-uploads of an existing version, so re-runs are safe). + +name: Publish to PyPI + +on: + push: + tags: + - 'py-v*' + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install package + run: pip install . + + - name: Verify tag matches pyproject version + if: github.ref_type == 'tag' + run: | + PKG_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + TAG_VERSION="${GITHUB_REF_NAME#py-v}" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "Tag $GITHUB_REF_NAME does not match pyproject.toml version $PKG_VERSION" >&2 + exit 1 + fi + + - name: Smoke test (entrypoint runs and its output passes through) + run: | + mkdir -p /tmp/smoke && cd /tmp/smoke + echo 'print("smoke-ok")' > main.py + OUTPUT="$(python -m actor_debugger 2>&1)" + echo "$OUTPUT" + echo "$OUTPUT" | grep -q 'smoke-ok' + + - name: Smoke test (debugger UI served over the web-server port) + run: | + mkdir -p /tmp/smoke-debug && cd /tmp/smoke-debug + printf 'import time\ntime.sleep(15)\n' > main.py + ACTOR_WEB_SERVER_PORT=4321 ACTOR_WEB_SERVER_URL=http://localhost:4321 \ + python -m actor_debugger main.py & + CLI_PID=$! + for i in $(seq 1 30); do + sleep 0.5 + if curl -sf http://127.0.0.1:4321/config > /dev/null; then break; fi + done + curl -sf http://127.0.0.1:4321/config | grep -q '"cwd"' + curl -sf http://127.0.0.1:4321/ui/ | grep -q 'Actor Debugger' + kill $CLI_PID + + publish: + needs: check + runs-on: ubuntu-latest + permissions: + id-token: write # required for PyPI Trusted Publishing (OIDC) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Build distributions + working-directory: python + run: | + pip install build + python -m build + + - name: Publish to PyPI (trusted publishing) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: python/dist diff --git a/.gitignore b/.gitignore index c2658d7..6371889 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules/ +__pycache__/ +*.egg-info/ +python/dist/ diff --git a/README.md b/README.md index 0f99b2d..95b389f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # actor-debugger +> **Python Actors:** the same one-link browser debugging exists for Python, injected the same way — +> `RUN pip install actor-debugger` + `CMD ["python3", "-m", "actor_debugger", "--brk"]` — built on +> debugpy plus a served DAP frontend instead of the Node inspector plus Chrome DevTools. +> See [`python/README.md`](python/README.md). + Drop-in remote debugging for **any Apify Node/TS Actor** with a one-line Dockerfile change. It launches your Actor under the Node inspector **and serves a full Chrome DevTools UI over the run's container URL** — so you open one link in your own browser and debug. No wstunnel, no local setup, diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..9f06ab5 --- /dev/null +++ b/python/README.md @@ -0,0 +1,143 @@ +# actor-debugger (Python) + +Drop-in remote debugging for **any Apify Python Actor** with a two-line Dockerfile change. It +launches your Actor under **debugpy** (the debugger that powers VS Code's Python debugging) **and +serves a full browser debugger UI over the run's container URL** — so you open one link in your +own browser and debug. No IDE, no tunnel, no local setup, no rebuild of your source, and **no +browser or IDE inside the Actor**. + +This is the Python sibling of the Node/TS [`actor-debugger`](../README.md) npm package, injected +the same way: + +```dockerfile +# Get the package +RUN pip install actor-debugger + +# Swap the entrypoint for the debug launcher (revert this line to disable debugging): +CMD ["python3", "-m", "actor_debugger", "--brk"] # was e.g.: CMD ["python3", "-m", "src"] +``` + +That's the entire integration — no code changes, no new ports, no platform configuration. Build, +run, and the run log prints one URL: + +``` +[actor-debugger] OPEN THIS in your local browser for a full debugger UI (no local setup): +[actor-debugger] https://.runs.apify.net/ui/ +``` + +Open it: click line numbers to set breakpoints, step, inspect the call stack and variables, +evaluate expressions in the paused frame, break on exceptions. `--brk` pauses the Actor on its +first line until you attach — drop it to let the Actor run and attach mid-flight instead. + +Until the package is published to PyPI, install it from the repository instead: + +```dockerfile +RUN pip install "actor-debugger @ git+https://github.com/apify/actor-debugger.git@master#subdirectory=python" +``` + +## CLI forms + +```dockerfile +CMD ["python3", "-m", "actor_debugger"] # auto-detect the Actor's entrypoint +CMD ["python3", "-m", "actor_debugger", "--brk"] # pause on the first line until attached +CMD ["python3", "-m", "actor_debugger", "-m", "src"] # explicit module +CMD ["python3", "-m", "actor_debugger", "main.py"] # explicit file +``` + +(`actor-debugger` also works as a console command; the `python3 -m` form is immune to PATH +surprises in slim images.) + +Entrypoint detection order: explicit `-m `/`` argument → `src/__main__.py` +(the Apify template's `python3 -m src`) → `src/main.py` → `main.py` / `__main__.py` / `app.py`. + +## How it works + +1. It launches your Actor as `python -m debugpy --listen 127.0.0.1:5678 ` — the debugger + runs on **your** code, in its own process, exactly as it normally runs. debugpy speaks the + [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/) (DAP), the same + protocol VS Code uses. +2. It runs one HTTP server on `ACTOR_WEB_SERVER_PORT` that: + - **serves a browser debugger UI** (~30 KB of static files bundled with the package) — the DAP + *client* is JavaScript running in your browser; + - **bridges a WebSocket at `/dap`** to debugpy's DAP-over-TCP socket, translating framing (one + WebSocket message per DAP JSON message ↔ `Content-Length`-framed TCP), injecting the + adapter's loopback address into `attach` requests, and sending a `disconnect` on the + browser's behalf when a tab closes abruptly — which is what makes page reloads re-attach + cleanly; + - serves `/source?path=...` so the UI can display files that exist only on the container's + disk (the same problem the Node version solves by inlining source maps). +3. The run log prints the one URL above. Program stdout keeps flowing to the Actor run log as + usual; breakpoints live in the browser's `localStorage`, so they survive reloads and + re-attach automatically. + +Because the container side is a dumb byte bridge, all protocol intelligence lives in the served +frontend — the Actor image gains only `debugpy` (~3 MB) plus the static files. The platform is +used exactly as-is: the standard container web-server port is the only channel. Prefer a raw +channel? `wss://.runs.apify.net/dap` speaks DAP directly, one JSON message per WebSocket +text frame — any DAP client can drive it. + +## Verified on the Apify platform + +The full loop has been exercised against a real platform run: the UI served over +`https://.runs.apify.net/ui/`, the `wss://…/dap` WebSocket passed the platform ingress, and +a live run was attached, paused, and driven from a plain browser. Automated coverage in this +repo: a protocol test drives a complete DAP session through the bridge (breakpoint, stack, +scopes, variables, evaluate, resume, re-attach after disconnect), and a headless-Chromium test +drives the real UI end to end (entry pause under `--brk`, gutter breakpoints, stepping, +variables, in-frame evaluate, resume, re-attach after page reload), including idle sessions. + +## Security + +The debug endpoint is **unauthenticated** — anyone who reaches the container URL can execute code +in your run (and read its env, including `APIFY_TOKEN`); `/source` additionally serves any file +readable in the container. Keep the debug `CMD` only in builds you are actively debugging, prefer +a restricted run/token, never ship it in a published Actor, and gate the endpoint (owner-only) +before any non-prototype use. This matches the security posture of the Node version; both need +the same hardening pass. + +## Design notes: the routes considered + +The constraint was: **no platform/infrastructure changes, and nothing but a browser on the +debugging machine.** Everything must therefore run inside the Actor container and be served over +the one exposed web-server port. Options considered: + +| Approach | Verdict | +| --- | --- | +| **debugpy + served browser DAP client** (this package) | CPython unchanged, actor code and deps run exactly as in production; debugpy is the canonical Python debugger; image cost ~3 MB. The UI is ours to grow. **Chosen.** | +| **GraalPy `--inspect`** — GraalVM's Python speaks the Chrome DevTools Protocol natively, so the Node version's chii DevTools frontend + CDP proxy would work *unchanged* | Elegant symmetry, but it swaps the runtime under the Actor: a different base image, slower startup, and C-extension compatibility risk for real-world deps (`lxml`, `pydantic`, `cryptography`, …). Debugging a different interpreter than production undermines the point. | +| **DAP→CDP translation shim** — keep CPython + debugpy, translate DAP into the Chrome DevTools Protocol and serve the same chii DevTools frontend the Node version uses | Best possible UI for free, one frontend for both languages, but the DevTools frontend is picky about `Debugger`/`Runtime` lifecycle — a meaningful project on its own. The `/dap` WebSocket this package exposes is where such a shim would slot in later. | +| **code-server / openvscode-server in the container** | Great UX, zero custom code, but ~300 MB and a Node runtime in every Python Actor image, plus auth wiring. Overkill for "set a breakpoint in a run". | +| **`web-pdb` / xterm.js + pdb over WebSocket** | Tiny, but a terminal `pdb` UX (no gutter breakpoints, no variable tree) and pdb can't attach to a running program the way debugpy can. | +| **Jupyter server in the container** (JupyterLab's debugger also speaks debugpy) | The kernel model doesn't fit debugging an already-running script; heavyweight. | +| **[`dap-python`](https://pypi.org/project/dap-python/)** — a typed Python DAP *client* library | The DAP client here is the browser; the container side is a protocol-agnostic byte bridge. Useful only for a server-orchestrated variant, at the cost of a Pydantic dependency and a Python ≥3.12 floor. | + +The same architecture extends beyond Python: any language with a DAP adapter (Node via js-debug, +Go via Delve — which speaks DAP natively — Rust via lldb-dap, …) can sit behind the identical +bridge and frontend; only the adapter spawn command and entrypoint detection differ. + +## Releasing + +Publishing to PyPI is automated by +[`.github/workflows/publish_to_pypi.yml`](../.github/workflows/publish_to_pypi.yml), which fires +on `py-v*` tags and publishes via **PyPI Trusted Publishing** (OIDC) — no API token or repository +secret. The trusted publisher configured on PyPI is: project `actor-debugger`, repository +`apify/actor-debugger`, workflow `publish_to_pypi.yml` (the workflow file name must stay exactly +that). To cut a release: + +```bash +# bump version in python/pyproject.toml and python/src/actor_debugger/__init__.py, commit, then: +git tag py-v0.1.0 +git push --tags +``` + +The workflow first installs the package, checks the tag matches the pyproject version, and smoke +tests the CLI and the served debugger UI; only then does the publish job build sdist+wheel and +upload. + +## Notes + +- Only runtime dependency is `debugpy`; the HTTP server and the RFC 6455 WebSocket implementation + are pure stdlib (Python ≥3.9). +- A crashed or closed debugger tab never blocks a *running* Actor — the process only waits at + startup, and only with `--brk`. +- "Just my code" is applied on (re)connect; untick it in the UI header to step into library code. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..0bcba79 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "actor-debugger" +version = "0.1.0" +description = "Drop-in remote debugging for Apify Python Actors: debugpy plus a browser debugger UI served over the run's container URL." +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.9" +dependencies = ["debugpy>=1.8"] +keywords = ["apify", "actor", "debugger", "debugpy", "dap", "remote-debugging"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Debuggers", +] + +[project.urls] +Homepage = "https://github.com/apify/actor-debugger" + +[project.scripts] +actor-debugger = "actor_debugger.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/actor_debugger"] + +[tool.hatch.build.targets.sdist] +include = ["src/actor_debugger", "README.md"] diff --git a/python/src/actor_debugger/__init__.py b/python/src/actor_debugger/__init__.py new file mode 100644 index 0000000..ab3bee4 --- /dev/null +++ b/python/src/actor_debugger/__init__.py @@ -0,0 +1,3 @@ +"""actor-debugger: browser-based remote debugging for Apify Python Actors.""" + +__version__ = "0.1.0" diff --git a/python/src/actor_debugger/__main__.py b/python/src/actor_debugger/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/python/src/actor_debugger/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/python/src/actor_debugger/cli.py b/python/src/actor_debugger/cli.py new file mode 100644 index 0000000..4dd8123 --- /dev/null +++ b/python/src/actor_debugger/cli.py @@ -0,0 +1,145 @@ +"""actor-debugger - launch an Apify Python Actor under debugpy, debuggable from a plain +browser over the run's container URL, with a one-line Dockerfile change: + + CMD ["python3", "-m", "actor_debugger"] # auto-detect the Actor's entrypoint + CMD ["python3", "-m", "actor_debugger", "-m", "src"] # explicit module + CMD ["python3", "-m", "actor_debugger", "main.py"] # explicit file + CMD ["python3", "-m", "actor_debugger", "--brk"] # pause on the first line until attached + +Running through this command is what enables debugging - revert the CMD to the normal +entrypoint to turn it off. +""" + +from __future__ import annotations + +import os +import signal +import socket +import subprocess +import sys +import threading +import time + +from .debug_server import start_debug_server + +ADAPTER_PORT = 5678 +TAG = "[actor-debugger]" + + +def log(message: str) -> None: + print(f"{TAG} {message}", file=sys.stderr, flush=True) + + +def resolve_entry(args: list[str]) -> list[str] | None: + """Return the argv tail (["-m", "pkg"] or ["path.py"]) for the Actor's entrypoint.""" + if "-m" in args: + module = args[args.index("-m") + 1 :][:1] + if module: + return ["-m", module[0]] + positional = [a for a in args if not a.startswith("-")] + if positional: + return [os.path.abspath(positional[0])] + # The Apify Python templates run `python3 -m src` (src/__main__.py + src/main.py). + if os.path.isfile("src/__main__.py"): + return ["-m", "src"] + if os.path.isfile("src/main.py"): + return ["-m", "src.main"] + for candidate in ("main.py", "__main__.py", "app.py"): + if os.path.isfile(candidate): + return [os.path.abspath(candidate)] + return None + + +def entry_file_path(entry: list[str]) -> str: + """Best-effort path of the file that runs first, for the frontend's --brk entry stop.""" + if entry[0] != "-m": + return entry[0] + base = entry[1].replace(".", os.sep) + for candidate in (os.path.join(base, "__main__.py"), base + ".py"): + if os.path.isfile(candidate): + return os.path.abspath(candidate) + return "" + + +def wait_for_adapter(port: int, timeout: float = 30.0) -> bool: + """Wait until something listens on the adapter port WITHOUT connecting to it. + + debugpy treats the first accepted connection as its DAP client and shuts the session + down when it drops, so probing with a real connect would kill the adapter. A bind + attempt failing with EADDRINUSE detects the listener without ever touching it. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind(("127.0.0.1", port)) + except OSError: + return True + finally: + probe.close() + time.sleep(0.25) + return False + + +def announce(web_server_url: str) -> None: + if not wait_for_adapter(ADAPTER_PORT): + log(f"debugpy adapter did not come up on 127.0.0.1:{ADAPTER_PORT}.") + return + base = web_server_url.rstrip("/") + log("=" * 64) + log("debugging is ON - reachable over the container URL.") + log("OPEN THIS in your local browser for a full debugger UI (no local setup):") + log(f" {base}/ui/") + host = base.split("://", 1)[-1] + log(f"raw DAP-over-WebSocket channel (one JSON message per frame): wss://{host}/dap") + log("=" * 64) + + +def main() -> None: + args = sys.argv[1:] + brk = "--brk" in args + entry = resolve_entry([a for a in args if a != "--brk"]) + if not entry: + log("could not find an Actor entrypoint. Pass one explicitly:") + log(' CMD ["python3", "-m", "actor_debugger", "-m", "src"]') + sys.exit(1) + + debugpy_argv = [ + sys.executable, + "-Xfrozen_modules=off", + "-m", + "debugpy", + "--listen", + f"127.0.0.1:{ADAPTER_PORT}", + *(["--wait-for-client"] if brk else []), + *entry, + ] + env = {**os.environ, "PYDEVD_DISABLE_FILE_VALIDATION": "1"} + child = subprocess.Popen(debugpy_argv, env=env) + + server = None + port = os.environ.get("ACTOR_WEB_SERVER_PORT") + url = os.environ.get("ACTOR_WEB_SERVER_URL") + if port and url: + frontend_dir = os.path.join(os.path.dirname(__file__), "frontend") + server = start_debug_server(int(port), ADAPTER_PORT, frontend_dir, brk=brk, entry_path=entry_file_path(entry)) + threading.Thread(target=announce, args=(url,), daemon=True).start() + if brk: + log("--brk: the Actor is paused on its first line until you attach from the browser.") + else: + log(f"ACTOR_WEB_SERVER_URL/PORT not set - DAP on 127.0.0.1:{ADAPTER_PORT} only (no container-URL bridge).") + + def forward(signum, _frame): + child.send_signal(signum) + + signal.signal(signal.SIGTERM, forward) + signal.signal(signal.SIGINT, forward) + + code = child.wait() + if server: + server.shutdown() + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/python/src/actor_debugger/debug_server.py b/python/src/actor_debugger/debug_server.py new file mode 100644 index 0000000..da7d729 --- /dev/null +++ b/python/src/actor_debugger/debug_server.py @@ -0,0 +1,262 @@ +"""One HTTP server on the container's web-server port that: + +- serves the bundled browser debugger UI (static files) so your LOCAL browser opens it - + no IDE, no local setup; +- bridges a WebSocket at /dap to the debugpy adapter's DAP-over-TCP socket on loopback, + translating framing (one WebSocket text message per DAP JSON message <-> Content-Length + framed TCP) so the browser client stays trivial; +- rewrites `attach` requests in flight to inject the adapter's loopback connect address, + which the browser cannot know (and which is what makes debugpy accept re-attach after + a page reload); +- serves /source?path=... so the remote frontend can display files that only exist on the + container's disk (a browser can never fetch file:// URLs from the container); +- serves /config with the working directory so the frontend can group project files apart + from library code. +""" + +from __future__ import annotations + +import json +import mimetypes +import os +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from .websocket import WebSocketConnection, WebSocketClosed, accept_key + +UI_PREFIX = "/ui/" + + +def _read_dap_messages(sock: socket.socket): + """Yield DAP message bodies (bytes) from a Content-Length framed TCP stream.""" + buf = b"" + while True: + while b"\r\n\r\n" not in buf: + chunk = sock.recv(65536) + if not chunk: + return + buf += chunk + header, buf = buf.split(b"\r\n\r\n", 1) + length = None + for line in header.split(b"\r\n"): + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + length = int(value.strip()) + if length is None: + return + while len(buf) < length: + chunk = sock.recv(65536) + if not chunk: + return + buf += chunk + body, buf = buf[:length], buf[length:] + yield body + + +class DebugRequestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + # Injected by start_debug_server(): + frontend_dir: str = "" + adapter_host: str = "127.0.0.1" + adapter_port: int = 5678 + brk: bool = False + entry_path: str = "" + + def log_message(self, format, *args): # noqa: A002 - stdlib signature + pass # keep the Actor log clean + + # -- HTTP --------------------------------------------------------------- + + def do_GET(self): # noqa: N802 - stdlib naming + parsed = urlparse(self.path) + if parsed.path == "/dap" and "websocket" in self.headers.get("Upgrade", "").lower(): + self._handle_websocket() + return + if parsed.path in ("", "/"): + self.send_response(302) + self.send_header("Location", f"{UI_PREFIX}") + self.send_header("Content-Length", "0") + self.end_headers() + return + if parsed.path == "/config": + self._send_json({"cwd": os.getcwd(), "brk": self.brk, "entryPath": self.entry_path}) + return + if parsed.path == "/source": + self._serve_source(parsed) + return + self._serve_static(parsed.path) + + def _send_json(self, payload: dict) -> None: + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_plain(self, status: int, text: str) -> None: + body = text.encode() + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_source(self, parsed) -> None: + path = (parse_qs(parsed.query).get("path") or [""])[0] + if not path or not os.path.isabs(path) or not os.path.isfile(path): + self._send_plain(404, "not found") + return + try: + with open(path, "rb") as f: + body = f.read(4 * 1024 * 1024) + except OSError: + self._send_plain(404, "not found") + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_static(self, url_path: str) -> None: + rel = url_path[len(UI_PREFIX):] if url_path.startswith(UI_PREFIX) else url_path.lstrip("/") + rel = rel or "index.html" + file_path = os.path.normpath(os.path.join(self.frontend_dir, rel)) + if not file_path.startswith(os.path.abspath(self.frontend_dir)): + self._send_plain(403, "forbidden") + return + if not os.path.isfile(file_path): + self._send_plain(404, "not found") + return + ctype = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + with open(file_path, "rb") as f: + body = f.read() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + # -- WebSocket <-> DAP bridge -------------------------------------------- + + def _handle_websocket(self) -> None: + key = self.headers.get("Sec-WebSocket-Key") + if not key: + self._send_plain(400, "bad websocket handshake") + return + upstream = None + deadline = time.monotonic() + 15 + while upstream is None: + try: + upstream = socket.create_connection((self.adapter_host, self.adapter_port), timeout=5) + except OSError: + if time.monotonic() >= deadline: + self._send_plain(502, "debug adapter unavailable") + return + time.sleep(0.5) + # The connect timeout must not linger on the established socket: it would make the + # idle recv() in the pump thread time out and tear the session down after 5 quiet + # seconds. A debug session idles for minutes while the developer reads code. + upstream.settimeout(None) + self.connection.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept_key(key).encode() + b"\r\n\r\n" + ) + self.close_connection = True + ws = WebSocketConnection(self.connection) + + def pump_adapter_to_browser(): + try: + for body in _read_dap_messages(upstream): + ws.send_text(body.decode("utf-8", errors="replace")) + except (OSError, WebSocketClosed): + pass + finally: + ws.close() + + pump = threading.Thread(target=pump_adapter_to_browser, daemon=True) + pump.start() + saw_disconnect = False + try: + while True: + text = ws.recv_text() + if '"disconnect"' in text: + try: + parsed = json.loads(text) + if parsed.get("type") == "request" and parsed.get("command") == "disconnect": + saw_disconnect = True + except ValueError: + pass + body = self._rewrite_attach(text).encode() + upstream.sendall(b"Content-Length: %d\r\n\r\n%b" % (len(body), body)) + except (OSError, WebSocketClosed): + pass + finally: + if not saw_disconnect: + # The browser vanished (tab closed, page reloaded) without a DAP disconnect. + # An abruptly dropped client makes debugpy's adapter stop listening, killing + # re-attach - so say goodbye on the browser's behalf. + goodbye = json.dumps({ + "seq": 2_000_000_000, + "type": "request", + "command": "disconnect", + "arguments": {"terminateDebuggee": False}, + }).encode() + try: + upstream.sendall(b"Content-Length: %d\r\n\r\n%b" % (len(goodbye), goodbye)) + time.sleep(0.5) + except OSError: + pass + try: + upstream.close() + except OSError: + pass + ws.close() + + def _rewrite_attach(self, text: str) -> str: + """Inject the adapter's connect address into `attach` requests. + + debugpy treats an attach with `connect` as a socket attach and then accepts later + re-attaches on the same listen port - which is what makes browser reloads work. + The browser cannot know the loopback port, so the bridge fills it in. + """ + try: + message = json.loads(text) + except ValueError: + return text + if message.get("type") == "request" and message.get("command") == "attach": + arguments = message.setdefault("arguments", {}) + arguments["connect"] = {"host": self.adapter_host, "port": self.adapter_port} + return json.dumps(message) + return text + + +def start_debug_server( + listen_port: int, + adapter_port: int, + frontend_dir: str, + brk: bool = False, + entry_path: str = "", +) -> ThreadingHTTPServer: + handler = type( + "BoundDebugRequestHandler", + (DebugRequestHandler,), + { + "frontend_dir": os.path.abspath(frontend_dir), + "adapter_port": adapter_port, + "brk": brk, + "entry_path": entry_path, + }, + ) + server = ThreadingHTTPServer(("0.0.0.0", listen_port), handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server diff --git a/python/src/actor_debugger/frontend/index.html b/python/src/actor_debugger/frontend/index.html new file mode 100644 index 0000000..942d7a6 --- /dev/null +++ b/python/src/actor_debugger/frontend/index.html @@ -0,0 +1,598 @@ + + + + + +Actor Debugger + + + + +
+
+ 🐛 Actor Debugger +
connecting…
+ + + + + + + + +
+ +

Sources

+ +
Waiting for the debug session…

+ Set breakpoints by clicking line numbers once a file is open. Files appear on the left as the + program loads them, or when it pauses.
+ +
+

Call stack

not paused
+

Variables

+

Breakpoints

+
+ + +
+
+
+ +
+
+
› +
+
+
+ + + + diff --git a/python/src/actor_debugger/websocket.py b/python/src/actor_debugger/websocket.py new file mode 100644 index 0000000..e729d4f --- /dev/null +++ b/python/src/actor_debugger/websocket.py @@ -0,0 +1,124 @@ +"""Minimal server-side RFC 6455 WebSocket implementation (stdlib only). + +Just enough for the debugger bridge: handshake, text frames, fragmentation, +ping/pong, and close. Server-to-client frames are never masked (per spec); +client-to-server frames must be masked and are rejected otherwise. +""" + +from __future__ import annotations + +import base64 +import hashlib +import socket +import struct +import threading + +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +OP_CONT = 0x0 +OP_TEXT = 0x1 +OP_BINARY = 0x2 +OP_CLOSE = 0x8 +OP_PING = 0x9 +OP_PONG = 0xA + + +def accept_key(sec_websocket_key: str) -> str: + digest = hashlib.sha1((sec_websocket_key + _GUID).encode()).digest() + return base64.b64encode(digest).decode() + + +class WebSocketClosed(Exception): + pass + + +class WebSocketConnection: + """A WebSocket over an already-upgraded socket.""" + + def __init__(self, sock: socket.socket) -> None: + self._sock = sock + self._send_lock = threading.Lock() + self._closed = False + + def _read_exact(self, n: int) -> bytes: + chunks = b"" + while len(chunks) < n: + chunk = self._sock.recv(n - len(chunks)) + if not chunk: + raise WebSocketClosed() + chunks += chunk + return chunks + + def _read_frame(self) -> tuple[bool, int, bytes]: + header = self._read_exact(2) + fin = bool(header[0] & 0x80) + opcode = header[0] & 0x0F + masked = bool(header[1] & 0x80) + length = header[1] & 0x7F + if length == 126: + length = struct.unpack(">H", self._read_exact(2))[0] + elif length == 127: + length = struct.unpack(">Q", self._read_exact(8))[0] + if not masked: + raise WebSocketClosed() # client frames must be masked + mask = self._read_exact(4) + payload = bytearray(self._read_exact(length)) + for i in range(length): + payload[i] ^= mask[i % 4] + return fin, opcode, bytes(payload) + + def recv_text(self) -> str: + """Block until a full text/binary message arrives; handle control frames inline. + + Raises WebSocketClosed when the peer closes or the socket drops. + """ + message = b"" + message_opcode = None + while True: + fin, opcode, payload = self._read_frame() + if opcode == OP_PING: + self._send_frame(OP_PONG, payload) + continue + if opcode == OP_PONG: + continue + if opcode == OP_CLOSE: + self.close() + raise WebSocketClosed() + if opcode in (OP_TEXT, OP_BINARY): + message_opcode = opcode + message = payload + elif opcode == OP_CONT and message_opcode is not None: + message += payload + else: + raise WebSocketClosed() + if fin: + return message.decode("utf-8", errors="replace") + + def _send_frame(self, opcode: int, payload: bytes) -> None: + length = len(payload) + if length < 126: + header = struct.pack(">BB", 0x80 | opcode, length) + elif length < 1 << 16: + header = struct.pack(">BBH", 0x80 | opcode, 126, length) + else: + header = struct.pack(">BBQ", 0x80 | opcode, 127, length) + with self._send_lock: + self._sock.sendall(header + payload) + + def send_text(self, text: str) -> None: + if self._closed: + raise WebSocketClosed() + self._send_frame(OP_TEXT, text.encode()) + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._send_frame(OP_CLOSE, struct.pack(">H", 1000)) + except OSError: + pass + try: + self._sock.close() + except OSError: + pass