Close the serial link after each MCP board operation

VersaPadLink never closes itself; get_board_status()/load_from_board()/
write_to_board() were leaving the exclusive COM port open for the rest of
the MCP server process's lifetime after a single call. That locked out
Live-Sync, the official VersaGUI, and even the MCP server's own next call
with "busy", live-observed today after a single write_to_board() call. Each
of the three now closes the link in a finally block regardless of outcome.

Also documented in AGENTS.md: this environment can run several independent
versapad_mcp_server.py processes at once, each with its own in-memory
state, which caused a write_to_board() call to silently write blank data
from a fresh process instead of the config that had just been built up on
another one (ACK still said {"ok": true}). Recommended workaround noted
there: load_local() right before write_to_board(), and read back with
load_from_board() + get_profile() afterwards instead of trusting the ACK.
This commit is contained in:
Julian Appel 2026-08-14 23:18:32 +02:00
parent 09fbd6ad96
commit 7d40fdaa60
2 changed files with 62 additions and 24 deletions

View file

@ -71,6 +71,26 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei
älteren FastMCP-Versionen). Tool-Liste: siehe README oder älteren FastMCP-Versionen). Tool-Liste: siehe README oder
`MCP_INFO_TEXT` in `desktop_viewer.py`. `MCP_INFO_TEXT` in `desktop_viewer.py`.
- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`,
`load_from_board`, `write_to_board``finally: _link.close()`). Grund:
`VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst
den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses
blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt
(am 2026-08-14 live so aufgetreten, siehe unten).
- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code-
VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`-
Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch
wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder
mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`).
Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen
korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete
aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich
eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix:
vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch
von der Platte, unabhängig davon welcher Prozess antwortet), und nach
jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen
statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den
Fehler hier live aufgedeckt.
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
nachschlagen — die Dateien sind klein genug, dass eine separate nachschlagen — die Dateien sind klein genug, dass eine separate

View file

@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict:
def get_board_status() -> dict: def get_board_status() -> dict:
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
Tkinter-Viewer den COM-Port gerade halten.""" Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung
danach wieder (siehe write_to_board() fuer den Grund) -- der Port
ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft
fuer Live-Sync/VersaGUI blockieren."""
try:
profile = _link.read_active_profile() profile = _link.read_active_profile()
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
finally:
_link.close()
# ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── # ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
@ -299,7 +305,9 @@ def load_from_board() -> dict:
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
von VersaGUI/dem Tkinter-Viewer gehalten wird.""" von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung
danach wieder (siehe write_to_board() fuer den Grund)."""
try:
raw_cfg = _link.read_full_config() raw_cfg = _link.read_full_config()
if raw_cfg is None: if raw_cfg is None:
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
@ -315,6 +323,8 @@ def load_from_board() -> dict:
names = _cfg()["profile_names"] names = _cfg()["profile_names"]
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
return list_profiles() return list_profiles()
finally:
_link.close()
@mcp.tool() @mcp.tool()
@ -322,13 +332,21 @@ def write_to_board() -> dict:
"""Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port
sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl),
was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit
"busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig
ist -- der Port ist exklusiv (siehe versapad_serial.py)."""
try:
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
if not _link.write_full_config(cfg_bytes): if not _link.write_full_config(cfg_bytes):
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
if not _link.write_macros(macro_bytes): if not _link.write_macros(macro_bytes):
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
return {"ok": True} return {"ok": True}
finally:
_link.close()
if __name__ == "__main__": if __name__ == "__main__":