diff --git a/TODO.org b/TODO.org index ac3f065e..f9ea5c11 100644 --- a/TODO.org +++ b/TODO.org @@ -1344,15 +1344,28 @@ retries and returns the new value), every attempt (it gives up with nothing), an a write left open (it never copies). A hook rather than a second thread, so the interleaving is the same on every run; rules out a timing-based stress test here. -** TODO The snapshot generation's racing stale claim has no test -The nested-break case is tested deterministically now. What is still absent is the -race: landing a request inside a two-millisecond poll from outside the process. It -wants a hook the test can drive, not a sleep. +** DONE The snapshot generation's racing stale claim has no test +CLOSED: [2026-09-25] +=flan_agent_break_poll_hook= runs on the stopped thread where a thunk from the +poll would, and test/agent_hooks.c uses it to choose at an outer break and then +nest a break on top before the outer one looks. The inner break turns past the +choice and resumes only on its own. Rules out a sleep-timed socket test for this. -** TODO SNAP_MAX and SNAP_NAMES are read rather than tested -Three of the four named buffers have evidence. Only this pair is still read rather -than driven, and sixty-five nested =restart-case=s are a lot of program for a -clamp. +** TODO A choice made at an outer break is lost to a nested one +=chosen_index=, =chosen_gen= and =chosen_ready= are one slot. A choice validated +against an outer break and met by a nested one survives the nested break's +turns, but the nested break can only resume on a choice of its own, which +overwrites it — so the outer break stays stopped after the listener answered ok +for it. test/agent_hooks.c's =stale= mode pins this as it is. A slot per +snapshot is the likely fix. + +** DONE SNAP_MAX and SNAP_NAMES are read rather than tested +CLOSED: [2026-09-25] +test/agent_hooks.c drives both through programs/agent-hooks.flan, which recurses +with one restart per level: 71 restarts list 64, and 31 with 200-byte names list +20, each whole, with the terminal counting the rest and a take by index landing +in the frame it names. The slot kept back for =abandon-evaluation= under +truncation is still not driven: it needs a thunk in progress. ** CANCELLED Probing for an interior overrun under memcheck CLOSED: [2026-09-12] diff --git a/test/agent_hooks.c b/test/agent_hooks.c new file mode 100644 index 00000000..e8fb923d --- /dev/null +++ b/test/agent_hooks.c @@ -0,0 +1,204 @@ +/* agent_hooks.c — the break loop's snapshot and its choice handoff, driven + * from inside the stopped thread. + * + * Every other break-loop test talks to a program from outside it, over the + * socket, and so can only ever see the interleavings a 2ms poll happens to + * produce. Two properties need a specific one: + * + * - a choice validated against one break and then met by a *different* + * break, nested inside the first, before the first looks for it; + * - a restart list longer than the snapshot holds, by count and by bytes. + * + * [flan_agent_break_poll_hook] runs on the stopped thread once per turn of + * the loop, where a thunk from the poll would run. Requests go through + * [flan_agent_request], the in-process path, which is the same verb table the + * socket reaches. The program under the hook is test/programs/agent-hooks.flan, + * which has no [main]; this file is the entry point, as reload_host.c is. + * + * argv: the mode, and the socket path the agent binds (it binds one to + * install its hooks, and nothing here ever connects to it). + */ + +#include +#include +#include +#include + +void flan_rt_init(int32_t argc, char **argv); +int32_t flan_agent_start(const uint8_t *path, int64_t len); +char *flan_agent_request(const char *line, uint64_t *len); +void flan_agent_request_free(char *p); +extern void (*flan_agent_break_poll_hook)(void); +extern void (*flan_break_hook)(const uint8_t *name, int64_t namelen, + void *condition, void *xfer); +void *flan_restart_push_c(const uint8_t *name, int64_t namelen); +void flan_restart_pop_c(void *frame); + +/* The trailing ptr is the transfer channel every Flan signature carries. */ +extern int32_t flan_deep(int32_t n, void *xfer) __asm__("flan.deep"); +extern int32_t flan_wide(int32_t n, void *xfer) __asm__("flan.wide"); + +/* One request, its reply printed to [buf] and returned. */ +static char reply_buf[1 << 16]; + +static const char *ask(const char *line) { + uint64_t n = 0; + char *r = flan_agent_request(line, &n); + if (n >= sizeof reply_buf) n = sizeof reply_buf - 1; + memcpy(reply_buf, r ? r : "", (size_t)n); + reply_buf[n] = '\0'; + flan_agent_request_free(r); + return reply_buf; +} + +/* ── The snapshot's two caps ───────────────────────────────────────── + * + * SNAP_MAX is 64 entries and SNAP_NAMES is 4096 bytes of names. [deep 70] + * puts 71 restarts on the stack with one-letter names, so the count is what + * runs out; [wide 30] puts 31 with 200-byte names, so the bytes run out after + * twenty. What is checked is that the listing stops cleanly at the cap — + * every entry it does list is whole and takeable — and that the terminal + * says how many were left out. The take is the proof that the indices in + * the truncated listing still name the frames they claim to. */ +static int listed, longest, shortest, taken_once; +static const char *take_line; + +static void list_and_take(void) { + const char *r, *p; + if (taken_once) return; + taken_once = 1; + r = ask("restarts"); + listed = 0; + longest = 0; + shortest = 1 << 30; + for (p = r; *p != '\0' && *p != '.';) { + const char *nl = strchr(p, '\n'); + const char *name; + int len; + if (nl == NULL) break; + /* "I F NAME" */ + name = strchr(p, ' '); + name = name ? strchr(name + 1, ' ') : NULL; + len = name ? (int)(nl - name - 1) : -1; + if (len > longest) longest = len; + if (len < shortest) shortest = len; + listed++; + p = nl + 1; + } + printf("listed %d\n", listed); + printf("names %d..%d\n", shortest, longest); + printf("take %s", ask(take_line)); + fflush(stdout); +} + +static int snapmax(void) { + void *xfer = NULL; + int32_t v; + take_line = "restart-at 5 retry"; + flan_agent_break_poll_hook = list_and_take; + v = flan_deep(70, &xfer); + flan_agent_break_poll_hook = NULL; + printf("returned %d\n", v); + return 0; +} + +static int snapnames(void) { + void *xfer = NULL; + int32_t v; + take_line = "restart-at 19"; + flan_agent_break_poll_hook = list_and_take; + v = flan_wide(30, &xfer); + flan_agent_break_poll_hook = NULL; + printf("returned %d\n", v); + return 0; +} + +/* ── A choice that lands inside the poll, with a break nested under it ── + * + * The outer break offers outer-b (0) and outer-a (1). On its first turn the + * hook chooses outer-a — validated against the outer snapshot, stamped with + * its generation — and then, still inside that turn, a second break starts + * on top, which is what a thunk that errors does. The inner break's list + * begins with [inner] and has outer-a at index 2, so an index 1 read without + * its generation would resume the inner break into outer-b: the wrong break, + * and the wrong frame. + * + * What must happen instead: the inner break turns past the choice that is + * not addressed to it, for as many turns as it is left alone, and resumes + * only on a choice made against its own list. + * + * What happens after that is pinned as it is, not as it ought to be: the + * choice slot is one slot, so the inner choice overwrote the outer one, and + * the outer break has to be asked again. TODO.org, "A choice made at an outer + * break is lost to a nested one". */ +static int level, inner_turns, outer_turns, reasked; +static void *outer_a, *outer_b, *inner; + +static void stale_hook(void) { + if (level == 1) { + outer_turns++; + if (outer_turns == 1) { + void *xin = NULL; + printf("outer choice %s", ask("restart-at 1 outer-a")); + inner = flan_restart_push_c((const uint8_t *)"inner", 5); + level = 2; + flan_break_hook((const uint8_t *)"Inner", 5, NULL, &xin); + level = 1; + printf("inner turns %d\n", inner_turns); + printf("inner resumed into %s\n", + xin == inner ? "inner" + : xin == outer_a ? "outer-a" + : xin == outer_b ? "outer-b" + : "nothing"); + flan_restart_pop_c(inner); + fflush(stdout); + return; + } + /* The outer break is still here, so its choice did not survive. */ + reasked++; + ask("restart-at 1 outer-a"); + return; + } + inner_turns++; + if (inner_turns == 5) { + printf("status %s", ask("status")); + printf("inner choice %s", ask("restart-at 0 inner")); + fflush(stdout); + } +} + +static int stale(void) { + void *xout = NULL; + outer_a = flan_restart_push_c((const uint8_t *)"outer-a", 7); + outer_b = flan_restart_push_c((const uint8_t *)"outer-b", 7); + level = 1; + flan_agent_break_poll_hook = stale_hook; + flan_break_hook((const uint8_t *)"Outer", 5, NULL, &xout); + flan_agent_break_poll_hook = NULL; + printf("outer re-asked %d\n", reasked); + printf("outer resumed into %s\n", + xout == outer_a ? "outer-a" + : xout == outer_b ? "outer-b" + : "nothing"); + flan_restart_pop_c(outer_b); + flan_restart_pop_c(outer_a); + return 0; +} + +int main(int argc, char **argv) { + flan_rt_init(argc, argv); + if (argc < 3) { + fprintf(stderr, "usage: %s snapmax|snapnames|stale SOCKET\n", argv[0]); + return 2; + } + if (flan_agent_start((const uint8_t *)argv[2], (int64_t)strlen(argv[2])) != 0) { + fprintf(stderr, "the agent did not start on %s\n", argv[2]); + return 2; + } + setvbuf(stdout, NULL, _IOLBF, 0); + if (strcmp(argv[1], "snapmax") == 0) return snapmax(); + if (strcmp(argv[1], "snapnames") == 0) return snapnames(); + if (strcmp(argv[1], "stale") == 0) return stale(); + fprintf(stderr, "unknown mode %s\n", argv[1]); + return 2; +} diff --git a/test/dune b/test/dune index a252d01d..187be30f 100644 --- a/test/dune +++ b/test/dune @@ -63,6 +63,9 @@ ; The other C main: flan_dev.c's two fixed limits, which no Flan program ; reaches, driven directly. (file dev_limits.c) + ; The break loop driven from inside the stopped thread, through the agent's + ; poll hook. + (file agent_hooks.c) ; And the third: the dynamic-value runtime, which has no Flan spelling yet ; at all. Its host program is under programs/ and comes in with the ; corpus; the header dyn_ops.c includes is the compiler's, dropped into the diff --git a/test/programs/agent-hooks.flan b/test/programs/agent-hooks.flan new file mode 100644 index 00000000..827b025e --- /dev/null +++ b/test/programs/agent-hooks.flan @@ -0,0 +1,23 @@ +;;;; The Flan half of test/agent_hooks.c, which is the program's entry point: +;;;; this file has no [main]. It exists to put restart frames on the stack in +;;;; numbers no hand-written program would, and then stop. +;;;; +;;;; One restart per level of recursion, so the depth is the number of frames +;;;; a break loop is offered. Taking the restart at a level returns that +;;;; level's own number, which is how the harness sees which frame it landed +;;;; in. +(import agent "vendor:agent") + +(defstruct Deep [n i32]) + +(defn deep [n i32] i32 + (restart-case + (if (= n 0) (do (error (Deep {.n n})) 0) (deep (- n 1))) + (retry [] n))) + +;;; The same, with a name two hundred bytes long, so the snapshot's name +;;; buffer fills before its entry count does. +(defn wide [n i32] i32 + (restart-case + (if (= n 0) (do (error (Deep {.n n})) 0) (wide (- n 1))) + (retry-with-a-name-long-enough-that-twenty-of-them-fill-the-four-kilobytes-a-break-loop-keeps-for-the-names-of-its-restarts-and-the-twenty-first-does-not-fit-anywhere-in-the-buffer-at-all-xxx-and-so-on [] n))) diff --git a/test/test_agent.ml b/test/test_agent.ml index 4d04b60a..91a2489f 100644 --- a/test/test_agent.ml +++ b/test/test_agent.ml @@ -780,5 +780,77 @@ let () = List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ exe; so1; so2; sock; out; bsock; bout; bexe; qexe; qso; qsock; qout; noinstall; lexe; lsock; lout ]; + + (* ── The snapshot's caps and the choice handoff, from inside ──────── *) + + (* agent_hooks.c is the entry point and drives the break loop through + [flan_agent_break_poll_hook], on the stopped thread, so each case below + is one fixed interleaving rather than a race against the loop's 2ms + sleep. One process per mode: each ends with a restart stack the next + would inherit. *) + let ht, hl = Session.create ~file:"programs/agent-hooks.flan" () in + let hexe = tmp "hooks" in + ignore + (Build.executable ~opts:dev ~csrcs:("agent_hooks.c" :: hl.Load.csrcs) + ~lflags:hl.Load.lflags ht.Session.host ~out:hexe); + let hook_mode m = + let o = tmp ("hooks-" ^ m ^ ".out") and e = tmp ("hooks-" ^ m ^ ".err") + and s = tmp ("hooks-" ^ m ^ ".sock") in + let code = + Sys.command + (Printf.sprintf "%s %s %s > %s 2> %s" (Filename.quote hexe) m + (Filename.quote s) (Filename.quote o) (Filename.quote e)) + in + let out = In_channel.with_open_bin o In_channel.input_all in + let err = In_channel.with_open_bin e In_channel.input_all in + List.iter (fun p -> try Sys.remove p with Sys_error _ -> ()) [ o; e; s ]; + (code, out, err) + in + let contains s sub = + let n = String.length sub in + let rec go i = + i + n <= String.length s && (String.sub s i n = sub || go (i + 1)) + in + go 0 + in + (* SNAP_MAX. 71 restarts on the stack, 64 listed, every listed one whole, + and the terminal saying how many were left out. Taking index 5 lands in + the frame five levels up from the one that erred, which returns 5: the + indices of a truncated list still name the frames they say. *) + let code, out, err = hook_mode "snapmax" in + let want = "listed 64\nnames 5..5\ntake ok\nreturned 5\n" in + if code <> 0 || out <> want then + fail "a break with more restarts than the snapshot holds\n got: %S (exit %d, err %S)\n wanted: %S" + out code err want; + if not (contains err "... and 7 more, not listed") then + fail "the break did not say how many restarts it left out: %S" err; + (* SNAP_NAMES. 31 restarts whose names are 200 bytes each: twenty fit in + 4096 bytes with their terminators, the twenty-first does not, and no + name is cut short to squeeze it in. *) + let code, out, err = hook_mode "snapnames" in + let want = "listed 20\nnames 200..200\ntake ok\nreturned 19\n" in + if code <> 0 || out <> want then + fail "a break whose restart names outgrow the snapshot\n got: %S (exit %d, err %S)\n wanted: %S" + out code err want; + if not (contains err "... and 11 more, not listed") then + fail "the break did not say how many long-named restarts it left out: %S" + err; + (* A choice made against the outer break, then a break nested on top of it + before the outer one looks. The inner break turns past it five times + and resumes only on its own choice, into its own frame; index 1 read + without its generation would have sent it to outer-b. The last two lines + are the outer break needing to be asked again, because the choice slot + is one slot — TODO.org, "A choice made at an outer break is lost to a + nested one". *) + let code, out, err = hook_mode "stale" in + let want = + "outer choice ok\nstatus stopped Inner\ninner choice ok\ninner turns 5\n\ + inner resumed into inner\nouter re-asked 1\nouter resumed into outer-a\n" + in + if code <> 0 || out <> want then + fail "a choice addressed to an outer break, met by a nested one\n got: %S (exit %d, err %S)\n wanted: %S" + out code err want; + (try Sys.remove hexe with Sys_error _ -> ()); + Test_support.report ~label:"agent" () | _ -> print_endline "agent: skipped (no clang or llc on PATH)" diff --git a/vendor/agent/flan_agent.c b/vendor/agent/flan_agent.c index c6ad7e7b..2ca2aadb 100644 --- a/vendor/agent/flan_agent.c +++ b/vendor/agent/flan_agent.c @@ -813,6 +813,14 @@ static _Noreturn void die_now(void) { * all still there to be looked at, and only the resume is refused. That is * strictly more than the alternative, which was the whole process exiting * before anyone could ask a question. */ +/* Called on the stopped thread once per turn of the loop below, after the poll + * and before the loop looks for a choice — the same place a thunk the poll ran + * would be. It exists for test/agent_hooks.c and nothing else sets it: a + * request that lands inside a poll, or a break nested inside one, is a race + * against a two-millisecond sleep from outside the process, and from in here + * it is the same interleaving on every run. */ +void (*flan_agent_break_poll_hook)(void); + static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition, void *xfer, int resumable) { struct timespec step = { 0, 2000000 }; /* 2ms */ @@ -887,6 +895,7 @@ static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition, } for (;;) { flan_agent_poll(); + if (flan_agent_break_poll_hook != NULL) flan_agent_break_poll_hook(); if (atomic_load(&aborting)) { fflush(stdout); fprintf(stderr, "flan: aborted at the break loop\n");