aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/transfers.py354
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py249
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py6
4 files changed, 644 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 7557302..9fcbc21 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -1368,6 +1368,15 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
if webrtc is not None:
webrtc.set_capacity(
max_concurrent_streams=updated["max_concurrent_streams"])
+ if ("max_concurrent_downloads" in updated
+ or "max_concurrent_uploads" in updated):
+ webrtc = state.get("webrtc")
+ if webrtc is not None:
+ webrtc.set_capacity(
+ max_concurrent_downloads=updated.get(
+ "max_concurrent_downloads"),
+ max_concurrent_uploads=updated.get(
+ "max_concurrent_uploads"))
if "stun_servers" in updated:
webrtc = state.get("webrtc")
if webrtc and hasattr(webrtc, '_stun'):
@@ -1382,6 +1391,33 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
return {"updated": updated}
+# ── Transfers ────────────────────────────────────────────────────────────────
+
+async def list_transfers(state: dict) -> dict:
+ """Live transfer leases and queue depth.
+
+ The operator's window into "is anything actually holding a slot". When
+ somebody reports a transfer stuck at waiting, this is the only thing that
+ says whether the node ever had them in a queue — the alternative is reading
+ a log for a line that, by definition, is not being printed.
+
+ Carries no filename and no path: a lease holds neither, and this is exactly
+ where it would be tempting to add one.
+ """
+ webrtc = state.get("webrtc")
+ slots = getattr(webrtc, "_ctx", {}).get("_transfer_slots") if webrtc else None
+ if slots is None:
+ from meshbay_node.transfers import (
+ DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_PER_MEMBER, KINDS)
+ # No pool built means nothing has transferred since the daemon started,
+ # which is a real answer and not an error.
+ return {"pools": {k: {"in_use": 0, "cap": DEFAULT_MAX_CONCURRENT,
+ "per_member": DEFAULT_MAX_PER_MEMBER,
+ "queued": 0} for k in KINDS},
+ "leases": []}
+ return slots.snapshot()
+
+
# ── Applications ─────────────────────────────────────────────────────────────
async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
diff --git a/packages/meshbay-node/src/meshbay_node/transfers.py b/packages/meshbay-node/src/meshbay_node/transfers.py
new file mode 100644
index 0000000..0689ec8
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transfers.py
@@ -0,0 +1,354 @@
+"""
+Transfer slots: how many downloads and uploads a node runs at once.
+
+A download is invisible to the node today. `pipelinedDownload` sends eight
+independent `file_req` messages and reassembles the answers; nothing tells the
+node a transfer started, and nothing tells it one ended. There is nothing to
+count and so nothing to cap — which is why this exists before any cap does.
+
+The unit is the **lease**: the node's record that a peer is transferring
+something, held for the length of the transfer and released by name. Six
+properties are load-bearing, and each one is a decision:
+
+ - **`tr` is drawn by the client**, like `upload_id`. Re-opening after a
+ reconnect with the same `tr` is idempotent, so a reconnect cannot charge a
+ member twice for one transfer.
+ - **A lease is scoped to the connection**, never to the account. It dies with
+ the session, which is what makes the primary reclaim deterministic.
+ - **A lease covers a job, not a file.** A directory zip is dozens of files and
+ one lease.
+ - **Nothing is persisted.** A restart drops every session anyway; a lease that
+ outlived the process would be a slot nothing can release.
+ - **Leases are counted, not bytes.** What a slot protects is concurrency —
+ open file handles, disk seeks, the channel buffer each transfer keeps full.
+ - **Per-member first, then node-wide.** A member at their own cap queues
+ behind their own transfers and never holds a node-wide slot a second member
+ has none of. Reversed, whoever arrives first takes everything.
+
+This module is deliberately free of asyncio and of the transport: it decides,
+and the caller does the I/O. `sweep()` is called on a clock the caller owns, and
+every method returns what changed so the caller can push it. That is what makes
+the failure modes in §5 of ~/next/improve-downloads.md testable at all — a
+queue that only reveals itself through a DataChannel is a queue nobody can
+prove things about.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass, field
+
+log = logging.getLogger(__name__)
+
+DOWNLOAD = "download"
+UPLOAD = "upload"
+KINDS = (DOWNLOAD, UPLOAD)
+
+# Node-wide defaults. The operator's own values arrive from node.toml/roster.db
+# via `set_capacity` — these apply when they have said nothing.
+DEFAULT_MAX_CONCURRENT = 8
+# Per account, per group. Absent means this, not "unlimited": a group that
+# predates the setting coming back unlimited would leave the node-wide cap as
+# the only control, which is the situation this exists to end.
+DEFAULT_MAX_PER_MEMBER = 2
+
+# A grant nobody takes up is a slot nobody can use. Long enough for a client to
+# send its first chunk request, short enough that a browser that died between
+# the grant and that request does not hold a slot until the idle timeout.
+GRANT_DEADLINE_SECS = 30.0
+# Silence on a granted lease. The session dying is the primary reclaim and is
+# immediate; this only catches a peer that vanished without the connection
+# noticing, so it can afford to be generous.
+IDLE_TIMEOUT_SECS = 120.0
+# Per account, per kind. Unbounded queues are how a node runs out of memory
+# politely; past this the client keeps the rest in its own list.
+MAX_QUEUED_PER_MEMBER = 32
+
+# Why a lease ended, as it reaches the peer.
+REASON_DONE = "done"
+REASON_CANCELLED = "cancelled"
+REASON_PAUSED = "paused"
+REASON_FAILED = "failed"
+REASON_SESSION_GONE = "session_gone"
+REASON_IDLE = "idle"
+REASON_NOT_TAKEN_UP = "not_taken_up"
+
+
+@dataclass
+class Lease:
+ tr: str
+ kind: str
+ session_key: str
+ user_id: str
+ group_id: str
+ bytes: int = 0
+ chunks: int = 0
+ state: str = "queued" # "queued" | "granted"
+ created_at: float = 0.0
+ granted_at: float | None = None
+ # Set the first time anything happens under this lease. Distinguishes "the
+ # client never came back for its slot" from "the client went quiet": the
+ # first is a grant to revoke and pass on, the second a transfer to reclaim.
+ used: bool = False
+ last_seen: float = 0.0
+
+ @property
+ def member(self) -> tuple[str, str]:
+ return (self.group_id, self.user_id)
+
+
+@dataclass
+class TransferSlots:
+ """Every lease on this node, and the queues behind them."""
+
+ caps: dict[str, int] = field(
+ default_factory=lambda: {k: DEFAULT_MAX_CONCURRENT for k in KINDS})
+ per_member: dict[str, int] = field(
+ default_factory=lambda: {k: DEFAULT_MAX_PER_MEMBER for k in KINDS})
+ leases: dict[str, Lease] = field(default_factory=dict)
+ # FIFO of `tr`, per kind. Order is arrival; a member at their own cap is
+ # skipped rather than blocking the head, or one member's limit would stall
+ # the whole node.
+ queues: dict[str, list[str]] = field(
+ default_factory=lambda: {k: [] for k in KINDS})
+
+ # ── counting ────────────────────────────────────────────────────────────
+
+ def in_use(self, kind: str) -> int:
+ return sum(1 for x in self.leases.values()
+ if x.kind == kind and x.state == "granted")
+
+ def member_in_use(self, kind: str, member: tuple[str, str]) -> int:
+ return sum(1 for x in self.leases.values()
+ if x.kind == kind and x.state == "granted"
+ and x.member == member)
+
+ def queued_for(self, kind: str, member: tuple[str, str]) -> int:
+ return sum(1 for tr in self.queues[kind]
+ if (x := self.leases.get(tr)) and x.member == member)
+
+ def ahead_of(self, lease: Lease) -> int:
+ """How many are in front of this one in its queue."""
+ try:
+ return self.queues[lease.kind].index(lease.tr)
+ except ValueError:
+ return 0
+
+ def _has_room(self, kind: str, member: tuple[str, str]) -> bool:
+ # Per-member first: see the module docstring.
+ if self.member_in_use(kind, member) >= self.per_member.get(
+ kind, DEFAULT_MAX_PER_MEMBER):
+ return False
+ return self.in_use(kind) < self.caps.get(kind, DEFAULT_MAX_CONCURRENT)
+
+ # ── the operations a peer asks for ──────────────────────────────────────
+
+ def open(self, *, tr: str, kind: str, session_key: str, user_id: str,
+ group_id: str, bytes: int = 0, chunks: int = 0,
+ now: float | None = None) -> tuple[Lease | None, str]:
+ """Ask for a slot. Returns (lease, error_code); one of them is falsy.
+
+ Idempotent on `tr`: re-opening a lease this session already holds
+ returns it unchanged rather than charging for a second one. That is what
+ makes a client's reconnect safe, and it is checked before anything else
+ because every other branch below would otherwise double-count.
+ """
+ now = time.monotonic() if now is None else now
+ if kind not in KINDS:
+ return None, "bad_kind"
+ existing = self.leases.get(tr)
+ if existing is not None:
+ if existing.session_key != session_key:
+ # Someone else's lease id. Refused rather than adopted: a `tr`
+ # is drawn at random by its owner, so a collision is either a
+ # bug or a peer guessing, and neither should move a slot between
+ # connections.
+ return None, "not_your_transfer"
+ return existing, ""
+
+ member = (group_id, user_id)
+ if self.queued_for(kind, member) >= MAX_QUEUED_PER_MEMBER:
+ return None, "too_many_queued"
+
+ lease = Lease(tr=tr, kind=kind, session_key=session_key,
+ user_id=user_id, group_id=group_id,
+ bytes=int(bytes or 0), chunks=int(chunks or 0),
+ created_at=now, last_seen=now)
+ self.leases[tr] = lease
+ if self._has_room(kind, member):
+ self._grant(lease, now)
+ else:
+ self.queues[kind].append(tr)
+ return lease, ""
+
+ def _grant(self, lease: Lease, now: float) -> None:
+ lease.state = "granted"
+ lease.granted_at = now
+ lease.last_seen = now
+ lease.used = False
+
+ def touch(self, tr: str, now: float | None = None) -> bool:
+ """Something happened under this lease. False if it is not granted."""
+ lease = self.leases.get(tr)
+ if lease is None or lease.state != "granted":
+ return False
+ lease.used = True
+ lease.last_seen = time.monotonic() if now is None else now
+ return True
+
+ def close(self, tr: str, reason: str = REASON_DONE,
+ now: float | None = None) -> tuple[Lease | None, list[Lease]]:
+ """Give a slot back. Returns (the closed lease, newly granted ones).
+
+ The only place a lease is destroyed, and the only caller of the pump —
+ two functions that both released would be this repo's flow-control
+ lesson one feature later.
+ """
+ lease = self.leases.pop(tr, None)
+ if lease is None:
+ return None, []
+ if lease.tr in self.queues[lease.kind]:
+ self.queues[lease.kind].remove(lease.tr)
+ log.debug("transfer: closed %s (%s, %s)", tr[:8], lease.kind, reason)
+ return lease, self._pump(lease.kind, now)
+
+ def release_session(self, session_key: str,
+ now: float | None = None) -> tuple[list[Lease], list[Lease]]:
+ """The connection is gone; everything it held goes with it.
+
+ The deterministic reclaim, and the reason a lease is scoped to a
+ connection rather than to an account: a tab closed, a browser quit and a
+ network that dropped all arrive here, and none of them needs a timer.
+ """
+ gone = [x for x in self.leases.values() if x.session_key == session_key]
+ for lease in gone:
+ self.leases.pop(lease.tr, None)
+ if lease.tr in self.queues[lease.kind]:
+ self.queues[lease.kind].remove(lease.tr)
+ granted: list[Lease] = []
+ for kind in KINDS:
+ if any(x.kind == kind for x in gone):
+ granted.extend(self._pump(kind, now))
+ return gone, granted
+
+ def sweep(self, now: float | None = None) -> tuple[list[tuple[Lease, str]],
+ list[Lease]]:
+ """Reclaim what the session teardown cannot see.
+
+ Two different failures, deliberately told apart:
+ a grant nobody took up (the client died between asking and starting)
+ goes back to the tail of the queue; a granted transfer that has gone
+ quiet is closed, and the peer is told, so its widget can offer a resume
+ rather than sit on a lie.
+ """
+ now = time.monotonic() if now is None else now
+ ended: list[tuple[Lease, str]] = []
+ requeued = False
+ for lease in list(self.leases.values()):
+ if lease.state != "granted":
+ continue
+ if not lease.used and lease.granted_at is not None \
+ and now - lease.granted_at > GRANT_DEADLINE_SECS:
+ lease.state = "queued"
+ lease.granted_at = None
+ self.queues[lease.kind].append(lease.tr)
+ ended.append((lease, REASON_NOT_TAKEN_UP))
+ requeued = True
+ elif lease.used and now - lease.last_seen > IDLE_TIMEOUT_SECS:
+ self.leases.pop(lease.tr, None)
+ ended.append((lease, REASON_IDLE))
+ granted: list[Lease] = []
+ if ended or requeued:
+ for kind in KINDS:
+ granted.extend(self._pump(kind, now))
+ return ended, granted
+
+ # ── the queue ───────────────────────────────────────────────────────────
+
+ def _pump(self, kind: str, now: float | None = None) -> list[Lease]:
+ """Grant to whoever can start, in arrival order, skipping who cannot.
+
+ Called from exactly one place per release. Walking past a member who is
+ at their own cap is the whole reason this is a walk and not a `pop(0)`:
+ granting strictly in order lets one member's limit stall every other
+ member behind them.
+ """
+ now = time.monotonic() if now is None else now
+ granted: list[Lease] = []
+ for tr in list(self.queues[kind]):
+ lease = self.leases.get(tr)
+ if lease is None: # closed while queued
+ self.queues[kind].remove(tr)
+ continue
+ if self.in_use(kind) >= self.caps.get(kind, DEFAULT_MAX_CONCURRENT):
+ break # the node is full; stop
+ if not self._has_room(kind, lease.member):
+ continue # this member is; skip them
+ self.queues[kind].remove(tr)
+ self._grant(lease, now)
+ granted.append(lease)
+ return granted
+
+ # ── what the operator sees ──────────────────────────────────────────────
+
+ def set_caps(self, *, node: dict[str, int] | None = None,
+ per_member: dict[str, int] | None = None,
+ now: float | None = None) -> list[Lease]:
+ """Change a cap live. Raising one may start queued transfers at once.
+
+ Lowering never interrupts a transfer that is running, for the same
+ reason lowering the stream cap does not stop a film: the new value
+ governs what starts next.
+ """
+ for kind, value in (node or {}).items():
+ if kind in KINDS:
+ self.caps[kind] = max(1, int(value))
+ for kind, value in (per_member or {}).items():
+ if kind in KINDS:
+ self.per_member[kind] = max(1, int(value))
+ granted: list[Lease] = []
+ for kind in KINDS:
+ granted.extend(self._pump(kind, now))
+ return granted
+
+ def snapshot(self) -> dict:
+ """The whole picture, for `GET /api/transfers` and the summary log.
+
+ When somebody reports a transfer stuck at "waiting", this is the only
+ thing that will say whether the node ever had them in a queue.
+ """
+ return {
+ "pools": {
+ kind: {
+ "in_use": self.in_use(kind),
+ "cap": self.caps.get(kind, DEFAULT_MAX_CONCURRENT),
+ "per_member": self.per_member.get(kind,
+ DEFAULT_MAX_PER_MEMBER),
+ "queued": len(self.queues[kind]),
+ } for kind in KINDS
+ },
+ "leases": [
+ {
+ "tr": x.tr[:12],
+ "kind": x.kind,
+ "state": x.state,
+ "user_id": x.user_id,
+ "group_id": x.group_id,
+ "bytes": x.bytes,
+ "used": x.used,
+ "ahead": self.ahead_of(x) if x.state == "queued" else 0,
+ }
+ # Never a filename or a path: a lease carries none, and this is
+ # the one place it would be tempting to add one for a prettier
+ # log line.
+ for x in sorted(self.leases.values(),
+ key=lambda l: (l.kind, l.state, l.created_at))
+ ],
+ }
+
+ def summary(self) -> str:
+ p = self.snapshot()["pools"]
+ return " ".join(
+ f"{kind[0]}={p[kind]['in_use']}/{p[kind]['cap']}"
+ f"(q{p[kind]['queued']})" for kind in KINDS)
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 33f5474..164cc62 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -127,6 +127,8 @@ from meshbay_node.transport.wire import index_sync_message
from meshbay_node.indexer import GroupIndex
from meshbay_node.indexer.indexer import DirectoryIndexer
from meshbay_node import linkpreview, ops, platform
+from meshbay_node import transfers as transfers_mod
+from meshbay_node.transfers import TransferSlots
# Re-imported under its original name: every call site and existing test in
# this module still refers to it as `_probe_video`. The implementation lives
# in media_probe.py so the indexer package (imported just above) can call it
@@ -258,6 +260,11 @@ STREAM_CREDIT_TIMEOUT = 120
# How often that budget is re-examined. A viewer who left stops being
# charged for a slot within this, rather than within the timeout.
STREAM_CREDIT_POLL = 3
+# How often transfer leases are swept. Nothing depends on it being
+# prompt -- the session teardown is the reclaim that matters and is
+# immediate; this catches peers that vanished without the connection
+# noticing, so it trades latency for a timer that hardly ever runs.
+TRANSFER_SWEEP_SECS = 15
def _pack(obj: dict) -> bytes:
@@ -524,6 +531,10 @@ class WebRTCPeerSession:
self._spawn(self._do_link_preview_request(msg))
elif mtype == MNP.PING:
self._do_ping(msg)
+ elif mtype == MNP.TRANSFER_OPEN:
+ self._do_transfer_open(msg)
+ elif mtype == MNP.TRANSFER_CLOSE:
+ self._do_transfer_close(msg)
elif mtype == MNP.FILE_UPLOAD:
self._do_file_upload(msg)
elif mtype == MNP.DIR_CREATE:
@@ -3309,6 +3320,187 @@ class WebRTCPeerSession:
"total_bytes": progress.total_bytes,
}
+ # ── Transfer slots ───────────────────────────────────────────────────────
+
+ def _slots(self) -> "TransferSlots":
+ """The node's transfer pools, shared across every peer and every group.
+
+ On the transport context, not the session: it counts the node's
+ transfers, not one browser's. Built once, for the same reason the
+ transcode semaphore is — rebuilding it per call would hand every caller
+ its own budget and cap nothing at all.
+ """
+ slots = self._ctx.get("_transfer_slots")
+ if slots is None:
+ slots = TransferSlots()
+ n = self._ctx.get("max_concurrent_downloads")
+ u = self._ctx.get("max_concurrent_uploads")
+ if n:
+ slots.caps[transfers_mod.DOWNLOAD] = int(n)
+ if u:
+ slots.caps[transfers_mod.UPLOAD] = int(u)
+ self._ctx["_transfer_slots"] = slots
+ log.info("transfer: %s", slots.summary())
+ return slots
+
+ def _transfer_state_msg(self, lease, state: str, reason: str = "") -> dict:
+ slots = self._slots()
+ out = {
+ "type": MNP.TRANSFER_STATE,
+ "v": MNP_VERSION,
+ "tr": lease.tr,
+ "state": state,
+ "kind": lease.kind,
+ "used": slots.member_in_use(lease.kind, lease.member),
+ "cap": slots.per_member.get(lease.kind,
+ transfers_mod.DEFAULT_MAX_PER_MEMBER),
+ "node_used": slots.in_use(lease.kind),
+ "node_cap": slots.caps.get(lease.kind,
+ transfers_mod.DEFAULT_MAX_CONCURRENT),
+ }
+ if state == "queued":
+ out["ahead"] = slots.ahead_of(lease)
+ if reason:
+ out["reason"] = reason
+ return out
+
+ def _notify_transfer(self, lease, state: str, reason: str = "") -> None:
+ """Push a lease's state to the connection that owns it.
+
+ By session key, never by account: a lease belongs to one connection, and
+ telling a member's other device that *its* transfer was granted is how a
+ queue starts lying.
+ """
+ session = self._peer_registry().get(lease.session_key)
+ for candidate in ([session] if session else
+ self._sessions_everywhere(lease.session_key)):
+ try:
+ candidate._send(self._transfer_state_msg(lease, state, reason))
+ except Exception:
+ pass
+
+ def _sessions_everywhere(self, session_key: str) -> list["WebRTCPeerSession"]:
+ """The session with this key, whichever group it is in.
+
+ `_peer_registry` is per group (finding H1) and the pools are node-wide,
+ so a slot freed in one group can grant one in another: the peer to tell
+ is not necessarily in this session's own registry.
+ """
+ groups = self._ctx.get("groups")
+ registries = ([g.get("_peers", {}) for g in groups.values()]
+ if groups else [self._ctx.get("_peers", {})])
+ return [reg[session_key] for reg in registries if session_key in reg]
+
+ def _announce(self, granted: list, ended: list | None = None) -> None:
+ for lease, reason in (ended or []):
+ self._notify_transfer(
+ lease, "queued" if lease.state == "queued" else "closed", reason)
+ for lease in granted:
+ self._notify_transfer(lease, "granted")
+
+ def _do_transfer_open(self, msg: dict) -> None:
+ tr = str(msg.get("tr") or "")[:64]
+ kind = str(msg.get("kind") or transfers_mod.DOWNLOAD)
+ if not tr:
+ self._send({"type": "error", "detail": "Missing transfer id",
+ "code": "bad_transfer_id"})
+ return
+ slots = self._slots()
+ try:
+ nbytes = int(msg.get("bytes") or 0)
+ chunks = int(msg.get("chunks") or 0)
+ except (TypeError, ValueError):
+ self._send({"type": "error", "detail": "Invalid transfer size",
+ "code": "bad_transfer_size", "tr": tr})
+ return
+ lease, err = slots.open(
+ tr=tr, kind=kind, session_key=self._registry_key,
+ user_id=self._user_id or "", group_id=self._group_id or "",
+ bytes=nbytes, chunks=chunks)
+ if err:
+ self._send({"type": "error", "detail": err, "code": err, "tr": tr})
+ return
+ self._send(self._transfer_state_msg(lease, lease.state))
+ log.debug("transfer: open %s %s -> %s (%s)",
+ kind, tr[:8], lease.state, slots.summary())
+ self._ensure_transfer_sweeper()
+
+ def _do_transfer_close(self, msg: dict) -> None:
+ tr = str(msg.get("tr") or "")[:64]
+ reason = str(msg.get("reason") or transfers_mod.REASON_DONE)[:32]
+ slots = self._slots()
+ held = slots.leases.get(tr)
+ if held is not None and held.session_key != self._registry_key:
+ # Closing somebody else's transfer would be a denial of service one
+ # random id away.
+ self._send({"type": "error", "detail": "not_your_transfer",
+ "code": "not_your_transfer", "tr": tr})
+ return
+ lease, granted = slots.close(tr, reason)
+ if lease is not None:
+ self._send(self._transfer_state_msg(lease, "closed", reason))
+ self._announce(granted)
+
+ def _release_transfers(self) -> None:
+ """Give back everything this connection held. Called from teardown."""
+ slots = self._ctx.get("_transfer_slots")
+ if slots is None:
+ return
+ gone, granted = slots.release_session(self._registry_key)
+ if gone:
+ log.info("transfer: session gone, released %d (%s)",
+ len(gone), slots.summary())
+ self._announce(granted)
+
+ def _ensure_transfer_sweeper(self) -> None:
+ """Start the maintenance task, once, and only while it has work.
+
+ It reclaims what a session teardown cannot see — a grant nobody took up,
+ a transfer that went quiet — and logs the one line that answers "was
+ this peer ever in a queue" when somebody reports a stuck transfer. It
+ stops when the last lease goes, so an idle node runs no timer.
+ """
+ running = self._ctx.get("_transfer_sweeper")
+ if running is not None and not running.done():
+ return
+
+ ctx = self._ctx
+
+ async def _sweep_loop() -> None:
+ while True:
+ await asyncio.sleep(TRANSFER_SWEEP_SECS)
+ slots = ctx.get("_transfer_slots")
+ if slots is None or not slots.leases:
+ return
+ ended, granted = slots.sweep()
+ for lease, reason in ended:
+ log.info("transfer: reclaimed %s (%s)", lease.tr[:8], reason)
+ self._announce(granted, ended)
+ log.debug("transfer: %s", slots.summary())
+
+ # Deliberately NOT `self._spawn`, which is otherwise the only way to
+ # start a task here. `_spawn` ties a task to *this session's* set, and
+ # `shutdown_tasks` cancels those when the peer leaves — so the sweeper
+ # would die with whichever connection happened to open the first
+ # transfer, and every other peer's abandoned lease would then never be
+ # reclaimed. It belongs to the node, so the strong reference that keeps
+ # it off the garbage collector lives on the transport context; the rule
+ # `_spawn` exists for (asyncio holds only a weak reference) is satisfied
+ # by that reference, not by which set it is in.
+ task = asyncio.ensure_future(_sweep_loop())
+ ctx["_transfer_sweeper"] = task
+
+ def _finished(done: asyncio.Task) -> None:
+ if ctx.get("_transfer_sweeper") is done:
+ ctx["_transfer_sweeper"] = None
+ if not done.cancelled() and done.exception() is not None:
+ # Nothing awaits this task, so an exception here would otherwise
+ # be swallowed and idle leases would silently stop being
+ # reclaimed — the failure mode is a node that fills up over days.
+ log.error("transfer: sweeper died: %r", done.exception())
+
+ task.add_done_callback(_finished)
+
def _register_peer(self) -> None:
"""Add this connection to its group's peer set.
@@ -5507,6 +5699,12 @@ class WebRTCPeerSession:
here: cancelling the task runs the exit of its `async with sem`.
"""
self._stop_stream()
+ # Before the tasks are cancelled: a lease is not held by a task, so
+ # nothing else would give it back, and this hook is the one place every
+ # way of walking away arrives at (see the connectionstatechange handler,
+ # which calls it for a closed tab, a quit browser and a dead network
+ # alike).
+ self._release_transfers()
for task in list(self._tasks):
task.cancel()
if self._tasks:
@@ -5514,6 +5712,7 @@ class WebRTCPeerSession:
async def close(self) -> None:
self._audit("disconnect")
+ self._release_transfers()
if self._user_id:
self._unregister_peer()
await self.shutdown_tasks()
@@ -5620,7 +5819,9 @@ class WebRTCTransport:
self._stun = stun_servers or list(DEFAULT_STUN_SERVERS)
self._sessions: dict[str, WebRTCPeerSession] = {}
- def set_capacity(self, *, max_concurrent_streams: int | None = None) -> dict:
+ def set_capacity(self, *, max_concurrent_streams: int | None = None,
+ max_concurrent_downloads: int | None = None,
+ max_concurrent_uploads: int | None = None) -> dict:
"""Resize a live pool without restarting the daemon.
`ops.set_node_settings` used to do this by assigning
@@ -5660,8 +5861,54 @@ class WebRTCTransport:
log.info("stream: capacity %s -> %d (no pool built yet)",
before, n)
changed["max_concurrent_streams"] = n
+
+ pools = {}
+ if max_concurrent_downloads is not None:
+ pools[transfers_mod.DOWNLOAD] = int(max_concurrent_downloads)
+ if max_concurrent_uploads is not None:
+ pools[transfers_mod.UPLOAD] = int(max_concurrent_uploads)
+ for key, value in pools.items():
+ if value < 1:
+ raise ValueError(f"max_concurrent_{key}s must be positive")
+ if pools:
+ # Kept on the context whether or not a pool exists yet: the pools
+ # are built on the first transfer, and would otherwise come up with
+ # the defaults after an operator had already changed them.
+ for key, value in pools.items():
+ self._ctx[f"max_concurrent_{key}s"] = value
+ changed[f"max_concurrent_{key}s"] = value
+ slots = self._ctx.get("_transfer_slots")
+ if slots is not None:
+ granted = slots.set_caps(node=pools)
+ log.info("transfer: capacity now %s (%d started at once)",
+ slots.summary(), len(granted))
+ # Raising a cap can start queued transfers immediately, and the
+ # peers waiting on them have to be told: a grant nobody hears
+ # about is the "stuck at waiting" report this design exists to
+ # prevent.
+ for lease in granted:
+ self._notify_granted(lease)
return changed
+ def _notify_granted(self, lease) -> None:
+ """Tell the connection that owns `lease` it may start.
+
+ On the transport rather than the session because a cap change has no
+ session behind it — it arrives from the loopback API.
+ """
+ groups = self._ctx.get("groups")
+ registries = ([g.get("_peers", {}) for g in groups.values()]
+ if groups else [self._ctx.get("_peers", {})])
+ for reg in registries:
+ session = reg.get(lease.session_key)
+ if session is not None:
+ try:
+ session._send(
+ session._transfer_state_msg(lease, "granted"))
+ except Exception:
+ pass
+ return
+
async def handle_offer(
self, offer_sdp: str, peer_id: str,
) -> tuple[str, list[dict]]:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 2b99f20..9b9307d 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -509,4 +509,10 @@ def create_ui_app(state: dict) -> FastAPI:
async def update_node_settings(payload: dict):
return await _op(lambda: ops.set_node_settings(state, payload))
+ # ── Transfers (operator only, localhost) ───────────────────────────────
+
+ @app.get("/api/transfers")
+ async def get_transfers():
+ return await _op(lambda: ops.list_transfers(state))
+
return app