diff options
6 files changed, 222 insertions, 30 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index f21ec2c..1850c74 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -199,7 +199,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { } function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true, onActivity }) { + onPreview, mayUpload = true, onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -218,28 +218,25 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, const atBottomRef = useRef(true); useEffect(() => { + if (status !== 'connected') return; const transport = transportRef.current; if (!transport || !transport.connected) return; if (!loadedRef.current) { loadedRef.current = true; - // The newest page. This used to be fetchChatHistory(0, 200), which paged - // forwards from the very first message ever sent, so a busy group opened - // on its oldest screen and the recent conversation was unreachable. transport.fetchChatHistory({ limit: CHAT_PAGE }) .then(({ messages: msgs, hasMore: more }) => { - setMessages(msgs); setHasMore(more); + setMessages(msgs); + requestAnimationFrame(() => { + const l = listRef.current; + if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; } + }); }) - .catch(() => {}); + .catch(() => { loadedRef.current = false; }); } transport.onChat = (msg) => { - // A live message has no row id until it is re-read from the node, so it - // gets a local one. Keys have to be stable and unique or prepending a - // page makes Preact reuse the wrong bubbles. Computed once and reused - // below: the unread marker points at a message by id, so generating a - // second one there would point it at nothing. const id = msg.id || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; setMessages(prev => [...prev, { @@ -250,13 +247,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, timestamp: msg.timestamp || Date.now() / 1000, thread_id: msg.thread_id, }]); - // Somebody wrote while you were reading further up: mark where you were - // rather than yanking the view down. if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; return () => { transport.onChat = null; }; - }, [transportRef.current?.connected]); + }, [status]); const loadOlder = useCallback(async () => { const transport = transportRef.current; @@ -286,14 +281,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, anchorRef.current = null; return; } - // Only follow the conversation if the reader was already at the bottom. - // Scrolling unconditionally fought every attempt to read back through it. - // - // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no - // height, so aligning it to the bottom of the viewport leaves the list's - // own padding below it and the bar stops just short of the end. if (atBottomRef.current) list.scrollTop = list.scrollHeight; - }, [messages]); + }, [messages, hasMore]); // Keep the view pinned to the newest message while the reader is at the // bottom, through everything that grows the content *after* the initial @@ -357,14 +346,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`; } }; - fit(); - window.addEventListener('resize', fit); - window.addEventListener('orientationchange', fit); - window.visualViewport?.addEventListener('resize', fit); + const fitAndPin = () => { + fit(); + const l = listRef.current; + if (l && atBottomRef.current) l.scrollTop = l.scrollHeight; + }; + fitAndPin(); + window.addEventListener('resize', fitAndPin); + window.addEventListener('orientationchange', fitAndPin); + window.visualViewport?.addEventListener('resize', fitAndPin); return () => { - window.removeEventListener('resize', fit); - window.removeEventListener('orientationchange', fit); - window.visualViewport?.removeEventListener('resize', fit); + window.removeEventListener('resize', fitAndPin); + window.removeEventListener('orientationchange', fitAndPin); + window.visualViewport?.removeEventListener('resize', fitAndPin); }; }, []); @@ -470,7 +464,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, <div class="chat-start">${t('chat.start_of_history')}</div> `} ${messages.length === 0 && html` - <div class="chat-empty">${t('chat.empty')}</div> + <div class="chat-empty"> + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') + ? html`<span class="spinner"></span>${' '}${t('status.connecting_short')}` + : t('chat.empty')} + </div> `} ${messages.map((m, i) => { const isOwn = m.sender_name === username || m.sender_id === username; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index dac0f45..acfcfc8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -577,7 +577,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, </div> ${apps.map(a => tab === a.key && html` - <${a.Component} key=${a.key} ...${commonProps} /> + <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} /> `)} ${tab === 'settings' && html` diff --git a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py new file mode 100644 index 0000000..7b66c00 --- /dev/null +++ b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py @@ -0,0 +1,92 @@ +""" +Structural guards for the chat panel's scroll and fetch behaviour. + +These have regressed repeatedly. The tests below lock the invariants that +prevent the known failure modes so that a future change to chat-app.js that +breaks them fails loudly in the suite rather than silently shipping to a +browser. + +Scroll (regressed five times): + +1. The scroll-to-bottom layout effect must depend on *both* ``messages`` and + ``hasMore``. The "load older" button is controlled by ``hasMore``; when it + appears, it pushes all messages down. If the effect ignores ``hasMore``, it + misses that shift and the chat stays above the last message. + +2. In the initial fetch callback, ``setHasMore`` must be called before + ``setMessages``. If the framework does not batch the two updates, messages + would arrive (and the scroll effect would fire) before the button is in + the DOM. + +Fetch (regressed three times): + +3. The history-fetch effect must depend on ``status``, not on + ``transportRef.current?.connected``. ChatPanel remounts on group switch + (keyed by groupId), and its mount effect fires *before* GroupPage's + cleanup releases the old transport. With a ref-based dep the effect sees + the old transport still connected, fetches from the wrong group, sets + ``loadedRef = true``, and never re-fires when the correct transport + connects (dep stays ``true``). Depending on the ``status`` prop avoids + this: GroupPage sets ``status = 'connected'`` only after the new transport + is fully connected and the index is fetched. +""" +from pathlib import Path + +import pytest + +STATIC = (Path(__file__).resolve().parents[1] + / "src" / "meshbay_hub" / "static") +CHAT = STATIC / "chat-app.js" + +pytestmark = pytest.mark.skipif(not CHAT.exists(), reason="SPA sources not present") + + +def _chat_panel_source() -> str: + src = CHAT.read_text() + start = src.index("\nfunction ChatPanel(") + end = src.find("\nfunction ", start + 1) + return src[start:end if end != -1 else len(src)] + + +def test_scroll_layout_effect_depends_on_has_more(): + """Without hasMore the 'load older' button appearing in a second render + is invisible to the scroll effect.""" + src = _chat_panel_source() + assert "}, [messages, hasMore])" in src, ( + "the scroll-to-bottom useLayoutEffect must depend on [messages, hasMore] " + "-- hasMore controls the 'load older' button, which shifts all messages " + "down when it appears; without it the scroll effect misses the shift") + + +def test_initial_fetch_sets_has_more_before_messages(): + """If the framework does not batch the two setState calls, calling + setMessages first lets the scroll effect run while the 'load older' + button is not yet in the DOM. Setting hasMore first means the button + is already present by the time messages (and the scroll) arrive.""" + src = _chat_panel_source() + fetch = src[src.index("fetchChatHistory("):] + fetch = fetch[:fetch.index(".catch(")] + has_more_pos = fetch.index("setHasMore") + messages_pos = fetch.index("setMessages") + assert has_more_pos < messages_pos, ( + "in the initial fetchChatHistory callback, setHasMore must come before " + "setMessages -- otherwise a non-batched render lets the scroll effect " + "run without the 'load older' button in the DOM") + + +def test_fetch_effect_depends_on_status_not_transport_ref(): + """The history-fetch effect must gate on the status prop, not on + transportRef.current?.connected. With a ref-based dep, ChatPanel's mount + effect (which fires before GroupPage's cleanup) sees the old transport + still connected, fetches from the wrong group, and never re-fires when + the correct transport connects.""" + src = _chat_panel_source() + fetch_block = src[src.index("fetchChatHistory("):] + effect_end = fetch_block[:fetch_block.index("const loadOlder")] + assert "}, [status])" in effect_end, ( + "the chat history fetch useEffect must depend on [status], not on " + "transportRef.current?.connected -- the ref-based dep races with " + "GroupPage's cleanup and picks up the stale transport on group switch") + assert "transportRef.current?.connected" not in effect_end, ( + "transportRef.current?.connected must not appear in the fetch effect's " + "dependency array -- it causes a race on group switch") diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index e7f3116..5d9282d 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -51,6 +51,12 @@ max_concurrent_streams = 8 # Set to false only if every viewer's client is known to decode HEVC itself. transcode_incompatible_video = true +# ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale, +# libvirt, Docker) are auto-excluded — a STUN request that can't reach the +# server holds the WebRTC answer for 5 seconds. Set this to restrict +# gathering to specific interfaces (by OS adapter name). +# ice_interfaces = ["wlp0s20f3", "eth0"] + # Browser and native clients reach this node over WebRTC DataChannel via hub # signaling — no inbound port to open. QUIC is the optional direct path. @@ -133,6 +139,12 @@ class NodeConfig: # that already decode the source codec directly, since transcoding costs # real CPU per concurrent viewer, unlike the copy path. transcode_incompatible_video: bool = True + # ICE candidate gathering: which network interfaces to include or exclude. + # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are + # auto-excluded because a STUN request that can't reach the server holds + # the gather for the full 5-second timeout — measured at 6 s total on a + # machine with a Tailscale wt0 interface. + ice_interfaces: list[str] = field(default_factory=list) # include-list overrides auto @dataclass @@ -290,6 +302,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.node.max_concurrent_streams, "max_concurrent_streams") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) + ice_if = nd.get("ice_interfaces") + if isinstance(ice_if, list): + cfg.node.ice_interfaces = [str(s) for s in ice_if] # Multi-group: [[groups]] array if "groups" in raw: diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 72813b6..266693a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -455,6 +455,11 @@ class NodeDaemon: denylist = self._denylist # 6. WebRTC transport (browser clients) + from meshbay_node.transport.ice_filter import install as install_ice_filter + install_ice_filter( + self._config.node.ice_interfaces or None, + ) + first = next(iter(groups_ctx.values()), None) if WEBRTC_AVAILABLE: self._webrtc = WebRTCTransport( diff --git a/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py new file mode 100644 index 0000000..a91724e --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py @@ -0,0 +1,82 @@ +""" +Filter network interfaces for ICE candidate gathering. + +aioice enumerates every interface and sends a STUN binding request from each +IPv4 address. On a machine with a Tailscale (wt0), libvirt (virbr*), or +Docker (docker*, veth*) interface, the STUN request often cannot reach the +server and burns the full 5-second asyncio.wait timeout — measured at 6 s +end-to-end on a Fedora laptop with Tailscale. + +This module patches aioice.ice.get_host_addresses so the gather runs against +the interfaces the operator actually wants. Two modes: + + ice_interfaces = ["wlp0s20f3"] → explicit include-list, nothing else + ice_interfaces = [] → auto-exclude virtual/VPN adapters + +Auto-excluded adapter name patterns: tailscale*, wt*, virbr*, docker*, +veth*, br-*, podman*, cni*. +""" + +import ipaddress +import logging +import re + +import aioice.ice + +log = logging.getLogger(__name__) + +_VIRTUAL_ADAPTER_RE = re.compile( + r"^(tailscale|wt|virbr|docker|veth|br-|podman|cni)", + re.IGNORECASE, +) + +_original_get_host_addresses = aioice.ice.get_host_addresses + + +def _is_cgnat(addr: str) -> bool: + """Tailscale uses 100.64.0.0/10 (RFC 6598 CGNAT).""" + try: + ip = ipaddress.ip_address(addr) + return ip in ipaddress.ip_network("100.64.0.0/10") + except ValueError: + return False + + +def install(include: list[str] | None = None) -> None: + """Monkey-patch aioice.ice.get_host_addresses with a filtered version.""" + import ifaddr + + include_set = set(include) if include else None + + def filtered_get_host_addresses( + use_ipv4: bool = True, use_ipv6: bool = True, + ) -> list[str]: + addresses: list[str] = [] + for adapter in ifaddr.get_adapters(): + if include_set is not None: + if adapter.name not in include_set: + continue + elif _VIRTUAL_ADAPTER_RE.match(adapter.name): + continue + + for ip in adapter.ips: + if isinstance(ip.ip, str) and use_ipv4 and ip.ip != "127.0.0.1": + if include_set is None and _is_cgnat(ip.ip): + continue + addresses.append(ip.ip) + elif use_ipv6 and ip.ip[0] != "::1" and ip.ip[2] == 0: + addresses.append(ip.ip[0]) + return addresses + + aioice.ice.get_host_addresses = filtered_get_host_addresses + + if include_set: + log.info("ICE interfaces (explicit): %s", ", ".join(sorted(include_set))) + else: + all_adapters = [a.name for a in ifaddr.get_adapters()] + excluded = [ + n for n in all_adapters + if _VIRTUAL_ADAPTER_RE.match(n) + ] + if excluded: + log.info("ICE auto-excluded interfaces: %s", ", ".join(excluded)) |