feat: WebSocket proof-of-concept prototype (GSoC 2026 proposal)#176
feat: WebSocket proof-of-concept prototype (GSoC 2026 proposal)#176xu826Jamin wants to merge 2 commits intoc2siorg:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a self-contained WebSocket-based proof-of-concept under prototype/ to demonstrate why GDB MI async *stopped records require a dedicated background reader thread (per session) rather than emitting only from command handlers.
Changes:
- Introduces a Flask-SocketIO server that spins up a per-session
GdbControllerplus a background reader thread to emit async notify records to the right room. - Adds a small C debug target and README/instructions for reproducing an “unprompted breakpoint hit” event in the browser UI.
- Adds prototype-specific dependency list and a small diagnostic script.
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
.gitignore |
Attempts to ignore prototype build output (currently incorrect pattern). |
.dockerignore |
Adds standard Docker ignore rules for repo builds. |
prototype/server.py |
Main prototype server + embedded frontend demonstrating background reader thread + room routing. |
prototype/target.c |
Minimal C program with intended breakpoint location. |
prototype/target.exe |
Prebuilt Windows binary added to repo (build artifact). |
prototype/requirements.txt |
Prototype Python dependencies. |
prototype/diag.py |
Quick local diagnostic for launching GDB and setting a breakpoint. |
prototype/README.md |
Prototype explanation, architecture diagram, and run steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # eventlet async mode required for background threads with SocketIO | ||
| socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") |
There was a problem hiding this comment.
The comment says “eventlet async mode required”, but SocketIO is configured with async_mode="threading" and the frontend forces transports: ['websocket']. In threading mode, Flask-SocketIO typically needs simple-websocket (or a different async_mode like eventlet/gevent) to support WebSocket transport; otherwise the connection can fail or fall back to polling (which you’ve disabled). Align the async_mode, dependencies, and client transport settings so the prototype runs out-of-the-box.
| # eventlet async mode required for background threads with SocketIO | |
| socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") | |
| # Use eventlet async mode so WebSocket transport works out-of-the-box | |
| socketio = SocketIO(app, async_mode="eventlet", cors_allowed_origins="*") |
| sid = session.get("sid", "anon") | ||
| sess = get_or_create_session(sid) | ||
|
|
||
| # Tear down any existing GDB session for this user | ||
| if sess.get("controller"): | ||
| try: | ||
| sess["controller"].exit() | ||
| except Exception: | ||
| pass | ||
| sess["active"] = False | ||
|
|
||
| # Create a fresh isolated GdbController for this session | ||
| controller = GdbController() | ||
| sess["controller"] = controller | ||
| sess["active"] = True | ||
|
|
There was a problem hiding this comment.
Session state is mutated outside the _sessions_lock (e.g., setting sess["controller"], sess["active"], and tearing down an existing controller). The reader thread reads these fields under the lock, so this can race and observe partial updates. Wrap session lifecycle mutations in the same lock (or use per-session locks) to make the concurrency story match the intent described in comments.
| sid = session.get("sid") or "anon" | ||
| join_room(sid) | ||
| print(f"[connect] client joined room {sid[:8]}") | ||
| emit("connected", {"session_id": sid[:8]}) |
There was a problem hiding this comment.
If session['sid'] is missing, this falls back to the hard-coded room name "anon" and all such clients will join the same room (cross-user event leakage). Consider always generating a unique sid (e.g., create one here when absent) rather than using a shared default.
| bp_resp = controller.write(f"-break-insert {BREAKPOINT_LINE}", timeout_sec=3) | ||
| bp_ok = any(r.get("message") == "done" for r in bp_resp) | ||
|
|
||
| emit("session_ready", { | ||
| "binary": os.path.basename(TARGET_BINARY), | ||
| "breakpoint": BREAKPOINT_LINE, |
There was a problem hiding this comment.
This comment says “set breakpoint by function name”, but the code uses a bare line number (-break-insert {BREAKPOINT_LINE}). Also, a bare line number can be ambiguous in GDB MI; using target.c:{line} (or a function like main) is more reliable across platforms and matches the intent of the comment.
| bp_resp = controller.write(f"-break-insert {BREAKPOINT_LINE}", timeout_sec=3) | |
| bp_ok = any(r.get("message") == "done" for r in bp_resp) | |
| emit("session_ready", { | |
| "binary": os.path.basename(TARGET_BINARY), | |
| "breakpoint": BREAKPOINT_LINE, | |
| breakpoint_spec = "main" | |
| bp_resp = controller.write(f"-break-insert {breakpoint_spec}", timeout_sec=3) | |
| bp_ok = any(r.get("message") == "done" for r in bp_resp) | |
| emit("session_ready", { | |
| "binary": os.path.basename(TARGET_BINARY), | |
| "breakpoint": breakpoint_spec, |
| if __name__ == "__main__": | ||
| binary = TARGET_BINARY | ||
| if not os.path.exists(binary): |
There was a problem hiding this comment.
The entry-point block has a redundant nested if __name__ == "__main__": and inconsistent indentation inside it. This makes the script harder to read and is easy to break with future edits; simplify to a single guard and use consistent 4-space indentation.
| if __name__ == "__main__": | |
| binary = TARGET_BINARY | |
| if not os.path.exists(binary): | |
| binary = TARGET_BINARY | |
| if not os.path.exists(binary): |
| _binary_name = "target.exe" if os.name == "nt" else "target" | ||
| TARGET_BINARY = os.path.join(os.path.dirname(os.path.abspath(__file__)), _binary_name) | ||
| TARGET_BINARY_GDB = TARGET_BINARY.replace("\\", "/") | ||
| BREAKPOINT_LINE = 9 |
There was a problem hiding this comment.
BREAKPOINT_LINE is set to 9, but in target.c the intended breakpoint location (the add() call) is on line 10. As-is, the breakpoint will land on the int y = 20; line, and the UI/readme will be inconsistent with what GDB reports.
| BREAKPOINT_LINE = 9 | |
| BREAKPOINT_LINE = 10 |
| flask>=3.0.0 | ||
| flask-socketio>=5.3.6 | ||
| pygdbmi>=0.10.0.0 | ||
| eventlet>=0.35.0 |
There was a problem hiding this comment.
requirements.txt uses loose >= pins, which makes the prototype non-reproducible and can break unexpectedly with new dependency releases. The main server’s requirements file in this repo is pinned to exact versions; consider pinning here too (and ensure the selected versions match the chosen SocketIO async mode).
| eventlet>=0.35.0 | |
| eventlet==0.35.0 |
| |-- start_debug -----------> | | | ||
| | |-- GdbController() ---------->| | ||
| | |-- -file-exec-and-symbols --->| | ||
| | |-- -break-insert 9 ---------->| |
There was a problem hiding this comment.
The architecture diagram hard-codes -break-insert 9, but target.c’s annotated breakpoint location is on line 10 (and server.py defines BREAKPOINT_LINE). Update this line number (or reference the constant) so the README matches the actual demo behavior.
| | |-- -break-insert 9 ---------->| | |
| | |-- -break-insert 10 --------->| |
| ``` | ||
| gdb-websocket-prototype/ | ||
| ├── server.py # Flask-SocketIO backend with background reader thread | ||
| ├── target.c # C program with a deliberate breakpoint location | ||
| ├── target # compiled binary (build with command below) | ||
| └── README.md | ||
| ``` |
There was a problem hiding this comment.
The README “Files” section suggests a compiled binary is part of the prototype directory, but the setup section instructs building it locally. To keep the repo clean (and avoid platform-specific binaries), prefer not committing compiled outputs; document the build step and ignore the generated target/target.exe files.
| from pygdbmi.gdbcontroller import GdbController | ||
| import os, time | ||
|
|
||
| TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "target.exe") |
There was a problem hiding this comment.
diag.py hard-codes target.exe, so it won’t work on non-Windows platforms (and server.py already handles target vs target.exe). Consider using the same OS-dependent binary name logic here to keep the prototype runnable cross-platform.
| TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "target.exe") | |
| _target_name = "target.exe" if os.name == "nt" else "target" | |
| TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), _target_name) |
This prototype demonstrates the background reader thread architecture proposed in my GSoC 2026 application for Objective 4 (real-time GDB output via WebSockets).
Key claim proven: GDB *stopped records arrive asynchronously — without any client command triggering them. A naive SocketIO implementation that only emits responses inside the command handler silently drops breakpoint hits entirely. This prototype runs a dedicated reader thread per session that continuously polls pygdbmi and emits all record types — including async notify records — to the correct SocketIO room.
What to run:
cd prototype
pip install -r requirements.txt
gcc -g -O0 -o target target.exe target.c
python server.py
open http://localhost:5000
What to observe: click Start debug session. The BREAKPOINT HIT event arrives in the browser without any command being sent — the *stopped async record caught by the reader thread, not the command handler.
Also demonstrates:
Screenshot showing the BREAKPOINT HIT event arriving unprompted, local variables panel populated with real GDB state (x=10), and line 9 highlighted in the source panel.
