A dyn value an expression answers is the reply's value, not the program's output

This commit is contained in:
Joseph Ferano 2026-09-25 07:14:13 +07:00
parent cf028e23ea
commit aa5052e401
8 changed files with 104 additions and 70 deletions

View File

@ -1591,10 +1591,12 @@ The park used to drain the agent ring only when something had asked it to poll,
and a plain redefine does not, so every generation ran one re-run later than and a plain redefine does not, so every generation ran one re-run later than
whoever pressed the key expected. The drain happens in front of the exit now. whoever pressed the key expected. The drain happens in front of the exit now.
** TODO A dyn value from eval-expr never reaches the reply's value field ** DONE A dyn value from eval-expr never reaches the reply's value field
It renders to the program's own stdout and arrives on a later reply's output CLOSED: [2026-09-25]
instead. Where a dyn expression's value should surface is a question about the The renderer's emitter has a dyn entry: =println= keeps =flan_dyn_print= to
editor protocol. stdout, and the REPL's renders through =flan_dyn_emit_dev= into the value
buffer, so a dyn answer is the reply's =:value= on both backends. A dyn text is
quoted there as a typed string is. Rules out a dyn value arriving on =:output=.
** DONE Memory diagnostics on demand ** DONE Memory diagnostics on demand
CLOSED: [2026-09-20] CLOSED: [2026-09-20]

View File

@ -8618,7 +8618,8 @@ and named_call ?(qualified = false) ctx ~want loc name args =
(Tast.Prim (Tast.EscapeBytes, [ x ])))); (Tast.Prim (Tast.EscapeBytes, [ x ]))));
ei64 = (fun x -> write (conv Tast.I64ToBytes x)); ei64 = (fun x -> write (conv Tast.I64ToBytes x));
eu64 = (fun x -> write (conv Tast.U64ToBytes x)); eu64 = (fun x -> write (conv Tast.U64ToBytes x));
ef64 = (fun x -> write (conv Tast.F64ToBytes x)) } ef64 = (fun x -> write (conv Tast.F64ToBytes x));
edyn = (fun x -> mk loc Types.Unit (Tast.Prim (Tast.Rt "flan_dyn_print", [ x ]))) }
in in
let rc = let rc =
{ Render.structs = { Render.structs =

View File

@ -4025,6 +4025,8 @@ declare i64 @flan_dyn_at(i64, i64)
declare void @flan_dyn_set_at(i64, i64, i64) declare void @flan_dyn_set_at(i64, i64, i64)
declare void @flan_dyn_push(i64, i64) declare void @flan_dyn_push(i64, i64)
declare void @flan_dyn_print(i64) declare void @flan_dyn_print(i64)
declare void @flan_dyn_emit_dev(i64)
declare void @flan_dyn_emit_watch(i64)
declare i64 @flan_dyn_need_i64(i64) declare i64 @flan_dyn_need_i64(i64)
declare double @flan_dyn_need_f64(i64) declare double @flan_dyn_need_f64(i64)
declare i32 @flan_dyn_need_bool(i64) declare i32 @flan_dyn_need_bool(i64)

View File

@ -31,6 +31,10 @@ type emitter = {
ei64 : Tast.expr -> Tast.expr; ei64 : Tast.expr -> Tast.expr;
eu64 : Tast.expr -> Tast.expr; eu64 : Tast.expr -> Tast.expr;
ef64 : Tast.expr -> Tast.expr; ef64 : Tast.expr -> Tast.expr;
(* A dyn value, whole. The walk cannot take one apart — the tag is the
runtime's to read — so the runtime renders it, into the same place the
other four write to. *)
edyn : Tast.expr -> Tast.expr;
} }
(* What a walk is allowed to do with a pointer, and it is exactly two (* What a walk is allowed to do with a pointer, and it is exactly two
@ -380,15 +384,9 @@ let rec render c depth (e : Tast.expr) : Tast.expr list =
Flan value carries no header and only the compiler knows what it is; a Flan value carries no header and only the compiler knows what it is; a
dyn value is the exact opposite — the runtime knows and the compiler dyn value is the exact opposite — the runtime knows and the compiler
does not — so the printing belongs on the side that can see the tag, and does not — so the printing belongs on the side that can see the tag, and
the walk hands the whole value over. the walk hands the whole value to [c.emit.edyn], which names the runtime
entry point that renders into this emitter's sink. *)
The cost is that it writes to stdout itself rather than through | Types.Dyn -> [ c.emit.edyn e ]
[c.emit], so a dyn printed at the REPL arrives on the program's output
and not in the REPL's buffer. Fixing that means an emit-shaped dyn
printer in the runtime — a second entry point taking the sink — and it
is not milestone 1's. *)
| Types.Dyn ->
[ unit_ (Tast.Prim (Tast.Rt "flan_dyn_print", [ e ])) ]
(* Reachable: [(println m)] on a Map. Everything else in [Types.t] has an (* Reachable: [(println m)] on a Map. Everything else in [Types.t] has an
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. *)

View File

@ -1140,7 +1140,13 @@ let dev_emitter : Render.emitter =
estr = call emit_str; estr = call emit_str;
ei64 = call emit_i64; ei64 = call emit_i64;
eu64 = call emit_u64; eu64 = call emit_u64;
ef64 = call emit_f64 } ef64 = call emit_f64;
(* Into the value buffer, not stdout: a dyn expression's value belongs in
the reply's value like any other. *)
edyn =
(fun x ->
{ Tast.e = Tast.Prim (Tast.Rt "flan_dyn_emit_dev", [ x ]);
ty = Types.Unit; loc = x.Tast.loc }) }
(* And what the REPL may do with a pointer, which [println] may not. See (* And what the REPL may do with a pointer, which [println] may not. See
render.ml's [pointers] for why the two sides differ. *) render.ml's [pointers] for why the two sides differ. *)

View File

@ -48,6 +48,13 @@
* copies honest. */ * copies honest. */
void flan_write_stdout(const uint8_t *p, int64_t n); void flan_write_stdout(const uint8_t *p, int64_t n);
/* The two sinks in flan_dev.c a dyn value can be rendered into instead of
* stdout: an evaluated expression's value, and a watch slot. flan_dev.c is
* linked into every build, so these resolve whether or not the build is a dev
* one; the reference runs this way round so that flan_dev.c names nothing in
* this file and the dyn runtime stays droppable at the file level. */
void flan_dev_emit(const uint8_t *bytes, int64_t len);
void flan_dev_watch_emit(const uint8_t *bytes, int64_t len);
/* The one non-local exit a dyn operation can take. flan_rt.c's [rt_trap] is /* The one non-local exit a dyn operation can take. flan_rt.c's [rt_trap] is
* static, and re-implementing what it does — the break-loop hook, the flush, * static, and re-implementing what it does — the break-loop hook, the flush,
@ -462,7 +469,8 @@ static inline const char *tag_of(flan_dyn v) {
* One walk, two callers. [flan_dyn_print] writes to stdout through * One walk, two callers. [flan_dyn_print] writes to stdout through
* [flan_write_stdout], so a dyn print and a typed print interleave correctly * [flan_write_stdout], so a dyn print and a typed print interleave correctly
* in the one buffer; a trap message renders into a small buffer and puts the * in the one buffer; a trap message renders into a small buffer and puts the
* values in the sentence. * values in the sentence. [flan_dyn_emit_dev] and [flan_dyn_emit_watch] are
* the print walk aimed at flan_dev.c's buffers instead of stdout.
* *
* What it renders, per tag, is what typed [print] renders for the * What it renders, per tag, is what typed [print] renders for the
* corresponding type — captured from a running program rather than read off * corresponding type — captured from a running program rather than read off
@ -495,11 +503,17 @@ static inline const char *tag_of(flan_dyn v) {
#define PRINT_DEPTH 16 #define PRINT_DEPTH 16
static void emit(const char *s) { /* Where the rendering goes. stdout for [print], or one of flan_dev.c's
flan_write_stdout((const uint8_t *)s, (int64_t)strlen(s)); * buffers when the value is an evaluated expression's or a watched one: a
* value written to stdout arrives on the program's output rather than as the
* value the editor asked for. */
typedef void (*dyn_sink)(const uint8_t *p, int64_t n);
static void emit(dyn_sink w, const char *s) {
w((const uint8_t *)s, (int64_t)strlen(s));
} }
static void emit_n(const uint8_t *p, int64_t n) { flan_write_stdout(p, n); } static void emit_n(dyn_sink w, const uint8_t *p, int64_t n) { w(p, n); }
/* A text inside a structure, quoted and escaped. The same table as /* A text inside a structure, quoted and escaped. The same table as
* flan_rt.c's [flan_escape_char], which is where the typed side's printers — * flan_rt.c's [flan_escape_char], which is where the typed side's printers —
@ -514,28 +528,28 @@ static void emit_n(const uint8_t *p, int64_t n) { flan_write_stdout(p, n); }
* shares with the other side is the part that must not drift. If that table * shares with the other side is the part that must not drift. If that table
* changes, change this one. Streamed rather than built, so there is no buffer * changes, change this one. Streamed rather than built, so there is no buffer
* to overrun and no length to cap. */ * to overrun and no length to cap. */
static void emit_escaped(const uint8_t *p, int64_t n) { static void emit_escaped(dyn_sink w, const uint8_t *p, int64_t n) {
int64_t i; int64_t i;
emit("\""); emit(w, "\"");
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
unsigned char c = p[i]; unsigned char c = p[i];
switch (c) { switch (c) {
case '"': emit("\\\""); break; case '"': emit(w, "\\\""); break;
case '\\': emit("\\\\"); break; case '\\': emit(w, "\\\\"); break;
case '\n': emit("\\n"); break; case '\n': emit(w, "\\n"); break;
case '\t': emit("\\t"); break; case '\t': emit(w, "\\t"); break;
case '\r': emit("\\r"); break; case '\r': emit(w, "\\r"); break;
default: default:
if (c < 0x20) { if (c < 0x20) {
char b[5]; char b[5];
snprintf(b, sizeof b, "\\x%02x", c); snprintf(b, sizeof b, "\\x%02x", c);
emit(b); emit(w, b);
} else { } else {
emit_n(&c, 1); emit_n(w, &c, 1);
} }
} }
} }
emit("\""); emit(w, "\"");
} }
static int64_t dyn_int_value(flan_dyn v); /* forward: both int shapes */ static int64_t dyn_int_value(flan_dyn v); /* forward: both int shapes */
@ -554,20 +568,20 @@ static int64_t view_elem_size(int32_t elem);
static int64_t vecish_len(flan_obj *o); static int64_t vecish_len(flan_obj *o);
static flan_dyn vecish_at(flan_obj *o, int64_t i); static flan_dyn vecish_at(flan_obj *o, int64_t i);
static void render(flan_dyn v, int depth, int nested) { static void render(dyn_sink w, flan_dyn v, int depth, int nested) {
char buf[64]; char buf[64];
int32_t t = flan_dyn_tag(v); int32_t t = flan_dyn_tag(v);
if (depth > PRINT_DEPTH) { emit("..."); return; } if (depth > PRINT_DEPTH) { emit(w, "..."); return; }
switch (t) { switch (t) {
case FLAN_DYN_TAG_NIL: case FLAN_DYN_TAG_NIL:
emit("nil"); emit(w, "nil");
return; return;
case FLAN_DYN_TAG_BOOL: case FLAN_DYN_TAG_BOOL:
emit(dyn_payload(v) ? "true" : "false"); emit(w, dyn_payload(v) ? "true" : "false");
return; return;
case FLAN_DYN_TAG_INT: case FLAN_DYN_TAG_INT:
snprintf(buf, sizeof buf, "%lld", (long long)dyn_int_value(v)); snprintf(buf, sizeof buf, "%lld", (long long)dyn_int_value(v));
emit(buf); emit(w, buf);
return; return;
case FLAN_DYN_TAG_FLOAT: { case FLAN_DYN_TAG_FLOAT: {
double d; double d;
@ -576,21 +590,21 @@ static void render(flan_dyn v, int depth, int nested) {
* the comparison flan_rt.c and the prelude both use. */ * the comparison flan_rt.c and the prelude both use. */
if (d != d) snprintf(buf, sizeof buf, "nan"); if (d != d) snprintf(buf, sizeof buf, "nan");
else snprintf(buf, sizeof buf, "%g", d); else snprintf(buf, sizeof buf, "%g", d);
emit(buf); emit(w, buf);
return; return;
} }
case FLAN_DYN_TAG_TEXT: { case FLAN_DYN_TAG_TEXT: {
flan_obj *o = dyn_obj(v); flan_obj *o = dyn_obj(v);
if (nested) emit_escaped(obj_text_bytes(o), o->len); if (nested) emit_escaped(w, obj_text_bytes(o), o->len);
else emit_n(obj_text_bytes(o), o->len); else emit_n(w, obj_text_bytes(o), o->len);
return; return;
} }
/* A keyword prints with its colon, bare, at every depth: :a is its own /* A keyword prints with its colon, bare, at every depth: :a is its own
* spelling the way true is, and quoting it would make it a text. */ * spelling the way true is, and quoting it would make it a text. */
case FLAN_DYN_TAG_KEYWORD: { case FLAN_DYN_TAG_KEYWORD: {
kw_entry *k = dyn_kw(v); kw_entry *k = dyn_kw(v);
emit(":"); emit(w, ":");
emit_n(kw_bytes(k), k->len); emit_n(w, kw_bytes(k), k->len);
return; return;
} }
/* The map prints in edn's shape with the vec's spacing: a space before /* The map prints in edn's shape with the vec's spacing: a space before
@ -604,40 +618,48 @@ static void render(flan_dyn v, int depth, int nested) {
* for a record: #point{ :x 1 :y 2}. The tag is not an entry, so it is * for a record: #point{ :x 1 :y 2}. The tag is not an entry, so it is
* written here or it is not written at all. */ * written here or it is not written at all. */
if (o->u.v.klass != NULL) { if (o->u.v.klass != NULL) {
emit("#"); emit(w, "#");
emit_n(kw_bytes(o->u.v.klass), o->u.v.klass->len); emit_n(w, kw_bytes(o->u.v.klass), o->u.v.klass->len);
} }
emit("{"); emit(w, "{");
for (i = 0; i < o->len; i++) { for (i = 0; i < o->len; i++) {
emit(" "); emit(w, " ");
render(o->u.v.items[i * 2], depth + 1, 1); render(w, o->u.v.items[i * 2], depth + 1, 1);
emit(" "); emit(w, " ");
render(o->u.v.items[i * 2 + 1], depth + 1, 1); render(w, o->u.v.items[i * 2 + 1], depth + 1, 1);
} }
emit("}"); emit(w, "}");
return; return;
} }
default: { default: {
flan_obj *o = dyn_obj(v); flan_obj *o = dyn_obj(v);
int64_t i, n = o->kind == OBJ_VIEW ? view_len("print", o) : o->len; int64_t i, n = o->kind == OBJ_VIEW ? view_len("print", o) : o->len;
emit("["); emit(w, "[");
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
emit(" "); emit(w, " ");
if (o->kind == OBJ_VIEW) if (o->kind == OBJ_VIEW)
render(view_box(o->u.view.elem, render(w, view_box(o->u.view.elem,
(const uint8_t *)view_base(o) (const uint8_t *)view_base(o)
+ i * view_elem_size(o->u.view.elem)), + i * view_elem_size(o->u.view.elem)),
depth + 1, 1); depth + 1, 1);
else else
render(o->u.v.items[i], depth + 1, 1); render(w, o->u.v.items[i], depth + 1, 1);
} }
emit("]"); emit(w, "]");
return; return;
} }
} }
} }
void flan_dyn_print(flan_dyn v) { render(v, 0, 0); } void flan_dyn_print(flan_dyn v) { render(flan_write_stdout, v, 0, 0); }
/* The same rendering into an evaluated expression's value, and into the watch
* slot [flan_dev_watch_begin] opened. lib/render.ml's dyn arm calls these on
* the inspecting side and [flan_dyn_print] on [println]'s. A text is quoted
* even at the top, because the typed side's renderer quotes a string there:
* the value "5" and the value 5 must not read alike. */
void flan_dyn_emit_dev(flan_dyn v) { render(flan_dev_emit, v, 0, 1); }
void flan_dyn_emit_watch(flan_dyn v) { render(flan_dev_watch_emit, v, 0, 1); }
/* The same walk into a buffer, for a trap's sentence. Bounded and truncated /* The same walk into a buffer, for a trap's sentence. Bounded and truncated
* rather than allocating: a trap is the one moment when allocating would be a * rather than allocating: a trap is the one moment when allocating would be a

View File

@ -186,6 +186,10 @@ flan_dyn flan_dyn_map_contains(flan_dyn m, flan_dyn k);
/* Structural, and per type it renders what typed [print] renders. Never /* Structural, and per type it renders what typed [print] renders. Never
* traps: every tag has a rendering, including nil. */ * traps: every tag has a rendering, including nil. */
void flan_dyn_print(flan_dyn v); void flan_dyn_print(flan_dyn v);
/* The same rendering, into flan_dev.c's evaluated-value buffer and into the
* open watch slot rather than stdout. A text is quoted at the top as well. */
void flan_dyn_emit_dev(flan_dyn v);
void flan_dyn_emit_watch(flan_dyn v);
/* ── The typed boundary ──────────────────────────────────────────────── /* ── The typed boundary ────────────────────────────────────────────────
* *

View File

@ -5860,12 +5860,11 @@ let () =
| Some { Form.v = Form.Sym "t"; _ } -> true | Some { Form.v = Form.Sym "t"; _ } -> true
| _ -> false | _ -> false
in in
(* What the expression answered, wherever it came back: a dyn value (* A dyn value is rendered into the reply's [:value], as a typed
is rendered into the reply's output rather than into [:value], one is, on both backends, and a dyn text is quoted there as a
and which of the two carries it is not what is under test. *) typed string is. *)
let answer r = let answer r =
Option.value ~default:"" (Wire.string_field r "value") Option.value ~default:"" (Wire.string_field r "value")
^ Option.value ~default:"" (Wire.string_field r "output")
in in
let read () = let read () =
answer answer
@ -5884,7 +5883,7 @@ let () =
if not (await ~ms:20000 parked) then if not (await ~ms:20000 parked) then
fail "the dyn-global program (--%s) never parked" backend fail "the dyn-global program (--%s) never parked" backend
else begin else begin
if not (contains_sub (read ()) "kept") then if read () <> "\"kept\"" then
fail "--%s: the global was not readable before any thunk ran: %S" fail "--%s: the global was not readable before any thunk ran: %S"
backend (read ()); backend (read ());
for cycle = 1 to 3 do for cycle = 1 to 3 do
@ -6076,13 +6075,11 @@ let () =
if not (printed "counter 45") then if not (printed "counter 45") then
fail "the defonce beside the edited def lost its value: %S" fail "the defonce beside the edited def lost its value: %S"
(Buffer.contents output); (Buffer.contents output);
(* Read back rather than only printed, [counter]'s reason. A dyn (* Read back rather than only printed, [counter]'s reason. [c] is a
renders through the program's printer — its reply carries the text dyn, and its value arrives in [:value] like a typed one's. *)
in [:output] and an empty [:value] — so the cast is what turns the
answer into a value the reply can hold. *)
(let r = (let r =
request c request c
"(:op \"eval-expr\" :code \"(i64 c)\" :file \"programs/dev-rerun.flan\")" "(:op \"eval-expr\" :code \"c\" :file \"programs/dev-rerun.flan\")"
in in
match Wire.string_field r "value" with match Wire.string_field r "value" with
| Some "10" -> () | Some "10" -> ()
@ -6525,13 +6522,9 @@ let () =
"(:op \"eval-expr\" :code %S :file \"programs/dev-class.flan\")" "(:op \"eval-expr\" :code %S :file \"programs/dev-class.flan\")"
code) code)
in in
(* Every answer is compared inside the expression rather than read out (* Most answers are compared inside the expression, which keeps each
of it. A generic answers a dyn, and a dyn value is rendered to the row about dispatch rather than about rendering; the row after the
program's own stdout rather than into the reply's :value — it does first ask reads a dyn answer straight out of :value.
reach a later reply's :output, which is how the dyn-global rows
below read one, but the flush is the next reply's and not this one's.
Asking the running program whether the answer is 12 puts a typed
value in :value and takes the timing out of the test.
The first ask is retried: the agent's thread is let go only after the The first ask is retried: the agent's thread is let go only after the
socket is bound, so an early ask is a race with the startup and not a socket is bound, so an early ask is a race with the startup and not a
@ -6546,6 +6539,12 @@ let () =
else begin else begin
if !answered <> "1" then if !answered <> "1" then
fail "the method the program was built with answered %S" !answered; fail "the method the program was built with answered %S" !answered;
(* A generic answers a dyn, and its value is in this reply's :value
and not on the program's stdout. *)
(let r = ask "(area (point 3 4))" in
if value r <> "12" then
fail "a dyn answer did not reach :value (%S, output %S)" (value r)
(Option.value ~default:"" (Wire.string_field r "output")));
(* A class is on [defs] as a class, with its slots and where it is (* A class is on [defs] as a class, with its slots and where it is
written, and its constructor is not listed a second time as a fn. *) written, and its constructor is not listed a second time as a fn. *)
(let r = request c "(:op \"defs\")" in (let r = request c "(:op \"defs\")" in