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:
parent
fb41b73274
commit
574ddc50d7
8 changed files with 370 additions and 1 deletions
BIN
asm/uncommonlisp
BIN
asm/uncommonlisp
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
10
c/builtins.c
10
c/builtins.c
|
|
@ -1226,6 +1226,15 @@ static Value bi_current_time_ms(Value *a, int n, Env *e) {
|
|||
int64_t ms = (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
return VAL_INT(ms);
|
||||
}
|
||||
|
||||
static Value bi_read_from_string(Value *a, int n, Env *e) {
|
||||
(void)e; CHECK_ARITY("read-from-string", 1); check_string(a[0]);
|
||||
int count = 0;
|
||||
Value *exprs = read_all(AS_STRING(a[0])->data, &count, false);
|
||||
Value r = (count > 0) ? exprs[0] : VAL_FALSE;
|
||||
ul_free(exprs);
|
||||
return r;
|
||||
}
|
||||
static Value bi_open_output_string(Value *a, int n, Env *e) {
|
||||
(void)e;
|
||||
return make_string_output_port();
|
||||
|
|
@ -1866,6 +1875,7 @@ Env *make_global_env(void) {
|
|||
DEF("heap-snapshot", bi_heap_snapshot);
|
||||
DEF("heap-restore", bi_heap_restore);
|
||||
DEF("current-time-ms", bi_current_time_ms);
|
||||
DEF("read-from-string", bi_read_from_string);
|
||||
DEF("open-input-string", bi_open_input_string);
|
||||
DEF("open-output-string", bi_open_output_string);
|
||||
DEF("get-output-string", bi_get_output_string);
|
||||
|
|
|
|||
87
examples/repl-server.lsp
Normal file
87
examples/repl-server.lsp
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
;;; repl-server.lsp — REMOTE SCHEME REPL OVER TCP.
|
||||
;;;
|
||||
;;; DANGER: this accepts ANY S-expression from the network and evaluates
|
||||
;;; it in the global environment. Anyone who can reach the TCP port can
|
||||
;;; run arbitrary Scheme code in this process — read files, open sockets,
|
||||
;;; shell out via system calls if any are exposed, leak the env, etc.
|
||||
;;;
|
||||
;;; Run on localhost only. Do not expose publicly. This exists to show
|
||||
;;; what the "language IS the interchange" thesis gets you when taken
|
||||
;;; to its logical end: a single socket carries a full-powered REPL
|
||||
;;; because both sides already have a reader and an evaluator.
|
||||
;;;
|
||||
;;; Usage:
|
||||
;;; python3 uncommonlisp.py --fast examples/repl-server.lsp
|
||||
;;; ./c/uncommonlisp examples/repl-server.lsp
|
||||
;;; ./asm/uncommonlisp < examples/repl-server.lsp
|
||||
;;;
|
||||
;;; Then from a client:
|
||||
;;; (define x 42) ; server mutates its global env
|
||||
;;; (* x 10) ; => 420
|
||||
;;; (map car '((1 a) (2 b))) ; => (1 2)
|
||||
;;;
|
||||
;;; Each connection = one request + one response. Persistent sessions
|
||||
;;; across connections because all defines land in the shared global env.
|
||||
|
||||
(define *port* 9081)
|
||||
(define *max-requests* 10000)
|
||||
|
||||
;; Portable serializer — same as rpc-server.lsp pattern.
|
||||
;; Avoids open-output-string and `guard` (neither exists in asm).
|
||||
|
||||
(define (atom->string v)
|
||||
(cond
|
||||
((number? v) (number->string v))
|
||||
((symbol? v) (symbol->string v))
|
||||
((null? v) "()")
|
||||
((pair? v) (string-append "(" (list->string v) ")"))
|
||||
((string? v) (string-append "\"" v "\""))
|
||||
(else "#<unknown>")))
|
||||
|
||||
(define (list->string lst)
|
||||
(cond
|
||||
((null? lst) "")
|
||||
((null? (cdr lst)) (atom->string (car lst)))
|
||||
(else (string-append (atom->string (car lst)) " " (list->string (cdr lst))))))
|
||||
|
||||
(define (response->string v)
|
||||
(cond
|
||||
((number? v) (string-append (number->string v) "\n"))
|
||||
((symbol? v) (string-append (symbol->string v) "\n"))
|
||||
((null? v) "()\n")
|
||||
((pair? v) (string-append "(" (list->string v) ")\n"))
|
||||
((string? v) (string-append "\"" v "\"\n"))
|
||||
(else "#<unknown>\n")))
|
||||
|
||||
(define (handle-request raw)
|
||||
(let ((form (read-from-string raw)))
|
||||
(if (eqv? form #f)
|
||||
"(error \"empty or malformed input\")\n"
|
||||
(response->string (eval form)))))
|
||||
|
||||
(define server (tcp-listen *port*))
|
||||
|
||||
;; Intentionally does NOT use heap-snapshot. A remote `(define x ...)`
|
||||
;; adds a new binding to the global env chain — heap cells allocated
|
||||
;; AFTER any snapshot point. Rewinding would invalidate those bindings.
|
||||
;; The asm heap grows with each new top-level define; the ulimit -v
|
||||
;; safety net kills the process if it escapes. Each request still
|
||||
;; produces garbage (tcp-recv buffer, intermediate strings) that stays
|
||||
;; forever — acceptable for a demo, at ~100 bytes per request plus
|
||||
;; whatever `define` binds.
|
||||
|
||||
(define (server-loop n)
|
||||
(if (>= n *max-requests*)
|
||||
(begin (display "request cap reached, exiting\n") (tcp-close server))
|
||||
(begin
|
||||
(let ((client (tcp-accept server)))
|
||||
(let ((req (tcp-recv client 8192)))
|
||||
(if (and req (> (string-length req) 0))
|
||||
(tcp-send client (handle-request req))
|
||||
#f))
|
||||
(tcp-close client))
|
||||
(server-loop (+ n 1)))))
|
||||
|
||||
(display "repl-server on :") (display *port*)
|
||||
(display " — DANGER: full remote eval") (newline)
|
||||
(server-loop 0)
|
||||
53
examples/rpc-client.lsp
Normal file
53
examples/rpc-client.lsp
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
;;; rpc-client.lsp — send one S-expression, read one back.
|
||||
;;;
|
||||
;;; Demonstrates the three-way symmetry: the CLIENT is also portable
|
||||
;;; across Python, C, and asm. Any client impl talks to any server impl
|
||||
;;; because they agree on S-expression framing.
|
||||
;;;
|
||||
;;; Usage:
|
||||
;;; python3 uncommonlisp.py --fast examples/rpc-client.lsp
|
||||
;;; ./c/uncommonlisp examples/rpc-client.lsp
|
||||
;;; ./asm/uncommonlisp < examples/rpc-client.lsp
|
||||
;;;
|
||||
;;; Override *target-port* before loading to hit either rpc-server (9080)
|
||||
;;; or repl-server (9081).
|
||||
|
||||
(define *host* "127.0.0.1")
|
||||
(define *target-port* 9080)
|
||||
|
||||
;; Serialize a form to a string — same pattern as rpc-server's
|
||||
;; response->string. Avoids open-output-string so this runs in asm.
|
||||
(define (form->string v)
|
||||
(cond
|
||||
((number? v) (number->string v))
|
||||
((symbol? v) (symbol->string v))
|
||||
((null? v) "()")
|
||||
((pair? v) (string-append "(" (list->string v) ")"))
|
||||
((string? v) (string-append "\"" v "\""))
|
||||
(else "#<unknown>")))
|
||||
(define (list->string lst)
|
||||
(cond
|
||||
((null? lst) "")
|
||||
((null? (cdr lst)) (form->string (car lst)))
|
||||
(else (string-append (form->string (car lst)) " " (list->string (cdr lst))))))
|
||||
|
||||
(define (rpc request-form)
|
||||
(let ((sock (tcp-connect *host* *target-port*)))
|
||||
(tcp-send sock (form->string request-form))
|
||||
(let ((resp (tcp-recv sock 8192)))
|
||||
(tcp-close sock)
|
||||
(if (and resp (> (string-length resp) 0))
|
||||
(read-from-string resp)
|
||||
'no-response))))
|
||||
|
||||
(define (demo form)
|
||||
(display "→ ") (write form) (newline)
|
||||
(display "← ") (write (rpc form)) (newline)
|
||||
(newline))
|
||||
|
||||
(demo '(ping))
|
||||
(demo '(add 1 2 3 4 5))
|
||||
(demo '(mul 6 7))
|
||||
(demo '(fib 30))
|
||||
(demo '(echo (hello world)))
|
||||
(demo '(nope whatever))
|
||||
110
examples/rpc-server.lsp
Normal file
110
examples/rpc-server.lsp
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
;;; rpc-server.lsp — S-expression RPC with a whitelisted dispatch table.
|
||||
;;;
|
||||
;;; Wire protocol: each connection carries ONE request S-expression and
|
||||
;;; returns ONE response S-expression. Bytes on the wire are Scheme source;
|
||||
;;; the parser on each side is already the right tool.
|
||||
;;;
|
||||
;;; Runs byte-identically in Python, C, and asm:
|
||||
;;; python3 uncommonlisp.py --fast examples/rpc-server.lsp
|
||||
;;; ./c/uncommonlisp examples/rpc-server.lsp
|
||||
;;; ./asm/uncommonlisp < examples/rpc-server.lsp
|
||||
;;;
|
||||
;;; Request examples (send as plain text, one per connection):
|
||||
;;; (ping) -> pong
|
||||
;;; (add 1 2 3) -> 6
|
||||
;;; (mul 6 7) -> 42
|
||||
;;; (fib 30) -> 832040
|
||||
;;; (echo (1 2 3)) -> (1 2 3)
|
||||
;;; (nope whatever) -> (error "unknown op: nope")
|
||||
;;;
|
||||
;;; The server never calls (eval) on client input. Only whitelisted ops
|
||||
;;; run. This is the safe RPC pattern. For full remote eval see
|
||||
;;; examples/repl-server.lsp.
|
||||
|
||||
(define *port* 9080)
|
||||
(define *max-requests* 100000)
|
||||
|
||||
;;; ── Whitelisted handlers ────────────────────────────────────
|
||||
|
||||
(define (do-ping args) 'pong)
|
||||
(define (do-echo args) (if (pair? args) (car args) '()))
|
||||
(define (do-add args)
|
||||
(if (null? args) 0
|
||||
(+ (car args) (do-add (cdr args)))))
|
||||
(define (do-mul args)
|
||||
(if (null? args) 1
|
||||
(* (car args) (do-mul (cdr args)))))
|
||||
(define (do-fib args)
|
||||
(let loop ((a 0) (b 1) (i 0) (n (car args)))
|
||||
(if (= i n) a (loop b (+ a b) (+ i 1) n))))
|
||||
|
||||
(define (dispatch op args)
|
||||
(cond
|
||||
((eqv? op 'ping) (do-ping args))
|
||||
((eqv? op 'echo) (do-echo args))
|
||||
((eqv? op 'add) (do-add args))
|
||||
((eqv? op 'mul) (do-mul args))
|
||||
((eqv? op 'fib) (do-fib args))
|
||||
(else (list 'error (string-append "unknown op: " (symbol->string op))))))
|
||||
|
||||
;;; ── Wire handler ────────────────────────────────────────────
|
||||
|
||||
(define (handle-request raw)
|
||||
;; raw is a string like "(add 1 2)". Parse, dispatch, return a string.
|
||||
(let ((form (read-from-string raw)))
|
||||
(if (pair? form)
|
||||
(let ((op (car form)) (args (cdr form)))
|
||||
(response->string (dispatch op args)))
|
||||
(response->string (list 'error "malformed request")))))
|
||||
|
||||
;; Defined leaf-first so closures never capture a forward reference —
|
||||
;; which the asm impl resolves at define time via env-chain pointer
|
||||
;; and therefore cannot see a name bound later.
|
||||
|
||||
(define (atom->string v)
|
||||
(cond
|
||||
((number? v) (number->string v))
|
||||
((symbol? v) (symbol->string v))
|
||||
((null? v) "()")
|
||||
((pair? v) (string-append "(" (list->string v) ")"))
|
||||
((string? v) (string-append "\"" v "\""))
|
||||
(else "#<unknown>")))
|
||||
|
||||
(define (list->string lst)
|
||||
(cond
|
||||
((null? lst) "")
|
||||
((null? (cdr lst)) (atom->string (car lst)))
|
||||
(else (string-append (atom->string (car lst)) " " (list->string (cdr lst))))))
|
||||
|
||||
(define (response->string v)
|
||||
;; Custom readable serializer — avoids open-output-string so this runs
|
||||
;; unchanged in asm (which lacks mutable string ports). Covers the
|
||||
;; response shapes our dispatch table can return.
|
||||
(cond
|
||||
((number? v) (string-append (number->string v) "\n"))
|
||||
((symbol? v) (string-append (symbol->string v) "\n"))
|
||||
((null? v) "()\n")
|
||||
((pair? v) (string-append "(" (list->string v) ")\n"))
|
||||
((string? v) (string-append "\"" v "\"\n"))
|
||||
(else "#<unknown>\n")))
|
||||
|
||||
;;; ── Main loop ───────────────────────────────────────────────
|
||||
|
||||
(define server (tcp-listen *port*))
|
||||
|
||||
(define (server-loop n snap)
|
||||
(if (>= n *max-requests*)
|
||||
(begin (display "request cap reached, exiting\n") (tcp-close server))
|
||||
(begin
|
||||
(let ((client (tcp-accept server)))
|
||||
(let ((req (tcp-recv client 4096)))
|
||||
(if (and req (> (string-length req) 0))
|
||||
(tcp-send client (handle-request req))
|
||||
#f))
|
||||
(tcp-close client))
|
||||
(heap-restore snap)
|
||||
(server-loop (+ n 1) snap))))
|
||||
|
||||
(display "rpc-server on :") (display *port*)
|
||||
(display " (whitelisted: ping echo add mul fib)") (newline)
|
||||
(server-loop 0 (heap-snapshot))
|
||||
|
|
@ -2659,6 +2659,11 @@ def _read_file_to_string(path):
|
|||
except OSError:
|
||||
return False
|
||||
|
||||
def _read_from_string(s):
|
||||
"""Parse one S-expression from the given string. Returns first form."""
|
||||
forms = list(read_all(s))
|
||||
return forms[0] if forms else False
|
||||
|
||||
import socket as _sockmod
|
||||
def _tcp_listen(port):
|
||||
s = _sockmod.socket(_sockmod.AF_INET, _sockmod.SOCK_STREAM)
|
||||
|
|
@ -3279,6 +3284,10 @@ def make_global_env():
|
|||
d(S('heap-snapshot'), lambda a, _: False)
|
||||
d(S('heap-restore'), lambda a, _: VOID)
|
||||
d(S('current-time-ms'), lambda a, _: int(__import__('time').time() * 1000))
|
||||
d(S('read-from-string'), lambda a, _: _read_from_string(_str_val(a[0])))
|
||||
# eval is already a special form (see leval); exposing it as a builtin would
|
||||
# be shadowed by that dispatch. RPC servers can still call `(eval sexp)`
|
||||
# literally because the special form handles it.
|
||||
d(S('open-input-string'), lambda a, _: StringInputPort(_str_val(a[0])))
|
||||
d(S('open-output-string'),lambda a, _: StringOutputPort())
|
||||
d(S('get-output-string'), lambda a, _: a[0].getvalue() if isinstance(a[0], StringOutputPort) else '')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue