From c06908b2aef8a97f5df3001c0155e2b08a0b492a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:41:11 +0530 Subject: [PATCH 1/3] fix(ios): serve terminals from the host instead of AXS in the emulator On iOS, AXS ran inside the Linux emulator, so every keystroke went through emulated sockets, epoll, tokio threads and a PTY. Terminals broke after the app was backgrounded because the emulator's socket restart hooks were never called. - Add a tty driver (Bridge/AlpineTerminal.c) that connects guest /dev/pts slaves directly to Swift via pty_open_fake, as iSH does. Only bash runs emulated; job control, termios and SIGWINCH keep working. - Serve the AXS HTTP/WebSocket API (/terminals, resize, terminate, status, execute-command) from Swift on 127.0.0.1:8767 so the JS terminal and plugins are unchanged. Output is coalesced like AXS, 256 KB scrollback is replayed on reattach, and writers are paused when the client falls behind. - Call sockrestart on suspend/resume and restart the listener on foreground. - Run init-alpine.sh --prepare once per boot instead of per AXS start, check required packages with one apk call, and skip the Android-only storage check in bash that forked two subshells per command. - Stream language server output directly from the pipe on its own queue instead of through AlpineRuntime.queue. - Fix LocalHTTPServer loopback listeners failing with EINVAL, which also affected EmbeddedProxyServer. - Remove stopped spawnStream servers instead of keeping them forever. Co-Authored-By: Claude Opus 5.5 --- .../app/src/main/assets/init-alpine.sh | 50 +++-- platforms/ios/Alpine/Bridge/AlpineRuntime.c | 41 +++- platforms/ios/Alpine/Bridge/AlpineSpawn.h | 5 + platforms/ios/Alpine/Bridge/AlpineTerminal.c | 107 +++++++++++ platforms/ios/Alpine/README.md | 27 ++- .../Alpine/Vendor/ios-linuxkit/meson.build | 3 +- platforms/ios/Alpine/include/AcodeAlpine.h | 19 ++ .../lib/preview/LocalHTTPConnection.swift | 16 ++ .../runner/lib/preview/LocalHTTPServer.swift | 9 +- .../runner/lib/preview/LocalWebSocket.swift | 125 +++++++++++++ .../runner/lib/terminal/AlpineProcess.swift | 5 +- .../runner/lib/terminal/AlpineRuntime.swift | 122 ++++++++++-- .../runner/lib/terminal/AlpineService.swift | 16 +- .../lib/terminal/AlpineStreamServer.swift | 63 ++++--- .../runner/lib/terminal/ExecutorService.swift | 3 + .../runner/lib/terminal/TerminalServer.swift | 176 ++++++++++++++++++ .../runner/lib/terminal/TerminalSession.swift | 175 +++++++++++++++++ .../ios/runnerTests/AlpineTerminalTests.swift | 44 ++++- 18 files changed, 912 insertions(+), 94 deletions(-) create mode 100644 platforms/ios/Alpine/Bridge/AlpineSpawn.h create mode 100644 platforms/ios/Alpine/Bridge/AlpineTerminal.c create mode 100644 platforms/ios/runner/lib/preview/LocalWebSocket.swift create mode 100644 platforms/ios/runner/lib/terminal/TerminalServer.swift create mode 100644 platforms/ios/runner/lib/terminal/TerminalSession.swift diff --git a/platforms/android/app/src/main/assets/init-alpine.sh b/platforms/android/app/src/main/assets/init-alpine.sh index 104df3e..81e5116 100644 --- a/platforms/android/app/src/main/assets/init-alpine.sh +++ b/platforms/android/app/src/main/assets/init-alpine.sh @@ -6,6 +6,7 @@ export TERM=xterm-256color INSTALLING=false FAILSAFE=false +PREPARE=false # Parse internal flags while [ $# -gt 0 ]; do @@ -18,6 +19,10 @@ while [ $# -gt 0 ]; do FAILSAFE=true shift ;; + --prepare) + PREPARE=true + shift + ;; --) shift break @@ -37,10 +42,13 @@ fi required_packages="bash command-not-found tzdata wget" missing_packages="" +installed_packages=" $(apk info -e $required_packages 2>/dev/null | tr '\n' ' ') " + for pkg in $required_packages; do - if ! apk info -e "$pkg" >/dev/null 2>&1; then - missing_packages="$missing_packages $pkg" - fi + case "$installed_packages" in + *" $pkg "*) ;; + *) missing_packages="$missing_packages $pkg" ;; + esac done if [ -n "$missing_packages" ]; then @@ -289,24 +297,27 @@ _acode_preexec() { check_binary_execution "$cmd" } -# Preserve any existing DEBUG trap and append our handler instead of overwriting it. -# This avoids clobbering user-installed preexec hooks (starship, fzf, bash-preexec, etc.). -__acode_existing_debug_trap="$(trap -p DEBUG 2>/dev/null)" -if [[ -n "${__acode_existing_debug_trap}" ]]; then - __acode_existing_cmd="$(printf "%s" "${__acode_existing_debug_trap}" | sed -E "s/.*'((.*)?)'.*/\1/")" -else - __acode_existing_cmd="" -fi - -# Only add our handler if it's not already present -if [[ "${__acode_existing_cmd}" != *"_acode_preexec"* ]]; then - if [[ -n "${__acode_existing_cmd}" ]]; then - trap "${__acode_existing_cmd}; _acode_preexec" DEBUG +# Only Android storage needs the check; it costs two subshells per command. +if [ -d /sdcard ] || [ -d /storage ]; then + # Preserve any existing DEBUG trap and append our handler instead of overwriting it. + # This avoids clobbering user-installed preexec hooks (starship, fzf, bash-preexec, etc.). + __acode_existing_debug_trap="$(trap -p DEBUG 2>/dev/null)" + if [[ -n "${__acode_existing_debug_trap}" ]]; then + __acode_existing_cmd="$(printf "%s" "${__acode_existing_debug_trap}" | sed -E "s/.*'((.*)?)'.*/\1/")" else - trap '_acode_preexec' DEBUG + __acode_existing_cmd="" + fi + + # Only add our handler if it's not already present + if [[ "${__acode_existing_cmd}" != *"_acode_preexec"* ]]; then + if [[ -n "${__acode_existing_cmd}" ]]; then + trap "${__acode_existing_cmd}; _acode_preexec" DEBUG + else + trap '_acode_preexec' DEBUG + fi fi + unset __acode_existing_debug_trap __acode_existing_cmd fi -unset __acode_existing_debug_trap __acode_existing_cmd # Command-not-found handler command_not_found_handle() { @@ -352,7 +363,8 @@ fi chmod +x "$ALPINE_ROOT/initrc" -if [ "$FAILSAFE" != true ]; then +# --prepare only sets up the environment; the caller serves the terminals +if [ "$FAILSAFE" != true ] && [ "$PREPARE" != true ]; then #everytime a terminal is started initrc will run "$PREFIX/axs" -c "bash --rcfile /initrc -i" fi diff --git a/platforms/ios/Alpine/Bridge/AlpineRuntime.c b/platforms/ios/Alpine/Bridge/AlpineRuntime.c index be8f06d..f24edba 100644 --- a/platforms/ios/Alpine/Bridge/AlpineRuntime.c +++ b/platforms/ios/Alpine/Bridge/AlpineRuntime.c @@ -11,11 +11,14 @@ #include "fs/devices.h" #include "fs/path.h" #include "fs/real.h" +#include "fs/sockrestart.h" +#include "AlpineSpawn.h" extern int fakefs_bind_mount(const char *, const char *, bool); extern int do_wait(int, pid_t_, struct siginfo_ *, struct rusage_ *, int); static struct task *init_task; static AlpineExitCallback on_exit; +static int attach_descriptors(void *argument); static void process_exit(struct task *task, int status); static struct fd *host_descriptor(int number); static void *failed_process(void *task); @@ -90,19 +93,23 @@ int alpine_chmod(const char *path, unsigned int mode) { } int alpine_start(const char *command, const char *environment, int input, int output, int error) { + int descriptors[] = {input, output, error}; + return alpine_spawn(command, environment, attach_descriptors, descriptors); +} + +int alpine_spawn(const char *command, const char *environment, int (*attach)(void *), void *argument) { current = init_task; int result = become_new_init_child(); if (result < 0) { current = NULL; return result; } - int descriptors[] = {input, output, error}; - for (int index = 0; index < 3; index++) { - current->files->files[index] = host_descriptor(descriptors[index]); + result = attach(argument); + if (result >= 0) { + size_t length = strlen(command); + char *arguments = calloc(1, length + 14); + memcpy(arguments, "/bin/sh\0-c\0", 11); + memcpy(arguments + 11, command, length); + result = do_execve("/bin/sh", 3, arguments, environment); + free(arguments); } - size_t length = strlen(command); - char *arguments = calloc(1, length + 14); - memcpy(arguments, "/bin/sh\0-c\0", 11); - memcpy(arguments + 11, command, length); - result = do_execve("/bin/sh", 3, arguments, environment); - free(arguments); int pid = current->pid; sigset_t wake, previous; sigemptyset(&wake); @@ -185,6 +192,22 @@ int alpine_unmount(void) { return result; } +void alpine_suspend(void) { + sockrestart_on_suspend(); +} + +void alpine_resume(void) { + sockrestart_on_resume(); +} + +static int attach_descriptors(void *argument) { + int *descriptors = argument; + for (int index = 0; index < 3; index++) { + current->files->files[index] = host_descriptor(descriptors[index]); + } + return 0; +} + static void process_exit(struct task *task, int status) { if (on_exit != NULL) on_exit(task->tgid, (status & 0x7f) ? 128 + (status & 0x7f) : status >> 8); } diff --git a/platforms/ios/Alpine/Bridge/AlpineSpawn.h b/platforms/ios/Alpine/Bridge/AlpineSpawn.h new file mode 100644 index 0000000..b79841a --- /dev/null +++ b/platforms/ios/Alpine/Bridge/AlpineSpawn.h @@ -0,0 +1,5 @@ +#pragma once + +// Runs `/bin/sh -c command` as a new child of PID 1. `attach` runs as the new +// task before exec to install its stdio; a negative result fails the spawn. +int alpine_spawn(const char *command, const char *environment, int (*attach)(void *), void *argument); diff --git a/platforms/ios/Alpine/Bridge/AlpineTerminal.c b/platforms/ios/Alpine/Bridge/AlpineTerminal.c new file mode 100644 index 0000000..94fb66a --- /dev/null +++ b/platforms/ios/Alpine/Bridge/AlpineTerminal.c @@ -0,0 +1,107 @@ +#include "AcodeAlpine.h" +#include +#include +#include "kernel/errno.h" +#include "kernel/init.h" +#include "kernel/signal.h" +#include "kernel/task.h" +#include "fs/devices.h" +#include "fs/tty.h" +#include "AlpineSpawn.h" + +// The guest side is a regular /dev/pts slave, so shells get job control, +// termios and SIGWINCH. The host side is this driver instead of a pty master, +// which keeps terminal I/O out of emulated sockets and polling. +struct AlpineTerminal { + struct tty *tty; + int rows; + int cols; + void *context; + AlpineTerminalOutput output; + AlpineTerminalRelease release; +}; + +static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking); +static void terminal_cleanup(struct tty *tty); +static int attach_terminal(void *argument); + +static const struct tty_driver_ops terminal_ops = { + .write = terminal_write, + .cleanup = terminal_cleanup, +}; +// pty_open_fake fills in the slot table and major number. +static struct tty_driver terminal_driver = {.ops = &terminal_ops}; + +int alpine_terminal_start(const char *command, const char *environment, int rows, int cols, + void *context, AlpineTerminalOutput output, AlpineTerminalRelease release, + AlpineTerminal **handle) { + *handle = NULL; + AlpineTerminal *terminal = calloc(1, sizeof(AlpineTerminal)); + if (terminal == NULL) { release(context); return _ENOMEM; } + *terminal = (AlpineTerminal) {.rows = rows, .cols = cols, .context = context, .output = output, .release = release}; + int pid = alpine_spawn(command, environment, attach_terminal, terminal); + if (terminal->tty == NULL) { + release(context); + free(terminal); + } else if (pid < 0) { + alpine_terminal_close(terminal); + } else { + *handle = terminal; + } + return pid; +} + +long alpine_terminal_input(AlpineTerminal *terminal, const char *data, size_t length) { + return tty_input(terminal->tty, data, length, false); +} + +void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols) { + struct tty *tty = terminal->tty; + lock(&tty->lock); + tty->winsize = (struct winsize_) {.row = rows, .col = cols}; + pid_t_ foreground = tty->fg_group; + unlock(&tty->lock); + if (foreground != 0) send_group_signal(foreground, SIGWINCH_, SIGINFO_NIL); +} + +void alpine_terminal_close(AlpineTerminal *terminal) { + struct tty *tty = terminal->tty; + lock(&tty->lock); + pid_t_ session = tty->session; + pid_t_ foreground = tty->fg_group; + tty_hangup(tty); + unlock(&tty->lock); + // Matches Linux when the master side of a pty goes away. + if (foreground != 0) send_group_signal(foreground, SIGHUP_, SIGINFO_NIL); + if (session != 0 && session != foreground) send_group_signal(session, SIGHUP_, SIGINFO_NIL); + lock(&ttys_lock); + tty_release(tty); + unlock(&ttys_lock); +} + +// Runs on guest threads. The tty lock may be held (echo), so it must not re-enter the tty. +static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking) { + AlpineTerminal *terminal = tty->data; + terminal->output(terminal->context, data, length, blocking); + return 0; +} + +// Runs once the host and every guest descriptor have released the tty. +static void terminal_cleanup(struct tty *tty) { + AlpineTerminal *terminal = tty->data; + terminal->release(terminal->context); + free(terminal); +} + +// Runs as the new session leader, so opening the slave makes it the controlling tty. +static int attach_terminal(void *argument) { + AlpineTerminal *terminal = argument; + struct tty *tty = pty_open_fake(&terminal_driver); + if (IS_ERR(tty)) return (int) PTR_ERR(tty); + tty->data = terminal; + tty->winsize = (struct winsize_) {.row = terminal->rows, .col = terminal->cols}; + terminal->tty = tty; + char path[32]; + snprintf(path, sizeof(path), "/dev/pts/%d", tty->num); + return create_stdio(path, TTY_PSEUDO_SLAVE_MAJOR, tty->num); +} diff --git a/platforms/ios/Alpine/README.md b/platforms/ios/Alpine/README.md index 3c7dd1a..2100d1c 100644 --- a/platforms/ios/Alpine/README.md +++ b/platforms/ios/Alpine/README.md @@ -2,9 +2,26 @@ The existing Acode xterm UI connects to the same AXS HTTP/WebSocket API used on Android. ARM64 Linux programs run inside ios-linuxkit's interpreter; they are not -launched as unsigned iOS executables. The native Executor services use guest -processes and pipes. Apple Network.framework supplies the raw process WebSocket -transport for `Executor.spawnStream`. +launched as unsigned iOS executables. + +On iOS that API is served by the app, not by AXS inside the guest. Only the +shells are emulated: + +``` +xterm.js ──ws/http──▶ TerminalServer (Swift, 127.0.0.1:8767) + └─ TerminalSession ──▶ Bridge/AlpineTerminal.c ──▶ /dev/pts/N ──▶ bash +``` + +`AlpineTerminal.c` registers a tty driver whose output goes straight to Swift +and whose input, resize and hangup go straight to the guest tty. Terminal I/O +therefore never uses emulated sockets, epoll or threads, and the guest pty still +provides job control, termios and `SIGWINCH`. `init-alpine.sh --prepare` runs +once per boot; each terminal then starts `bash --rcfile /initrc -i` directly. +The AXS binary stays in the guest as `axs` for users and plugins. + +The native Executor services use guest processes and pipes. Apple +Network.framework supplies the raw process WebSocket transport for +`Executor.spawnStream`. ## Sources @@ -55,6 +72,10 @@ SIGUSR1` in Xcode's debug console, then `continue`. - `Bridge/AlpineRuntime.c` owns guest init, stdio, process cleanup and mounts. SIGUSR1 is unblocked only while creating guest threads, then the host worker's signal mask is restored. +- `Bridge/AlpineTerminal.c` connects guest pseudo-terminals to Swift through + `pty_open_fake`, as iSH does for its terminal view. +- `alpine_suspend`/`alpine_resume` run upstream `sockrestart` when the app moves + to the background and back, so guest servers keep their listening sockets. The guest root lives at `Library/Alpine`, outside every host bind mount. `/public`, `/home` and `/root` share Acode's Terminal Public directory. `/acode` maps app data, diff --git a/platforms/ios/Alpine/Vendor/ios-linuxkit/meson.build b/platforms/ios/Alpine/Vendor/ios-linuxkit/meson.build index bcd7333..83ff803 100644 --- a/platforms/ios/Alpine/Vendor/ios-linuxkit/meson.build +++ b/platforms/ios/Alpine/Vendor/ios-linuxkit/meson.build @@ -320,7 +320,8 @@ if meson.is_cross_build() include_directories: [include_directories('deps'), include_directories('deps/libarchive/libarchive')], c_args: ['-DHAVE_CONFIG_H', '-I' + meson.get_external_property('sdk_path') + '/usr/include/libxml2', '-w']) acode_alpine = static_library('acode_alpine', - ['../../Bridge/AlpineRuntime.c', '../../Bridge/AlpineFaults.c', '../../Bridge/AlpineArchive.c', 'tools/fakefs.c'], + ['../../Bridge/AlpineRuntime.c', '../../Bridge/AlpineTerminal.c', '../../Bridge/AlpineFaults.c', + '../../Bridge/AlpineArchive.c', 'tools/fakefs.c'], include_directories: [includes, include_directories('deps/libarchive/libarchive')], c_args: ['-I' + meson.project_source_root() + '/../../include']) endif diff --git a/platforms/ios/Alpine/include/AcodeAlpine.h b/platforms/ios/Alpine/include/AcodeAlpine.h index 90a9111..6b86048 100644 --- a/platforms/ios/Alpine/include/AcodeAlpine.h +++ b/platforms/ios/Alpine/include/AcodeAlpine.h @@ -3,6 +3,9 @@ #include typedef void (*AlpineExitCallback)(int pid, int status); +typedef struct AlpineTerminal AlpineTerminal; +typedef void (*AlpineTerminalOutput)(void *context, const char *data, size_t length, bool blocking); +typedef void (*AlpineTerminalRelease)(void *context); // Calls that access guest state are serialized by AlpineRuntime.queue. int alpine_boot(const char *root, AlpineExitCallback callback); @@ -17,3 +20,19 @@ bool alpine_idle(void); int alpine_unmount(void); bool alpine_import(const char *archive, const char *root, char *error, size_t capacity); void alpine_install_fault_handlers(void); +// Recreate guest listening sockets that iOS reclaims while the app is suspended. +void alpine_suspend(void); +void alpine_resume(void); + +// Runs a command on a new pseudo-terminal and returns its pid. The context is +// owned by the terminal from this call on: `release` runs once the terminal is +// gone, including when starting fails. `output` runs on guest threads and may +// block the writer when `blocking` is true. +int alpine_terminal_start(const char *command, const char *environment, int rows, int cols, + void *context, AlpineTerminalOutput output, AlpineTerminalRelease release, + AlpineTerminal **terminal); +// Input, resize and close are thread-safe and do not need AlpineRuntime.queue. +long alpine_terminal_input(AlpineTerminal *terminal, const char *data, size_t length); +void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols); +// Hangs up the session and drops the host's reference; the handle is invalid afterwards. +void alpine_terminal_close(AlpineTerminal *terminal); diff --git a/platforms/ios/runner/lib/preview/LocalHTTPConnection.swift b/platforms/ios/runner/lib/preview/LocalHTTPConnection.swift index ec58dfc..f1c009d 100644 --- a/platforms/ios/runner/lib/preview/LocalHTTPConnection.swift +++ b/platforms/ios/runner/lib/preview/LocalHTTPConnection.swift @@ -74,6 +74,22 @@ final class LocalHTTPConnection { send(body) { [weak self] in self?.end() } } + /// Sends `101 Switching Protocols` and hands the socket to the new protocol, which owns it from then on. + func upgrade(headers: [String: String]) -> NWConnection? { + guard !started, !finished else { return nil } + started = true + finished = true + deadline?.cancel() + var text = "HTTP/1.1 101 Switching Protocols\r\n" + for (name, value) in headers { text += "\(name): \(value)\r\n" } + connection.send(content: Data((text + "\r\n").utf8), completion: .contentProcessed { _ in }) + connection.stateUpdateHandler = nil + onClose?() + onClose = nil + onRequest = nil + return connection + } + func sendFile(_ file: FileHandle, count: UInt64) { guard !finished, !headOnly, count > 0 else { try? file.close(); end(); return } do { diff --git a/platforms/ios/runner/lib/preview/LocalHTTPServer.swift b/platforms/ios/runner/lib/preview/LocalHTTPServer.swift index d82e11a..918277a 100644 --- a/platforms/ios/runner/lib/preview/LocalHTTPServer.swift +++ b/platforms/ios/runner/lib/preview/LocalHTTPServer.swift @@ -15,8 +15,13 @@ final class LocalHTTPServer { self.queue = queue let parameters = NWParameters.tcp parameters.allowLocalEndpointReuse = true - if loopback { parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: endpoint) } - listener = try NWListener(using: parameters, on: endpoint) + // A port passed to `on:` as well as in requiredLocalEndpoint fails with EINVAL. + if loopback { + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: endpoint) + listener = try NWListener(using: parameters) + } else { + listener = try NWListener(using: parameters, on: endpoint) + } listener.newConnectionLimit = 64 } diff --git a/platforms/ios/runner/lib/preview/LocalWebSocket.swift b/platforms/ios/runner/lib/preview/LocalWebSocket.swift new file mode 100644 index 0000000..e720bd7 --- /dev/null +++ b/platforms/ios/runner/lib/preview/LocalWebSocket.swift @@ -0,0 +1,125 @@ +import CryptoKit +import Foundation +import Network + +/// Server side of RFC 6455 on a socket upgraded by `LocalHTTPConnection`. Not thread-safe; use its queue. +final class LocalWebSocket { + private static let maximumMessage = 16 * 1024 * 1024 + private let connection: NWConnection + private var buffer = Data() + private var message = Data() + private var messageIsText = false + private var closed = false + var onMessage: ((Data, Bool) -> Void)? + var onClose: (() -> Void)? + + static func accept(_ request: LocalHTTPRequest, client: LocalHTTPConnection) -> LocalWebSocket? { + guard request.method == "GET", request.headers["upgrade"]?.lowercased() == "websocket", + let key = request.headers["sec-websocket-key"], + let connection = client.upgrade(headers: ["Upgrade": "websocket", "Connection": "Upgrade", + "Sec-WebSocket-Accept": acceptKey(key)]) else { return nil } + return LocalWebSocket(connection) + } + + private init(_ connection: NWConnection) { + self.connection = connection + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .failed, .cancelled: self?.close() + default: break + } + } + receive() + } + + func send(_ payload: Data, text: Bool = false, completion: @escaping () -> Void = {}) { + guard !closed else { return } + connection.send(content: Self.frame(text ? 0x1 : 0x2, payload), completion: .contentProcessed { [weak self] error in + if error != nil { self?.close(); return } + completion() + }) + } + + func close() { + guard !closed else { return } + closed = true + connection.send(content: Self.frame(0x8, Data([0x03, 0xE8])), completion: .contentProcessed { [connection] _ in + connection.cancel() + }) + let handler = onClose + onClose = nil + onMessage = nil + handler?() + } + + private func receive() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, complete, error in + guard let self, !self.closed else { return } + if let data { buffer.append(data) } + do { + while !closed, let frame = try nextFrame() { handle(frame) } + } catch { close(); return } + if error != nil || complete { close(); return } + if !closed { receive() } + } + } + + private func handle(_ frame: (opcode: UInt8, final: Bool, payload: Data)) { + switch frame.opcode { + case 0x0, 0x1, 0x2: + if frame.opcode != 0x0 { message = Data(); messageIsText = frame.opcode == 0x1 } + message.append(frame.payload) + guard message.count <= Self.maximumMessage else { close(); return } + if frame.final { onMessage?(message, messageIsText); message = Data() } + case 0x8: close() + case 0x9: connection.send(content: Self.frame(0xA, frame.payload), completion: .contentProcessed { _ in }) + default: break + } + } + + private func nextFrame() throws -> (opcode: UInt8, final: Bool, payload: Data)? { + let head = [UInt8](buffer.prefix(14)) + guard head.count >= 2 else { return nil } + var length = Int(head[1] & 0x7F) + var offset = 2 + if length == 126 { + guard head.count >= 4 else { return nil } + length = Int(head[2]) << 8 | Int(head[3]) + offset = 4 + } else if length == 127 { + guard head.count >= 10 else { return nil } + guard head[2] & 0x80 == 0 else { throw LocalHTTPError(400) } + length = head[2..<10].reduce(0) { $0 << 8 | Int($1) } + offset = 10 + } + guard length <= Self.maximumMessage else { throw LocalHTTPError(413) } + let masked = head[1] & 0x80 != 0 + let start = offset + (masked ? 4 : 0) + guard buffer.count >= start + length else { return nil } + var payload = [UInt8](buffer[(buffer.startIndex + start)..<(buffer.startIndex + start + length)]) + if masked { + let mask = [UInt8](buffer[(buffer.startIndex + offset)..<(buffer.startIndex + offset + 4)]) + for index in payload.indices { payload[index] ^= mask[index & 3] } + } + buffer = Data(buffer.dropFirst(start + length)) + return (head[0] & 0x0F, head[0] & 0x80 != 0, Data(payload)) + } + + private static func frame(_ opcode: UInt8, _ payload: Data) -> Data { + var frame = Data([0x80 | opcode]) + if payload.count < 126 { + frame.append(UInt8(payload.count)) + } else if payload.count <= 0xFFFF { + frame.append(contentsOf: [126, UInt8(payload.count >> 8), UInt8(payload.count & 0xFF)]) + } else { + frame.append(127) + for shift in stride(from: 56, through: 0, by: -8) { frame.append(UInt8((payload.count >> shift) & 0xFF)) } + } + frame.append(payload) + return frame + } + + private static func acceptKey(_ key: String) -> String { + Data(Insecure.SHA1.hash(data: Data((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").utf8))).base64EncodedString() + } +} diff --git a/platforms/ios/runner/lib/terminal/AlpineProcess.swift b/platforms/ios/runner/lib/terminal/AlpineProcess.swift index c387ff4..88f925e 100644 --- a/platforms/ios/runner/lib/terminal/AlpineProcess.swift +++ b/platforms/ios/runner/lib/terminal/AlpineProcess.swift @@ -27,10 +27,14 @@ final class AlpineProcess { "startedAt": startedAt, "background": background] } + /// Line output and exit bookkeeping run on `queue`; raw stdout goes straight + /// from the pipe's reader thread so streaming never waits behind guest calls. func observe(on queue: DispatchQueue) { for (kind, pipe) in [("stdout", output), ("stderr", errors)] { + let raw = kind == "stdout" ? rawOutput : nil pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData + if let raw, !data.isEmpty { raw(data); return } if data.isEmpty { handle.readabilityHandler = nil } queue.async { self?.receive(data, kind: kind) } } @@ -53,7 +57,6 @@ final class AlpineProcess { } private func receive(_ data: Data, kind: String) { - if kind == "stdout", let rawOutput, !data.isEmpty { rawOutput(data); return } pending[kind, default: Data()].append(data) // Executor uses lines on Android. Buffering also preserves split UTF-8 sequences. while let end = pending[kind]!.firstIndex(of: 10) { diff --git a/platforms/ios/runner/lib/terminal/AlpineRuntime.swift b/platforms/ios/runner/lib/terminal/AlpineRuntime.swift index e493291..16d650b 100644 --- a/platforms/ios/runner/lib/terminal/AlpineRuntime.swift +++ b/platforms/ios/runner/lib/terminal/AlpineRuntime.swift @@ -1,14 +1,18 @@ import AcodeAlpine import Foundation +import UIKit final class AlpineRuntime { static let shared = AlpineRuntime() let queue = DispatchQueue(label: "app.acode.alpine", qos: .userInitiated) let files = AppFiles.shared private(set) var processes: [String: AlpineProcess] = [:] + private var terminals: [Int32: TerminalSession] = [:] private var booted = false + private var prepared = false + private var preparing: [() -> Void]? + private var suspended = false private(set) var maintaining = false - var serverID: String? private var sharedPaths = Set() // The guest root must sit outside every host bind mount. Otherwise the @@ -23,6 +27,17 @@ final class AlpineRuntime { "PYTHONMALLOC=malloc", "PYTHONDONTWRITEBYTECODE=1"].joined(separator: "\0") + "\0\0" } + private init() { + // Guest servers keep listening across suspension; iOS reclaims their host sockets meanwhile. + let center = NotificationCenter.default + center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + self?.suspend() + } + center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in + self?.resume() + } + } + func extract() throws { guard !FileManager.default.fileExists(atPath: root.path) else { return } let staging = root.appendingPathExtension("installing") @@ -63,15 +78,14 @@ final class AlpineRuntime { @discardableResult func start(_ command: String, background: Bool = false, listener: ((String, String) -> Void)? = nil, + rawOutput: ((Data) -> Void)? = nil, completion: ((Int32, String, String) -> Void)? = nil) throws -> AlpineProcess { - guard !maintaining else { throw failure("Alpine maintenance is in progress") } - try boot() - try shareFiles() + try ready() let process = AlpineProcess(command: command, background: background) process.listener = listener + process.rawOutput = rawOutput process.completion = completion - let script = "cd /public; " + command - let pid = script.withCString { script in + let pid = shellScript(command).withCString { script in environment.withCString { env in alpine_start(script, env, process.input.fileHandleForReading.fileDescriptor, process.output.fileHandleForWriting.fileDescriptor, process.errors.fileHandleForWriting.fileDescriptor) @@ -85,6 +99,48 @@ final class AlpineRuntime { return process } + /// Starts an interactive command on a guest pseudo-terminal whose I/O runs on `terminalQueue`. + func startTerminal(_ command: String, rows: Int, cols: Int, queue terminalQueue: DispatchQueue) throws -> TerminalSession { + try ready() + let session = TerminalSession(queue: terminalQueue) + let context = Unmanaged.passRetained(session).toOpaque() + var handle: OpaquePointer? + let pid = shellScript(command).withCString { script in + environment.withCString { env in + alpine_terminal_start(script, env, Int32(rows), Int32(cols), context, terminalOutput, terminalRelease, &handle) + } + } + try check(pid) + guard let handle else { throw failure("Could not open a terminal") } + session.started(pid: pid, handle: handle) + terminals[pid] = session + return session + } + + /// Kills a hung-up shell that ignored SIGHUP once it had a chance to exit. + func killTerminal(_ session: TerminalSession) { + queue.asyncAfter(deadline: .now() + 1) { [self] in + if terminals[session.pid] === session { _ = alpine_kill(session.pid) } + } + } + + /// Runs the shared setup script once per boot so each shell only has to start bash. + func prepare(_ completion: @escaping () -> Void) { + if prepared { completion(); return } + guard preparing == nil else { preparing?.append(completion); return } + preparing = [completion] + do { + try start("exec /bin/sh /acode/init-alpine.sh --prepare", completion: { [self] status, _, errors in + if status == 0 { prepared = true } + else { print("Alpine setup exited with status \(status): \(errors)") } + finishPreparing() + }) + } catch { + print("Alpine setup failed: \(error.localizedDescription)") + finishPreparing() + } + } + func execute(_ command: String, callback: Callback) throws { try start(command, completion: { status, output, errors in if status == 0 { callback.success(output) } @@ -108,13 +164,40 @@ final class AlpineRuntime { waitForShutdown(deadline: Date().addingTimeInterval(10), completion: completion) } + func shareFiles() throws { + guard booted else { return } + for (name, url) in files.allRoots() where name != "application" { + for path in Set([url.path, url.resolvingSymlinksInPath().path]) { + if sharedPaths.contains(path) { continue } + try check(alpine_bind(path, url.path, false)) + sharedPaths.insert(path) + } + } + } + + private func ready() throws { + guard !maintaining else { throw failure("Alpine maintenance is in progress") } + try boot() + try shareFiles() + } + + private func shellScript(_ command: String) -> String { + "cd /public; " + command + } + + private func finishPreparing() { + let waiting = preparing ?? [] + preparing = nil + for completion in waiting { completion() } + } + private func waitForShutdown(deadline: Date, completion: @escaping (Error?) -> Void) { if alpine_idle() { do { try check(alpine_unmount()) booted = false + prepared = false sharedPaths.removeAll() - serverID = nil maintaining = false completion(nil) } catch { maintaining = false; completion(error) } @@ -127,23 +210,22 @@ final class AlpineRuntime { } private func didExit(pid: Int32, status: Int32) { - if let process = processes.values.first(where: { $0.pid == pid }) { - process.finish(status) - if process.id == serverID { serverID = nil } - } + terminals.removeValue(forKey: pid)?.finish(status) + if let process = processes.values.first(where: { $0.pid == pid }) { process.finish(status) } alpine_reap() processes = processes.filter { $0.value.status == nil || $0.value.completion != nil || $0.value.listener != nil } } - func shareFiles() throws { - guard booted else { return } - for (name, url) in files.allRoots() where name != "application" { - for path in Set([url.path, url.resolvingSymlinksInPath().path]) { - if sharedPaths.contains(path) { continue } - try check(alpine_bind(path, url.path, false)) - sharedPaths.insert(path) - } - } + private func suspend() { + guard !suspended else { return } + suspended = true + alpine_suspend() + } + + private func resume() { + guard suspended else { return } + suspended = false + alpine_resume() } private func check(_ result: Int32) throws { diff --git a/platforms/ios/runner/lib/terminal/AlpineService.swift b/platforms/ios/runner/lib/terminal/AlpineService.swift index d3dece4..c39587b 100644 --- a/platforms/ios/runner/lib/terminal/AlpineService.swift +++ b/platforms/ios/runner/lib/terminal/AlpineService.swift @@ -1,4 +1,3 @@ -import AcodeAlpine import Foundation final class AlpineService: BaseService { @@ -12,19 +11,20 @@ final class AlpineService: BaseService { case "isInstalled": callback.success(runtime.installed) case "isAxsRunning": try runtime.shareFiles() - callback.success(runtime.serverID.flatMap { runtime.processes[$0] }.map { alpine_running($0.pid) } ?? false) + callback.success(TerminalServer.shared.isRunning) case "install": try install(callback) case "backup", "restore", "uninstall", "clearBackup": try AlpineMaintenance.perform(action, callback: callback) case "startAxs": try runtime.shareFiles() - if let id = runtime.serverID, let process = runtime.processes[id], alpine_running(process.pid) { - callback.success(); return + let failsafe = args[safe: 0] as? Bool == true + let start = { + TerminalServer.shared.start(shell: failsafe ? "exec sh" : "exec bash --rcfile /initrc -i") { error in + if let error { callback.error(error.localizedDescription) } else { callback.success() } + } } - let command = args[safe: 0] as? Bool == true ? "exec /acode/axs -c sh" : "exec /bin/sh /acode/init-alpine.sh" - runtime.serverID = try runtime.start(command).id - callback.success() + if failsafe { start() } else { runtime.prepare(start) } case "stopAxs": - if let id = runtime.serverID { runtime.stop(id) } + TerminalServer.shared.stop() callback.success() default: callback.error("Unknown Alpine action: \(action)") } diff --git a/platforms/ios/runner/lib/terminal/AlpineStreamServer.swift b/platforms/ios/runner/lib/terminal/AlpineStreamServer.swift index a2884bd..9ef8bce 100644 --- a/platforms/ios/runner/lib/terminal/AlpineStreamServer.swift +++ b/platforms/ios/runner/lib/terminal/AlpineStreamServer.swift @@ -1,13 +1,18 @@ import Foundation import Network +/// Raw WebSocket transport for `Executor.spawnStream` (language servers). Socket +/// I/O runs on its own queue; only process start and kill touch the runtime queue. final class AlpineStreamServer { private let runtime = AlpineRuntime.shared + private let queue = DispatchQueue(label: "app.acode.alpine.stream", qos: .userInitiated) private let listener: NWListener private let command: [String] private let callback: Callback private var connection: NWConnection? private var process: AlpineProcess? + private var stopped = false + var onStop: (() -> Void)? init(command: [String], callback: Callback) throws { self.command = command @@ -25,19 +30,28 @@ final class AlpineStreamServer { guard let self else { return } switch state { case .ready: callback.success(Int(listener.port!.rawValue)) - case .failed(let error): callback.error(error.localizedDescription); stop() + case .failed(let error): callback.error(error.localizedDescription); shutdown() default: break } } listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) } - listener.start(queue: runtime.queue) + listener.start(queue: queue) } func stop() { + queue.async { self.shutdown() } + } + + private func shutdown() { + guard !stopped else { return } + stopped = true listener.cancel() connection?.cancel() - if let process { runtime.stop(process.id) } + if let process { runtime.queue.async { [runtime] in runtime.stop(process.id) } } process = nil + let handler = onStop + onStop = nil + handler?() } private func accept(_ connection: NWConnection) { @@ -46,37 +60,44 @@ final class AlpineStreamServer { connection.stateUpdateHandler = { [weak self] state in guard let self else { return } switch state { - case .ready: - do { - process = try runtime.start("exec " + command.map(shellQuote).joined(separator: " ") + " 2>&1") - process?.rawOutput = { [weak self] data in self?.send(data) } - process?.listener = { [weak self] kind, _ in if kind == "exit" { self?.stop() } } - receive() - } catch { stop() } - case .failed, .cancelled: stop() + case .ready: launch(on: connection) + case .failed, .cancelled: shutdown() default: break } } - connection.start(queue: runtime.queue) + connection.start(queue: queue) + } + + private func launch(on connection: NWConnection) { + let script = "exec " + command.map(shellQuote).joined(separator: " ") + " 2>&1" + let send = { [weak self, connection] (data: Data) in + let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) + let context = NWConnection.ContentContext(identifier: "stdout", metadata: [metadata]) + connection.send(content: data, contentContext: context, isComplete: true, completion: .contentProcessed { error in + if error != nil { self?.stop() } + }) + } + runtime.queue.async { [weak self, runtime] in + do { + let process = try runtime.start(script, listener: { kind, _ in if kind == "exit" { self?.stop() } }, rawOutput: send) + self?.queue.async { + guard let self, !self.stopped else { runtime.queue.async { runtime.stop(process.id) }; return } + self.process = process + self.receive() + } + } catch { self?.stop() } + } } private func receive() { connection?.receiveMessage { [weak self] data, context, _, error in guard let self else { return } let metadata = context?.protocolMetadata(definition: NWProtocolWebSocket.definition) as? NWProtocolWebSocket.Metadata - if error != nil || metadata?.opcode == .close { stop(); return } + if error != nil || metadata?.opcode == .close { shutdown(); return } if let data { try? process?.input.fileHandleForWriting.write(contentsOf: data) } receive() } } - - private func send(_ data: Data) { - let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) - let context = NWConnection.ContentContext(identifier: "stdout", metadata: [metadata]) - connection?.send(content: data, contentContext: context, isComplete: true, completion: .contentProcessed { [weak self] error in - if error != nil { self?.stop() } - }) - } } func shellQuote(_ value: String) -> String { diff --git a/platforms/ios/runner/lib/terminal/ExecutorService.swift b/platforms/ios/runner/lib/terminal/ExecutorService.swift index f9b5e2e..1bb0c0f 100644 --- a/platforms/ios/runner/lib/terminal/ExecutorService.swift +++ b/platforms/ios/runner/lib/terminal/ExecutorService.swift @@ -35,6 +35,9 @@ class ExecutorService: BaseService { guard let command = args[safe: 0] as? [String], !command.isEmpty else { throw terminalError("Command is required") } let server = try AlpineStreamServer(command: command, callback: callback) streams.append(server) + server.onStop = { [weak self, weak server] in + self?.runtime.queue.async { self?.streams.removeAll { $0 === server } } + } server.start() case "loadLibrary": throw terminalError("Loading native libraries directly from JavaScript is not supported.") default: callback.error("Unknown Executor action: \(action)") diff --git a/platforms/ios/runner/lib/terminal/TerminalServer.swift b/platforms/ios/runner/lib/terminal/TerminalServer.swift new file mode 100644 index 0000000..b11c17b --- /dev/null +++ b/platforms/ios/runner/lib/terminal/TerminalServer.swift @@ -0,0 +1,176 @@ +import Foundation +import UIKit + +/// Serves the AXS HTTP/WebSocket API from the host so terminal traffic never +/// passes through emulated sockets. Guest shells are the only emulated part. +final class TerminalServer { + static let shared = TerminalServer() + private static let port = 8767 + private static let origins: Set = ["https://localhost", "acode://localhost"] + private let queue = DispatchQueue(label: "app.acode.terminal", qos: .userInitiated) + private let runtime = AlpineRuntime.shared + private var server: LocalHTTPServer? + private var listening = false + private var shell = "" + private var sessions: [Int32: TerminalSession] = [:] + + private init() { + // iOS reclaims listening sockets of suspended apps; sessions outlive the listener. + NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.queue.async { self?.relisten() } + } + } + + var isRunning: Bool { queue.sync { listening } } + + func start(shell: String, completion: @escaping (Error?) -> Void) { + queue.async { [self] in + guard server == nil else { completion(nil); return } + self.shell = shell + listen(completion) + } + } + + func stop() { + queue.async { [self] in + for session in sessions.values { terminate(session) } + sessions.removeAll() + server?.stop() + server = nil + listening = false + } + } + + private func listen(_ completion: @escaping (Error?) -> Void = { _ in }) { + do { + let server = try LocalHTTPServer(port: Self.port, loopback: true, queue: queue) + self.server = server + server.onRequest = { [weak self] request, client in self?.route(request, client) } + server.start { [weak self, weak server] result in + guard let self, self.server === server else { return } + switch result { + case .success: + listening = true + completion(nil) + case .failure(let error): + self.server = nil + listening = false + completion(error) + } + } + } catch { completion(error) } + } + + private func relisten() { + guard let server else { return } + listening = false + server.stop { [weak self] in self?.queue.async { self?.listen() } } + } + + private func route(_ request: LocalHTTPRequest, _ client: LocalHTTPConnection) { + let path = request.target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? "" + let parts = path.split(separator: "/").map(String.init) + let origin = request.headers["origin"].flatMap { Self.origins.contains($0) ? $0 : nil } + let respond = { (status: Int, body: Any) in Self.respond(client, status: status, body: body, origin: origin) } + let session = parts.count >= 2 && parts[0] == "terminals" ? Int32(parts[1]).flatMap { sessions[$0] } : nil + switch (request.method, parts.count) { + case ("OPTIONS", _): respond(204, "") + case ("GET", 0): respond(200, "Acode terminal server") + case ("GET", 1) where parts[0] == "status": respond(200, "OK") + case ("POST", 1) where parts[0] == "terminals": create(request, respond) + case ("POST", 1) where parts[0] == "execute-command": execute(request, respond) + case ("GET", 2) where parts[0] == "terminals": + guard let session else { respond(404, ["error": "Session not found"]); return } + guard let socket = LocalWebSocket.accept(request, client: client) else { respond(400, ["error": "WebSocket upgrade required"]); return } + session.attach(socket) + case ("POST", 3) where parts[0] == "terminals" && parts[2] == "resize": + guard let session else { respond(404, ["error": "Session not found"]); return } + guard let size = Self.size(request.body) else { respond(400, ["error": "Invalid terminal size"]); return } + session.resize(rows: size.rows, cols: size.cols) + respond(200, ["success": true]) + case ("POST", 3) where parts[0] == "terminals" && parts[2] == "terminate": + guard let session else { respond(404, ["error": "Session not found"]); return } + sessions.removeValue(forKey: session.pid) + terminate(session) + respond(200, ["success": true]) + default: respond(404, ["error": "Not found"]) + } + } + + private func create(_ request: LocalHTTPRequest, _ respond: @escaping (Int, Any) -> Void) { + guard let size = Self.size(request.body) else { respond(400, ["error": "Invalid terminal size"]); return } + let shell = shell + runtime.queue.async { [self] in + do { + let session = try runtime.startTerminal(shell, rows: size.rows, cols: size.cols, queue: queue) + queue.async { [self] in + sessions[session.pid] = session + session.onFinish = { [weak self, weak session] in + if let session { self?.sessions.removeValue(forKey: session.pid) } + } + respond(200, String(session.pid)) + } + } catch { + queue.async { respond(500, ["error": error.localizedDescription]) } + } + } + } + + private func execute(_ request: LocalHTTPRequest, _ respond: @escaping (Int, Any) -> Void) { + let body = (try? JSONSerialization.jsonObject(with: request.body)) as? [String: Any] ?? [:] + guard let command = body["command"] as? String else { respond(400, ["output": "", "error": "Command is required"]); return } + let directory = (body["cwd"] as? String ?? body["u_cwd"] as? String).flatMap { $0.isEmpty ? nil : $0 } + let script = (directory.map { "cd \(shellQuote($0)) || exit 1; " } ?? "") + "{ \(command)\n} 2>&1" + var answered = false + let answer = { [queue] (status: Int, output: String, error: String?) in + queue.async { + guard !answered else { return } + answered = true + respond(status, ["output": Self.stripEscapes(output), "error": error.map { $0 as Any } ?? NSNull()]) + } + } + runtime.queue.async { [runtime] in + do { + let process = try runtime.start(script, completion: { _, output, _ in answer(200, output, nil) }) + runtime.queue.asyncAfter(deadline: .now() + 30) { + guard process.status == nil else { return } + runtime.stop(process.id) + answer(500, "", "Command execution timed out") + } + } catch { answer(500, "", error.localizedDescription) } + } + } + + private func terminate(_ session: TerminalSession) { + session.terminate() + runtime.killTerminal(session) + } + + private static func size(_ body: Data) -> (rows: Int, cols: Int)? { + let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:] + let value = { (key: String) -> Int? in + let number = (json[key] as? NSNumber)?.intValue ?? (json[key] as? String).flatMap { Int($0) } + return number.flatMap { (1...Int(UInt16.max)).contains($0) ? $0 : nil } + } + guard let rows = value("rows"), let cols = value("cols") else { return nil } + return (rows, cols) + } + + private static func respond(_ client: LocalHTTPConnection, status: Int, body: Any, origin: String?) { + var headers = ["Access-Control-Allow-Methods": "*", "Access-Control-Allow-Headers": "*"] + if let origin { headers["Access-Control-Allow-Origin"] = origin } + let data: Data + if let text = body as? String { + data = Data(text.utf8) + headers["Content-Type"] = "text/plain; charset=utf-8" + } else { + data = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + headers["Content-Type"] = "application/json" + } + client.respond(status: status, headers: headers, body: data) + } + + private static func stripEscapes(_ text: String) -> String { + text.replacingOccurrences(of: "\u{1B}\\[[0-9;?]*[A-Za-z]", with: "", options: .regularExpression) + } +} diff --git a/platforms/ios/runner/lib/terminal/TerminalSession.swift b/platforms/ios/runner/lib/terminal/TerminalSession.swift new file mode 100644 index 0000000..66ad06b --- /dev/null +++ b/platforms/ios/runner/lib/terminal/TerminalSession.swift @@ -0,0 +1,175 @@ +import AcodeAlpine +import Foundation + +/// One interactive shell on a guest pseudo-terminal. Output arrives on guest +/// threads; everything else runs on the terminal server's queue. +final class TerminalSession { + private static let scrollbackLimit = 256 * 1024 + private static let unsentLimit = 1024 * 1024 + private static let frameSize = 8 * 1024 + private static let coalesceDelay = DispatchTimeInterval.milliseconds(8) + private let queue: DispatchQueue + private let output = NSCondition() + private var scrollback = Data() + private var pending = Data() + private var unsent = 0 + private var attached = false + private var flushScheduled = false + private var socket: LocalWebSocket? + private var input = Data() + private var handle: OpaquePointer? + private var status: Int32? + private(set) var pid: Int32 = 0 + var onFinish: (() -> Void)? + + init(queue: DispatchQueue) { + self.queue = queue + } + + func started(pid: Int32, handle: OpaquePointer) { + self.pid = pid + self.handle = handle + } + + /// Replays recent output, then streams live output until the socket closes. + func attach(_ socket: LocalWebSocket) { + if let status { + socket.send(Self.exitMessage(status), text: true) { socket.close() } + onFinish?() + return + } + self.socket?.close() + output.lock() + let history = scrollback.suffix(Self.scrollbackLimit) + pending.removeAll() + unsent = 0 + attached = true + output.broadcast() + output.unlock() + self.socket = socket + socket.onMessage = { [weak self] data, _ in self?.write(data) } + socket.onClose = { [weak self, weak socket] in + guard let self, self.socket === socket else { return } + self.detach() + } + if !history.isEmpty { socket.send(Data(history)) } + } + + func resize(rows: Int, cols: Int) { + guard let handle else { return } + alpine_terminal_resize(handle, Int32(rows), Int32(cols)) + } + + func terminate() { + socket?.close() + close() + } + + /// Called by the runtime when the shell exits. + func finish(_ code: Int32) { + queue.async { [self] in + guard status == nil else { return } + status = code + close() + flush() + guard let socket else { return } + socket.send(Self.exitMessage(code), text: true) { socket.close() } + onFinish?() + } + } + + /// Runs on guest threads and blocks the writer while the client falls behind. + func receive(_ data: Data, blocking: Bool) { + output.lock() + scrollback.append(data) + if scrollback.count > Self.scrollbackLimit * 2 { + scrollback = Data(scrollback.suffix(Self.scrollbackLimit)) + } + guard attached else { output.unlock(); return } + pending.append(data) + unsent += data.count + let immediate = pending.count >= Self.frameSize + let schedule = !flushScheduled + flushScheduled = true + output.unlock() + if immediate { queue.async { self.flush() } } + else if schedule { queue.asyncAfter(deadline: .now() + Self.coalesceDelay) { self.flush() } } + guard blocking else { return } + output.lock() + while attached, unsent > Self.unsentLimit { + output.wait(until: Date().addingTimeInterval(0.1)) + } + output.unlock() + } + + private func flush() { + output.lock() + let data = pending + pending.removeAll(keepingCapacity: true) + flushScheduled = false + output.unlock() + guard !data.isEmpty, let socket else { return } + socket.send(data) { [weak self] in self?.acknowledge(data.count) } + } + + private func acknowledge(_ count: Int) { + output.lock() + unsent = max(0, unsent - count) + output.broadcast() + output.unlock() + } + + private func detach() { + socket = nil + output.lock() + attached = false + pending.removeAll() + unsent = 0 + output.broadcast() + output.unlock() + } + + private func write(_ data: Data) { + let idle = input.isEmpty + input.append(data) + if idle { drainInput() } + } + + // The guest line buffer holds 4 KiB, so large pastes are fed as the shell reads. + private func drainInput() { + guard let handle, !input.isEmpty else { input.removeAll(); return } + let written = input.withUnsafeBytes { bytes in + alpine_terminal_input(handle, bytes.bindMemory(to: CChar.self).baseAddress, bytes.count) + } + if written > 0 { input = Data(input.dropFirst(written)) } + guard !input.isEmpty else { return } + queue.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in self?.drainInput() } + } + + private func close() { + guard let handle else { return } + self.handle = nil + input.removeAll() + alpine_terminal_close(handle) + } + + private static func exitMessage(_ code: Int32) -> Data { + let message: [String: Any] = [ + "type": "exit", + "data": ["exit_code": code, "signal": NSNull(), + "message": code == 0 ? "Process exited successfully" : "Process exited with non-zero status"], + ] + return (try? JSONSerialization.data(withJSONObject: message)) ?? Data() + } +} + +let terminalOutput: AlpineTerminalOutput = { context, data, length, blocking in + guard let context, let data else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue() + .receive(Data(bytes: data, count: length), blocking: blocking) +} + +let terminalRelease: AlpineTerminalRelease = { context in + guard let context else { return } + Unmanaged.fromOpaque(context).release() +} diff --git a/platforms/ios/runnerTests/AlpineTerminalTests.swift b/platforms/ios/runnerTests/AlpineTerminalTests.swift index a96b415..5afddbe 100644 --- a/platforms/ios/runnerTests/AlpineTerminalTests.swift +++ b/platforms/ios/runnerTests/AlpineTerminalTests.swift @@ -30,19 +30,43 @@ final class AlpineTerminalTests: BridgeTestCase { await new Promise(resolve => setTimeout(resolve, 100)); } if (!ready) throw new Error('AXS not ready'); - const pid = (await request('/terminals', {cols:80, rows:24})).data.trim(); - const output = await new Promise((resolve, reject) => { + const attach = pid => { const socket = new WebSocket('ws://127.0.0.1:8767/terminals/' + pid); + socket.binaryType = 'arraybuffer'; + const decoder = new TextDecoder(); + const waiters = []; let text = ''; - const timeout = setTimeout(() => { socket.close(); reject(new Error('PTY timeout: ' + text)); }, 15000); - socket.onopen = () => socket.send("printf 'PTY:%s\\n' \"$((7*8))\"\n"); - socket.onmessage = async event => { - text += typeof event.data === 'string' ? event.data : await event.data.text(); - if (text.includes('PTY:56')) { clearTimeout(timeout); socket.close(); resolve(text); } + socket.onmessage = event => { + text += typeof event.data === 'string' ? event.data : decoder.decode(event.data, {stream: true}); + for (const waiter of waiters.filter(waiter => text.includes(waiter.token))) waiter.resolve(text); }; - socket.onerror = () => { clearTimeout(timeout); reject(new Error('PTY connection failed')); }; - }); - await request('/terminals/' + pid + '/terminate', {}); + const opened = new Promise((resolve, reject) => { + socket.onopen = resolve; + socket.onerror = () => reject(new Error('PTY connection failed')); + }); + const expect = token => new Promise((resolve, reject) => { + if (text.includes(token)) { resolve(text); return; } + const timeout = setTimeout(() => reject(new Error('PTY timeout waiting for ' + token + ': ' + text)), 15000); + waiters.push({token, resolve: value => { clearTimeout(timeout); resolve(value); }}); + }); + return {socket, opened, expect}; + }; + const pid = (await request('/terminals', {cols:80, rows:24})).data.trim(); + const first = attach(pid); + await first.opened; + first.socket.send("printf 'PTY:%s\\n' \"$((7*8))\"\n"); + const output = await first.expect('PTY:56'); + await request('/terminals/' + pid + '/resize', {cols:100, rows:40}); + first.socket.send('stty size\n'); + await first.expect('40 100'); + first.socket.close(); + const second = attach(pid); + await second.opened; + await second.expect('PTY:56'); + second.socket.send('exit\n'); + await second.expect('"type":"exit"'); + const other = (await request('/terminals', {cols:80, rows:24})).data.trim(); + await request('/terminals/' + other + '/terminate', {}); const fs = acode.require('fs'); const directory = 'alpine://localhost/tmp/acode-files-test'; await Executor.execute('mkdir -p /tmp/acode-files-test', true); From 5e0c5bba8cb1022d416d5ac3a034a54add8efd33 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:41:11 +0530 Subject: [PATCH 2/3] fix(terminal): reattach to the PTY after an unexpected socket drop A dropped WebSocket (e.g. after the app is suspended) closed the terminal tab even though the shell was still running. Retry the same session with backoff; the server replays recent output, so the screen is reset first. The tab only closes once the session is really gone. Co-Authored-By: Claude Opus 5.5 --- .../runnerTests/AlpineInteractionTests.swift | 11 ++++- src/components/terminal/terminal.js | 45 ++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/platforms/ios/runnerTests/AlpineInteractionTests.swift b/platforms/ios/runnerTests/AlpineInteractionTests.swift index 820b414..2f7a536 100644 --- a/platforms/ios/runnerTests/AlpineInteractionTests.swift +++ b/platforms/ios/runnerTests/AlpineInteractionTests.swift @@ -46,6 +46,15 @@ final class AlpineInteractionTests: BridgeTestCase { await wait(first, 'READY:2'); input(first, 'hello\r'); await wait(first, 'INPUT:hello'); + const dropped = second.component.websocket; + dropped.close(); + for (let i = 0; i < 100 && (second.component.websocket === dropped || !second.component.isConnected); i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!second.component.isConnected) throw new Error('Terminal did not reconnect'); + await wait(second, 'STILL:second'); + input(second, 'printf "BACK:%s\\n" "$ACODE_CHECK"\r'); + await wait(second, 'BACK:second'); return {first:content(first), second:content(second)}; } finally { await manager.close(first.id); @@ -53,6 +62,6 @@ final class AlpineInteractionTests: BridgeTestCase { } """#, arguments: [:], in: nil, contentWorld: .page) as? [String: String] XCTAssertTrue(result?["first"]?.contains("SIGNAL:130") == true) - XCTAssertTrue(result?["second"]?.contains("STILL:second") == true) + XCTAssertTrue(result?["second"]?.contains("BACK:second") == true) } } diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index b5e6afe..9038170 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -29,6 +29,9 @@ import TerminalThemeManager from "./terminalThemeManager"; import TerminalTouchScrolling from "./terminalTouchScrolling"; import TerminalTouchSelection from "./terminalTouchSelection"; +// Backoff for re-attaching to a live PTY after the socket drops (e.g. app suspension). +const RECONNECT_DELAYS = [0, 500, 1500, 3000]; + export default class TerminalComponent { constructor(options = {}) { // Get terminal settings from shared defaults @@ -81,6 +84,7 @@ export default class TerminalComponent { // Lifecycle flags so exit/disconnect/error don't race into zombie tabs this.intentionalClose = false; this.processExited = false; + this.reconnectAttempts = 0; this.init(); } @@ -873,6 +877,8 @@ export default class TerminalComponent { }; websocket.onmessage = (event) => { + // Any frame (the server replays scrollback first) proves the session is alive. + this.reconnectAttempts = 0; // Lifecycle control (AXS exit JSON) is always a text frame. // Never decode binary frames as exit — ordinary PTY output can // contain the same bytes and must not close the session. @@ -905,12 +911,17 @@ export default class TerminalComponent { return; } - this.onDisconnect?.({ + const info = { intentional: this.intentionalClose, processExited: this.processExited, code: event?.code, reason: event?.reason, - }); + }; + if (info.intentional || info.processExited) { + this.onDisconnect?.(info); + return; + } + void this.reconnectToSession(info); }; websocket.onerror = (error) => { @@ -923,15 +934,39 @@ export default class TerminalComponent { return; } - // Ignore teardown noise from intentional close / already-handled exit + // A close event always follows; onclose decides whether to reconnect. if (this.intentionalClose || this.processExited) return; - console.error("WebSocket error:", error); - this.onError?.(error); }; }); } + /** + * Re-attach to the same PTY after an unexpected disconnect. The backend + * replays recent output, so the screen is reset before reconnecting. + * @param {object} info - Disconnect details forwarded if reconnecting fails + */ + async reconnectToSession(info) { + if (this.reconnectAttempts >= RECONNECT_DELAYS.length) { + this.onDisconnect?.(info); + return; + } + const delay = RECONNECT_DELAYS[this.reconnectAttempts++]; + await new Promise((resolve) => setTimeout(resolve, delay)); + if (this.intentionalClose || this.processExited) return; + + try { + this.attachAddon?.dispose(); + this.attachAddon = null; + this.terminal.reset(); + await this.connectToSession(this.pid); + if (this.intentionalClose) this.websocket?.close(); + } catch (error) { + console.error(`Failed to reconnect terminal ${this.pid}:`, error); + await this.reconnectToSession(info); + } + } + /** * Connect xterm to an interactive Maverick SSH shell. */ From 8fcd10704e23ced1d058d903a8d5408b7cb51068 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:02:15 +0530 Subject: [PATCH 3/3] fix(terminal): address review on backpressure, reconnect and execute-command - Park congested guest writers in C with the emulator's wait_for instead of an NSCondition in Swift. Pending guest signals (Ctrl-C, SIGKILL from unmount) now interrupt the wait with EINTR before any data is accepted, and closing a terminal releases waiting writers. - Keep the tab when reconnect attempts run out: mark it disconnected and retry on the next keypress or app resume. Only a process exit or an explicit close ends the session. - Run /execute-command on an 80x24 PTY like AXS, answer when the shell exits, return 400 for a missing cwd, and apply the 30 s deadline until the response is sent, terminating the session's process group on timeout. - Add AlpineTerminalServerTests (congested writer vs Ctrl-C, execute-command PTY and deadline) and extend the interaction test for kept tabs. Co-Authored-By: Claude Opus 5.5 --- platforms/ios/Alpine/Bridge/AlpineTerminal.c | 47 +++++-- platforms/ios/Alpine/include/AcodeAlpine.h | 22 ++-- .../runner/lib/terminal/AlpineRuntime.swift | 9 +- .../runner/lib/terminal/TerminalServer.swift | 47 +++++-- .../runner/lib/terminal/TerminalSession.swift | 79 +++++++---- .../runnerTests/AlpineInteractionTests.swift | 16 ++- .../AlpineTerminalServerTests.swift | 124 ++++++++++++++++++ src/components/terminal/terminal.js | 63 ++++++--- 8 files changed, 323 insertions(+), 84 deletions(-) create mode 100644 platforms/ios/runnerTests/AlpineTerminalServerTests.swift diff --git a/platforms/ios/Alpine/Bridge/AlpineTerminal.c b/platforms/ios/Alpine/Bridge/AlpineTerminal.c index 94fb66a..f7bf0f8 100644 --- a/platforms/ios/Alpine/Bridge/AlpineTerminal.c +++ b/platforms/ios/Alpine/Bridge/AlpineTerminal.c @@ -17,8 +17,10 @@ struct AlpineTerminal { int rows; int cols; void *context; - AlpineTerminalOutput output; - AlpineTerminalRelease release; + AlpineTerminalCallbacks callbacks; + lock_t lock; + cond_t drained; + bool closed; }; static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking); @@ -33,15 +35,17 @@ static const struct tty_driver_ops terminal_ops = { static struct tty_driver terminal_driver = {.ops = &terminal_ops}; int alpine_terminal_start(const char *command, const char *environment, int rows, int cols, - void *context, AlpineTerminalOutput output, AlpineTerminalRelease release, - AlpineTerminal **handle) { + void *context, AlpineTerminalCallbacks callbacks, AlpineTerminal **handle) { *handle = NULL; AlpineTerminal *terminal = calloc(1, sizeof(AlpineTerminal)); - if (terminal == NULL) { release(context); return _ENOMEM; } - *terminal = (AlpineTerminal) {.rows = rows, .cols = cols, .context = context, .output = output, .release = release}; + if (terminal == NULL) { callbacks.release(context); return _ENOMEM; } + *terminal = (AlpineTerminal) {.rows = rows, .cols = cols, .context = context, .callbacks = callbacks}; + lock_init(&terminal->lock); + cond_init(&terminal->drained); int pid = alpine_spawn(command, environment, attach_terminal, terminal); if (terminal->tty == NULL) { - release(context); + callbacks.release(context); + cond_destroy(&terminal->drained); free(terminal); } else if (pid < 0) { alpine_terminal_close(terminal); @@ -64,7 +68,17 @@ void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols) { if (foreground != 0) send_group_signal(foreground, SIGWINCH_, SIGINFO_NIL); } +void alpine_terminal_drained(AlpineTerminal *terminal) { + lock(&terminal->lock); + notify(&terminal->drained); + unlock(&terminal->lock); +} + void alpine_terminal_close(AlpineTerminal *terminal) { + lock(&terminal->lock); + terminal->closed = true; + notify(&terminal->drained); + unlock(&terminal->lock); struct tty *tty = terminal->tty; lock(&tty->lock); pid_t_ session = tty->session; @@ -80,16 +94,31 @@ void alpine_terminal_close(AlpineTerminal *terminal) { } // Runs on guest threads. The tty lock may be held (echo), so it must not re-enter the tty. +// A blocking writer waits like on a full pipe while the client is behind. wait_for +// returns _EINTR for pending guest signals, so Ctrl-C and SIGKILL still get through, +// and nothing has been written yet when it does. static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking) { AlpineTerminal *terminal = tty->data; - terminal->output(terminal->context, data, length, blocking); + if (blocking) { + int result = 0; + lock(&terminal->lock); + while (result == 0 && !terminal->closed && terminal->callbacks.congested(terminal->context)) { + struct timespec recheck = {.tv_nsec = 100 * 1000 * 1000}; + result = wait_for(&terminal->drained, &terminal->lock, &recheck); + if (result == _ETIMEDOUT) result = 0; + } + unlock(&terminal->lock); + if (result < 0) return result; + } + terminal->callbacks.output(terminal->context, data, length); return 0; } // Runs once the host and every guest descriptor have released the tty. static void terminal_cleanup(struct tty *tty) { AlpineTerminal *terminal = tty->data; - terminal->release(terminal->context); + terminal->callbacks.release(terminal->context); + cond_destroy(&terminal->drained); free(terminal); } diff --git a/platforms/ios/Alpine/include/AcodeAlpine.h b/platforms/ios/Alpine/include/AcodeAlpine.h index 6b86048..e6ff6a4 100644 --- a/platforms/ios/Alpine/include/AcodeAlpine.h +++ b/platforms/ios/Alpine/include/AcodeAlpine.h @@ -4,8 +4,13 @@ typedef void (*AlpineExitCallback)(int pid, int status); typedef struct AlpineTerminal AlpineTerminal; -typedef void (*AlpineTerminalOutput)(void *context, const char *data, size_t length, bool blocking); -typedef void (*AlpineTerminalRelease)(void *context); +// `output` and `congested` run on guest threads. `congested` tells blocking writers +// to wait until alpine_terminal_drained; `release` runs once the terminal is gone. +typedef struct { + void (*output)(void *context, const char *data, size_t length); + bool (*congested)(void *context); + void (*release)(void *context); +} AlpineTerminalCallbacks; // Calls that access guest state are serialized by AlpineRuntime.queue. int alpine_boot(const char *root, AlpineExitCallback callback); @@ -25,14 +30,13 @@ void alpine_suspend(void); void alpine_resume(void); // Runs a command on a new pseudo-terminal and returns its pid. The context is -// owned by the terminal from this call on: `release` runs once the terminal is -// gone, including when starting fails. `output` runs on guest threads and may -// block the writer when `blocking` is true. +// owned by the terminal from this call on, including when starting fails. int alpine_terminal_start(const char *command, const char *environment, int rows, int cols, - void *context, AlpineTerminalOutput output, AlpineTerminalRelease release, - AlpineTerminal **terminal); -// Input, resize and close are thread-safe and do not need AlpineRuntime.queue. + void *context, AlpineTerminalCallbacks callbacks, AlpineTerminal **terminal); +// Input, resize, drained and close are thread-safe and do not need AlpineRuntime.queue. long alpine_terminal_input(AlpineTerminal *terminal, const char *data, size_t length); void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols); -// Hangs up the session and drops the host's reference; the handle is invalid afterwards. +void alpine_terminal_drained(AlpineTerminal *terminal); +// Releases waiting writers, hangs up the session and drops the host's reference; +// the handle is invalid afterwards. void alpine_terminal_close(AlpineTerminal *terminal); diff --git a/platforms/ios/runner/lib/terminal/AlpineRuntime.swift b/platforms/ios/runner/lib/terminal/AlpineRuntime.swift index 16d650b..ce28dbd 100644 --- a/platforms/ios/runner/lib/terminal/AlpineRuntime.swift +++ b/platforms/ios/runner/lib/terminal/AlpineRuntime.swift @@ -99,15 +99,16 @@ final class AlpineRuntime { return process } - /// Starts an interactive command on a guest pseudo-terminal whose I/O runs on `terminalQueue`. - func startTerminal(_ command: String, rows: Int, cols: Int, queue terminalQueue: DispatchQueue) throws -> TerminalSession { + /// Starts a command on a guest pseudo-terminal whose I/O runs on `terminalQueue`. + func startTerminal(_ command: String, rows: Int, cols: Int, queue terminalQueue: DispatchQueue, + scrollbackLimit: Int = TerminalSession.replayLimit) throws -> TerminalSession { try ready() - let session = TerminalSession(queue: terminalQueue) + let session = TerminalSession(queue: terminalQueue, scrollbackLimit: scrollbackLimit) let context = Unmanaged.passRetained(session).toOpaque() var handle: OpaquePointer? let pid = shellScript(command).withCString { script in environment.withCString { env in - alpine_terminal_start(script, env, Int32(rows), Int32(cols), context, terminalOutput, terminalRelease, &handle) + alpine_terminal_start(script, env, Int32(rows), Int32(cols), context, terminalCallbacks, &handle) } } try check(pid) diff --git a/platforms/ios/runner/lib/terminal/TerminalServer.swift b/platforms/ios/runner/lib/terminal/TerminalServer.swift index b11c17b..85843ac 100644 --- a/platforms/ios/runner/lib/terminal/TerminalServer.swift +++ b/platforms/ios/runner/lib/terminal/TerminalServer.swift @@ -7,6 +7,7 @@ final class TerminalServer { static let shared = TerminalServer() private static let port = 8767 private static let origins: Set = ["https://localhost", "acode://localhost"] + private static let captureLimit = 16 * 1024 * 1024 private let queue = DispatchQueue(label: "app.acode.terminal", qos: .userInitiated) private let runtime = AlpineRuntime.shared private var server: LocalHTTPServer? @@ -116,28 +117,46 @@ final class TerminalServer { } } + /// Matches AXS: `sh -c` on an 80×24 PTY in `cwd` (default HOME), answered when the + /// shell exits or after 30 s. Pipe-based execution stays available as `Executor.execute`. private func execute(_ request: LocalHTTPRequest, _ respond: @escaping (Int, Any) -> Void) { let body = (try? JSONSerialization.jsonObject(with: request.body)) as? [String: Any] ?? [:] guard let command = body["command"] as? String else { respond(400, ["output": "", "error": "Command is required"]); return } let directory = (body["cwd"] as? String ?? body["u_cwd"] as? String).flatMap { $0.isEmpty ? nil : $0 } - let script = (directory.map { "cd \(shellQuote($0)) || exit 1; " } ?? "") + "{ \(command)\n} 2>&1" + let script = (directory.map { "cd \(shellQuote($0)); " } ?? "") + "exec sh -c \(shellQuote(command))" var answered = false - let answer = { [queue] (status: Int, output: String, error: String?) in - queue.async { - guard !answered else { return } - answered = true - respond(status, ["output": Self.stripEscapes(output), "error": error.map { $0 as Any } ?? NSNull()]) - } + let answer = { (status: Int, output: String, error: String?) in + guard !answered else { return } + answered = true + respond(status, ["output": Self.stripEscapes(output), "error": error.map { $0 as Any } ?? NSNull()]) } - runtime.queue.async { [runtime] in + let run = { [self] in do { - let process = try runtime.start(script, completion: { _, output, _ in answer(200, output, nil) }) - runtime.queue.asyncAfter(deadline: .now() + 30) { - guard process.status == nil else { return } - runtime.stop(process.id) - answer(500, "", "Command execution timed out") + let session = try runtime.startTerminal(script, rows: 24, cols: 80, queue: queue, scrollbackLimit: Self.captureLimit) + queue.async { [self] in + session.onExit = { [weak session] _ in + answer(200, String(decoding: session?.capturedOutput ?? Data(), as: UTF8.self), nil) + } + // Background children can outlive the shell, so the deadline tracks the response. + queue.asyncAfter(deadline: .now() + 30) { [self] in + guard !answered else { return } + terminate(session) + answer(500, "", "Command execution timed out") + } } - } catch { answer(500, "", error.localizedDescription) } + } catch { + queue.async { answer(500, "", error.localizedDescription) } + } + } + runtime.queue.async { [self] in + guard let directory else { run(); return } + do { + try runtime.start("test -d \(shellQuote(directory))", completion: { [self] status, _, _ in + if status == 0 { run() } else { queue.async { answer(400, "", "Working directory does not exist") } } + }) + } catch { + queue.async { answer(500, "", error.localizedDescription) } + } } } diff --git a/platforms/ios/runner/lib/terminal/TerminalSession.swift b/platforms/ios/runner/lib/terminal/TerminalSession.swift index 66ad06b..8438019 100644 --- a/platforms/ios/runner/lib/terminal/TerminalSession.swift +++ b/platforms/ios/runner/lib/terminal/TerminalSession.swift @@ -1,15 +1,16 @@ import AcodeAlpine import Foundation -/// One interactive shell on a guest pseudo-terminal. Output arrives on guest -/// threads; everything else runs on the terminal server's queue. +/// One command on a guest pseudo-terminal. Output arrives on guest threads; +/// everything else runs on the terminal server's queue. final class TerminalSession { - private static let scrollbackLimit = 256 * 1024 + static let replayLimit = 256 * 1024 private static let unsentLimit = 1024 * 1024 private static let frameSize = 8 * 1024 private static let coalesceDelay = DispatchTimeInterval.milliseconds(8) private let queue: DispatchQueue - private let output = NSCondition() + private let scrollbackLimit: Int + private let output = NSLock() private var scrollback = Data() private var pending = Data() private var unsent = 0 @@ -21,9 +22,25 @@ final class TerminalSession { private var status: Int32? private(set) var pid: Int32 = 0 var onFinish: (() -> Void)? + var onExit: ((Int32) -> Void)? - init(queue: DispatchQueue) { + /// Keeps the last `scrollbackLimit` bytes for replay on reattach or for `capturedOutput`. + init(queue: DispatchQueue, scrollbackLimit: Int = replayLimit) { self.queue = queue + self.scrollbackLimit = scrollbackLimit + } + + var capturedOutput: Data { + output.lock() + defer { output.unlock() } + return Data(scrollback.suffix(scrollbackLimit)) + } + + /// Queried by blocking guest writers, which wait while the client is behind. + var congested: Bool { + output.lock() + defer { output.unlock() } + return attached && unsent > Self.unsentLimit } func started(pid: Int32, handle: OpaquePointer) { @@ -40,11 +57,10 @@ final class TerminalSession { } self.socket?.close() output.lock() - let history = scrollback.suffix(Self.scrollbackLimit) + let history = scrollback.suffix(Self.replayLimit) pending.removeAll() unsent = 0 attached = true - output.broadcast() output.unlock() self.socket = socket socket.onMessage = { [weak self] data, _ in self?.write(data) } @@ -72,18 +88,20 @@ final class TerminalSession { status = code close() flush() + onExit?(code) + onExit = nil guard let socket else { return } socket.send(Self.exitMessage(code), text: true) { socket.close() } onFinish?() } } - /// Runs on guest threads and blocks the writer while the client falls behind. - func receive(_ data: Data, blocking: Bool) { + /// Runs on guest threads. + func receive(_ data: Data) { output.lock() scrollback.append(data) - if scrollback.count > Self.scrollbackLimit * 2 { - scrollback = Data(scrollback.suffix(Self.scrollbackLimit)) + if scrollback.count > scrollbackLimit * 2 { + scrollback = Data(scrollback.suffix(scrollbackLimit)) } guard attached else { output.unlock(); return } pending.append(data) @@ -94,12 +112,6 @@ final class TerminalSession { output.unlock() if immediate { queue.async { self.flush() } } else if schedule { queue.asyncAfter(deadline: .now() + Self.coalesceDelay) { self.flush() } } - guard blocking else { return } - output.lock() - while attached, unsent > Self.unsentLimit { - output.wait(until: Date().addingTimeInterval(0.1)) - } - output.unlock() } private func flush() { @@ -115,8 +127,8 @@ final class TerminalSession { private func acknowledge(_ count: Int) { output.lock() unsent = max(0, unsent - count) - output.broadcast() output.unlock() + wakeWriters() } private func detach() { @@ -125,8 +137,13 @@ final class TerminalSession { attached = false pending.removeAll() unsent = 0 - output.broadcast() output.unlock() + wakeWriters() + } + + private func wakeWriters() { + guard let handle else { return } + alpine_terminal_drained(handle) } private func write(_ data: Data) { @@ -163,13 +180,17 @@ final class TerminalSession { } } -let terminalOutput: AlpineTerminalOutput = { context, data, length, blocking in - guard let context, let data else { return } - Unmanaged.fromOpaque(context).takeUnretainedValue() - .receive(Data(bytes: data, count: length), blocking: blocking) -} - -let terminalRelease: AlpineTerminalRelease = { context in - guard let context else { return } - Unmanaged.fromOpaque(context).release() -} +let terminalCallbacks = AlpineTerminalCallbacks( + output: { context, data, length in + guard let context, let data else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().receive(Data(bytes: data, count: length)) + }, + congested: { context in + guard let context else { return false } + return Unmanaged.fromOpaque(context).takeUnretainedValue().congested + }, + release: { context in + guard let context else { return } + Unmanaged.fromOpaque(context).release() + } +) diff --git a/platforms/ios/runnerTests/AlpineInteractionTests.swift b/platforms/ios/runnerTests/AlpineInteractionTests.swift index 2f7a536..1f9f627 100644 --- a/platforms/ios/runnerTests/AlpineInteractionTests.swift +++ b/platforms/ios/runnerTests/AlpineInteractionTests.swift @@ -55,6 +55,20 @@ final class AlpineInteractionTests: BridgeTestCase { await wait(second, 'STILL:second'); input(second, 'printf "BACK:%s\\n" "$ACODE_CHECK"\r'); await wait(second, 'BACK:second'); + second.component.options.port = 9; + const lost = second.component.websocket; + lost.close(); + for (let i = 0; i < 100 && !second.component.disconnected; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!second.component.disconnected || second.component.intentionalClose) throw new Error('Tab was not kept after reconnect failures'); + second.component.options.port = 8767; + document.dispatchEvent(new CustomEvent('resume')); + for (let i = 0; i < 100 && (second.component.websocket === lost || !second.component.isConnected); i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + input(second, 'printf "AGAIN:%s\\n" "$ACODE_CHECK"\r'); + await wait(second, 'AGAIN:second'); return {first:content(first), second:content(second)}; } finally { await manager.close(first.id); @@ -62,6 +76,6 @@ final class AlpineInteractionTests: BridgeTestCase { } """#, arguments: [:], in: nil, contentWorld: .page) as? [String: String] XCTAssertTrue(result?["first"]?.contains("SIGNAL:130") == true) - XCTAssertTrue(result?["second"]?.contains("BACK:second") == true) + XCTAssertTrue(result?["second"]?.contains("AGAIN:second") == true) } } diff --git a/platforms/ios/runnerTests/AlpineTerminalServerTests.swift b/platforms/ios/runnerTests/AlpineTerminalServerTests.swift new file mode 100644 index 0000000..ed55b41 --- /dev/null +++ b/platforms/ios/runnerTests/AlpineTerminalServerTests.swift @@ -0,0 +1,124 @@ +import Network +import XCTest +import WebKit + +@MainActor +final class AlpineTerminalServerTests: BridgeTestCase { + func testCongestedWriterStillReceivesSignals() async throws { + let webView = try await editorWebView() + let pid = try await webView.callAsyncJavaScript(Self.prelude + #""" + return (await request('/terminals', {cols:80, rows:24})).data.trim(); + """#, arguments: [:], in: nil, contentWorld: .page) as? String ?? "" + let socket = RawWebSocket() + try await socket.open("/terminals/\(pid)") + // The client stops reading, so `cat` fills the socket and must block in the guest. + try await socket.send("cat /dev/zero\r") + let running = try await webView.callAsyncJavaScript(Self.prelude + #""" + return await waitFor(async () => (await processes()).includes('cat'), 100); + """#, arguments: [:], in: nil, contentWorld: .page) as? Bool + XCTAssertEqual(running, true) + try await Task.sleep(for: .seconds(2)) + try await socket.send("\u{03}") + let stopped = try await webView.callAsyncJavaScript(Self.prelude + #""" + return await waitFor(async () => !(await processes()).includes('cat'), 50); + """#, arguments: [:], in: nil, contentWorld: .page) as? Bool + XCTAssertEqual(stopped, true, "Ctrl-C did not reach a writer blocked on a congested client") + socket.close() + _ = try await webView.callAsyncJavaScript(Self.prelude + #""" + await request('/terminals/' + pid + '/terminate', {}); + """#, arguments: ["pid": pid], in: nil, contentWorld: .page) + } + + func testExecuteCommandRunsOnPtyWithResponseDeadline() async throws { + let webView = try await editorWebView() + let result = try await webView.callAsyncJavaScript(Self.prelude + #""" + const execute = async body => { + try { return JSON.parse((await request('/execute-command', body)).data); } + catch (error) { return {status: error.status, ...JSON.parse(error.error || '{}')}; } + }; + const tty = await execute({command: 'test -t 0 && stty size'}); + const missing = await execute({command: 'true', cwd: '/acode-missing-directory'}); + let started = Date.now(); + const background = await execute({command: 'sleep 60 & echo started'}); + const backgroundSeconds = (Date.now() - started) / 1000; + started = Date.now(); + const timeout = await execute({command: 'sleep 45'}); + const timeoutSeconds = (Date.now() - started) / 1000; + const sleepersKilled = await waitFor(async () => !(await processes()).includes('sleep'), 50); + return {tty: tty.output, missing, background: background.output, backgroundSeconds, + timeout: timeout.error, timeoutSeconds, sleepersKilled}; + """#, arguments: [:], in: nil, contentWorld: .page) as? [String: Any] + XCTAssertTrue((result?["tty"] as? String)?.contains("24 80") == true, "\(String(describing: result))") + XCTAssertEqual((result?["missing"] as? [String: Any])?["status"] as? Int, 400) + XCTAssertTrue((result?["background"] as? String)?.contains("started") == true) + XCTAssertLessThan(result?["backgroundSeconds"] as? Double ?? 99, 10) + XCTAssertEqual(result?["timeout"] as? String, "Command execution timed out") + XCTAssertLessThan(result?["timeoutSeconds"] as? Double ?? 99, 40) + XCTAssertEqual(result?["sleepersKilled"] as? Bool, true) + } + + private static let prelude = #""" + if (!await Terminal.isInstalled() && !await Terminal.install()) throw new Error(Terminal.lastInstallError); + await Terminal.startAxs(); + const request = (path, data) => new Promise((resolve, reject) => Bridge.http.sendRequest( + 'http://127.0.0.1:8767' + path, + {method:'POST', serializer:'json', responseType:'text', data}, + resolve, reject)); + const processes = async () => (await Executor.execute('ps -o comm', true)).split('\n').map(line => line.trim()); + const waitFor = async (check, attempts) => { + for (let i = 0; i < attempts; i++) { + if (await check()) return true; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; + }; + + """# +} + +/// A WebSocket client that only reads the handshake, so tests can stop draining output. +private final class RawWebSocket { + private let connection = NWConnection(host: "127.0.0.1", port: 8767, using: .tcp) + private let queue = DispatchQueue(label: "app.acode.tests.raw-websocket") + + func open(_ path: String) async throws { + connection.start(queue: queue) + let key = Data((0..<16).map { _ in UInt8.random(in: 0...255) }).base64EncodedString() + try await write(Data("GET \(path) HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: \(key)\r\nSec-WebSocket-Version: 13\r\n\r\n".utf8)) + var head = Data() + while head.range(of: Data("\r\n\r\n".utf8)) == nil { head.append(try await read()) } + guard String(decoding: head, as: UTF8.self).hasPrefix("HTTP/1.1 101") else { + throw NSError(domain: "AcodeTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Upgrade failed"]) + } + } + + func send(_ text: String) async throws { + let payload = Array(text.utf8) + let mask = (0..<4).map { _ in UInt8.random(in: 0...255) } + var frame = Data([0x81, 0x80 | UInt8(payload.count)] + mask) + frame.append(contentsOf: payload.enumerated().map { $1 ^ mask[$0 & 3] }) + try await write(frame) + } + + func close() { + connection.cancel() + } + + private func write(_ data: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + }) + } + } + + private func read() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, complete, error in + if let error { continuation.resume(throwing: error) } + else if complete, data?.isEmpty ?? true { continuation.resume(throwing: NWError.posix(.ECONNRESET)) } + else { continuation.resume(returning: data ?? Data()) } + } + } + } +} diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 9038170..eab8c11 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -31,6 +31,8 @@ import TerminalTouchSelection from "./terminalTouchSelection"; // Backoff for re-attaching to a live PTY after the socket drops (e.g. app suspension). const RECONNECT_DELAYS = [0, 500, 1500, 3000]; +const DISCONNECTED_NOTICE = + "\r\n\x1b[2m[Disconnected from terminal session. Press any key to reconnect.]\x1b[0m\r\n"; export default class TerminalComponent { constructor(options = {}) { @@ -85,6 +87,8 @@ export default class TerminalComponent { this.intentionalClose = false; this.processExited = false; this.reconnectAttempts = 0; + this.disconnected = false; + this.boundResumeConnection = null; this.init(); } @@ -152,6 +156,11 @@ export default class TerminalComponent { // Handle custom OSC 7777 for acode CLI commands this.setupOscHandler(); + + // Retry a dropped session on the next keypress or when the app returns + this.terminal.onData(() => this.resumeConnection()); + this.boundResumeConnection = () => this.resumeConnection(); + document.addEventListener("resume", this.boundResumeConnection); } /** @@ -812,8 +821,9 @@ export default class TerminalComponent { /** * Connect to terminal session via WebSocket * @param {string} pid - Terminal PID + * @param {boolean} reattach - Clear the screen before the server replays recent output */ - async connectToSession(pid) { + async connectToSession(pid, reattach = false) { if (!this.serverMode) { throw new Error( "Terminal is in local mode, cannot connect to server session", @@ -860,6 +870,7 @@ export default class TerminalComponent { hasOpened = true; this.isConnected = true; this.onConnect?.(); + if (reattach) this.terminal.reset(); // Load attach addon after connection this.attachAddon = new AttachAddon(websocket); @@ -911,17 +922,16 @@ export default class TerminalComponent { return; } - const info = { - intentional: this.intentionalClose, - processExited: this.processExited, - code: event?.code, - reason: event?.reason, - }; - if (info.intentional || info.processExited) { - this.onDisconnect?.(info); + if (this.intentionalClose || this.processExited) { + this.onDisconnect?.({ + intentional: this.intentionalClose, + processExited: this.processExited, + code: event?.code, + reason: event?.reason, + }); return; } - void this.reconnectToSession(info); + void this.reconnectToSession(); }; websocket.onerror = (error) => { @@ -942,13 +952,15 @@ export default class TerminalComponent { } /** - * Re-attach to the same PTY after an unexpected disconnect. The backend - * replays recent output, so the screen is reset before reconnecting. - * @param {object} info - Disconnect details forwarded if reconnecting fails + * Re-attach to the same PTY after an unexpected disconnect. When the backend + * stays unreachable the tab is kept and marked disconnected: the shell may + * still be alive, so only its exit or an explicit close ends the session. */ - async reconnectToSession(info) { + async reconnectToSession() { if (this.reconnectAttempts >= RECONNECT_DELAYS.length) { - this.onDisconnect?.(info); + this.reconnectAttempts = 0; + this.disconnected = true; + this.terminal.write(DISCONNECTED_NOTICE); return; } const delay = RECONNECT_DELAYS[this.reconnectAttempts++]; @@ -958,13 +970,23 @@ export default class TerminalComponent { try { this.attachAddon?.dispose(); this.attachAddon = null; - this.terminal.reset(); - await this.connectToSession(this.pid); + await this.connectToSession(this.pid, true); if (this.intentionalClose) this.websocket?.close(); } catch (error) { console.error(`Failed to reconnect terminal ${this.pid}:`, error); - await this.reconnectToSession(info); + await this.reconnectToSession(); + } + } + + /** + * Retry a disconnected session, e.g. on input or when the app returns to the foreground. + */ + resumeConnection() { + if (!this.disconnected || this.intentionalClose || this.processExited) { + return; } + this.disconnected = false; + void this.reconnectToSession(); } /** @@ -1504,6 +1526,11 @@ export default class TerminalComponent { this.intentionalClose = true; this.terminate(); + if (this.boundResumeConnection) { + document.removeEventListener("resume", this.boundResumeConnection); + this.boundResumeConnection = null; + } + // Dispose touch selection if (this.touchSelection) { this.touchSelection.destroy();