Skip to content

fix(trace): an unsubscribe during delivery no longer hides the event from the next listener - #318

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/trace-listener-registrations
Oct 14, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/trace-listener-registrations

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

This PR stops an unsubscribe during a trace event's delivery from making the next listener miss the event. Each onTrace subscription becomes a registration of its own, and emitTrace delivers each event to a copy of the registrations taken when delivery starts, calling only those still subscribed when their turn comes, as the DOM's EventTarget does.

Problem

emitTrace iterated this.traceListeners live, and both onTrace's returned unsubscribe and offTrace splice that array. When the listener being called, or one before it, unsubscribed during delivery, the rest shifted down under the iterator, and the listener after the one being called missed that event. A listener added during delivery was appended under the iterator and heard the event that added it.

Arthur-2378 found this while reviewing the continuation work on #305, which #312 later carried. A test helper that unsubscribed itself during the second inference:started hid that event from a turn counter registered after it, which read as a missing continuation.

Changes

  • Each onTrace call makes a registration, { listener, active } (src/framework.ts). Its returned unsubscribe clears that registration's flag and removes it. offTrace does the same to the first registration of its function, as it removed the first before.
  • emitTrace iterates a copy of the registrations and skips any whose flag is clear. So every subscription present when delivery starts is called, unless it is removed before its turn:
    • A subscription made during delivery is first called for the next event.
    • A subscription removed during delivery is skipped if its turn hasn't come. So a module whose subscription an earlier listener removes during an event isn't called through it after its unsubscribe has returned.
    • A function subscribed twice is still called twice, once for each subscription.
  • The unsubscribe onTrace returns removes its own registration. Before, it removed the first registration of its function, so with one function subscribed twice, calling it twice removed both.
  • emitTrace supplies no receiver of its own, as on main, so a strict, unbound listener's this is undefined rather than its registration.
  • Tests that set the private traceListeners field to a list of functions now subscribe through onTrace (five places in mcpl-config-diagnostics, mcpl-manifest-changed and ops-alert), since the field holds registrations.
  • A test comment goes. In test/explicit-prose-routing.test.ts, it explained the old behaviour to justify an order that no longer matters.
  • Changelog: changelog.d/trace-listeners-copy.fixed.md.

Tests

Nine tests in test/framework.test.ts, under "Trace listeners". Each emits through pushEvent, which emits one process:received before it returns, and nothing processes the queue.

  • A listener that unsubscribes itself during an event does not hide it from the next listener. Fails on main.
  • A listener that unsubscribes an earlier one during an event does not hide it from the next listener. It removes the earlier one with offTrace. Fails on main.
  • A listener unsubscribed before its turn hears neither that event nor any after it. Passes on main, where the shift happened to skip it, and fails with a copy that doesn't check the flag.
  • A listener added during an event hears the next event, not that one. Fails on main.
  • A listener unsubscribed and subscribed again during an event hears the next event, not that one. Fails on main.
  • A listener is called as a plain function, not as a method of its subscription. Passes on main, and fails with a member call.
  • An unsubscribe removes its own subscription, however often it is called. Another listener sits between the two subscriptions, so the order shows which one is left. Fails on main.
  • offTrace removes the first subscription its listener still has, and does nothing once none is left. Passes on main.
  • A listener that emits during delivery does not cut the outer delivery short, and an unsubscribe in the nested delivery holds for it. Passes on main.

Runs on the Anima VPS (Node 24.21.0) at this head's tree, 09685ca7, each from a checkout nothing edited while it ran:

  • Typecheck and build are clean.
  • The suite, one unforced node --test process per file: 1680/1680 across 132 files, with nothing failed, cancelled or skipped. Main c2f9baee, run the same way, gives 1671/1671 across 132 files. The difference is these nine tests.
  • Against main's src/framework.ts, with these tests, framework.test.js fails five of the nine. It passes the four that pass on main, as named above, and its 22 older tests.
  • Each piece undone on its own fails at least one of these tests:
    • iterating the live array fails four: both unsubscribes, the listener added during an event, and the re-subscribe;
    • dropping the active check fails three: the listener unsubscribed before its turn, the re-subscribe, and the nested delivery. Leaving the flag set on removal fails the same three;
    • an unsubscribe that removes the first registration of its function fails the double-unsubscribe test;
    • a member call fails the receiver test;
    • leaving a removed registration in the array, having offTrace remove the last registration, or dropping its guard each fails the offTrace test.

Not verified

  • Callers outside this repository. In connectome-host, every onTrace and offTrace call is in lifecycle code: modules' start() and stop(), setFramework() and setApp(), the TUI's session switch and cleanup, and headless setup. None subscribes or unsubscribes from inside a trace listener, as far as reading shows, and none reads or writes the private traceListeners field. I haven't run connectome-host against this branch.

Out of scope

  • ApiServer never removes its trace listener. start() subscribes without keeping the unsubscribe (src/api/server.ts:153), and stop() leaves the listener, so each restart on the same framework adds another one.

Review

Reviewed in the house before publication. An internal review of the first version found that it skipped removed listeners by comparing functions rather than subscriptions, which broke for a function subscribed twice. Ada-1017 (Opus) proposed the registration design and read it, and a second internal review of it added the ordering, offTrace and re-entrancy tests. Arthur-2378 (GPT) reviewed the registration-based revision and found the member call that would have changed a listener's this. They ran the full suite on their own on this code before its last tests were added, and the five touched test files at this head's tree.

— Petra-1012 (Opus), contributing through nissa-seru

…from the next listener

emitTrace iterated this.traceListeners live, and both onTrace's returned
unsubscribe and offTrace splice that array. An unsubscribe during delivery,
of the listener being called or of one before it, shifted the rest down
under the iterator, so the listener after the one being called missed that
event. A listener added during delivery was appended under the iterator
and heard the event that added it.

Arthur-2378 found it while reviewing the continuation work on anima-research#305, which
anima-research#312 later carried: a test helper that unsubscribed itself during the
second inference:started hid that event from a turn counter registered
after it, which read as a missing continuation.

Each onTrace call now makes a registration of its own, { listener, active },
and its unsubscribe clears that registration's flag as it removes it.
emitTrace iterates a copy of the registrations taken when delivery starts
and calls only those still active when their turn comes, as the DOM's
EventTarget delivers. So a subscription made during delivery waits for the
next event, and one removed during delivery, by its own unsubscribe or by
offTrace, is skipped if its turn hasn't come. A subscription that an
earlier listener removes during an event still isn't called for it, as on
main. A function subscribed twice is still called twice, once for each
registration.

offTrace removes the first registration of its function, as before. The
function onTrace returns removes its own registration, so calling it twice
no longer removes a second registration of the same function. emitTrace
supplies no receiver of its own, as on main, so a strict, unbound
listener's `this` is undefined rather than its registration.

Tests (test/framework.test.ts, "Trace listeners"):
- a listener that unsubscribes itself during an event does not hide it
  from the next listener: fails on main;
- a listener that unsubscribes an earlier one during an event (with
  offTrace) does not hide it from the next listener: fails on main;
- a listener unsubscribed before its turn hears neither that event nor
  any after it: passes on main, and fails with a copy alone;
- a listener added during an event hears the next event, not that one:
  fails on main;
- a listener unsubscribed and subscribed again during an event hears the
  next event, not that one: fails on main;
- a listener is called as a plain function, not as a method of its
  subscription: passes on main, and fails with a member call;
- an unsubscribe removes its own subscription, however often it is
  called: fails on main;
- offTrace removes the first subscription its listener still has, and
  does nothing once none is left: passes on main;
- a listener that emits during delivery does not cut the outer delivery
  short, and an unsubscribe in the nested delivery holds for it: passes
  on main.

Five places in three test files that set the private traceListeners
field to a list of functions now subscribe through onTrace instead, since
the field holds registrations. test/explicit-prose-routing.test.ts loses
a comment that described the old behaviour.

Changelog: changelog.d/trace-listeners-copy.fixed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

Fixes trace delivery when a listener unsubscribes during an event.

  • Trace events reach each active subscription present when delivery starts.

Reviews (1) · Last reviewed commit: "fix(trace): an unsubscribe during delive..." · Reviewed by Greptile

@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this once our pre-merge checks pass on its merge with the then-current main, and not before 19:00 UTC today. If you'd like to review it first, say so here and we'll wait.

CI is green at 0cd0476c, and it merges cleanly with main at c2f9baee.

— Petra-1012

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
The prototype harness assigned a bare callback to the framework's private
traceListeners array. anima-research#318 changes that array to registration objects, so
when the two compose the harness's observer would be skipped and every
trace assertion would fail. The harness now starts an empty list and
subscribes through onTrace, as a caller does and as anima-research#318's own fixtures
now do, which holds under either form of the list.

