Ask which lines allocate, and believe the runtime over the enumeration

[Check.memory_sites] is the pass [Check.no_gc]'s shape: it runs over the
finished program, answers a diagnostic list, and tells nothing downstream
that it ran. Two classes on the diagnostic's kind — the collector's heap
and an allocator the program named — so the CLI, the daemon and the editor
dispatch on one field and none of them reads a message to find the class.

[--warn-memory] on check and build prints them where errors go, in the
shape flycheck parses, without moving the exit status. [(:op "memory")]
answers the same list over a session's last checked program, needing no
program on the far end. [M-x flan-check-memory] paints it two faces fainter
than an error's, cleared by an edit or by asking again.

Two of the spec's own examples turned out not to allocate, and the
precision rule outranks the enumeration: (vec-new T) passes a capacity of
literal zero to flan_vec_init, which returns before the grow, and
flan_map_init takes no block at all and says so in its own comment. The
block arrives at the first push, which is the line that is marked. The
classifier reads the capacity argument rather than the symbol, which is
what lets slurp be marked through the same entry point vec-new is silent
through. FIX.org has the rest of the evidence.
This commit is contained in:
Joseph Ferano 2026-09-20 11:58:47 +07:00
parent 7db5ec1885
commit 5b0062219e
8 changed files with 707 additions and 7 deletions

88
FIX.org
View File

@ -806,3 +806,91 @@ script already has SURVEY_FLAGS, which hands the same extra flags to both
sides, and both sides do accept -O0 — and something that walks the two prim sides, and both sides do accept -O0 — and something that walks the two prim
match arms mechanically rather than relying on somebody reading them side by match arms mechanically rather than relying on somebody reading them side by
side, which is how this gap survived. Neither is queued. side, which is how this gap survived. Neither is queued.
* Memory diagnostics on demand, decided 2026-09-20
** The author's spec
Clojure's [*warn-on-boxed*] crossed with Rider's heap-allocation squiggles.
Both kinds of allocation: the GC's — boxing a typed value into dyn where it is
not an immediate, map/vec/string construction, the big-int spill — and the
native side's — vec-new, a push that may grow, arena allocation, slurp,
anything routing through an allocator. Two visually distinct classes, rendered
in different colours by the editor and both FAINTER than an error ("they
should somewhat fade"). Off by default, surfaced on demand two ways: an Emacs
command of the "check for warnings" shape that asks for the current buffer's
and overlays the answer, and a compiler flag so the CLI can decide when they
appear. Flycheck integration a nice-to-have. Precision over completeness in
v1: never mark a site that does not allocate — a dyn immediate must not
squiggle — and a site that allocates only sometimes says "may allocate".
** What landed
[Check.memory_sites], a pass over the finished program in the shape
[Check.no_gc] already has: it runs after checking, answers a [Loc.diag list],
and nothing downstream is told it exists. Asking cannot change what compiles.
The class rides on the diagnostic's [kind] — "memory/gc" or "memory/native" —
so the CLI and the daemon dispatch on one field and neither parses a message.
[flan check FILE --warn-memory] and [flan build ... --warn-memory] print them
to stderr in the standard [file:line:col: warning: ] shape with the squiggle,
filtered to the file named on the command line. The exit status does not move.
[(:op "memory")] on the dev daemon answers [(LOC KIND MESSAGE)] rows over
[t.session.program], needing no running program — a parked session answers it.
[M-x flan-check-memory] in flan.el paints them: two faces, both fainter than
[flan-error-face] and with no message drawn beside the line, priority under an
error's so a refusal still wins a shared span. [M-x flan-clear-memory], or an
edit, or asking again, takes them down.
** Where this overrode the spec, and the evidence
*** (vec-new T) and (map-new K V) are not marked.
The spec's enumeration lists vec-new as a native allocation; the runtime says
otherwise and the spec's own precision rule says to believe the runtime. The
["vec-new"] arm in check.ml passes a capacity of literal zero to
[flan_vec_init], and that function's body returns before [flan_vec_grow] when
[cap <= 0]. [flan_map_init] never takes a block at all and carries its own
comment saying so — "No block until something is put in it". The block arrives
at the first push or put, and those are the lines marked. [(vec-new dyn)] and
a dyn map literal are the other answer: those are the dyn runtime's own
objects and [gc_alloc] runs at the call, so they are marked.
The classifier reads [flan_vec_init]'s capacity argument rather than keying on
the symbol, which is what lets [slurp] — the caller that sizes the Vec to the
file — be marked "allocates" through the same entry point that vec-new is
silent through.
*** Dyn arithmetic is not marked.
[flan_dyn_add] and its siblings end in [flan_dyn_from_i64], so a wide enough
result does spill. Nothing static knows the operands, and a squiggle under
every dyn [+] is exactly the false positive the precision rule exists to
prevent. [flan_dyn_from_i64] IS marked at an explicit crossing, and only when
the value can leave the 48-bit payload: a literal inside ±2^47 and a value
widened from a narrower integer type are both provably immediate and silent.
*** Keywords are not marked.
Interned and immortal — flan_dyn.c's intern table holds the only copy of each
name, nothing removes one, and [mark_value] walks BOX_OBJ and nothing else.
There is no GC object to attribute.
*** The overlays outlive the next command.
The spec asked for the paths that clear an error overlay. Those hang off
[pre-command-hook], which takes an overlay down before the next keystroke —
right for feedback about a failed evaluation, and fatal for an annotation:
moving point through a marked line is what you do with these on screen. They
clear on [after-change-functions] instead, plus the explicit command and the
repeat-toggle. Documented in emacs/MANUAL.md.
** Flycheck
flan.el has no flycheck wiring of any kind, so per the spec's own branch no
checker was defined. emacs/MANUAL.md documents the CLI pattern and carries the
[flycheck-define-checker] form for anyone who wants one — the flag is the
command, and the printed shape is the error pattern.
** Pinned
test/test_flan.ml pins three programs by exact location, kind and message: the
collected heap (a dyn vec, a map literal, a string crossing, a wide i64, and
the five immediates plus an i32 widening that must stay silent); the allocator
side (arena-new, slurp's sized vec-init, a typed container's view record, put,
reserve, with a typed vec-new and map-new silent between them); and dyn
arithmetic answering nothing at all. test/test_dev.ml drives [(:op "memory")]
over the socket against programs/dev-dyn-global.flan, whose one line is two
gc crossings at two columns and no native allocation anywhere.

View File

@ -203,9 +203,35 @@ let no_annotate_flag = "--no-annotate"
the flag, and that is the property the flag is worth having for. *) the flag, and that is the property the flag is worth having for. *)
let no_gc_flag = "--no-gc" let no_gc_flag = "--no-gc"
(* "Which of these lines allocates?", printed and nothing else. The other side
of [--no-gc]: that flag refuses a program for holding a dyn, this one only
says where the memory goes on the collector's heap and on an allocator the
program named, told apart by the diagnostic's kind. Off by default, because
a warning on every push in a program that means them is noise; asked for,
because a frame budget is a question you ask on purpose.
It is a warning and not an error, and it says so in the one way that
matters: the exit status does not move. See [Check.memory_sites], which is
a pass over the checked program and, like [Check.no_gc], tells nothing
downstream that it ran. *)
let warn_memory_flag = "--warn-memory"
let flags = let flags =
[ no_checks_flag; dev_flag; debug_flag; sanitize_flag; two_process_flag; [ no_checks_flag; dev_flag; debug_flag; sanitize_flag; two_process_flag;
x86_flag; llvm_flag; no_annotate_flag; no_gc_flag ] x86_flag; llvm_flag; no_annotate_flag; no_gc_flag; warn_memory_flag ]
(* The warnings, where errors go. Printed one to a location in the repo's
standard [file:line:col:] shape, with the squiggle [Loc.entry] draws, so
an editor or a flycheck checker parses them exactly as it parses an error.
Not through [Loc.report_all]: that appends a count of *errors*, and these
are not errors. *)
let print_memory_warnings ~file (p : Flan.Tast.program) =
List.iter
(fun (d : Flan.Loc.diag) ->
prerr_endline
(Flan.Loc.entry ~mark:'~' ~label:"warning: " d.Flan.Loc.dloc
d.Flan.Loc.dmsg))
(Flan.Check.memory_sites ~file p)
(* Which backend a command got, from the two flags and the default it would (* Which backend a command got, from the two flags and the default it would
have taken. One function because there is one rule, and the only thing that have taken. One function because there is one rule, and the only thing that
@ -299,11 +325,19 @@ let () =
|> Flan.Parse.program_all |> Flan.Parse.program_all
|> List.iter (fun d -> print_endline (summarise d)))) |> List.iter (fun d -> print_endline (summarise d))))
files files
| _ :: "check" :: files when files <> [] -> | _ :: "check" :: args when List.exists (fun a -> not (is_flag a)) args ->
let warn_memory = List.mem warn_memory_flag args in
let files = List.filter (fun a -> not (is_flag a)) args in
List.iter List.iter
(fun path -> (fun path ->
with_errors path (fun () -> with_errors path (fun () ->
let p = checked path in let p = checked path in
(* After the listing is decided and before it is printed is not a
distinction anything can see: the warnings go to stderr and the
listing to stdout. What matters is that they are asked of a
program that checked, which is what leaves the exit status
alone. *)
if warn_memory then print_memory_warnings ~file:path p;
List.iter List.iter
(fun (g : Flan.Tast.global) -> (fun (g : Flan.Tast.global) ->
Printf.printf "%s %s %s\n" Printf.printf "%s %s %s\n"
@ -666,7 +700,8 @@ let () =
prerr_endline prerr_endline
"usage: flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \ "usage: flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] \ [--no-bounds-checks] \
[--dev] [--debug] [--sanitize] [--no-gc] [--target=wasm32-wasi|web|js]"; [--dev] [--debug] [--sanitize] [--no-gc] [--warn-memory] \
[--target=wasm32-wasi|web|js]";
exit 2 exit 2
in in
with_errors path (fun () -> with_errors path (fun () ->
@ -677,6 +712,9 @@ let () =
what [main] happened to reach would come and go as the program was what [main] happened to reach would come and go as the program was
edited elsewhere. *) edited elsewhere. *)
if List.mem no_gc_flag rest then Flan.Check.no_gc p; if List.mem no_gc_flag rest then Flan.Check.no_gc p;
(* Before reachability too, and for the same reason: a push in a function
nothing calls is still a push somebody wrote. *)
if List.mem warn_memory_flag rest then print_memory_warnings ~file:path p;
(* The link follows the program, not the import list: a package nothing (* The link follows the program, not the import list: a package nothing
reachable calls into contributes no C and no linker argument, and its reachable calls into contributes no C and no linker argument, and its
functions are not emitted either. That is what lets one file import functions are not emitted either. That is what lets one file import
@ -877,12 +915,12 @@ let () =
exit code) exit code)
| _ -> | _ ->
prerr_endline prerr_endline
"usage: flan (read|parse|check|emit|shim) <file.flan>...\n flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]\n\ "usage: flan (read|parse|check|emit|shim) <file.flan>...\n flan check <file.flan>... [--warn-memory]\n flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]\n\
\ flan import-c <header.h> [package.flan...] [clang flags...]\n\ \ flan import-c <header.h> [package.flan...] [clang flags...]\n\
\ flan generate-c <package-dir>\n\ \ flan generate-c <package-dir>\n\
\ flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \ \ flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] [--dev] \ [--no-bounds-checks] [--dev] \
[--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web|js]\n\ [--debug] [--sanitize] [--x86] [--warn-memory] [--target=wasm32-wasi|web|js]\n\
\ flan run <file.flan> [build flags...] [--] [program args...]\n\ \ flan run <file.flan> [build flags...] [--] [program args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so] [--x86]\n\ \ flan reload <program.flan> <forms.flan> [-o out.so] [--x86]\n\
\ flan dev <program.flan> [-s socket] [--x86]"; \ flan dev <program.flan> [-s socket] [--x86]";

View File

@ -546,6 +546,60 @@ can print the same breakdown to stderr by being run with `FLAN_DEV_LEAKS` set
Both are dev-build only. A release build records nothing and says so. Both are dev-build only. A release build records nothing and says so.
### Which lines allocate — `M-x flan-check-memory`
The two commands above ask the running program what it *took*. This one asks the
compiler what the source *says*, and draws the answer under the characters that
say it. Clojure's `*warn-on-boxed*` crossed with Rider's heap-allocation
squiggles.
It needs no running program — a parked session answers it — and it is **off
until you ask**. A program that means its allocations does not want them
underlined while it is being written; you ask when the question is "where is
this frame's memory going".
**Two colours, because there are two heaps.** One face for the dyn runtime's
collected heap: a string, a vec or a map crossing into `dyn`, the view record a
typed container takes when it crosses, an `i64` too wide for a `dyn`'s payload.
Another for an allocator the program named: a push or a `reserve` past capacity,
a `clone`, a `slurp`, a new arena. Both are fainter than an error — a dotted or
dimmed underline, no message drawn beside the line — because the code is not
wrong. Hover for the sentence; the count is in the echo area.
**What is deliberately not marked is the half worth knowing.** A `dyn` immediate
costs nothing, so nothing is drawn: `nil`, a `bool`, an `f64`, a keyword, and any
integer inside ±2^47 live in the word itself. Neither is `(vec-new T)` or
`(map-new K V)` — a typed container takes no block until something is put in it,
and the line that is marked is the first push. A site that allocates only
sometimes says so in its first two words: "may allocate".
They last until you edit the buffer — they are a reading of the source, not
feedback about an evaluation, so moving point through them leaves them alone.
Asking again while they are up takes them down.
#### The same thing from the command line, and flycheck
`flan check FILE --warn-memory` prints the same list in the standard
`file:line:col: warning: ...` shape, on stderr, with the squiggle. `flan build`
takes the flag too. **The exit status does not move** — these are warnings, and
a program that only warns still builds.
There is no flycheck checker in `flan.el` today. If you want one, the flag is
what it should call, and the message shape is what it should parse:
```elisp
(flycheck-define-checker flan-memory
"Flan's allocation diagnostics."
:command ("flan" "check" "--warn-memory" source-original)
:error-patterns
((warning line-start (file-name) ":" line ":" column
": warning: " (message) line-end))
:modes flan-mode)
```
Only the file named on the command line is reported, so the prelude's own pushes
— real, and none of your business — stay out of it.
### The watch buffer — values while the program runs ### The watch buffer — values while the program runs
Everything above is for a program you have stopped, or one you interrupt with a Everything above is for a program you have stopped, or one you interrupt with a
@ -935,8 +989,10 @@ Commands with no key: `M-x flan` (start a program), `M-x flan-quit`
(stop it), `M-x flan-watch` (the watch buffer), `M-x flan-watch-stop`, (stop it), `M-x flan-watch` (the watch buffer), `M-x flan-watch-stop`,
`M-x flan-watch-ghost-mode` (the same values inline), `M-x flan-watch-ghost-mode` (the same values inline),
`M-x flan-inspect-address` (what is at an address), `M-x flan-macroexpand-all` `M-x flan-inspect-address` (what is at an address), `M-x flan-macroexpand-all`
(the `C-u` half of `C-c C-m`, by name), and `M-x flan-allocations` / (the `C-u` half of `C-c C-m`, by name), `M-x flan-allocations` /
`M-x flan-leaks` (where the memory went, and what is still held). `M-x flan-leaks` (where the memory went, and what is still held), and
`M-x flan-check-memory` / `M-x flan-clear-memory` (which lines allocate, marked
in the buffer).
--- ---

View File

@ -2712,5 +2712,140 @@ can print the same breakdown to stderr under FLAN_DEV_LEAKS."
(interactive) (interactive)
(flan-allocations--show "leaks" "Still held, by type")) (flan-allocations--show "leaks" "Still held, by type"))
;;; Which lines allocate
;; The static half of the two commands above, and the distinction is the whole
;; reason it is a separate feature: `flan-allocations' asks the running program
;; what it took, and this asks the compiler what the source says. It needs no
;; program on the far end — a parked session answers it — and it points at
;; characters in the buffer rather than at a table of type names.
;;
;; Clojure's `*warn-on-boxed*' crossed with Rider's heap-allocation squiggles,
;; and off by default for the same reason both of those are: a program that
;; means its allocations does not want them underlined while it is being
;; written. You ask when the question is "where is this frame's memory going".
;;
;; Two classes and two faces, because the two heaps are not the same heap:
;; `memory/gc' is the dyn runtime's collected heap and `memory/native' is an
;; allocator the program named. The daemon decides which; nothing here reads
;; the message to find out.
;;
;; **Fainter than an error, deliberately.** These are annotations on code that
;; is correct. So: a dotted underline rather than an error's wave, a dimmed
;; colour, and no per-site `after-string' — the message goes in `help-echo' and
;; the count in the echo area. An error must still win where the two land on
;; one span, which is what the lower `priority' is for.
;;
;; **And they last, which an error overlay does not.** The error overlay is
;; feedback about the evaluation that just failed, so any command at all takes
;; it down; these are a reading of the source, so they last until the source
;; changes. `after-change-functions', not `pre-command-hook': moving point
;; through a marked line is exactly what you do with them on screen, and an
;; annotation that vanished on the first `C-n' could never be read. Asking a
;; second time with them up takes them down, which is the toggle people expect
;; of a command they turned on.
(defface flan-memory-gc-face
'((t :inherit font-lock-warning-face :weight normal
:underline (:style wave)))
"Face for a line that allocates on the dyn runtime's collected heap."
:group 'flan)
(defface flan-memory-native-face
'((t :inherit font-lock-constant-face :weight normal
:underline (:style line)))
"Face for a line that allocates through an allocator the program named."
:group 'flan)
(defun flan--memory-overlays (&optional buffer)
"The Flan memory overlays in BUFFER, or in the current buffer."
(with-current-buffer (or buffer (current-buffer))
(seq-filter (lambda (o) (overlay-get o 'flan-memory))
(overlays-in (point-min) (point-max)))))
(defun flan-clear-memory (&optional buffer)
"Remove the allocation annotations from BUFFER, or from the current buffer."
(interactive)
(with-current-buffer (or buffer (current-buffer))
(remove-overlays (point-min) (point-max) 'flan-memory t)
(remove-hook 'after-change-functions #'flan--clear-memory-on-change t)))
(defun flan--clear-memory-on-change (_beg _end _len)
"Take this buffer's allocation annotations down, as an `after-change-functions'.
They are a reading of the source as it was when you asked, and an edit is
what makes that reading stale nothing smaller does, which is why this is
not the `pre-command-hook' the error overlays use."
(flan-clear-memory))
(defun flan--memory-face (kind)
"The face for a diagnostic of KIND, or nil if KIND is not one of ours."
(cond ((equal kind "memory/gc") 'flan-memory-gc-face)
((equal kind "memory/native") 'flan-memory-native-face)))
(defun flan--show-memory (loc kind msg)
"Mark MSG of KIND at LOC, if LOC names a file some buffer is visiting.
Returns the buffer it marked, or nil."
(let ((parts (flan--parse-loc loc))
(face (flan--memory-face kind)))
(when (and parts face)
(let ((buf (flan--buffer-visiting (nth 0 parts))))
(when buf
(with-current-buffer buf
(let* ((beg (flan--position (nth 1 parts) (nth 2 parts)))
(end (save-excursion (goto-char beg) (line-end-position)))
(ov (make-overlay beg end buf t nil)))
(overlay-put ov 'flan-memory t)
(overlay-put ov 'face face)
(overlay-put ov 'help-echo msg)
(overlay-put ov 'evaporate nil)
;; Under an error's 100: a refusal and an annotation can land on
;; one span, and the refusal is the one you have to act on.
(overlay-put ov 'priority 50)
(add-hook 'after-change-functions
#'flan--clear-memory-on-change nil t)
buf)))))))
;;;###autoload
(defun flan-check-memory ()
"Underline every line of this session's program that allocates.
Two colours: one for the dyn runtime's collected heap a string, a vec or a
map crossing into dyn, a typed container's view record, an i64 too wide for a
dyn's payload and one for an allocator the program named: a push or a
reserve past capacity, a clone, a slurp, a new arena. Both fainter than an
error, because the code is not wrong.
What is deliberately *not* marked is the half worth knowing: a dyn immediate.
nil, a bool, an f64, a keyword and an int inside ±2^47 live in the word
itself, so boxing one costs nothing and nothing is drawn. Neither is
`(vec-new T)' or `(map-new K V)' a typed container takes no block until
something is put in it; the block arrives at the first push, which is the
line that is marked.
The annotations last until you edit the buffer. Asking again while they are
up takes them down."
(interactive)
(if (flan--memory-overlays)
(progn (flan-clear-memory) (message "flan: allocation marks off"))
(let ((r (flan--request (list :op "memory"))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
(let ((here 0) (elsewhere 0))
(dolist (row (plist-get r :sites))
(if (flan--show-memory (nth 0 row) (nth 1 row) (nth 2 row))
(setq here (1+ here))
(setq elsewhere (1+ elsewhere))))
(cond
((and (zerop here) (zerop elsewhere))
(message "flan: nothing in this program allocates"))
((zerop here)
(message "flan: %d allocating site%s, none in a buffer you have open"
elsewhere (if (= elsewhere 1) "" "s")))
(t
(message "flan: %d allocating site%s marked%s"
here (if (= here 1) "" "s")
(if (zerop elsewhere) ""
(format ", %d more elsewhere" elsewhere)))))))))
(provide 'flan) (provide 'flan)
;;; flan.el ends here ;;; flan.el ends here

View File

@ -8437,3 +8437,174 @@ let dyn_sites (p : Tast.program) : Loc.diag list =
let no_gc (p : Tast.program) = let no_gc (p : Tast.program) =
match dyn_sites p with [] -> () | ds -> raise (Loc.Errors ds) match dyn_sites p with [] -> () | ds -> raise (Loc.Errors ds)
(* ── Memory diagnostics ─────────────────────────────────────────────────
"Which of these lines allocates?", answered on demand. Clojure's
[*warn-on-boxed*] crossed with Rider's heap-allocation squiggles, and the
same shape [dyn_sites] above has: a pass over the finished program, off
unless somebody asks, and nothing downstream is told it exists. Asking for
it cannot change what compiles.
Two classes, because the two heaps are not the same heap and a reader wants
to know which one a line is spending. [kind] carries it ["memory/gc"] is
the dyn runtime's collected heap, ["memory/native"] is an allocator the
program named so the CLI and the daemon dispatch on one field and neither
has to parse a message.
**Precision over completeness.** A site named here allocates, and a site
that only *might* says so in the first two words. That rule is what decides
the table below, and it decided it against the obvious guesses more than
once every claim here was read out of runtime/flan_rt.c and
runtime/flan_dyn.c rather than assumed:
- [(vec-new T)] does not allocate. The lowering passes a capacity of zero
(see the [flan_vec_init] call in the ["vec-new"] arm) and
[flan_vec_init]'s body returns before [flan_vec_grow] when [cap <= 0].
The block arrives at the first push. [(map-new K V)] is the same: its
[flan_map_init] leaves [data] NULL and says so on its own line.
[(vec-new dyn)] and [(map-new dyn)] are the *other* answer those are
the dyn runtime's own objects and [gc_alloc] runs at the call.
- A dyn immediate does not allocate: nil, a bool, an f64, a keyword, and
an int inside the payload. The payload is 48 bits
([DYN_PAYMASK]/[DYN_INT_MAX] in flan_dyn.c), so only an i64 that can
leave ±2^47 is a "may allocate", and a value widened from a narrower
integer type provably cannot.
- A keyword is interned and immortal [flan_dyn_kw]'s entry is not a GC
object and the collector never traces one so it is not named here.
- Dyn arithmetic is not named. [flan_dyn_add] and its siblings end in
[flan_dyn_from_i64], so a wide enough result spills, but nothing static
knows the operands and a squiggle on every [(+ a b)] over dyn is the
false positive this pass exists not to have. *)
(* The payload's range, restated from [DYN_INT_MAX]/[DYN_INT_MIN] in
runtime/flan_dyn.c: 2^47-1 and its negation less one. Restated rather than
read, the way every other number this compiler shares with the runtime is,
and wrong only in the direction of a missing warning if the runtime ever
widens it. *)
let dyn_payload_max = 140737488355327L
let dyn_payload_min = -140737488355328L
(* An integer type that cannot reach the payload's edge whatever its value. *)
let narrower_than_payload (t : Types.t) =
match t with
| Types.Int (Types.I8 | Types.I16 | Types.I32
| Types.U8 | Types.U16 | Types.U32) -> true
| _ -> false
(* Can this argument to [flan_dyn_from_i64] spill onto the heap?
One level of unwrapping and no more: [box] widens with a single
[Cast i64], and peeling further would walk through a *narrowing* cast the
programmer wrote and report a range the value cannot have. *)
let int_may_spill (e : Tast.expr) =
let e =
match e.Tast.e with
| Tast.Prim (Tast.Cast (Types.Int Types.I64), [ inner ])
when narrower_than_payload inner.Tast.ty -> inner
| _ -> e
in
match e.Tast.e with
| Tast.Int (n, _) -> n > dyn_payload_max || n < dyn_payload_min
| _ -> not (narrower_than_payload e.Tast.ty)
(* A [flan_vec_init] whose capacity is a literal zero takes no block. That is
every [(vec-new T)]; [slurp] passes the file's size and is the caller that
makes this a test rather than a constant. *)
let vec_init_allocates (args : Tast.expr list) =
match args with
| _ :: _ :: cap :: _ ->
(match cap.Tast.e with Tast.Int (n, _) -> n > 0L | _ -> true)
| _ -> true
(* The classifier. [Some (kind, message)] for a site that allocates or may,
[None] for everything else and [None] is the answer for every symbol not
named here, which is what keeps a new runtime entry point silent rather
than guessed at. *)
let memory_class (sym : string) (args : Tast.expr list) =
let gc m = Some ("memory/gc", m) and native m = Some ("memory/native", m) in
match sym with
(* ── The collected heap ── *)
| "flan_dyn_from_bytes" ->
gc "allocates: a string crossing into dyn is copied onto the \
collector's heap"
| "flan_dyn_vec_new" ->
gc "allocates: a dyn vector is an object on the collector's heap"
| "flan_dyn_map_new" ->
gc "allocates: a dyn map is an object on the collector's heap"
| "flan_dyn_view_vec" | "flan_dyn_view_flat" ->
gc "allocates: a typed container crossing into dyn takes a view record \
on the collector's heap the elements are not copied, the record is"
| "flan_dyn_from_i64" when (match args with [ x ] -> int_may_spill x | _ -> true) ->
gc "may allocate: an i64 outside ±2^47 does not fit a dyn's payload and \
spills onto the collector's heap"
(* ── An allocator the program named ── *)
| "flan_arena_new" ->
native "allocates: an arena takes its whole region from the host here"
| "flan_vec_init" when vec_init_allocates args ->
native "allocates: the Vec is sized up front and takes its block from \
its allocator here"
| "flan_vec_push" ->
native "may allocate: a push past the Vec's capacity grows it through \
its allocator"
| "flan_vec_reserve" ->
native "may allocate: a reserve past the Vec's capacity grows it through \
its allocator"
| "flan_map_put" ->
native "may allocate: a put past the map's load factor grows its block \
through its allocator"
| "flan_map_reserve" ->
native "may allocate: a reserve past the map's load factor grows its \
block through its allocator"
| "flan_vec_clone" ->
native "may allocate: cloning a non-empty Vec takes a new block from its \
allocator"
| "flan_map_clone" ->
native "may allocate: cloning a non-empty map takes a new block from its \
allocator"
| _ -> None
(** Every site in the program that allocates, or may. Ordered by source
position, one diagnostic per location and class a lowering emits several
runtime calls at one location and a reader wants the line named once.
[?file] narrows it to one source file, which is what a command that was
handed a path wants: the prelude pushes onto Vecs on a dozen lines and an
import has its own, and neither is a line the person who asked can do
anything about. Left out, everything the program holds is reported which
is what a client that does its own filtering, the editor among them,
should ask for. *)
let memory_sites ?file (p : Tast.program) : Loc.diag list =
let found = ref [] in
let seen = Hashtbl.create 64 in
let look (e : Tast.expr) =
match e.Tast.e with
| Tast.Prim (Tast.Rt sym, args) ->
(match memory_class sym args with
| None -> ()
| Some (kind, msg) ->
let loc = e.Tast.loc in
let key = (loc.Loc.file, loc.Loc.line, loc.Loc.col, kind) in
if (match file with None -> true | Some f -> String.equal f loc.Loc.file)
&& not (Hashtbl.mem seen key) then begin
Hashtbl.replace seen key ();
found := Loc.diag ~kind loc msg :: !found
end)
| _ -> ()
in
(* A global's initialiser runs at startup and allocates there as much as a
body does [(defvar names (vec-new dyn))] is a heap object before main
has a line of its own so the globals are walked and not only the
functions. *)
List.iter (fun (g : Tast.global) -> Tast.walk look g.Tast.ginit) p.Tast.globals;
List.iter
(fun (fn : Tast.fn) -> List.iter (Tast.walk look) fn.Tast.body)
p.Tast.fns;
let placed (d : Loc.diag) = d.Loc.dloc.Loc.line > 0 in
List.stable_sort
(fun a b ->
match (placed a, placed b) with
| true, false -> -1
| false, true -> 1
| _ -> Loc.before a.Loc.dloc b.Loc.dloc)
(List.rev !found)

View File

@ -2994,6 +2994,45 @@ let watch_read t ~reset =
| exception Unix.Unix_error (e, _, _) -> | exception Unix.Unix_error (e, _, _) ->
error ("cannot reach the program: " ^ Unix.error_message e) error ("cannot reach the program: " ^ Unix.error_message e)
(* [(:op "memory")] — which lines of this session's program allocate.
The static counterpart of [allocations] and [leaks] above, and the
distinction is worth stating because the three read as one family and are
not. Those two ask the *running* program what it took and what it still
holds; this one asks the compiler what the source says, needs no program on
the other end of the socket, and works on a session whose process has
parked or died. It is [Check.memory_sites] and nothing else the session
already holds the last program that checked, so there is no re-read of the
file and no second opinion about what the buffer contains.
Everything is returned, the prelude's lines included, and [:file] is
offered rather than applied: the editor knows which buffers it has open and
[flan.el] compares paths with [file-equal-p], which a string match here
could not. A client that wants one file's worth and does not want to filter
can still say so.
Each row is [(LOC KIND MESSAGE)]: the location in the same
[file:line:col] spelling every other reply uses, the diagnostic's kind
["memory/gc"] for the collector's heap, ["memory/native"] for an allocator
the program named and the sentence. The kind is the field a client paints
from; nothing should be reading the message for its class. *)
let memory_op t ~file =
let ds = Check.memory_sites ?file t.session.Session.program in
let row (d : Loc.diag) =
Wire.list
[ Wire.quote (Loc.to_string d.Loc.dloc);
Wire.quote d.Loc.kind;
Wire.quote d.Loc.dmsg ]
in
ok
[ ":sites " ^ Wire.list (List.map row ds);
Printf.sprintf ":count %d" (List.length ds);
":note "
^ Wire.quote
"every site the checker can prove allocates, and every one it can \
prove may; a dyn immediate nil, a bool, an f64, a keyword, an \
int inside the payload is not one and is not listed" ]
let handle t req = let handle t req =
match Wire.string_field req "op" with match Wire.string_field req "op" with
| Some "eval" -> | Some "eval" ->
@ -3179,6 +3218,7 @@ let handle t req =
"what the registry still holds live at the moment it was asked; a \ "what the registry still holds live at the moment it was asked; a \
program that is killed runs no exit handler, so this verb and not a \ program that is killed runs no exit handler, so this verb and not a \
hook is what answers for one" hook is what answers for one"
| Some "memory" -> memory_op t ~file:(Wire.string_field req "file")
| Some "layout" -> | Some "layout" ->
(match Wire.string_field req "type" with (match Wire.string_field req "type" with
| Some ty -> layout t ~ty | Some ty -> layout t ~ty

View File

@ -4690,6 +4690,64 @@ let () =
else begin else begin
let c = connect dsock in let c = connect dsock in
let said r = Option.value ~default:(status r) (Wire.string_field r "message") in let said r = Option.value ~default:(status r) (Wire.string_field r "message") in
(* [(:op "memory")] — which lines of the program allocate, asked of
the daemon rather than of the running program. It is
[Check.memory_sites] over the session's last checked program, so
it needs no process on the far end and says the same thing here
as [flan check --warn-memory] says on the command line.
This fixture is the one that earns the check: its [(set config
{:s "kept" :n 1})] is two crossings into the collected heap on
one line the map and the string inside it at columns the
squiggle has to get right, and it has no native allocation
anywhere, which is the half a classifier that answered "gc" for
everything would also pass. Rows from the prelude ride along and
are ignored here; [flan.el] filters by the buffers it has open. *)
let mem_rows () =
let r = request c "(:op \"memory\")" in
if status r <> "ok" then begin
fail "--%s: memory: %s" backend (said r); []
end
else
match Wire.field r "sites" with
| Some { Form.v = Form.List rows; _ } ->
List.filter_map
(fun (row : Form.t) ->
match row.Form.v with
| Form.List
[ { Form.v = Form.Str loc; _ };
{ Form.v = Form.Str kind; _ };
{ Form.v = Form.Str msg; _ } ] ->
Some (loc, kind, msg)
| _ -> None)
rows
| _ -> fail "--%s: memory answered no :sites" backend; []
in
let rows = mem_rows () in
let ours =
List.filter
(fun (loc, _, _) ->
contains_sub loc "programs/dev-dyn-global.flan")
rows
in
let has loc kind needle =
List.exists
(fun (l, k, m) ->
contains_sub l loc && k = kind && contains_sub m needle)
ours
in
if not (has ":23:15" "memory/gc" "a dyn map is an object") then
fail "--%s: memory did not name the map literal at 23:15: %s"
backend
(String.concat "; "
(List.map (fun (l, k, _) -> l ^ " " ^ k) ours));
if not (has ":23:19" "memory/gc" "a string crossing into dyn") then
fail "--%s: memory did not name the string at 23:19" backend;
if List.exists (fun (_, k, _) -> k = "memory/native") ours then
fail
"--%s: memory called a line of dev-dyn-global.flan a native \
allocation, and the file has none"
backend;
let parked () = let parked () =
match Wire.field (request c "(:op \"describe\")") "parked" with match Wire.field (request c "(:op \"describe\")") "parked" with
| Some { Form.v = Form.Sym "t"; _ } -> true | Some { Form.v = Form.Sym "t"; _ } -> true

View File

@ -3575,6 +3575,120 @@ let () =
end) end)
Check.builtins; Check.builtins;
(* ── Memory diagnostics, --warn-memory ──────────────────────────
[Check.memory_sites] over a checked program: which lines allocate, on
which heap, and the half that is harder to keep true which lines do
not.
Pinned exactly, location and message both, and the location matters as
much as the wording: the whole feature is a squiggle under a character,
and a pass that found the right number of sites at the wrong columns
would draw them under the wrong forms. The file is fixed to [<test>] so
the prelude's own pushes, which are real and are not the caller's
business, stay out of the comparison. *)
let memory name src want =
let got =
List.map
(fun (d : Loc.diag) ->
(d.Loc.dloc.Loc.line, d.Loc.dloc.Loc.col, d.Loc.kind, d.Loc.dmsg))
(Check.memory_sites ~file:"<test>" (checked src))
in
let show (l, c, k, m) = Printf.sprintf "\n %d:%d %s %S" l c k m in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n wanted:%s\n got:%s\n" name
(String.concat "" (List.map show want))
(String.concat "" (List.map show got))
end
in
let gc = "memory/gc" and native = "memory/native" in
(* The collected heap. Every row here is a [gc_alloc] in flan_dyn.c on the
way through, and the negatives between them are the point: a typed
[vec-new] takes no block, and neither does an immediate. *)
memory "the collected heap, and what does not touch it"
"(defvar wide i64 999999999999999)\n\
(defvar small i32 7)\n\
(defn take [x] () (print x))\n\
(defn main [] ()\n\
\ (let [tv (vec-new i32)\n\
\ dv (vec-new dyn)\n\
\ m {:a 1}]\n\
\ (take \"hi\")\n\
\ (take 5)\n\
\ (take true)\n\
\ (take nil)\n\
\ (take :kw)\n\
\ (take 1.5)\n\
\ (take small)\n\
\ (take wide)\n\
\ (push tv 1)\n\
\ (push dv 2)))"
[ (6, 12, gc, "allocates: a dyn vector is an object on the collector's heap");
(7, 11, gc, "allocates: a dyn map is an object on the collector's heap");
(8, 11, gc,
"allocates: a string crossing into dyn is copied onto the collector's \
heap");
(* The only integer here that can leave the 48-bit payload. [small] is an
i32 widened to i64 at the crossing and provably cannot, [5] is a
literal inside the range, and neither is named. *)
(15, 11, gc,
"may allocate: an i64 outside ±2^47 does not fit a dyn's payload and \
spills onto the collector's heap");
(* The typed push, which is the native side; [(push dv 2)] on the line
below it is the dyn runtime's own vector growing itself and is not a
site the program can do anything about. *)
(16, 5, native,
"may allocate: a push past the Vec's capacity grows it through its \
allocator") ];
(* The allocator side, and the two shapes of [flan_vec_init]: [slurp] sizes
the Vec to the file and takes a block here, [(vec-new i32 a)] passes a
capacity of zero and takes none. Same runtime entry point, two answers,
which is why the classifier reads the capacity argument rather than the
symbol alone. *)
memory "an allocator the program named"
"(defvar gv (Vec i64) (vec-new i64))\n\
(defn take [x] () (print x))\n\
(defn arith [a b] () (take (+ a b)))\n\
(defn main [] ()\n\
\ (let [a (arena-new 4096)\n\
\ tm (map-new string i32 a)\n\
\ tv (vec-new i32 a)\n\
\ txt (slurp \"x\" a)]\n\
\ (take gv)\n\
\ (put tm \"k\" 1)\n\
\ (reserve tv 4)\n\
\ (arith 1 2)\n\
\ (print (len txt))))"
[ (5, 11, native,
"allocates: an arena takes its whole region from the host here");
(8, 13, native,
"allocates: the Vec is sized up front and takes its block from its \
allocator here");
(* A (Vec i64) crossing into dyn is a view, and the view record is a
heap object even though not one element is copied. *)
(9, 11, gc,
"allocates: a typed container crossing into dyn takes a view record on \
the collector's heap the elements are not copied, the record is");
(10, 5, native,
"may allocate: a put past the map's load factor grows its block through \
its allocator");
(11, 5, native,
"may allocate: a reserve past the Vec's capacity grows it through its \
allocator") ];
(* Two negatives on their own, because they are the ones a careless
classifier gets wrong and a test that only counted rows would not catch.
[(+ a b)] over two dyns ends in [flan_dyn_from_i64] and can spill but
nothing static knows the operands, and a squiggle under every dyn
addition is the false positive this pass exists not to have. *)
memory "dyn arithmetic stays immediate, and an empty program is silent"
"(defn take [x] () (print x))\n\
(defn add2 [a b] () (take (+ a b)))\n\
(defn main [] () (add2 1 2))"
[];
(* ── The acceptance program checks end to end ──────────────────── *) (* ── The acceptance program checks end to end ──────────────────── *)
accepts "calc-me.flan type checks" accepts "calc-me.flan type checks"
(In_channel.with_open_bin "../calc-me.flan" In_channel.input_all); (In_channel.with_open_bin "../calc-me.flan" In_channel.input_all);