On this page
- What You Need Coming In
- Two-Way Pipe with an Internet Extension Cord
- Setting Up the WebSocket Server
- Spawning the Process in a PTY
- The Tricky Part: Reading from the PTY Without Blocking
- Sending Keystrokes from the Browser
- Terminal Resizing
- Three Edge Cases to Get Right
- Why Greenlets Instead of Threads
I built a complex network simulation running inside a Docker container. Users needed to interact with it through a Mininet CLI — but requiring them to install Docker Desktop, learn docker exec, configure SSH keys, and wrestle with terminal emulation inconsistencies across operating systems was killing adoption. Students would give up before getting started. Demos would fail at the wrong moment.
The solution was to stream a pseudo-terminal directly to the browser over WebSockets. Users click a URL, see a terminal, and start typing — no installation, no configuration. This tutorial walks through webapp/app.py, which does exactly that using Flask-SocketIO and Python’s pty module.
What You Need Coming In
- Basic Python (functions, imports, classes)
- HTTP basics (what a POST request is)
- Familiarity with terminals (stdin/stdout concept)
- WebSocket protocol fundamentals and Unix system calls are helpful but not required — you’ll learn them here
pip3 install flask flask-socketio eventlet
Flask’s default WSGI server can’t handle WebSockets. Eventlet provides greenlet-based concurrency that’s lightweight enough for development environments.
Two-Way Pipe with an Internet Extension Cord
Before writing code, it helps to understand what a pseudo-terminal actually is.
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
One end of the pseudo-terminal (the “slave”) is connected to the Mininet process. From Mininet’s perspective, it’s talking to a real terminal — it can read from stdin and write to stdout like normal. The other end (the “master”) is controlled by our Flask app. We read the output Mininet writes and send it to the browser, and we write keystrokes from the browser to the master so Mininet sees them as stdin. WebSockets are the internet extension cord that connects the browser to the master end.
Setting Up the WebSocket Server
import eventlet
eventlet.monkey_patch() # CRITICAL: Must be the first import
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet')
eventlet.monkey_patch() must come before any other imports. Eventlet replaces Python’s standard socket libraries with non-blocking versions. If you import Flask before patching, Flask caches the blocking socket library, and WebSocket connections hang. This ordering is one of those things that causes mysterious debugging sessions if you don’t know about it.
Spawning the Process in a PTY
import pty, subprocess, shlex, os
fd_master = None
proc = None
def start_mininet():
global fd_master, proc
(master, slave) = pty.openpty()
fd_master = master
cmd = "python2 -u topo.py" # -u disables Python's output buffering
proc = subprocess.Popen(
shlex.split(cmd),
stdin=slave,
stdout=slave,
stderr=slave,
cwd="/app",
close_fds=True
)
socketio.start_background_task(
target=read_and_forward_pty_output,
fd=fd_master,
socket_io_instance=socketio
)
pty.openpty() creates a linked pair: master (controlled by us) and slave (given to the subprocess). We pass the slave as stdin, stdout, and stderr for the subprocess, so Mininet’s I/O goes through the PTY.
close_fds=True prevents leaking file descriptors to the child process. Without it, the child inherits all open file descriptors from the parent — including sockets from other WebSocket connections. Under load this causes “too many open files” errors.
The Tricky Part: Reading from the PTY Without Blocking
A naive implementation reads in a loop:
# Wrong — blocks forever if there's no data
while True:
output = os.read(fd_master, 1024)
socketio.emit('term_output', {'output': output})
os.read() blocks until data is available. In an eventlet-based server, a blocked greenlet can’t yield to other greenlets, which freezes the entire application.
The correct approach uses select() to check whether data is available before reading:
import select
def read_and_forward_pty_output(fd, socket_io_instance):
output_buffer = []
max_read_bytes = 1024 * 20
while True:
socket_io_instance.sleep(0.01) # Yield to eventlet scheduler
(readable, _, _) = select.select([fd], [], [], 0.1)
if fd in readable:
try:
output = os.read(fd, max_read_bytes).decode(errors='ignore')
if output:
output_buffer.append(output)
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
select.select([fd], [], [], 0.1) asks “is fd readable within 0.1 seconds?” If nothing’s available, it times out and the loop continues, giving eventlet a chance to handle other greenlets. Only when data is confirmed available do we call os.read(), which won’t block. The socket_io_instance.sleep(0.01) at the top is the explicit yield that makes cooperative multitasking work.
Sending Keystrokes from the Browser
@socketio.on('term_input')
def handle_term_input(data):
global fd_master
if fd_master:
os.write(fd_master, data['input'].encode())
WebSockets transmit strings. os.write() expects bytes. The .encode() call handles the conversion — the browser sends "ping 192.168.2.2\n" and the PTY slave receives raw bytes that Mininet reads as stdin.
Terminal Resizing
If the browser window resizes but the PTY doesn’t know about it, line wrapping breaks. The TIOCSWINSZ ioctl updates the PTY’s reported dimensions:
import fcntl, termios, struct
def set_winsize(fd, row, col, xpix=0, ypix=0):
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'])
struct.pack("HHHH", ...) packs four unsigned shorts into the binary format the kernel expects for the window size ioctl. This is what the resize command does under the hood when you resize a terminal.
Three Edge Cases to Get Right
Process cleanup when clients disconnect. If Flask stops but the Mininet process keeps running, the Docker container stays alive indefinitely. 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)
Memory on slow clients. If a client has a slow connection, SocketIO queues unsent messages in memory. A client receiving more data than it can handle will cause RAM to balloon. The buffer cap in read_and_forward_pty_output limits this:
if len(output_buffer) > 1000:
output_buffer = output_buffer[-1000:]
This accepts that late-joining clients won’t see the full log — a reasonable trade-off for bounded memory usage.
Late connections seeing a blank screen. When a client connects five seconds after Mininet started, without a history buffer they’d see nothing until new output arrives. The connection handler sends all buffered output immediately:
@socketio.on('connect')
def handle_connect():
global output_buffer
if output_buffer:
emit('term_output', {'output': "".join(output_buffer)})
Why Greenlets Instead of Threads
Threads would cause two problems here. First, os.read() blocks the thread until data arrives — wasted CPU. Second, socketio.emit() requires being in the SocketIO context, which is tricky to maintain across threads. Eventlet’s greenlets are cooperative: when one blocks on I/O, eventlet switches to another. The socket_io_instance.sleep(0.01) call is the explicit yield that makes this work. Greenlets also use about 1KB of stack versus 1–2MB for OS threads, so a server handling 50 concurrent connections uses 50KB instead of 100MB.
This pattern — PTY streaming over WebSockets — transfers directly to admin dashboards, log streaming, remote debugging tools, or anything else where you want browser access to a command-line process without installing software on the client.