Skip to content

Conversation

@mostlygeek
Copy link
Owner

@mostlygeek mostlygeek commented Sep 8, 2025

  • move SSE (EventSource) connection to module level
  • manage EventSource as a singleton, closing open connection before reopening a new one

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of real-time updates with more robust connection handling and reconnection.
    • Reduced intermittent disconnects and duplicate event streams.
  • Performance

    • Lowered resource usage by consolidating real-time event connections.
  • Refactor

    • Streamlined real-time event management for consistency across the app without changing public APIs.

- move SSE (EventSource) connection to module level
- manage EventSource as a singleton, closing open connection before
  reopening a new one
@coderabbitai
Copy link

coderabbitai bot commented Sep 8, 2025

Walkthrough

Consolidated EventSource management from component-scoped (useRef) to a module-level variable. Updated enable/disable and connect flows to operate on the shared EventSource, binding handlers directly and maintaining retry/backoff on errors. Connection state, logs, metrics, and models are reset on open. Public API signatures remain unchanged.

Changes

Cohort / File(s) Summary
API event stream management
ui/src/contexts/APIProvider.tsx
Replaced per-instance EventSource ref with module-scoped apiEventSource. Updated enable/disable to close/reset global source. Revised connect to recreate and bind handlers on the global source, preserving message handling and exponential backoff on errors. Removed useRef usage and related assignments.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor UI as UI
  participant Provider as APIProvider (module)
  participant SSE as EventSource (/api/events)

  UI->>Provider: enableAPIEvents(true)
  activate Provider
  Provider->>Provider: connect()
  alt existing apiEventSource
    Provider->>SSE: close()
    note right of Provider: Reset global apiEventSource to null
  end
  Provider->>SSE: new EventSource()
  Provider->>Provider: set handlers (onopen, onmessage, onerror)

  SSE-->>Provider: onopen
  Provider->>Provider: reset logs/metrics/models<br/>set connected=true

  SSE-->>Provider: onmessage
  Provider->>Provider: process event

  SSE-->>Provider: onerror
  Provider->>SSE: close()
  Provider->>Provider: schedule reconnect with backoff (setTimeout connect)

  UI->>Provider: enableAPIEvents(false)
  Provider->>SSE: close()
  Provider->>Provider: apiEventSource = null
  deactivate Provider
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-288

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mostlygeek mostlygeek merged commit b21dee2 into main Sep 8, 2025
2 of 3 checks passed
@mostlygeek mostlygeek deleted the fix-288 branch September 8, 2025 04:49
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
ui/src/contexts/APIProvider.tsx (4)

72-79: Fully disable the stream: clear pending retry, mark disabled, and reflect status.

