Skip to content

Commit 2da2f50

Browse files
committed
fix(gateway): normalize transport-closed error matching
rmcp Display strings vary (Transport closed, channel closed, unexpected end of stream). Tokenize instead of listing one more substring. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 54d0de2 commit 2da2f50

3 files changed

Lines changed: 159 additions & 58 deletions

File tree

crates/mcpmux-gateway/src/pool/routing.rs

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -945,27 +945,50 @@ impl RoutingService {
945945

946946
/// Check if an error string indicates authentication is needed
947947
fn is_auth_error(error_str: &str) -> bool {
948+
let lower = error_str.to_ascii_lowercase();
948949
let indicators = [
949950
"401",
950951
"unauthorized",
951952
"invalid_token",
952953
"token expired",
953954
"access token",
954955
];
955-
indicators.iter().any(|s| error_str.contains(s))
956+
indicators.iter().any(|s| lower.contains(s))
956957
}
957958

958959
/// Check if an error string indicates the backend transport died.
959960
///
960-
/// Live rmcp stdio kill (Aug 20) stringified as `MCP call failed: Transport closed`.
961+
/// Matches after case/punctuation/camelCase normalize so rmcp Display
962+
/// variants (`Transport closed`, `connection closed: …`, `Transport channel
963+
/// closed`, `unexpected end of stream`) and MCP `-32000` all hit the same
964+
/// path. Auth is classified first by [`reconnect_path_for_error`].
961965
fn is_transport_closed_error(error_str: &str) -> bool {
962-
let indicators = [
963-
"connection closed",
964-
"-32000",
966+
let lower = error_str.to_ascii_lowercase();
967+
if lower.contains("-32000") {
968+
return true;
969+
}
970+
let tokens = normalize_error_tokens(error_str);
971+
const PHRASES: &[&str] = &[
965972
"transport closed",
966-
"transport channel closed",
973+
"connection closed",
974+
"channel closed",
975+
"transport terminated",
976+
"handler terminated",
977+
"unexpected end of stream",
978+
"session expired",
979+
"broken pipe",
980+
"connection reset",
981+
"keep alive timeout",
982+
"transport send error",
983+
"send message error",
967984
];
968-
indicators.iter().any(|s| error_str.contains(s))
985+
if PHRASES.iter().any(|p| tokens.contains(p)) {
986+
return true;
987+
}
988+
tokens.contains("closed")
989+
&& ["transport", "connection", "channel", "stream"]
990+
.iter()
991+
.any(|noun| tokens.contains(noun))
969992
}
970993

971994
/// Check if tool result content contains authentication error indicators.
@@ -1010,7 +1033,34 @@ fn failure_trigger_label(path: Option<ReconnectPath>) -> &'static str {
10101033
.unwrap_or("unmatched")
10111034
}
10121035

1013-
/// Classify a lowercased transport error for retry routing.
1036+
/// Collapse camelCase and punctuation so `TransportClosed` / `transport_closed`
1037+
/// / `transport-closed` all become `transport closed`.
1038+
fn normalize_error_tokens(error_str: &str) -> String {
1039+
let mut with_camel = String::with_capacity(error_str.len() + 8);
1040+
let mut prev_was_lower = false;
1041+
for c in error_str.chars() {
1042+
if c.is_ascii_uppercase() && prev_was_lower {
1043+
with_camel.push(' ');
1044+
}
1045+
with_camel.push(c);
1046+
prev_was_lower = c.is_ascii_lowercase();
1047+
}
1048+
1049+
let mut out = String::with_capacity(with_camel.len());
1050+
let mut prev_space = false;
1051+
for c in with_camel.chars() {
1052+
if c.is_ascii_alphanumeric() {
1053+
out.push(c.to_ascii_lowercase());
1054+
prev_space = false;
1055+
} else if !prev_space {
1056+
out.push(' ');
1057+
prev_space = true;
1058+
}
1059+
}
1060+
out
1061+
}
1062+
1063+
/// Classify a transport error for retry routing. Case-insensitive.
10141064
fn reconnect_path_for_error(err_str: &str) -> Option<ReconnectPath> {
10151065
if RoutingService::is_auth_error(err_str) {
10161066
Some(ReconnectPath::OAuth)
@@ -1079,11 +1129,35 @@ mod call_failure_classify_tests {
10791129

10801130
#[test]
10811131
fn rmcp_stdio_transport_closed_is_not_unmatched() {
1082-
let err = "mcp call failed: transport closed";
1132+
let err = "MCP call failed: Transport closed";
10831133
assert_eq!(reconnect_path_for_error(err), Some(ReconnectPath::Fresh));
10841134
assert!(!RoutingService::is_auth_error(err));
10851135
}
10861136

1137+
#[test]
1138+
fn transport_closed_shapes_normalize_to_fresh() {
1139+
let cases = [
1140+
"TransportClosed",
1141+
"transport_closed",
1142+
"transport-closed",
1143+
"Transport channel closed",
1144+
"connection closed: initialize response",
1145+
"unexpected end of stream",
1146+
"broken pipe",
1147+
"Session expired (HTTP 404)",
1148+
"keep alive timeout after 30000ms",
1149+
"connection reset by peer",
1150+
"the transport has been closed",
1151+
];
1152+
for err in cases {
1153+
assert_eq!(
1154+
reconnect_path_for_error(err),
1155+
Some(ReconnectPath::Fresh),
1156+
"{err}"
1157+
);
1158+
}
1159+
}
1160+
10871161
#[test]
10881162
fn auth_error_still_routes_to_oauth_reconnect() {
10891163
let err = "401 unauthorized: token expired";
@@ -1099,4 +1173,16 @@ mod call_failure_classify_tests {
10991173
assert!(!RoutingService::is_transport_closed_error(err));
11001174
assert!(!RoutingService::is_auth_error(err));
11011175
}
1176+
1177+
#[test]
1178+
fn closed_without_transport_noun_is_unmatched() {
1179+
assert!(reconnect_path_for_error("file closed").is_none());
1180+
assert!(reconnect_path_for_error("request timeout after 60s").is_none());
1181+
}
1182+
1183+
#[test]
1184+
fn auth_wins_when_error_also_looks_closed() {
1185+
let err = "401 unauthorized: connection closed";
1186+
assert_eq!(reconnect_path_for_error(err), Some(ReconnectPath::OAuth));
1187+
}
11021188
}

docs/planning/backend-connection-resilience-test.md

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,48 @@
11
# Backend Connection Resilience — Manual Test Playbook
22

3-
**For:** any agent (or human) verifying `c09e569` / `root-resolution`
3+
**For:** re-running verification on `root-resolution`
44
**Last Updated:** Aug 20, 2026
55
**Implements:** [`backend-connection-resilience.md`](./backend-connection-resilience.md)
6-
**Do not implement code.** Execute the cases below, record pass/fail, stop.
6+
**Shipped:** `c09e569` (retry + FK guard), `54d0de2` (literal `transport closed`). Matcher now also normalizes rmcp Display variants (camelCase / punctuation).
7+
8+
---
9+
10+
## Results (Aug 20, 2026)
11+
12+
| Case | Result |
13+
| ---- | ------ |
14+
| A bind FK | **PASS**`invalid_argument` + `mcpmux_list_feature_sets`; 38/74 counts unchanged |
15+
| B stdio reconnect | **PASS**`wakatime` / `wakatime_wakatime_summaries`. Kill child → invoke succeeded. Log: `trigger=transport_closed`, `reconnect_fresh completed ok=true`. Live error: `MCP call failed: Transport closed` |
16+
| C HA idle | **SKIPPED** |
17+
| D unmatched | **SKIPPED** — grant-layer "did you mean", never reached classifier |
18+
19+
Confounders: Tauri-watch rebuild wiped inbound sessions (`POST /mcp` → 404 until MCP reload). After reload, 6 unpinned roots required one `mcpmux_set_workspace_root` to *this* repo before invoke was possible. That pin was session disambiguation, not the reconnect path.
720

821
---
922

1023
## Answer first: new sessions in different roots?
1124

12-
**No. Do not open a new Cursor chat, do not switch workspace roots, do not call `mcpmux_set_workspace_root`.**
25+
**No. Do not open a new Cursor chat, do not switch workspace roots, do not call `mcpmux_set_workspace_root` to "fix" a closed connection.**
1326

14-
That was the old workaround. It opens a fresh *inbound* rmcp session and hides the outbound-pool bug. Using it here makes Case B/C inconclusive.
27+
That was the old workaround. It opens a fresh *inbound* rmcp session and hides the outbound-pool bug. Using it as the Case B/C recovery makes those cases inconclusive.
1528

1629
| Action | Use for this test? |
1730
| --- | --- |
1831
| Same Cursor chat, same workspace (`/Users/joe/Desktop/Repos/Personal/mcp-mux`) | **Yes — required** |
19-
| Reload Cursor MCP tools (once, before starting, if the gateway just rebuilt) | Yes, once |
20-
| New chat / different root / `set_workspace_root` | **No** (confounds reconnect) |
32+
| Reload Cursor MCP tools (once, if the gateway just rebuilt) | Yes, once, *before* Case B/C |
33+
| `set_workspace_root` to this repo after reload, **only if** `mcpmux_list_servers` says multiple roots are unpinned | Yes — otherwise nothing is `ready` |
34+
| `set_workspace_root` after a transport-closed invoke | **No** |
2135
| Bind a real FeatureSet onto another repo | **No** (Case A uses a fake UUID; no write) |
2236

23-
Bindings are keyed by exact `workspace_root` + `machine_id`, not by chat. This repo already has two rows for the same path (Gondor vs Rohan), both on FeatureSet `All`. Stay here.
37+
Bindings are keyed by exact `workspace_root` + `machine_id`, not by chat. Stay in this repo.
2438

2539
---
2640

2741
## Snapshot (live DB, Aug 20 2026)
2842

2943
```
3044
DB=~/Library/Application\ Support/com.mcpmux.desktop/mcpmux.db
31-
LOG=~/Library/Application\ Support/com.mcpmux.desktop/logs/mcpmux.2026-08-20.log
45+
LOG=~/Library/Application\ Support/com.mcpmux.desktop/logs/mcpmux.$(date +%Y-%m-%d).log
3246
```
3347

3448
| Fact | Value |
@@ -37,8 +51,8 @@ LOG=~/Library/Application\ Support/com.mcpmux.desktop/logs/mcpmux.2026-08-20.log
3751
| This root | `/Users/joe/Desktop/Repos/Personal/mcp-mux` |
3852
| Binding (Gondor `ec211deb…`) | `5a588b93-5ed1-4ada-b7cb-8e32a9f11058` → FeatureSet `All` (`fs_default_00000000-0000-0000-0000-000000000001`) |
3953
| Binding (Rohan `5d581ac9…`) | `8e2b36b6-eeff-4818-9259-948c1b9c3b6b``All` |
40-
| HA backend | `home-assistant-new` (HTTP, enabled, 95 features) |
41-
| Binding row count | 38 parents / 74 junction rows (re-count before Case A) |
54+
| HA backend | `home-assistant-new` (HTTP) |
55+
| Stdio used for B | `wakatime` |
4256
| Pool stats | **in-memory only**`consecutive_failures` will not appear in SQLite |
4357

4458
Re-count before Case A (numbers drift):
@@ -53,10 +67,11 @@ sqlite3 "$HOME/Library/Application Support/com.mcpmux.desktop/mcpmux.db" \
5367

5468
## Preconditions
5569

56-
1. Debug gateway is the listener on `:45818` (ancestor = `launchd`, not Cursor Helper). Health: `curl -sf http://127.0.0.1:45818/health``{"status":"ok","version":"0.5.0"}`.
57-
2. Cursor MCP `user-mcpmux` points at `http://localhost:45818/mcp`. Reload tools **once** if the binary was just rebuilt, then do not reload again.
58-
3. Only call mux via `user-mcpmux` (`mcpmux_search_tools``mcpmux_get_tool_schema` if needed → `mcpmux_invoke_tool`). No direct backend MCP servers.
59-
4. Do not run `pnpm dev:stop` / rebuild mid-test (evicts the pool and invalidates Case B/C).
70+
1. Debug gateway is the listener on `:45818`. Health: `curl -sf http://127.0.0.1:45818/health``{"status":"ok",…}`.
71+
2. Cursor MCP `user-mcpmux` points at `http://localhost:45818/mcp`. Reload tools **once** if the binary was just rebuilt, then do not reload again mid-case.
72+
3. Only call mux via `user-mcpmux`. No direct backend MCP servers.
73+
4. Do not run `pnpm dev:stop` / rebuild mid-test (evicts the pool *and* all inbound sessions).
74+
5. If `mcpmux_list_servers` reports several unpinned roots, pin `/Users/joe/Desktop/Repos/Personal/mcp-mux` once, then start Case A/B.
6075

6176
---
6277

@@ -65,47 +80,38 @@ sqlite3 "$HOME/Library/Application Support/com.mcpmux.desktop/mcpmux.db" \
6580
**Goal:** a nonexistent `feature_set_id` returns `invalid_argument`, not `FOREIGN KEY constraint failed`, and the DB does not grow.
6681

6782
1. Snapshot counts (query above). Call them `B0` / `J0`.
68-
2. Call `mcpmux_bind_current_workspace` with `feature_set_id` = `00000000-0000-0000-0000-00000000dead` (or any other unused UUID). Do **not** approve anything — the guard runs before consent.
83+
2. Call `mcpmux_bind_current_workspace` with `feature_set_id` = `00000000-0000-0000-0000-00000000dead`. Do **not** approve anything — the guard runs before consent.
6984
3. **Pass** if the tool error JSON has `"error":"invalid_argument"` and the message contains `mcpmux_list_feature_sets`.
7085
4. **Fail** if the message contains `FOREIGN KEY`, `internal_error`, or `constraint`.
7186
5. Re-count. `B0` and `J0` must be unchanged.
72-
6. Grep the log:
73-
74-
```bash
75-
rg "bind_current_workspace rejected" \
76-
"$HOME/Library/Application Support/com.mcpmux.desktop/logs/mcpmux.$(date +%Y-%m-%d).log"
77-
```
78-
79-
Expect a `warn` with the fake `feature_set_id` and this Space id.
87+
6. Grep: `rg "bind_current_workspace rejected" "$LOG"`
8088

8189
---
8290

8391
## Case B — reconnect after a killed stdio child (~5 min)
8492

8593
**Goal:** a transport-closed error triggers `reconnect_fresh` (not OAuth `reconnect_instance`) and the retry succeeds. Same session.
8694

87-
Pick a **stdio** server that is already invokable under `All`. Cheap options on this machine: `wakatime`, `markitdown`, `chrome-devtools`. Confirm first:
95+
Pick a **stdio** server that is `ready` under `All`. Cheap options: `wakatime`, `markitdown`, `chrome-devtools`.
8896

8997
```
9098
mcpmux_search_tools({ "server_id": "wakatime", "mode": "browse", "limit": 5 })
9199
```
92100

93-
If `server_readiness` is not `ready`, pick another stdio id from that browse, or `mcpmux_list_servers` and take one with `ready`.
101+
If `server_readiness` is not `ready`, pick another stdio id, or pin this root first (see Preconditions).
94102

95-
1. Invoke a cheap read-only tool once so the instance is live. Example (only if search returned it): `mcpmux_invoke_tool` `server_id=wakatime` `tool=wakatime_wakatime_summaries` with explicit `start`/`end` dates for today. Any successful call is enough.
103+
1. Invoke a cheap read-only tool once so the instance is live. `wakatime`: `mcpmux_invoke_tool` `server_id=wakatime` `tool=wakatime_wakatime_summaries` with explicit `start`/`end` for today (qualified name; bare `wakatime_summaries` can fail invoke).
96104
2. Note the time (`date -u +%H:%M:%S`).
97105
3. Kill **only the child**, not McpMux:
98106

99107
```bash
100-
# find the stdio child (example: wakatime). Do not kill target/debug/mcpmux.
101108
pgrep -lf wakatime
102109
# then: kill <child-pid>
110+
# Do not kill target/debug/mcpmux. Do not `pkill -f mcpmux`.
103111
```
104112

105-
If you cannot identify a safe child, stop and report INCONCLUSIVE. Do not `pkill -f mcpmux`.
106-
107113
4. In **this same chat**, invoke the same tool again. Do not call `set_workspace_root`.
108-
5. **Pass** if the invoke succeeds (or returns a normal tool error, not `-32000` / `Connection closed`).
114+
5. **Pass** if the invoke succeeds (or a normal tool error, not raw `Transport closed` / `-32000` to the agent).
109115
6. Grep from the timestamp in step 2:
110116

111117
```bash
@@ -115,23 +121,19 @@ rg "backend call_tool failed|reconnect attempted after call_tool failure|reconne
115121

116122
**Pass logs:**
117123

118-
- `backend call_tool failed` with `trigger=transport_closed` (or `auth` only if the error was actually 401)
124+
- `backend call_tool failed` with `trigger=transport_closed` (stdio kill has been `error=mcp call failed: transport closed`)
119125
- `reconnect attempted after call_tool failure` with `ok=true`
120-
- `reconnect_fresh completed` with `ok=true` (this is the new path; `reconnect_instance` / `Reconnecting instance ... after OAuth` must **not** be the line for this failure)
126+
- `reconnect_fresh completed` with `ok=true` (`reconnect_instance` / OAuth must **not** be the line for this failure)
121127

122-
**Fail:** invoke returns `MCP error -32000: Connection closed` to the agent, or the log shows OAuth reconnect for a stdio kill.
128+
**Fail:** invoke returns `Transport closed` / `-32000` / `Connection closed` to the agent, or `trigger=unmatched`, or OAuth reconnect for a stdio kill.
123129

124130
---
125131

126132
## Case C — original HA idle (optional, 15–20 min)
127133

128-
**Goal:** reproduce the reported HTTP shape against `home-assistant-new`.
134+
**Goal:** the reported HTTP shape against `home-assistant-new`.
129135

130-
1. Search: `mcpmux_search_tools({ "server_id": "home-assistant-new", "mode": "browse", "limit": 5 })`.
131-
2. Invoke one cheap read-only HA tool. Confirm success.
132-
3. Wait 15–20 minutes. Do not invoke that server. Do not reload MCP. Do not re-pin the workspace. Other mux tools are fine.
133-
4. Invoke the **same** HA tool again in this chat.
134-
5. **Pass / fail / log checks** are identical to Case B, except `server_id=home-assistant-new` and the error string historically was `MCP error -32000: Connection closed`.
136+
Same as B except `server_id=home-assistant-new`, wait 15–20 min with no traffic to that server, and the historical error was `MCP error -32000: Connection closed`.
135137

136138
If you cannot wait, mark Case C SKIPPED and rely on B.
137139

@@ -142,7 +144,7 @@ If you cannot wait, mark Case C SKIPPED and rely on B.
142144
**Goal:** a failure that is neither auth nor transport-closed is not swallowed.
143145

144146
1. `mcpmux_invoke_tool` against a ready server with a tool name that does not exist, e.g. `server_id=wakatime` `tool=definitely_not_a_real_tool`.
145-
2. **Pass** if the raw error comes back to the caller (not a silent success, not a reconnect).
147+
2. **Pass** if the raw error comes back (not a silent success, not a reconnect).
146148
3. If the failure is classified unmatched, the log line is `trigger=unmatched` and there is **no** `reconnect attempted after call_tool failure` for that call.
147149

148150
A permission / not-found error that never hits the backend is also acceptable — note it as "never reached classifier" rather than fail.
@@ -172,7 +174,7 @@ Case B stdio reconnect: PASS | FAIL | INCONCLUSIVE | SKIPPED
172174
server_id / tool:
173175
first invoke: ok/err
174176
child kill: pid / skipped why
175-
second invoke: ok / raw -32000 / other
177+
second invoke: ok / raw Transport closed / other
176178
trigger= :
177179
reconnect_fresh ok= :
178180
oauth reconnect used: yes/no

0 commit comments

Comments
 (0)