Summary
When a WithObserve observer sits INSIDE the local_handler's scope (deeper in the handler stack), effects performed by a Local(env, subprogram) subtree are not delivered to that observer — only the Local effect itself is observed. With the observer OUTSIDE the handler stack, everything is observed. Whether observers are nested makes no difference; the trigger is purely the observer's position relative to local_handler.
This looks like the same family as the Spawn boundary drop fixed in #479 ("Spawn inherits WithObserve boundaries via GetBoundaries(k)", ADR-DOE-CORE-001): the Local subprogram appears to execute at the handler's frame, outside the inner observer boundary.
Practical impact: a profiling/tracing observer attached by wrapping the program (e.g. via a --transform, which ends up innermost after the interpreter wraps its handlers) silently misses entire Local subtrees — in our effect-profiler the whole subtree showed up as one 17-second "gap" attributed to Local. Program results are correct; only observation is lost.
Environment
- doeff 0.4.1 @
6e5d2128, Python 3.14, macOS arm64
Repro (verified)
from doeff import Local, WithObserve, do, run, with_handlers
from doeff_core_effects import Ask, Put
from doeff_core_effects.handlers import lazy_ask, local_handler, state
from doeff_core_effects.scheduler import scheduled
@do
def inner():
yield Put("y", 1)
v = yield Ask("k")
return v
def case(observer_inside, local):
seen = []
obs = lambda e: seen.append(type(e).__name__)
body = Local({"k": 1}, inner()) if local else inner()
if observer_inside:
prog = WithObserve(obs, body)
wrapped = with_handlers([lazy_ask(env={"k": 0})],
with_handlers([local_handler],
with_handlers([state()], prog)))
else:
wrapped = with_handlers([lazy_ask(env={"k": 0})],
with_handlers([local_handler],
with_handlers([state()], body)))
wrapped = WithObserve(obs, wrapped)
result = run(scheduled(wrapped))
return result, seen
for inside in (False, True):
for local in (False, True):
r, seen = case(inside, local)
print(f"observer_inside={inside} local={local} -> result={r} observed={seen}")
Output:
observer_inside=False local=False -> result=0 observed=['Put', 'Ask']
observer_inside=False local=True -> result=1 observed=['Local', 'Put', 'Ask']
observer_inside=True local=False -> result=0 observed=['Put', 'Ask']
observer_inside=True local=True -> result=1 observed=['Local'] # <-- inner effects lost
Question
Is the observer_inside=True, local=True behavior intended (subprogram executes at the handler's stack position, so it is genuinely outside the inner observer's boundary), or should Local re-establish the caller's boundaries for its subprogram the way Spawn does since #479? If intended, a doc note on WithObserve placement would prevent the trap.
Found while building a dev-time effect profiler in proboscis-ema (TRD-176; packages/proboscis-research/src/proboscis_research/issues/trd083/effect_profiler.hy documents the limitation).
Summary
When a
WithObserveobserver sits INSIDE thelocal_handler's scope (deeper in the handler stack), effects performed by aLocal(env, subprogram)subtree are not delivered to that observer — only theLocaleffect itself is observed. With the observer OUTSIDE the handler stack, everything is observed. Whether observers are nested makes no difference; the trigger is purely the observer's position relative tolocal_handler.This looks like the same family as the Spawn boundary drop fixed in #479 ("Spawn inherits WithObserve boundaries via GetBoundaries(k)", ADR-DOE-CORE-001): the Local subprogram appears to execute at the handler's frame, outside the inner observer boundary.
Practical impact: a profiling/tracing observer attached by wrapping the program (e.g. via a
--transform, which ends up innermost after the interpreter wraps its handlers) silently misses entire Local subtrees — in our effect-profiler the whole subtree showed up as one 17-second "gap" attributed toLocal. Program results are correct; only observation is lost.Environment
6e5d2128, Python 3.14, macOS arm64Repro (verified)
Output:
Question
Is the
observer_inside=True, local=Truebehavior intended (subprogram executes at the handler's stack position, so it is genuinely outside the inner observer's boundary), or shouldLocalre-establish the caller's boundaries for its subprogram the way Spawn does since #479? If intended, a doc note on WithObserve placement would prevent the trap.Found while building a dev-time effect profiler in proboscis-ema (TRD-176;
packages/proboscis-research/src/proboscis_research/issues/trd083/effect_profiler.hydocuments the limitation).