mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""Two installations, one MCBotPlugin object: no cache or storage row is shared.
|
|
|
|
Evidence model: the real SDK ``bind_invocation``/``InstallationBinding`` drive
|
|
two distinct installation bindings through one plugin object, and a stub Host
|
|
serves plugin storage with the flat row key LangBot uses
|
|
(``[instance, workspace, owner, key]`` — no installation dimension), so any
|
|
key the plugin leaves unscoped is visibly shared between the two bindings.
|
|
|
|
Pre-fix failure (asserted below): the plugin kept one ``bindings`` dict, one
|
|
``records`` list and one ``_track_task`` on the process-wide object and wrote
|
|
them under ``mcbot_bindings``/``mcbot_records``. ``get_bound_server`` for
|
|
installation B therefore returned installation A's server, ``count_playtime``
|
|
for B returned A's playtime records, the Host saw a single storage row for both
|
|
installations, and ``on_installation_revoked`` released nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from langbot_plugin.api.proxies.invocation import bind_invocation, invocation_capability
|
|
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
|
|
from langbot_plugin.entities.io.context import InstallationBinding
|
|
|
|
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _load_main():
|
|
spec = importlib.util.spec_from_file_location("mcbot_main", PLUGIN_ROOT / "main.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
mcbot_main = _load_main()
|
|
|
|
|
|
class FlatHost:
|
|
"""Stub Host: one flat KV namespace, exactly the Host row shape that has no
|
|
installation dimension. Also records the request contexts it was called
|
|
under, so a test can see which binding a storage row was written for."""
|
|
|
|
def __init__(self):
|
|
self.data: dict[str, bytes] = {}
|
|
self.actions: list[str] = []
|
|
|
|
async def call_action(self, action, data, *args, **kwargs):
|
|
self.actions.append(str(action))
|
|
if action == Action.GET_PLUGIN_STORAGE:
|
|
raw = self.data.get(data["key"])
|
|
return {"value_base64": base64.b64encode(raw).decode() if raw is not None else ""}
|
|
if action == Action.SET_PLUGIN_STORAGE:
|
|
self.data[data["key"]] = base64.b64decode(data["value_base64"])
|
|
return {}
|
|
if action == Action.GET_PLUGIN_STORAGE_KEYS:
|
|
return {"keys": list(self.data)}
|
|
if action == Action.DELETE_PLUGIN_STORAGE:
|
|
self.data.pop(data["key"], None)
|
|
return {}
|
|
raise AssertionError(f"unexpected Host action: {action}")
|
|
|
|
|
|
def binding(installation: str, *, workspace: str = "workspace-1"):
|
|
return InstallationBinding(
|
|
instance_uuid="instance-1",
|
|
workspace_uuid=workspace,
|
|
installation_uuid=installation,
|
|
placement_generation=1,
|
|
runtime_revision=1,
|
|
artifact_digest="a" * 64,
|
|
)
|
|
|
|
|
|
def build_plugin():
|
|
plugin = mcbot_main.MCBotPlugin()
|
|
host = FlatHost()
|
|
plugin.plugin_runtime_handler = host
|
|
return plugin, host
|
|
|
|
|
|
CONFIG = {"track_interval": 60, "ping_timeout": 10}
|
|
|
|
|
|
def test_bindings_and_records_do_not_leak_between_installations():
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
first, second = binding("installation-a"), binding("installation-b")
|
|
|
|
async def fake_ping(server_addr, timeout=None):
|
|
return {
|
|
"motd": "motd",
|
|
"version": "1.20",
|
|
"online": 1,
|
|
"max": 20,
|
|
"players": ["Steve"],
|
|
}
|
|
|
|
plugin.ping_server = fake_ping
|
|
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
await plugin.bind_server("group_1", "a.example:25565")
|
|
|
|
with bind_invocation(host, config=CONFIG, binding=second):
|
|
# Pre-fix this returned "a.example:25565" from the shared bindings dict.
|
|
assert await plugin.get_bound_server("group_1") is None
|
|
await plugin.bind_server("group_1", "b.example:25565")
|
|
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
assert await plugin.get_bound_server("group_1") == "a.example:25565"
|
|
|
|
# A sampling cycle for installation A must not appear as B's playtime.
|
|
await plugin._track_once(plugin._scope(first), 60, 10)
|
|
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
first_stats = await plugin.count_playtime("a.example:25565", 60)
|
|
with bind_invocation(host, config=CONFIG, binding=second):
|
|
# Pre-fix this returned [("Steve", 60)] from the shared records list.
|
|
assert await plugin.count_playtime("a.example:25565", 60) == []
|
|
assert first_stats == [("Steve", 60)]
|
|
|
|
# Two distinct storage rows, one per installation (pre-fix: one row
|
|
# named "mcbot_bindings" holding whichever installation wrote last).
|
|
binding_rows = {k: v for k, v in host.data.items() if k.startswith("mcbot_bindings")}
|
|
assert set(binding_rows) == {
|
|
f"mcbot_bindings:{plugin._scope(first)}",
|
|
f"mcbot_bindings:{plugin._scope(second)}",
|
|
}
|
|
assert json.loads(binding_rows[f"mcbot_bindings:{plugin._scope(first)}"].decode()) == {
|
|
"group_1": "a.example:25565"
|
|
}
|
|
assert json.loads(binding_rows[f"mcbot_bindings:{plugin._scope(second)}"].decode()) == {
|
|
"group_1": "b.example:25565"
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_records_flush_to_the_owning_installation_row():
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
first, second = binding("installation-a"), binding("installation-b")
|
|
|
|
async def fake_ping(server_addr, timeout=None):
|
|
return {"motd": "", "version": "1", "online": 1, "max": 2, "players": ["Alex"]}
|
|
|
|
plugin.ping_server = fake_ping
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
await plugin.bind_server("group_1", "a.example:25565")
|
|
|
|
# The detached tracker performs no Host call, so nothing is written yet.
|
|
await plugin._track_once(plugin._scope(first), 60, 10)
|
|
assert host.data.get(f"mcbot_records:{plugin._scope(first)}") is None
|
|
|
|
# The next invocation of the owning installation writes the samples.
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
await plugin.get_bound_server("group_1")
|
|
records = json.loads(host.data[f"mcbot_records:{plugin._scope(first)}"].decode())
|
|
assert [r["players"] for r in records] == [["Alex"]]
|
|
assert f"mcbot_records:{plugin._scope(second)}" not in host.data
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_tracker_is_registered_per_binding_and_stopped_on_revocation():
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
first, second = binding("installation-a"), binding("installation-b")
|
|
started: list[tuple[str, int, float]] = []
|
|
|
|
async def fake_loop(scope, interval, timeout):
|
|
started.append((scope, interval, timeout))
|
|
await asyncio.sleep(3600)
|
|
|
|
plugin._track_loop = fake_loop
|
|
config = {"track_interval": 45, "ping_timeout": 7}
|
|
|
|
with bind_invocation(host, config=config, binding=first):
|
|
await plugin.bind_server("group_1", "a.example:25565")
|
|
with bind_invocation(host, config=config, binding=second):
|
|
await plugin.bind_server("group_2", "b.example:25565")
|
|
|
|
await asyncio.sleep(0)
|
|
assert set(plugin._track_tasks) == {plugin._scope(first), plugin._scope(second)}
|
|
assert started == [
|
|
(plugin._scope(first), 45, 7.0),
|
|
(plugin._scope(second), 45, 7.0),
|
|
]
|
|
first_task = plugin._track_tasks[plugin._scope(first)]
|
|
|
|
await plugin.on_installation_revoked(first)
|
|
assert first_task.cancelled() or first_task.done()
|
|
assert set(plugin._track_tasks) == {plugin._scope(second)}
|
|
assert plugin._scope(first) not in plugin._states
|
|
# Release is per installation: the sibling tracker keeps running.
|
|
assert not plugin._track_tasks[plugin._scope(second)].done()
|
|
|
|
await plugin.on_installation_revoked(second)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_two_installations_write_two_storage_rows():
|
|
"""Pre-fix failure, asserted through pre-fix-compatible APIs only.
|
|
|
|
Both installations bound a server through ``bind_server`` and the stub Host
|
|
keeps the flat row namespace LangBot uses. Pre-fix the second bind
|
|
overwrote the first one's single ``mcbot_bindings`` row, so the Host held
|
|
one row and installation A's server was gone.
|
|
"""
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
first, second = binding("installation-a"), binding("installation-b")
|
|
|
|
async def sleeper(scope, interval, timeout):
|
|
await asyncio.sleep(3600)
|
|
|
|
plugin._track_loop = sleeper
|
|
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
await plugin.bind_server("group_1", "a.example:25565")
|
|
with bind_invocation(host, config=CONFIG, binding=second):
|
|
await plugin.bind_server("group_1", "b.example:25565")
|
|
|
|
assert len(host.data) == 2, host.data
|
|
assert sorted(json.loads(v.decode())["group_1"] for v in host.data.values()) == [
|
|
"a.example:25565",
|
|
"b.example:25565",
|
|
]
|
|
|
|
await plugin.on_installation_revoked(first)
|
|
await plugin.on_installation_revoked(second)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_dedicated_scope_keeps_the_legacy_storage_key():
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
with bind_invocation(host, config=CONFIG): # no installation binding
|
|
await plugin.bind_server("group_1", "srv.example:25565")
|
|
assert list(host.data) == ["mcbot_bindings"]
|
|
assert plugin._scope() == mcbot_main.LEGACY_SCOPE
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_tracker_task_holds_no_invocation_authority():
|
|
"""The tracker must not inherit a finished invocation's (dead) authority.
|
|
|
|
SDK rule verified here: a task started inside an invocation inherits the
|
|
invocation context, and ``invocation_capability`` then raises
|
|
"Plugin invocation has ended" once the invocation returns — so every Host
|
|
call from such a task fails. ``_start_detached`` runs the tracker in a fresh
|
|
context instead, which is why the samples it collects are flushed to storage
|
|
by the next invocation rather than written from the loop.
|
|
"""
|
|
async def scenario():
|
|
plugin, host = build_plugin()
|
|
first = binding("installation-a")
|
|
seen: list[object] = []
|
|
loop_gate = asyncio.Event()
|
|
|
|
async def probe():
|
|
seen.append(plugin._scope(first))
|
|
result = invocation_capability(host)
|
|
seen.append(result)
|
|
return result
|
|
|
|
async def fake_loop(scope, interval, timeout):
|
|
try:
|
|
await probe()
|
|
except BaseException as exc: # noqa: BLE001
|
|
seen.append(exc)
|
|
await loop_gate.wait()
|
|
|
|
plugin._track_loop = fake_loop
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
await plugin.bind_server("group_1", "a.example:25565")
|
|
await asyncio.sleep(0)
|
|
|
|
# Fresh context: the loop sees neither an invocation nor any capability.
|
|
assert seen == [plugin._scope(first), None]
|
|
|
|
# The pre-fix shape: a plain task created in the invocation context.
|
|
with bind_invocation(host, config=CONFIG, binding=first):
|
|
inherited = asyncio.create_task(probe())
|
|
with pytest.raises(RuntimeError, match="invocation has ended"):
|
|
await inherited
|
|
|
|
await plugin.on_installation_revoked(first)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_scope_key_is_the_full_binding():
|
|
plugin, _host = build_plugin()
|
|
same_installation_other_workspace = binding("installation-a", workspace="workspace-2")
|
|
assert plugin._scope(binding("installation-a")) != plugin._scope(same_installation_other_workspace)
|
|
assert plugin._scope(binding("installation-a")) == plugin._scope(binding("installation-a"))
|
|
assert plugin._scope(binding("installation-a")) == "instance-1:workspace-1:installation-a"
|
|
assert plugin._scope(SimpleNamespace(instance_uuid="i", workspace_uuid="w", installation_uuid="x")) == "i:w:x"
|