Skip to content

Effects inside Local are invisible to a WithObserve installed inside local_handler's scope (#479 family) #489

Description

@proboscis

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions