summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_stream_capacity_config.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_stream_capacity_config.py')
-rw-r--r--packages/meshbay-node/tests/test_stream_capacity_config.py148
1 files changed, 148 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_stream_capacity_config.py b/packages/meshbay-node/tests/test_stream_capacity_config.py
new file mode 100644
index 0000000..7c33a2f
--- /dev/null
+++ b/packages/meshbay-node/tests/test_stream_capacity_config.py
@@ -0,0 +1,148 @@
+"""
+How many people may watch at once, and who decides.
+
+Bounding the client's read-ahead to ninety seconds of film changed what a
+transcode slot is. It used to be a burst — the browser took segments as fast as
+it could append them, so a slot came back within the minute whatever the length
+of the film. Now a slot is held for as long as someone is watching, so the cap
+is a cap on simultaneous viewers, and the right number stopped being a property
+of the code: it depends on the machine the node runs on.
+
+So it belongs to the operator. `max_concurrent_streams` under [node] in
+node.toml, `MESHBAY_MAX_CONCURRENT_STREAMS` in the environment, and the
+constant in the source as the default when neither says anything.
+
+The tests below follow the value along that whole path rather than checking
+that the field parses, because every join in it has been wrong at least once:
+the daemon reads `self._config`, not `self.cfg`, and nothing about the
+attribute that does not exist fails until a video is played.
+"""
+
+import asyncio
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from meshbay_node.config import load_config
+from meshbay_node.transport.webrtc_server import (
+ MAX_CONCURRENT_TRANSCODES,
+ WebRTCPeerSession,
+ WebRTCTransport,
+)
+
+
+def _cfg(tmp_path: Path, body: str):
+ p = tmp_path / "node.toml"
+ p.write_text(textwrap.dedent(body))
+ return load_config(p)
+
+
+# ── What the operator writes ──────────────────────────────────────────────────
+
+def test_the_operator_sets_it(tmp_path):
+ cfg = _cfg(tmp_path, """
+ [node]
+ max_concurrent_streams = 3
+ """)
+ assert cfg.node.max_concurrent_streams == 3
+
+
+def test_saying_nothing_gets_the_default(tmp_path):
+ cfg = _cfg(tmp_path, """
+ [node]
+ quic_port = 19010
+ """)
+ assert cfg.node.max_concurrent_streams == MAX_CONCURRENT_TRANSCODES, (
+ "the config default and the source default disagree, so the number "
+ "depends on whether a node.toml happens to mention it")
+
+
+def test_the_environment_wins_over_the_file(tmp_path, monkeypatch):
+ monkeypatch.setenv("MESHBAY_MAX_CONCURRENT_STREAMS", "5")
+ cfg = _cfg(tmp_path, """
+ [node]
+ max_concurrent_streams = 3
+ """)
+ assert cfg.node.max_concurrent_streams == 5
+
+
+@pytest.mark.parametrize("value", ["0", "-4", '"lots"', "true"])
+def test_a_value_that_would_break_streaming_is_refused(tmp_path, value, caplog):
+ """Zero is the dangerous one.
+
+ `asyncio.Semaphore(0)` is not "no limit". It is a node where every video
+ waits forever, with nothing in the log to say why — so the operator gets
+ the default and a warning naming the setting instead.
+ """
+ cfg = _cfg(tmp_path, f"""
+ [node]
+ max_concurrent_streams = {value}
+ """)
+ assert cfg.node.max_concurrent_streams == MAX_CONCURRENT_TRANSCODES
+ assert "max_concurrent_streams" in caplog.text, (
+ "the value was silently discarded — the operator has no way to learn "
+ "their setting is not in effect")
+
+
+# ── That the number reaches the thing it limits ───────────────────────────────
+
+class _FakePC:
+ def on(self, *a, **k):
+ return lambda f: f
+
+
+def _semaphore_size(n):
+ t = WebRTCTransport(
+ sk_node=None, hub_pk_pem=b"", gek=b"\0" * 32,
+ shared_root=Path("/tmp"), index=None, max_concurrent_streams=n)
+ s = WebRTCPeerSession(_FakePC(), t._ctx, peer_id="p")
+ return s._transcode_semaphore()._value
+
+
+@pytest.mark.parametrize("n,expect", [(None, MAX_CONCURRENT_TRANSCODES), (3, 3), (20, 20)])
+def test_the_configured_number_is_the_semaphore(n, expect):
+ assert asyncio.run(_run(n)) == expect
+
+
+async def _run(n):
+ return _semaphore_size(n)
+
+
+def test_the_budget_is_shared_between_peers():
+ """One budget for the node, not one per browser.
+
+ Building it per call would cap nothing: every viewer would arrive with a
+ full allowance and the node would spawn ffmpeg without limit.
+ """
+ t = WebRTCTransport(
+ sk_node=None, hub_pk_pem=b"", gek=b"\0" * 32,
+ shared_root=Path("/tmp"), index=None, max_concurrent_streams=2)
+
+ async def go():
+ a = WebRTCPeerSession(_FakePC(), t._ctx, peer_id="a")
+ b = WebRTCPeerSession(_FakePC(), t._ctx, peer_id="b")
+ sem_a, sem_b = a._transcode_semaphore(), b._transcode_semaphore()
+ assert sem_a is sem_b, "each peer got its own budget, so there is no cap"
+ await sem_a.acquire()
+ assert sem_b._value == 1, "one peer's stream did not spend the node's budget"
+
+ asyncio.run(go())
+
+
+def test_the_daemon_passes_it( ):
+ """The join that syntax checking cannot see.
+
+ `self.cfg` parses and imports perfectly well; it raises AttributeError the
+ first time somebody plays a video, which is not where anyone would look.
+ """
+ daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node"
+ / "daemon.py").read_text()
+ i = daemon.index("WebRTCTransport(")
+ call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))]
+ assert "max_concurrent_streams=" in call, (
+ "the daemon builds the transport without the operator's setting, so "
+ "node.toml is read and then ignored")
+ assert "self._config.node.max_concurrent_streams" in call, (
+ "the daemon holds its config in _config; any other attribute is an "
+ "AttributeError deferred until someone plays a video")