flan/lib/wire.ml
Joseph Ferano 23b440db16 flan dev: a session, the program beside it, and a socket
The piece between an editor and everything else. One long-lived Session, the
program it belongs to launched and owned by the same process, and a socket that
takes forms and installs them. What it adds over flan reload is that the
session persists - a defvar added by one evaluation is part of what the next is
checked against - and that it owns the build, which is what makes its layout
rules describe the process actually running rather than a guess about it.

The protocol is s-expressions rather than bencode, and I changed my mind about
that. The case for nREPL was reusing a designed op set and not re-litigating
session identity, but with the client ours too there is no CIDER to be
compatible with, its eval is string-in/string-out with no slot for which form
from which file, and Emacs already has read and prin1. So: one sexp per
message, length framed because the payload contains newlines. No parsing code
on the editor side, and on this side the parser is the language's own reader,
where :op is already a keyword and Flan source is already a string literal. An
nREPL front end can sit on the same Session later; it should not gate the
editor.

Two silent failures the daemon refuses to have. The agent socket is chosen by
the daemon and forced through FLAN_AGENT_SOCKET before spawning, because a
program's source has to name some path and a daemon that guessed would compile,
build and deliver a module to nobody. And delivery is checked: agent/start
returning 0 means a socket was bound, not that anyone connected, so a failed
connect or a reply that is not ok becomes an error the editor sees.

It waits for the program to bind before accepting an evaluation, since one
arriving first fails for a reason that reads like a compiler bug, and it
accepts with a timeout so a program that has exited takes the daemon with it
instead of leaving an editor waiting on a socket nobody serves.
2026-09-10 22:07:33 +07:00

96 lines
3.1 KiB
OCaml

(** The editor protocol: one s-expression per message, length framed.
Not bencode and not nREPL, and the reason is that both ends are ours. There
is no CIDER to be compatible with, nREPL's [eval] is string-in/string-out
with no slot for *which form, from which file*, and Emacs already has
[read] and [prin1] — so a sexp protocol is no parsing code on that side and
a few lines here, where the reader that parses it is the language's own.
An nREPL server can be a second front end on the same [Session] later; it
should not gate the editor.
Framing is a decimal byte count, a newline, then that many bytes. A message
carries Flan source, which contains newlines, so a line-oriented protocol
would need an escape layer that this does not. *)
(* Elisp's [read] understands \\n and \\t but not OCaml's \\ddd, so this
escapes the two characters that must be escaped and passes everything else
through as itself. Both readers take a raw newline inside a string. *)
let quote s =
let b = Buffer.create (String.length s + 8) in
Buffer.add_char b '"';
String.iter
(fun c ->
if c = '"' || c = '\\' then Buffer.add_char b '\\';
Buffer.add_char b c)
s;
Buffer.add_char b '"';
Buffer.contents b
let list items = "(" ^ String.concat " " items ^ ")"
let strings ss = list (List.map quote ss)
let send fd payload =
let framed = Printf.sprintf "%d\n%s" (String.length payload) payload in
let n = String.length framed in
let rec go i =
if i < n then
match Unix.write_substring fd framed i (n - i) with
| 0 -> ()
| k -> go (i + k)
in
go 0
exception Closed
let read_exactly fd n =
let b = Bytes.create n in
let rec go i =
if i = n then Bytes.to_string b
else
match Unix.read fd b i (n - i) with
| 0 -> raise Closed
| k -> go (i + k)
in
go 0
(* The header is short and read a byte at a time, which keeps the payload
boundary exact without a buffer that would have to be carried between
calls. *)
let recv fd =
let b = Bytes.create 1 in
let buf = Buffer.create 16 in
let rec header () =
match Unix.read fd b 0 1 with
| 0 -> raise Closed
| _ ->
if Bytes.get b 0 = '\n' then Buffer.contents buf
else begin Buffer.add_char buf (Bytes.get b 0); header () end
in
let n =
match int_of_string_opt (String.trim (header ())) with
| Some n when n >= 0 -> n
| _ -> raise Closed
in
read_exactly fd n
(* A request is read by the language's own reader, so [:op] is a keyword and a
payload of Flan source is an ordinary string literal. *)
let field (form : Form.t) key =
let rec go = function
| { Form.v = Form.Kw k; _ } :: v :: rest ->
if String.equal k key then Some v else go rest
| _ :: rest -> go rest
| [] -> None
in
match form.Form.v with Form.List l -> go l | _ -> None
let string_field form key =
match field form key with
| Some { Form.v = Form.Str s; _ } -> Some s
| _ -> None
let parse src =
match Reader.read_all ~file:"<wire>" src with
| [ f ] -> f
| _ -> Loc.fail Loc.unknown "one form per message"