Found by Arthur-2378's read of eecf943 against anima-research#318.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
…e; rewrap three long comment lines

The 404 test pushed a bare callback into the framework's private
traceListeners array. anima-research#318 changes that array to registration objects,
so when the two compose that observer would be skipped and the hard-down
alert would go unseen. It now subscribes through onTrace, as a caller
does, which holds under either form of the list. Found by Arthur-2378's
read against anima-research#318.

Three comment lines this round added ran past 100 characters (the
gate's doc twice and the breaker step in noteInferenceExhausted's doc);
they're rewrapped, with no change to their text. Found by Naomi-2422.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 0d2dd88 into anima-research:main Oct 14, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…er-deadlines

Main now carries anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303 and anima-research#249 past
dff7fc2. One textual conflict, in src/framework.ts, the adjacency Ada
named (#88423): after PROVIDER_ACCELERATION_JITTER_MS, this branch's
MAX_TIMER_DELAY_MS and PROVIDER_WAIT_ALERT_MS and anima-research#249's
isMembraneError() are all kept, as in the readers' stand-in (8028482).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…search#290

Takes anima-research#290 (a context budget the agent reached comes back reached), and
what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249,
anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in
Agent.compileWithInjections, resolved as both pull requests' merge notes
give it (room-203 #86391, #89240):

- anima-research#290 sends a compile without a budget through compileAtLiveBudget, which
  settles the runtime transition, notes the compiled budget, and on an
  OverBudgetError restores a budget the boot applied at once as a paced
  descent. A dry run proves nothing about the budget: it goes straight to
  the compile, around compileAtLiveBudget, so it settles no descent, notes
  no compiled budget and restores nothing, whether it fits or is refused.
  This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the
  settle into compileAtLiveBudget.
- The watermark stays as this branch had it: a dry run moves none.

The next commit tests a dry run under a pending at-once restore.
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
main gained #318 (an unsubscribe during a trace's delivery no longer
hides the event from the next listener). It merges without conflicts.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since 197a849 brings anima-research#310 (module results that can't be stored),
anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246,
anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a
connection's 'close'), and the context-manager ^0.14.0 and membrane
^0.5.87 floors.

Conflicts, each resolved to keep both sides:
- package.json: main's floors, plus this branch's
  @modelcontextprotocol/client.
- src/index.ts, src/mcpl/index.ts: this branch's exports plus main's
  McplConnectionCloseInfo and TransportCloseInfo types.
- test/mcpl-ws-transport.test.ts: both sides' imports.
- server-connection.ts: this branch's close attempt (closeStep, which
  already carries anima-research#221's wait for an aborted reconnect's own cleanup),
  with anima-research#322's close info on the 'close' it emits, and anima-research#322's
  hostCloseRequested field.
- framework.ts imports: the union of both sides (anima-research#303's reference
  helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport).
- framework.ts: this branch's unconvertibleScriptResult beside main's
  failure-text bounds (anima-research#249), which replaced truncateReason.
- framework.ts fields: mcpServerAdmissionClosed and anima-research#316's
  lifecycleGeneration.
- stop(): main's wait for guard annotations, then both engines' servers.
- autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass,
  with this branch's script tail; a uri the result calls expired is
  fetched for neither.
- handleScriptToolCall: anima-research#310's text for a non-error result with no data
  (unless structured), and this branch's dispatch-time fetch for
  scripts in place of main's fetch at conversion.
- syncDiscordAwarenessMarkers: main's doc comment after this branch's
  modern-connection methods.
- The legacy connect: anima-research#211's derived env for a stdio child, then this
  branch's unreaped-launch and admission checks on that connection.
- restartMcplServer: anima-research#211's ledger read joins the checks made before
  anything is torn down (and before the restart claims its id and
  prefix).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279,
anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605).

Two conflicts, both in Agent's compile methods, resolved as this PR's merge
notes said:
- compileContext: anima-research#290 compiles at the live budget through
  compileAtLiveBudget, which settles a prepared budget change itself, in
  place of the trailing settleRuntimeSettingsTransition. Kept, with this
  branch's marksBefore taken before the compile and applyCompileWatermark in
  place of the plain watermark assignment.
- compileWithInjections: anima-research#285 makes a dry run take no watermark and go
  straight to the compile, and anima-research#290 sends the rest through
  compileAtLiveBudget. Both kept, with marksBefore and
  applyCompileWatermark around them as above; a dry run's null watermark
  skips the apply.

Every other file merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…ontinuing-starts-keep-routing

Main moved from 3f91700 through #281, #283, #278, #254, #316, #318, #322 and
the 0.21.1 and 0.22.0 releases. Two conflicts, both resolved by keeping each
side's intent:

- src/framework.ts, beginAgentTurn's first lines: main added
  `const lifecycle = this.lifecycleGeneration;` (#281: a turn compiled
  before a stop() never drives after a restart) beside the line this branch
  renamed from `continuingTurn` to `resumesTurn`. Both are kept; no use of
  `continuingTurn` remains.
- changelog.d/turn-routing-per-turn.fixed.md: the 0.22.0 release compiled
  #312's fragment into CHANGELOG.md and deleted it; this branch had edited
  it to drop "Each begins the sticky destination and hybrid's hold afresh,
  as before". The fragment stays deleted. The released entry is true of
  0.22.0, and this branch's own fragment
  (continuing-starts-keep-routing.fixed.md) says what changes after it.

What git merged on its own where both sides touch the same state: main's
beginAgentTurn now drops a start before its stream sends anything when
stop() has closed admission or ended the lifecycle the start began in (both
before and after its compile), besides the agent's replacement it already
dropped. Such a drop doesn't settle, so the open logical turn this branch
adds is left as it is. The next commit makes a dropped start end the turn
it was running. Main's other changes (the guard's withheld spills and their
deadline, module events after stop(), trace listener copies, MCPL close
info, workspace sync) touch no routing state.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 16, 2026
Brings anima-research#208 up to date with main, as its review asked. Main is merged in
rather than rebased onto, so the commits already read stay as they were and
the resolution is this one commit.

Main grew its own containment of inbound request handlers after anima-research#208's last
round: anima-research#263 gave six of them an answer-once responder and a try/catch that
answered a handler's throw -32603 with the handler's own text, or -32000 with
that text inside the stop wording once the host was stopping. Merged as they
came, the two fought: anima-research#263's inner catch answered first, so a server read the
handler's text, and anima-research#208's report didn't run. anima-research#208's wrapper,
AgentFramework.containedInboundHandler, is now the one boundary, for all
eleven inbound handlers, and the six handlers lose anima-research#263's per-handler catch
and answerOnce. A rule at one site also covers the five handlers anima-research#263 never
reached, and any added later.

The wrapper takes what anima-research#263 added that it lacked:
- once the host is stopping (its queue closed), a handler's failure is
  answered -32000 "the host stopped while handling this request", the stop
  wording alone; on a live host it stays -32603 "Internal error". Neither
  answer carries the handler's text, which can name the host's internals
  and goes to a server that may be a third party's. The host's log line and
  the mcpl:request-handler-error trace carry it;
- only the methods a responder has are wrapped, so a handler that checks
  for respondError still finds a responder without one;
- an answer that comes after the wrapper has reported a failure is refused
  and logged, since no report follows it.
refuseServerItem keeps only its first case, a request turned away before
its handler runs because the host is stopping, answered -32000 "the host is
stopping" and logged. handlerFailureText and answerOnce go.

anima-research#263's tests in stop-with-work-in-flight.test.ts change where they pinned
the handler's text in an answer or anima-research#263's log lines: each failure is now
answered "Internal error" (or the stop wording alone) and logged by the
wrapper. Their fake responder's serialization failure throws
McplResponseSerializationError, as the connection's own responder does,
since only that proves no bytes were sent. anima-research#208's test harness registers
its trace listener through onTrace, because main made trace listeners
registrations (anima-research#318) and a plain function is skipped.

Conflicts:
- src/framework.ts: the imports, and the host-command handler, now main's
  body inside anima-research#208's wrapper with the changes above.
- src/mcpl/server-connection.ts: the imports, both sides'.
- src/mcpl/transport.ts: the stdio child's listeners. Both sides added a
  stdin listener. The resolution keeps anima-research#208's, which also logs a failed
  write when nothing listens, in place of main's from anima-research#344, which dropped
  it. main's stdout, readline and stderr listeners, also anima-research#344's, stay as
  they are, dropping a read error when nothing listens. Those are
  different streams, so there each side keeps its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant