Keep one AppState across streamable-http sessions #1

Merged
airaneel merged 1 commit from remote-appstate-singleton into main 2026-07-18 02:21:34 +03:00
Owner

Over streamable-http, babash's whole isolation model reset on reconnect. A remote client that had already called babash_initialize would come back to unknown chat_id for the id it was handed.

Why

app_lifespan built a fresh AppState(chats={}) every time it was entered.

  • stdio — the lifespan is entered once per process. Fine.
  • streamable-http — it is entered once per mcp-session-id. Confirmed in the SDK: StreamableHTTPSessionManager runs the low-level Server.run() (which is what enters the lifespan) for every new session, and once per request in stateless mode. _handle_stateful_request only reuses a transport when the client sends back a known mcp-session-id; a new/absent one spins a fresh server run → fresh lifespan → fresh AppState.

So every reconnecting client got an empty chats dict, and every chat_id it held was suddenly unknown.

This is in production. babash-remote runs babash_mcp --transport streamable-http:

mcp_baabsh-babash-1: "babash_mcp --transport streamable-http"

Fix

AppState is 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.

An earlier draft (from a parallel session) refcounted sessions and cleaned up when the count hit zero. That only survives overlapping reconnects; for the sequential case that actually happens, the count still hits zero in the gap and wipes everything. Replaced with the create-once model.

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 same AppState (a per-session AppState fails this).
  • Confirmed against streamable_http_manager.py that Server.run — and thus the lifespan — is entered per session / per stateless request.
  • 96 tests, mypy --strict and ruff clean.

Also carries a small INSTRUCTIONS clarification: 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` would come back to `unknown chat_id` for the id it was handed. ## Why `app_lifespan` built a fresh `AppState(chats={})` every time it was entered. - **stdio** — the lifespan is entered once per process. Fine. - **streamable-http** — it is entered once per `mcp-session-id`. Confirmed in the SDK: `StreamableHTTPSessionManager` runs the low-level `Server.run()` (which is what enters the lifespan) for **every new session**, and once **per request** in stateless mode. `_handle_stateful_request` only reuses a transport when the client sends back a known `mcp-session-id`; a new/absent one spins a fresh server run → fresh lifespan → fresh `AppState`. So every reconnecting client got an empty `chats` dict, and every `chat_id` it held was suddenly unknown. **This is in production.** babash-remote runs `babash_mcp --transport streamable-http`: ``` mcp_baabsh-babash-1: "babash_mcp --transport streamable-http" ``` ## Fix `AppState` is 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. > An earlier draft (from a parallel session) refcounted sessions and cleaned up when the count hit zero. That only survives *overlapping* reconnects; for the sequential case that actually happens, the count still hits zero in the gap and wipes everything. Replaced with the create-once model. 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 same `AppState` (a per-session AppState fails this). - Confirmed against `streamable_http_manager.py` that `Server.run` — and thus the lifespan — is entered per session / per stateless request. - 96 tests, `mypy --strict` and `ruff` clean. Also carries a small `INSTRUCTIONS` clarification: 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](https://claude.com/claude-code)
fix: keep one AppState across streamable-http sessions, not one per session
Some checks failed
Python Test / test (3.11) (pull_request) Has been cancelled
Python Test / test (3.12) (pull_request) Has been cancelled
Python Test / test (3.13) (pull_request) Has been cancelled
Mypy strict / typecheck (3.11) (pull_request) Has been cancelled
Mypy strict / typecheck (3.12) (pull_request) Has been cancelled
Mypy strict / typecheck (3.13) (pull_request) Has been cancelled
28cdc09bf1
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>
airaneel deleted branch remote-appstate-singleton 2026-07-18 02:21:35 +03:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
airaneel/babash!1
No description provided.