A value that traps or faults while the inspector reads it shows as #<trapped: NAME> with no break pushed, a restart already accepted is taken before queued reads run, and a fault in the inspector's read names the read.

This commit is contained in:
Joseph Ferano 2026-09-25 21:27:24 +07:00
parent bd70dfa913
commit d77d541360
9 changed files with 239 additions and 64 deletions

View File

@ -2628,7 +2628,11 @@ they say a thunk renders it, `Inspect` now does.
**What the program is asked** is six agent verbs, all refused unless stopped: `slot F I`, `cond-at` and `global SYM` **What the program is asked** is six agent verbs, all refused unless stopped: `slot F I`, `cond-at` and `global SYM`
for where a root is; `peek ADDR LEN` for bytes; `ptr ADDR` for the registry's live/dead/unknown and the epitaph for where a root is; `peek ADDR LEN` for bytes; `ptr ADDR` for the registry's live/dead/unknown and the epitaph
(`flan_dev_reg_epitaph`, which `flan_dev_reg_emit` now writes through, so the sentence has one author); and `dyn WORD`, (`flan_dev_reg_epitaph`, which `flan_dev_reg_emit` now writes through, so the sentence has one author); and `dyn WORD`,
because a dyn value's tag is the runtime's to read and `flan_dyn_emit_to` renders it. They go through `Dev.request`, because a dyn value's tag is the runtime's to read. `dyn` queues a job the break loop runs on the program's thread, since the
dyn printer follows pointers: a trap or fault there is the reader's, so `break_loop_at` writes the value as
`#<trapped: NAME>` and unwinds through the evaluation escape without pushing a break, and the fault report says the
inspector's read touched the address. A restart already accepted is taken before the ring is drained, and a job
that needs the stop is refused after one is. They go through `Dev.request`,
so the two-process daemon answers them over the socket and the merged one by a call. `peek` reads through so the two-process daemon answers them over the socket and the merged one by a call. `peek` reads through
`process_vm_readv` on its own process, so an unmapped address is a refusal and not a fault that takes the program — `process_vm_readv` on its own process, so an unmapped address is a refusal and not a fault that takes the program —
and in one process, the daemon — down with it. and in one process, the daemon — down with it.

View File

