Merge: an x86 dev build pushes the frames the inspector reads

This commit is contained in:
Joseph Ferano 2026-09-19 04:47:30 +07:00
commit 6055e9c247
2 changed files with 386 additions and 6 deletions

View File

@ -448,8 +448,18 @@ let layout_ctx ~checks ~dev (p : Tast.program) : Emit.m =
p.Tast.datas;
List.iter (fun (u : Tast.structure) -> Hashtbl.replace unions u.Tast.sname u)
p.Tast.unions;
(* The globals table is filled and not left empty, which it was for as long
as nothing here asked a question about a name. [Reach.ref_fingerprint]
does: it hashes the set of *globals* a body names, and with an empty table
no name is a global, so every frame descriptor would carry the hash of the
empty set while the daemon recomputes it against the real one. The frames
would then all read as superseded and the break loop would refuse the
locals of a body nobody had touched. *)
let globals = Hashtbl.create 16 in
List.iter (fun (g : Tast.global) -> Hashtbl.replace globals g.Tast.gname g.Tast.gty)
p.Tast.globals;
{ Emit.out = Buffer.create 1; strs = Buffer.create 1; structs; datas; unions;
globals = Hashtbl.create 1; externs = Hashtbl.create 1; checks;
globals; externs = Hashtbl.create 1; checks;
dev; known = (fun _ -> true); dbg = None; sanitize = false;
nstr = 0; nfi = 0 }
@ -616,6 +626,22 @@ type fnctx = {
mutable xfer_off : int; (* the incoming transfer channel pointer *)
mutable sret_off : int; (* where the hidden return pointer was put *)
mutable retval : int; (* the scalar return value's temporary *)
(* The shadow-stack record this function pushes, in a dev build: three words
at [rbp+dframe], holding the previous head, the static description, and
the slot table. [None] in a release build, where there is no record and
the epilogue below restores nothing. *)
mutable dframe : int option;
(* Where a dev build records each named slot's address, so that a stopped
frame's locals can be read: one word per Tast slot at [rbp+dslotv]. [None]
in a release build and in a function with no named slot at all the same
gate [emit.ml] applies, and for a reason that is LLVM's rather than ours
(an alloca whose address escapes stops being promotable). Keeping the gate
anyway is what makes the two backends' frames answer identically, which is
the only thing the break loop can check. *)
mutable dslotv : int option;
(* [fn.snames], carried so [bind_slot] can ask whether a slot has a name to
show without the whole [Tast.fn] being threaded to every binding site. *)
snames : string option array;
mutable frame : int; (* bytes currently allocated below rbp *)
mutable maxframe : int;
mutable outgoing : int; (* bytes the widest call needs for stack args *)
@ -936,6 +962,71 @@ let float_const f (x : float) ~f64 =
(Printf.sprintf "\t.align 4\n%s:\n\t.long 0x%lx\n" l (Int32.bits_of_float x));
l
(* ── The shadow stack's descriptors ──────────────────────────────────── *)
(* One [flan_fninfo] (runtime/flan_dev.c) per function in a dev build: the
name and the location as bytes and lengths, how many slots the frame has,
and the two fingerprints the daemon compares a frame against. Static data
nothing about a function changes between two calls to it so the record a
call pushes is three words and points at this.
Not through [string_const], and that is the whole reason this has its own
emitter rather than borrowing that one. [string_const] bumps [md.nstr],
which is the test [redefinition] applies before it lets an expression
thunk's module say nothing points into it: a literal in the image may be
held by the program afterwards, so a module with one keeps its mapping. A
descriptor is not held the frames naming it were popped on the way out and
the break loop copies the bytes it shows. Counting these there would stop
every C-x C-e module from ever being unloaded, which is [emit.ml]'s [m.nfi]
comment and the same trap on this side.
The bytes carry no NUL and are not meant to: every reader takes the length
beside the pointer. *)
let fi_bytes f s =
let l = rodata_label f in
Buffer.add_string f.rodata (Printf.sprintf "\t.align 1\n%s:\n" l);
if String.length s > 0 then
Buffer.add_string f.rodata (Printf.sprintf "\t.byte %s\n" (escape_bytes s));
l, String.length s
(* [.data.rel.ro] and not [.rodata], which every other constant here goes in.
This is the only constant this backend emits that holds an *address*, and an
address in a shared object is a relocation the loader applies at load time
so the section has to be one the loader may write. A redefinition module is
exactly such an object. The section is switched back afterwards because the
caller's buffer is emitted inside [.rodata] and everything after this in it
belongs there. *)
let fninfo f (fn : Tast.fn) ~nslots =
let nlbl, nlen = fi_bytes f fn.Tast.name in
let llbl, llen = fi_bytes f (Loc.to_string fn.Tast.floc) in
let l = rodata_label f in
Buffer.add_string f.rodata
(Printf.sprintf
"\t.section\t.data.rel.ro,\"aw\"\n\t.align 8\n%s:\n\
\t.quad\t%s\n\t.quad\t%d\n\t.quad\t%s\n\t.quad\t%d\n\
\t.long\t%d\n\t.long\t%d\n\t.long\t%d\n\t.long\t0\n\
\t.section\t.rodata\n"
l nlbl nlen llbl llen nslots (Emit.slot_fingerprint fn)
(Reach.ref_fingerprint ~is_global:(Hashtbl.mem f.md.Emit.globals) fn));
l
(* The store that says "this slot is bound now", and it is the address rather
than a flag for [emit.ml]'s reason: the reader needs the address anyway, so
one store carries both facts, and a slot the control flow has not reached
reads as null rather than as a plausible value at an address nobody wrote.
rax is free at every site that calls this each one has just finished
moving a value into the slot, and nothing in this backend is live in a
register across a statement. *)
let bind_slot f i =
match f.dslotv with
| None -> ()
| Some sv ->
if i < Array.length f.snames && f.snames.(i) <> None then begin
lea f.b ~dst:rax ~mm:(Frame f.slots.(i));
store_int f.b ~src:rax ~mm:(Frame (sv + (8 * i))) ~size:8
end
(* ── Locations ───────────────────────────────────────────────────────── *)
(* Where a value lives. Every value in this backend lives in memory, so the
@ -1476,7 +1567,8 @@ and lower_at f (e : Tast.expr) (dst : loc) : unit =
| Tast.Let (bs, body) ->
List.iter
(fun (slot, (v : Tast.expr)) ->
scoped f (fun () -> lower f v (Lf f.slots.(slot))))
scoped f (fun () -> lower f v (Lf f.slots.(slot)));
bind_slot f slot)
bs;
block f body dst t
| Tast.If (c, a, b) ->
@ -1847,7 +1939,8 @@ and emit_restart_case f clauses body dst t =
List.iteri
(fun i (slot_i, ty) ->
move f ~dst:(Lf f.slots.(slot_i))
~src:(Lp (bufp, List.nth offs i)) ty)
~src:(Lp (bufp, List.nth offs i)) ty;
bind_slot f slot_i)
c.Tast.rparams);
scoped f (fun () -> block f c.Tast.rbody dst t);
jmp_lbl f.b ld;
@ -2045,7 +2138,8 @@ and emit_match f (scrut : Tast.expr) (arms : Tast.arm list) dst t =
let src, fty =
bind_at (match a.Tast.acase with Some c -> c | None -> "") k
in
move f ~dst:(Lf f.slots.(slot)) ~src fty)
move f ~dst:(Lf f.slots.(slot)) ~src fty;
bind_slot f slot)
a.Tast.binds;
block f a.Tast.abody dst t;
jmp_lbl f.b lend;
@ -2935,7 +3029,8 @@ let where_from = function
| Istk d -> Printf.sprintf "from the caller's stack, at rbp+0x%x" d
let frame_map (md : Emit.m) (fn : Tast.fn) ~slots ~fixed ~total ~outgoing
~xfer_off ~sret_off ~retval ~sret ~sret_at ~param_at ~xfer_at =
~xfer_off ~sret_off ~retval ~dframe ~dslotv ~sret ~sret_at ~param_at
~xfer_at =
let b = Buffer.create 1024 in
let line s = Buffer.add_string b (if s = "" then "#\n" else "# " ^ s ^ "\n") in
(* The prose paragraphs wrap; the table below does not, because its columns
@ -3053,6 +3148,18 @@ let frame_map (md : Emit.m) (fn : Tast.fn) ~slots ~fixed ~total ~outgoing
if (not sret) && not (is_void fn.Tast.ret) then
row retval "<ret>" (Types.to_string fn.Tast.ret)
"the return value the epilogue loads";
(* Present in a dev build and absent from a release one, and the map says
which build this is by saying nothing when there is nothing there. *)
(match dframe with
| Some off ->
row off "<frame>" "ptr[3]"
"the shadow-stack record: the previous head, the description, the slots"
| None -> ());
(match dslotv with
| Some off ->
row off "<slots>" "ptr[]"
"one address per slot, null until the binding that fills it has run"
| None -> ());
para (Printf.sprintf
"Everything below -0x%x is a temporary. They are bump-allocated and reclaimed \
at the end of the form that made them, so a later form reuses the bytes and \
@ -3069,6 +3176,7 @@ let emit_fn (md : Emit.m) ~externs ~fns ?(ext = fun _ -> false)
{ b; md; fnname = fn.Tast.name; retlbl = "";
fret = fn.Tast.ret; slots = Array.make nslots 0;
xfer_off = 0; sret_off = 0; retval = 0;
dframe = None; dslotv = None; snames = fn.Tast.snames;
frame = 0; maxframe = 0; outgoing = 0;
loops = []; pads = []; xfer_lbl = ""; unwound = false;
rodata = Buffer.create 64; externs; fns; ext; slot; dw;
@ -3111,6 +3219,24 @@ let emit_fn (md : Emit.m) ~externs ~fns ?(ext = fun _ -> false)
f.xfer_off <- ptmp f;
if sret then f.sret_off <- ptmp f;
if (not sret) && not (is_void fn.Tast.ret) then f.retval <- tmp f fn.Tast.ret;
(* The shadow stack's storage, in a dev build and nowhere else: three words
for the record and one per slot for the table. Allocated here, beside the
channel and the return temporary, so that they sit above [fixed] and the
frame map has somewhere to name them; and allocated outside every [scoped]
so nothing below reuses the bytes.
Only a function with at least one *named* slot gets a table, which is the
gate [emit.ml] applies. There it buys an optimisation an alloca whose
address escapes is one mem2reg cannot promote and nothing here promotes
anything, so on this side it buys only the stores. It is kept because the
two backends have to answer a stopped frame identically: a function whose
every slot the compiler invented reports no slots on LLVM, and a listing
that differed by backend is the one thing the break loop cannot check. *)
if md.Emit.dev then begin
if nslots > 0 && Array.exists (fun n -> n <> None) fn.Tast.snames then
f.dslotv <- Some (alloc f (8 * nslots) 8);
f.dframe <- Some (alloc f 24 8)
end;
f.retlbl <- new_label f "ret";
f.xfer_lbl <- new_label f "xfer";
(* Where the named part of the frame ends and the nameless part begins. Read
@ -3132,6 +3258,55 @@ let emit_fn (md : Emit.m) ~externs ~fns ?(ext = fun _ -> false)
| _ -> None)
fn.Tast.params param_at
in
(* The shadow stack's push, and the pop is in the epilogue. plan.org has had
*Frames: shadow stack* in the dev column since the beginning; [emit.ml]
builds the same four words on the LLVM side and this is the x86 one, down
to which fields are written and when, so that a break loop cannot tell
which backend it stopped.
Inline rather than a call, for [emit.ml]'s reason: this is on every call in
a dev build, and a call made to record a call would be most of what it
costs. What it does cost here is a handful of stores the table's zeroing,
three words of record, one head store, and one [lea]+store per named
parameter.
Emitted into the body buffer rather than the prologue's, and that is not a
detail: the prologue is where the incoming registers still hold the
arguments, and every instruction below clobbers rax. The body buffer's
first byte is the first point at which they are all safely in the frame.
One push per [emit_fn] and no more. [emit_main], [emit_globals_init] and
[flan_reload_install] build their context by hand and push nothing, which
is what [emit.ml] does too: they are C's frames, not the program's, and a
backtrace that named them would be naming the runtime. *)
(match f.dframe with
| None -> ()
| Some fr ->
if ann then set_ind f.b "";
note f
"The shadow stack's push — runtime/flan_dev.c. Dev builds only, and it is what \
lets a stopped program say where it is. The pop is the first thing in the \
epilogue, so a transfer out of this frame pops it too.";
(* Every entry, not only the named ones: "null means not bound" has to
hold at every index, or a reader has to know which indices it may
trust, and that is a second thing to keep in step. *)
(match f.dslotv with
| Some sv -> zero_frame f ~dst:sv (8 * nslots)
| None -> ());
let head = sym_loc f "flan_frame_head" in
load_int f.b ~dst:rax ~mm:(lmem f head ~scratch:r11) ~size:8 ~signed:false;
store_int f.b ~src:rax ~mm:(Frame fr) ~size:8;
addr_into f ~reg:rax (Lg (fninfo f fn ~nslots:(if f.dslotv = None then 0 else nslots), 0));
store_int f.b ~src:rax ~mm:(Frame (fr + 8)) ~size:8;
(match f.dslotv with
| Some sv -> lea f.b ~dst:rax ~mm:(Frame sv)
| None -> xor_rr f.b ~dst:rax ~src:rax);
store_int f.b ~src:rax ~mm:(Frame (fr + 16)) ~size:8;
lea f.b ~dst:rax ~mm:(Frame fr);
store_int f.b ~src:rax ~mm:(lmem f head ~scratch:r11) ~size:8;
(* The parameters are bound before the body starts, so they are recorded
here rather than at a binding site there is none of. *)
List.iteri (fun i _ -> bind_slot f i) fn.Tast.params);
(* The body. Lowered into its own buffer, because the prologue's [sub] needs
a frame size only the body can decide, and every relocation this backend
emits is an assembler expression so nothing has to be patched. *)
@ -3292,6 +3467,21 @@ let emit_fn (md : Emit.m) ~externs ~fns ?(ext = fun _ -> false)
"The epilogue, and every return and every transfer out of this frame arrives here, \
so the frame is torn down once.";
lbl f.b f.retlbl;
(* The shadow stack's pop, and it is one store because this backend has one
epilogue: a [return], the fall through the body's tail, and the transfer
exit all arrive at this label, so the head is restored on the unwinding
path as well as on the normal one. [emit.ml] needs the same restore at
five separate [ret]s and routes them through its [ret] for exactly that
reason a pop written only on the normal path leaves a dead frame behind
every handled condition, and the next backtrace is a lie.
Before the return value is loaded, because it is loaded into rax. *)
(match f.dframe with
| None -> ()
| Some fr ->
load_int f.b ~dst:rax ~mm:(Frame fr) ~size:8 ~signed:false;
store_int f.b ~src:rax
~mm:(lmem f (sym_loc f "flan_frame_head") ~scratch:r11) ~size:8);
if sret then load_int f.b ~dst:rax ~mm:(Frame f.sret_off) ~size:8 ~signed:false
else if not (is_void fn.Tast.ret) then
load_scalar f ~reg:(if is_float fn.Tast.ret then xmm0 else rax)
@ -3307,7 +3497,8 @@ let emit_fn (md : Emit.m) ~externs ~fns ?(ext = fun _ -> false)
Buffer.add_string out
(frame_map md fn ~slots:f.slots ~fixed ~total:(frame_bytes f)
~outgoing:f.outgoing ~xfer_off:f.xfer_off ~sret_off:f.sret_off
~retval:f.retval ~sret ~sret_at ~param_at ~xfer_at);
~retval:f.retval ~dframe:f.dframe ~dslotv:f.dslotv ~sret ~sret_at
~param_at ~xfer_at);
Buffer.add_string out (Printf.sprintf "\t.globl\t%s\n" sym);
(* [emit.ml:2072] says this is load-bearing and it is: default visibility in
a shared object is interposable, and that applies to taking the address
@ -3454,6 +3645,7 @@ let emit_globals_init ?(cfi = false) ?(ann = false) ~sym (md : Emit.m) ~externs
let f =
{ b; md; fnname = "<globals>"; retlbl = new_label () "ginit";
fret = Types.Unit; slots = [||]; xfer_off = 0; sret_off = 0; retval = 0;
dframe = None; dslotv = None; snames = [||];
frame = 0; maxframe = 0; outgoing = 0; loops = []; pads = [];
xfer_lbl = ""; unwound = false;
rodata = Buffer.create 64; externs; fns; ext = (fun _ -> false);
@ -4096,6 +4288,7 @@ let redefinition ~checks ?(dev = true) ?(known = fun _ -> true)
let f =
{ b = ib; md; fnname = "<install>"; retlbl = new_label () "install";
fret = Types.Unit; slots = [||]; xfer_off = 0; sret_off = 0; retval = 0;
dframe = None; dslotv = None; snames = [||];
frame = 0; maxframe = 0; outgoing = 0; loops = []; pads = [];
xfer_lbl = ""; unwound = false;
rodata = Buffer.create 256; externs; fns = fnstbl; ext; slot; dw = None;

View File

@ -4030,10 +4030,197 @@ let () =
if status r <> "ok" then fail "x86 new name round trip: %s" (said r)
else if value r <> "82" then
fail "x86 (twice fresh) answered %S, so the registry lookups did not resolve" (value r);
(* And a backtrace *through* a redefined body, which is the one frame a
module has to get right on its own: the descriptor a body points at
travels in the object that body was compiled into, so a module that
pushed no frame would leave a gap where the redefinition ran, and one
that pointed at the host's descriptor would report the location of the
body it replaced. The installed body's own file is what says which
happened. Last in this block deliberately it stops the program. *)
let r =
request c
"(:op \"eval\" :code \"(defn step [] i64 (let [n (+ ticks 1)] (error (Missing {.id 3})) n))\" :file \"/tmp/x86redef.flan\")"
in
if status r <> "ok" then fail "x86 redefined-body backtrace, install: %s" (said r)
else if not (await (fun () ->
match Wire.field (request c "(:op \"describe\")") "stopped" with
| Some { Form.v = Form.Sym "t"; _ } -> true
| _ -> false))
then fail "the --x86 program never stopped in the redefined body"
else begin
let r = request c "(:op \"backtrace\")" in
let top =
match Wire.field r "frames" with
| Some { Form.v = Form.List ({ Form.v = Form.List
({ Form.v = Form.Str n; _ }
:: { Form.v = Form.Str loc; _ } :: _); _ }
:: _); _ } -> Some (n, loc)
| _ -> None
in
match top with
| Some ("step", loc) when contains_sub loc "x86redef.flan" -> ()
| Some (n, loc) -> fail "x86 backtrace through a redefined body: %s at %S" n loc
| None -> fail "x86 backtrace through a redefined body: %s" (said r)
end;
ignore (request c "(:op \"close\")");
(try Unix.close c with Unix.Unix_error _ -> ());
(try ignore (Unix.waitpid [] xpid2) with Unix.Unix_error _ -> ())
end;
(* ── The inspector, on the other backend ──────────────────────────── *)
(* The break loop's four questions asked of an [--x86] host. They are the
ones that go through the shadow stack rather than through a module: the
frame chain gives the depth and the names, and the addresses in it are
what a render thunk reads the locals and the globals out of. Until
[X86.emit_fn] pushed a frame this daemon answered every one of them with
"this program was not built with --dev", which was false of it.
[programs/dev-locals.flan] and not a fixture of its own, deliberately:
the LLVM block above asks these same questions of that same program, so
the two sets of answers can be read against each other, and what is
being claimed is that they are the *same* answers rather than merely
plausible ones. A backend the break loop can tell apart is a backend the
break loop cannot be trusted on. *)
let isock = tmp "x86locals.sock" and iout = tmp "x86locals.out" in
(try Sys.remove isock with Sys_error _ -> ());
let ifd =
Unix.openfile iout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600
in
let ipid =
Unix.create_process flan
[| flan; "dev"; "programs/dev-locals.flan"; "-s"; isock; "--x86" |]
Unix.stdin ifd Unix.stderr
in
Unix.close ifd;
if not (listening ~pid:ipid isock) then begin
fail "the --x86 inspector daemon %s (%S)" !listen_why
(In_channel.with_open_bin iout In_channel.input_all);
(try Unix.kill ipid Sys.sigkill with Unix.Unix_error _ -> ())
end
else begin
let c = connect isock in
let said r = Option.value ~default:"" (Wire.string_field r "message") in
let stopped r =
match Wire.field r "stopped" with
| Some { Form.v = Form.Sym "t"; _ } -> true
| _ -> false
in
if not (await (fun () -> stopped (request c "(:op \"describe\")"))) then
fail "the --x86 inspector program never stopped"
else begin
(* The backtrace first, because everything below names a frame by the
index this listing gives. Two frames and not one: [main] called
[look], so a chain with only the innermost on it would mean the push
happened and the previous head was not saved. *)
let r = request c "(:op \"backtrace\")" in
let frames =
match Wire.field r "frames" with
| Some { Form.v = Form.List l; _ } ->
List.filter_map
(fun (e : Form.t) ->
match e.Form.v with
| Form.List ({ Form.v = Form.Str n; _ }
:: { Form.v = Form.Str loc; _ }
:: { Form.v = Form.Str origin; _ } :: _) ->
Some (n, loc, origin)
| _ -> None)
l
| _ -> []
in
if status r <> "ok" then fail "x86 backtrace: %s" (said r)
else
(match frames with
| [ ("look", l0, "program"); ("main", _, "program") ] ->
(* The location travels in the frame's own descriptor, so a wrong
one is a descriptor built from the wrong function rather than a
cosmetic slip. *)
if not (contains_sub l0 "dev-locals.flan:14") then
fail "x86 backtrace put look at %S" l0
| _ ->
fail "x86 backtrace: %s"
(String.concat ", "
(List.map (fun (n, _, o) -> n ^ "/" ^ o) frames)));
let triples r key =
match Wire.field r key with
| Some { Form.v = Form.List l; _ } ->
List.filter_map
(fun (e : Form.t) ->
match e.Form.v with
| Form.List ({ Form.v = Form.Str a; _ }
:: { Form.v = Form.Str b; _ } :: rest) ->
Some (a, b,
match rest with
| { Form.v = Form.Str c; _ } :: _ -> c
| _ -> "")
| _ -> None)
l
| _ -> []
in
(* The locals of the stopped frame, and this is the claim the slot
table exists for: the values are the ones [look] was called with and
bound to, read at the addresses the frame recorded, under this
backend's own frame layout. A wrong address renders whatever those
bytes happen to be, so matching the LLVM listing exactly is the
check and a shape-only assertion would not be. *)
let r = request c "(:op \"locals\" :frame 0)" in
if status r <> "ok" then fail "x86 locals: %s" (said r)
else begin
let want =
[ ("n", "i64", "3");
("label", "string", "\"hello\"");
("p", "Point", "(Point {.x 1.5 .y 2.5})");
("xs", "[3 i32]", "[ 10 20 30]");
("flag", "bool", "true") ]
in
let got = triples r "locals" in
if got <> want then
fail "x86 locals of the stopped frame: %s"
(String.concat ", "
(List.map (fun (n, ty, v) -> n ^ " " ^ ty ^ " = " ^ v) got));
(* [after] is bound past the error, so nothing wrote its entry and it
is still the null the push stored. That null is the whole of how
"not bound yet" is told from "bound" on this side too there is no
liveness analysis behind it so a frame whose table were left
uninitialised would render this one from stack litter. *)
match List.filter (fun (n, _, _) -> n = "after") (triples r "refused") with
| [ (_, why, _) ] when why <> "" -> ()
| _ ->
fail "x86: a slot bound after the error was not refused by name: %s"
(String.concat ", "
(List.map (fun (n, w, _) -> n ^ ": " ^ w) (triples r "refused")))
end;
(* One slot by index, which is the inspector's own root rather than
[locals]' listing, and an aggregate for it: an x86 frame passes every
aggregate by pointer, so a struct is where a recorded address could
most easily be the caller's copy instead of this frame's. *)
let r = request c "(:op \"inspect\" :frame 0 :slot 2)" in
if status r <> "ok" then fail "x86 inspect: %s" (said r)
else if Wire.string_field r "value" <> Some "(Point {.x 1.5 .y 2.5})" then
fail "x86 inspect of slot 2 answered %S"
(Option.value ~default:"" (Wire.string_field r "value"));
(* And the globals, which are not in the frame at all -- they are found
through the same descriptor's fingerprint, and a frame whose
[refsig] disagreed with what the daemon recomputes would refuse
every one of them while the locals above still read. That is the
failure an empty globals table in [X86.layout_ctx] produces, and it
is why this question is asked here and not left to the LLVM block. *)
let r = request c "(:op \"globals\")" in
if status r <> "ok" then fail "x86 globals: %s" (said r)
else
(match triples r "globals" with
| [ ("ticks", "i64", v) ] when v <> "" -> ()
| got ->
fail "x86 globals: %s"
(String.concat ", "
(List.map (fun (n, ty, v) -> n ^ " " ^ ty ^ " = " ^ v) got)))
end;
ignore (request c "(:op \"close\")");
(try Unix.close c with Unix.Unix_error _ -> ());
(try ignore (Unix.waitpid [] ipid) with Unix.Unix_error _ -> ())
end;
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ isock; iout ];
(* And the *merged* daemon on this backend, which used to be refused by
name and is the case the refusal was standing in for.