This is the story of how pseudoterminals broke on Android, how Microsoft's node-pty became a long-standing blocker, and how a Rust crate cracked the problem open — enabling real web terminals on Android for the first time.
What Is a PTY?
A pseudoterminal (PTY) is a kernel mechanism that lets a program act as a terminal. It is the fundamental abstraction behind every terminal emulator, every SSH session, every tmux pane, and every screen session on any Unix system.
A PTY is a pair of virtual file descriptors:
- Master side — held by the controlling program (the terminal emulator, SSH daemon, or multiplexer). It reads what the child process writes and sends keystrokes to the child.
- Slave side — presented to the child process (the shell, or whatever runs inside the terminal). From the child's perspective, the slave looks exactly like a real hardware terminal.
Terminal Emulator (xterm, Alacritty, Termux)
│
│ reads/writes
▼
┌──────────┐
│ Master │ <── PTY pair (kernel-managed)
│ (ptmx) │
└────┬─────┘
│ kernel passes data between master ↔ slave
┌────┴─────┐
│ Slave │ <── looks like /dev/pts/N
│ (pts/N) │
└──────────┘
│
│ stdin/stdout/stderr
▼
Shell (bash, zsh, fish)
The POSIX convenience function forkpty() bundles the entire dance into one call: it allocates the PTY pair, forks a child process, connects the child's stdin/stdout/stderr to the slave side, and returns the master file descriptor to the parent. Every major terminal emulator on Linux and macOS uses forkpty() — it is the standard way to spawn a shell inside a program.
node-pty — The Standard That Breaks on Android
node-pty is Microsoft's npm package for PTY allocation in Node.js. It is the de facto standard for any Node.js application that needs to spawn a terminal session. The list of tools that depend on it is staggering:
| Tool |
What It Does |
| VS Code (integrated terminal) |
The most popular code editor's terminal |
| code-server |
VS Code in a browser (self-hosted) |
| Hyper |
Electron-based terminal emulator |
| Tabby |
Cross-platform terminal with SSH |
| ttyd |
Share terminal over the web |
| Wetty |
Web-based SSH terminal |
| Gemini CLI |
Google's AI coding assistant |
| Claude Code |
Anthropic's AI coding CLI |
node-pty has two fatal problems on Termux:
Problem 1 — Build Failure: node-pty uses node-gyp to compile a native C++ addon. Its binding.gyp file references an android_ndk_path variable that does not exist in Termux. Termux is not an Android NDK build environment — it is a native Linux environment that happens to run on Android. The build fails immediately with gyp errors.
Problem 2 — Runtime Failure: Even if you could get it to compile, node-pty calls forkpty() from glibc's <pty.h>. Android's Bionic libc historically did not implement forkpty(). The function either does not exist or behaves incorrectly on older Android versions. The process crashes or hangs trying to allocate a PTY.
This has been a long-standing, well-known blocker. Issues have been reported against node-pty since 2024. As of late 2025, there is no upstream fix. The problem is fundamental — node-pty was designed for desktop operating systems and has no Android code path.
The real-world impact: any xterm.js-based web terminal with a Node.js backend simply cannot work on Android. code-server's terminal tab? Broken. ttyd on your phone? Dead on arrival. Building a web-based terminal tool in Node.js for Termux? Impossible.
portable-pty — The Rust Solution
portable-pty is a Rust crate by Wez Furlong, the author of WezTerm terminal. It provides a trait-based, cross-platform PTY abstraction that works on Linux, macOS, Windows, and — with a patch — Android.
Claude helped patch portable-pty to add #[cfg(target_os = "android")] detection. The Android code path avoids forkpty() entirely and instead uses lower-level POSIX primitives that Bionic does support:
let master = open("/dev/ptmx", O_RDWR | O_NOCTTY);
grantpt(master);
unlockpt(master);
let slave_name = ptsname(master);
let slave = open(slave_name, O_RDWR);
match fork() {
Ok(ForkResult::Child) => {
setsid();
ioctl(slave, TIOCSCTTY);
dup2(slave, STDIN);
dup2(slave, STDOUT);
dup2(slave, STDERR);
exec("/data/data/com.termux/files/usr/bin/bash");
}
Ok(ForkResult::Parent { child }) => {
}
}
The key insight: forkpty() is just a convenience wrapper around these individual operations. Every one of these primitives — open, grantpt, unlockpt, ptsname, fork, setsid, ioctl(TIOCSCTTY) — works correctly on Android's Bionic. The problem was never that Android couldn't do PTYs. It was that the standard convenience function was broken, and nobody had implemented the manual path.
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
fn spawn_shell() -> Result<(), Box<dyn std::error::Error>> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new("bash");
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd)?;
let mut reader = pair.master.try_clone_reader()?;
let mut writer = pair.master.take_writer()?;
Ok(())
}
What This Enables
With portable-pty working on Android, the entire architecture changes:
| Architecture |
Data Flow |
Status |
| Old (Node.js) |
Browser xterm.js → WebSocket → Node.js → node-pty → bash |
BROKEN |
| New (Rust) |
Browser xterm.js → WebSocket → Rust server → portable-pty → bash |
WORKS |
Rust web frameworks — axum, actix-web, warp — all compile and run natively on Termux. Combined with portable-pty, you can build a complete web terminal server that:
- Serves an xterm.js frontend over HTTP
- Accepts WebSocket connections from browsers
- Spawns real bash sessions via PTY
- Runs entirely on your Android phone
pkg install rust
cargo new termux-web-terminal
cd termux-web-terminal
cargo add axum tokio portable-pty tokio-tungstenite
cargo build --release
./target/release/termux-web-terminal
This is a fundamental capability unlock. Web-based development tools, terminal sharing, remote access servers, pair programming tools — all are now possible on Android. The bottleneck was never the hardware (modern phones have 8+ cores and 12GB+ RAM). It was a single missing function in a C library.
Claude Code TMPDIR Fix
Even after solving PTY allocation, getting real development tools running on Termux exposed another class of problem: hardcoded temp paths.
Claude Code (Anthropic's AI coding CLI) hardcoded /tmp/claude/ paths throughout its codebase for subagent communication, background task state, and the Task tool. On Android, /tmp is not writable by non-root users. Termux provides $TMPDIR pointing to $PREFIX/tmp, but Claude Code ignored it entirely.
Impact: The basic CLI launched and could hold a conversation, but critical subsystems silently failed. Subagent spawning crashed with EACCES. Background tasks could not write state files. The Task tool threw permission errors. The tool appeared to work but was running in a severely degraded mode.
Multiple GitHub issues documented the problem over months:
| Issue |
Title / Context |
#15628 |
Initial report — /tmp permission denied on Termux |
#15637 |
Subagent spawn failure on Android |
#15700 |
EACCES writing to /tmp/claude/ in Termux |
#16955 |
Task tool broken on Termux |
#17366 |
Background tasks fail silently |
#18342 |
Request: respect $TMPDIR environment variable |
#19387 |
Follow-up / confirmation of fix |
The fix arrived in Claude Code v2.1.4 / v2.1.5 with a new environment variable:
export CLAUDE_CODE_TMPDIR="$TMPDIR/claude"
mkdir -p "$CLAUDE_CODE_TMPDIR"
echo 'export CLAUDE_CODE_TMPDIR="$TMPDIR/claude"' >> ~/.bashrc
echo 'mkdir -p "$CLAUDE_CODE_TMPDIR"' >> ~/.bashrc
echo $CLAUDE_CODE_TMPDIR
Broader Lesson: Well-behaved Unix programs should use os.tmpdir() (Node.js), tempfile.gettempdir() (Python), or std::env::temp_dir() (Rust) — all of which respect $TMPDIR. Hardcoding /tmp is a portability bug that breaks on Android, macOS sandboxed apps, and various embedded Linux systems. If your tool writes temp files, honor the environment variable.