@ -2475,9 +2475,9 @@ let agent_mem t : Inspect.mem =
| Ok l when starts l "dead" -> Inspect.Dead (after l "dead") | Ok l when starts l "dead" -> Inspect.Dead (after l "dead")
| _ -> Inspect.Unknown); | _ -> Inspect.Unknown);
(* Rendered by the program, on its own thread, as an evaluation is: the (* Rendered by the program, on its own thread, as an evaluation is: the
dyn printer follows pointers and can trap, and a trap there is a nested dyn printer follows pointers and can trap or fault, and there that is
break rather than a daemon that never answers again. See the agent's caught and the value reads [#<trapped: NAME>], rather than a daemon
[dyn] verb. *) that never answers again. See the agent's [dyn] verb. *)
dyn = dyn =
(fun w -> (fun w ->
let mark = job_mark ~at_stop:1 t in let mark = job_mark ~at_stop:1 t in

View File

@ -2545,6 +2545,11 @@ static void crash_hex(uintptr_t x) {
crash_puts(b + i, sizeof b - i); crash_puts(b + i, sizeof b - i);
} }
/* Non-NULL while the agent renders a value for the inspector, naming it. A
* fault then is the reader's, and the report says so instead of blaming the
* frame that happens to be on top — the program was stopped, not running. */
const char *volatile flan_dev_crash_reading;
static void crash_handler(int sig, siginfo_t *si, void *uc) { static void crash_handler(int sig, siginfo_t *si, void *uc) {
/* Not the program's thread: this is the daemon's own fault to deal with, /* Not the program's thread: this is the daemon's own fault to deal with,
* and OCaml's handler is the one that knows how. See the header. */ * and OCaml's handler is the one that knows how. See the header. */
@ -2555,12 +2560,19 @@ static void crash_handler(int sig, siginfo_t *si, void *uc) {
if (flan_crash_entered++) goto die; if (flan_crash_entered++) goto die;
crash_puts("\nflan: ", 7); crash_puts("\nflan: ", 7);
if (sig == SIGBUS) crash_puts("SIGBUS", 6); else crash_puts("SIGSEGV", 7); if (sig == SIGBUS) crash_puts("SIGBUS", 6); else crash_puts("SIGSEGV", 7);
{ if (flan_dev_crash_reading != NULL) {
static const char reading[] = " \xe2\x80\x94 reading ";
static const char tail[] = " for the inspector touched ";
crash_puts(reading, sizeof reading - 1);
crash_puts(flan_dev_crash_reading, strlen(flan_dev_crash_reading));
crash_puts(tail, sizeof tail - 1);
} else {
static const char touched[] = " \xe2\x80\x94 the program touched "; static const char touched[] = " \xe2\x80\x94 the program touched ";
crash_puts(touched, sizeof touched - 1); crash_puts(touched, sizeof touched - 1);
} }
crash_hex((uintptr_t)si->si_addr); crash_hex((uintptr_t)si->si_addr);
if (flan_frame_head != NULL && flan_frame_head->info != NULL) { if (flan_dev_crash_reading == NULL
&& flan_frame_head != NULL && flan_frame_head->info != NULL) {
const flan_fninfo *fi = flan_frame_head->info; const flan_fninfo *fi = flan_frame_head->info;
crash_puts(" in ", 4); crash_puts(" in ", 4);
crash_puts(fi->name, (size_t)fi->namelen); crash_puts(fi->name, (size_t)fi->namelen);

View File

@ -23,6 +23,8 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <pthread.h>
#include <time.h>
void flan_rt_init(int32_t argc, char **argv); void flan_rt_init(int32_t argc, char **argv);
int32_t flan_agent_start(const uint8_t *path, int64_t len); int32_t flan_agent_start(const uint8_t *path, int64_t len);
@ -190,6 +192,59 @@ static int stale(void) {
return 0; return 0;
} }
/* ── A restart accepted with a read queued behind it ────────────────
*
* The inspector's [dyn] read is a job for the stopped thread, named for the
* stop. One queued before a restart is accepted, and one asked for after,
* must neither run in the break being left: the first stays in the ring and
* is dropped at the next poll, the second is refused at the door. The break
* takes the restart without draining the ring first. */
extern uint64_t flan_dynword(void *xfer) __asm__("flan.dynword");
int32_t flan_agent_poll(void);
static int resuming_done;
static char r_queued[128], r_take[128], r_late[128];
static pthread_t resumer;
/* The listener's side of it, from another thread: the break loop is asleep
* between turns when these land, as a request from the editor would be. */
static void *resume_from_outside(void *arg) {
const char *line = arg;
struct timespec pause = { 0, 500000 };
nanosleep(&pause, NULL);
snprintf(r_queued, sizeof r_queued, "%s", ask(line));
snprintf(r_take, sizeof r_take, "%s", ask("restart-at 0 retry"));
snprintf(r_late, sizeof r_late, "%s", ask(line));
return NULL;
}
static void resuming_hook(void) {
static char line[64];
void *x = NULL;
if (resuming_done) return;
resuming_done = 1;
snprintf(line, sizeof line, "dyn %llu",
(unsigned long long)flan_dynword(&x));
pthread_create(&resumer, NULL, resume_from_outside, line);
}
static long refusal_count(void) { return strtol(ask("refusals"), NULL, 10); }
static int resuming(void) {
void *xfer = NULL;
int32_t v;
long before;
flan_agent_break_poll_hook = resuming_hook;
v = flan_deep(0, &xfer);
flan_agent_break_poll_hook = NULL;
pthread_join(resumer, NULL);
printf("queued %stake %slate %s", r_queued, r_take, r_late);
printf("returned %d\n", v);
before = refusal_count();
flan_agent_poll();
printf("dropped %ld\n", refusal_count() - before);
return 0;
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
flan_rt_init(argc, argv); flan_rt_init(argc, argv);
if (argc < 3) { if (argc < 3) {
@ -204,6 +259,7 @@ int main(int argc, char **argv) {
if (strcmp(argv[1], "snapmax") == 0) return snapmax(); if (strcmp(argv[1], "snapmax") == 0) return snapmax();
if (strcmp(argv[1], "snapnames") == 0) return snapnames(); if (strcmp(argv[1], "snapnames") == 0) return snapnames();
if (strcmp(argv[1], "stale") == 0) return stale(); if (strcmp(argv[1], "stale") == 0) return stale();
if (strcmp(argv[1], "resuming") == 0) return resuming();
fprintf(stderr, "unknown mode %s\n", argv[1]); fprintf(stderr, "unknown mode %s\n", argv[1]);
return 2; return 2;
} }

View File

@ -21,3 +21,7 @@
(restart-case (restart-case
(if (= n 0) (do (error (Deep {.n n})) 0) (wide (- n 1))) (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))) (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)))
;;; A dyn keyword, so the dyn runtime is linked and the agent's [dyn] verb has
;;; something to render. agent_hooks.c's "resuming" mode reads one.
(defn dynword [] dyn :k)

View File

@ -2,8 +2,8 @@
;;;; whose arena was freed, which traps DynRange when printed; the first ;;;; whose arena was freed, which traps DynRange when printed; the first
;;;; element of [s] is a word that reads as a boxed pointer to address 0x10, ;;;; element of [s] is a word that reads as a boxed pointer to address 0x10,
;;;; which faults when printed as a dyn. The inspector hands each to the ;;;; which faults when printed as a dyn. The inspector hands each to the
;;;; program's own thread to render, so both become a nested break and the ;;;; program's own thread to render, which catches the trap: each reads as
;;;; daemon keeps answering. ;;;; #<trapped: NAME>, no break is pushed, and the daemon keeps answering.
(import agent "vendor:agent") (import agent "vendor:agent")
(defstruct Boom [why i32]) (defstruct Boom [why i32])

View File

@ -956,6 +956,18 @@ let () =
if code <> 0 || out <> want then 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" 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; out code err want;
(* A restart accepted with an inspector read queued behind it, and a
second asked for after. Neither runs in the break being left: the
break takes the restart without draining the ring, the queued job is
dropped at the next poll, and the late one is refused at the door. *)
let code, out, err = hook_mode "resuming" in
let want =
"queued ok\ntake ok\nlate err the program is resuming: a restart was taken\n\
returned 0\ndropped 1\n"
in
if code <> 0 || out <> want then
fail "a restart accepted with a read queued behind it\n got: %S (exit %d, err %S)\n wanted: %S"
out code err want;
(try Sys.remove hexe with Sys_error _ -> ()); (try Sys.remove hexe with Sys_error _ -> ());
Test_support.report ~label:"agent" () Test_support.report ~label:"agent" ()

View File

@ -6759,20 +6759,28 @@ let () =
(* The reader hands a dyn word to the program's own thread to render, (* The reader hands a dyn word to the program's own thread to render,
because the dyn printer follows pointers: a view of a freed arena because the dyn printer follows pointers: a view of a freed arena
traps, and a scribbled word faults. Rendered on the agent's thread traps, and a scribbled word faults. Either is the reader's trouble and
either one hung the daemon for good or killed it. Here each must come not the program's, so it pushes no break: the value shows as the trap's
back as a refusal, and the daemon must go on answering. *) name, the rest of the section renders, and the stop on top is still
let dsock = tmp "dyntrap.sock" and dout = tmp "dyntrap.out" in the program's own after any number of refreshes. A fault says it was
the inspector's read that touched the address, not the stopped frame.
Both backends. *)
List.iter
(fun backend ->
let dsock = tmp ("dyntrap" ^ backend ^ ".sock")
and dout = tmp ("dyntrap" ^ backend ^ ".out") in
(try Sys.remove dsock with Sys_error _ -> ()); (try Sys.remove dsock with Sys_error _ -> ());
let dfd = Unix.openfile dout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600 in let dfd =
Unix.openfile dout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600
in
let dpid = let dpid =
Unix.create_process flan Unix.create_process flan
[| flan; "dev"; "programs/dev-dyn-trap.flan"; "-s"; dsock; "--llvm" |] [| flan; "dev"; "programs/dev-dyn-trap.flan"; "-s"; dsock; backend |]
Unix.stdin dfd Unix.stderr Unix.stdin dfd dfd
in in
Unix.close dfd; Unix.close dfd;
if not (listening ~pid:dpid dsock) then begin if not (listening ~pid:dpid dsock) then begin
fail "the dyn-trap daemon %s" !listen_why; fail "the %s dyn-trap daemon %s" backend !listen_why;
(try Unix.kill dpid Sys.sigkill with Unix.Unix_error _ -> ()) (try Unix.kill dpid Sys.sigkill with Unix.Unix_error _ -> ())
end end
else begin else begin
@ -6782,36 +6790,64 @@ let () =
| Some { Form.v = Form.Sym "t"; _ } -> true | Some { Form.v = Form.Sym "t"; _ } -> true
| _ -> false | _ -> false
in in
let answers what = let still_boom what =
let r = request c "(:op \"eval-expr\" :code \"n\")" in let r = request c "(:op \"describe\")" in
if Wire.string_field r "value" <> Some "5" then if Wire.string_field r "condition" <> Some "Boom" then
fail "after %s the daemon no longer answers: %s" what fail "%s: after %s the program is stopped on %s, not its own Boom"
(Option.value ~default:(status r) (Wire.string_field r "message")) backend what
(Option.value ~default:"nothing" (Wire.string_field r "condition"))
in in
if not (await (fun () -> stopped (request c "(:op \"describe\")"))) then if not (await (fun () -> stopped (request c "(:op \"describe\")"))) then
fail "the dyn-trap program never stopped" fail "the %s dyn-trap program never stopped" backend
else begin else begin
let r = request c "(:op \"inspect\" :frame 0 :slot 0 :path (0))" in let r = request c "(:op \"inspect\" :frame 0 :slot 0 :path (0))" in
(match Wire.field r "addr" with (match Wire.field r "addr" with
| Some { Form.v = Form.Int a; _ } -> | Some { Form.v = Form.Int a; _ } ->
let r = let r =
request c (Printf.sprintf "(:op \"at\" :addr %Ld :type \"dyn\")" a) request c
(Printf.sprintf "(:op \"at\" :addr %Ld :type \"dyn\")" a)
in in
if status r <> "error" then if Wire.string_field r "value" <> Some "<ptr #<trapped: SegFault>>"
fail "a dyn word pointing at 0x10 rendered: %s" then
(Option.value ~default:"" (Wire.string_field r "value")); fail "%s: a dyn word pointing at 0x10 read as %s" backend
answers "a dyn word that faults" (Option.value ~default:(status r) (Wire.string_field r "value"));
| _ -> fail "inspecting the slice element gave no :addr"); still_boom "a dyn word that faults"
| _ -> fail "%s: inspecting the slice element gave no :addr" backend);
for i = 1 to 10 do
let r = request c "(:op \"globals\")" in let r = request c "(:op \"globals\")" in
if status r <> "error" then let rows =
fail "a dyn view of a freed arena rendered in the globals section"; match Wire.field r "globals" with
answers "a dyn value that traps" | Some { Form.v = Form.List l; _ } ->
List.filter_map
(fun (e : Form.t) ->
match e.Form.v with
| Form.List ({ Form.v = Form.Str n; _ } :: _
:: { Form.v = Form.Str v; _ } :: _) ->
Some (n, v)
| _ -> None)
l
| _ -> []
in
if List.assoc_opt "dv" rows <> Some "#<trapped: DynRange>"
|| List.assoc_opt "n" rows <> Some "5"
then
fail "%s: globals refresh %d with a trapping dyn: %s" backend i
(String.concat ", " (List.map (fun (n, v) -> n ^ "=" ^ v) rows))
done;
still_boom "ten refreshes of a section holding a trapping dyn"
end; end;
ignore (request c "(:op \"close\")"); ignore (request c "(:op \"close\")");
(try Unix.close c with Unix.Unix_error _ -> ()); (try Unix.close c with Unix.Unix_error _ -> ());
(try ignore (Unix.waitpid [] dpid) with Unix.Unix_error _ -> ()) (try ignore (Unix.waitpid [] dpid) with Unix.Unix_error _ -> ());
let said = In_channel.with_open_bin dout In_channel.input_all in
if not (contains_sub said "reading a dyn value for the inspector touched")
|| contains_sub said "the program touched"
then
fail "%s: the fault in the inspector's read was reported as: %s"
backend said
end; end;
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ dsock; dout ]; List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ dsock; dout ])
[ "--llvm"; "--x86" ];
(* ── What a half-finished assignment looks like from the break ────── *) (* ── What a half-finished assignment looks like from the break ────── *)

View File

@ -465,6 +465,13 @@ static const uint8_t abandon_report[] =
* frames jumped over do not run. NULL when no evaluation is in progress; * frames jumped over do not run. NULL when no evaluation is in progress;
* saved and restored around the call like [eval_boundary]. */ * saved and restored around the call like [eval_boundary]. */
static sigjmp_buf *eval_escape; static sigjmp_buf *eval_escape;
/* Set while [dyn_job] renders a value for the inspector: a trap in there is
* the reader's, not the program's. See [break_loop_at]. Game thread only. */
static int inspector_reading;
/* flan_dev.c's: what the fault report names when the fault is a read the
* inspector asked for, rather than the program's own. */
extern const char *volatile flan_dev_crash_reading;
void flan_dev_emit(const uint8_t *bytes, int64_t len);
/* Whether the game thread is inside an evaluated thunk's call, at any depth, /* Whether the game thread is inside an evaluated thunk's call, at any depth,
* rather than in the program's own code. A break records it, and it is what * rather than in the program's own code. A break records it, and it is what
@ -1078,6 +1085,27 @@ void (*flan_agent_break_poll_hook)(void);
static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition, static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition,
void *xfer, int resumable) { void *xfer, int resumable) {
struct timespec step = { 0, 2000000 }; /* 2ms */ struct timespec step = { 0, 2000000 }; /* 2ms */
/* A trap or fault inside the inspector's own read of a value — the dyn
* printer following a pointer that is no longer good. It is not the
* program's error and no break is pushed for it: the value is written as
* the trap's name, the evaluation escape unwinds the job exactly as an
* abandon would, and the stop the reader was looking at stays the one on
* top. A break here would leave the program one level deeper on every
* refresh of a section that holds the value. */
if (inspector_reading && eval_escape != NULL) {
inspector_reading = 0;
flan_dev_crash_reading = NULL;
fprintf(stderr, "flan: reading a value for the inspector stopped on "
"%.*s; it is shown as #<trapped: %.*s>\n",
(int)namelen, (const char *)name, (int)namelen, (const char *)name);
fflush(stderr);
flan_dev_result_begin();
flan_dev_emit((const uint8_t *)"#<trapped: ", 11);
flan_dev_emit(name, namelen);
flan_dev_emit((const uint8_t *)">", 1);
flan_dev_result_end();
siglongjmp(*eval_escape, 1);
}
fflush(stdout); fflush(stdout);
fprintf(stderr, "\nflan: unhandled %.*s — stopped, not dead.\n", fprintf(stderr, "\nflan: unhandled %.*s — stopped, not dead.\n",
(int)namelen, (const char *)name); (int)namelen, (const char *)name);
@ -1164,9 +1192,16 @@ static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition,
fflush(stderr); fflush(stderr);
die_now(); die_now();
} }
/* The ring is not drained while a choice for this break is waiting. A job
* queued after a restart was accepted belongs to the stop being left; run
* first, it would run in that stop and could push a break of its own that
* takes the restart meant for this one. Left in the ring, it meets the gate
* in [flan_agent_poll] at the next boundary and is dropped. */
for (;;) { for (;;) {
if (!(atomic_load(&chosen_ready) && atomic_load(&chosen_gen) == my_gen)) {
flan_agent_poll(); flan_agent_poll();
if (flan_agent_break_poll_hook != NULL) flan_agent_break_poll_hook(); if (flan_agent_break_poll_hook != NULL) flan_agent_break_poll_hook();
}
if (atomic_load(&aborting)) { if (atomic_load(&aborting)) {
fflush(stdout); fflush(stdout);
fprintf(stderr, "flan: aborted at the break loop\n"); fprintf(stderr, "flan: aborted at the break loop\n");
@ -1529,7 +1564,11 @@ static pthread_mutex_t request_lock = PTHREAD_MUTEX_INITIALIZER;
static uint64_t dyn_word; static uint64_t dyn_word;
static void dyn_job(void) { static void dyn_job(void) {
flan_dev_result_begin(); flan_dev_result_begin();
inspector_reading = 1;
flan_dev_crash_reading = "a dyn value";
flan_dyn_emit_dev(dyn_word); flan_dyn_emit_dev(dyn_word);
inspector_reading = 0;
flan_dev_crash_reading = NULL;
flan_dev_result_end(); flan_dev_result_end();
} }
@ -2111,8 +2150,9 @@ static void handle_line(char *line, sink *o) {
* trap — a view of a Vec whose arena was freed — or fault on a scribbled * trap — a view of a Vec whose arena was freed — or fault on a scribbled
* word, and on this thread either one is a break loop that holds * word, and on this thread either one is a break loop that holds
* [request_lock] for ever, or the daemon's own death. On the game thread it * [request_lock] for ever, or the daemon's own death. On the game thread it
* runs as an evaluation does, inside [flan_agent_poll]'s escape, so it * runs as an evaluation does, inside [flan_agent_poll]'s escape, and a trap
* becomes a nested break the break buffer shows. */ * there is caught by [break_loop_at] and written as the value's text
* without pushing a break. */
if (strncmp(line, "dyn ", 4) == 0) { if (strncmp(line, "dyn ", 4) == 0) {
char *end = NULL; char *end = NULL;
unsigned long long w = strtoull(line + 4, &end, 0); unsigned long long w = strtoull(line + 4, &end, 0);
@ -2124,6 +2164,10 @@ static void handle_line(char *line, sink *o) {
return; return;
} }
if (s == NULL) { reply(o, "err no snapshot\n"); return; } if (s == NULL) { reply(o, "err no snapshot\n"); return; }
if (atomic_load(&chosen_ready)) {
reply(o, "err the program is resuming: a restart was taken\n");
return;
}
if (!queue_room()) { reply(o, "err the install queue is full\n"); return; } if (!queue_room()) { reply(o, "err the install queue is full\n"); return; }
dyn_word = (uint64_t)w; dyn_word = (uint64_t)w;
j.call = dyn_job; j.call = dyn_job;
@ -2355,6 +2399,13 @@ static void handle_line(char *line, sink *o) {
* one there is no point relocating, and refusing here means no handle is * one there is no point relocating, and refusing here means no handle is
* taken for it at all. Only one producer runs at a time, so room seen now is * taken for it at all. Only one producer runs at a time, so room seen now is
* room still there at [publish] below. */ * room still there at [publish] below. */
/* A job that needs the stop, arriving after a restart was accepted and
* before the break loop has taken it: the stop it was built for is being
* left, so it is refused here rather than run in a break that is ending. */
if ((stopped_only || at_stop != 0) && atomic_load(&chosen_ready)) {
reply(o, "err the program is resuming: a restart was taken\n");
return;
}
if (!queue_room()) { if (!queue_room()) {
reply(o, "err reload queue full; the program is not calling " reply(o, "err reload queue full; the program is not calling "
"agent/poll\n"); "agent/poll\n");