aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/pyproject.toml2
-rw-r--r--packages/meshbay-node/src/meshbay_node/__init__.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transfers.py83
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py29
-rw-r--r--packages/meshbay-node/tests/test_leaseless_reads.py85
5 files changed, 199 insertions, 2 deletions
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