featured image

Building a Browser-Based Terminal with Flask-SocketIO and PTY Streaming

Learn how to create a web-based terminal interface that streams a pseudo-terminal (PTY) over WebSockets using Flask-SocketIO, enabling users to interact with command-line applications like Mininet directly from their browsers without any installation.

Published

Sat Jan 31 2026

Technologies Used

Python Flask Mininet
Beginner 10 minutes

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.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!