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:
parent
c2f26a9909
commit
f0fc66940f
29
TODO.org
29
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]
|
||||
|
||||
204
test/agent_hooks.c
Normal file
204
test/agent_hooks.c
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
23
test/programs/agent-hooks.flan
Normal file
23
test/programs/agent-hooks.flan
Normal 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)))
|
||||
@ -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)"
|
||||
|
||||
9
vendor/agent/flan_agent.c
vendored
9
vendor/agent/flan_agent.c
vendored
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user