The goal
textish serves Textual TUI apps over SSH. You import your Textual app, hand the class to serve(), and anyone with an SSH client can connect and use the app in their terminal, with nothing to install on their end. It is the same idea as Charmbracelet’s wish for Go: the app is imported and run inside the server process, not launched as an external command.
from textish import serve
from myapp import MyApp # your Textual App, in an importable module
serve(MyApp, port=2222)
# then, from anywhere: ssh localhost -p 2222
That one-line API hides the entire interesting problem. A server has to run many independent app instances at once, each wired to a different client’s terminal, from a single long-lived process. How you run those instances — process, interpreter, thread, or task — is the whole design. I built it three different ways before settling on the fourth.
The hard part: Textual wants to own the terminal
Textual talks to the outside world through a Driver, and the stock drivers assume they own a real terminal. They read sys.stdin, write sys.stdout, call termios, install signal handlers, and swap the process-global sys.stdout and sys.stderr for the lifetime of the app. That is fine for one app on one terminal. It is a direct conflict when a single server has to drive many terminals at once.
So the requirement was concrete: every connection needs its own isolated terminal, input stream, screen state, and app object, but they all have to coexist inside one server without fighting over process-global state. That is fundamentally a question about concurrency and isolation, which is exactly the axis Python gives you several ways to answer. The problem was never rendering. It was running many isolated apps in one process without them stepping on each other. I worked through the options in order of decreasing isolation and decreasing weight.
Architecture 1: one process per connection
The first design gave every connection its own process. The very first version bridged the SSH channel to an app subprocess using Textual’s own WebDriver packet protocol, the same protocol that powers textual serve on the web, so textish spoke packets over the child’s stdio. That protocol added a handshake, framing, and a class of timeout and cleanup bugs. So I replaced it with raw PTY forwarding: the app subprocess runs under a real pseudo-terminal, and textish just shuttles raw terminal bytes between the SSH channel and the PTY. The app believes it is on an ordinary terminal, because it is.
v0.1 ssh channel <-> WebDriver packet protocol <-> app subprocess
v0.3 ssh channel <-> raw PTY bytes <-> app subprocess (own PTY)
The isolation was excellent: an OS process boundary per session, real parallelism, and each app with its own process-global stdio. But it was heavy in every dimension that matters for a server. A fork and a fresh interpreter per connection, Textual re-imported every time, large per-session memory, slow startup, many file descriptors, and PTY plumbing plus lifecycle handling like zombie reaping, graceful termination, and timeouts. Strong isolation is the right default when you host untrusted code. It is a lot to pay when the apps are your own dashboards.
- A fork and a full interpreter per connection.
- Textual and the app re-imported for every session.
- High memory and slow startup at scale.
- PTY, IPC, and process-cleanup complexity.
Architecture 2: many interpreters, one process
Subinterpreters looked like a way to keep isolation while shedding the process overhead. With PEP 734’s concurrent.interpreters and the per-interpreter GIL from PEP 684, each session runs in its own interpreter inside one process: true multicore parallelism for busy sessions, but no fork. I rewrote textish down to a subinterpreter-only backend to test the idea properly.
from concurrent import interpreters # PEP 734, Python 3.14+
interp = interpreters.create() # its own GIL, its own imports
# but to run one session in here you must:
# - forward the parent's sys.path so "myapp:MyApp" is importable
# - keep asyncssh out of the worker's import chain (it crashed the subinterp)
# - move every value across a queue, catching stdlib queue.Empty
In practice, the boundary that made subinterpreters safe also made them awkward. Nothing could be shared directly. Every value crossed an interpreter via a queue, so I had to propagate sys.path so the app was importable, keep asyncssh entirely out of the worker’s import chain because importing it crashed the subinterpreter, and paper over quirks like queue exceptions that did not match their documented types. It also used substantially more memory per session than sharing would, added real lifecycle complexity, and, crucially, was still not a security sandbox. A per-interpreter GIL buys parallelism, but you serialize across every boundary and still get no sandbox. I was paying for isolation I did not actually need.
Architecture 3: real threads, no GIL
The third option was free-threaded CPython, the no-GIL build from PEP 703. Remove the GIL and you can run one OS thread per session inside a single interpreter: shared memory with no serialization, and genuine multicore parallelism at the same time. On paper it is the best of both earlier designs, so I built a threads-per-session variant on the free-threaded interpreter and benchmarked it against the others.
# PEP 703: no GIL, real threads sharing one interpreter's memory
python3.14t run.py # one thread per session, true parallelism
It did not earn its place. The free-threaded build is still maturing, and asking every deployment to run a special no-GIL interpreter, with dependencies that are not all guaranteed to be free-threading-ready, is a heavy adoption cost. More decisively, the workload is wrong for it. Serving TUIs over SSH is I/O-bound, so the GIL is rarely the bottleneck, and the parallelism free-threading unlocks buys little here while adding the thread-safety burden that Textual’s cooperative async model is designed to avoid. Free-threading pays off when the GIL is your ceiling; serving terminals over SSH, it almost never is. In practice, there was no win worth that cost.
What I shipped: share everything except the app
The design I shipped shares the most and isolates the least. Every session runs as one asyncio task on a single shared event loop, in one interpreter, with shared imported modules, process globals, and GIL. What stays private is exactly what has to: each session gets a fresh App instance and a session-specific SSHDriver, its own input stream, terminal size, and screen state. There are no per-session processes, threads, polling loops, or cross-interpreter queues.
# one interpreter, one event loop; a fresh app + driver per connection
async def run_session(channel):
app = MyApp() # fresh instance, isolated state
driver = SSHDriver(app, channel) # this client's terminal
await app.run_async() # just another asyncio task
# SSHDriver bridges one AsyncSSH channel to one Textual app:
# feed(data) -> decode this client's bytes into Textual events
# write(data) -> encode this app's output to the channel
# resize(w, h) -> post a Textual resize message
The rest is the machinery that makes sharing safe. A SessionManager enforces max_connections with a reservation count and throttles connection bursts through a small startup gate, so a flood of new clients cannot monopolize the loop with expensive Textual startups. Each channel caps its output buffer and disconnects a slow reader that falls too far behind. And because Textual normally swaps process-global sys.stdout and sys.stderr per app, which is unsafe when app lifetimes overlap, server mode disables that swap. Apps use logging, not print, for diagnostics.
- Fresh App and SSHDriver per session; shared modules and loop.
- SessionManager: connection reservations plus a startup gate.
- Bounded output buffer with slow-reader backpressure.
- Textual’s process-global stdout/stderr swap disabled in server mode.
The lesson: match the primitive to the workload
The shared model is not free either, and the README says so plainly. One event loop and GIL means cooperative scheduling, so a blocking or CPU-heavy handler delays everyone; that work has to go to async I/O, a Textual worker, asyncio.to_thread(), or a process pool. One process means a whole-process fault takes down every session. Shared module globals must not be used as unkeyed per-user state. And it is explicitly not a security boundary: textish is for trusted apps like dashboards and admin tools, not for hosting arbitrary user code.
That list of trade-offs is precisely why the shared model won. Subprocess, subinterpreter, and free-threading each chase parallelism and isolation, and I kept reaching for the most powerful primitive available. But serving trusted, I/O-bound TUIs, the scarce resource was never CPU or a trust boundary. It was per-connection overhead. The right design gives up true parallelism and hard isolation for the lightest possible session, and exports the rare CPU-bound case to a worker. The strongest tool was the wrong one; the cheapest one that fit the workload was right.