fix: recover from sleep/wake disconnection with connection state tracking and immediate health check (#940)

When the computer sleeps and wakes, the SSE/WS event stream drops
silently. Messages appeared sent (optimistic insert) but never reached
the OpenCode server, and the user had no indication the system was
disconnected.

Three fixes:

1. Connection state tracking: add onDisconnect callback to the event
   pipeline. Stream failures set isConnected=false in useConfigStore;
   successful reconnect sets isConnected=true.

2. Send guard: optimisticSend, respondToPermission, and
   respondToQuestion now check isConnected before making API calls,
   throwing a clear error that surfaces as a toast to the user.
   The /compact command also checks connection with error feedback.

3. Faster server recovery: add triggerHealthCheck() to the server
   lifecycle and wire it into the WS event stream runtime. When the
   upstream OpenCode connection fails, the server immediately checks
   health and restarts if needed, instead of waiting up to 15s for
   the periodic health check.
This commit is contained in:
jwcrystal
2026-04-17 18:48:39 +03:00
committed by GitHub
parent b3f2732dff
commit f5535dcaf1
8 changed files with 78 additions and 12 deletions
+2
View File
@@ -870,6 +870,7 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL);
const triggerHealthCheck = () => openCodeLifecycleRuntime.triggerHealthCheck();
const scheduledTasksRuntime = createScheduledTasksRuntime({
projectConfigRuntime,
listProjects: async () => {
@@ -1155,6 +1156,7 @@ async function main(options = {}) {
setupProxy,
scheduleOpenCodeApiDetection,
bootstrapOpenCodeAtStartup,
triggerHealthCheck,
staticRoutesRuntime,
process,
crypto,
@@ -54,6 +54,7 @@ export function createMessageStreamWsRuntime({
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients,
triggerHealthCheck,
fetchImpl = fetch,
}) {
const wsServer = new WebSocketServer({
@@ -136,6 +137,10 @@ export function createMessageStreamWsRuntime({
if (!controller.signal.aborted) {
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Failed to connect to OpenCode event stream' });
socket.close(1011, 'Failed to connect to OpenCode event stream');
// Trigger immediate health check so the server detects and
// restarts a dead OpenCode process without waiting for the next
// periodic interval (up to 15 s).
triggerHealthCheck?.();
}
return;
}
@@ -146,6 +151,7 @@ export function createMessageStreamWsRuntime({
message: `OpenCode event stream unavailable (${upstream.status})`,
});
socket.close(1011, 'OpenCode event stream unavailable');
triggerHealthCheck?.();
return;
}
@@ -714,6 +714,25 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
};
/**
* Perform an immediate (one-shot) health check and restart OpenCode if it's
* not healthy. Callers on the SSE / WS proxy path use this to trigger
* recovery without waiting for the next periodic interval (up to 15 s).
*/
const triggerHealthCheck = async () => {
if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return;
try {
const healthy = await isOpenCodeProcessHealthy();
if (!healthy) {
console.log('[lifecycle] immediate health check: OpenCode not healthy, restarting...');
await restartOpenCode();
}
} catch (error) {
console.error(`[lifecycle] immediate health check error: ${error.message}`);
}
};
const startHealthMonitoring = (healthCheckIntervalMs) => {
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
@@ -743,6 +762,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
refreshOpenCodeAfterConfigChange,
bootstrapOpenCodeAtStartup,
startHealthMonitoring,
triggerHealthCheck,
waitForPortRelease,
};
};
@@ -22,6 +22,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
getOpenCodeAuthHeaders,
processForwardedEventPayload,
messageStreamWsClients,
triggerHealthCheck,
terminalHeartbeatIntervalMs,
terminalRebindWindowMs,
terminalMaxRebindsPerWindow,
@@ -76,6 +77,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients: messageStreamWsClients,
triggerHealthCheck,
});
setupProxy(app);