Explorar o código

chore: tweak reconnect login to avoid reconnect spam

jamesread hai 2 semanas
pai
achega
8f6d53a029

+ 58 - 7
frontend/js/websocket.js

@@ -2,23 +2,63 @@ import { buttonResults } from '../resources/vue/stores/buttonResults.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { connectionState } from '../resources/vue/stores/connectionState.js'
 import { connectionState } from '../resources/vue/stores/connectionState.js'
 
 
-const RECONNECT_DELAYS_MS = [0, 1000, 2000, 4000, 8000, 16000, 32000]
+const RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
 const BANNER_DELAY_MS = 2000
 const BANNER_DELAY_MS = 2000
 
 
 let reconnectAttempt = 0
 let reconnectAttempt = 0
 let reconnectTimer = null
 let reconnectTimer = null
+let listenersInitialized = false
 
 
-export function initWebsocket () {
-  window.addEventListener('EventOutputChunk', onOutputChunk)
-  window.addEventListener('EventExecutionStarted', onExecutionChanged)
-  window.addEventListener('EventExecutionFinished', onExecutionChanged)
+function shouldConnectEventStream () {
+  return window.initResponse && !window.initResponse.loginRequired
+}
+
+export function stopEventStream () {
+  if (reconnectTimer != null) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+
+  reconnectAttempt = 0
+  connectionState.connected = false
+  connectionState.reconnecting = false
+  connectionState.scheduledReconnectDelayMs = 0
+  connectionState.nextReconnectAt = null
+  connectionState.showDisconnectedBanner = false
+  window.websocketAvailable = false
+}
+
+export function connectEventStreamIfNeeded () {
+  if (!shouldConnectEventStream()) {
+    stopEventStream()
+    return
+  }
+
+  if (window.websocketAvailable || reconnectTimer != null) {
+    return
+  }
 
 
   reconnectWebsocket()
   reconnectWebsocket()
 }
 }
 
 
