From 8278a135fc27f6ef44463f8cc7211384596c53ba Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 16:25:56 -0500 Subject: [PATCH] observer: a predicate under `unobserved:` raises, and the block stops leaking its depth (#871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects. The filed one: `unobserved:` suppresses observer updates and its depth is DYNAMIC — it covers every function called from inside the block. So a caller adding a performance annotation silently changed a callee's answer (the issue's settle loop returns -1 instead of 22), and a bare `loop while not converged` inside one never terminated: the predicate could not become true, and the stall backstop that would have ended the loop is gated on the same depth. Both mechanisms that could have saved it failed for the same reason at the same moment. A predicate asked under an unobserved block is being asked a question the runtime structurally cannot answer, so it now says so, naming the predicate, the block, and the transitive scope. Checked in the three predicate opcode handlers rather than inside vm_slot_predicate, because a binding assigned inside the block has no `used` slot at all and the classifier is never reached on exactly the path that hangs. The predicate vocabulary moved into one shared table (eigs_predicate_name), which lint's W016 now reads instead of keeping a second copy. Deliberately NOT fixed by ungating the stall backstop instead: that check reads a frozen trajectory as "quiet", so ungating it would exit every legitimate unobserved loop after 100 iterations — including the accumulator loop README.md:189 measures at 2.7x. With the predicate raising, the hang is unreachable. And the block stays dynamic rather than becoming lexical, because lexical scoping would exclude callees, which is most of what a hot region does. The second defect, found while writing the test and worse: g_unobserved_depth is a runtime counter that only OP_UNOBSERVED_END decrements, and the compiler emitted that opcode on the fallthrough edge ONLY. A return, break, or continue out of the block — or any error caught outside it — left the depth elevated for the rest of the PROCESS. From that point the observer recorded nothing and every `report` answered "equilibrium" about a value that was plainly moving. Four independent silent deaths of the runtime's central mechanism, none of which produced a diagnostic: try: unobserved: boom is [1, 2][99] catch e: pass # observer is now dead for the rest of the program Fixed the way #726 fixed the identical disease in g_try_depth: break/continue/return emit the OP_UNOBSERVED_ENDs for every block they jump out of (with per-loop baselines for the first two, mirroring try_depth_at_entry), and a try handler records the depth at registration and restores it when an error unwinds into the catch. tests/test_unobserved.eigs pins the raise across all three predicate opcodes and all four leak edges plus nesting, using a moving-value probe that reads "equilibrium" when the observer is dead. Suite 3788/3788 release, 3786/3786 ASan+UBSan with detect_leaks=1, leak tally 0. Closes #871 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 42 ++++++++++- README.md | 18 +++++ docs/SPEC.md | 6 +- src/compiler.c | 29 ++++++++ src/eigenscript.c | 12 +++ src/eigenscript.h | 2 + src/lint.c | 5 +- src/vm.c | 46 ++++++++++++ src/vm.h | 7 +- tests/test_unobserved.eigs | 147 +++++++++++++++++++++++++++++++++++++ 10 files changed, 306 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efee4d80..15b7d7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,46 @@ All notable changes to EigenScript are documented here. wherever the extension is compiled in — previously the whole file was effectively inert without a server) plus DB18–DB26 against the live CI postgres service. +- **An observer predicate inside `unobserved:` raises instead of + answering `false` forever (#871).** The block's depth is *dynamic* — + it covers every function called from inside it — so a caller adding a + performance annotation silently changed a callee's answer (a settle + loop returned `-1` instead of `22`), and a bare + `loop while not converged` inside one **never terminated**: the + predicate could not become true, and the stall backstop that would + have ended the loop is gated on the same depth, so both mechanisms + that could have saved it failed for the same reason at the same + moment. A predicate asked under an `unobserved:` block is being asked + a question the runtime structurally cannot answer, and now says so — + naming the predicate, the block, and the transitive scope. Everything + that does not interrogate the observer is untouched, so the block + remains the performance knob it is documented to be. + Deliberately *not* fixed by ungating the stall backstop instead: that + check reads a frozen trajectory as "quiet", so ungating it would exit + every legitimate `unobserved:` loop after 100 iterations — including + the accumulator loop README.md:189 measures at 2.7x. With the + predicate raising, the hang is unreachable. And `unobserved:` was left + dynamic rather than made lexical, because lexical scoping would + exclude callees, which is most of what a hot region does. + +### Fixed + +- **`unobserved:` leaked its depth on every exit edge but one, silently + killing the observer for the rest of the process (#871, found while + fixing it).** `g_unobserved_depth` is a runtime counter that only + `OP_UNOBSERVED_END` decrements, and the compiler emitted that opcode + on the fallthrough edge only. A `return`, `break`, or `continue` out + of the block — or **any error caught outside it** — left the depth + elevated permanently: from then on the observer recorded nothing, and + every `report` answered `equilibrium` about a value that was plainly + moving. Four independent silent deaths of the runtime's central + mechanism, none producing a diagnostic. Fixed the way #726 fixed the + identical disease in `g_try_depth`: `break`/`continue`/`return` now + emit the `OP_UNOBSERVED_END`s for every block they jump out of + (per-loop baselines for the first two), and a `try` handler records + the depth at registration and restores it when an error unwinds into + the catch. `tests/test_unobserved.eigs` pins all four edges plus + nesting, with a moving-value probe. - **chart renders 1.5× faster at high point counts (#828).** The series hot loop called `_chart_map` — a fresh 2-element list — per plotted @@ -188,8 +228,6 @@ All notable changes to EigenScript are documented here. naming the file used and the file shadowed. Sweep of the repo and all 15 consumer repos found zero imports whose resolution flips. -### Fixed - - **vm_run_bytecode/sandbox_run: an assembled chunk's temporal opcodes now turn history recording on themselves (#831).** `g_trace_hist` was set only by the bytecode compiler's source scan, so a descriptor diff --git a/README.md b/README.md index 60a8fca6..695094bb 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,24 @@ nothing; `--lint` flags a block with no plain-variable assignments as W020. Note the depth is global rather than lexical: a function *called* inside the block runs unobserved too. +Because the depth is dynamic, an interrogation inside the block — or +inside anything it calls — has no trajectory to classify. Rather than +answer `false` forever, **an observer predicate raises inside an +`unobserved:` block** (#871): + +``` +Error line 4: converged: the observer is off inside an 'unobserved:' +block, so this predicate has no trajectory to classify — the block's +depth is dynamic, so it also covers functions called from inside it +``` + +That is what keeps the annotation a *performance* knob: it cannot +silently change an answer. Before it raised, wrapping a call in +`unobserved:` made a settle loop return `-1` instead of `22`, and a bare +`loop while not converged` inside one never terminated at all — the +predicate could not become true, and the stall backstop that would have +ended the loop is gated on the same depth. + ### Tensor Math ```eigenscript diff --git a/docs/SPEC.md b/docs/SPEC.md index 9bf01dba..049f587e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1277,7 +1277,11 @@ diverging ``` `unobserved:` blocks (and `loop` bodies inside them) skip observer -updates entirely — use them for hot numeric loops: +updates entirely — use them for hot numeric loops. The depth is +dynamic, so it covers functions called from inside the block; an +observer predicate asked anywhere under one **raises**, because there is +no trajectory for it to classify (a performance annotation must not +change an answer): ```eigenscript total is 0 diff --git a/src/compiler.c b/src/compiler.c index 3a058e4d..f3e146ed 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -30,6 +30,9 @@ typedef struct { int continue_target; int scope_depth; int has_fresh_env; /* 1 if loop emits OP_LOOP_ENV_FRESH per iteration (for-loops) */ + int unobs_depth_at_entry; /* #871: c->unobs_depth when the loop opened — + * break/continue leave every `unobserved:` block + * they jump out of, exactly as they leave `try`. */ int try_depth_at_entry; /* c->try_depth when the loop opened — break/continue * must emit one OP_TRY_END per try block they jump * out of, or the handler stays registered (#726) */ @@ -92,6 +95,11 @@ typedef struct Compiler { * normal name-call path honors the user * binding, and the builtin fallback is * semantically identical (fail-open). */ + int unobs_depth; /* #871: lexical `unobserved:` nesting here. + * g_unobserved_depth is a runtime counter + * that only UNOBSERVED_END decrements, so + * every non-fallthrough exit must emit its + * own — same disease as #726's try_depth. */ int try_depth; /* #726: lexical `try` nesting at this point in * THIS function's body (a nested AST_FUNC gets * its own Compiler, so it restarts at 0 — @@ -193,6 +201,7 @@ static LoopCtx *loop_push(Compiler *c) { } LoopCtx *lp = xcalloc(1, sizeof(LoopCtx)); lp->try_depth_at_entry = c->try_depth; + lp->unobs_depth_at_entry = c->unobs_depth; c->loops[c->loop_depth++] = lp; return lp; } @@ -2256,6 +2265,13 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { * (#726). Mirrors the loop-env cleanup below. */ for (int t = c->try_depth; t > lp->try_depth_at_entry; t--) emit(c, OP_TRY_END, node->line); + /* #871: and leave every `unobserved:` block being jumped out of. + * Without this the runtime depth stayed elevated for the rest of + * the PROCESS — the observer silently stopped recording, so every + * later `report` read `equilibrium` on a moving value and every + * predicate answered about a frozen trajectory. */ + for (int u = c->unobs_depth; u > lp->unobs_depth_at_entry; u--) + emit(c, OP_UNOBSERVED_END, node->line); /* Clean up loop env before jumping out, but ONLY if the loop allocated * a per-iteration env. While-loops don't — emitting OP_LOOP_ENV_END * there would free the surrounding env (often the global one). */ @@ -2290,6 +2306,13 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { * ran, so try_count climbed until it pinned at the cap. */ for (int t = c->try_depth; t > lp->try_depth_at_entry; t--) emit(c, OP_TRY_END, node->line); + /* #871: and leave every `unobserved:` block being jumped out of. + * Without this the runtime depth stayed elevated for the rest of + * the PROCESS — the observer silently stopped recording, so every + * later `report` read `equilibrium` on a moving value and every + * predicate answered about a frozen trajectory. */ + for (int u = c->unobs_depth; u > lp->unobs_depth_at_entry; u--) + emit(c, OP_UNOBSERVED_END, node->line); /* End this iteration's env before jumping back, exactly as break * does below — the back-edge target sits BEFORE the per-iteration * OP_LOOP_ENV_FRESH, so without this the env is never torn down: @@ -2484,6 +2507,10 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { * every later uncaught error in the process (#726). */ for (int t = c->try_depth; t > 0; t--) emit(c, OP_TRY_END, node->line); + /* #871: same for `unobserved:` — a `return` from inside one leaked the + * runtime depth permanently and killed the observer process-wide. */ + for (int u = c->unobs_depth; u > 0; u--) + emit(c, OP_UNOBSERVED_END, node->line); emit(c, node->data.ret.expr ? OP_RETURN : OP_RETURN_NULL, node->line); break; } @@ -2930,8 +2957,10 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { name_set_free(&interrogated_here); } emit(c, OP_UNOBSERVED_BEGIN, node->line); + c->unobs_depth++; /* #871 */ /* Unobserved block body is stored as block.stmts */ compile_block(c, node->data.block.stmts, node->data.block.count); + c->unobs_depth--; emit(c, OP_UNOBSERVED_END, node->line); break; } diff --git a/src/eigenscript.c b/src/eigenscript.c index 8e5b478b..bb607f76 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -945,6 +945,18 @@ int observer_slot_stable(const ObserverSlot *s) { return 1; } +/* #871: the predicate vocabulary, in kind order. The parser derives a kind as + * `TOK_CONVERGED + k` (parser.c:842) and vm_slot_predicate switches on the same + * k, so this table is the one place the words live — lint's W016 reads it too, + * rather than keeping a second copy that could drift out of order. */ +static const char *EIGS_PREDICATE_NAMES[6] = { + "converged", "stable", "improving", "oscillating", "diverging", "equilibrium" +}; + +const char* eigs_predicate_name(unsigned kind) { + return kind < 6 ? EIGS_PREDICATE_NAMES[kind] : "predicate"; +} + /* Slot mirror of builtin_report — same priority order and partial-window * fallback, reading the slot trajectory instead of a Value's. */ /* The entropy-channel report — the classifier for non-numeric bindings, and diff --git a/src/eigenscript.h b/src/eigenscript.h index 4c223d9b..990ede6c 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -1220,6 +1220,8 @@ typedef enum { EK_USER, /* `throw` — catch binds the thrown value, not a dict */ } ErrKind; const char* err_kind_name(ErrKind k); +/* #871: predicate word for a kind (parser/VM/lint share this table). */ +const char* eigs_predicate_name(unsigned kind); void rt_error(ErrKind kind, int line, const char *fmt, ...) __attribute__((format(printf, 3, 4))); char* read_file_util(const char *path, long *out_size); diff --git a/src/lint.c b/src/lint.c index 47bd1257..abef9e77 100644 --- a/src/lint.c +++ b/src/lint.c @@ -2039,16 +2039,13 @@ static void check_outer_mutation(ASTNode *ast, LintContext *ctx) { * #262 aliasing workaround, not a bare read). Sites that mean the bare read * deliberately carry `# lint: allow W016` (#399). */ -static const char *W016_PREDICATE_NAMES[] = { - "converged", "stable", "improving", "oscillating", "diverging", "equilibrium" -}; static void w016_scan(ASTNode *n, LintContext *ctx) { if (!n) return; switch (n->type) { case AST_PREDICATE: { int k = n->data.predicate.kind; - const char *nm = (k >= 0 && k < 6) ? W016_PREDICATE_NAMES[k] : "predicate"; + const char *nm = (k >= 0) ? eigs_predicate_name((unsigned)k) : "predicate"; lint_warn(ctx, n->line, "W016", "bare '%s' reads the last-observed binding (an invisible " "alias) — write '%s of '", nm, nm); diff --git a/src/vm.c b/src/vm.c index 54933fce..acd9de47 100644 --- a/src/vm.c +++ b/src/vm.c @@ -239,6 +239,37 @@ static inline void eigs_observe_safepoint(Env *e) { /* Classify an observer slot's trajectory by predicate kind (0..5), matching the * bare OP_PREDICATE dispatch. Shared by the named OP_PREDICATE_SLOT/NAME ops, * which read a SPECIFIC binding's slot instead of the global last-observed one. */ +/* #871: a predicate asked inside an `unobserved:` block cannot be answered. + * + * The block suppresses observer updates, and its depth is DYNAMIC — it covers + * every function called from inside it. So a caller adding a performance + * annotation silently changed a callee's answer (a settle loop returned -1 + * instead of 22), and a bare `loop while not converged` inside one never + * terminated: the predicate could not become true, and the stall backstop that + * would have ended the loop is gated on the same depth. + * + * Returning `false` forever is the worst available answer — the predicate is + * being asked a question the runtime structurally cannot answer, so it says + * so. This is checked in the opcode handlers rather than inside + * vm_slot_predicate because a binding assigned inside the block has no `used` + * slot at all, so the classifier is never reached on exactly the path that + * hangs. Returns 1 when it raised. + * + * NOT fixed by ungating the stall backstop instead: that check treats a frozen + * trajectory as "quiet", so ungating it would exit every legitimate + * `unobserved:` loop after 100 iterations — including the accumulator loop + * README.md:189 measures at 2.7x. With the predicate raising, the hang is + * unreachable and the backstop's gate is no longer load-bearing here. */ +static int vm_pred_unobserved(uint16_t kind, int line) { + if (g_unobserved_depth == 0) return 0; + rt_error(EK_VALUE, line, + "%s: the observer is off inside an 'unobserved:' block, so this " + "predicate has no trajectory to classify — the block's depth is " + "dynamic, so it also covers functions called from inside it", + eigs_predicate_name(kind)); + return 1; +} + static int vm_slot_predicate(const ObserverSlot *s, uint16_t kind) { switch (kind) { case 0: return observer_slot_converged(s); @@ -2703,6 +2734,8 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { frame->try_count--; \ uint8_t *_catch_ip = frame->try_handlers[frame->try_count].catch_ip; \ int _catch_bp = frame->try_handlers[frame->try_count].catch_bp; \ + g_unobserved_depth = \ + frame->try_handlers[frame->try_count].unobs_depth; /* #871 */ \ frame->is_try = (frame->try_count > 0); \ while (g_vm.sp > _catch_bp) val_decref(vm_pop()); \ vm_push(vm_take_error_value()); \ @@ -4571,6 +4604,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { g_try_depth++; frame->try_handlers[frame->try_count].catch_ip = ip + catch_offset; frame->try_handlers[frame->try_count].catch_bp = g_vm.sp; + frame->try_handlers[frame->try_count].unobs_depth = g_unobserved_depth; /* #871 */ frame->try_count++; frame->is_try = 1; DISPATCH(); @@ -5131,6 +5165,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * only on the slot, an operand with no live slot has no trajectory → * the predicate is false. */ uint16_t kind = read_u16(ip); ip += 2; + if (vm_pred_unobserved(kind, current_line)) { /* #871 */ + vm_push_slot(slot_null()); + DISPATCH(); + } int result = 0; const ObserverSlot *s = vm_slot_value_opaque(g_last_obs_slot_env, g_last_obs_slot_idx) @@ -5151,6 +5189,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * OP_PREDICATE reads. An unobserved/empty slot is false. */ uint16_t kind = read_u16(ip); ip += 2; uint16_t slot = read_u16(ip); ip += 2; + if (vm_pred_unobserved(kind, current_line)) { /* #871 */ + vm_push_slot(slot_null()); + DISPATCH(); + } Env *e = frame->fn_env; int result = 0; const ObserverSlot *ps_l = env_obs_slot(e, (int)slot); @@ -5167,6 +5209,10 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { * its slot (mirrors REPORT_NAME). Undefined name raises like GET_NAME. */ uint16_t kind = read_u16(ip); ip += 2; uint16_t name_idx = read_u16(ip); ip += 2; + if (vm_pred_unobserved(kind, current_line)) { /* #871 */ + vm_push_slot(slot_null()); + DISPATCH(); + } const char *name = chunk->const_interns[name_idx]; uint32_t h = chunk->const_hashes ? chunk->const_hashes[name_idx] : 0; if (h == 0) { h = env_hash_name(name); if (chunk->const_hashes) chunk->const_hashes[name_idx] = h; } diff --git a/src/vm.h b/src/vm.h index 0ac1b48c..f8d2e1af 100644 --- a/src/vm.h +++ b/src/vm.h @@ -461,7 +461,12 @@ typedef struct { * compiler rejects source that nests deeper than MAX_TRY_HANDLERS; the * VM re-checks because untrusted chunks (vm_run_bytecode / sandbox_run) * reach TRY_BEGIN without going through the compiler at all (#726). */ - struct { uint8_t *catch_ip; int catch_bp; } try_handlers[MAX_TRY_HANDLERS]; + /* #871: unobs_depth is g_unobserved_depth as it stood when this handler + * was registered. An error unwinding INTO the catch skips every + * OP_UNOBSERVED_END between the raise and here, so without restoring it + * the runtime depth stays elevated and the observer silently stops + * recording for the rest of the process. */ + struct { uint8_t *catch_ip; int catch_bp; int unobs_depth; } try_handlers[MAX_TRY_HANDLERS]; int try_count; /* number of active try handlers */ /* Saved loop-stall globals (so a callee's loops don't inherit caller's * accumulated stall count / iteration count). Scoped per call frame. */ diff --git a/tests/test_unobserved.eigs b/tests/test_unobserved.eigs index 11e041a2..248008b7 100644 --- a/tests/test_unobserved.eigs +++ b/tests/test_unobserved.eigs @@ -50,4 +50,151 @@ g.z is 5 g.z is g.z + 1 assert_eq of [g.z, 6, "observed mutation still works"] +# ---- #871: a predicate inside `unobserved:` RAISES ---- +# The block's depth is dynamic, so it also covers callees. Before this, a +# caller adding a performance annotation silently changed a callee's answer, +# and a bare `loop while not converged` inside one never terminated at all: +# the predicate could not become true, and the stall backstop that would have +# ended the loop is gated on the same depth. Returning false forever is the +# worst available answer, so the predicate says it cannot answer. + +define settle as: + x is 5.0 + k is 0 + loop while not (converged of x): + x is x * 0.5 + k is k + 1 + if k > 60: + return 0 - 1 + return k + +# Observed, the callee works and returns a real iteration count. +normal is settle of 0 +assert of [normal > 0, "UO-871 the callee terminates and answers when observed"] + +# Called from inside an unobserved: block, it raises instead of answering +# with the silently-wrong -1 it used to return. +caught is 0 +kind is "" +msg is "" +try: + unobserved: # lint: allow W020 -- the call is the point, not a speedup + settle of 0 +catch e: + caught is 1 + kind is e.kind + msg is e.message +assert of [caught == 1, "UO-871 a predicate in a callee under unobserved raises"] +assert of [kind == "value", "UO-871 kind is value"] +assert of [(contains of [msg, "converged"]) == 1, "UO-871 message names the predicate"] +assert of [(contains of [msg, "unobserved"]) == 1, "UO-871 message names the block"] +assert of [(contains of [msg, "dynamic"]) == 1, "UO-871 message explains the transitive scope"] + +# The message names the predicate that was actually asked, not a fixed word. +caught2 is 0 +msg2 is "" +try: + unobserved: # lint: allow W020 -- ditto + w is 1.0 + w is w + 1.0 + if diverging of w: + w is 0 +catch e: + caught2 is 1 + msg2 is e.message +assert of [caught2 == 1, "UO-871 a direct named predicate raises too"] +assert of [(contains of [msg2, "diverging"]) == 1, "UO-871 message names THAT predicate"] + +# A bare predicate (no operand) is the third opcode and raises as well. +caught3 is 0 +try: + unobserved: # lint: allow W020 -- ditto + q is 2.0 + q is q * 2.0 + if stable: + q is 0 +catch e: + caught3 is 1 +assert of [caught3 == 1, "UO-871 a bare predicate raises too"] + +# Non-predicate work inside unobserved is unaffected — the block is still a +# performance annotation for everything that does not ask the observer. +acc is 0 +unobserved: + for i in range of 100: + acc is acc + i +assert_eq of [acc, 4950, "UO-871 ordinary unobserved work is untouched"] + +# And once the block is left, predicates answer again. +after is settle of 0 +assert of [after > 0, "UO-871 predicates work again after the block"] + +# ---- #871: leaving an `unobserved:` block by ANY edge restores the depth ---- +# g_unobserved_depth is a runtime counter that only OP_UNOBSERVED_END +# decrements, and the compiler emitted that opcode on the fallthrough edge +# only. So a return / break / continue out of the block, or an error caught +# outside it, left the depth elevated for the rest of the PROCESS: the +# observer silently stopped recording, and every later `report` answered +# "equilibrium" about a value that was plainly moving. Four separate silent +# deaths of the observer, none of which produced any diagnostic. +# +# The probe: a value that IS moving must report as moving. +define moving_probe as: + y is 3.0 + y is y * 0.5 + y is y * 0.5 + return report of y + +assert of [(moving_probe of 0) == "moving", "UO-871L baseline: a moving value reports moving"] + +# 1. an error caught OUTSIDE the block +try: + unobserved: # lint: allow W020 -- the unwind is the point + pair is [1, 2] + boom is pair[99] +catch e: + boom_kind is e.kind +assert of [(moving_probe of 0) == "moving", "UO-871L a caught error does not kill the observer"] + +# 2. `return` from inside the block +define returns_from_inside as: + unobserved: # lint: allow W020 -- ditto + return 1 + return 0 +assert of [(returns_from_inside of 0) == 1, "UO-871L return from inside the block still returns"] +assert of [(moving_probe of 0) == "moving", "UO-871L return does not kill the observer"] + +# 3. `break` out of the block +for bi in range of 3: + unobserved: # lint: allow W020 -- ditto + if bi == 0: + break +assert of [(moving_probe of 0) == "moving", "UO-871L break does not kill the observer"] + +# 4. `continue` out of the block +for ci in range of 3: + unobserved: # lint: allow W020 -- ditto + if ci == 0: + continue +assert of [(moving_probe of 0) == "moving", "UO-871L continue does not kill the observer"] + +# 5. nested blocks unwind all the way out, not one level +define nested_return as: + unobserved: # lint: allow W020 -- ditto + unobserved: # lint: allow W020 -- ditto + return 7 + return 0 +assert of [(nested_return of 0) == 7, "UO-871L nested return still returns"] +assert of [(moving_probe of 0) == "moving", "UO-871L nested return restores every level"] + +# 6. and the block still WORKS after all that — it is not stuck off either. +seen_inside is "" +m is 9.0 +unobserved: # lint: allow W020 -- checking the block is still functional + m is m * 0.5 + m is m * 0.5 + seen_inside is report of m +assert of [seen_inside == "equilibrium", "UO-871L the block still suppresses observation"] +assert of [(moving_probe of 0) == "moving", "UO-871L and normal observation resumes after it"] + test_summary of null