diff --git a/contributors/emails/rob@cocodelivery.com b/contributors/emails/rob@cocodelivery.com new file mode 100644 index 000000000000..017c9381e44d --- /dev/null +++ b/contributors/emails/rob@cocodelivery.com @@ -0,0 +1 @@ +rob-coco diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index 4752e77c0411..55e2c50fef4f 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -360,6 +360,7 @@ class BuzzAdapter(BasePlatformAdapter): # Runtime state self._poll_task: Optional[asyncio.Task] = None + self._lock_key: Optional[str] = None # channel_id -> {"chat_type", "last_ts", "seen": OrderedDict[event_id, None]} self._channel_state: Dict[str, dict] = {} self._channel_names: Dict[str, str] = {} @@ -421,6 +422,27 @@ class BuzzAdapter(BasePlatformAdapter): self._display_name = str(profiles[0].get("display_name") or "").strip() self._self_npub = hex_to_npub(self._self_pubkey) or "" + # Prevent two profiles from driving the same Buzz identity on the + # same relay (duplicate replies, split de-dupe state). Mirrors the + # IRC adapter's scoped-lock pattern. + try: + from gateway.status import acquire_scoped_lock + + lock_key = f"{self.relay_url}:{self._self_pubkey}" + if not acquire_scoped_lock("buzz", lock_key): + logger.error( + "Buzz: identity %s… on %s already in use by another profile", + self._self_pubkey[:8], + self.relay_url, + ) + self._set_fatal_error( + "lock_conflict", "Buzz identity in use by another profile", retryable=False + ) + return False + self._lock_key = lock_key + except ImportError: + self._lock_key = None # status module not available (e.g. tests) + # Map channel ids to names and pick the watch set. code, out, err = await self._run_cli(["channels", "list"]) if code != 0: @@ -463,6 +485,15 @@ class BuzzAdapter(BasePlatformAdapter): async def disconnect(self) -> None: """Stop the poll loop and drop runtime state.""" self._mark_disconnected() + lock_key = getattr(self, "_lock_key", None) + if lock_key: + try: + from gateway.status import release_scoped_lock + + release_scoped_lock("buzz", lock_key) + except Exception: + pass + self._lock_key = None if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: @@ -874,9 +905,15 @@ class BuzzAdapter(BasePlatformAdapter): return stripped.strip() async def _resolve_user_name(self, pubkey: str) -> str: - """Resolve a pubkey to a display name (cached; falls back to npub prefix).""" + """Resolve a pubkey to a display name (cached; falls back to npub prefix). + + Failures are cached too (negative caching): without it, every message + from a profile-less pubkey re-runs ``users get`` each poll sweep, + which amplifies badly when several adapter instances poll in one + process. + """ cached = self._user_names.get(pubkey) - if cached: + if cached is not None: return cached name = "" code, out, _err = await self._run_cli(["users", "get", "--pubkey", pubkey]) diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index 8ec0fba12809..890d0789663c 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -704,6 +704,43 @@ class TestBuzzAdapterLifecycle: assert adapter._poll_task is None assert adapter.is_connected is False + @pytest.mark.asyncio + async def test_disconnect_releases_scoped_lock(self, monkeypatch): + """The identity lock taken in connect() must be released on disconnect.""" + import gateway.status as gateway_status + + released = [] + monkeypatch.setattr( + gateway_status, + "release_scoped_lock", + lambda platform, key: released.append((platform, key)), + ) + adapter = _make_adapter() + adapter._lock_key = "wss://relay.example:" + SELF_PUBKEY + await adapter.disconnect() + assert released == [("buzz", "wss://relay.example:" + SELF_PUBKEY)] + assert adapter._lock_key is None + + @pytest.mark.asyncio + async def test_connect_fails_when_identity_lock_held(self, monkeypatch): + """A second profile using the same relay+pubkey must fail fast.""" + import gateway.status as gateway_status + + monkeypatch.setattr( + gateway_status, "acquire_scoped_lock", lambda platform, key: False + ) + adapter = _make_adapter() + adapter.cli_path = "/fake/buzz" + monkeypatch.setattr(_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1test") + cli = _ScriptedCli() + cli.script( + "users", "get", + [{"pubkey": SELF_PUBKEY, "display_name": "Chip"}], + ) + adapter._run_cli = cli + assert await adapter.connect() is False + assert adapter._lock_key is None + @pytest.mark.asyncio async def test_get_chat_info_uses_cached_names(self): adapter = _make_adapter() diff --git a/website/sidebars.ts b/website/sidebars.ts index 87f7a7e8d898..bc4310097f5d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -656,6 +656,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/mattermost', 'user-guide/messaging/matrix', 'user-guide/messaging/bluebubbles', + 'user-guide/messaging/buzz', 'user-guide/messaging/photon', 'user-guide/messaging/google_chat', 'user-guide/messaging/line',