Skip to content

Commit c89f73d

Browse files
bajrangCoderclaude
andauthored
fix(ios): native host terminal server and PTY reconnect (#8)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent abc8efc commit c89f73d

21 files changed

Lines changed: 1206 additions & 105 deletions

‎platforms/android/app/src/main/assets/init-alpine.sh‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export TERM=xterm-256color
66

77
INSTALLING=false
88
FAILSAFE=false
9+
PREPARE=false
910

1011
# Parse internal flags
1112
while [ $# -gt 0 ]; do
@@ -18,6 +19,10 @@ while [ $# -gt 0 ]; do
1819
FAILSAFE=true
1920
shift
2021
;;
22+
--prepare)
23+
PREPARE=true
24+
shift
25+
;;
2126
--)
2227
shift
2328
break
@@ -37,10 +42,13 @@ fi
3742
required_packages="bash command-not-found tzdata wget"
3843
missing_packages=""
3944

45+
installed_packages=" $(apk info -e $required_packages 2>/dev/null | tr '\n' ' ') "
46+
4047
for pkg in $required_packages; do
41-
if ! apk info -e "$pkg" >/dev/null 2>&1; then
42-
missing_packages="$missing_packages $pkg"
43-
fi
48+
case "$installed_packages" in
49+
*" $pkg "*) ;;
50+
*) missing_packages="$missing_packages $pkg" ;;
51+
esac
4452
done
4553

4654
if [ -n "$missing_packages" ]; then
@@ -289,24 +297,27 @@ _acode_preexec() {
289297
check_binary_execution "$cmd"
290298
}
291299
292-
# Preserve any existing DEBUG trap and append our handler instead of overwriting it.
293-
# This avoids clobbering user-installed preexec hooks (starship, fzf, bash-preexec, etc.).
294-
__acode_existing_debug_trap="$(trap -p DEBUG 2>/dev/null)"
295-
if [[ -n "${__acode_existing_debug_trap}" ]]; then
296-
__acode_existing_cmd="$(printf "%s" "${__acode_existing_debug_trap}" | sed -E "s/.*'((.*)?)'.*/\1/")"
297-
else
298-
__acode_existing_cmd=""
299-
fi
300-
301-
# Only add our handler if it's not already present
302-
if [[ "${__acode_existing_cmd}" != *"_acode_preexec"* ]]; then
303-
if [[ -n "${__acode_existing_cmd}" ]]; then
304-
trap "${__acode_existing_cmd}; _acode_preexec" DEBUG
300+
# Only Android storage needs the check; it costs two subshells per command.
301+
if [ -d /sdcard ] || [ -d /storage ]; then
302+
# Preserve any existing DEBUG trap and append our handler instead of overwriting it.
303+
# This avoids clobbering user-installed preexec hooks (starship, fzf, bash-preexec, etc.).
304+
__acode_existing_debug_trap="$(trap -p DEBUG 2>/dev/null)"
305+
if [[ -n "${__acode_existing_debug_trap}" ]]; then
306+
__acode_existing_cmd="$(printf "%s" "${__acode_existing_debug_trap}" | sed -E "s/.*'((.*)?)'.*/\1/")"
305307
else
306-
trap '_acode_preexec' DEBUG
308+
__acode_existing_cmd=""
309+
fi
310+
311+
# Only add our handler if it's not already present
312+
if [[ "${__acode_existing_cmd}" != *"_acode_preexec"* ]]; then
313+
if [[ -n "${__acode_existing_cmd}" ]]; then
314+
trap "${__acode_existing_cmd}; _acode_preexec" DEBUG
315+
else
316+
trap '_acode_preexec' DEBUG
317+
fi
307318
fi
319+
unset __acode_existing_debug_trap __acode_existing_cmd
308320
fi
309-
unset __acode_existing_debug_trap __acode_existing_cmd
310321
311322
# Command-not-found handler
312323
command_not_found_handle() {
@@ -352,7 +363,8 @@ fi
352363

353364
chmod +x "$ALPINE_ROOT/initrc"
354365

355-
if [ "$FAILSAFE" != true ]; then
366+
# --prepare only sets up the environment; the caller serves the terminals
367+
if [ "$FAILSAFE" != true ] && [ "$PREPARE" != true ]; then
356368
#everytime a terminal is started initrc will run
357369
"$PREFIX/axs" -c "bash --rcfile /initrc -i"
358370
fi

‎platforms/ios/Alpine/Bridge/AlpineRuntime.c‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
#include "fs/devices.h"
1212
#include "fs/path.h"
1313
#include "fs/real.h"
14+
#include "fs/sockrestart.h"
15+
#include "AlpineSpawn.h"
1416

1517
extern int fakefs_bind_mount(const char *, const char *, bool);
1618
extern int do_wait(int, pid_t_, struct siginfo_ *, struct rusage_ *, int);
1719
static struct task *init_task;
1820
static AlpineExitCallback on_exit;
21+
static int attach_descriptors(void *argument);
1922
static void process_exit(struct task *task, int status);
2023
static struct fd *host_descriptor(int number);
2124
static void *failed_process(void *task);
@@ -90,19 +93,23 @@ int alpine_chmod(const char *path, unsigned int mode) {
9093
}
9194

9295
int alpine_start(const char *command, const char *environment, int input, int output, int error) {
96+
int descriptors[] = {input, output, error};
97+
return alpine_spawn(command, environment, attach_descriptors, descriptors);
98+
}
99+
100+
int alpine_spawn(const char *command, const char *environment, int (*attach)(void *), void *argument) {
93101
current = init_task;
94102
int result = become_new_init_child();
95103
if (result < 0) { current = NULL; return result; }
96-
int descriptors[] = {input, output, error};
97-
for (int index = 0; index < 3; index++) {
98-
current->files->files[index] = host_descriptor(descriptors[index]);
104+
result = attach(argument);
105+
if (result >= 0) {
106+
size_t length = strlen(command);
107+
char *arguments = calloc(1, length + 14);
108+
memcpy(arguments, "/bin/sh\0-c\0", 11);
109+
memcpy(arguments + 11, command, length);
110+
result = do_execve("/bin/sh", 3, arguments, environment);
111+
free(arguments);
99112
}
100-
size_t length = strlen(command);
101-
char *arguments = calloc(1, length + 14);
102-
memcpy(arguments, "/bin/sh\0-c\0", 11);
103-
memcpy(arguments + 11, command, length);
104-
result = do_execve("/bin/sh", 3, arguments, environment);
105-
free(arguments);
106113
int pid = current->pid;
107114
sigset_t wake, previous;
108115
sigemptyset(&wake);
@@ -185,6 +192,22 @@ int alpine_unmount(void) {
185192
return result;
186193
}
187194

195+
void alpine_suspend(void) {
196+
sockrestart_on_suspend();
197+
}
198+
199+
void alpine_resume(void) {
200+
sockrestart_on_resume();
201+
}
202+
203+
static int attach_descriptors(void *argument) {
204+
int *descriptors = argument;
205+
for (int index = 0; index < 3; index++) {
206+
current->files->files[index] = host_descriptor(descriptors[index]);
207+
}
208+
return 0;
209+
}
210+
188211
static void process_exit(struct task *task, int status) {
189212
if (on_exit != NULL) on_exit(task->tgid, (status & 0x7f) ? 128 + (status & 0x7f) : status >> 8);
190213
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#pragma once
2+
3+
// Runs `/bin/sh -c command` as a new child of PID 1. `attach` runs as the new
4+
// task before exec to install its stdio; a negative result fails the spawn.
5+
int alpine_spawn(const char *command, const char *environment, int (*attach)(void *), void *argument);
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#include "AcodeAlpine.h"
2+
#include <stdio.h>
3+
#include <stdlib.h>
4+
#include "kernel/errno.h"
5+
#include "kernel/init.h"
6+
#include "kernel/signal.h"
7+
#include "kernel/task.h"
8+
#include "fs/devices.h"
9+
#include "fs/tty.h"
10+
#include "AlpineSpawn.h"
11+
12+
// The guest side is a regular /dev/pts slave, so shells get job control,
13+
// termios and SIGWINCH. The host side is this driver instead of a pty master,
14+
// which keeps terminal I/O out of emulated sockets and polling.
15+
struct AlpineTerminal {
16+
struct tty *tty;
17+
int rows;
18+
int cols;
19+
void *context;
20+
AlpineTerminalCallbacks callbacks;
21+
lock_t lock;
22+
cond_t drained;
23+
bool closed;
24+
};
25+
26+
static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking);
27+
static void terminal_cleanup(struct tty *tty);
28+
static int attach_terminal(void *argument);
29+
30+
static const struct tty_driver_ops terminal_ops = {
31+
.write = terminal_write,
32+
.cleanup = terminal_cleanup,
33+
};
34+
// pty_open_fake fills in the slot table and major number.
35+
static struct tty_driver terminal_driver = {.ops = &terminal_ops};
36+
37+
int alpine_terminal_start(const char *command, const char *environment, int rows, int cols,
38+
void *context, AlpineTerminalCallbacks callbacks, AlpineTerminal **handle) {
39+
*handle = NULL;
40+
AlpineTerminal *terminal = calloc(1, sizeof(AlpineTerminal));
41+
if (terminal == NULL) { callbacks.release(context); return _ENOMEM; }
42+
*terminal = (AlpineTerminal) {.rows = rows, .cols = cols, .context = context, .callbacks = callbacks};
43+
lock_init(&terminal->lock);
44+
cond_init(&terminal->drained);
45+
int pid = alpine_spawn(command, environment, attach_terminal, terminal);
46+
if (terminal->tty == NULL) {
47+
callbacks.release(context);
48+
cond_destroy(&terminal->drained);
49+
free(terminal);
50+
} else if (pid < 0) {
51+
alpine_terminal_close(terminal);
52+
} else {
53+
*handle = terminal;
54+
}
55+
return pid;
56+
}
57+
58+
long alpine_terminal_input(AlpineTerminal *terminal, const char *data, size_t length) {
59+
return tty_input(terminal->tty, data, length, false);
60+
}
61+
62+
void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols) {
63+
struct tty *tty = terminal->tty;
64+
lock(&tty->lock);
65+
tty->winsize = (struct winsize_) {.row = rows, .col = cols};
66+
pid_t_ foreground = tty->fg_group;
67+
unlock(&tty->lock);
68+
if (foreground != 0) send_group_signal(foreground, SIGWINCH_, SIGINFO_NIL);
69+
}
70+
71+
void alpine_terminal_drained(AlpineTerminal *terminal) {
72+
lock(&terminal->lock);
73+
notify(&terminal->drained);
74+
unlock(&terminal->lock);
75+
}
76+
77+
void alpine_terminal_close(AlpineTerminal *terminal) {
78+
lock(&terminal->lock);
79+
terminal->closed = true;
80+
notify(&terminal->drained);
81+
unlock(&terminal->lock);
82+
struct tty *tty = terminal->tty;
83+
lock(&tty->lock);
84+
pid_t_ session = tty->session;
85+
pid_t_ foreground = tty->fg_group;
86+
tty_hangup(tty);
87+
unlock(&tty->lock);
88+
// Matches Linux when the master side of a pty goes away.
89+
if (foreground != 0) send_group_signal(foreground, SIGHUP_, SIGINFO_NIL);
90+
if (session != 0 && session != foreground) send_group_signal(session, SIGHUP_, SIGINFO_NIL);
91+
lock(&ttys_lock);
92+
tty_release(tty);
93+
unlock(&ttys_lock);
94+
}
95+
96+
// Runs on guest threads. The tty lock may be held (echo), so it must not re-enter the tty.
97+
// A blocking writer waits like on a full pipe while the client is behind. wait_for
98+
// returns _EINTR for pending guest signals, so Ctrl-C and SIGKILL still get through,
99+
// and nothing has been written yet when it does.
100+
static int terminal_write(struct tty *tty, const void *data, size_t length, bool blocking) {
101+
AlpineTerminal *terminal = tty->data;
102+
if (blocking) {
103+
int result = 0;
104+
lock(&terminal->lock);
105+
while (result == 0 && !terminal->closed && terminal->callbacks.congested(terminal->context)) {
106+
struct timespec recheck = {.tv_nsec = 100 * 1000 * 1000};
107+
result = wait_for(&terminal->drained, &terminal->lock, &recheck);
108+
if (result == _ETIMEDOUT) result = 0;
109+
}
110+
unlock(&terminal->lock);
111+
if (result < 0) return result;
112+
}
113+
terminal->callbacks.output(terminal->context, data, length);
114+
return 0;
115+
}
116+
117+
// Runs once the host and every guest descriptor have released the tty.
118+
static void terminal_cleanup(struct tty *tty) {
119+
AlpineTerminal *terminal = tty->data;
120+
terminal->callbacks.release(terminal->context);
121+
cond_destroy(&terminal->drained);
122+
free(terminal);
123+
}
124+
125+
// Runs as the new session leader, so opening the slave makes it the controlling tty.
126+
static int attach_terminal(void *argument) {
127+
AlpineTerminal *terminal = argument;
128+
struct tty *tty = pty_open_fake(&terminal_driver);
129+
if (IS_ERR(tty)) return (int) PTR_ERR(tty);
130+
tty->data = terminal;
131+
tty->winsize = (struct winsize_) {.row = terminal->rows, .col = terminal->cols};
132+
terminal->tty = tty;
133+
char path[32];
134+
snprintf(path, sizeof(path), "/dev/pts/%d", tty->num);
135+
return create_stdio(path, TTY_PSEUDO_SLAVE_MAJOR, tty->num);
136+
}

‎platforms/ios/Alpine/README.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@
22

33
The existing Acode xterm UI connects to the same AXS HTTP/WebSocket API used on
44
Android. ARM64 Linux programs run inside ios-linuxkit's interpreter; they are not
5-
launched as unsigned iOS executables. The native Executor services use guest
6-
processes and pipes. Apple Network.framework supplies the raw process WebSocket
7-
transport for `Executor.spawnStream`.
5+
launched as unsigned iOS executables.
6+
7+
On iOS that API is served by the app, not by AXS inside the guest. Only the
8+
shells are emulated:
9+
10+
```
11+
xterm.js ──ws/http──▶ TerminalServer (Swift, 127.0.0.1:8767)
12+
└─ TerminalSession ──▶ Bridge/AlpineTerminal.c ──▶ /dev/pts/N ──▶ bash
13+
```
14+
15+
`AlpineTerminal.c` registers a tty driver whose output goes straight to Swift
16+
and whose input, resize and hangup go straight to the guest tty. Terminal I/O
17+
therefore never uses emulated sockets, epoll or threads, and the guest pty still
18+
provides job control, termios and `SIGWINCH`. `init-alpine.sh --prepare` runs
19+
once per boot; each terminal then starts `bash --rcfile /initrc -i` directly.
20+
The AXS binary stays in the guest as `axs` for users and plugins.
21+
22+
The native Executor services use guest processes and pipes. Apple
23+
Network.framework supplies the raw process WebSocket transport for
24+
`Executor.spawnStream`.
825

926
## Sources
1027

@@ -55,6 +72,10 @@ SIGUSR1` in Xcode's debug console, then `continue`.
5572
- `Bridge/AlpineRuntime.c` owns guest init, stdio, process cleanup and mounts.
5673
SIGUSR1 is unblocked only while creating guest threads, then the host worker's
5774
signal mask is restored.
75+
- `Bridge/AlpineTerminal.c` connects guest pseudo-terminals to Swift through
76+
`pty_open_fake`, as iSH does for its terminal view.
77+
- `alpine_suspend`/`alpine_resume` run upstream `sockrestart` when the app moves
78+
to the background and back, so guest servers keep their listening sockets.
5879

5980
The guest root lives at `Library/Alpine`, outside every host bind mount. `/public`,
6081
`/home` and `/root` share Acode's Terminal Public directory. `/acode` maps app data,

‎platforms/ios/Alpine/Vendor/ios-linuxkit/meson.build‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,8 @@ if meson.is_cross_build()
320320
include_directories: [include_directories('deps'), include_directories('deps/libarchive/libarchive')],
321321
c_args: ['-DHAVE_CONFIG_H', '-I' + meson.get_external_property('sdk_path') + '/usr/include/libxml2', '-w'])
322322
acode_alpine = static_library('acode_alpine',
323-
['../../Bridge/AlpineRuntime.c', '../../Bridge/AlpineFaults.c', '../../Bridge/AlpineArchive.c', 'tools/fakefs.c'],
323+
['../../Bridge/AlpineRuntime.c', '../../Bridge/AlpineTerminal.c', '../../Bridge/AlpineFaults.c',
324+
'../../Bridge/AlpineArchive.c', 'tools/fakefs.c'],
324325
include_directories: [includes, include_directories('deps/libarchive/libarchive')],
325326
c_args: ['-I' + meson.project_source_root() + '/../../include'])
326327
endif

‎platforms/ios/Alpine/include/AcodeAlpine.h‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
#include <stddef.h>
44

55
typedef void (*AlpineExitCallback)(int pid, int status);
6+
typedef struct AlpineTerminal AlpineTerminal;
7+
// `output` and `congested` run on guest threads. `congested` tells blocking writers
8+
// to wait until alpine_terminal_drained; `release` runs once the terminal is gone.
9+
typedef struct {
10+
void (*output)(void *context, const char *data, size_t length);
11+
bool (*congested)(void *context);
12+
void (*release)(void *context);
13+
} AlpineTerminalCallbacks;
614

715
// Calls that access guest state are serialized by AlpineRuntime.queue.
816
int alpine_boot(const char *root, AlpineExitCallback callback);
@@ -17,3 +25,18 @@ bool alpine_idle(void);
1725
int alpine_unmount(void);
1826
bool alpine_import(const char *archive, const char *root, char *error, size_t capacity);
1927
void alpine_install_fault_handlers(void);
28+
// Recreate guest listening sockets that iOS reclaims while the app is suspended.
29+
void alpine_suspend(void);
30+
void alpine_resume(void);
31+
32+
// Runs a command on a new pseudo-terminal and returns its pid. The context is
33+
// owned by the terminal from this call on, including when starting fails.
34+
int alpine_terminal_start(const char *command, const char *environment, int rows, int cols,
35+
void *context, AlpineTerminalCallbacks callbacks, AlpineTerminal **terminal);
36+
// Input, resize, drained and close are thread-safe and do not need AlpineRuntime.queue.
37+
long alpine_terminal_input(AlpineTerminal *terminal, const char *data, size_t length);
38+
void alpine_terminal_resize(AlpineTerminal *terminal, int rows, int cols);
39+
void alpine_terminal_drained(AlpineTerminal *terminal);
40+
// Releases waiting writers, hangs up the session and drops the host's reference;
41+
// the handle is invalid afterwards.
42+
void alpine_terminal_close(AlpineTerminal *terminal);

0 commit comments

Comments
 (0)