-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(transport): SSOT phase 3 — clean reconnect #440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
53d1571
9c1cbb6
6c91ae9
1f94b7a
74e7d46
fe40257
263f7bc
031c537
66ef2cf
13153d6
1737898
76c4040
46c3750
d50296f
c72a27a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,6 @@ export interface SseConnectionConfig { | |
| fetch: (url: string, init?: RequestInit) => Promise<Response>; | ||
| /** Factory for EventSource — allows injection for testing. */ | ||
| createEventSource?: (url: string) => EventSource; | ||
| reconnectDelayMs?: number; | ||
| /** URL for the sendBeacon suspend fallback. */ | ||
| suspendUrl?: string; | ||
| } | ||
|
|
@@ -38,7 +37,6 @@ export class SseConnection implements ChatConnection { | |
| private listener: ConnectionListener | null = null; | ||
| private seqBySession = new Map<string, number>(); | ||
| private pendingSends: Array<{ endpoint: string; body: Record<string, unknown> }> = []; | ||
| private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | ||
| private boundOnVisibility: (() => void) | null = null; | ||
| private boundOnPageShow: ((e: PageTransitionEvent) => void) | null = null; | ||
| private boundOnPageHide: (() => void) | null = null; | ||
|
|
@@ -47,7 +45,6 @@ export class SseConnection implements ChatConnection { | |
| constructor(config: SseConnectionConfig) { | ||
| this.config = { | ||
| createEventSource: (url: string) => new EventSource(url), | ||
| reconnectDelayMs: 500, | ||
| suspendUrl: '', | ||
| ...config, | ||
| }; | ||
|
|
@@ -60,10 +57,6 @@ export class SseConnection implements ChatConnection { | |
|
|
||
| disconnect(): void { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bugs: |
||
| this.removeBrowserListeners(); | ||
| if (this.reconnectTimer) { | ||
| clearTimeout(this.reconnectTimer); | ||
| this.reconnectTimer = null; | ||
| } | ||
| if (this.es) { | ||
| this.es.close(); | ||
| this.es = null; | ||
|
|
@@ -92,8 +85,8 @@ export class SseConnection implements ChatConnection { | |
| return true; | ||
| } | ||
|
|
||
| // Queue if reconnecting | ||
| if (this.reconnectTimer || this.es) { | ||
| // Queue if EventSource exists (reconnecting) | ||
| if (this.es) { | ||
| if (this.pendingSends.length >= MAX_PENDING_SENDS) { | ||
| this.pendingSends.shift(); | ||
| } | ||
|
|
@@ -175,7 +168,6 @@ export class SseConnection implements ChatConnection { | |
| */ | ||
| checkAndReconnect(force = false): void { | ||
| if (!force && this._connected) return; | ||
| if (this.reconnectTimer) return; | ||
| if (this.es) { | ||
| this.es.close(); | ||
| this.es = null; | ||
|
|
@@ -193,11 +185,6 @@ export class SseConnection implements ChatConnection { | |
| private doConnect(): void { | ||
| if (this.es) return; | ||
|
|
||
| if (this.reconnectTimer) { | ||
| clearTimeout(this.reconnectTimer); | ||
| this.reconnectTimer = null; | ||
| } | ||
|
|
||
| // Always use the base URL — reconnect sessions are sent via POST in the | ||
| // welcome handler. This avoids the bug where EventSource auto-reconnect | ||
| // reuses the original URL (missing ?sessions=), and eliminates double | ||
|
|
@@ -217,18 +204,21 @@ export class SseConnection implements ChatConnection { | |
| } | ||
| this._connectionId = msg.connectionId as string; | ||
|
|
||
| // _connected deferred until doReconnectPost succeeds — prevents | ||
| // external send() from bypassing the pending queue mid-reconnect. | ||
| // Capture both connectionId and ES instance for the staleness guard. | ||
| const welcomeConnectionId = this._connectionId; | ||
| const welcomeEs = this.es; | ||
| // Fire reconnect POST (fire-and-forget) if reconnecting with sessions. | ||
| // No need to defer _connected — handleSendV2 handles ownership on first | ||
| // message, and replayed events arrive via SSE regardless. | ||
| if (this._isReconnect && this.seqBySession.size > 0) { | ||
| this.doReconnectPost(welcomeConnectionId, welcomeEs); | ||
| } else { | ||
| this._connected = true; | ||
| this.flushPendingSends(); | ||
| this.listener?.({ type: '_open' }); | ||
| this.doPost('reconnect', { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 unsafe_assumptions: Fire-and-forget reconnect POST means sends can arrive at the server before handleReconnect runs. handleSendV2 calls |
||
| type: 'reconnect', | ||
| sessions: Array.from(this.seqBySession.entries()).map(([sessionId, lastSeq]) => ({ | ||
| sessionId, | ||
| lastSeq, | ||
| })), | ||
| }); | ||
| } | ||
| this._connected = true; | ||
| this.flushPendingSends(); | ||
| this.listener?.({ type: '_open' }); | ||
| this._isReconnect = true; | ||
| }); | ||
|
|
||
|
|
@@ -262,66 +252,6 @@ export class SseConnection implements ChatConnection { | |
| // but we wait for the 'welcome' event before marking as connected. | ||
| } | ||
|
|
||
| /** | ||
| * Send the reconnect POST and only mark connected on success. | ||
| * | ||
| * On failure the client stays disconnected — the next EventSource | ||
| * auto-reconnect will trigger a fresh welcome + retry. This prevents | ||
| * flushing pending sends into the void when the server never ran | ||
| * handleReconnect (no watch, no reattach, no replay). | ||
| */ | ||
| private async doReconnectPost( | ||
| welcomeConnectionId: string, | ||
| welcomeEs: EventSource | null, | ||
| ): Promise<void> { | ||
| try { | ||
| const res = await this.config.fetch(`${this.config.baseUrl}/api/chat/reconnect`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-Connection-ID': welcomeConnectionId, | ||
| }, | ||
| body: JSON.stringify({ | ||
| type: 'reconnect', | ||
| sessions: Array.from(this.seqBySession.entries()).map(([sessionId, lastSeq]) => ({ | ||
| sessionId, | ||
| lastSeq, | ||
| })), | ||
| }), | ||
| }); | ||
|
|
||
| // Guard: bail if disconnect() was called, a newer welcome arrived, | ||
| // or checkAndReconnect replaced the EventSource while in-flight. | ||
| if (!this.es || this.es !== welcomeEs || this._connectionId !== welcomeConnectionId) return; | ||
|
|
||
| if (res.ok) { | ||
| this._connected = true; | ||
| this.flushPendingSends(); | ||
| this.listener?.({ type: '_open' }); | ||
| } else { | ||
| console.warn('[SseConnection] reconnect POST returned', res.status); | ||
| this.scheduleReconnect(); | ||
| } | ||
| } catch (err) { | ||
| if (!this.es || this.es !== welcomeEs || this._connectionId !== welcomeConnectionId) return; | ||
| console.warn('[SseConnection] reconnect POST failed', err); | ||
| this.scheduleReconnect(); | ||
| } | ||
| } | ||
|
|
||
| /** Tear down and reconnect after a delay to avoid tight retry loops. */ | ||
| private scheduleReconnect(): void { | ||
| if (this.reconnectTimer) return; | ||
| if (this.es) { | ||
| this.es.close(); | ||
| this.es = null; | ||
| } | ||
| this.reconnectTimer = setTimeout(() => { | ||
| this.reconnectTimer = null; | ||
| this.doConnect(); | ||
| }, this.config.reconnectDelayMs); | ||
| } | ||
|
|
||
| private async doPost(endpoint: string, body: Record<string, unknown>): Promise<void> { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 bugs: |
||
| if (!this._connectionId) return; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bugs: doPost silently resolves (doesn't throw) when _connectionId is null. When called from the reconnect .then() chain (line 236), if _connectionId were somehow cleared between the welcome event and the fetch, the success handler would fire without the POST ever being sent — marking _connected=true and flushing sends into the void. In practice this path is unreachable (connectionId is set at line 208 before the POST), but the contract is misleading: callers assume doPost either succeeds or throws, yet this early return is a silent success. |
||
| try { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 bugs:
disconnect()does not clear_pendingReconnectSessions. If a reconnect POST failed (setting_pendingReconnectSessions), then the user disconnects and later reconnects, the stale sessions array will be retried on the next welcome — even if those sessions no longer exist locally (cleared viaclearSession). Addthis._pendingReconnectSessions = null;indisconnect().[fixable]