+export function initWebsocket () {
+  if (!listenersInitialized) {
+    window.addEventListener('EventOutputChunk', onOutputChunk)
+    window.addEventListener('EventExecutionStarted', onExecutionChanged)
+    window.addEventListener('EventExecutionFinished', onExecutionChanged)
+    listenersInitialized = true
+  }
+
+  connectEventStreamIfNeeded()
+}
+
 window.websocketAvailable = false
 window.websocketAvailable = false
 
 
 export function requestReconnectNow () {
 export function requestReconnectNow () {
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
   if (window.websocketAvailable) {
   if (window.websocketAvailable) {
     return
     return
   }
   }
@@ -29,7 +69,7 @@ export function requestReconnectNow () {
   }
   }
 
 
   reconnectAttempt = 0
   reconnectAttempt = 0
-  scheduleReconnect(0)
+  scheduleReconnect(RECONNECT_DELAYS_MS[0])
 }
 }
 
 
 function scheduleReconnect (delayMs) {
 function scheduleReconnect (delayMs) {
@@ -57,6 +97,10 @@ function updateBannerVisibility () {
 }
 }
 
 
 async function reconnectWebsocket () {
 async function reconnectWebsocket () {
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
   if (window.websocketAvailable) {
   if (window.websocketAvailable) {
     return
     return
   }
   }
@@ -78,8 +122,10 @@ async function reconnectWebsocket () {
     connectionState.nextReconnectAt = null
     connectionState.nextReconnectAt = null
     connectionState.scheduledReconnectDelayMs = 0
     connectionState.scheduledReconnectDelayMs = 0
     connectionState.showDisconnectedBanner = false
     connectionState.showDisconnectedBanner = false
-    reconnectAttempt = 0
     for await (const e of stream) {
     for await (const e of stream) {
+      if (reconnectAttempt !== 0) {
+        reconnectAttempt = 0
+      }
       handleEvent(e)
       handleEvent(e)
     }
     }
   } catch (err) {
   } catch (err) {
@@ -94,6 +140,11 @@ async function reconnectWebsocket () {
   const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]
   const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]
   reconnectAttempt++
   reconnectAttempt++
   console.log('Reconnecting websocket in ' + delay + 'ms...')
   console.log('Reconnecting websocket in ' + delay + 'ms...')
+
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
   scheduleReconnect(delay)
   scheduleReconnect(delay)
 }
 }
 
 

+ 3 - 1
frontend/resources/vue/ActionButton.vue

@@ -214,8 +214,10 @@ function getUniqueId() {
 
 
 async function pollExecutionUntilDone (trackingId) {
 async function pollExecutionUntilDone (trackingId) {
   const pollIntervalMs = 500
   const pollIntervalMs = 500
+  const pollTimeoutMs = 10 * 60 * 1000
+  const deadline = Date.now() + pollTimeoutMs
 
 
-  while (!connectionState.connected) {
+  while (Date.now() < deadline) {
     try {
     try {
       const result = await window.client.executionStatus({ executionTrackingId: trackingId })
       const result = await window.client.executionStatus({ executionTrackingId: trackingId })
       if (result.logEntry) {
       if (result.logEntry) {

+ 4 - 0
frontend/resources/vue/App.vue

@@ -100,6 +100,7 @@ import Sidebar from 'picocrank/vue/components/Sidebar.vue';
 import Navigation from 'picocrank/vue/components/Navigation.vue';
 import Navigation from 'picocrank/vue/components/Navigation.vue';
 import Header from 'picocrank/vue/components/Header.vue';
 import Header from 'picocrank/vue/components/Header.vue';
 import ConnectionBanner from './components/ConnectionBanner.vue';
 import ConnectionBanner from './components/ConnectionBanner.vue';
+import { connectEventStreamIfNeeded } from '../../js/websocket.js';
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { Menu01Icon } from '@hugeicons/core-free-icons'
 import { Menu01Icon } from '@hugeicons/core-free-icons'
 import { UserCircle02Icon } from '@hugeicons/core-free-icons'
 import { UserCircle02Icon } from '@hugeicons/core-free-icons'
@@ -237,9 +238,12 @@ function updateHeaderFromInit() {
     applyTheme()
     applyTheme()
 
 
     if (window.initResponse.loginRequired) {
     if (window.initResponse.loginRequired) {
+        connectEventStreamIfNeeded()
         router.push('/login')
         router.push('/login')
         return
         return
     }
     }
+
+    connectEventStreamIfNeeded()
 }
 }
 
 
 function renderNavigation() {
 function renderNavigation() {

+ 50 - 13
service/internal/api/api.go

@@ -58,18 +58,43 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
 type streamingClient struct {
 type streamingClient struct {
 	channel           chan *apiv1.EventStreamResponse
 	channel           chan *apiv1.EventStreamResponse
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	AuthenticatedUser *authpublic.AuthenticatedUser
+	heartbeatStopOnce sync.Once
+	heartbeatStop     chan struct{}
+	heartbeatDone     chan struct{}
 }
 }
 
 
-// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full (client should be removed).
+func (c *streamingClient) stopHeartbeat() {
+	if c.heartbeatStop == nil || c.heartbeatDone == nil {
+		return
+	}
+	c.heartbeatStopOnce.Do(func() {
+		close(c.heartbeatStop)
+	})
+	<-c.heartbeatDone
+}
+
+// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed.
 func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
 func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
 	if client == nil || msg == nil {
 	if client == nil || msg == nil {
 		return false
 		return false
 	}
 	}
+	sent := sendToStreamingClientChannel(client.channel, msg)
+	if !sent {
+		log.Warnf("EventStream: client channel is full or closed, removing client")
+	}
+	return sent
+}
+
+func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) {
+	defer func() {
+		if recover() != nil {
+			sent = false
+		}
+	}()
 	select {
 	select {
-	case client.channel <- msg:
+	case ch <- msg:
 		return true
 		return true
 	default:
 	default:
-		log.Warnf("EventStream: client channel is full, removing client")
 		return false
 		return false
 	}
 	}
 }
 }
@@ -933,6 +958,8 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	client := &streamingClient{
 	client := &streamingClient{
 		channel:           make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
 		channel:           make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
 		AuthenticatedUser: user,
 		AuthenticatedUser: user,
+		heartbeatStop:     make(chan struct{}),
+		heartbeatDone:     make(chan struct{}),
 	}
 	}
 
 
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
@@ -943,9 +970,7 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	api.streamingClients[client] = struct{}{}
 	api.streamingClients[client] = struct{}{}
 	api.streamingClientsMutex.Unlock()
 	api.streamingClientsMutex.Unlock()
 
 
-	heartbeatDone := make(chan struct{})
-	defer close(heartbeatDone)
-	go api.sendEventStreamHeartbeats(heartbeatDone, client)
+	go api.sendEventStreamHeartbeats(client)
 
 
 	// loop over client channel and send events to connectedClient
 	// loop over client channel and send events to connectedClient
 	for msg := range client.channel {
 	for msg := range client.channel {
@@ -963,12 +988,21 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	return nil
 	return nil
 }
 }
 
 
-func (api *oliveTinAPI) sendEventStreamHeartbeats(done <-chan struct{}, client *streamingClient) {
+func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
+	defer close(client.heartbeatDone)
+
+	if !api.sendEventStreamHeartbeat(client) {
+		return
+	}
+
 	ticker := time.NewTicker(10 * time.Second)
 	ticker := time.NewTicker(10 * time.Second)
 	defer ticker.Stop()
 	defer ticker.Stop()
+	api.runEventStreamHeartbeatLoop(client, ticker)
+}
 
 
+func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
 	for {
 	for {
-		if api.waitEventStreamHeartbeatOrDone(done, ticker) {
+		if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
 			return
 			return
 		}
 		}
 		if !api.sendEventStreamHeartbeat(client) {
 		if !api.sendEventStreamHeartbeat(client) {
@@ -1000,8 +1034,13 @@ func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
 		return
 		return
 	}
 	}
 	api.streamingClientsMutex.Lock()
 	api.streamingClientsMutex.Lock()
+	if _, exists := api.streamingClients[clientToRemove]; !exists {
+		api.streamingClientsMutex.Unlock()
+		return
+	}
 	delete(api.streamingClients, clientToRemove)
 	delete(api.streamingClients, clientToRemove)
 	api.streamingClientsMutex.Unlock()
 	api.streamingClientsMutex.Unlock()
+	clientToRemove.stopHeartbeat()
 	close(clientToRemove.channel)
 	close(clientToRemove.channel)
 }
 }
 
 
@@ -1009,14 +1048,12 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
 	toRemove := []*streamingClient{}
 	toRemove := []*streamingClient{}
 
 
 	for _, client := range api.copyOfStreamingClients() {
 	for _, client := range api.copyOfStreamingClients() {
-		select {
-		case client.channel <- &apiv1.EventStreamResponse{
+		msg := &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_ConfigChanged{
 			Event: &apiv1.EventStreamResponse_ConfigChanged{
 				ConfigChanged: &apiv1.EventConfigChanged{},
 				ConfigChanged: &apiv1.EventConfigChanged{},
 			},
 			},
-		}:
-		default:
-			log.Warnf("EventStream: client channel is full, removing client")
+		}
+		if !api.trySendEventToClient(client, msg) {
 			toRemove = append(toRemove, client)
 			toRemove = append(toRemove, client)
 		}
 		}
 	}
 	}