|
2 | 2 | * Probe outcome classifier (pure module): maps the outcome of the connect-time |
3 | 3 | * `server/discover` probe onto one of four verdicts — modern era, the |
4 | 4 | * spec-mandated `-32022` corrective continuation, legacy fallback (the plain |
5 | | - * 2025 `initialize` handshake on the same connection), or a typed connect error. |
| 5 | + * 2025 `initialize` handshake — on the same connection, or on a restarted |
| 6 | + * transport when a stdio close consumed it), or a typed connect error. |
6 | 7 | * |
7 | 8 | * The classifier is deliberately conservative: anything it does not positively |
8 | 9 | * recognize as modern resolves to the legacy fallback, and a network outage is a |
@@ -48,6 +49,14 @@ export type ProbeOutcome = |
48 | 49 | | { kind: 'network-error'; error: unknown } |
49 | 50 | /** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */ |
50 | 51 | | { kind: 'auth-required'; error: Error } |
| 52 | + /** |
| 53 | + * The transport reported close while the probe awaited its reply. |
| 54 | + * `localClose` is true when the close was initiated on the local side — |
| 55 | + * `transport.close()` ran during the probe window, whether from a caller |
| 56 | + * shutdown/abort or the transport's own error recovery — and is therefore |
| 57 | + * never a server-side signal. |
| 58 | + */ |
| 59 | + | { kind: 'closed'; localClose?: boolean } |
51 | 60 | /** No response arrived within the probe timeout. */ |
52 | 61 | | { kind: 'timeout'; timeoutMs: number }; |
53 | 62 |
|
@@ -82,8 +91,15 @@ export type ProbeVerdict = |
82 | 91 | * arms a loop guard on the second rejection, throwing `error`. |
83 | 92 | */ |
84 | 93 | | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } |
85 | | - /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ |
86 | | - | { kind: 'legacy' } |
| 94 | + /** |
| 95 | + * Definitive legacy signal or unrecognized shape: perform the plain legacy |
| 96 | + * `initialize` handshake on the same connection. `restart` marks the one |
| 97 | + * row where the same connection no longer exists (stdio close): the |
| 98 | + * handshake runs on a restarted transport instead — for the stdio |
| 99 | + * transport, `start()` respawns the server process, which is exactly what |
| 100 | + * a `mode: 'legacy'` connect would have done. |
| 101 | + */ |
| 102 | + | { kind: 'legacy'; restart?: true } |
87 | 103 | /** Typed connect error — never converted to an era verdict. */ |
88 | 104 | | { kind: 'error'; error: Error }; |
89 | 105 |
|
@@ -125,6 +141,39 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi |
125 | 141 | // handshake an auth-gated modern server as legacy. |
126 | 142 | return { kind: 'error', error: outcome.error }; |
127 | 143 | } |
| 144 | + case 'closed': { |
| 145 | + if (outcome.localClose) { |
| 146 | + // The local side closed the transport mid-probe — a caller |
| 147 | + // shutdown/abort, or the transport's own error recovery (e.g. |
| 148 | + // read-buffer overflow). Never era evidence on any transport, |
| 149 | + // and on stdio it must not respawn a process the local side |
| 150 | + // chose to terminate. |
| 151 | + return { |
| 152 | + kind: 'error', |
| 153 | + error: new SdkError( |
| 154 | + SdkErrorCode.EraProbeConnectionClosed, |
| 155 | + 'Connection closed locally during the version negotiation probe' |
| 156 | + ) |
| 157 | + }; |
| 158 | + } |
| 159 | + if (context.transportKind === 'stdio') { |
| 160 | + // A stdio child that exits on the unrecognized probe instead of |
| 161 | + // answering is a legacy signal — the same backward-compatibility |
| 162 | + // rule as the timeout row below (SDKs like the official Rust one |
| 163 | + // terminate the server on ANY pre-initialize request). The |
| 164 | + // stream died with the process, so the fallback cannot run on |
| 165 | + // the same connection: `restart` has the caller run `initialize` |
| 166 | + // on a restarted transport. |
| 167 | + return { kind: 'legacy', restart: true }; |
| 168 | + } |
| 169 | + // On HTTP a mid-probe close is an ambiguous network condition |
| 170 | + // (proxy drop, crash, redeploy) — a typed connect error, never an |
| 171 | + // era verdict. |
| 172 | + return { |
| 173 | + kind: 'error', |
| 174 | + error: new SdkError(SdkErrorCode.EraProbeConnectionClosed, 'Connection closed during the version negotiation probe') |
| 175 | + }; |
| 176 | + } |
128 | 177 | case 'timeout': { |
129 | 178 | if (context.transportKind === 'stdio') { |
130 | 179 | // Per the stdio transport's backward-compatibility rule, a probe |
|
0 commit comments