A release build's watch is only its value, next-error lets go of a stack that has resumed, and the frame a stop is in is always shown

This commit is contained in:
Joseph Ferano 2026-09-25 08:36:53 +07:00
parent cda939c8be
commit ff744120fc
18 changed files with 193 additions and 36 deletions

View File

@ -1562,8 +1562,10 @@ CLOSED: [2026-09-25]
=(watch "name" v)= is a checker arm beside =print=, sharing its render context =(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 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 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= program behaves the same with or without a watch buffer open. Outside a dev
scalar entry points stay. See docs/BUILT.md, "The scalar entry points, and the build, and always in the JS dialect, the backend drops everything but the
value's evaluation, so a release build makes no call. The =declare-c= scalar
entry points stay. See docs/BUILT.md, "The scalar entry points, and the
form for everything else". form for everything else".
** DONE Two ways to root a walk ** DONE Two ways to root a walk
@ -1815,8 +1817,9 @@ frame's names and types.
CLOSED: [2026-09-25] CLOSED: [2026-09-25]
A frame whose location is =<prelude>= is hidden by default, and a line in its A frame whose location is =<prelude>= is hidden by default, and a line in its
place counts the hidden run; =P= shows them. A hidden frame keeps its index, place counts the hidden run; =P= shows them. A hidden frame keeps its index,
because =locals= and the inspector are asked by it. Rules out renumbering the because =locals= and the inspector are asked by it. The innermost frame is
visible frames. shown even when it is the prelude's, unless the stop is =(pause)=, because it
is where the program stopped. Rules out renumbering the visible frames.
** TODO There is no stepper ** TODO There is no stepper
=(pause)= stops and offers restarts, frames, locals and the inspector, but =(pause)= stops and offers restarts, frames, locals and the inspector, but

View File

@ -5441,8 +5441,14 @@ 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 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. 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. The `(watch ...)` form below still makes the call, and It is not *free*, and the distinction is worth keeping honest: a load and a branch per watched value per frame is the
still evaluates its value, so a load and a branch per watched value per frame is the real number. real number for a scalar entry point, in either build. The `(watch ...)` form below costs that in a dev build and
nothing but its value's evaluation in any other, because the checker wraps the render in an `If` on
`flan_dev_watch_begin_n` and each backend lowers that `If` to its unit else branch when the build is not a dev one
(`Tast.is_watch_guard`). The checker does not know which build it is checking for, so the drop has to be the
backend's, the way the allocation registry's notes are. The JS dialect always drops it: there is no watch table there.
Measured on a 200-million-iteration loop at `-O2`: 0.53s with the call, 0.00s without it, the same as the loop with no
`watch` in it.
### The scalar entry points, and the form for everything else ### The scalar entry points, and the form for everything else

View File

@ -382,7 +382,9 @@ program was running, abandon and call the code again.
**The stack.** Frames are numbered innermost first. A frame of a prelude **The stack.** Frames are numbered innermost first. A frame of a prelude
function — `pause` is one, so every breakpoint has one — is hidden, and a line function — `pause` is one, so every breakpoint has one — is hidden, and a line
in its place says how many were hidden; `P` shows them. A hidden frame keeps in its place says how many were hidden; `P` shows them. A hidden frame keeps
its number, so the numbers either side of it have a gap. its number, so the numbers either side of it have a gap. The innermost frame is
where the program stopped, so it is shown even when it belongs to the prelude,
unless the stop is a `(pause)`.
A frame's location is where its function is written, and the `at` line under A frame's location is where its function is written, and the `at` line under
the condition is the expression that stopped. `RET` on either opens that file the condition is the expression that stopped. `RET` on either opens that file
@ -718,9 +720,11 @@ to see what the last frame held.
**What it costs when you are not watching.** Nothing writes the table until a **What it costs when you are not watching.** Nothing writes the table until a
watch buffer is open; `M-x flan-watch` tells the program somebody is looking watch buffer is open; `M-x flan-watch` tells the program somebody is looking
and closing it tells the program to stop. So a `watch-i64` call in a program and closing it tells the program to stop. So in a dev build a `watch` nobody is
nobody is debugging is a load and a branch that is not taken, in a release looking at costs a load and a branch that is not taken. In a release build
build as in a dev one. `watch` makes no call at all: the value is evaluated and nothing else happens.
A scalar entry point called through `declare-c` is an ordinary C call and costs
the load and the branch in either build.
**The limits.** The table holds **64 names**, and names past that are dropped **The limits.** The table holds **64 names**, and names past that are dropped
rather than being fatal — the buffer says how many, because a value that simply rather than being fatal — the buffer says how many, because a value that simply

View File

@ -67,6 +67,7 @@
(declare-function flan--request "flan" (form)) (declare-function flan--request "flan" (form))
(declare-function flan-visit-loc "flan" (loc subject)) (declare-function flan-visit-loc "flan" (loc subject))
(declare-function flan--forget-break-stack "flan" ())
(declare-function flan-inspect "flan-inspect" (expr)) (declare-function flan-inspect "flan-inspect" (expr))
(declare-function flan-inspect-slot "flan-inspect" (frame slot name)) (declare-function flan-inspect-slot "flan-inspect" (frame slot name))
@ -399,7 +400,14 @@ breakpoint the program's author wrote, not a step of the program."
;; A hidden frame keeps its number. The index is what `locals' and ;; A hidden frame keeps its number. The index is what `locals' and
;; the inspector are asked by, so the frames either side of a hidden ;; the inspector are asked by, so the frames either side of a hidden
;; run are numbered with a gap, and the line in the gap says why. ;; run are numbered with a gap, and the line in the gap says why.
(if (and (not flan-cnr--show-prelude) (flan-cnr--prelude-frame-p fr)) ;;
;; The innermost frame is where the program stopped, so it is shown
;; even when it is the prelude's — unless the stop is `(pause)',
;; whose own frame is the breakpoint and not where anything failed.
(if (and (not flan-cnr--show-prelude)
(flan-cnr--prelude-frame-p fr)
(or (> i 0)
(equal (plist-get state :condition) flan-cnr-breakpoint)))
(setq hidden (1+ hidden)) (setq hidden (1+ hidden))
(when (> hidden 0) (when (> hidden 0)
(flan-cnr--insert-hidden hidden) (flan-cnr--insert-hidden hidden)
@ -647,6 +655,11 @@ visits the source of the one it lands on."
(flan-visit-loc (get-text-property found 'flan-cnr-loc) (flan-visit-loc (get-text-property found 'flan-cnr-loc)
(flan-cnr--loc-subject found)))) (flan-cnr--loc-subject found))))
(defun flan-cnr--forget ()
"The stack this buffer drew is gone; stop `next-error' walking it."
(when (eq next-error-last-buffer (current-buffer))
(setq next-error-last-buffer nil)))
(defun flan-cnr--invoke (index name) (defun flan-cnr--invoke (index name)
"Take restart INDEX, named NAME. "Take restart INDEX, named NAME.
By index, because the index is the identity — two frames can offer `retry' By index, because the index is the identity — two frames can offer `retry'
@ -662,6 +675,7 @@ different restart than the one it showed."
;; buffer says so and goes, rather than redrawing a state that is about ;; buffer says so and goes, rather than redrawing a state that is about
;; to stop being true. ;; to stop being true.
(progn (message "flan: %s — %s" name (or (plist-get r :note) "accepted")) (progn (message "flan: %s — %s" name (or (plist-get r :note) "accepted"))
(flan-cnr--forget)
(quit-window)) (quit-window))
(user-error "flan: %s" (or (plist-get r :message) "refused"))))) (user-error "flan: %s" (or (plist-get r :message) "refused")))))
@ -688,7 +702,9 @@ different restart than the one it showed."
(interactive) (interactive)
(let ((r (funcall flan-cnr-request-function '(:op "abort")))) (let ((r (funcall flan-cnr-request-function '(:op "abort"))))
(if (equal (plist-get r :status) "ok") (if (equal (plist-get r :status) "ok")
(progn (message "flan: %s" (or (plist-get r :note) "aborted")) (quit-window)) (progn (message "flan: %s" (or (plist-get r :note) "aborted"))
(flan-cnr--forget)
(quit-window))
(user-error "flan: %s" (or (plist-get r :message) "refused"))))) (user-error "flan: %s" (or (plist-get r :message) "refused")))))
(defun flan-cnr-toggle-frame () (defun flan-cnr-toggle-frame ()
@ -1032,8 +1048,9 @@ walk from a running program."
(flan-cnr-backtrace) (flan-cnr-backtrace)
(flan-cnr-globals))) (flan-cnr-globals)))
;; So `M-g M-n' from the source buffer walks this stack rather than ;; So `M-g M-n' from the source buffer walks this stack rather than
;; the last compilation's errors, for as long as the program is ;; the last compilation's errors. Taken back when a restart or an
;; stopped here. ;; abort is accepted, here or from `flan.el', and when the poll sees
;; the program running again: see `flan--forget-break-stack'.
(setq next-error-last-buffer buf)) (setq next-error-last-buffer buf))
(pop-to-buffer buf) (pop-to-buffer buf)
buf))) buf)))

View File

@ -168,9 +168,8 @@ was the only consumer and wrong the moment it was not.")
;; both are painted from the same reply, in `flan-watch--absorb'. ;; both are painted from the same reply, in `flan-watch--absorb'.
;; ;;
;; WHERE A VALUE ATTACHES. The table carries a name and a rendered string and ;; WHERE A VALUE ATTACHES. The table carries a name and a rendered string and
;; no source location, and it is not going to grow one here — that needs a ;; no source location. A location is not actually missing: `(watch "hp" hp)'
;; `(watch ...)' form in the checker, which is a file this does not own. But a ;; or `(watch-i64 "hp" hp)' is *in the buffer*,
;; location is not actually missing: `(watch-i64 "hp" hp)' is *in the buffer*,
;; and the name in the table is the string literal in it. So the anchor is ;; and the name in the table is the string literal in it. So the anchor is
;; found by searching the text rather than by being told, which costs one ;; found by searching the text rather than by being told, which costs one
;; regexp scan per displayed buffer per repaint and needs nothing new from the ;; regexp scan per displayed buffer per repaint and needs nothing new from the

View File

@ -484,6 +484,7 @@ with it, and a rejected evaluation is a likely moment to *become* stopped."
(setq flan--stopped now) (setq flan--stopped now)
(unless (equal was now) (unless (equal was now)
(force-mode-line-update t) (force-mode-line-update t)
(unless now (flan--forget-break-stack))
;; Once, on the edge. A message every poll would bury whatever else the ;; Once, on the edge. A message every poll would bury whatever else the
;; echo area was saying, every second, for as long as the program sat ;; echo area was saying, every second, for as long as the program sat
;; there. ;; there.
@ -1170,6 +1171,7 @@ apart, so a prompt cannot take a different restart than the one it showed."
(progn (progn
;; Accepted, not resumed — see `flan-restart'. ;; Accepted, not resumed — see `flan-restart'.
(setq flan--stopped nil) (setq flan--stopped nil)
(flan--forget-break-stack)
(force-mode-line-update t) (force-mode-line-update t)
(message "flan: %s — %s" name (or (plist-get r :note) "accepted"))) (message "flan: %s — %s" name (or (plist-get r :note) "accepted")))
(user-error "flan: %s" (or (plist-get r :message) "refused"))))) (user-error "flan: %s" (or (plist-get r :message) "refused")))))
@ -1191,6 +1193,7 @@ here for a name known in advance."
;; re-established by the next poll if the program is somehow still ;; re-established by the next poll if the program is somehow still
;; there. ;; there.
(setq flan--stopped nil) (setq flan--stopped nil)
(flan--forget-break-stack)
(force-mode-line-update t) (force-mode-line-update t)
(message "flan: %s — %s" name (message "flan: %s — %s" name
(or (plist-get r :note) "accepted"))) (or (plist-get r :note) "accepted")))
@ -1236,6 +1239,7 @@ nothing left to serve once it has gone."
(let ((r (flan--request '(:op "abort")))) (let ((r (flan--request '(:op "abort"))))
(if (equal (plist-get r :status) "ok") (if (equal (plist-get r :status) "ok")
(progn (setq flan--stopped nil) (progn (setq flan--stopped nil)
(flan--forget-break-stack)
(force-mode-line-update t) (force-mode-line-update t)
(message "flan: %s" (or (plist-get r :note) "aborted"))) (message "flan: %s" (or (plist-get r :note) "aborted")))
(user-error "flan: %s" (or (plist-get r :message) "refused"))))) (user-error "flan: %s" (or (plist-get r :message) "refused")))))
@ -1317,6 +1321,17 @@ than being told so."
(string-to-number (match-string 2 loc)) (string-to-number (match-string 2 loc))
(string-to-number (match-string 3 loc))))) (string-to-number (match-string 3 loc)))))
(defun flan--forget-break-stack ()
"Stop `next-error' walking the break buffer's stack.
The break buffer makes itself `next-error-last-buffer' while the program is
stopped. Once the program resumes or ends that stack is gone, and `M-g M-n'
from a source buffer must go back to the last compilation's errors rather
than visit frames that no longer exist."
(let ((buf (and (boundp 'flan-cnr-buffer)
(get-buffer (symbol-value 'flan-cnr-buffer)))))
(when (and buf (eq next-error-last-buffer buf))
(setq next-error-last-buffer nil))))
(defun flan--visitable-loc (loc subject) (defun flan--visitable-loc (loc subject)
"LOC as (FILE LINE COL) when FILE can be visited, or a `user-error'. "LOC as (FILE LINE COL) when FILE can be visited, or a `user-error'.
SUBJECT names what LOC is the location of, for the refusal. One set of SUBJECT names what LOC is the location of, for the refusal. One set of

View File

@ -1319,6 +1319,53 @@ would be overwritten. Look again and re-do the edit")
(let ((b (get-file-buffer src))) (when b (kill-buffer b)))) (let ((b (get-file-buffer src))) (when b (kill-buffer b))))
(delete-directory dir t))) (delete-directory dir t)))
;; A stop raised inside a prelude function is shown where it stopped: the
;; innermost frame stays even though it is the prelude's, and the prelude
;; frames further out are still hidden. `(pause)' is the exception, above.
(let ((text (with-current-buffer
(test-flan--cnr
(list :condition "BoundsError" :restarts nil
:stack (list (list :fn "sum" :loc "<prelude>:40:3")
(list :fn "fold" :loc "<prelude>:12:1")
(list :fn "main" :loc "/g.flan:3:1"))))
(buffer-string))))
(test-flan--check "a prelude frame that stopped is shown"
(string-match-p " 0: > sum" text))
(test-flan--check "and the prelude frames outside it are hidden"
(and (not (string-match-p " 1: > fold" text))
(string-match-p "1 prelude frame hidden" text))))
;; Once a restart or an abort is accepted the stack is gone, and `next-error'
;; from a source buffer must stop walking it.
(dolist (how '(restart abort))
(let* ((flan-cnr-request-function (lambda (_) '(:status "ok")))
(buf (test-flan--cnr (list :condition "Missing" :restarts '("retry")
:stack (list (list :fn "f" :loc "/x.flan:1:1"))))))
(setq next-error-last-buffer buf)
(with-current-buffer buf
(save-window-excursion
(if (eq how 'abort) (flan-cnr-abort)
(goto-char (point-min))
(search-forward "retry")
(flan-cnr-take))))
(test-flan--check (format "after the %s, next-error no longer walks the stack" how)
(null next-error-last-buffer))))
;; And the same when the program resumed some other way: the break buffer is
;; forgotten, and any other next-error buffer is left alone.
(let ((flan-cnr-buffer " *test-cnr*")
(other (get-buffer-create " *test-other-errors*")))
(setq next-error-last-buffer (get-buffer " *test-cnr*"))
(flan--forget-break-stack)
(test-flan--check "a resume forgets the break buffer as the next-error buffer"
(null next-error-last-buffer))
(setq next-error-last-buffer other)
(flan--forget-break-stack)
(test-flan--check "and leaves a compilation's alone"
(eq next-error-last-buffer other))
(setq next-error-last-buffer nil)
(kill-buffer other))
;; The two buffers meet, and this is the fixture the bug lived in. `i' used ;; The two buffers meet, and this is the fixture the bug lived in. `i' used
;; to send the local's *name* to be evaluated, which resolves wherever the ;; to send the local's *name* to be evaluated, which resolves wherever the
;; evaluator stands: right on the innermost frame by luck, and on any other ;; evaluator stands: right on the innermost frame by luck, and on any other

View File

@ -8675,13 +8675,16 @@ and named_call ?(qualified = false) ctx ~want loc name args =
The same walk as [print], with the pieces aimed at flan_dev.c's watch 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 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 watches the way it prints.
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 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 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 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. *) when the table is not armed or is full, and the render is skipped.
Outside a dev build the backends drop the guarded [If] whole — see
[Tast.is_watch_guard] — so a release build evaluates the value and makes
no call at all. *)
| "watch" -> | "watch" ->
arity ctx loc name 2 args; arity ctx loc name 2 args;
let label = check ctx ~want:Types.String (List.hd args) in let label = check ctx ~want:Types.String (List.hd args) in
@ -8715,11 +8718,18 @@ and named_call ?(qualified = false) ctx ~want loc name args =
a number. *) a number. *)
| Types.String -> | Types.String ->
[ emitter.Render.estr (mk loc bslice (Tast.Prim (Tast.Bytes, [ value ]))) ] [ emitter.Render.estr (mk loc bslice (Tast.Prim (Tast.Bytes, [ value ]))) ]
| _ -> Render.render (render_ctx ctx emitter) 0 value | _ ->
Render.render
~refuse:(fun _loc t ->
Printf.sprintf
"%s has no rendering, so it cannot be watched — watch the \
values you want out of it instead"
(Types.to_string t))
(render_ctx ctx emitter) 0 value
in in
let begin_ = let begin_ =
mk loc (Types.Int Types.I32) mk loc (Types.Int Types.I32)
(Tast.Prim (Tast.Rt "flan_dev_watch_begin_n", [ label ])) (Tast.Prim (Tast.Rt Tast.watch_begin, [ label ]))
in in
let zero = mk loc (Types.Int Types.I32) (Tast.Int (0L, Types.I32)) 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 open_ = mk loc Types.Bool (Tast.Prim (Tast.Ne, [ begin_; zero ])) in
@ -10023,7 +10033,8 @@ let builtins : (string * string * string) list =
("watch", "watch [string T] ()", ("watch", "watch [string T] ()",
"Writes the value, rendered as print renders it, into the dev session's \ "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 \ 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."); when no watch buffer is open. Outside a dev build it only evaluates \
the value.");
("exit", "exit [i32] never", ("exit", "exit [i32] never",
"Ends the process with this status. It has no value, so nothing written \ "Ends the process with this status. It has no value, so nothing written \
after it runs."); after it runs.");

View File

@ -1865,6 +1865,9 @@ and value_at f (e : Tast.expr) : string =
bind_slot f slot) bind_slot f slot)
bs; bs;
block f body block f body
(* A (watch ...) outside a dev build: its else branch, which is unit. See
[Tast.is_watch_guard]. *)
| Tast.If (c, _, e') when (not f.md.dev) && Tast.is_watch_guard c -> value f e'
| Tast.If (c, t, e') -> emit_if f e.Tast.ty c t e' | Tast.If (c, t, e') -> emit_if f e.Tast.ty c t e'
| Tast.While (c, body, latch) -> emit_while f c body latch; "zeroinitializer" | Tast.While (c, body, latch) -> emit_while f c body latch; "zeroinitializer"
(* A plain branch, and then the block is dead — [term] closes it and [ins] (* A plain branch, and then the block is dead — [term] closes it and [ins]

View File

@ -1158,6 +1158,9 @@ and stmt f dest (e : Tast.expr) =
line f "%s = %s;" f.names.(slot) x) line f "%s = %s;" f.names.(slot) x)
binds; binds;
block f dest body block f dest body
(* A (watch ...): there is no watch table in the JS dialect, so only the
else branch, which is unit. See [Tast.is_watch_guard]. *)
| Tast.If (c, _, b) when Tast.is_watch_guard c -> stmt f dest b
| Tast.If (c, a, b) -> | Tast.If (c, a, b) ->
let cv = value f c in let cv = value f c in
line f "if (%s) {" cv; line f "if (%s) {" cv;

View File

@ -97,7 +97,14 @@ let max_span = 8
let fail = Loc.fail let fail = Loc.fail
let rec render c depth (e : Tast.expr) : Tast.expr list = (* The refusal for a type the walk has no arm for, worded for the form that
asked. [print]'s is the default. *)
let print_refusal _loc t =
Printf.sprintf "no printer for %s — print the values you want out of it"
(Types.to_string t)
let rec render ?(refuse = print_refusal) c depth (e : Tast.expr) : Tast.expr list =
let render c depth e = render ~refuse c depth e in
let loc = e.Tast.loc in let loc = e.Tast.loc in
let unit_ e = { Tast.e; ty = Types.Unit; loc } in let unit_ e = { Tast.e; ty = Types.Unit; loc } in
let cast t x = { Tast.e = Tast.Prim (Tast.Cast t, [ x ]); ty = t; loc } in let cast t x = { Tast.e = Tast.Prim (Tast.Cast t, [ x ]); ty = t; loc } in
@ -391,5 +398,4 @@ let rec render c depth (e : Tast.expr) : Tast.expr list =
arm above, and a [Var] never reaches a backend. So this names the fix arm above, and a [Var] never reaches a backend. So this names the fix
rather than only the refusal. *) rather than only the refusal. *)
| t -> | t ->
fail loc "no printer for %s — print the values you want out of it" fail loc "%s" (refuse loc t)
(Types.to_string t)

View File

@ -437,6 +437,19 @@ type program = {
A lifted clause's body is not in here: it is a function of its own, and this A lifted clause's body is not in here: it is a function of its own, and this
walks one expression. A reader that wants it follows [hfn], the way [Reach] walks one expression. A reader that wants it follows [hfn], the way [Reach]
does. *) does. *)
(* The name [(watch ...)] asks the table under, and the test a backend uses to
recognise the [If] the checker wraps a watch's rendering in. The checker
does not know whether the build is a dev one; a backend does, and outside a
dev build it lowers the whole [If] to its else branch, which is unit — so a
release build keeps only the value's own evaluation, bound before the [If],
and makes no call into the runtime at all. *)
let watch_begin = "flan_dev_watch_begin_n"
let is_watch_guard (c : expr) =
match c.e with
| Prim (Ne, [ { e = Prim (Rt s, _); _ }; _ ]) -> String.equal s watch_begin
| _ -> false
let rec walk (f : expr -> unit) (e : expr) = let rec walk (f : expr -> unit) (e : expr) =
f e; f e;
let go = walk f in let go = walk f in

View File

@ -1821,6 +1821,10 @@ and lower_at f (e : Tast.expr) (dst : loc) : unit =
bind_slot f slot) bind_slot f slot)
bs; bs;
block f body dst t block f body dst t
(* A (watch ...) outside a dev build: its else branch, which is unit. See
[Tast.is_watch_guard]. *)
| Tast.If (c, _, b) when (not f.md.Emit.dev) && Tast.is_watch_guard c ->
lower f b dst
| Tast.If (c, a, b) -> | Tast.If (c, a, b) ->
let lelse = new_label f "else" and lend = new_label f "endif" in let lelse = new_label f "else" and lend = new_label f "endif" in
scoped f (fun () -> let cv = eval f c in load_loc f ~reg:rax cv Types.Bool); scoped f (fun () -> let cv = eval f c in load_loc f ~reg:rax cv Types.Bool);

View File

@ -566,10 +566,10 @@ static uint64_t watch_epoch;
* build — [flan_dev.c] is linked into both (see [Build], which says why) so the * 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. * symbols resolve either way and there is no second version of this file.
* *
* What it is *not*: free. The [(watch ...)] form still makes the call, so it * What it is *not*: free. A scalar entry point called through [declare-c]
* costs the same as a scalar entry point does. A load and a branch * costs a load and a branch per watched value per frame, in both builds. The
* per watched value per frame is the honest number, and it is the same number * [(watch ...)] form costs that in a dev build only: outside one the backend
* in both builds rather than a dev-only tax. */ * drops its call, and what is left is the value's own evaluation. */
static int watch_on; static int watch_on;
void flan_dev_watch_enable(int on) { void flan_dev_watch_enable(int on) {

View File

@ -1,9 +1,8 @@
;;;; (watch "name" v) in a build with no dev session behind it. ;;;; (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 ;;;; Outside a dev build a watch makes no call into the runtime: the program
;;;; branch and nothing else: the program must compile, link and print exactly ;;;; must compile, link and print exactly what it prints without them. The value
;;;; what it prints without them. The value is still evaluated once, which the ;;;; is still evaluated once, which the counter shows.
;;;; counter shows.
(defstruct Pos [x i32 y f64]) (defstruct Pos [x i32 y f64])

View File

@ -4406,6 +4406,22 @@ level "1"
"programs/watch-release.flan" "3 2\n"; "programs/watch-release.flan" "3 2\n";
outputs ~x86:true "watch: a release build, --x86" outputs ~x86:true "watch: a release build, --x86"
"programs/watch-release.flan" "3 2\n"; "programs/watch-release.flan" "3 2\n";
(* And a release build makes no call into the watch table at all: the
backend drops the guarded render, so the only cost left is the value's
own evaluation. A dev build keeps the call. *)
(let p =
Reader.read_file "programs/watch-release.flan" |> Parse.program
|> Check.program
in
let call = "call i32 @flan_dev_watch_begin_n(" in
if contains (Emit.program p) call then begin
incr failures;
print_endline "FAIL watch: a release build still calls the watch table"
end;
if not (contains (Emit.program ~dev:true p) call) then begin
incr failures;
print_endline "FAIL watch: a dev build lost its call to the watch table"
end);
(* ── Per-type descriptors, M2 item 2 ───────────────────────────── (* ── Per-type descriptors, M2 item 2 ─────────────────────────────
The first program anywhere with a dyn field in a struct, which was a 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 refusal until the descriptors landed. It matters at all three rows

View File

@ -5747,6 +5747,17 @@ let () =
Printf.printf "FAIL %s\n wanted: %s (no printer for)\n got: %s (%s)\n" Printf.printf "FAIL %s\n wanted: %s (no printer for)\n got: %s (%s)\n"
name "<test>:1:43" got dmsg name "<test>:1:43" got dmsg
end); end);
(* The same refusal from watch is worded for watch, not for print. *)
(let name = "an unwatchable value is refused in watch's own words" in
match checked "(defn f [m (Map i32 i32)] () (watch \"m\" m))" with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n" name
| exception Loc.Error { Loc.dmsg; _ } ->
if not (contains dmsg "cannot be watched") || contains dmsg "print" then begin
incr failures;
Printf.printf "FAIL %s\n got: %s\n" name dmsg
end);
(* A predicate a body relies on has to be carried by every signature between (* A predicate a body relies on has to be carried by every signature between
it and the call site, or the refusal moves into code the caller did not it and the call site, or the refusal moves into code the caller did not

View File

@ -213,8 +213,8 @@ let corpus =
immortal and is not a collector object. If that reasoning is wrong, immortal and is not a collector object. If that reasoning is wrong,
50000 instances past the one-megabyte floor is where ASan says so. *) 50000 instances past the one-megabyte floor is where ASan says so. *)
"programs/dyn-class.flan", []; "programs/dyn-class.flan", [];
(* (watch ...) with the table unarmed: every value is bound and every (* (watch ...) outside a dev build: every value is bound to a slot and
call reaches flan_dev_watch_begin_n, and nothing past it runs. *) evaluated, and nothing else about the form is emitted. *)
"programs/watch-release.flan", []; "programs/watch-release.flan", [];
(* nil <-> None at (Option T), M2 queue item 4: an Option's tag is read (* 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 with a raw [Field] the surface language never writes (check.ml's