S-expressions over sockets — RPC + remote REPL in portable Scheme

Fuses portal (Scheme-source-as-interchange) with sockets (bytes over
the network). Wire protocol: one S-expression per connection. Same
server + client .lsp runs byte-identically in Python, C, and asm.

New primitives in all three impls:
- read-from-string — parse one sexp from a string

Asm gets two more:
- symbol->string — standard R7RS, was missing
- eval — evaluate a Scheme value in the global env (Python + C had
  it as a special form; asm exposes it as a builtin)

examples/rpc-server.lsp (port 9080):
- Whitelisted dispatch: ping / add / mul / fib / echo
- Never calls eval on client input; safe by construction
- Uses heap-snapshot/restore for O(1) memory on asm
- ~90 lines, portable

examples/rpc-client.lsp:
- Sends one request, reads one response, displays both
- 45 lines, portable

examples/repl-server.lsp (port 9081):
- DANGER: full remote eval. Any Scheme form accepted and evaluated
  in the server's global env. Persistent across connections.
- Deliberately does NOT use heap-snapshot — remote (define x ...)
  lives in the global env above any snapshot point; rewinding would
  invalidate the new binding. The ulimit -v 512 MB safety net
  (documented in CLAUDE.md) ensures an escaped process can't crash
  the machine.
- ~70 lines, portable. Demonstrates what "the language IS the
  interchange format" gets you at the limit: a single socket and
  a single primitive (eval) carry a full-powered REPL.

Verified 3×3 server×client matrix: all 9 combinations green.
All 132 asm + 571 py + 189 shared + 83 c tests still pass.

One quirk discovered and worked around: in asm, a closure captures
its env chain by pointer at define time. Forward-referenced names
in mutually-recursive toplevel defines can fail under specific
heap-restore patterns — see the leaf-first ordering note in
rpc-server.lsp.
This commit is contained in:
russell@unturf.com 2026-04-17 09:00:04 -04:00
parent fb41b73274
commit 574ddc50d7
8 changed files with 370 additions and 1 deletions

Binary file not shown.

Binary file not shown.

View file

@ -165,7 +165,10 @@
.equ BI_HEAPSNAP, 85
.equ BI_HEAPREST, 86
.equ BI_CURTIME, 87
.equ BI_COUNT, 88
.equ BI_READSTR, 88
.equ BI_EVAL, 89
.equ BI_SYMTOSTR, 90
.equ BI_COUNT, 91
# ============================================================
.data
@ -279,6 +282,9 @@ bn_tcpclose: .byte 9; .ascii "tcp-close"
bn_heapsnap: .byte 13; .ascii "heap-snapshot"
bn_heaprest: .byte 12; .ascii "heap-restore"
bn_curtime: .byte 15; .ascii "current-time-ms"
bn_readstr: .byte 16; .ascii "read-from-string"
bn_eval: .byte 4; .ascii "eval"
bn_symtostr: .byte 14; .ascii "symbol->string"
portal_magic: .ascii "ULPORTAL"
.equ PORTAL_MAGIC_LEN, 8
@ -309,6 +315,7 @@ bi_names:
.quad bn_tcplisten, bn_tcpaccept, bn_tcpconnect
.quad bn_tcprecv, bn_tcpsend, bn_tcpclose
.quad bn_heapsnap, bn_heaprest, bn_curtime
.quad bn_readstr, bn_eval, bn_symtostr
# Error messages
err_unbound: .ascii "Error: unbound variable: "
@ -2503,6 +2510,12 @@ eval_list:
je bi_heap_restore
cmpq $BI_CURTIME, %rax
je bi_current_time_ms
cmpq $BI_READSTR, %rax
je bi_read_from_string
cmpq $BI_EVAL, %rax
je bi_eval
cmpq $BI_SYMTOSTR, %rax
je bi_symbol_to_string
movq $VAL_VOID, %rax
popq %r12
@ -4454,6 +4467,93 @@ bi_heap_restore:
movq $VAL_VOID, %rax
RET_VAL
# bi_read_from_string: (read-from-string "sexp") → value
# Saves the current input-buffer state, swaps in the caller's string
# as the input source, reads one expression, restores input state.
bi_read_from_string:
GETARG %rdi
andq $-8, %rdi
movq (%rdi), %rcx # length
leaq 8(%rdi), %r8 # pointer to string bytes
# Save input state (buf_ptr, pos, end, is_file) to stack
movq input_buf_ptr(%rip), %rax
pushq %rax
movq input_pos(%rip), %rax
pushq %rax
movq input_end(%rip), %rax
pushq %rax
movq input_is_file(%rip), %rax
pushq %rax
# Swap input to the string. Mark as "file" so no stdin refill.
movq %r8, input_buf_ptr(%rip)
movq $0, input_pos(%rip)
movq %rcx, input_end(%rip)
movq $1, input_is_file(%rip)
call scheme_read # rax = value (or 0 for EOF)
movq %rax, %rbx # stash result in callee-save
# Restore input state (pop reversed order of push)
popq %rax
movq %rax, input_is_file(%rip)
popq %rax
movq %rax, input_end(%rip)
popq %rax
movq %rax, input_pos(%rip)
popq %rax
movq %rax, input_buf_ptr(%rip)
# Translate EOF (0) into VAL_FALSE so callers can distinguish
movq %rbx, %rax
testq %rax, %rax
jnz .brfs_done
movq $VAL_FALSE, %rax
.brfs_done:
RET_VAL
# bi_eval: (eval expr) → value (evaluates in global env r14)
bi_eval:
GETARG %rdi # expr
movq %r14, %rsi # env
call eval
RET_VAL
# bi_symbol_to_string: (symbol->string 'foo) → "foo"
# Symbols are stored as [length-byte, bytes...] at ptr (low 3 bits = TAG_SYM).
# Return a fresh string cell [8-byte length, bytes...].
bi_symbol_to_string:
GETARG %rdi # symbol value
andq $-8, %rdi # untag
movzbq (%rdi), %rcx # length (one byte)
leaq 1(%rdi), %r8 # byte pointer
pushq %rcx
pushq %r8
movq %rcx, %rdi
addq $8, %rdi # cell size: 8-byte length + bytes
call heap_alloc
popq %r8
popq %rcx
movq %rcx, (%rax) # store length as 8 bytes
movq %rax, %rbx # save result pointer
leaq 8(%rax), %rdx # dest
.b_s2s_copy:
testq %rcx, %rcx
jz .b_s2s_done
movb (%r8), %r9b
movb %r9b, (%rdx)
incq %r8
incq %rdx
decq %rcx
jmp .b_s2s_copy
.b_s2s_done:
movq %rbx, %rax
orq $TAG_STRING, %rax
RET_VAL
# bi_current_time_ms: (current-time-ms) → int ms since epoch
# struct timespec is { int64_t tv_sec; int64_t tv_nsec; } — 16 bytes.
bi_current_time_ms: