On this page
- Purpose
- Remote System Access Without SSH Complexity
- Zero-Install Browser Terminal
- The Constraint Triangle
- Prerequisites & Tooling
- Knowledge Base
- Environment
- High-Level Architecture
- Data Flow Diagram
- The Plumbing
- Implementation
- Setting Up the WebSocket Server
- Creating the Pseudo-Terminal
- Reading from the PTY (The Tricky Part)
- Handling Input from Browser
- Terminal Resizing (Bonus UX)
- Under the Hood
- The Kernel’s Role: PTY Internals
- Why Eventlet Over Threads?
- Edge Cases & Pitfalls
- The Zombie Process
- Buffer Explosion on Slow Clients
- Race on Late Client Connection
- Conclusion
- Skills Acquired
Purpose
Remote System Access Without SSH Complexity
Imagine you’ve built a complex network simulation running inside a Docker container. Users need to interact with it through a Mininet CLI, but requiring them to:
- Install Docker Desktop
- Learn
docker execcommands - Configure SSH keys or VNC
- Deal with terminal emulation inconsistencies across OS platforms
…creates massive friction. Students give up. Demos fail. Your beautifully architected system sits unused because the last mile of accessibility is broken.
Zero-Install Browser Terminal
This module (webapp/app.py) solves this by streaming a pseudo-terminal (PTY) directly to the browser over WebSockets. Users click a URL, see a terminal, and start typing commands—no installation, no configuration, no platform-specific bugs.
The Constraint Triangle
This isn’t just “embed a terminal widget.” We’re solving three competing constraints:
- Real-time bidirectional I/O (user types → command executes → output streams back)
- Process isolation (the Mininet process must run in its own namespace)
- Concurrency (multiple WebSocket clients should connect to the same session without race conditions)
Prerequisites & Tooling
Knowledge Base
-
Required:
- Basic Python (functions, imports, classes)
- HTTP basics (what a POST request is)
- Familiarity with terminals (stdin/stdout concept)
-
Helpful But Not Required:
- WebSocket protocol fundamentals
- Unix system calls (you’ll learn this here!)
Environment
From the Dockerfile and requirements used in this project:
# Python 3.8+
python3 --version
# Install dependencies
pip3 install flask flask-socketio eventlet
Why Eventlet? Flask’s default WSGI server (Werkzeug) can’t handle WebSockets. Eventlet provides greenlet-based concurrency that’s lightweight enough for dev environments.
High-Level Architecture
Data Flow Diagram
sequenceDiagram
participant Browser
participant Flask
participant PTY_Master
participant Mininet_Process
Browser->>Flask: WebSocket Connect
Flask->>PTY_Master: Create pseudo-terminal pair
Flask->>Mininet_Process: Spawn with PTY as stdin/stdout
Flask-->>Browser: Send buffered history
loop Real-time I/O
Browser->>Flask: 'term_input' event (keystrokes)
Flask->>PTY_Master: os.write(fd_master, data)
PTY_Master->>Mininet_Process: Characters appear in stdin
Mininet_Process->>PTY_Master: Prints output to stdout
PTY_Master->>Flask: select() detects readable data
Flask->>Browser: 'term_output' event (display text)
end
The Plumbing
Think of a pseudo-terminal as a two-way pipe:
- One end (the “slave”) is connected to the Mininet process—it thinks it’s talking to a real terminal
- The other end (the “master”) is controlled by our Flask app—we read/write bytes as if we’re the keyboard and screen
WebSockets are the internet extension cord that connects the browser to this master end.
Implementation
Setting Up the WebSocket Server
Goal: Initialize Flask with SocketIO support and configure async mode.
import eventlet
eventlet.monkey_patch() # CRITICAL: Must be first import!
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!' # Replace in production!
# cors_allowed_origins="*" allows browser access from any origin
# async_mode='eventlet' tells SocketIO to use greenlets
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet')
🔵 Deep Dive: Why monkey_patch() First?
Eventlet replaces Python’s standard socket libraries with non-blocking versions. If you import Flask before patching, Flask will use the blocking sockets, and your WebSocket connections will hang. The order matters because Python caches imports—once socket is imported, patching won’t affect it.
Creating the Pseudo-Terminal
Goal: Spawn the Mininet process inside a PTY.
import pty
import subprocess
import shlex
fd_master = None # Global: File descriptor for PTY master
proc = None # Global: The subprocess handle
def start_mininet():
global fd_master, proc
# Create a linked pair: master (controlled by us) and slave (given to subprocess)
(master, slave) = pty.openpty()
fd_master = master
cmd = "python2 -u topo.py" # -u disables Python's output buffering
# Spawn subprocess with slave as its stdin/stdout/stderr
proc = subprocess.Popen(
shlex.split(cmd),
stdin=slave,
stdout=slave,
stderr=slave,
cwd="/app",
close_fds=True # Prevents leaking file descriptors to child
)
# Start background thread to read from master and emit to WebSocket
socketio.start_background_task(
target=read_and_forward_pty_output,
fd=fd_master,
socket_io_instance=socketio
)
🔴 Danger: File Descriptor Leaks
If you don’t use close_fds=True, the child process will inherit all open file descriptors from the parent, including sockets from other requests. This can cause “too many open files” errors under load.
Reading from the PTY (The Tricky Part)
Naive Approach (WRONG):
# DON'T DO THIS - Blocks forever if no data!
while True:
output = os.read(fd_master, 1024)
socketio.emit('term_output', {'output': output})
Refined Solution (From the Repo):
import select
def read_and_forward_pty_output(fd, socket_io_instance):
"""
Continuously polls the PTY master for output and broadcasts it.
Uses select() to avoid blocking when no data is available.
"""
output_buffer = [] # Store history for late-joining clients
max_read_bytes = 1024 * 20
while True:
socket_io_instance.sleep(0.01) # Yield to eventlet scheduler
# select() with 0.1s timeout: "Is fd readable right now?"
(readable, _, _) = select.select([fd], [], [], 0.1)
if fd in readable:
try:
# Non-blocking read: only executes if data is available
output = os.read(fd, max_read_bytes).decode(errors='ignore')
if output:
output_buffer.append(output)
# Prevent unbounded memory growth
if len(output_buffer) > 1000:
output_buffer = output_buffer[-1000:]
socket_io_instance.emit('term_output', {'output': output})
except OSError as e:
print(f"PTY closed: {e}")
break
Why This Works:
select.select([fd], [], [], 0.1)says “tell me iffdhas data to read within 0.1 seconds”- If timeout expires, we loop again and yield to eventlet (allowing other greenlets to run)
- Only when data is available do we call
os.read(), which won’t block
Handling Input from Browser
Goal: When user types in browser, write those bytes to the PTY master.
@socketio.on('term_input')
def handle_term_input(data):
"""
Receives keystrokes from browser and writes them to PTY master.
The Mininet process (connected to PTY slave) sees them as stdin.
"""
global fd_master
if fd_master:
# data['input'] is a string like 'ls\n' when user presses Enter
os.write(fd_master, data['input'].encode())
🔵 Deep Dive: The Encoding Dance
WebSockets transmit strings (UTF-8 text). But os.write() expects bytes.
- Browser sends:
"ping 192.168.2.2\n"(JSON string) - We encode:
b'ping 192.168.2.2\n'(byte array) - PTY slave receives: raw bytes
- Mininet process reads: characters from stdin
Terminal Resizing (Bonus UX)
Why It Matters: If the browser window resizes but the PTY doesn’t know, line wrapping breaks.
import fcntl
import termios
import struct
def set_winsize(fd, row, col, xpix=0, ypix=0):
"""
Sends TIOCSWINSZ ioctl to update terminal dimensions.
This is what the 'resize' command does under the hood.
"""
# Pack dimensions into a binary struct: 4 unsigned shorts (HHHH)
winsize = struct.pack("HHHH", row, col, xpix, ypix)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
@socketio.on('resize')
def handle_resize(data):
if fd_master:
set_winsize(fd_master, data['rows'], data['cols'])
Under the Hood
The Kernel’s Role: PTY Internals
When you call pty.openpty(), the Linux kernel:
- Allocates a PTY device pair (
/dev/pts/Nfor slave, in-memory FD for master) - Configures terminal line discipline (handles backspace, Ctrl+C, etc.)
- Links them such that writes to master appear as reads on slave, and vice versa
Memory Perspective:
- Data written to
fd_mastergoes into a kernel ring buffer (typically 4KB) - If buffer fills (you write faster than the process reads),
os.write()blocks - This is why we check
select()before reading—we don’t want to block the event loop
Why Eventlet Over Threads?
Thread Approach Issues:
# If we used threads instead of greenlets...
import threading
def read_thread():
while True:
output = os.read(fd_master, 1024) # BLOCKS!
emit('term_output', ...) # emit() not thread-safe in Flask-SocketIO!
Problems:
os.read()blocks the thread until data arrives—wasted CPU time- Thread-safety: SocketIO’s
emit()requires you to be in the SocketIO context - Overhead: OS threads are ~1-2MB stack each; greenlets are ~1KB
Eventlet’s greenlets are cooperative: when one blocks on I/O, eventlet switches to another greenlet. This works because we use socket_io_instance.sleep(0.01) to explicitly yield control.
Edge Cases & Pitfalls
The Zombie Process
Scenario: User closes browser tab while Mininet is running.
Without Cleanup:
# If Flask stops but proc is still running...
proc.poll() # Returns None (still alive)
# Mininet keeps the Docker container alive indefinitely!
Defense: Add signal handlers:
import signal
def cleanup(signum, frame):
if proc:
proc.terminate()
proc.wait(timeout=5)
os._exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
Buffer Explosion on Slow Clients
Scenario: Client has slow network. Server emits output faster than client can receive.
Problem: SocketIO queues unsent messages in memory. A client with 1KB/s connection receiving 1MB/s of output will cause RAM to balloon.
Solution from Repo:
if len(output_buffer) > 1000:
output_buffer = output_buffer[-1000:] # Discard old history
This caps memory at ~20KB (1000 chunks × ~20 bytes average), accepting that late joiners won’t see the full log.
Race on Late Client Connection
Scenario:
- Mininet starts and prints setup messages
- Client A connects and sees them
- Mininet continues, prints more
- Client B connects 5 seconds later
Without History Buffer: Client B sees a blank screen until new output arrives—confusing UX.
Solution:
@socketio.on('connect')
def handle_connect():
global output_buffer
if output_buffer:
emit('term_output', {'output': "".join(output_buffer)})
Client B immediately receives all buffered history on connection.
Conclusion
Skills Acquired
You’ve learned:
- PTY Fundamentals: How Unix pseudo-terminals work at the system call level
- Non-Blocking I/O: Using
select()to poll file descriptors without blocking - WebSocket Integration: Bridging browser events to system processes
- Concurrency Patterns: Why eventlet’s greenlets beat threads for I/O-bound tasks
- Production Hardening: Handling cleanup, buffering, and late connections
The Proficiency Marker: Most tutorials show “hello world” WebSocket echo servers. You’ve implemented a production-grade terminal proxy that handles process lifecycle, resource cleanup, and concurrent clients—skills that transfer to building admin dashboards, log streaming, or remote debugging tools.
Next Step: Try extending this to support multiple concurrent sessions (one PTY per WebSocket connection) or add command history with arrow-key navigation.