From eedbca3f0d47af39b4dd8812683e5a14ae4e48e6 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 16:34:12 +0200 Subject: fix: two waits with no deadline, resume positions per account, group settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Joining a group could hang.** Reported after a first attempt that never finished and a later one that worked — the shape of a network wait with no deadline, and there were two. Signaling here is non-trickle: the offer is not sent until ICE gathering says it is done. A STUN server that is slow, filtered, or resolved through a DNS that is not answering means `icegatheringstatechange` never reaches `complete`, and `connect()` never returns. Same shape as the fullscreen denial fixed yesterday: a promise that never settles leaves no error to find. Gathering now has four seconds, after which the offer goes out with what it has — host candidates are already there, which is enough on a LAN, and giving up instead would turn a slow STUN server into a refusal to connect. The second: `hub:fetch` in the desktop client had no timeout, so a host that accepts a connection and then says nothing holds the request for as long as the OS allows. `hub:probe` had one; the handler that carries signaling did not. Now thirty seconds — longer than the hub's own fifteen-second signaling wait, so it cannot abort a call that was about to succeed — and it says the hub did not answer rather than "fetch failed". **Resume positions belonged to the machine, not the account.** Stored as `mb:pos:`, so a second account signing in on the same computer was offered "resume where you left off" in a film it had never opened. Wrong on its own terms, and a small disclosure of what the other person watches, since the offer only appears for files someone has actually been through. The account is in the key now. Positions written before this are deleted rather than re-keyed: there is no record of whose they were, and guessing hands them to whoever signs in next, which is the bug. **The staggered rules in the members table.** `display: flex` on the actions `` — a flex table cell stops being a table cell, so it no longer stretches to its row and its bottom border is drawn wherever its own content ends. Measured: in a row whose other cells were `top 76, height 40`, that cell was `top 77, height 30`, its rule nine pixels above the rest. It is a table cell again, held open by a zero-width strut so the owner's row — which has no remove button — stays as tall as the others. Every cell now shares its row's top and bottom exactly, at 420px and 900px. **Members became Settings.** It was a list with three unrelated forms stacked above it, laid out with inline styles on whichever element needed them, and the group's own controls somewhere else entirely — leaving or deleting a group sat in the page header beside the title. Now one tab in sections: invitations, operator pairing, your devices on this node, leaving or deleting, and the roster last, since it is the only part with no upper bound. One consequence worth stating: the tab bar no longer waits for the node. Membership is hub-side, and gating it on a live connection would have made "leave this group" unreachable exactly when a node is down — which is when someone most wants it. Files and chat still need the node and say so. **A download button in the viewer**, beside the close button and in the same style, for both the video player and the file preview. 844 tests pass. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/tests/test_spa_ordering.py | 59 +++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) (limited to 'packages/meshbay-hub/tests/test_spa_ordering.py') diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 9d02d42..1556cb7 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -125,24 +125,67 @@ def _component(name: str) -> str: return source[start:end if end != -1 else len(source)] -def test_members_panel_renders_what_it_owns(): - panel = _component("MembersPanel") +def test_the_group_settings_panel_renders_what_it_owns(): + panel = _component("GroupSettingsPanel") assert "members.map(" in panel, "the member list is not rendered" assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered" assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered" + assert "device.mine_title" in panel, "the devices section is not rendered" -def test_the_invite_form_comes_before_the_list(): - panel = _component("MembersPanel") - assert panel.index("onSubmit=${doInvite}") < panel.index("members.map("), \ - "the invite form belongs above the member list" +def test_the_roster_comes_last(): + """ + It is the only part of this tab with no upper bound. Two hundred members + would put every form and every control below the fold, which is what the + order is for — asked for in those terms. + """ + panel = _component("GroupSettingsPanel") + listing = panel.index("members.map(") + for name, marker in (("the invite form", "onSubmit=${doInvite}"), + ("the pairing form", "onSubmit=${doPair}"), + ("the devices section", "device.mine_title"), + ("leaving and deleting", "members.danger_title")): + assert panel.index(marker) < listing, f"{name} belongs above the roster" + + +def test_leaving_a_group_lives_with_the_group_settings(): + """It used to sit in the page header beside the group's name, which is + neither where it belongs nor where anyone looked for it.""" + panel = _component("GroupSettingsPanel") + assert "group.leave_confirm" in panel and "group.delete_group_confirm" in panel + page = _component("GroupPage") + assert "group.leave_confirm" not in page, "still in the header as well" + + +def test_leaving_does_not_require_the_node_to_be_up(): + """ + Moving these into a tab that only rendered on a live connection would have + made them unreachable exactly when a node is down — which is when someone + most wants to leave. Membership is hub-side; the tab bar does not wait for + the node. + """ + page = _component("GroupPage") + tabs = page[page.index("group-tabs"):] + tabs = tabs[:tabs.index("")] + before = page[:page.index("group-tabs")] + guard = before[before.rindex("${"):] + assert "status === 'connected'" not in guard, ( + "the tab bar is gated on the connection, so a group on an offline node " + "cannot be left") + + +def test_the_node_dependent_sections_say_when_the_node_is_down(): + """The other half of that: inviting needs the node to wrap the group key, + so it must explain itself rather than silently doing nothing.""" + panel = _component("GroupSettingsPanel") + assert "connected &&" in panel and "!connected &&" in panel -def test_admin_page_does_not_borrow_the_members_panel_state(): +def test_admin_page_does_not_borrow_the_group_settings_state(): admin = _component("AdminPage") for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"): assert name not in admin, \ - f"AdminPage references {name}, which only exists in MembersPanel" + f"AdminPage references {name}, which only exists in GroupSettingsPanel" # ── Upload pipelining ─────────────────────────────────────────────────────── -- cgit v1.2.3