The break loop's snapshot caps and its choice handoff are driven from inside the stopped thread by a poll hook

This commit is contained in:
Joseph Ferano 2026-09-25 10:15:35 +07:00
parent c2f26a9909
commit f0fc66940f
6 changed files with 332 additions and 8 deletions

View File

@ -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]

204
test/agent_hooks.c Normal file
View File

@ -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 <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}

View File

@ -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

View File

@ -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)))

View File

@ -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)"

View File

@ -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");