diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-19 08:54:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-19 08:54:52 +0200 |
| commit | 933daccbcfde7705413d3a10db87d910c650ed42 (patch) | |
| tree | d9b9372868f8f71c4fcb9d6e85600977fcfc0df6 /packages/meshbay-node | |
| parent | 765214c22e7956f9add1501c69e00c918f3e5f2b (diff) | |
| download | meshbay-933daccbcfde7705413d3a10db87d910c650ed42.tar.gz | |
fix(node): node.toml's transfer pools reach the transport
The daemon built the defaults dict for `Roster.node_settings` by hand and
left out `max_concurrent_downloads` and `max_concurrent_uploads`. Absent
from the dict, both resolved to None, were assigned back onto the config,
and the transport skipped them — so node.toml was parsed, validated, and
then replaced by `transfers.py`'s own 8. Invisible to anyone who left the
value at 8, which is the value the template suggests.
There were three copies of that dict and they all disagreed: node_status'
was missing those two and `max_upload_gb` besides. One builder now,
`config.node_settings_defaults`, and the resolver's key list is a class
attribute the tests hold it to, along with the writer's.
1429 passed against a baseline of 1423; the two new behavioural tests fail
with the builder reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 29 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 67 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 53 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_node_settings_defaults.py | 85 |
5 files changed, 184 insertions, 65 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index bf62639..2ae3c7c 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -366,6 +366,35 @@ def _read_roots(group: dict) -> list[RootSpec]: return specs +def node_settings_defaults(nd: NodeConfig | None = None) -> dict: + """ + The `node.toml` side of every setting the roster resolves. + + One function because there were three copies of this dict written by hand + and they disagreed. The daemon's left out both transfer pools, so on a node + whose operator had never touched the panel they resolved to None, were + written back onto the config, and the transport fell through to its own + defaults — `node.toml` parsed, validated, and then ignored. + + `test_node_settings_defaults.py` holds this against + `Roster.node_setting_keys()`, because a key missing here raises nothing + anywhere: it is a setting that stops working quietly. + """ + nd = nd or NodeConfig() + return { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "max_concurrent_downloads": nd.max_concurrent_downloads, + "max_concurrent_uploads": nd.max_concurrent_uploads, + "max_upload_gb": nd.max_upload_gb, + "transcode_incompatible_video": nd.transcode_incompatible_video, + "stun_servers": nd.stun_servers or list(DEFAULT_STUN_SERVERS), + "ice_interfaces": nd.ice_interfaces, + } + + def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """ Load config from TOML file. Supports both single [group] and diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 1525205..478faae 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -293,19 +293,10 @@ class NodeDaemon: # Apply any roster overrides to node config (panel-edited values # take precedence over node.toml defaults). - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults nd = self._config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "max_upload_gb": nd.max_upload_gb, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } - effective = await self._roster.node_settings(defaults) + effective = await self._roster.node_settings( + node_settings_defaults(nd)) for k, v in effective.items(): setattr(nd, k, v) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 9f0e6d1..b95ee2b 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -40,6 +40,7 @@ from meshbay_common.crypto import ( from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.roots import RootError, RootSet, off_disk +from meshbay_node.roster import Roster log = logging.getLogger(__name__) @@ -623,17 +624,11 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) - from meshbay_node.config import DEFAULT_STUN_SERVERS - nd = config.node if config else None - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, - "pair_ttl_hours": nd.pair_ttl_hours if nd else 24, - "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, - "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, - "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, - "stun_servers": nd.stun_servers if nd and nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces if nd else [], - } + from meshbay_node.config import node_settings_defaults + # No config (a test, an unconfigured node) falls back to NodeConfig()'s own + # values rather than to numbers repeated here, which is the copy this used + # to be: it was missing three settings and reported them as null. + defaults = node_settings_defaults(config.node if config else None) if roster: settings = await roster.node_settings(defaults) else: @@ -1288,24 +1283,33 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict: # ── Node settings ──────────────────────────────────────────────────────────── +# What `set_node_settings` accepts, and how each value is validated. A module +# constant so a test can hold its key set against `Roster.node_setting_keys()`: +# this is the third list of the same settings, and the first two had already +# drifted apart once — the reader's defaults covered fewer settings than the +# resolver answered for, which is how node.toml's transfer pools came to be +# parsed and then ignored. The kinds here are the *writer's* validation and +# deliberately not the resolver's coercions. +NODE_SETTING_WRITERS: dict[str, tuple[str, str]] = { + "invite_ttl_hours": ("int", Roster.SETTING_INVITE_TTL), + "pair_ttl_hours": ("int", Roster.SETTING_PAIR_TTL), + "device_request_ttl_minutes": ("int", Roster.SETTING_DEVICE_TTL), + "max_concurrent_streams": ("int", Roster.SETTING_MAX_STREAMS), + "max_concurrent_downloads": ("int", Roster.SETTING_MAX_DOWNLOADS), + "max_concurrent_uploads": ("int", Roster.SETTING_MAX_UPLOADS), + "max_upload_gb": ("size", Roster.SETTING_MAX_UPLOAD_GB), + "transcode_incompatible_video": ("bool", Roster.SETTING_TRANSCODE), + "stun_servers": ("stun_list", Roster.SETTING_STUN_SERVERS), + "ice_interfaces": ("list", Roster.SETTING_ICE_INTERFACES), +} + + async def get_node_settings(state: dict) -> dict: """Return current effective node settings.""" - from meshbay_node.config import DEFAULT_STUN_SERVERS + from meshbay_node.config import node_settings_defaults roster = _roster(state) config = _config(state) - nd = config.node - defaults = { - "invite_ttl_hours": nd.invite_ttl_hours, - "pair_ttl_hours": nd.pair_ttl_hours, - "device_request_ttl_minutes": nd.device_request_ttl_minutes, - "max_concurrent_streams": nd.max_concurrent_streams, - "max_concurrent_downloads": nd.max_concurrent_downloads, - "max_concurrent_uploads": nd.max_concurrent_uploads, - "max_upload_gb": nd.max_upload_gb, - "transcode_incompatible_video": nd.transcode_incompatible_video, - "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), - "ice_interfaces": nd.ice_interfaces, - } + defaults = node_settings_defaults(config.node) if roster: return await roster.node_settings(defaults) return defaults @@ -1317,18 +1321,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict: nd = config.node conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) - allowed_keys = { - "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL), - "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL), - "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), - "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), - "max_concurrent_downloads": ("int", roster.SETTING_MAX_DOWNLOADS), - "max_concurrent_uploads": ("int", roster.SETTING_MAX_UPLOADS), - "max_upload_gb": ("size", roster.SETTING_MAX_UPLOAD_GB), - "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), - "stun_servers": ("stun_list", roster.SETTING_STUN_SERVERS), - "ice_interfaces": ("list", roster.SETTING_ICE_INTERFACES), - } + allowed_keys = NODE_SETTING_WRITERS set_by = state.get("node_user_id", "") updated = {} diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 474db31..0753400 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -988,34 +988,55 @@ class Roster: SETTING_STUN_SERVERS = "stun_servers" SETTING_ICE_INTERFACES = "ice_interfaces" + # Every setting this resolver answers for, and how a stored string becomes + # a value. `node_settings` iterates these two and nothing else, and + # `config.node_settings_defaults` is built from the same names — so the + # defaults dict cannot quietly cover fewer settings than are resolved. + # + # It did. The daemon's dict was written by hand and omitted the two + # transfer pools, so on a node with no panel override they resolved to + # `defaults.get(key)` → None, were assigned back onto the config, and the + # transport skipped them: `node.toml` was parsed, validated, and then + # replaced by the transport's own defaults. A missing key is not an error + # anywhere along that path — it is a setting that stops working in silence, + # and invisible unless the operator picked a value other than the default. + NODE_SETTING_SCALARS: tuple[tuple[str, str, str], ...] = ( + ("invite_ttl_hours", SETTING_INVITE_TTL, "int"), + ("pair_ttl_hours", SETTING_PAIR_TTL, "int"), + ("device_request_ttl_minutes", SETTING_DEVICE_TTL, "int"), + ("max_concurrent_streams", SETTING_MAX_STREAMS, "int"), + ("max_concurrent_downloads", SETTING_MAX_DOWNLOADS, "int"), + ("max_concurrent_uploads", SETTING_MAX_UPLOADS, "int"), + ("transcode_incompatible_video", SETTING_TRANSCODE, "bool"), + ("max_upload_gb", SETTING_MAX_UPLOAD_GB, "float"), + ) + NODE_SETTING_LISTS: tuple[tuple[str, str], ...] = ( + ("stun_servers", SETTING_STUN_SERVERS), + ("ice_interfaces", SETTING_ICE_INTERFACES), + ) + + @classmethod + def node_setting_keys(cls) -> frozenset[str]: + """Every key `node_settings` returns — what a defaults dict must cover.""" + return frozenset([k for k, _, _ in cls.NODE_SETTING_SCALARS] + + [k for k, _ in cls.NODE_SETTING_LISTS]) + async def node_settings(self, defaults: dict) -> dict: """Current effective settings: roster override if present, else config default.""" import json as _json result = {} - for key, setting in [ - ("invite_ttl_hours", self.SETTING_INVITE_TTL), - ("pair_ttl_hours", self.SETTING_PAIR_TTL), - ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL), - ("max_concurrent_streams", self.SETTING_MAX_STREAMS), - ("max_concurrent_downloads", self.SETTING_MAX_DOWNLOADS), - ("max_concurrent_uploads", self.SETTING_MAX_UPLOADS), - ("transcode_incompatible_video", self.SETTING_TRANSCODE), - ("max_upload_gb", self.SETTING_MAX_UPLOAD_GB), - ]: + for key, setting, kind in self.NODE_SETTING_SCALARS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: - if key == "transcode_incompatible_video": + if kind == "bool": result[key] = stored != "0" - elif key == "max_upload_gb": + elif kind == "float": result[key] = float(stored) else: result[key] = int(stored) else: result[key] = defaults.get(key) - for list_key, setting in [ - ("stun_servers", self.SETTING_STUN_SERVERS), - ("ice_interfaces", self.SETTING_ICE_INTERFACES), - ]: + for list_key, setting in self.NODE_SETTING_LISTS: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: try: diff --git a/packages/meshbay-node/tests/test_node_settings_defaults.py b/packages/meshbay-node/tests/test_node_settings_defaults.py new file mode 100644 index 0000000..f5d7856 --- /dev/null +++ b/packages/meshbay-node/tests/test_node_settings_defaults.py @@ -0,0 +1,85 @@ +""" +Three lists name the same node settings, and they must agree. + +The resolver (`Roster.node_settings`) answers for a set of keys; the reader +(`config.node_settings_defaults`) supplies what node.toml says for each; the +writer (`ops.NODE_SETTING_WRITERS`) says which may be set and how each is +validated. Nothing in the code path errors when the reader covers fewer keys +than the resolver answers for: the missing one resolves to None, is written +back onto the config, and the consumer falls through to its own default. So +node.toml is parsed, validated — and ignored. + +That is what happened to `max_concurrent_downloads` and +`max_concurrent_uploads`, and it was invisible because the value it fell back +to was the same 8 the file suggests. Only an operator who set something else +would ever have seen it, and then only as pools that did not match the file. +""" +import pytest +from meshbay_node.config import NodeConfig, load_config, node_settings_defaults +from meshbay_node.ops import NODE_SETTING_WRITERS +from meshbay_node.roster import Roster + + +def test_the_reader_covers_every_setting_the_resolver_answers_for(): + assert set(node_settings_defaults()) == set(Roster.node_setting_keys()) + + +def test_the_writer_covers_every_setting_the_resolver_answers_for(): + assert set(NODE_SETTING_WRITERS) == set(Roster.node_setting_keys()) + + +def test_no_default_is_none(): + """A None here is indistinguishable from a key that is missing.""" + missing = [k for k, v in node_settings_defaults().items() if v is None] + assert not missing + + +@pytest.mark.asyncio +async def test_node_toml_survives_startup_with_no_panel_override(tmp_path): + """The whole bug, at the level an operator meets it.""" + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\n' + 'max_concurrent_downloads = 3\n' + 'max_concurrent_uploads = 2\n' + 'max_concurrent_streams = 5\n' + 'max_upload_gb = 4\n', + encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 3 + assert effective["max_concurrent_uploads"] == 2 + assert effective["max_concurrent_streams"] == 5 + assert effective["max_upload_gb"] == 4.0 + + +@pytest.mark.asyncio +async def test_a_panel_override_still_wins_over_the_file(tmp_path): + conf = tmp_path / "node.toml" + conf.write_text( + '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n' + '[node]\nmax_concurrent_downloads = 3\n', encoding="utf-8") + nd = load_config(conf).node + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_node_setting(Roster.SETTING_MAX_DOWNLOADS, "6", "op") + effective = await roster.node_settings(node_settings_defaults(nd)) + finally: + await roster.close() + + assert effective["max_concurrent_downloads"] == 6 + + +def test_an_absent_config_falls_back_to_the_dataclass_not_to_literals(): + """`node_status` used to repeat the numbers by hand, and they went stale.""" + assert node_settings_defaults(None) == node_settings_defaults(NodeConfig()) |