This document outlines a plan to simplify PTY handling in FlySsh by using a fake local PTY to trick the client into thinking it’s talking to a real terminal, while forwarding all data to the real PTY on the remote server. This allows the session connection to remain raw while still supporting interactive programs like vim
, htop
, and tmux
.
A terminal emulator (like gnome-terminal
, iTerm
, or cmd.exe
) expects to communicate with a real PTY. If we just stream raw bytes from the remote PTY to the local terminal, we hit problems:
❌ Programs like vim
or htop
break because they expect a TTY.
❌ Line wrapping and input behavior is inconsistent since there’s no real PTY.
❌ No way to properly handle window resizing without a PTY.
Instead of running a real shell locally, we create a fake PTY that simply forwards everything to the remote PTY.
- Client opens a local PTY (fake terminal device)
- Client forwards local PTY input to the remote PTY
- Client forwards remote PTY output back to the local PTY
- The local terminal emulator is none the wiser—it thinks it’s talking to a real shell.
On Unix-based systems, we use the pty.Open()
function to create a local PTY. This acts as the interface between the user's terminal and the remote PTY.
// Open a fake local PTY
localPTY, localTTY, err := pty.Open()
if err != nil {
log.Fatal(err)
}
// Open WebSocket connection to remote PTY
remoteConn := openWebSocketToServer()
// Forward local PTY input to the remote PTY
go func() {
io.Copy(remoteConn, localPTY) // Local input -> Remote PTY
}()
// Forward remote PTY output to the local PTY
go func() {
io.Copy(localPTY, remoteConn) // Remote PTY -> Local PTY
}()
✅ Client thinks it’s talking to a real PTY
✅ Remote PTY handles all terminal behaviors
✅ Works with interactive programs (vim
, htop
, tmux
)
A real PTY would automatically handle SIGWINCH
(window resize signals). Since we’re forwarding everything to the remote PTY, we need to ensure that resizing events propagate properly.
window.addEventListener("resize", function () {
let cols = process.stdout.columns;
let rows = process.stdout.rows;
controlSocket.send(JSON.stringify({
type: "resize",
cols: cols,
rows: rows
}));
});
ws.On("resize", func(msg ResizeMessage) {
winsize := &pty.Winsize{
Cols: uint16(msg.Cols),
Rows: uint16(msg.Rows),
}
err := pty.Setsize(remotePTY, winsize)
if err != nil {
log.Println("Failed to resize PTY:", err)
}
});
✅ Client-side terminal adjusts normally ✅ Remote PTY resizes properly ✅ Maintains a clean protocol separation (session = raw, control = resize updates)
Windows does not have Unix-style PTYs, but Windows 10+ includes ConPTY (Console Pseudo Terminal), which can be used to create a fake local PTY.
const { spawn } = require('child_process');
const ptyProcess = spawn("cmd.exe", [], {
windowsHide: true,
stdio: "pipe"
});
ptyProcess.stdout.on("data", (data) => {
socket.send(data.toString()); // Send to remote PTY
});
socket.on("message", (data) => {
ptyProcess.stdin.write(data); // Receive from remote PTY
});
✅ Windows users get a real PTY without needing Unix-like /dev/pts/N
✅ Remote PTY remains the only real shell
✅ Fully cross-platform implementation
Component | Action |
---|---|
Local PTY | Fake PTY that forwards everything to remote |
Remote PTY | The real PTY that runs the shell/program |
Session Conn. | Raw data stream (stdin/stdout forwarding) |
Control Conn. | Resize events only |
✅ Terminal emulator sees a PTY and works normally ✅ Session connection stays raw, no control messages mixed in ✅ Cross-platform: Works on Unix (PTY) and Windows (ConPTY)
- Implement local PTY forwarding (Unix & Windows)
- Ensure resize events sync properly over control connection
- Test with
vim
,htop
,tmux
to confirm interactive compatibility
This approach keeps the protocol simple while ensuring full PTY behavior for interactive applications. 🚀