diff --git a/docs/REVIEW-production-readiness.md b/docs/REVIEW-production-readiness.md index e744b6d..5fb0e38 100644 --- a/docs/REVIEW-production-readiness.md +++ b/docs/REVIEW-production-readiness.md @@ -139,10 +139,23 @@ The containers, strings/UTF-8, sequences, random, and printing layers are decent - **`abort()` in the dev runtime** — `flan_dev.c:47-50`, `:103`: reload-name-table exhaustion, intern OOM, and "global changed size" kill the game instead of signalling. Against the grain of everything else in the runtime; route through `flan_error`. -- **Six trap paths bypass `flan_exit_hook`** and end a merged `flan dev` session - (`flan_restart_fail`, `flan_restart_args_fail`, `flan_restart_unarmed`, - `flan_transfer_fail`, `flan_null_alloc_fail`, `flan_free_all_fail` → `rt_die`). Bounds - and arithmetic already park via the break-loop hook; these should too. +- ~~**Six trap paths bypass `flan_exit_hook`** and end a merged `flan dev` session~~ — + fixed 2026-09-18. Two corrections to the entry as written. The hook bounds and + arithmetic park through is `flan_break_hook`, not `flan_exit_hook` — that second one is + normal termination, the one `main` reaches when it ends. And the six could not be routed + through it as it stands: `flan_break_hook`'s contract is that the loop may answer by + aiming a transfer channel, and these six are called by emitted code that falls off the + end with no channel in the call at all, so a restart chosen against one would be accepted + and silently dropped. So there is a second hook, `flan_trap_hook`, and a break that + refuses the resume with a reason rather than a process that exits before anyone can ask + a question. All six park, for two reasons rather than one. Four are guards that fire + *before* the operation they guard (`if (!a)` and the capability test both precede + `a->proc(...)`), so nothing is half done. The other two — `flan_transfer_fail` and + `flan_restart_unarmed` — fire mid-transfer, with this frame's defers possibly half run, + and park only to be *looked at*: stopping on a torn unwind is strictly more than exiting + before anyone can ask what tore it. Standalone is unchanged: the + hook is null in a program that did not import the agent, and the trap still exits 134 + with the same sentence. - **Unchecked `malloc` in `flan_argv`** (`flan_rt.c:162`) — the one in the file. - ~~**`v->gen` stale-slice word is maintained and never consulted**~~ — deleted 2026-09-18 with the second round of the repeal; the header is five words now. diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 4267dbb..d1a7e5d 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -527,6 +527,52 @@ _Noreturn void flan_bounds_fail(const uint8_t *loc, int64_t loclen, void (*flan_break_hook)(const uint8_t *name, int64_t namelen, void *condition, void *xfer); +/* The other half of that, for the traps that have nowhere to resume *to*. + * + * Six refusals in this file are reached with no transfer channel in their + * hands: the emitted code calls them and falls off the end, because the + * checker has already decided the program is not going to continue. Under a + * merged `flan dev' that decision was being made on the program's behalf and + * charged to the *session*, since rt_die is [_exit] and the compiler is in the + * same process — a program that named a restart nobody established took the + * daemon down with it, which is exactly what the break loop exists to stop. + * + * So they park instead. Not with [flan_break_hook]: that hook's contract is + * that the loop may answer by aiming the channel, and there is no channel + * here — a restart chosen against one of these would be a transfer nothing + * carries out, which is the "accepted and silently dropped" shape the + * thunk-boundary refusal in flan_agent.c already went to some length to avoid. + * This hook says the other thing: stop here, let everything be read, and + * refuse a resume with a reason when one is asked for. The loop behind it + * never returns; if some future one does, the [rt_die] below is still the + * answer, which is also what a standalone build does every time, because + * nothing installs this outside a dev session. + * + * All six park, and they do not all park for the same reason. Four are guards + * that fire *before* the thing they guard — the null and capability tests + * above [a->proc], and the two restart lookups — so nothing is half done and + * the frame is as readable as any other. The other two are not: a transfer is + * already under way at [flan_transfer_fail] and at [flan_restart_unarmed], + * and this frame's defers may be half run, which is what the note on + * flan_transfer_fail says a few lines down. Those two park anyway, and only + * to be *looked at*: a half-run unwind is a thing worth seeing, and the + * resume is refused there for the same reason it is refused at the other + * four. Stopping on a torn transfer is strictly more than exiting before + * anyone could ask what tore it. + * + * The name is a trap's name and not a condition's — there is no defstruct + * behind [NullAllocator], and the `layout' op will say it cannot place it. + * That is already a case the conditions buffer draws (see flan-cnr.el, which + * treats a refusal as a reason to show rather than an error): the name is + * there to say *which* trap the program is standing in, and the sentence each + * site printed just above carries the detail. */ +void (*flan_trap_hook)(const uint8_t *name, int64_t namelen); + +static _Noreturn void rt_trap(const uint8_t *name, int64_t namelen) { + if (flan_trap_hook != NULL) flan_trap_hook(name, namelen); + rt_die(); +} + /* Must agree with Check.type_id, byte for byte, or a name typed at the break * loop matches nothing. FNV-1a over the name, 32 bits. */ static uint32_t flan_name_id(const uint8_t *s, int64_t n) { @@ -566,13 +612,15 @@ void flan_error(uint32_t type_id, void *condition, void *xfer, /* Nothing on the restart stack offers the name. It is reported where the * invoke was, because that is the only place that knows what was asked for; - * there is nowhere to resume, so there is nothing else to do. */ + * there is nowhere to resume *to*, which is not the same as there being + * nothing else to do — see rt_trap, which stops here instead of ending the + * process, so that the stack that offered no such name can be read. */ _Noreturn void flan_restart_fail(const uint8_t *loc, int64_t loclen, const uint8_t *name, int64_t namelen) { rt_flush_out(); fprintf(stderr, "%.*s: no restart named %.*s is active\n", (int)loclen, (const char *)loc, (int)namelen, (const char *)name); - rt_die(); + rt_trap((const uint8_t *)"NoSuchRestart", 13); } /* The frame the name found does not take these arguments — spec-conditions.md @@ -588,7 +636,7 @@ _Noreturn void flan_restart_args_fail(const uint8_t *loc, int64_t loclen, fprintf(stderr, "%.*s: restart %.*s takes %.*s, given %.*s\n", (int)loclen, (const char *)loc, (int)namelen, (const char *)name, (int)wantlen, (const char *)want, (int)gotlen, (const char *)got); - rt_die(); + rt_trap((const uint8_t *)"RestartArity", 12); } /* A clause with parameters was reached by a transfer that filled none of them @@ -606,7 +654,7 @@ _Noreturn void flan_restart_unarmed(const uint8_t *loc, int64_t loclen, "break loop yet\n", (int)loclen, (const char *)loc, (int)namelen, (const char *)name, (int)wantlen, (const char *)want); - rt_die(); + rt_trap((const uint8_t *)"RestartUnarmed", 14); } /* Something a defer called invoked a restart. A defer is the cleanup a @@ -620,7 +668,7 @@ _Noreturn void flan_transfer_fail(const uint8_t *loc, int64_t loclen) { "%.*s: a defer invoked a restart, which a defer may not do — it is " "the cleanup a transfer runs on its way out\n", (int)loclen, (const char *)loc); - rt_die(); + rt_trap((const uint8_t *)"TransferFromDefer", 17); } _Noreturn void flan_slice_fail(const uint8_t *loc, int64_t loclen, @@ -1311,7 +1359,7 @@ _Noreturn void flan_null_alloc_fail(const uint8_t *loc, int64_t loclen) { "%.*s: this allocator is null — a zeroed Allocator was never given " "one\n", (int)loclen, (const char *)loc); - rt_die(); + rt_trap((const uint8_t *)"NullAllocator", 13); } _Noreturn void flan_free_all_fail(const uint8_t *loc, int64_t loclen) { @@ -1321,7 +1369,7 @@ _Noreturn void flan_free_all_fail(const uint8_t *loc, int64_t loclen) { "release, and releasing nothing is not the same as releasing " "everything\n", (int)loclen, (const char *)loc); - rt_die(); + rt_trap((const uint8_t *)"NoFreeAll", 9); } /* ── The region requirement, spec-memory.md's arena rule ─────────────── diff --git a/test/programs/dev-trap-free-all.flan b/test/programs/dev-trap-free-all.flan new file mode 100644 index 0000000..e83f133 --- /dev/null +++ b/test/programs/dev-trap-free-all.flan @@ -0,0 +1,34 @@ +;;;; A program that stops on a trap it cannot be resumed from, for driving the +;;;; break loop over one. +;;;; +;;;; dev-break-bounds.flan is the case where stopping and *resuming* both work: +;;;; a bad index signals BoundsError, the walk finds nothing, and the break +;;;; loop hands the program back to its own `continue`. This is the other half. +;;;; `free-all` on an allocator that has no region to release is a refusal the +;;;; emitted code makes no channel for — it calls the trap and falls off the +;;;; end — so there is nothing for a chosen restart to transfer into, and until +;;;; now that meant `_exit(134)`, which under a merged `flan dev` is the +;;;; session and the compiler as well as the program. +;;;; +;;;; The claim is that the session survives it anyway. The program stops where +;;;; it erred, everything is readable, and the *resume* is the only thing +;;;; refused — with a sentence saying why, rather than by the process being +;;;; gone before anyone could ask. +;;;; +;;;; The `restart-case` is load-bearing and not scenery: with no restart on the +;;;; stack the break loop would say "no restarts are active" and the refusal +;;;; that had to be written would never run. `continue` is live, is listed, and +;;;; is still not takeable — which is exactly the state this trap leaves a +;;;; program in. +(import agent "vendor:agent") + +(defn release [] () + ;; The heap allocator frees one block and owns no region, so this traps. + (free-all (heap-allocator))) + +(defn main [] i32 + (agent/start "/tmp/flan-dev-trap-free-all-fallback.sock") + (restart-case + (release) + (continue [] (println "resumed"))) + 0) diff --git a/test/programs/dev-trap-null-alloc.flan b/test/programs/dev-trap-null-alloc.flan new file mode 100644 index 0000000..05d5231 --- /dev/null +++ b/test/programs/dev-trap-null-alloc.flan @@ -0,0 +1,18 @@ +;;;; The same claim as dev-trap-free-all.flan over the other allocator trap, +;;;; and with nothing on the restart stack. +;;;; +;;;; `nowhere` is a zeroed `Allocator` — a `defvar` nobody assigned — and +;;;; `free-all` on it is the "I released the region" / "I never made one" +;;;; collapse the runtime refuses to let a program spell the same way. The +;;;; refusal is a trap with no transfer channel, so it parks rather than +;;;; resumes, and here there is not even a restart to list: the break loop says +;;;; so and stands still, which is the whole difference between a session you +;;;; can fix the program in and a daemon that is gone. +(import agent "vendor:agent") + +(defvar nowhere Allocator) + +(defn main [] i32 + (agent/start "/tmp/flan-dev-trap-null-alloc-fallback.sock") + (free-all nowhere) + 0) diff --git a/test/test_dev.ml b/test/test_dev.ml index 8f82476..622f313 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -1120,6 +1120,155 @@ let () = end end; + (* ── A break over a trap with no way back ─────────────── *) + + (* The block above stops on a bad index and *resumes*: the signal walks the + handlers, reaches the break loop, and the program's own [continue] + carries it out. Six refusals in the runtime cannot be reached that way. + [flan_restart_fail], [flan_restart_args_fail], [flan_restart_unarmed], + [flan_transfer_fail], [flan_null_alloc_fail] and [flan_free_all_fail] + are called by emitted code that then falls off the end, with no transfer + channel anywhere in the call — so there is nothing for a chosen restart + to land in, and every one of them called [_exit(134)]. In a merged + [flan dev] that is one process, and the session went with the program. + + Two of the six are driven here, and the pair is chosen so that the + refusal has something to refuse: [dev-trap-free-all.flan] traps inside a + live [restart-case], so [continue] is on the stack, is listed, and is + still not takeable; [dev-trap-null-alloc.flan] traps with an empty + restart stack, which is the other shape the break loop has to print. + + What is asserted is the difference the change is about. The program is + *stopped* and not gone — [describe] answers, and answers [:stopped] — an + expression still evaluates while it stands there, which is the proof the + daemon is alive and serving, and a resume is refused *with a reason* + rather than by the process having exited before anyone could ask. The + standalone half of the same claim is test_acceptance.ml's + free-all-refused, which still exits 134: nothing installs the hook in a + program that did not import the agent. *) + let trap_park what prog cond restarts = + let tsock = tmp (prog ^ ".sock") and tout = tmp (prog ^ ".out") in + (try Sys.remove tsock with Sys_error _ -> ()); + let tfd = + Unix.openfile tout [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600 + in + let tpid = + Unix.create_process flan + [| flan; "dev"; "programs/" ^ prog; "-s"; tsock |] + Unix.stdin tfd Unix.stderr + in + Unix.close tfd; + if not (listening ~pid:tpid tsock) then begin + fail "the %s trap daemon %s" what !listen_why; + (try Unix.kill tpid Sys.sigkill with Unix.Unix_error _ -> ()) + end + else begin + let c = connect tsock in + let ask sexp = Wire.parse (Wire.send c sexp; Wire.recv c) in + let stopped r = + match Wire.field r "stopped" with + | Some { Form.v = Form.Sym "t"; _ } -> true + | _ -> false + in + let last = ref (ask "(:op \"describe\")") in + if not + (await (fun () -> last := ask "(:op \"describe\")"; stopped !last)) + then fail "the %s trap never stopped the program, it ended it" what + else begin + (* Which trap, by name. There is no [defstruct] behind these — they + are traps and not conditions, and [layout] will say it cannot + place the name, which flan-cnr.el already draws as a reason. The + name is here to say where the program is standing. *) + let got = + match Wire.string_field !last "condition" with + | Some c -> c + | None -> "" + in + if got <> cond then + fail "the %s trap is reported as %S, wanted %S" what got cond; + (* What is on offer, which at a trap is a list nobody can take. It + is still *listed*: "why can I not have that one" is a fair + question, and an empty list would make a live restart-case look + like it had been unwound. *) + let r = ask "(:op \"break\")" in + if status r <> "ok" then fail "break at the %s trap: %s" what (status r); + (match Wire.field r "restarts" with + | Some { Form.v = Form.List l; _ } -> + let names = + List.filter_map + (fun (n : Form.t) -> + match n.Form.v with Form.Str x -> Some x | _ -> None) + l + in + if names <> restarts then + fail "restarts at the %s trap: %s" what + (String.concat ", " names) + | _ -> fail "break at the %s trap listed no restarts field" what); + (* And every one of them named as untakeable. This is the half the + editor reads: the terminal listing says so in words, and a + [:unreachable] that disagreed with it would be the two ends + describing different programs — a restart shown as choosable, + chosen, and then refused. *) + (match Wire.field r "unreachable" with + | Some { Form.v = Form.List l; _ } -> + let idx = + List.filter_map + (fun (n : Form.t) -> + match n.Form.v with Form.Int i -> Some (Int64.to_int i) | _ -> None) + l + in + if idx <> List.mapi (fun i _ -> i) restarts then + fail "unreachable restarts at the %s trap: %s" what + (String.concat ", " (List.map string_of_int idx)) + | _ -> + if restarts <> [] then + fail "break at the %s trap named no unreachable restarts" what); + (* And the refusal, where there is a name to refuse. Answered [err] + with the reason rather than [ok] and then dropped, which is the + shape that would tell an editor the program had resumed when it + had not. *) + List.iter + (fun name -> + let r = + ask (Printf.sprintf "(:op \"restart\" :name %s)" + (Wire.quote name)) + in + if status r = "ok" then + fail "the %s trap accepted a restart it cannot take" what) + restarts; + (* The session, still a session. This is the whole point: the daemon + is answering, the compiler is in this process, and the program is + standing still in front of it. *) + let r = + ask "(:op \"eval-expr\" :code \"(+ 2 2)\" :file \"/tmp/buf.flan\")" + in + if Wire.string_field r "value" <> Some "4" then + fail "an expression while parked at the %s trap: %s" what + (Option.value ~default:(status r) (Wire.string_field r "message")); + (* Torn down by [abort], not by [close]: a trap parks for good, so + there is no resume to wait for and nothing to gain by waiting. *) + let r = ask "(:op \"abort\")" in + if status r <> "ok" then + fail "abort at the %s trap: %s" what + (Option.value ~default:"" (Wire.string_field r "message")) + end; + Unix.close c; + if not + (await ~ms:5000 (fun () -> + match Unix.waitpid [ Unix.WNOHANG ] tpid with + | 0, _ -> false + | _ -> true + | exception Unix.Unix_error _ -> true)) + then begin + fail "the daemon outlived the %s trap it aborted" what; + (try Unix.kill tpid Sys.sigkill with Unix.Unix_error _ -> ()); + (try ignore (Unix.waitpid [] tpid) with Unix.Unix_error _ -> ()) + end + end + in + trap_park "free-all" "dev-trap-free-all.flan" "NoFreeAll" [ "continue" ]; + trap_park "null allocator" "dev-trap-null-alloc.flan" "NullAllocator" []; + (* ── The locals of a stopped frame ─────────────────────────────── *) (* A third daemon, over a program that stops with something worth looking diff --git a/vendor/agent/flan_agent.c b/vendor/agent/flan_agent.c index f9e0584..8006673 100644 --- a/vendor/agent/flan_agent.c +++ b/vendor/agent/flan_agent.c @@ -255,6 +255,10 @@ static int publish(job j) { * runtime must not depend on an optional package for either. */ extern void (*flan_break_hook)(const uint8_t *name, int64_t namelen, void *condition, void *xfer); +/* The other hook, for the traps that call with no channel — see rt_trap in + * flan_rt.c, which carries the argument for why they are two hooks and not + * one. This end of it is [trap_stop] below. */ +extern void (*flan_trap_hook)(const uint8_t *name, int64_t namelen); extern int32_t flan_break_resume(const uint8_t *name, int64_t namelen, void *xfer); extern int32_t flan_restart_count(void); @@ -354,6 +358,15 @@ static _Atomic int aborting; typedef struct { int32_t gen; /* never reused, never 0 */ + /* Whether *any* restart on this list can be taken, which is a property of + * the break and not of the restarts. [reachable] answers a different + * question — that one is per restart, and it is about the thunk boundary. + * A break taken by a trap with no transfer channel has a perfectly good + * list of live restarts below it and no way to aim at one, so the whole + * snapshot is marked instead of each entry: the refusal sentence differs, + * and a reader of this struct should not have to infer which case a run of + * zeroes in [reachable] meant. */ + int32_t resumable; int32_t n; int32_t total; /* before SNAP_MAX truncated it */ void *frame[SNAP_MAX]; @@ -425,12 +438,13 @@ static snapshot *snap_top(void) { * to nest, which the caller reports rather than serving a stale one. */ static int32_t snap_gen; /* monotone; 0 is "no snapshot" */ -static int snap_push(void) { +static int snap_push(int resumable) { int d = atomic_load(&snap_depth); if (d >= BREAK_MAX) return 0; snapshot *s = &snaps[d]; int32_t n = flan_restart_count(); s->gen = ++snap_gen; + s->resumable = resumable; s->total = n; s->used = 0; s->n = 0; @@ -536,8 +550,16 @@ static _Noreturn void die_now(void) { _exit(134); } -static void break_loop(const uint8_t *name, int64_t namelen, void *condition, - void *xfer) { +/* The loop itself, with one bit of what the caller knows: whether a restart + * taken here has anywhere to land. [break_loop] passes 1 — it holds the + * signaller's channel, and writing a frame into it is how §2 resumes. + * [trap_stop] passes 0, and then this is a place to *stand and read* and + * nothing more: the stack, the locals, the globals and the restart list are + * 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. */ +static void break_loop_at(const uint8_t *name, int64_t namelen, void *condition, + void *xfer, int resumable) { struct timespec step = { 0, 2000000 }; /* 2ms */ (void)condition; fflush(stdout); @@ -548,7 +570,7 @@ static void break_loop(const uint8_t *name, int64_t namelen, void *condition, * are then the same list, numbered the same way, and the numbers are what a * choice is made of. */ int32_t my_gen; - if (!snap_push()) { + if (!snap_push(resumable)) { fflush(stdout); fprintf(stderr, "flan: %d nested break loops - giving up rather than " "spinning\n", BREAK_MAX); @@ -558,7 +580,18 @@ static void break_loop(const uint8_t *name, int64_t namelen, void *condition, { snapshot *s = snap_top(); my_gen = s->gen; - if (s->n == 0) + /* Said once, above the list, rather than appended to every line: it is + * one fact about the break, and repeating it per restart would read as + * each restart having its own separate problem. The list is still + * printed, because what is on offer is part of where the program is even + * when none of it can be taken — and because the same names come back + * from a `restarts' query, and the terminal and the socket must not be + * describing two different programs. */ + if (!s->resumable) + fprintf(stderr, + " this trap has no transfer channel, so nothing here can be " + "resumed into; read the frame, then fix and reload, or abort\n"); + else if (s->n == 0) fprintf(stderr, " no restarts are active; abort, or fix and reload\n"); for (int32_t i = 0; i < s->n; i++) /* Numbered, because that is how one is taken now, and marked when it is @@ -566,7 +599,9 @@ static void break_loop(const uint8_t *name, int64_t namelen, void *condition, * than hidden, since "why can I not have that one" is a fair question * and silence is how this went wrong the first time. */ fprintf(stderr, " %2d. restart: %s%s\n", i, s->names + s->off[i], - s->reachable[i] ? "" : " (below this break; cannot be taken)"); + !s->resumable ? " (cannot be taken from this trap)" + : s->reachable[i] ? "" + : " (below this break; cannot be taken)"); if (s->total > s->n) fprintf(stderr, " ... and %d more, not listed\n", s->total - s->n); } @@ -614,7 +649,7 @@ static void break_loop(const uint8_t *name, int64_t namelen, void *condition, atomic_store(&chosen_ready, 0); snapshot *s = snap_top(); int ok = s != NULL && s->gen == my_gen && take >= 0 && take < s->n - && s->reachable[take]; + && s->resumable && s->reachable[take]; if (ok) { flan_restart_take(s->frame[take], xfer); fprintf(stderr, "flan: resuming at restart %d. %s\n", take, @@ -644,6 +679,27 @@ static void break_loop(const uint8_t *name, int64_t namelen, void *condition, } } +/* §2's break, which can resume, and the trap's, which cannot. Two names over + * one loop because the two hooks have different signatures and different + * promises, and a caller of either should not have to pass a flag it does not + * understand. + * + * [trap_stop] is not marked _Noreturn even though it never returns: the loop + * above leaves only by resuming, which needs [resumable], or by [die_now]. + * Saying so in the type would oblige this function to prove it, and the honest + * proof is the [die_now] below — reached only if the loop is ever given a way + * out that this call did not ask for, and dying there is what the program did + * before any of this existed. */ +static void break_loop(const uint8_t *name, int64_t namelen, void *condition, + void *xfer) { + break_loop_at(name, namelen, condition, xfer, 1); +} + +static void trap_stop(const uint8_t *name, int64_t namelen) { + break_loop_at(name, namelen, NULL, NULL, 0); + die_now(); +} + /* Re-entrant, and it has to be: a thunk this runs may itself error, and the * break loop that catches it polls again from inside that very call. So a job * is *claimed* — tail advanced past it — before it is run, and both indices @@ -860,8 +916,12 @@ static void handle_line(char *line, sink *o) { if (s == NULL) { reply(o, "err no restart snapshot\n"); return; } for (int32_t i = 0; i < s->n; i++) { char hdr[32]; + /* Both facts fold into the one flag, because the flag answers one + * question — can this be taken — and a break taken by a trap can take + * none of them. The terminal listing above says the same thing in + * words; the two must not describe different programs. */ int k = snprintf(hdr, sizeof hdr, "%d %c ", i, - s->reachable[i] ? '+' : '-'); + (s->resumable && s->reachable[i]) ? '+' : '-'); if (k > 0) emit(o, hdr, (size_t)k); emit(o, s->names + s->off[i], (size_t)s->len[i]); reply(o, "\n"); @@ -977,6 +1037,16 @@ static void handle_line(char *line, sink *o) { return; } } + /* Checked before [reachable], because it is the stronger fact and the one + * with the better sentence: at a trap every restart is unreachable, and + * answering with the thunk-boundary reason would send the reader looking + * for an evaluation that is not there. */ + if (!s->resumable) { + reply(o, "err this break was taken by a trap with no transfer channel, " + "so no restart can be taken from it; read the frame, then fix " + "and reload, or abort\n"); + return; + } if (!s->reachable[idx]) { /* Refused, with the reason, rather than accepted and dropped. The * transfer would unwind to the thunk this break is inside and stop @@ -1017,6 +1087,12 @@ static void handle_line(char *line, sink *o) { reply(o, " is active\n"); return; } + if (!s->resumable) { + reply(o, "err this break was taken by a trap with no transfer channel, " + "so no restart can be taken from it; read the frame, then fix " + "and reload, or abort\n"); + return; + } if (!s->reachable[at]) { reply(o, "err restart "); reply(o, line + 8); @@ -1491,5 +1567,6 @@ int32_t flan_agent_start(const uint8_t *path, int64_t len) { * socket and not before it: without a listener there is nobody to ask what * to do, and stopping forever is worse than the abort it replaces. */ flan_break_hook = break_loop; + flan_trap_hook = trap_stop; return 0; }