Surface inlet connection status and re-resolve lost streams - #40
Merged
Conversation
An inlet that never resolves its stream, one that resolved the wrong stream, and one attached to a stream that is simply quiet all look the same from outside: a unit that emits nothing. Log a line on each transition instead. - On connect, name the resolved stream, its host, channel count and rate. - On the first failed resolve, describe what was being looked for. - On a pull that raises, warn once that the stream may have been lost. Each is logged once per connection since all three conditions are polled every tick, and the flags reset on reconnect. A pull that raises after shutdown() or a settings-change reset dropped the live inlet is orderly teardown, so that case stays quiet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
liblsl's built-in recovery re-acquires a lost stream by source_id alone, which is wrong for us in two ways. It ignores the `host` criterion, so a same-named stream on a different machine can be silently substituted. And it retries indefinitely without ever raising, so an upstream that returns with a different shape -- and therefore a different source_id -- is never found and the inlet stalls forever, looking exactly like a quiet stream. Pass recover=False and reconnect here instead. This costs no data: a dropped connection discards the outlet's per-consumer queue, so no backlog survives for either mechanism to reclaim, and liblsl checks the lost state before draining, leaving the inlet's own buffer unreachable regardless. - LostError is flagged on the pull worker and acted on in _produce, which calls _request_reset(). _reset_state already drops the connection and rebuilds the resolver, so that is the whole teardown. - Within reconnect_grace_dur, only the original source_id is accepted; afterwards any stream matching the criteria is, so a restarted and reconfigured upstream is picked up. The host filter applies throughout. - uid, source_id and hostname are stamped onto every message. uid is the outlet *instance*, so it distinguishes a dropped socket from a restart; source_id is deliberately stable across restarts and cannot. - distinct_key_per_connection (off by default) folds a uid-change counter into the key. Downstream hashes on (shape, rate, key), so a restarted upstream keeping its name and shape otherwise carries stale filter state and partial windows across the gap. Off by default because key names NWB containers and routes pipelines. - Warn when a reconnect lands on a different shape or host, naming the delta; bound the previously unbounded info() fetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits. The first makes inlet status transitions visible; the second acts on the failure the first one exposed.
1. Log inlet connection status transitions
An inlet that never resolves, one that resolved the wrong stream, and one attached to a stream that is simply quiet are indistinguishable from outside — all three are a unit that emits nothing. Three log lines, one per transition (connected / first failed resolve / pull raised), each emitted once per connection since all three conditions are polled every tick.
2. Re-resolve lost streams instead of relying on liblsl
liblsl's built-in recovery re-acquires by
source_idalone. That is wrong for us twice over:hostcriterion._try_connecthonorsinfo.host; liblsl's recovery does not, and its docs are explicit that a stream may return "even after the serving app, device or computer crashes". A same-named stream on another machine can be silently substituted.source_id, so it is never re-acquired and the inlet stalls forever — looking exactly like a quiet stream.So:
recover=False, and reconnect here.This costs no data
Measured, not assumed:
samples_available()= 51,pull_chunkraisesLostError, 0 drainedThe outlet fans out to a per-consumer queue that exists only while that consumer is attached, and there is no resume-by-offset in the LSL protocol — the 11 samples pushed while nothing was connected were lost too. No backlog survives for either mechanism to reclaim. And liblsl checks the lost state before draining, so the inlet's own buffer is unreachable once the stream is declared lost — a "drain before teardown" salvage path is not possible. Loss is already unavoidable today; this only makes it visible.
If the blip is short enough that TCP survives, nothing raises and no reconnect happens — that path is unchanged.
Changes
LostErroris flagged on the pull worker and acted on in_produce, which calls_request_reset()._reset_statealready drops the connection and rebuilds theContinuousResolver, so that is the entire teardown. The old inlet is released rather than closed, so an in-flight pull holding it via its snapshot stays valid.reconnect_grace_dur(5 s) only the originalsource_idis accepted; afterwards any criteria match is. Thehostfilter applies throughout.uid,source_id,hostnamestamped onto every message viaattrs.uididentifies the outlet instance and so distinguishes a dropped socket from a restart;source_idis deliberately stable across restarts and cannot.distinct_key_per_connection(default off) folds a uid-change counter intokey. Downstream hashes on(shape, rate, key)— verified inwindow.py:254,downsample.py:54— so a restarted upstream that keeps its name and shape otherwise carries stale filter state and half-full windows across the gap. Off by default becausekeynames NWB containers (writer.py:257) and routes pipelines.info()fetch; a connect that opens but cannot fetch its description is now retried rather than hanging.Tests
43 pass. The end-to-end test drives a real outlet, kills it, and brings up a replacement with a different
source_idand channel count — the case liblsl would never re-acquire. Confirmed meaningful: withrecover=Trueit fails at"a vanished outlet never surfaced as a lost stream".🤖 Generated with Claude Code