Currently disables SSE but can leave a pending reconnect and doesn’t update connectionStatus.

   const enableAPIEvents = useCallback((enabled: boolean) => {
     if (!enabled) {
-      apiEventSource?.close();
-      apiEventSource = null;
+      eventsEnabled = false;
+      apiEventSource?.close();
+      apiEventSource = null;
+      if (retryTimer) {
+        clearTimeout(retryTimer);
+        retryTimer = null;
+      }
+      setConnectionState("disconnected");
       setMetrics([]);
       return;
     }

80-83: Mark enabled before connecting.

Ensures guards inside connect/onopen/onerror behave correctly.

-    let retryCount = 0;
+    eventsEnabled = true;
+    let retryCount = 0;

142-148: Do not reconnect when disabled; de-dupe retries.

Add guard and manage a single retry timer.

-      apiEventSource.onerror = () => {
-        apiEventSource?.close();
-        retryCount++;
-        const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
-        setConnectionState("disconnected");
-        setTimeout(connect, delay);
-      };
+      apiEventSource.onerror = () => {
+        apiEventSource?.close();
+        if (!eventsEnabled) return;
+        retryCount++;
+        const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
+        setConnectionState("disconnected");
+        if (retryTimer) clearTimeout(retryTimer);
+        retryTimer = setTimeout(connect, delay);
+      };

206-219: Fix stale connectionStatus in context value (missing dep).

Consumers won’t re-render on status changes.

-    [models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics]
+    [models, listModels, unloadAllModels, loadModel, enableAPIEvents, proxyLogs, upstreamLogs, metrics, connectionStatus]
🧹 Nitpick comments (4)
ui/src/contexts/APIProvider.tsx (4)

61-61: Remove commented-out ref.

Dead code adds noise.

-  //const apiEventSource = useRef<EventSource | null>(null);

99-140: Minor: avoid shadowing ‘models’; tighten sort comparator.

Renaming clarifies intent; comparator is cleaner and stable.

-            case "modelStatus":
-              {
-                const models = JSON.parse(message.data) as Model[];
-
-                // sort models by name and id
-                models.sort((a, b) => {
-                  return (a.name + a.id).localeCompare(b.name + b.id);
-                });
-
-                setModels(models);
-              }
+            case "modelStatus": {
+              const incomingModels = JSON.parse(message.data) as Model[];
+              // sort by name, then id
+              incomingModels.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
+              setModels(incomingModels);
+            }
               break;

131-134: Optional: cap metrics to prevent unbounded growth.

Helps memory in long-running sessions.

-                setMetrics((prevMetrics) => {
-                  return [...newMetrics, ...prevMetrics];
-                });
+                setMetrics((prevMetrics) => {
+                  const merged = [...newMetrics, ...prevMetrics];
+                  const MAX = 5000;
+                  return merged.length > MAX ? merged.slice(0, MAX) : merged;
+                });

54-149: Optional: rely on native EventSource auto-retry instead of manual loop.

If the server sends proper retry: directives, you can drop the manual close/reconnect to simplify. Not required if you prefer explicit control.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f58c8c8 and 1f457d1.

📒 Files selected for processing (1)
  • ui/src/contexts/APIProvider.tsx (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
ui/src/contexts/APIProvider.tsx (3)
ui/src/lib/types.ts (1)
  • ConnectionState (1-1)
ui/src/pages/LogViewer.tsx (1)
  • useAPI (15-37)
ui/src/components/ConnectionStatus.tsx (1)
  • useAPI (4-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: run-tests
🔇 Additional comments (2)
ui/src/contexts/APIProvider.tsx (2)

1-1: LGTM: removed unnecessary useRef import.


54-149: Single APIProvider usage confirmed; no conflicting EventSource instances found.
Ripgrep shows APIProvider is only mounted once in ui/src/main.tsx and the only new EventSource call resides within APIProvider itself.

Comment on lines +54 to +55
let apiEventSource: EventSource | null = null;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Prevent orphan SSE on HMR and reconnection after disable.

Add a retry timer + enabled flag, and close SSE on Vite HMR dispose. Without these, HMR can leave a dangling EventSource and scheduled retries can reopen after unmount/disable.

Apply:

 let apiEventSource: EventSource | null = null;
+
+let retryTimer: ReturnType<typeof setTimeout> | null = null;
+let eventsEnabled = false;
+
+if (import.meta.hot) {
+  import.meta.hot.dispose(() => {
+    apiEventSource?.close();
+    apiEventSource = null;
+    if (retryTimer) {
+      clearTimeout(retryTimer);
+      retryTimer = null;
+    }
+  });
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let apiEventSource: EventSource | null = null;
// ui/src/contexts/APIProvider.tsx
let apiEventSource: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let eventsEnabled = false;
if (import.meta.hot) {
import.meta.hot.dispose(() => {
apiEventSource?.close();
apiEventSource = null;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
});
}
🤖 Prompt for AI Agents
In ui/src/contexts/APIProvider.tsx around lines 54-55, the EventSource is
created as a single module-scoped variable which can be left dangling across HMR
and can be reopened by scheduled retries after the provider is disabled; add an
"enabled" flag and a retry timer handle (module-scoped), ensure any scheduled
retry is cleared when the provider is disabled or unmounted, set enabled=false
before closing the EventSource and null it out, and on Vite HMR disposal (if
import.meta.hot) close the EventSource, clear the retry timer, and set
enabled=false to prevent reconnection after HMR or disable.

Comment on lines +84 to +86
apiEventSource = new EventSource("/api/events");

setConnectionState("connecting");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard connect and clear any previous retry timer.

Prevents reconnects when disabled and avoids timer accumulation.

-    const connect = () => {
-      apiEventSource?.close();
-      apiEventSource = new EventSource("/api/events");
+    const connect = () => {
+      if (!eventsEnabled) return;
+      if (retryTimer) {
+        clearTimeout(retryTimer);
+        retryTimer = null;
+      }
+      apiEventSource?.close();
+      apiEventSource = new EventSource("/api/events");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apiEventSource?.close();
apiEventSource = new EventSource("/api/events");
const connect = () => {
if (!eventsEnabled) return;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
apiEventSource?.close();
apiEventSource = new EventSource("/api/events");
};
🤖 Prompt for AI Agents
In ui/src/contexts/APIProvider.tsx around lines 84-86, the code unconditionally
closes any existing EventSource and immediately creates a new one which can
cause unwanted reconnects when the API is disabled and can accumulate retry
timers; guard the creation with the provider's "enabled" (or similar) flag so
you only instantiate EventSource when enabled, and before creating a new
EventSource clear any existing reconnect/retry timer
(clearTimeout/clearInterval) and nullify that timer variable so timers don't
stack; also ensure you remove existing event listeners and set apiEventSource =
null after closing if not reconnecting.

Comment on lines +89 to 97
// clear everything out on connect to keep things in sync
setProxyLogs("");
setUpstreamLogs("");
setMetrics([]); // clear metrics on reconnect
setModels([]); // clear models on reconnect
apiEventSource.current = eventSource;
retryCount = 0;
setConnectionState("connected");
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ignore late onopen after disable.

Avoids updating state and keeping an open SSE when already disabled.

-      apiEventSource.onopen = () => {
+      apiEventSource.onopen = () => {
+        if (!eventsEnabled) {
+          apiEventSource?.close();
+          return;
+        }
         // clear everything out on connect to keep things in sync
         setProxyLogs("");
         setUpstreamLogs("");
         setMetrics([]); // clear metrics on reconnect
         setModels([]); // clear models on reconnect
         retryCount = 0;
         setConnectionState("connected");
       };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apiEventSource.onopen = () => {
// clear everything out on connect to keep things in sync
setProxyLogs("");
setUpstreamLogs("");
setMetrics([]); // clear metrics on reconnect
setModels([]); // clear models on reconnect
apiEventSource.current = eventSource;
retryCount = 0;
setConnectionState("connected");
};
apiEventSource.onopen = () => {
if (!eventsEnabled) {
apiEventSource?.close();
return;
}
// clear everything out on connect to keep things in sync
setProxyLogs("");
setUpstreamLogs("");
setMetrics([]); // clear metrics on reconnect
setModels([]); // clear models on reconnect
retryCount = 0;
setConnectionState("connected");
};
🤖 Prompt for AI Agents
In ui/src/contexts/APIProvider.tsx around lines 89-97, the apiEventSource.onopen
handler can run after the connection has been disabled; update the handler to
first check whether the connection is currently disabled (e.g., connectionState
=== "disabled" or a local `isDisabled` flag) or the active event source doesn't
match the current ref, and if so call apiEventSource.close() and return early;
otherwise proceed to clear logs, reset retryCount, and
setConnectionState("connected") as before. Ensure the check prevents any state
updates when disabled and closes the stale SSE to avoid keeping it open.

mcowger pushed a commit to mcowger/llama-swap that referenced this pull request Sep 8, 2025
…nnections (mostlygeek#290)

- move SSE (EventSource) connection to module level
- manage EventSource as a singleton, closing open connection before
  reopening a new one
mcowger added a commit to mcowger/llama-swap that referenced this pull request Sep 9, 2025
* Add a config editor page

* Support llama.cpp's cache_n in timings info (mostlygeek#287)

Capture prompt cache metrics and surface them on Activities page in UI

* Fix mostlygeek#288 Vite hot module reloading creating multiple SSE connections (mostlygeek#290)

- move SSE (EventSource) connection to module level
- manage EventSource as a singleton, closing open connection before
  reopening a new one

* Add model name copy button to Models UI

---------

Co-authored-by: Benson Wong <mostlygeek@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants