From aa5052e40104a1a09d6eb2fb7479788c4d871ca0 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:14:13 +0700 Subject: [PATCH] A dyn value an expression answers is the reply's value, not the program's output --- TODO.org | 10 +++-- lib/check.ml | 3 +- lib/emit.ml | 2 + lib/render.ml | 16 ++++---- lib/session.ml | 8 +++- runtime/flan_dyn.c | 98 ++++++++++++++++++++++++++++------------------ runtime/flan_dyn.h | 4 ++ test/test_dev.ml | 33 ++++++++-------- 8 files changed, 104 insertions(+), 70 deletions(-) diff --git a/TODO.org b/TODO.org index 9997215c..8eb0dd4f 100644 --- a/TODO.org +++ b/TODO.org @@ -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] diff --git a/lib/check.ml b/lib/check.ml index 1383ce91..14f13600 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 = diff --git a/lib/emit.ml b/lib/emit.ml index f6af7bbc..91d60654 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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) diff --git a/lib/render.ml b/lib/render.ml index 1e3d3348..03307359 100644 --- a/lib/render.ml +++ b/lib/render.ml @@ -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. *) diff --git a/lib/session.ml b/lib/session.ml index b54592e2..4a8df7f1 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -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. *) diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 101ce84c..9c67b115 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -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 diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index d200f6d4..eea4c87f 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -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 ──────────────────────────────────────────────── * diff --git a/test/test_dev.ml b/test/test_dev.ml index 6e4ca1a8..e0c36438 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -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