chore: tweak reconnect login to avoid reconnect spam
This commit is contained in:
parent
65ed5c1ff4
commit
8f6d53a029
|
|
@ -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
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
export function initWebsocket () {
|
export function initWebsocket () {
|
||||||
|
if (!listenersInitialized) {
|
||||||
window.addEventListener('EventOutputChunk', onOutputChunk)
|
window.addEventListener('EventOutputChunk', onOutputChunk)
|
||||||
window.addEventListener('EventExecutionStarted', onExecutionChanged)
|
window.addEventListener('EventExecutionStarted', onExecutionChanged)
|
||||||
window.addEventListener('EventExecutionFinished', onExecutionChanged)
|
window.addEventListener('EventExecutionFinished', onExecutionChanged)
|
||||||
|
listenersInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
reconnectWebsocket()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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{})
|
go api.sendEventStreamHeartbeats(client)
|
||||||
defer close(heartbeatDone)
|
|
||||||
go api.sendEventStreamHeartbeats(heartbeatDone, 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 {
|
msg := &apiv1.EventStreamResponse{
|
||||||
case client.channel <- &apiv1.EventStreamResponse{
|
|
||||||
Event: &apiv1.EventStreamResponse_ConfigChanged{
|
Event: &apiv1.EventStreamResponse_ConfigChanged{
|
||||||
ConfigChanged: &apiv1.EventConfigChanged{},
|
ConfigChanged: &apiv1.EventConfigChanged{},
|
||||||
},
|
},
|
||||||
}:
|
}
|
||||||
default:
|
if !api.trySendEventToClient(client, msg) {
|
||||||
log.Warnf("EventStream: client channel is full, removing client")
|
|
||||||
toRemove = append(toRemove, client)
|
toRemove = append(toRemove, client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue