Keep one AppState across streamable-http sessions #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "remote-appstate-singleton"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Over streamable-http, babash's whole isolation model reset on reconnect. A remote client that had already called
babash_initializewould come back tounknown chat_idfor the id it was handed.Why
app_lifespanbuilt a freshAppState(chats={})every time it was entered.mcp-session-id. Confirmed in the SDK:StreamableHTTPSessionManagerruns the low-levelServer.run()(which is what enters the lifespan) for every new session, and once per request in stateless mode._handle_stateful_requestonly reuses a transport when the client sends back a knownmcp-session-id; a new/absent one spins a fresh server run → fresh lifespan → freshAppState.So every reconnecting client got an empty
chatsdict, and everychat_idit held was suddenly unknown.This is in production. babash-remote runs
babash_mcp --transport streamable-http:Fix
AppStateis now a process-wide singleton (_ProcessApp) — built once, lazily, kept for the life of the process.It is deliberately not torn down when a session's lifespan exits. Reconnects are usually sequential — the old session closes before the new one opens — so cleanup-on-session-exit would kill every shell in that gap and lose exactly the state the reconnect was meant to keep.
Teardown is registered with
atexit, covering a normal shutdown. A hard kill (SIGKILL, container SIGTERM) leaks ptys — but the next start reaps them, because spawning a shell already scans for and closes orphaned babash screens.No
global: the singleton lives on a class attribute.What this does not fix
The local "two babash processes" situation is unrelated: Claude Desktop launches a separate babash process for the local UI and for the remote-tools-device bridge. Those are two OS processes with separate memory — a within-process singleton cannot share state across them. That's a Claude Desktop launch behavior, not a babash bug.
Verification
test_process_app: entering the lifespan twice returns the sameAppState(a per-session AppState fails this).streamable_http_manager.pythatServer.run— and thus the lifespan — is entered per session / per stateless request.mypy --strictandruffclean.Also carries a small
INSTRUCTIONSclarification: a file behind a URL is better read by a PDF-capable fetch tool than pulled through babash; babash is for files with no URL.🤖 Generated with Claude Code
Over streamable-http, babash's whole isolation model reset on reconnect: a remote client that had already called babash_initialize came back to "unknown chat_id" for the id it was handed. Cause: app_lifespan built a fresh AppState(chats={}) every time it was entered. Over stdio the lifespan is entered once per process, so that was fine by accident. Over streamable-http the SDK enters it once per mcp-session-id — StreamableHTTPSessionManager runs the low-level Server.run(), which enters the lifespan, for every new session (and once per request when stateless). So every reconnect minted an empty chats dict and every prior chat_id was suddenly unknown. babash-remote runs exactly this way (babash_mcp --transport streamable-http), so it hit this in production. The registry of chats has to be process-global, not per-connection, because babash keys isolation on the model-supplied chat_id, not on the transport's session id (#41836 — Claude sends no conversation id over the wire). So the fix is to build AppState the same way SETTINGS is already built: once, at import, as a module constant. The transport can re-enter the lifespan as often as it likes; there is nothing there to reset. The most canonical fix would be client-side — a client that preserved its mcp-session-id across reconnects would reuse the transport and need no server change — but babash cannot control the remote client and has already decided not to trust transport identity, so process- global state is the right server-side answer. Teardown is on process exit, not connection close: a reconnect that briefly leaves zero live sessions must not take the shells with it. atexit covers a clean shutdown; a hard kill leaks ptys, which the next start reaps (spawning a shell already scans for and closes orphaned babash screens). Also carries a docs clarification in INSTRUCTIONS: a file behind a URL is better read by a PDF-capable fetch tool than pulled through babash; babash is for files with no URL (on an SSH host, or only on disk). Verified: re-entering the lifespan returns the same AppState (test_process_app); confirmed against the SDK that Server.run enters the lifespan per session. mypy --strict and ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>