///|
/// Raised by `pty_reader` when the local shell exits. Propagates up
/// through the outer `with_task_group` in `run_bridge`, cancelling all
/// bridge tasks and the retry loop so `main` returns cleanly.
priv suberror ShellExited derive(Debug)
///|
/// Persistent PTY reader. Spawned once at startup and lives for the
/// entire session, independent of the relay. It tees output to the
/// local terminal **regardless of relay state** and broadcasts to
/// remote clients only when a relay session is active — this is what
/// makes the local shell usable even when the relay is down.
///
/// On EOF (local shell exited / `exit` / `Ctrl-D`) it raises
/// `ShellExited`, which propagates out through the outer task group
/// and shuts the server down cleanly.
async fn pty_reader(bridge : BridgeState, pty : @pty.Pty) -> Unit {
@log.info("pty_reader: starting, pid=\{pty.pid()}")
let pid = pty.pid()
// Use a task group so that when wait_pid fires (child exited), the
// blocked read loop is cancelled — ConPTY on Windows does not deliver
// EOF on the output pipe until ClosePseudoConsole, so we cannot rely
// on read returning 0 to detect child exit.
@async.with_task_group(async fn(group) {
group.spawn_bg(async fn() {
let exit_code = @process.wait_pid(pid)
@log.info("pty_reader: child exited(\{exit_code}), pid=\{pid}")
bridge.pty = None
bridge.shell_exited.val = true
raise ShellExited
})
let reader = pty.reader()
let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
for ;; {
let n = reader.read(buf)
@log.info("pty_reader: read returned n=\{n}")
if n > 0 {
let bytes = Bytes::from_array(buf[:n])
let created = @async.now()
let entry = bridge.replay.append(created, bytes)
let responses = @terminal.pty_to_host(bytes, bridge.terminal)
for resp in responses {
pty.write(resp) catch {
err if @async.is_being_cancelled() => raise err
err =>
@log.error(
"[pty.host_response.write.fail] bytes=\{resp.length()} error=\{@debug.to_string(err)}",
)
}
}
// Remote broadcast — only when a relay session is connected.
if bridge.current.val is Some(session) {
let data = @base64.encode(bytes[:])
for client_id, _ in session.clients {
let msg : @proto.Message = Term(
Data(client_id~, id=entry.id, created=Some(created), data~),
)
session.send_queue.put(msg.to_json().stringify()) catch {
err if @async.is_being_cancelled() => raise err
err =>
@log.error(
"[relay.enqueue.fail] source=\"pty_broadcast\" client_id=\{client_id} id=\{entry.id} error=\{@debug.to_string(err)}",
)
}
}
}
} else {
@log.info("pty_reader: read returned \{n} (EOF), pid=\{pid}")
bridge.pty = None
bridge.shell_exited.val = true
raise ShellExited
}
}
})
}
///|
/// Read from local stdin, let the terminal backend filter each chunk
/// (consuming any host-side control input it owns and returning the
/// rest), then forward the filtered bytes to the PTY master.
/// Exits when stdin EOFs or when the task group cancels this task.
/// Uses `@async/stdio.stdin` directly so the same code works on Unix
/// (epoll on fd 0) and Windows (worker-thread blocking read on the
/// console HANDLE).
async fn local_stdin_task(bridge : BridgeState) -> Unit {
let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
for ;; {
let n = @async/stdio.stdin.read(buf)
if n <= 0 {
break
}
let chunk = Bytes::from_array(buf[:n])
let forward = @terminal.host_to_pty(chunk, bridge.terminal)
if forward.length() > 0 && bridge.pty is Some(pty) {
pty.write(forward) catch {
err if @async.is_being_cancelled() => raise err
_ => @log.error("local stdin -> pty write failed")
}
}
}
@log.info("local_stdin_task: exited")
}
///|
/// Move verbose runtime logs to the default file sink once the local
/// tty has entered raw mode so PTY traffic owns the terminal. Failures
/// here are non-fatal and continue on the current sink.
async fn maybe_redirect_log_to_file() -> Unit {
let log_path = @log.default_log_path()
try {
@log.ensure_log_dir_for(log_path)
@log.set_output_path(log_path, append=true)
} catch {
err if @async.is_being_cancelled() => raise err
err =>
@log.warn(
"Failed to prepare log file: \{@debug.to_string(err)}; continuing with current log sink",
)
}
}
///|
/// Bridge setup: now that the relay session is established, save
/// termios, set raw mode, redirect verbose logs to the file sink, spawn
/// the PTY-backed child, then run the active session and reconnect
/// loop.
async fn run_bridge(
config : ConnectConfig,
credential : CredentialStore,
child_argv : Array[String],
first_ws : @ws.Conn,
) -> Unit {
let in_fd = @async/stdio.stdin.fd()
let out_fd = @async/stdio.stdout.fd()
let saved = @tty.save_termios(in_fd, out_fd)
@tty.set_raw(saved)
maybe_redirect_log_to_file()
@tty.arm_exit_restore(saved)
defer (@tty.restore(saved) catch {
@tty.TermiosSetFailed(errno~) =>
@log.warn("tty restore failed: \{errno.code()} (\{errno.strerror()})")
error => @log.warn("tty restore failed: \{error.to_repr()}")
})
let terminal = @terminal.new() catch {
err => {
@log.error("terminal init failed: \{err}")
return
}
}
// Let the backend write any one-shot host-state setup it needs
// before the bridge starts forwarding bytes. The defer runs before
// the tty restore defer thanks to LIFO ordering, so we're still in
// raw mode when the teardown bytes are emitted.
@terminal.host_setup(terminal)
defer @terminal.host_teardown(terminal)
let bridge = BridgeState::new(terminal)
// The relay has already accepted the session, so it is now safe to
// take over the local tty and spawn the PTY-backed child. The PTY is
// fixed at 80x24 for the life of the session — no SIGWINCH handling,
// no client-driven resize. Remote clients render this grid at
// whatever font size / CSS scale fits their viewport.
let pty = @pty.Pty::spawn(child_argv, cols=80, rows=24) catch {
@pty.SpawnFailed(msg) => {
@log.error("Failed to spawn child: \{msg}")
return
}
err => {
@log.error("Failed to spawn child: \{err.to_repr()}")
return
}
}
bridge.pty = Some(pty)
defer {
bridge.pty = None
pty.close()
}
@log.info("spawned child pid=\{pty.pid()} size=80x24")
@async.with_task_group(async fn(group) {
group.spawn_bg(no_wait=true, async fn() { local_stdin_task(bridge) })
// pty_reader runs persistently — tees to local stdout always,
// broadcasts to remote clients only when a relay session is
// active. Spawning it here (instead of inside run_session) means
// the local terminal works the moment tun-server starts,
// regardless of relay state.
group.spawn_bg(no_wait=true, async fn() { pty_reader(bridge, pty) })
run_retry_loop(config, credential, bridge, first_ws~)
}) catch {
ShellExited => @log.info("shell exited, server shutting down")
err => raise err
}
}