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
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
It renders to the program's own stdout and arrives on a later reply's output
instead. Where a dyn expression's value should surface is a question about the
editor protocol.
** DONE A dyn value from eval-expr never reaches the reply's value field
CLOSED: [2026-09-25]
The renderer's emitter has a dyn entry: =println= keeps =flan_dyn_print= to
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
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 ]))));
ei64 = (fun x -> write (conv Tast.I64ToBytes 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
let rc =
{ 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_push(i64, 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 double @flan_dyn_need_f64(i64)
declare i32 @flan_dyn_need_bool(i64)

View File

@ -31,6 +31,10 @@ type emitter = {
ei64 : Tast.expr -> Tast.expr;
eu64 : 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
@ -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
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
the walk hands the whole value over.
The cost is that it writes to stdout itself rather than through
[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 ])) ]
the walk hands the whole value to [c.emit.edyn], which names the runtime
entry point that renders into this emitter's sink. *)
| Types.Dyn -> [ c.emit.edyn e ]
(* 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
rather than only the refusal. *)

View File

@ -1140,7 +1140,13 @@ let dev_emitter : Render.emitter =
estr = call emit_str;
ei64 = call emit_i64;
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
render.ml's [pointers] for why the two sides differ. *)

View File

@ -48,6 +48,13 @@
* copies honest. */
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
* 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
* [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
* 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
* 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
static void emit(const char *s) {
flan_write_stdout((const uint8_t *)s, (int64_t)strlen(s));
/* Where the rendering goes. stdout for [print], or one of flan_dev.c'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
* 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
* changes, change this one. Streamed rather than built, so there is no buffer
* 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;
emit("\"");
emit(w, "\"");
for (i = 0; i < n; i++) {
unsigned char c = p[i];
switch (c) {
case '"': emit("\\\""); break;
case '\\': emit("\\\\"); break;
case '\n': emit("\\n"); break;
case '\t': emit("\\t"); break;
case '\r': emit("\\r"); break;
case '"': emit(w, "\\\""); break;
case '\\': emit(w, "\\\\"); break;
case '\n': emit(w, "\\n"); break;
case '\t': emit(w, "\\t"); break;
case '\r': emit(w, "\\r"); break;
default:
if (c < 0x20) {
char b[5];
snprintf(b, sizeof b, "\\x%02x", c);
emit(b);
emit(w, b);
} 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 */
@ -554,20 +568,20 @@ static int64_t view_elem_size(int32_t elem);
static int64_t vecish_len(flan_obj *o);
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];
int32_t t = flan_dyn_tag(v);
if (depth > PRINT_DEPTH) { emit("..."); return; }
if (depth > PRINT_DEPTH) { emit(w, "..."); return; }
switch (t) {
case FLAN_DYN_TAG_NIL:
emit("nil");
emit(w, "nil");
return;
case FLAN_DYN_TAG_BOOL:
emit(dyn_payload(v) ? "true" : "false");
emit(w, dyn_payload(v) ? "true" : "false");
return;
case FLAN_DYN_TAG_INT:
snprintf(buf, sizeof buf, "%lld", (long long)dyn_int_value(v));
emit(buf);
emit(w, buf);
return;
case FLAN_DYN_TAG_FLOAT: {
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. */
if (d != d) snprintf(buf, sizeof buf, "nan");
else snprintf(buf, sizeof buf, "%g", d);
emit(buf);
emit(w, buf);
return;
}
case FLAN_DYN_TAG_TEXT: {
flan_obj *o = dyn_obj(v);
if (nested) emit_escaped(obj_text_bytes(o), o->len);
else emit_n(obj_text_bytes(o), o->len);
if (nested) emit_escaped(w, obj_text_bytes(o), o->len);
else emit_n(w, obj_text_bytes(o), o->len);
return;
}
/* 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. */
case FLAN_DYN_TAG_KEYWORD: {
kw_entry *k = dyn_kw(v);
emit(":");
emit_n(kw_bytes(k), k->len);
emit(w, ":");
emit_n(w, kw_bytes(k), k->len);
return;
}
/* 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
* written here or it is not written at all. */
if (o->u.v.klass != NULL) {
emit("#");
emit_n(kw_bytes(o->u.v.klass), o->u.v.klass->len);
emit(w, "#");
emit_n(w, kw_bytes(o->u.v.klass), o->u.v.klass->len);
}
emit("{");
emit(w, "{");
for (i = 0; i < o->len; i++) {
emit(" ");
render(o->u.v.items[i * 2], depth + 1, 1);
emit(" ");
render(o->u.v.items[i * 2 + 1], depth + 1, 1);
emit(w, " ");
render(w, o->u.v.items[i * 2], depth + 1, 1);
emit(w, " ");
render(w, o->u.v.items[i * 2 + 1], depth + 1, 1);
}
emit("}");
emit(w, "}");
return;
}
default: {
flan_obj *o = dyn_obj(v);
int64_t i, n = o->kind == OBJ_VIEW ? view_len("print", o) : o->len;
emit("[");
emit(w, "[");
for (i = 0; i < n; i++) {
emit(" ");
emit(w, " ");
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)
+ i * view_elem_size(o->u.view.elem)),
depth + 1, 1);
else
render(o->u.v.items[i], depth + 1, 1);
render(w, o->u.v.items[i], depth + 1, 1);
}
emit("]");
emit(w, "]");
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
* 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
* traps: every tag has a rendering, including nil. */
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 ────────────────────────────────────────────────
*

View File

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