diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-client/package.json | 4 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 70 | ||||
| -rw-r--r-- | packages/meshbay-common/pyproject.toml | 2 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/__init__.py | 36 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/pyproject.toml | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/__init__.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/hub.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 8 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_client_version_gate.py | 141 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_versions_agree.py | 74 | ||||
| -rw-r--r-- | packages/meshbay-node/pyproject.toml | 2 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/__init__.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transfers.py | 83 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 29 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_leaseless_reads.py | 85 |
16 files changed, 549 insertions, 15 deletions
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json index 00ed8a9..e88b2f0 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -1,7 +1,7 @@ { "name": "meshbay-client", - "version": "1.0.0", - "description": "MeshBay desktop client — the interface ships with the application, not from the hub", + "version": "0.13.0", + "description": "MeshBay desktop client \u2014 the interface ships with the application, not from the hub", "license": "AGPL-3.0-or-later", "author": "MeshBay Team <team@meshbay.org>", "homepage": "https://meshbay.org", diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 9ff0069..ab579a1 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1701,6 +1701,71 @@ function describeUnreachable(url, error) { return `Could not reach ${url}: ${detail}`; } +// ── The version gate ──────────────────────────────────────────────────────── + +/** Compare two dotted versions. -1, 0 or 1; unreadable sorts as equal. */ +function compareVersions(a, b) { + const parse = (v) => String(v || '').split('.').map((n) => parseInt(n, 10)); + const [x, y] = [parse(a), parse(b)]; + if (x.some(Number.isNaN) || y.some(Number.isNaN)) return 0; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + const d = (x[i] || 0) - (y[i] || 0); + if (d) return d < 0 ? -1 : 1; + } + return 0; +} + +/** + * Refuse to start when this build is older than the hub will talk to. + * + * The reason this exists rather than letting the handshake do it: the SPA is + * served by the hub and picks up a new client on reload, but **this + * application ships its own interface**. On the MNP 3.0 flag day an + * un-updated one can still sign in, still list groups, and then fail every + * connection with `version_too_old` — a refusal in a protocol vocabulary, + * surfacing as a node that will not talk, with nothing anyone can act on. + * + * So the question is asked once, up front, of `/v1/hub/version`, which has + * carried `client.minimum` since before there was a client to check it. + * + * **Unreachable is not too old.** A hub that is down, a laptop with no network, + * a captive portal: none of those are a reason to refuse to open the + * application, and treating them as one would make an offline start impossible + * for ever. Only a definite answer, saying in so many words that this version + * is below the minimum, stops anything. + */ +async function refuseIfTooOld() { + const base = String(config.hubBase || '').replace(/\/+$/, ''); + if (!base) return false; // First run: there is no hub to ask yet. + let info; + try { + const r = await fetch(`${base}/v1/hub/version`, + { signal: AbortSignal.timeout(10000) }); + if (!r.ok) return false; + info = await r.json(); + } catch { + return false; + } + const minimum = info && info.client && info.client.minimum; + if (!minimum) return false; + const mine = app.getVersion(); + if (compareVersions(mine, minimum) >= 0) return false; + + const { response } = await dialog.showMessageBox({ + type: 'warning', + title: 'Update required', + message: 'This version of MeshBay can no longer connect', + detail: `This application is version ${mine}, and ${base} now requires ` + + `${minimum} or later.\n\nDownload the current version and install it ` + + 'over this one — your groups, keys and settings are kept.', + buttons: ['Download the update', 'Quit'], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) await shell.openExternal(base); + return true; +} + // ── Lifecycle ─────────────────────────────────────────────────────────────── // One instance. Two would fight over the config file and the secrets blob, and @@ -1712,7 +1777,10 @@ if (!app.requestSingleInstanceLock()) { showFromTray(); }); - app.whenReady().then(() => { + app.whenReady().then(async () => { + // Before anything else is built. A window that opens and then cannot + // connect is the failure this replaces. + if (await refuseIfTooOld()) { app.quit(); return; } registerUiProtocol(); // Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge // assigns, so creating the tray after it means the Start/Stop entry is on diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml index 13ade83..6a1370d 100644 --- a/packages/meshbay-common/pyproject.toml +++ b/packages/meshbay-common/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-common" -version = "0.12.0" +version = "0.13.0" description = "MeshBay shared cryptographic primitives and protocol types" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b64a6f2..1aaa269 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -1,6 +1,6 @@ """MeshBay common — shared crypto primitives and protocol types.""" -__version__ = "0.12.0" +__version__ = "0.13.0" # 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are # additive — an 0.1 peer sends no `before` and gets the newest page, which is # what it wanted — so this is a MINOR bump, not a MAJOR one. @@ -133,5 +133,37 @@ __version__ = "0.12.0" # `index_progress` (counters only — see daemon.py `_push_index_progress`), the # admin and configuration acks, and the media-metadata replies. The index at # rest and file content on the operator's disk are unchanged. -MNP_VERSION = "2.0" +# **3.0 (2026-09-09): a transfer needs a lease, and a peer that cannot ask for +# one is refused at the handshake.** +# +# `transfer_open` / `transfer_close` / `transfer_state` carry the lease a +# download or an upload runs under; `file_req` gains an optional `tr` and +# `file_upload` gains one beside the `upload_id` already in clear. All three are +# in clear, like `index_progress` and for the same stated reason: `tr` is +# opaque, `bytes` and `chunks` are numbers, and there is no filename and no path +# anywhere in them. Putting one there to make a log line prettier is exactly the +# trade `groupbox.py` exists to refuse. +# +# **The messages are additive; the requirement is not, and that is what makes +# this MAJOR.** A 2.0 client sends no `tr`, so it is a leaseless reader — and a +# leaseless reader is either refused as soon as it opens a third file, or it is +# not refused and transfers outside every cap the operator set. An opt-in switch +# ("enforce leases only for clients that speak 3.0") leaves that branch +# reachable on every node, which is finding C6's lesson — a transport that +# accepted a bare JWT — one feature later. It was already refused once, for chat +# encryption, on 2026-09-07. +# +# Browsing is deliberately **not** leased and never will be: not the poster +# grid, not the covers, not opening a photo to look at it. That exemption is +# bounded rather than open (`transfers.LeaselessReads`, two files in flight per +# session), because an exemption with no bound is the leaseless branch under +# another name. +# +# **What it costs, stated plainly.** The SPA is served by the hub, so a browser +# picks up the new client on reload. The desktop client ships its own UI, so an +# un-updated one is locked out — which is why `GET /v1/hub/version` carries +# `client.minimum` and the client checks it *before* connecting, and says "this +# version can no longer connect" rather than showing a handshake refusal nobody +# can act on. +MNP_VERSION = "3.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 188a8aa..65b4e85 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -78,7 +78,12 @@ HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" # handshake and then discovering that every message it sends is rejected and # every message it receives is unreadable. A stated refusal is a bug report; a # chat that quietly does not work is a support case. -MNP_MIN_SUPPORTED = "2.0" +# 3.0 (2026-09-09): a transfer runs under a lease, and a 2.x peer cannot ask for +# one. Admitting it would mean either refusing it later, per file, in a way it +# has no vocabulary to understand — or serving it outside every cap the operator +# set, which makes the caps decoration. Neither is honest, so it is refused +# here, with a code and a sentence. +MNP_MIN_SUPPORTED = "3.0" ROLE_CLIENT = "client" ROLE_NODE = "node" diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml index 9e5aace..012824c 100644 --- a/packages/meshbay-hub/pyproject.toml +++ b/packages/meshbay-hub/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-hub" -version = "0.12.0" +version = "0.13.0" description = "MeshBay Hub — identity authority and group registry server" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index b713cf7..117aced 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.12.0" +__version__ = "0.13.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 8223400..a48313d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -54,8 +54,21 @@ async def hub_pubkey(): # user "update to keep using this" before it becomes "this stopped working". # Raise `minimum` only for a change a client genuinely cannot survive, and # remember store review latency makes that expensive on Android. -MIN_CLIENT_VERSION = "0.1.0" -RECOMMENDED_CLIENT_VERSION = "0.1.0" +# Raised on the MNP 3.0 flag day (2026-09-09). A client older than this speaks +# MNP 2.x, cannot ask for a transfer lease, and is refused at the node's +# handshake with `version_too_old` — a refusal in a protocol vocabulary that +# surfaces as "the node will not talk to me". The client checks this field +# before connecting and says something a person can act on instead. +# +# **This first raise does not reach the clients already installed**, and that is +# understood rather than overlooked. `package.json` had drifted to "1.0.0" while +# every other package was on 0.12.0, so an installed client announces a version +# that sorts *above* this minimum and sails through the gate — then meets the +# handshake refusal anyway. The operator is updating every client, node and hub +# by hand for this flag day, which is what makes that acceptable exactly once. +# The gate is in place for the next one, where it will work as intended. +MIN_CLIENT_VERSION = "0.13.0" +RECOMMENDED_CLIENT_VERSION = "0.13.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 23823ac..0b2fed5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -261,8 +261,12 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '2.0'; -const MNP_V_MIN = '1.0'; +const MNP_V = '3.0'; +// Raised with it on the 3.0 flag day. A node older than 3.0 cannot grant the +// lease this client opens for every download and upload, so talking to one +// would mean every transfer failing for a reason the person cannot act on. +// Refusing it at the handshake says so once, in a sentence. +const MNP_V_MIN = '3.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's // check_version): `version_too_old` means *we* are too old for it, diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py new file mode 100644 index 0000000..69ca062 --- /dev/null +++ b/packages/meshbay-hub/tests/test_client_version_gate.py @@ -0,0 +1,141 @@ +""" +The desktop client refuses to start when the hub will no longer talk to it. + +The SPA is served by the hub, so a browser picks up a new client on reload. The +desktop application **ships its own interface**, so on a flag day an un-updated +one can still sign in, still list groups, and then fail every connection with +`version_too_old` — a refusal in a protocol vocabulary, surfacing as a node that +will not talk, with nothing anyone can act on. §12.3 of +~/next/improve-downloads.md named this as the thing that had to exist before +MNP 3.0 could ship. + +`compareVersions` and `refuseIfTooOld` are lifted out of `main.js` **as text** +and executed against a modelled environment, on the rule this repo follows +elsewhere: model the environment, never the code under test. The rest of +`test_desktop_shell.py` can only read the source, because there is no npm here +to launch Electron with; these two are ordinary functions and can be run. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +MAIN = CLIENT / "src" / "main.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not MAIN.exists(), + reason="node or the desktop client sources are not available") + + +def _lift(name: str) -> str: + src = MAIN.read_text() + cut = src[src.index(name):] + return cut[:cut.index("\n}\n") + 2] + + +def _run(tmp_path, *, mine="1.1.0", hub_base="https://hub.example", + answer=None, status=200, throws=False): + """Drive the gate against one hub. + + `answer` is what `/v1/hub/version` returns; None means the field is absent + entirely, which is what an older hub sends. + """ + script = tmp_path / "gate.mjs" + script.write_text(f""" +const out = {{ dialogs: 0, opened: null }}; +const config = {{ hubBase: {json.dumps(hub_base)} }}; +const app = {{ getVersion: () => {json.dumps(mine)} }}; +const dialog = {{ + showMessageBox: async () => {{ out.dialogs += 1; return {{ response: 0 }}; }}, +}}; +const shell = {{ openExternal: async (u) => {{ out.opened = u; }} }}; +globalThis.fetch = async () => {{ + if ({json.dumps(throws)}) throw new Error('unreachable'); + return {{ ok: {json.dumps(status)} === 200, + json: async () => ({json.dumps(answer)}) }}; +}}; +""" + _lift("function compareVersions") + _lift("async function refuseIfTooOld") + """ +out.refused = await refuseIfTooOld(); +out.compare = [ + compareVersions('1.0.0', '1.1.0'), + compareVersions('1.1.0', '1.1.0'), + compareVersions('1.2.0', '1.1.0'), + compareVersions('1.10.0', '1.9.0'), + compareVersions('1.1', '1.1.0'), + compareVersions('nonsense', '1.1.0'), +]; +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +OK = {"client": {"minimum": "1.1.0", "recommended": "1.1.0"}} + + +# ── the comparison ────────────────────────────────────────────────────────── + +def test_versions_compare_by_number_and_not_by_string(tmp_path): + """`1.10.0` is newer than `1.9.0`, which string comparison gets backwards — + and that mistake locks out exactly the people who did update.""" + assert _run(tmp_path, answer=OK)["compare"] == [-1, 0, 1, 1, 0, 0] + + +# ── the gate ──────────────────────────────────────────────────────────────── + +def test_a_client_older_than_the_minimum_is_stopped(tmp_path): + out = _run(tmp_path, mine="1.0.0", answer=OK) + assert out["refused"] is True + assert out["dialogs"] == 1, "it stopped without saying why" + assert out["opened"] == "https://hub.example", ( + "the offer to download the update led nowhere") + + +def test_a_current_client_starts_normally(tmp_path): + out = _run(tmp_path, mine="1.1.0", answer=OK) + assert out["refused"] is False + assert out["dialogs"] == 0 + + +def test_a_newer_client_is_not_stopped(tmp_path): + """A development build ahead of the hub is not a reason to refuse to open + the application.""" + assert _run(tmp_path, mine="2.0.0", answer=OK)["refused"] is False + + +def test_an_unreachable_hub_is_not_too_old(tmp_path): + """A hub that is down, a laptop with no network, a captive portal. Treating + any of those as "you are out of date" would make an offline start + impossible for ever, and would do it at the worst moment.""" + assert _run(tmp_path, mine="1.0.0", throws=True)["refused"] is False + assert _run(tmp_path, mine="1.0.0", status=503, answer=OK)["refused"] is False + + +def test_a_hub_that_states_no_minimum_stops_nothing(tmp_path): + """An older hub answers without the field. Absent must read as "no opinion", + never as a refusal.""" + assert _run(tmp_path, mine="0.0.1", answer={"hub": "1.2.3"})["refused"] is False + + +def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path): + """There is nothing to ask, and the first-run screen is where the address + gets typed.""" + assert _run(tmp_path, mine="0.0.1", hub_base="", answer=OK)["refused"] is False + + +# ── where it is called ────────────────────────────────────────────────────── + +def test_the_gate_runs_before_the_window_is_built(): + """A window that opens and then cannot connect is the failure this + replaces, so the order is the whole point.""" + src = MAIN.read_text() + ready = src[src.index("app.whenReady().then("):] + ready = ready[:ready.index("createWindow();")] + assert "await refuseIfTooOld()" in ready, ( + "the version check does not run before the window is created") + assert "app.quit()" in ready diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py new file mode 100644 index 0000000..4466c93 --- /dev/null +++ b/packages/meshbay-hub/tests/test_versions_agree.py @@ -0,0 +1,74 @@ +""" +Every package in this repository carries the same version. + +They are built, deployed and updated together — hub, node, common and the +desktop client — so a version that differs is not a statement about that +package, it is a mistake nobody has noticed yet. + +**Found on 2026-09-09, on the MNP 3.0 flag day.** `meshbay-client`'s +`package.json` had drifted to `1.0.0` while every Python package was on +`0.12.0`. That was invisible until the hub started publishing a minimum client +version and the client started comparing itself against it — at which point an +installed client announcing `1.0.0` sorted *above* a minimum of `0.13.0` and +walked straight through the gate meant to stop it. A version nobody reads is +free to be wrong; the moment something compares it, it is load-bearing. +""" + +import json +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +PACKAGES = ROOT / "packages" + + +def _python_versions() -> dict[str, str]: + found = {} + for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")): + m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M) + if m: + found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1) + for init in sorted(PACKAGES.glob("*/src/*/__init__.py")): + m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M) + if m: + found[f"{init.parent.name}/__init__.py"] = m.group(1) + return found + + +def _client_version() -> str | None: + pkg = PACKAGES / "meshbay-client" / "package.json" + if not pkg.exists(): + return None + return json.loads(pkg.read_text()).get("version") + + +@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present") +def test_every_package_carries_the_same_version(): + versions = _python_versions() + assert versions, "no package versions found at all — has the layout moved?" + client = _client_version() + if client is not None: + versions["meshbay-client/package.json"] = client + distinct = sorted(set(versions.values())) + assert len(distinct) == 1, ( + "packages disagree about the version: " + + ", ".join(f"{k}={v}" for k, v in sorted(versions.items()))) + + +@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present") +def test_the_hub_will_not_refuse_the_client_it_ships_with(): + """`MIN_CLIENT_VERSION` is compared against a client's own version, so a + minimum above the version being built would lock out the very build being + released — the one failure this field can cause that nobody would think to + test for by hand.""" + from meshbay_hub.api.hub import MIN_CLIENT_VERSION + + client = _client_version() + if client is None: + pytest.skip("desktop client sources not present") + as_numbers = lambda v: [int(n) for n in v.split(".")] # noqa: E731 + assert as_numbers(MIN_CLIENT_VERSION) <= as_numbers(client), ( + f"the hub requires client {MIN_CLIENT_VERSION} but this tree builds " + f"{client}") diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index 7cd0ca9..aa13d1e 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-node" -version = "0.12.0" +version = "0.13.0" description = "MeshBay Node — local file host, streaming server, and group daemon" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py index 1bc8c9f..182ed32 100644 --- a/packages/meshbay-node/src/meshbay_node/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/__init__.py @@ -1,3 +1,3 @@ """MeshBay Node — local file host, streaming server, and group daemon.""" -__version__ = "0.12.0" +__version__ = "0.13.0" diff --git a/packages/meshbay-node/src/meshbay_node/transfers.py b/packages/meshbay-node/src/meshbay_node/transfers.py index dd5da5c..2185547 100644 --- a/packages/meshbay-node/src/meshbay_node/transfers.py +++ b/packages/meshbay-node/src/meshbay_node/transfers.py @@ -403,3 +403,86 @@ class TransferSlots: return " ".join( f"{kind[0]}={p[kind]['in_use']}/{p[kind]['cap']}" f"(q{p[kind]['queued']})" for kind in KINDS) + + +# ── Reads that carry no lease ─────────────────────────────────────────────── + +# How many distinct files one session may be reading at once without a lease. +# +# Browsing a group is never subject to a transfer slot — not the poster grid, +# not the covers, not opening a photo or a PDF to look at it. A member must be +# able to browse a group that is at capacity exactly as they browse an idle one. +# That is a requirement, and §3.4 of ~/next/improve-downloads.md satisfies it +# structurally: a transfer is what the transfers widget shows, and nothing else +# takes a slot. +# +# But "not leased" cannot mean "unbounded", or a client that simply omits `tr` +# transfers outside every cap and the caps are decoration. Two, because a viewer +# looks at *one* file — one photo, one document — and the second is there so +# that prefetching the next photo stays possible. +# +# Deliberately a count of files and not a byte budget: a RAW photo out of a +# camera is 60-80 MB and is browsing, a 40 MB archive is a download, and no +# size threshold separates them. What separates them is which function asked. +# +# What it costs, stated plainly: a client that lies — labelling a bulk download +# as a view — gets two files at a time instead of its member cap. That is the +# residual, it is bounded, it is audited, and it is the same kind of statement +# as the cap itself. **This is a fairness control among cooperating clients**, +# not a defence against a member determined to saturate a node's disk. The +# answer to that member is `member revoke`. +MAX_LEASELESS_IN_FLIGHT = 2 + +# A leaseless read has no "close" message, so it ends when the last chunk goes +# out — or, when a viewer is closed mid-file and simply stops asking, when it +# has been quiet this long. +LEASELESS_IDLE_SECS = 60 + + +class LeaselessReads: + """ + The files one session is reading without a lease, and the bound on them. + + Per session rather than per member: this is not a resource pool, it is a + ceiling on what one connection can do while claiming to be browsing. A + member with three tabs open is browsing in three tabs, which is fine. + """ + + def __init__(self, limit: int = MAX_LEASELESS_IN_FLIGHT, + idle: float = LEASELESS_IDLE_SECS) -> None: + self.limit = limit + self.idle = idle + self._seen: dict[str, float] = {} + + def admit(self, file_id: str, now: float | None = None) -> bool: + """May this session read `file_id` without a lease right now? + + True for a file it is already reading, whatever the count: refusing a + chunk halfway through a photo because the limit moved would be worse + than never having admitted it. + """ + when = time.monotonic() if now is None else now + self._expire(when) + if file_id in self._seen: + self._seen[file_id] = when + return True + if len(self._seen) >= self.limit: + return False + self._seen[file_id] = when + return True + + def finish(self, file_id: str) -> None: + """The last chunk went out; the slot is free at once rather than in a + minute.""" + self._seen.pop(file_id, None) + + def _expire(self, now: float) -> None: + # A viewer closed mid-file stops asking and says nothing. Without this + # the session would carry two dead entries and refuse every later + # preview, which is the bound turning into a bug. + for file_id, last in list(self._seen.items()): + if now - last > self.idle: + del self._seen[file_id] + + def __len__(self) -> int: + return len(self._seen) diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 9de799c..507650a 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -430,6 +430,11 @@ class WebRTCPeerSession: self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation # Uploads in progress live in the group context, not here: see # `_partial_uploads` and `uploads.py`. + # + # Leaseless reads, though, *are* this connection's: the bound is on what + # one session may do while claiming to be browsing, not a pool shared + # between them. Three tabs open is browsing in three tabs. + self._leaseless = transfers_mod.LeaselessReads() # Diagnostics only (_WEBRTC_TRACE): when the last DataChannel message # arrived, so the heartbeat can report silence duration. self._last_msg_at: float = 0.0 @@ -3689,6 +3694,25 @@ class WebRTCPeerSession: self._send({"type": "error", "detail": "File not on disk"}) return + # A real index entry, asked for without a lease: browsing, or a client + # helping itself to the whole library outside every cap. + # + # Both look identical here — which is why the bound is a small count of + # files rather than a judgement about what the read is for. Thumbnails, + # posters and cover art never reach this line: they resolve through + # `_try_serve_thumbnail` above, out of a cache the node built itself, + # and are never leased, never counted, never queued. + if not tr: + if not self._leaseless.admit(str(file_id)): + self._send({ + "type": "error", + "detail": "Too many files open at once without a transfer. " + "Download this one instead of previewing it.", + "code": "transfer_required", + "file_id": file_id, + }) + return + log.debug("dl: req file=%s chunk=%s buffered=%s", file_id[:12], chunk_index, getattr(self._channel, "bufferedAmount", "?")) @@ -3713,6 +3737,11 @@ class WebRTCPeerSession: getattr(self._channel, "bufferedAmount", "?")) if chunk_index == 0: self._audit("file_download", entry.name) + # The last chunk is the only "close" a leaseless read has. Without this + # the session carries the entry until it goes idle, and the person who + # just looked at two photos cannot look at a third for a minute. + if not tr and (chunk_index + 1) * CHUNK_SIZE >= entry.size: + self._leaseless.finish(str(file_id)) @staticmethod async def _fetch_and_cache_poster(media_cache, tmdb_client, poster_path: str | None) -> str | None: diff --git a/packages/meshbay-node/tests/test_leaseless_reads.py b/packages/meshbay-node/tests/test_leaseless_reads.py new file mode 100644 index 0000000..70fd24f --- /dev/null +++ b/packages/meshbay-node/tests/test_leaseless_reads.py @@ -0,0 +1,85 @@ +""" +Browsing is never subject to a transfer slot — and is not unbounded either. + +**Operator decision, 2026-09-08:** a member must be able to browse a group that +is at capacity exactly as they browse an idle one. Not the poster grid, not the +covers, not opening a photo or a PDF to look at it. §3.4 of +~/next/improve-downloads.md satisfies that structurally: a transfer is what the +transfers widget shows, and nothing else takes a slot. + +But "not leased" cannot mean "unbounded". With MNP 3.0 making leases +compulsory, a client that simply omits `tr` would otherwise transfer outside +every cap, and the caps would be decoration — the leaseless branch left +reachable is finding C6's lesson (a transport that accepted a bare token) one +feature later. + +So a leaseless read is bounded by a small count of *files in flight*, not by +bytes: a RAW photo out of a camera is 60–80 MB and is browsing, a 40 MB archive +is a download, and no size threshold separates them. What separates them is +which function asked. +""" + +from meshbay_node.transfers import ( + LEASELESS_IDLE_SECS, MAX_LEASELESS_IN_FLIGHT, LeaselessReads, +) + + +def test_a_viewer_looking_at_one_file_is_never_refused(): + reads = LeaselessReads() + for chunk in range(20): + assert reads.admit("photo-1", now=float(chunk)) is True + + +def test_a_second_file_is_allowed_so_prefetching_stays_possible(): + """One is what a viewer needs; two is so the photo viewer can fetch the + next one while showing this one.""" + reads = LeaselessReads() + assert reads.admit("photo-1", now=0.0) is True + assert reads.admit("photo-2", now=0.0) is True + + +def test_a_third_file_is_refused(): + reads = LeaselessReads() + reads.admit("a", now=0.0) + reads.admit("b", now=0.0) + assert reads.admit("c", now=0.0) is False + + +def test_a_file_already_being_read_is_never_cut_off(): + """Even once the limit is reached. Refusing a chunk halfway through a photo + because the count moved would be worse than never having admitted it — the + viewer would show half an image and no error anyone can act on.""" + reads = LeaselessReads() + reads.admit("a", now=0.0) + reads.admit("b", now=0.0) + assert reads.admit("c", now=0.0) is False + assert reads.admit("a", now=1.0) is True + + +def test_finishing_one_frees_it_at_once(): + """The last chunk is the only "close" a leaseless read has. Waiting for the + idle timeout instead would mean somebody who looked at two photos cannot + look at a third for a minute.""" + reads = LeaselessReads() + reads.admit("a", now=0.0) + reads.admit("b", now=0.0) + reads.finish("a") + assert reads.admit("c", now=0.0) is True + + +def test_a_viewer_closed_mid_file_does_not_hold_its_place_for_ever(): + """It stops asking and says nothing — there is no message for "I closed the + tab". Without the idle expiry the session would carry two dead entries and + refuse every later preview, which is the bound turning into a bug.""" + reads = LeaselessReads() + reads.admit("a", now=0.0) + reads.admit("b", now=0.0) + assert reads.admit("c", now=1.0) is False + assert reads.admit("c", now=LEASELESS_IDLE_SECS + 2) is True + + +def test_the_bound_is_two(): + """Stated here so that changing it is a decision rather than a typo: it is + the number §3.4.1 argues for, and the argument is about viewers, not about + tuning.""" + assert MAX_LEASELESS_IN_FLIGHT == 2 |