feat(rt2): term.size + term.width — the wmux ladder's last runtime asks

- term.size(fd) -> ?TermSize{cols,rows}: TIOCGWINSZ, resize's read twin;
  nil = not a tty (expected answer, never a trap)
- term.width(cp): libc wcwidth under C.UTF-8 (LC_CTYPE set on first
  use, host-locale fallback): -1 control, 0 combining, 1, 2
- ids 108/109 (all four registrations); TermSize predeclared
- legs: PTY sized 77x33 from outside answers exactly that, pipe answers
  nil, widths a/CJK/combining/BEL = 1/2/0/-1; test_term 81/0, woc 557/0
- story runtime-v2 6 recorded done; board row appended

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1514fb46c21c4318856cfcb3ca2b4d430caba72b)
This commit is contained in:
shoney.arickathil 2026-09-02 01:29:37 +02:00
parent 6e997759e5
commit 5e8e0960bc
8 changed files with 161 additions and 3 deletions

View file

@ -272,11 +272,18 @@ let signal_record_name = "Signal"
let signal_record_fields : (string * field_ty) list = [ ("sig", Scalar "Int") ]
(* runtime-v2 6: term.size's answer; nil = the fd is not a tty *)
let termsize_record_name = "TermSize"
let termsize_record_fields : (string * field_ty) list =
[ ("cols", Scalar "Int"); ("rows", Scalar "Int") ]
let predeclared_records : (string * (string * field_ty) list) list =
[ (error_record_name, error_record_fields); (stat_record_name, stat_record_fields);
(time_record_name, time_record_fields); (proc_record_name, proc_record_fields);
(child_record_name, child_record_fields);
(signal_record_name, signal_record_fields) ]
(signal_record_name, signal_record_fields);
(termsize_record_name, termsize_record_fields) ]
(* One member of a reserved stdlib module (`fs.stat`, `net.write`, ...).
[sm_builtin] is its .wob builtin id (runtime/src/wob.h); [sm_record] names
@ -357,6 +364,10 @@ let stdlib_members : stdlib_member list =
m "net" "send_fd" 2 105 (Some (TScalar "Bool")) None;
m "net" "recv_fd" 1 106 (Some (TNullable (TScalar "Int"))) None;
m "net" "connect_unix" 1 107 (Some (TScalar "Int")) None;
(* runtime-v2 6: resize's read twin (nil = not a tty), and a
codepoint's terminal cell width (libc wcwidth under C.UTF-8) *)
m "term" "size" 1 108 (Some (TNullable (TScalar termsize_record_name))) (Some termsize_record_name);
m "term" "width" 1 109 (Some (TScalar "Int")) 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

@ -1247,6 +1247,7 @@ starts. Edges in [dependency graph section 6](../00-dependency-graph.md).
| 3 | [signals as events](runtime-v2/03-signals-as-events.md) | ✅ **DONE 2026-09-02** — `signal.on(sig, addr)` delivering a fresh Signal record (scalar payloads crash by construction — spec amendment); handler-latch + wake eventfd instead of signalfd (amendment); TERM/INT refused by name |
| 4 | [termios adoption](runtime-v2/04-termios.md) | ✅ **DONE 2026-09-02** — `term.raw/restore`; restore proven a runtime obligation twice (DIV0 while raw, and the double-raw refusal itself) |
| 5 | [fd passing](runtime-v2/05-fd-passing.md) | ✅ **DONE 2026-09-02** — `net.send_fd`/`recv_fd`/`connect_unix`; a tty crossed the socket, was raw'd through the received copy and restored at destroy — the wmux handover in miniature |
| 6 | [term.size + term.width](runtime-v2/06-term-size-width.md) | ✅ **DONE 2026-09-02** — TIOCGWINSZ read twin (nil = not a tty) and libc wcwidth under C.UTF-8; the only runtime work the whole wmux parity ladder needs |
### ▸ wmux — the terminal multiplexer track

View file

@ -0,0 +1,24 @@
---
track: runtime-v2
iteration: "6"
status: done
readiness: ready
---
# runtime-v2 6 — term.size and term.width: the two verbs the wmux ladder asked for
> Part of [Story — runtime-v2: the runtime beyond sockets](00-story.md).
> Named by the wmux parity-ladder brainstorm (2026-09-02) as the ONLY
> runtime work all nine wmux rungs need beyond the landed track.
>
> **DONE 2026-09-02, same day.** `term.size(fd) -> ?TermSize {cols,
> rows}` — TIOCGWINSZ, the resize verb's read twin; nil = not a tty (an
> expected answer, never a trap). `term.width(cp) -> Int` — libc
> `wcwidth` under `C.UTF-8` (first use sets `LC_CTYPE`; falls back to
> the host locale): -1 control, 0 combining, 1, 2. Ids 108/109;
> `TermSize` joins the predeclared records. Consumers: wmux 1 (attach
> at the client's real size), wmux 2 (grid column math), wmux 6
> (multi-client min-size, SIGWINCH forwarding).
>
> Legs in `test_term.c`: a PTY sized 77×33 from outside answers exactly
> that; a pipe answers nil; widths a=1, U+4E2D=2, U+0301=0, BEL=-1.

View file

@ -172,7 +172,7 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
if (C == WO_B_JSON_ENCODE || C == WO_B_JSON_DECODE)
return wo_builtin_json(vm, R, ins, msg);
if ((C >= WO_B_SYS_FIRST && C <= WO_B_PROC_RUN) || C == WO_B_TIME_TICKS
|| (C >= WO_B_NET_READ_DL && C <= WO_B_NET_CONNECT_UNIX))
|| (C >= WO_B_NET_READ_DL && C <= WO_B_TERM_WIDTH))
return wo_builtin_sys(vm, R, ins, msg);
if (C >= WO_B_SHA1 && C <= WO_B_HMAC_SHA256)
return wo_builtin_crypto(vm, R, ins, msg);

View file

@ -78,6 +78,7 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
[WO_B_PROC_SPAWN_PTY] = 5, [WO_B_PROC_RESIZE] = 3,
[WO_B_SIGNAL_ON] = 3, [WO_B_TERM_RAW] = 1, [WO_B_TERM_RESTORE] = 1,
[WO_B_NET_SEND_FD] = 2, [WO_B_NET_RECV_FD] = 1, [WO_B_NET_CONNECT_UNIX] = 1,
[WO_B_TERM_SIZE] = 2, [WO_B_TERM_WIDTH] = 1,
/* json (json.c): encode takes the value's static kind, decode the class
id to build */
[WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, [WO_B_MAP_GET_OPT] = 2,

View file

@ -30,12 +30,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <locale.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <wchar.h>
#include "builtin.h"
#include "cont.h"
@ -1643,6 +1645,30 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = WO_NIL_SCALAR; /* plain bytes (or EOF): no fd arrived */
return 0;
}
/* ---- runtime-v2 6: the size read-twin and cell width -------------- */
case WO_B_TERM_SIZE: { /* (fd, cls) -> ?TermSize {cols, rows} */
struct winsize ws;
if (ioctl((int)(int64_t)R[B], TIOCGWINSZ, &ws) != 0) {
R[A] = 0; /* not a tty: nil, an EXPECTED answer */
return 0;
}
wo_hdr *o = record_of(vm, R[B + 1], 2, msg);
if (!o) return R[B + 1] >= vm->mod->class_cnt ? WO_T_BOUNDS : WO_T_OOM;
uint64_t *fp = wo_fields(o);
fp[0] = (uint64_t)ws.ws_col;
fp[1] = (uint64_t)ws.ws_row;
R[A] = (uint64_t)(uintptr_t)o;
return 0;
}
case WO_B_TERM_WIDTH: { /* (codepoint) -> cell width via wcwidth */
static int loc_inited = 0;
if (!loc_inited) {
loc_inited = 1;
if (!setlocale(LC_CTYPE, "C.UTF-8")) setlocale(LC_CTYPE, "");
}
R[A] = (uint64_t)(int64_t)wcwidth((wchar_t)(int64_t)R[B]);
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++)

View file

@ -541,9 +541,15 @@ enum {
* the peer sent plain bytes */
WO_B_NET_CONNECT_UNIX = 107, /* (path) -> Int: AF_UNIX client fd,
* nonblocking */
/* ---- runtime-v2 6: the size read-twin and cell width (wmux 2/6) */
WO_B_TERM_SIZE = 108, /* (fd, cls) -> ?TermSize {cols, rows}:
* TIOCGWINSZ; nil = not a tty */
WO_B_TERM_WIDTH = 109, /* (codepoint) -> Int cell width via libc
* wcwidth under C.UTF-8: -1 control,
* 0 combining, 1, or 2 */
};
#define WO_B_MAX 107u
#define WO_B_MAX 109u
/* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS

View file

@ -8,6 +8,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
@ -404,9 +405,97 @@ static void test_fd_passing(void) {
unlink("/tmp/wo-rt2-fdpass.sock");
}
/* ---- runtime-v2 6: term.size + term.width -------------------------------
* methods: 0 size(fd) -> ?TermSize, 1 width(cp) -> Int */
static uint8_t *size_module(size_t *len) {
wb_t *b = wb_new();
uint32_t kts = wb_const_text(b, "TermSize");
uint32_t ksz = wb_const_text(b, "size");
uint32_t kw = wb_const_text(b, "width");
uint8_t kinds[2] = {WO_K_SCALAR, WO_K_SCALAR};
uint32_t cls = wb_class(b, kts, 0, kinds, 2);
uint32_t kcls = wb_const_int(b, (int64_t)cls);
{ /* size(fd) */
uint32_t code[6];
uint32_t n = 0;
code[n++] = wo_ins_abc(WOP_MOVE, 1, 0, 0);
code[n++] = wo_ins_abx(WOP_LOADK, 2, (uint16_t)kcls);
code[n++] = wo_ins_abc(WOP_BUILTIN, 3, 1, WO_B_TERM_SIZE);
code[n++] = wo_ins_abc(WOP_RET, 3, 0, 0);
wb_method(b, ksz, WOB_NONE, 1, 4, code, n, NULL, 0, NULL, 0);
}
{ /* width(cp) */
uint32_t code[4];
uint32_t n = 0;
code[n++] = wo_ins_abc(WOP_BUILTIN, 1, 0, WO_B_TERM_WIDTH);
code[n++] = wo_ins_abc(WOP_RET, 1, 0, 0);
wb_method(b, kw, WOB_NONE, 1, 2, code, n, NULL, 0, NULL, 0);
}
return wb_finish(b, len);
}
static void test_size_and_width(void) {
size_t len;
uint8_t *img = size_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 ret = 0;
wo_err err;
/* a PTY sized 77x33 from the outside answers exactly that */
int master = posix_openpt(O_RDWR | O_NOCTTY);
T_CHECK(master >= 0);
T_EQ(grantpt(master), 0);
T_EQ(unlockpt(master), 0);
struct winsize ws = {.ws_row = 33, .ws_col = 77};
T_EQ(ioctl(master, TIOCSWINSZ, &ws), 0);
uint64_t sargs[1] = {(uint64_t)master};
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 0, sargs, 1, &ret, &err), 0);
T_CHECK(ret != 0 && ret != WO_NIL_SCALAR);
if (ret != 0 && ret != WO_NIL_SCALAR) {
wo_hdr *o = (wo_hdr *)(uintptr_t)ret;
T_EQ(wo_fields(o)[0], 77u);
T_EQ(wo_fields(o)[1], 33u);
wo_drop_obj(&VM.rt, o);
}
close(master);
/* a pipe is not a tty: nil */
int p[2];
T_EQ(pipe(p), 0);
uint64_t pargs[1] = {(uint64_t)p[0]};
memset(&err, 0, sizeof err);
T_EQ(wo_vm_call(&VM, 0, pargs, 1, &ret, &err), 0);
T_EQ(ret, 0u); /* ?TermSize nil is the zero word (heap-shaped) */
close(p[0]);
close(p[1]);
/* widths under C.UTF-8: ascii 1, CJK 2, combining 0, control -1 */
uint64_t wa[1] = {97};
T_EQ(wo_vm_call(&VM, 1, wa, 1, &ret, &err), 0);
T_EQ((int64_t)ret, 1);
uint64_t wc[1] = {0x4E2D};
T_EQ(wo_vm_call(&VM, 1, wc, 1, &ret, &err), 0);
T_EQ((int64_t)ret, 2);
uint64_t wm[1] = {0x0301};
T_EQ(wo_vm_call(&VM, 1, wm, 1, &ret, &err), 0);
T_EQ((int64_t)ret, 0);
uint64_t wb_[1] = {7};
T_EQ(wo_vm_call(&VM, 1, wb_, 1, &ret, &err), 0);
T_EQ((int64_t)ret, -1);
wo_vm_destroy(&VM);
wo_module_free(&mod);
free(img);
}
int main(void) {
test_signal_on_delivers_record();
test_term_raw_restore();
test_fd_passing();
test_size_and_width();
return t_report("test_term");
}