fix: a stopping program stops (executable plan, Task 4)
- blocking stdlib calls that PARK (net.accept, socket read/write, time.sleep, a child wait) no longer restart the syscall when the stop flag is set on an interruption: a server sitting in accept ignored SIGTERM and only `kill -9` ended it - a stop is NOT a trap -- builtin.h's WO_SYS_STOPPED carries no error record and no catch handler sees it (`try` must not swallow SIGTERM); the VM unwinds the whole stack through the same drop machinery an uncaught trap uses, so nothing leaks on the way out - wo_vm_call gained a third outcome (1 = stopped); the CLI maps it to the status the program's own `return 0` would have given, and a regular-file read keeps its plain EINTR retry -- it does not park - an ASSIGNMENT was not an ownership boundary: `api_key = j.mcp.apiKey` moved the field pointer into the local, so the local aliased the record and the first unwind freed the same string twice (SIGSEGV in class_free). `let` copied a Text place, assignment now does too -- the same double free was latent on the normal exit path, hidden by the order the compiler happens to emit drops in - log-watcher-accept is 7 checks: the seventh is the stop itself, with the hard kill demoted to a fallback whose use is the failure - measured under ASan: mcp parked, mcp after traffic, watch and run all exit rc 0 with zero leaks; SIGINT behaves as SIGTERM - gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0, wovm-test green, log-watcher 7/0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33e79c0827
commit
d30ad04bd9
9 changed files with 124 additions and 23 deletions
|
|
@ -3135,6 +3135,12 @@ and emit_assign (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (target : Ast
|
||||||
stored *)
|
stored *)
|
||||||
let t = alloc_temp p f value.pos in
|
let t = alloc_temp p f value.pos in
|
||||||
emit_expr p f v ~dst:t ~expected:ty value;
|
emit_expr p f v ~dst:t ~expected:ty value;
|
||||||
|
(* an assignment is an ownership boundary exactly as a `let` is —
|
||||||
|
`api_key = j.mcp.apiKey` made the local ALIAS the record's field,
|
||||||
|
so dropping the record left the local dangling and its own drop
|
||||||
|
freed the string a second time. Copy before the old value dies:
|
||||||
|
the source can live inside what is about to be dropped. *)
|
||||||
|
copy_place_text p f t value;
|
||||||
f.f_cur_line <- s.s_pos.line;
|
f.f_cur_line <- s.s_pos.line;
|
||||||
if overwrite then begin
|
if overwrite then begin
|
||||||
put f (ins_abc op_drop r 0 0);
|
put f (ins_abc op_drop r 0 0);
|
||||||
|
|
@ -3146,7 +3152,10 @@ and emit_assign (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (target : Ast
|
||||||
end;
|
end;
|
||||||
put f (ins_abc op_move r t 0)
|
put f (ins_abc op_move r t 0)
|
||||||
end
|
end
|
||||||
else emit_expr p f v ~dst:r ~expected:ty value;
|
else begin
|
||||||
|
emit_expr p f v ~dst:r ~expected:ty value;
|
||||||
|
copy_place_text p f r value
|
||||||
|
end;
|
||||||
(* a whole-local target cannot be an unprovable alias of anything
|
(* a whole-local target cannot be an unprovable alias of anything
|
||||||
(relate answers Overlap or Disjoint for a place with no
|
(relate answers Overlap or Disjoint for a place with no
|
||||||
projections), so this normally finds nothing; consumed anyway so
|
projections), so this normally finds nothing; consumed anyway so
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,15 @@ running", and every item below came from a measurement on the sample itself:
|
||||||
built, after the entry returns and after a trap alike. **All three modes
|
built, after the entry returns and after a trap alike. **All three modes
|
||||||
now report ZERO leaks under ASan** — `watch`, `run`, and the full MCP mix —
|
now report ZERO leaks under ASan** — `watch`, `run`, and the full MCP mix —
|
||||||
which is the clean baseline item 6's soak needs to read against.
|
which is the clean baseline item 6's soak needs to read against.
|
||||||
4. **A stopping program does not stop** — `env.stopping()` sets a flag, but
|
4. ~~A stopping program does not stop~~ — **done 2026-08-14**. A blocking
|
||||||
`net.accept`/`net.read` restart on `EINTR`, so a server parked in `accept`
|
call that parks (`net.accept`, socket read/write, `time.sleep`, a child
|
||||||
ignores SIGTERM and needs `kill -9`.
|
wait) now ends the program when it is interrupted with the stop flag set,
|
||||||
|
instead of restarting the syscall. A stop is not a trap: `try` cannot
|
||||||
|
swallow it, and the stack unwinds through the same drop machinery, so the
|
||||||
|
exit is clean and leak-free in every mode. It also uncovered a real
|
||||||
|
double-free: an **assignment** of a Text place was a move, not a copy, so
|
||||||
|
`api_key = j.mcp.apiKey` aliased the record — `let` copied, assignment now
|
||||||
|
does too. `just log-watcher` is 7 checks; the seventh is the stop.
|
||||||
5. **The MCP server never closes an accepted connection** — `net.close` exists
|
5. **The MCP server never closes an accepted connection** — `net.close` exists
|
||||||
and is unused; every request costs a descriptor.
|
and is unused; every request costs a descriptor.
|
||||||
6. **Nothing soaks** — every check is seconds long, which is exactly the window
|
6. **Nothing soaks** — every check is seconds long, which is exactly the window
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ before the heap is torn down.
|
||||||
CRITERIA MET, `just oop-e2e` 71/0, `just wovm-test` green,
|
CRITERIA MET, `just oop-e2e` 71/0, `just wovm-test` green,
|
||||||
`just log-watcher` 6/0.
|
`just log-watcher` 6/0.
|
||||||
|
|
||||||
### Task 4: A stopping program must actually stop
|
### Task 4 ✅: A stopping program must actually stop
|
||||||
|
|
||||||
**Concept & reason:** `env.stopping()` installs SIGTERM/SIGINT handlers that set
|
**Concept & reason:** `env.stopping()` installs SIGTERM/SIGINT handlers that set
|
||||||
a flag, and `net.accept`/`net.read` retry on `EINTR` — so a server parked in
|
a flag, and `net.accept`/`net.read` retry on `EINTR` — so a server parked in
|
||||||
|
|
@ -182,11 +182,32 @@ would put a trap in the middle of every accept loop the language will ever
|
||||||
write, and the shard-actor runtime (iteration 8) replaces these blocking calls
|
write, and the shard-actor runtime (iteration 8) replaces these blocking calls
|
||||||
with an event loop anyway.
|
with an event loop anyway.
|
||||||
|
|
||||||
- [ ] Failing measurement: `mcp` mode ignores SIGTERM and needs `kill -9`.
|
- [x] Failing measurement: `mcp` mode ignored SIGTERM and needed `kill -9`.
|
||||||
- [ ] Blocking stdlib calls observe the stop flag on interruption; the process
|
- [x] The calls that genuinely PARK — `net.accept`, a socket read/write,
|
||||||
exits cleanly, flushing output.
|
`time.sleep`, a child wait — no longer restart the syscall when the stop
|
||||||
- [ ] `just log-watcher` no longer needs `kill -9` in teardown, and the script's
|
flag is set on an interruption. They hand back `WO_SYS_STOPPED`
|
||||||
hard-kill fallback becomes belt-and-braces rather than the mechanism.
|
(builtin.h), which is **not** a trap code: no error record, no catch
|
||||||
|
handler sees it (a `try` must not be able to swallow SIGTERM), and the
|
||||||
|
VM unwinds the whole stack through the same drop machinery an uncaught
|
||||||
|
trap uses, so every live value is still released. `wo_vm_call` gained a
|
||||||
|
third outcome (1 = stopped) and the CLI maps it to the status the
|
||||||
|
program's own `return 0` would have produced. A regular-file read keeps
|
||||||
|
its plain retry — it does not park.
|
||||||
|
- [x] **Found and fixed while measuring: an assignment was not an ownership
|
||||||
|
boundary.** `api_key = j.mcp.apiKey` MOVED the field pointer into the
|
||||||
|
local, so the local aliased the record; the first stop unwind dropped
|
||||||
|
the record and then the alias, freeing the same string twice (a SIGSEGV
|
||||||
|
in `class_free`). `let` copied a Text place, assignment did not — it
|
||||||
|
does now. The same double free was latent on the normal exit path,
|
||||||
|
hidden only by the drop ORDER the compiler happens to emit.
|
||||||
|
- [x] `just log-watcher` is **7 checks** now: the seventh is the stop itself —
|
||||||
|
SIGTERM sent to a server parked in `accept` with no traffic coming, with
|
||||||
|
the hard kill demoted to a fallback whose use is the failure.
|
||||||
|
- [x] Re-measured, all under ASan: `mcp` stopped while parked (rc 0, zero
|
||||||
|
leaks), `mcp` stopped after serving traffic (rc 0, zero leaks), `watch`
|
||||||
|
and `run` stopped mid-poll (rc 0, zero leaks), and SIGINT behaves as
|
||||||
|
SIGTERM. Gates: `just oop-accept` ALL CRITERIA MET, `oop-e2e` 71/0,
|
||||||
|
`woc-test` 565/0, `wovm-test` green, `just log-watcher` 7/0.
|
||||||
|
|
||||||
### Task 5: The MCP server must close what it accepts
|
### Task 5: The MCP server must close what it accepts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@
|
||||||
|
|
||||||
#include "vm.h"
|
#include "vm.h"
|
||||||
|
|
||||||
|
/* Not a trap code: the stop flag (SIGTERM/SIGINT, armed by `env.stopping`)
|
||||||
|
* was set when a BLOCKING call was interrupted, so the call does not restart
|
||||||
|
* the syscall — it hands this back and the VM ends the program with it. A
|
||||||
|
* service parked in `accept` otherwise never observes the flag and only
|
||||||
|
* `kill -9` ends it. Negative so it cannot collide with a WO_T_* code, and
|
||||||
|
* deliberately NOT catchable: `try` must not be able to swallow a stop. */
|
||||||
|
#define WO_SYS_STOPPED (-2)
|
||||||
|
|
||||||
int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
|
int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
|
||||||
|
|
||||||
/* The systems stdlib's OS half (runtime/src/sysio.c): same contract as
|
/* The systems stdlib's OS half (runtime/src/sysio.c): same contract as
|
||||||
|
|
|
||||||
|
|
@ -189,12 +189,16 @@ int main(int argc, char **argv) {
|
||||||
wo_err terr;
|
wo_err terr;
|
||||||
int rc = wo_vm_call(&VM, mod.entry, entry_argc == 1 ? &argv_val : NULL, entry_argc, &ret,
|
int rc = wo_vm_call(&VM, mod.entry, entry_argc == 1 ? &argv_val : NULL, entry_argc, &ret,
|
||||||
&terr);
|
&terr);
|
||||||
if (rc != 0)
|
if (rc < 0)
|
||||||
fprintf(stderr, "trap %u in %s at line %u: %s\n", (unsigned)terr.code,
|
fprintf(stderr, "trap %u in %s at line %u: %s\n", (unsigned)terr.code,
|
||||||
terr.method, (unsigned)terr.line, terr.msg);
|
terr.method, (unsigned)terr.line, terr.msg);
|
||||||
/* the entry's return value IS the exit code (docs/plan/oop-vm/
|
/* the entry's return value IS the exit code (docs/plan/oop-vm/
|
||||||
* 08-builtin-surface.md's "Program entry"): 0..255, a trap is 1 */
|
* 08-builtin-surface.md's "Program entry"): 0..255, a trap is 1. A stop
|
||||||
int exit_code = rc == 0 ? (int)((uint64_t)ret & 0xFF) : 1;
|
* (rc == 1) is not a failure and not a trap — SIGTERM landing in a
|
||||||
|
* blocking call is the operator asking for the shutdown the program
|
||||||
|
* would have taken at its own next `env.stopping()` check, so it exits
|
||||||
|
* with the status that check's `return 0` would have produced. */
|
||||||
|
int exit_code = rc == 0 ? (int)((uint64_t)ret & 0xFF) : (rc > 0 ? 0 : 1);
|
||||||
/* the entry only BORROWS its arguments -- a parameter is never a `take`,
|
/* the entry only BORROWS its arguments -- a parameter is never a `take`,
|
||||||
* and the drop tables never drop one -- so the runtime that built the
|
* and the drop tables never drop one -- so the runtime that built the
|
||||||
* container is the one that releases it, elements included. Without this
|
* container is the one that releases it, elements included. Without this
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,13 @@ static void on_stop(int sig) {
|
||||||
stop_flag = 1;
|
stop_flag = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An interrupted blocking call asks this before restarting the syscall: a
|
||||||
|
* set flag means the program was told to stop, and the calls below stop
|
||||||
|
* instead of restarting (builtin.h's WO_SYS_STOPPED). Only the calls that
|
||||||
|
* genuinely PARK consult it — accept, a socket read/write, sleep and a child
|
||||||
|
* wait. A regular-file read is not one of them and keeps its plain retry. */
|
||||||
|
static int stop_pending(void) { return stop_flag != 0; }
|
||||||
|
|
||||||
static void install_stop_handlers(void) {
|
static void install_stop_handlers(void) {
|
||||||
if (stop_installed) return;
|
if (stop_installed) return;
|
||||||
stop_installed = 1;
|
stop_installed = 1;
|
||||||
|
|
@ -262,7 +269,10 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
||||||
int64_t ms = (int64_t)R[B];
|
int64_t ms = (int64_t)R[B];
|
||||||
if (ms > 0) {
|
if (ms > 0) {
|
||||||
struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L}, rem;
|
struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L}, rem;
|
||||||
while (nanosleep(&ts, &rem) != 0 && errno == EINTR) ts = rem;
|
while (nanosleep(&ts, &rem) != 0 && errno == EINTR) {
|
||||||
|
if (stop_pending()) return WO_SYS_STOPPED;
|
||||||
|
ts = rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
R[A] = 0;
|
R[A] = 0;
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -358,9 +368,11 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
||||||
}
|
}
|
||||||
case WO_B_NET_ACCEPT: {
|
case WO_B_NET_ACCEPT: {
|
||||||
int fd;
|
int fd;
|
||||||
do {
|
for (;;) {
|
||||||
fd = accept((int)R[B], NULL, NULL);
|
fd = accept((int)R[B], NULL, NULL);
|
||||||
} while (fd < 0 && errno == EINTR);
|
if (fd >= 0 || errno != EINTR) break;
|
||||||
|
if (stop_pending()) return WO_SYS_STOPPED;
|
||||||
|
}
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
*msg = strerror(errno);
|
*msg = strerror(errno);
|
||||||
return WO_T_IO;
|
return WO_T_IO;
|
||||||
|
|
@ -378,9 +390,14 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
||||||
return WO_T_OOM;
|
return WO_T_OOM;
|
||||||
}
|
}
|
||||||
ssize_t n;
|
ssize_t n;
|
||||||
do {
|
for (;;) {
|
||||||
n = read((int)R[B], s->data, (size_t)max);
|
n = read((int)R[B], s->data, (size_t)max);
|
||||||
} while (n < 0 && errno == EINTR);
|
if (n >= 0 || errno != EINTR) break;
|
||||||
|
if (stop_pending()) {
|
||||||
|
wo_str_free(rt, s);
|
||||||
|
return WO_SYS_STOPPED;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (n < 0) {
|
if (n < 0) {
|
||||||
wo_str_free(rt, s);
|
wo_str_free(rt, s);
|
||||||
*msg = strerror(errno);
|
*msg = strerror(errno);
|
||||||
|
|
@ -411,7 +428,10 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
||||||
while (at < body->len) {
|
while (at < body->len) {
|
||||||
ssize_t n = write((int)R[B], body->data + at, body->len - at);
|
ssize_t n = write((int)R[B], body->data + at, body->len - at);
|
||||||
if (n < 0) {
|
if (n < 0) {
|
||||||
if (errno == EINTR) continue;
|
if (errno == EINTR) {
|
||||||
|
if (stop_pending()) return WO_SYS_STOPPED;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
*msg = strerror(errno);
|
*msg = strerror(errno);
|
||||||
return WO_T_IO;
|
return WO_T_IO;
|
||||||
}
|
}
|
||||||
|
|
@ -497,6 +517,7 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
||||||
close(ep[0]);
|
close(ep[0]);
|
||||||
int status = 0;
|
int status = 0;
|
||||||
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
||||||
|
if (stop_pending()) return WO_SYS_STOPPED;
|
||||||
}
|
}
|
||||||
wo_hdr *o = record_of(vm, R[B + 2], 3, msg);
|
wo_hdr *o = record_of(vm, R[B + 2], 3, msg);
|
||||||
if (!o) return R[B + 2] >= vm->mod->class_cnt ? WO_T_BOUNDS : WO_T_OOM;
|
if (!o) return R[B + 2] >= vm->mod->class_cnt ? WO_T_BOUNDS : WO_T_OOM;
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,17 @@ dispatch:
|
||||||
CASE(BUILTIN) : {
|
CASE(BUILTIN) : {
|
||||||
const char *bmsg = "builtin failed";
|
const char *bmsg = "builtin failed";
|
||||||
int brc = wo_builtin(vm, R, ins, &bmsg);
|
int brc = wo_builtin(vm, R, ins, &bmsg);
|
||||||
|
/* A stop is not a trap: no error record, no catch handler gets a
|
||||||
|
* look (`try` must not be able to swallow SIGTERM), and no message.
|
||||||
|
* The stack is unwound exactly as an uncaught trap unwinds it, so
|
||||||
|
* every live value is still released on the way out; the CLI turns
|
||||||
|
* this into the same exit status a clean `return 0` gives. */
|
||||||
|
if (brc == WO_SYS_STOPPED) {
|
||||||
|
vm->frames[vm->depth - 1].pc = pc - 1;
|
||||||
|
vm->ncatch = 0;
|
||||||
|
vm_unwind(vm, 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (brc) TRAPF((uint32_t)brc, "%s", bmsg);
|
if (brc) TRAPF((uint32_t)brc, "%s", bmsg);
|
||||||
NEXT();
|
NEXT();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,10 @@ void wo_vm_destroy(wo_vm *vm);
|
||||||
|
|
||||||
/* Call a method with raw argument words. argc must equal the method's
|
/* Call a method with raw argument words. argc must equal the method's
|
||||||
* declared arity. 0 = done, *ret filled; -1 = trapped, *err filled and the
|
* declared arity. 0 = done, *ret filled; -1 = trapped, *err filled and the
|
||||||
* stack fully unwound (depth 0). */
|
* stack fully unwound (depth 0); 1 = STOPPED — a blocking stdlib call was
|
||||||
|
* interrupted with the stop flag set (builtin.h's WO_SYS_STOPPED), the stack
|
||||||
|
* is unwound the same way, *ret and *err are untouched, and there is nothing
|
||||||
|
* to report: the program was told to stop and did. */
|
||||||
int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
|
int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
|
||||||
uint32_t argc, uint64_t *ret, wo_err *err);
|
uint32_t argc, uint64_t *ret, wo_err *err);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ printf 'info service starting\n' >>"$LOG"
|
||||||
sleep 2
|
sleep 2
|
||||||
printf 'error disk full\n' >>"$LOG"
|
printf 'error disk full\n' >>"$LOG"
|
||||||
sleep 6
|
sleep 6
|
||||||
kill -9 "$WATCH_PID" 2>/dev/null
|
kill -TERM "$WATCH_PID" 2>/dev/null
|
||||||
wait "$WATCH_PID" 2>/dev/null
|
wait "$WATCH_PID" 2>/dev/null
|
||||||
WATCH_PID=""
|
WATCH_PID=""
|
||||||
if grep -q "^watching " "$WORK/watch.out" && grep -q "^ALERT .*last entry is error" "$WORK/watch.out"; then
|
if grep -q "^watching " "$WORK/watch.out" && grep -q "^ALERT .*last entry is error" "$WORK/watch.out"; then
|
||||||
|
|
@ -203,8 +203,26 @@ else
|
||||||
bad "mcp auth" "got: $(printf '%s' "$NOAUTH" | tr '\n' '|' | cut -c1-160)"
|
bad "mcp auth" "got: $(printf '%s' "$NOAUTH" | tr '\n' '|' | cut -c1-160)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kill -9 "$SRV_PID" 2>/dev/null
|
# The stop check (executable plan, Task 4): the server is parked in
|
||||||
wait "$SRV_PID" 2>/dev/null
|
# `net.accept` with no traffic coming, and TERM alone has to end it. Before
|
||||||
|
# the runtime observed the stop flag on an interrupted blocking call this
|
||||||
|
# needed `kill -9`, which is not a shutdown — no drops, no flush, nothing a
|
||||||
|
# supervisor or a deploy can rely on. The hard kill below stays as a
|
||||||
|
# belt-and-braces fallback, and reaching it is the failure.
|
||||||
|
kill -TERM "$SRV_PID" 2>/dev/null
|
||||||
|
STOPPED=""
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if ! kill -0 "$SRV_PID" 2>/dev/null; then STOPPED=1; break; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [[ -n "$STOPPED" ]]; then
|
||||||
|
wait "$SRV_PID" 2>/dev/null
|
||||||
|
ok "mcp stop (SIGTERM ends a server parked in accept)"
|
||||||
|
else
|
||||||
|
kill -9 "$SRV_PID" 2>/dev/null
|
||||||
|
wait "$SRV_PID" 2>/dev/null
|
||||||
|
bad "mcp stop" "still running 4s after SIGTERM; needed kill -9"
|
||||||
|
fi
|
||||||
SRV_PID=""
|
SRV_PID=""
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue