Skip to content

fix(watch): recover from a reset catalog and a same-name republish#2188

Open
fperex wants to merge 2 commits into
moq-dev:devfrom
fperex:split/watch-recovery
Open

fix(watch): recover from a reset catalog and a same-name republish#2188
fperex wants to merge 2 commits into
moq-dev:devfrom
fperex:split/watch-recovery

Conversation

@fperex

@fperex fperex commented Jul 12, 2026

Copy link
Copy Markdown

Split out of #2163.

Stacked on #2182 (it uses isStreamAbort to tell a stream reset apart from a real fault). The first commit belongs to that PR; review the second commit only. The diff collapses once #2182 merges and I rebase.

Two ways a tile could go permanently offline while the relay was perfectly healthy:

1. A catalog subscription that ended was terminal. #runCatalog subscribed once and wrote status = "offline" whenever its fetch loop ended. But the effect's tracked signals (enabled, name, catalogFormat, active) do not change when only the catalog stream resets, so the effect never re-ran and nothing ever resubscribed. Any transient reset (a slow-consumer drop, a relay bounce, a publisher handover) stranded the tile offline until a page reload.

It now reopens on a bounded backoff, but only when the loop ended in a stream reset. A clean end (the publisher genuinely stopped) or a coded fault still reports offline immediately, which is why this depends on the isStreamAbort classification rather than retrying on any error.

2. A republish under the same name was invisible. The announce Set only tracks presence, so when a publisher's unannounce and the new instance's announce coalesce, the set never observably flips and the watcher stays bound to the dead route. Reload now tracks a per-path announce generation that bumps on every (re-)announce, so a same-name republish is a real change the watcher re-consumes.

The generation map deliberately never deletes on unannounce: resetting a path to 1 would make a coalesced republish look unchanged (1 -> 1) and reintroduce the bug. It is reset on reconnect, so it grows by one entry per distinct path ever announced, which is an accepted tradeoff.

Public API changes: @moq/net gains Reload.announcedGenerations; @moq/watch gains an optional announcedGenerations in the Broadcast options bag (both additive).

Test plan: just js check green. Verified in the demo: killing and immediately restarting a publisher under the same name re-attaches the viewer, and bouncing the relay no longer strands a tile offline.

fperex added 2 commits July 11, 2026 22:14
Every run loop logged any error from a stream as a warning, so a normal
unsubscribe, a publisher handover or a peer closing the session produced
scary warnings on every viewer that stopped watching.

isStreamAbort() decides on the application code the relay encodes
(WebTransportError.streamErrorCode natively, or the trailing number in
qmux's RESET_STREAM/STOP_SENDING and Connection closed messages on the
WebSocket fallback). Coded faults are a denylist, so an unknown code is
treated as routine teardown while a client-actionable fault (auth,
not-found, protocol, unroutable) still surfaces at warn.

Call sites in the lite/ietf publishers, the subscriber and the connection
run loop now pick their log level from it. The bandwidth probe streams no
longer swallow genuine faults at debug.
Two ways a tile could go permanently offline while the relay was perfectly
healthy:

A catalog subscription that ended was terminal. #runCatalog subscribed once and
wrote status=offline whenever its fetch loop ended, but the tracked signals
(enabled, name, catalogFormat, active) do not change when only the catalog
stream resets, so the effect never re-ran. Any transient reset (a slow-consumer
drop, a relay bounce, a publisher handover) stranded the tile offline until a
reload. Reopen it on a bounded backoff, but only when the loop ended in a stream
reset: a clean end (the publisher stopped) or a coded fault still reports
offline immediately.

A republish under the same name was invisible. The announce Set only tracks
presence, so when a publisher's unannounce and the new instance's announce
coalesce, the Set never observably flips and the watcher stays bound to the dead
route. Track a per-path announce generation that bumps on every (re-)announce,
so a same-name republish is a real change the watcher re-consumes.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @fperex, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

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