(watch "name" v) renders any value into the watch table the way print renders it

This commit is contained in:
Joseph Ferano 2026-09-25 07:22:59 +07:00
parent aa5052e401
commit 85b9f35a66
12 changed files with 251 additions and 92 deletions

View File

@ -1557,10 +1557,14 @@ Push was chosen partly because polling costs a compile, and that premise weakene
when the processes merged. The table-and-read design stands; whether it should stay
pushed is open.
** TODO A watch over a struct or a slice
Scalars work today through four runtime entry points and need no compiler change.
A struct or a slice needs a compile-time walk over its type — one arm beside
=print=.
** DONE A watch over a struct or a slice
CLOSED: [2026-09-25]
=(watch "name" v)= is a checker arm beside =print=, sharing its render context
with the emitter aimed at the watch slot, so any value watches as it prints. The
value is evaluated once, before the table is asked whether it is armed, so the
program behaves the same with or without a watch buffer open. The =declare-c=
scalar entry points stay. See docs/BUILT.md, "The scalar entry points, and the
form for everything else".
** DONE Two ways to root a walk
The inspector takes a frame and a slot index as well as an expression. An index is

View File

@ -5441,52 +5441,44 @@ So the cost of a watch call in a program nobody is debugging is **one relaxed lo
is the same number in a release build as in a dev one: `flan_dev.c` is linked into every build (`Build`, which says
why), so the symbols resolve either way and there is no second version of the file.
It is not *free*, and the distinction is worth keeping honest. Eliding the call entirely needs the compiler to know
the form, which is the `check.ml` arm below. A load and a branch per watched value per frame is the real number.
It is not *free*, and the distinction is worth keeping honest. The `(watch ...)` form below still makes the call, and
still evaluates its value, so a load and a branch per watched value per frame is the real number.
### Scalars work today; composites need a `check.ml` arm that was not built
### The scalar entry points, and the form for everything else
The four entry points a program can reach through `declare-c` are the whole feature for a scalar:
Four entry points a program can reach through `declare-c` write a scalar:
```flan
(declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64")
(watch-i64 "ticks" ticks)
```
No arm in the checker, no new special form, nothing the compiler has to learn. They return `i32` rather than nothing
for a blunt reason: `declare-c` refuses a void return outright — "which is not a value C can carry", `shim.ml` — so a
function a program can declare has to return something, and since it must, it returns the useful thing: 1 if the value
was written, 0 if nobody is watching or the table is full.
They return `i32` rather than nothing for a blunt reason: `declare-c` refuses a void return outright — "which is not a
value C can carry", `shim.ml` — so a function a program can declare has to return something, and since it must, it
returns the useful thing: 1 if the value was written, 0 if nobody is watching or the table is full.
A composite — a struct, a slice, a union — cannot be reached this way, and that is not a shortcoming of the four. A
Flan value carries no header, so nothing at run time can say what it is, and rendering one is a compile-time walk over
its *type*. **That is the same reason `C-x C-e` renders in the thunk rather than marshalling anything**, and it is the
layout decision's bill, paid in the same place.
A composite — a struct, a slice, a union — cannot be reached that way. A Flan value carries no header, so nothing at run
time can say what it is, and rendering one is a compile-time walk over its *type*. **That is the same reason `C-x C-e`
renders in the thunk rather than marshalling anything**, and it is the layout decision's bill, paid in the same place.
**The missing piece is one arm in `check.ml`, and it was deliberately not written** — that file is held by another
lane. It sits beside `print` (`check.ml:3480`) and is the same shape as it:
So `(watch "name" v)` is an arm in `check.ml`, beside `print`, and it is `print` with the emitter aimed elsewhere. The
two share `render_ctx`; the watch emitter points each piece at `flan_dev_watch_emit*` rather than at `WriteStdout`, and a
dyn value at `flan_dyn_emit_watch`, the dyn printer with the watch slot as its sink. The walk is wrapped in
`flan_dev_watch_begin_n` — the name as bytes and a length, since that is how a Flan string crosses — and
`flan_dev_watch_end`, and runs only when `begin` answers that the table is armed and has room.
```
| "watch" ->
arity loc name 2 args; (* a name and a value *)
(* a read, not a move — as print is, for the same reason: (watch "v" v)
must not consume a Vec and make that its last showing *)
let n = check ctx (List.nth args 0) in (* must be String *)
let a = borrowed ctx target (fun () -> check ctx (List.nth args 1)) in
(* begin, the walk, end — with the emitter aimed at the four
flan_dev_watch_emit_* rather than at WriteStdout *)
Render.render { rc with emit = watch_emitter } 0 a
```
Two choices in it. The value is bound to a slot of the calling frame unless it is already a local or a global, because
the walk names its argument once per field and a call would otherwise run once per field; and it is bound *before* the
table is asked, so a side effect in the value happens whether or not anyone is watching — the program's behaviour does
not depend on an editor window. A string watches quoted, as it renders inside a structure, because a table row is a
value and an unquoted `5` could not be told from the number.
with `flan/watch-begin`, `flan/watch-end` and the four emit functions declared as externs the way `Session.externs`
already declares `flan_dev_emit*`. Nothing else has to move: `Render.render` is unchanged, the runtime side is built
and tested, and the daemon and the editor cannot tell which kind of caller filled the table.
The daemon and the editor cannot tell which kind of caller filled the table.
~~That arm is also what **ghost text** is gated on.~~ **It was not, and ghost text is built without it** — see
"Ghost text finds its anchor in the buffer, not in the table" below. The claim was that values shown inline need a
*place* and nothing in the table has one, so a source location would have to be carried per entry, so the call site
would have to be generated. True of the table and false of the conclusion: the call site is in the buffer. The
composite renderer still wants the form, for its own reason, and it is the only one of the two that does.
would have to be generated. True of the table and false of the conclusion: the call site is in the buffer.
## An error is a value, and there is more than one of them
@ -5773,7 +5765,7 @@ rather than a discovery.
A Flan struct is exactly its C layout. No header, no tag word — deliberately, and it is what makes a struct free and
what makes the FFI work. The consequence is stated elsewhere in this file more than once: *a Flan value carries no
header, so nothing at run time can say what it is*, which is why a rendering is a compile-time walk over a type and
why `(watch "v" v)` cannot reach a composite without an arm in `check.ml`.
why `(watch "v" v)` is an arm in `check.ml` rather than a function.
The allocation registry does not answer that question. It sidesteps it. **The allocator's caller knows the type at the
moment it asks for memory**, and the compiler is standing right there, so a dev build writes it down: base address,

View File

@ -684,19 +684,21 @@ closes it down, and killing the buffer does the same.
**The program decides what is shown.** There is no watch list to maintain, no
per-variable registration, no place to type an expression. The program says
what it wants seen, from inside its own loop:
what it wants seen, from inside its own loop, with `(watch "name" value)`:
```flan
(declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64")
(declare-c watch-f64 [name string x f64] i32 "flan_dev_watch_f64")
(declare-c watch-str [name string s string] i32 "flan_dev_watch_str")
(defn step [] i64
(set ticks (+ ticks 1))
(watch-i64 "ticks" ticks)
(watch "ticks" ticks)
(watch "player" player)
ticks)
```
The value is rendered the way `print` renders it, so a struct, an array, a
slice, an option or a dyn value watches as it prints: `(Pos {.x 3 .y 1.5})`,
`[ 1 2 3]`. A string is quoted. The value is evaluated once whether or not a
watch buffer is open.
That is the whole of it. `C-c C-c` on `step` adds or removes a watched value
the same way it changes anything else, so the watch list is edited in the place
you were already looking.
@ -749,11 +751,18 @@ move again, so the two most useful of the five would go dead exactly when you
start playing. A whole number prints as one, because a spy on an array index
reading `66.0000` sends you looking for a rounding bug that is not there.
**Scalars only, so far.** `i64`, `u64`, `f64` and `string` have entry points; a
struct or a slice does not. That is not an oversight in the runtime — a Flan
value carries no header, so rendering one is a walk over its *type* at compile
time, and a `(watch "hp" hp)` form in the compiler is what would do that walk.
It is not built. `flan-watch.el` says what it would need.
**The scalar entry points.** `watch` is a form the compiler knows. The same
table is also reachable as plain C functions, one per scalar type, which a
program declares like any other:
```flan
(declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64")
(declare-c watch-f64 [name string x f64] i32 "flan_dev_watch_f64")
(declare-c watch-str [name string s string] i32 "flan_dev_watch_str")
```
Each returns 1 if the value was written and 0 if nobody is watching or the
table is full.
### Ghost text — the same values, inline

View File

