feat(rt2): term.raw/restore — no wrecked tty, ever

- two verbs on any tty fd; saved termios in a per-shard 8-entry table;
  double-raw and restore-without-save refuse by name
- restore is a RUNTIME obligation: vm_unwind at depth 0 (uncaught trap,
  fiber reap) restores the dying fiber's entries newest-first, and
  wo_vm_destroy sweeps the rest — proven twice in the legs: a DIV0
  while raw restores, and even the double-raw REFUSAL (itself a trap)
  restores the first raw
- test_term 39/0 against a real PTY pair made by the test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b439387dbf4f653e034c721bc3f08b83616c5e24)
This commit is contained in:
shoney.arickathil 2026-09-02 00:03:32 +02:00
parent c55d6e1d33
commit 22ba51bfd3
5 changed files with 192 additions and 0 deletions

View file

@ -348,6 +348,10 @@ let stdlib_members : stdlib_member list =
Signal {sig} record to the actor. SIGTERM/SIGINT refused (the stop
latch). Coalescing disclosed. *)
m "signal" "on" 2 102 None (Some signal_record_name);
(* runtime-v2 4: raw mode on a tty the process was GIVEN; restore is
a runtime obligation (unwind/stop), never only the caller's *)
m "term" "raw" 1 103 None None;
m "term" "restore" 1 104 None None;
(* json — both members are lowered specially (emit.ml): encode needs its
argument's static kind, and decode has no type until an `as` names one,
so neither goes through the generic builtin path. They are listed here

View file

@ -300,6 +300,26 @@ void wo_vm_signals_drain(wo_vm *vm) {
}
}
/* ---- runtime-v2 4: termios adoption -----------------------------------
* term.raw saves into the shard table and cfmakeraw's the fd; restore is
* a RUNTIME obligation — vm_unwind (full), fib_reap and wo_vm_destroy
* all sweep, newest-first. */
void wo_term_abandon(wo_vm *vm, wo_fiber *fb) {
for (int i = 7; i >= 0; i--)
if (vm->ttysave[i].used && vm->ttysave[i].owner == fb) {
tcsetattr(vm->ttysave[i].fd, TCSANOW, &vm->ttysave[i].saved);
vm->ttysave[i].used = 0;
}
}
void wo_term_restore_all(wo_vm *vm) {
for (int i = 7; i >= 0; i--)
if (vm->ttysave[i].used) {
tcsetattr(vm->ttysave[i].fd, TCSANOW, &vm->ttysave[i].saved);
vm->ttysave[i].used = 0;
}
}
/* claim a slot or refuse by name (shared by run/run_dl/spawn forms) */
static wo_child *proc_slot_claim(wo_vm *vm, const char **msg) {
for (uint32_t i = 0; i < WO_PROC_MAX; i++)
@ -1463,6 +1483,54 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = 0;
return 0;
}
/* ---- runtime-v2 4: termios adoption ------------------------------- */
case WO_B_TERM_RAW: { /* (fd) -> 0: save, then cfmakeraw */
int fd = (int)(int64_t)R[B];
int slot = -1;
for (int i = 0; i < 8; i++) {
if (vm->ttysave[i].used && vm->ttysave[i].fd == fd) {
*msg = "term.raw: fd is already raw";
return WO_T_IO;
}
if (!vm->ttysave[i].used && slot < 0) slot = i;
}
if (slot < 0) {
*msg = "term.raw: saved-termios table full (8)";
return WO_T_IO;
}
struct termios t;
if (tcgetattr(fd, &t) != 0) {
*msg = strerror(errno);
return WO_T_IO;
}
vm->ttysave[slot].saved = t;
vm->ttysave[slot].fd = fd;
vm->ttysave[slot].owner = vm->cur;
vm->ttysave[slot].used = 1;
cfmakeraw(&t);
if (tcsetattr(fd, TCSANOW, &t) != 0) {
vm->ttysave[slot].used = 0;
*msg = strerror(errno);
return WO_T_IO;
}
R[A] = 0;
return 0;
}
case WO_B_TERM_RESTORE: { /* (fd) -> 0 from the saved entry */
int fd = (int)(int64_t)R[B];
for (int i = 0; i < 8; i++)
if (vm->ttysave[i].used && vm->ttysave[i].fd == fd) {
if (tcsetattr(fd, TCSANOW, &vm->ttysave[i].saved) != 0) {
*msg = strerror(errno);
return WO_T_IO;
}
vm->ttysave[i].used = 0;
R[A] = 0;
return 0;
}
*msg = "term.restore: fd was never made raw";
return WO_T_IO;
}
default:
*msg = "unknown stdlib builtin";
return WO_T_EXPLICIT;

View file

@ -769,6 +769,7 @@ int wo_vm_init(wo_vm *vm, const wo_module *mod, size_t heap_cap) {
void wo_vm_destroy(wo_vm *vm) {
wo_proc_reap_all(vm); /* iteration 42: no child outlives its shard */
wo_term_restore_all(vm); /* runtime-v2 4: no wrecked tty either */
/* iteration 35: the fiber pool dies with the vm */
while (vm->fib_pool) {
wo_fiber *fb = vm->fib_pool;
@ -1458,6 +1459,9 @@ static void vm_gc_safepoint(wo_vm *vm) {
* slot sees 0 and skips. stop_depth is 0 for an uncaught trap (the whole
* stack dies) and the catching frame's depth for a caught one. */
static void vm_unwind(wo_vm *vm, uint32_t stop_depth) {
/* runtime-v2 4: a FULL unwind (uncaught trap, fiber reap) restores
* the ttys this fiber raw'd — no trap path leaves a wrecked tty */
if (stop_depth == 0) wo_term_abandon(vm, vm->cur);
for (uint32_t d = vm->cur->depth; d > stop_depth; d--) {
const wo_frame *f = &vm->cur->frames[d - 1];
vm_release_frame(vm, d, (d == vm->cur->depth) ? f->pc : f->pc - 1, UINT32_MAX);

View file

@ -4,6 +4,8 @@
#ifndef WO_VM_H
#define WO_VM_H
#include <termios.h> /* runtime-v2 4: the saved-termios table */
#include "loader.h"
/* structured trap error (spec §6): one shape forever — the CLI prints it,
@ -208,6 +210,11 @@ void wo_vm_signals_drain(struct wo_vm *vm);
void wo_actor_notify(struct wo_vm *vm, struct wo_actor *target,
uint64_t payload, const char *what);
/* runtime-v2 4 (sysio.c): restore the ttys a dying fiber raw'd (full
* unwind / fib_reap), or every saved tty (wo_vm_destroy). */
void wo_term_abandon(struct wo_vm *vm, wo_fiber *fb);
void wo_term_restore_all(struct wo_vm *vm);
/* iteration 24: the one mailbox cap (default 1024, WO_MAILBOX overrides
* at boot — soak tests shrink it to force the fail-fast policy). */
extern uint32_t wo_mailbox_cap;
@ -265,6 +272,15 @@ typedef struct wo_vm {
} sigsubs[8];
uint32_t nsigsubs;
uint32_t sig_seen;
/* runtime-v2 4: ttys this shard put into raw mode. Restore is a
* RUNTIME obligation — full unwind, fiber reap and vm destroy all
* restore (newest-first) so no trap path leaves a wrecked tty. */
struct {
int used;
int fd;
struct termios saved;
wo_fiber *owner;
} ttysave[8];
/* iteration 35, uring backend: the shard's ONE deadline tick — a
* TIMEOUT op with a sentinel user_data armed for the nearest fd-park
* deadline (fd parks keep exactly one POLL op each; expiry wakes them

View file

@ -1,12 +1,15 @@
/* test_term — runtime-v2 3/4/5: signals as events, termios adoption,
* fd passing. Named for the terminal-facing half of the track. */
#define _XOPEN_SOURCE 700 /* posix_openpt/grantpt/unlockpt + 200809L base */
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#include "cont.h"
@ -136,7 +139,104 @@ static void test_signal_on_delivers_record(void) {
signal(SIGUSR1, SIG_DFL);
}
/* ---- runtime-v2 4: termios adoption --------------------------------------
* methods on an fd argument: 0 raw(fd), 1 restore(fd), 2 trap_while_raw
* (raw then DIV0 — the unwind must restore). */
static uint8_t *term_module(size_t *len) {
wb_t *b = wb_new();
uint32_t kraw = wb_const_text(b, "raw");
uint32_t kres = wb_const_text(b, "restore");
uint32_t ktrap = wb_const_text(b, "trap_while_raw");
uint32_t kone = wb_const_int(b, 1);
uint32_t kzero = wb_const_int(b, 0);
{ /* raw(fd) */
uint32_t code[3];
code[0] = wo_ins_abc(WOP_BUILTIN, 1, 0, WO_B_TERM_RAW);
code[1] = wo_ins_abc(WOP_RET0, 0, 0, 0);
wb_method(b, kraw, WOB_NONE, 1, 2, code, 2, NULL, 0, NULL, 0);
}
{ /* restore(fd) */
uint32_t code[3];
code[0] = wo_ins_abc(WOP_BUILTIN, 1, 0, WO_B_TERM_RESTORE);
code[1] = wo_ins_abc(WOP_RET0, 0, 0, 0);
wb_method(b, kres, WOB_NONE, 1, 2, code, 2, NULL, 0, NULL, 0);
}
{ /* trap_while_raw(fd): raw, then DIV0 */
uint32_t code[6];
code[0] = wo_ins_abc(WOP_BUILTIN, 1, 0, WO_B_TERM_RAW);
code[1] = wo_ins_abx(WOP_LOADK, 1, (uint16_t)kone);
code[2] = wo_ins_abx(WOP_LOADK, 2, (uint16_t)kzero);
code[3] = wo_ins_abc(WOP_DIV, 3, 1, 2);
code[4] = wo_ins_abc(WOP_RET0, 0, 0, 0);
wb_method(b, ktrap, WOB_NONE, 1, 4, code, 5, NULL, 0, NULL, 0);
}
return wb_finish(b, len);
}
static void test_term_raw_restore(void) {
/* a real tty pair, made by the TEST (no builtin involved) */
int master = posix_openpt(O_RDWR | O_NOCTTY);
T_CHECK(master >= 0);
T_EQ(grantpt(master), 0);
T_EQ(unlockpt(master), 0);
char sname[128];
T_EQ(ptsname_r(master, sname, sizeof sname), 0);
int slave = open(sname, O_RDWR | O_NOCTTY);
T_CHECK(slave >= 0);
struct termios t0;
T_EQ(tcgetattr(slave, &t0), 0);
T_CHECK(t0.c_lflag & ECHO);
size_t len;
uint8_t *img = term_module(&len);
wo_module mod;
char lerr[256];
T_EQ(wo_load_buf(&mod, img, len, lerr, sizeof lerr), 0);
T_EQ(wo_vm_init(&VM, &mod, 1 << 20), 0);
uint64_t args[1] = {(uint64_t)slave};
uint64_t ret = 0;
wo_err err;
/* raw clears ECHO/ICANON */
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 0, args, 1, &ret, &err), 0);
struct termios tr;
T_EQ(tcgetattr(slave, &tr), 0);
T_CHECK((tr.c_lflag & (ECHO | ICANON)) == 0);
/* restore brings the saved flags back */
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 1, args, 1, &ret, &err), 0);
struct termios t1;
T_EQ(tcgetattr(slave, &t1), 0);
T_EQ((long long)t1.c_lflag, (long long)t0.c_lflag);
/* restore with nothing saved refuses by name */
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 1, args, 1, &ret, &err), -1);
T_CHECK(strstr(err.msg, "never made raw") != NULL);
/* double raw refuses by name — and the refusal is a TRAP, so the
* full unwind restores the first raw (the obligation, observed) */
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 0, args, 1, &ret, &err), 0);
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 0, args, 1, &ret, &err), -1);
T_CHECK(strstr(err.msg, "already raw") != NULL);
T_EQ(tcgetattr(slave, &t1), 0);
T_EQ((long long)t1.c_lflag, (long long)t0.c_lflag);
/* a trap while raw still restores — the obligation, second proof */
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 2, args, 1, &ret, &err), -1);
T_EQ(tcgetattr(slave, &t1), 0);
T_EQ((long long)t1.c_lflag, (long long)t0.c_lflag);
wo_vm_destroy(&VM);
wo_module_free(&mod);
free(img);
close(slave);
close(master);
}
int main(void) {
test_signal_on_delivers_record();
test_term_raw_restore();
return t_report("test_term");
}