@ -62,19 +62,17 @@
;; insert, because it diffs, so point and scroll survive a repaint instead of
;; being yanked to the top five times a second.
;;
;; What a program writes today, with no compiler change:
;;
;; (declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64")
;; (declare-c watch-f64 [name string x f64] i32 "flan_dev_watch_f64")
;; What a program writes:
;;
;; (defn step [] i64
;; (set ticks (+ ticks 1))
;; (watch-i64 "ticks" ticks)
;; (watch "ticks" ticks)
;; (watch "player" player)
;; ticks)
;;
;; Scalars only, so far. A struct or a slice needs a compile-time walk over
;; its type — a `(watch "hp" hp)' form in the checker — and that is a file this
;; change does not own. See docs/BUILT.md.
;; `watch' renders any value the way `print' does — a struct, a slice, a dyn
;; value — into the table. The scalar entry points underneath it,
;; `flan_dev_watch_i64' and the rest, are reachable through `declare-c' too.
;;; Code:
@ -118,11 +116,9 @@ was the only consumer and wrong the moment it was not.")
"The buffer's text for ROWS. OVERFLOW means some name found no slot."
(if (null rows)
(concat "nothing is being watched\n\n"
"The program decides what is shown. Call into the watch table\n"
"The program decides what is shown. Write into the watch table\n"
"from your own loop:\n\n"
" (declare-c watch-i64 [name string x i64] i32 \"flan_dev_watch_i64\")\n"
" ...\n"
" (watch-i64 \"ticks\" ticks)\n")
" (watch \"ticks\" ticks)\n")
(let ((w (apply #'max (mapcar (lambda (r) (length (car r))) rows))))
(concat
(mapconcat (lambda (r)

View File

@ -571,6 +571,26 @@ let fresh_slot ?name ctx ty =
ctx.slot_names <- name :: ctx.slot_names;
s
(* The structural printer's context over this checker's tables, with the
pieces aimed wherever [emit] sends them. [print] and [watch] differ in the
emitter and in nothing else, and a second copy of this would be a second
answer to which types the walk knows. *)
let render_ctx ctx (emit : Render.emitter) : Render.ctx =
{ Render.structs = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.structs [];
datas = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.datas [];
unions = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.unions [];
enums = Hashtbl.fold (fun k v acc -> (k, v) :: acc) ctx.env.enums [];
emit;
(* Neither [println] nor [watch] follows a pointer, and the allocation
registry does not change that. spec-memory.md fixes what it prints —
"Ptr and Handle print their address or identity rather than
recursively dereferencing" — and a printed line belongs to the program,
so it must read the same in a release build, where there is no registry
to ask. Following one is the *inspector's* move, and session.ml is where
that context is built. *)
ptrs = None;
alloc = (fun ty -> fresh_slot ctx ty) }
(* Shadowing is legal -- [(let [v 11] (let [v 22] ...))] is two slots, both
named [v] -- and the debug info has nowhere to put the distinction. Every
[!DILocalVariable] is scoped to the subprogram, because the typed IR has no
@ -8621,23 +8641,7 @@ and named_call ?(qualified = false) ctx ~want loc name args =
ef64 = (fun x -> write (conv Tast.F64ToBytes x));
edyn = (fun x -> mk loc Types.Unit (Tast.Prim (Tast.Rt "flan_dyn_print", [ x ]))) }
in
let rc =
{ Render.structs =
Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.structs [];
datas = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.datas [];
unions = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.unions [];
enums = Hashtbl.fold (fun k v acc -> (k, v) :: acc) ctx.env.enums [];
emit = emitter;
(* [println] never follows a pointer, and the allocation registry does
not change that. spec-memory.md fixes what it prints — "Ptr and
Handle print their address or identity rather than recursively
dereferencing" — and a printed line belongs to the program, so it
must read the same in a release build, where there is no registry to
ask. Following one is the *inspector's* move, and session.ml is
where that context is built. *)
ptrs = None;
alloc = (fun ty -> fresh_slot ctx ty) }
in
let rc = render_ctx ctx emitter in
let render_one a =
match a.Tast.ty with
| Types.String | Types.Slice (Types.Int Types.U8) ->
@ -8667,6 +8671,71 @@ and named_call ?(qualified = false) ctx ~want loc name args =
else []
in
expect ctx loc ~want (mk loc Types.Unit (Tast.Do (parts @ nl)))
(* (watch "name" v) — v rendered into the dev watch table under the name.
The same walk as [print], with the pieces aimed at flan_dev.c's watch
slot instead of stdout, so a struct, a slice, an option or a dyn value
watches the way it prints. flan_dev.c is linked into every build, so this
compiles the same in a release one, where nothing ever arms the table.
The value is evaluated once, before the table is asked whether anyone is
looking, so a side effect in it happens whether or not a watch buffer is
open. The walk runs only when one is: [flan_dev_watch_begin_n] answers 0
when the table is not armed or is full, and the render is skipped. *)
| "watch" ->
arity ctx loc name 2 args;
let label = check ctx ~want:Types.String (List.hd args) in
let v = check ctx (List.nth args 1) in
if generic_ty v.Tast.ty then mk loc Types.Unit Tast.Unit
else begin
let unit_rt sym args = mk loc Types.Unit (Tast.Prim (Tast.Rt sym, args)) in
let bslice = Types.Slice (Types.Int Types.U8) in
let emitter : Render.emitter =
{ Render.ebytes = (fun x -> unit_rt "flan_dev_watch_emit" [ x ]);
estr = (fun x -> unit_rt "flan_dev_watch_emit_str" [ x ]);
ei64 = (fun x -> unit_rt "flan_dev_watch_emit_i64" [ x ]);
eu64 = (fun x -> unit_rt "flan_dev_watch_emit_u64" [ x ]);
ef64 = (fun x -> unit_rt "flan_dev_watch_emit_f64" [ x ]);
edyn = (fun x -> unit_rt "flan_dyn_emit_watch" [ x ]) }
in
(* A place is read where it stands; anything else is bound to a slot of
this frame first, so the walk — which names its argument once per
field — does not run it once per field. *)
let bind, value =
match v.Tast.e with
| Tast.Local _ | Tast.Global _ -> [], v
| _ ->
let s = fresh_slot ctx v.Tast.ty in
[ (s, v) ], mk loc v.Tast.ty (Tast.Local s)
in
let body =
match value.Tast.ty with
(* A string watches quoted, as it renders inside a structure: the
table's rows are values, and an unquoted one could not be told from
a number. *)
| Types.String ->
[ emitter.Render.estr (mk loc bslice (Tast.Prim (Tast.Bytes, [ value ]))) ]
| _ -> Render.render (render_ctx ctx emitter) 0 value
in
let begin_ =
mk loc (Types.Int Types.I32)
(Tast.Prim (Tast.Rt "flan_dev_watch_begin_n", [ label ]))
in
let zero = mk loc (Types.Int Types.I32) (Tast.Int (0L, Types.I32)) in
let open_ = mk loc Types.Bool (Tast.Prim (Tast.Ne, [ begin_; zero ])) in
let guarded =
mk loc Types.Unit
(Tast.If
( open_,
mk loc Types.Unit
(Tast.Do (body @ [ unit_rt "flan_dev_watch_end" [] ])),
mk loc Types.Unit Tast.Unit ))
in
expect ctx loc ~want
(match bind with
| [] -> guarded
| _ -> mk loc Types.Unit (Tast.Let (bind, [ guarded ])))
end
| "exit" ->
arity ctx loc name 1 args;
prim Tast.Exit Types.Never [ check ctx ~want:index_ty (List.hd args) ]
@ -9951,6 +10020,10 @@ let builtins : (string * string * string) list =
is a read, so it does not consume the value.");
("println", "println [T ...] ()",
"print, with a newline after it — (println) alone is the newline.");
("watch", "watch [string T] ()",
"Writes the value, rendered as print renders it, into the dev session's \
watch table under the name, where M-x flan-watch shows it. Does nothing \
when no watch buffer is open, and in a build with no dev session.");
("exit", "exit [i32] never",
"Ends the process with this status. It has no value, so nothing written \
after it runs.");

View File

@ -4027,6 +4027,15 @@ declare void @flan_dyn_push(i64, i64)
declare void @flan_dyn_print(i64)
declare void @flan_dyn_emit_dev(i64)
declare void @flan_dyn_emit_watch(i64)
; The watch table, which (watch "name" v) renders into. flan_dev.c is linked
; into every build, so these resolve in a release build too.
declare i32 @flan_dev_watch_begin_n(ptr, i64)
declare void @flan_dev_watch_emit(ptr, i64)
declare void @flan_dev_watch_emit_str(ptr, i64)
declare void @flan_dev_watch_emit_i64(i64)
declare void @flan_dev_watch_emit_u64(i64)
declare void @flan_dev_watch_emit_f64(double)
declare void @flan_dev_watch_end()
declare i64 @flan_dyn_need_i64(i64)
declare double @flan_dyn_need_f64(i64)
declare i32 @flan_dyn_need_bool(i64)

View File

@ -566,8 +566,8 @@ static uint64_t watch_epoch;
* build — [flan_dev.c] is linked into both (see [Build], which says why) so the
* symbols resolve either way and there is no second version of this file.
*
* What it is *not*: free. Eliding the call entirely needs the compiler to know
* the form, which is a [check.ml] arm this does not have. A load and a branch
* What it is *not*: free. The [(watch ...)] form still makes the call, so it
* costs the same as a scalar entry point does. A load and a branch
* per watched value per frame is the honest number, and it is the same number
* in both builds rather than a dev-only tax. */
static int watch_on;
@ -639,6 +639,22 @@ int flan_dev_watch_begin(const char *name) {
return 1;
}
/* [flan_dev_watch_begin] for a name that arrives as bytes and a length, which
* is how a Flan string crosses to the runtime: (watch "name" v) calls this.
* The name is copied to a NUL-terminated buffer on the stack, cut to what a
* slot holds, so nothing here allocates. */
int flan_dev_watch_begin_n(const uint8_t *name, int64_t len) {
/* The same first test [flan_dev_watch_begin] makes, ahead of the copy, so an
* unarmed table costs a load and a branch here too. */
if (!__atomic_load_n(&watch_on, __ATOMIC_RELAXED)) { watch_cur = NULL; return 0; }
char buf[WATCH_NAME];
size_t n = len < 0 ? 0 : (size_t)len;
if (n > WATCH_NAME - 1) n = WATCH_NAME - 1;
memcpy(buf, name, n);
buf[n] = '\0';
return flan_dev_watch_begin(buf);
}
void flan_dev_watch_emit(const uint8_t *bytes, int64_t len) {
watch_slot *s = watch_cur;
if (s == NULL) return;
@ -703,14 +719,12 @@ void flan_dev_watch_end(void) {
* which is either nobody watching or a full table. A caller is free to ignore
* it and normally does.
*
* A composite — a struct, a slice, a union — cannot be done this way, and that
* is not a shortcoming of these four: a Flan value carries no header, so
* nothing at run time can say what it is, and rendering one is a compile-time
* walk over its *type*. The walk already exists — [Render.render] — and the
* four [flan_dev_watch_emit_*] above are the emitter it would be pointed at,
* shaped exactly like the [print] arm's. What is missing is the
* [(watch "hp" hp)] arm in check.ml that joins the two, which is a file this
* change does not own. docs/BUILT.md says what that arm is. */
* A composite — a struct, a slice, a union — cannot be done this way: a Flan
* value carries no header, so nothing at run time can say what it is, and
* rendering one is a compile-time walk over its *type*. That is the
* [(watch "hp" hp)] form in check.ml, which points [Render.render] at
* [flan_dev_watch_begin_n], the [flan_dev_watch_emit_*] above and
* [flan_dev_watch_end]. */
int32_t flan_dev_watch_i64(const char *name, int64_t x) {
if (!flan_dev_watch_begin(name)) return 0;
flan_dev_watch_emit_i64(x);

View File

@ -1,10 +1,8 @@
;;;; A program that pushes values into the watch table from its own loop.
;;;;
;;;; The point of the case is that this needs no compiler change: the watch
;;;; entry points are ordinary C functions, so a program reaches them through
;;;; [declare-c] the same way it reaches anything else in the runtime. That is
;;;; deliberate — a [(watch "hp" hp)] form would be an arm in the checker, and
;;;; a scalar does not need one.
;;;; Two ways in. A scalar reaches the watch entry points as ordinary C
;;;; functions, through [declare-c]. A struct, a slice or anything else goes
;;;; through the [(watch "name" v)] form, which renders it the way print does.
;;;;
;;;; The values are written every iteration and are *not* read back from here.
;;;; What reads them is the daemon's [watch] op, over the agent, while this
@ -22,6 +20,8 @@
(defonce ticks i64)
(defstruct Pos [x i32 y f64])
(defn loop-cells [] i32
(let [i 0]
(while (< i 8)
@ -37,6 +37,10 @@
(watch-i64 "ticks" ticks)
(watch-f64 "half" (/ (f64 ticks) 2.0))
(watch-str "label" "sand")
;; The form: a struct, a slice, and a scalar through the same form.
(watch "pos" (Pos {.x 3 .y 1.5}))
(watch "row" [1 2 3])
(watch "t2" (* ticks 2))
;; A hot inner loop, and the value *varies* across it — which is what makes
;; the row a test of the accumulator rather than of the plumbing. A slot that
;; only kept [last] would report 21 and no range; n, min and max are each

View File

@ -0,0 +1,28 @@
;;;; (watch "name" v) in a build with no dev session behind it.
;;;;
;;;; Nothing arms the watch table here, so every watch is the load and the
;;;; branch and nothing else: the program must compile, link and print exactly
;;;; what it prints without them. The value is still evaluated once, which the
;;;; counter shows.
(defstruct Pos [x i32 y f64])
(defonce calls i64)
(defn bump [] i64
(set calls (+ calls 1))
calls)
(defn main [] i32
(let [p (Pos {.x 3 .y 1.5})
xs [1 2 3]
d {:a 1}]
(watch "pos" p)
(watch "xs" xs)
(watch "slice" (slice xs 0 2))
(watch "dyn" d)
(watch "label" "sand")
(watch "bump" (bump))
(watch "bump" (bump))
(println (.x p) calls))
0)

View File

@ -4397,6 +4397,15 @@ level "1"
"programs/dyn-class.flan" dyn_class_out;
outputs ~x86:true "dyn: classes and dispatch, --x86"
"programs/dyn-class.flan" dyn_class_out;
(* (watch "name" v) with nothing arming the table: a struct, an array, a
slice, a dyn map and a string all compile against flan_dev.c's watch
entry points on both backends, write nothing, and evaluate the value
once — [calls] is 2 after two watched calls to [bump]. *)
outputs "watch: a release build" "programs/watch-release.flan" "3 2\n";
outputs ~opt:"-O0" "watch: a release build, -O0"
"programs/watch-release.flan" "3 2\n";
outputs ~x86:true "watch: a release build, --x86"
"programs/watch-release.flan" "3 2\n";
(* ── Per-type descriptors, M2 item 2 ─────────────────────────────
The first program anywhere with a dyn field in a struct, which was a
refusal until the descriptors landed. It matters at all three rows

View File

@ -3889,6 +3889,20 @@ let () =
| Some "\"sand\"" -> ()
| Some v -> fail "watch rendered a string as %s, unquoted" v
| None -> fail "watch lost the string");
(* The (watch ...) form: a struct and a slice, rendered by the same
walk print uses, and a scalar through the same form. *)
(match List.assoc_opt "pos" t with
| Some "(Pos {.x 3 .y 1.5})" -> ()
| Some v -> fail "watch rendered a struct as %s" v
| None -> fail "the (watch ...) form never wrote a struct");
(match List.assoc_opt "row" t with
| Some "[ 1 2 3]" -> ()
| Some v -> fail "watch rendered a slice as %s" v
| None -> fail "the (watch ...) form never wrote a slice");
(match List.assoc_opt "t2" t with
| Some v when int_of_string_opt v <> None -> ()
| Some v -> fail "watch rendered a computed i64 as %s" v
| None -> fail "the (watch ...) form never wrote a scalar");
(* The accumulator, which is the other half of the watch and the
half a scalar row cannot stand in for. [loop-cells] samples "cell"
eight times per step at 0, 3, ... 21, so the row has to show a
@ -5633,8 +5647,12 @@ let () =
failure above would leave behind — and this fixture then polls for
twenty seconds and parks, so the wait below would be a hang rather
than a report. The signal is for that case only. *)
if status (request c "(:op \"abort\")") <> "ok" then
(try Unix.kill hpid Sys.sigkill with Unix.Unix_error _ -> ());
(* Through [aborted], because the connection closing before the reply
is an abort that worked; see there. *)
(match aborted c with
| Some r when status r <> "ok" ->
(try Unix.kill hpid Sys.sigkill with Unix.Unix_error _ -> ())
| _ -> ());
(try Unix.close c with Unix.Unix_error _ -> ());
(try ignore (Unix.waitpid [] hpid) with Unix.Unix_error _ -> ())
end;

View File

@ -213,6 +213,9 @@ let corpus =
immortal and is not a collector object. If that reasoning is wrong,
50000 instances past the one-megabyte floor is where ASan says so. *)
"programs/dyn-class.flan", [];
(* (watch ...) with the table unarmed: every value is bound and every
call reaches flan_dev_watch_begin_n, and nothing past it runs. *)
"programs/watch-release.flan", [];
(* nil <-> None at (Option T), M2 queue item 4: an Option's tag is read
with a raw [Field] the surface language never writes (check.ml's
[box_option]/[unbox_option], the same access Render's structural