Fix: end refresh loops on sign-out and back off failed retries
Fixes #588. Sign-out already cleared the token-refresh timers and stopped the keep-alive interval - the reported endless loops came from async callbacks that were in flight at that moment. The token refresh's failure handler re-armed its retry after logout, and a failing keep-alive ping called reconnect() -> connect(), which restarts the keep-alive and thereby revived the interval disconnect() had just stopped. Only closing the tab ended it. Two mechanisms fix that class: transiently failed token refreshes only re-arm while the account is still signed in (checked when the failure lands, not when the request started), and the client carries an intentionallyDisconnected flag set by disconnect() - the ping callback, reconnect(), the SSE reconnect scheduling and the polling fallback all stop at it, so nothing revives after an intentional sign-out. Failed retries also back off instead of hammering a down server every 30 seconds: the token refresh climbs 30s/1m/2m/5m (capped, reset on success), and the keep-alive skips upcoming ticks on consecutive failures for the same effective ladder. Recovery after an outage is unchanged in substance - the session survives and reconnects within at most ~5 minutes, immediately on user activity.
This commit is contained in:
@@ -507,6 +507,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
private session: JMAPSession | null = null;
|
||||
private lastPingTime: number = 0;
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
// Set by disconnect() so async callbacks that were already in flight
|
||||
// (keep-alive ping, SSE error handlers) cannot revive timers or
|
||||
// reconnect after an intentional sign-out (#588).
|
||||
private intentionallyDisconnected = false;
|
||||
// Consecutive keep-alive failures; failed pings skip upcoming ticks
|
||||
// (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588).
|
||||
private pingFailureCount = 0;
|
||||
private pingSkipRemaining = 0;
|
||||
private accounts: Record<string, JMAPAccount> = {};
|
||||
private eventSource: EventSource | null = null;
|
||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||
@@ -689,6 +697,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.intentionallyDisconnected = false;
|
||||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||
|
||||
try {
|
||||
@@ -758,19 +767,33 @@ export class JMAPClient implements IJMAPClient {
|
||||
this.stopKeepAlive();
|
||||
|
||||
this.pingInterval = setInterval(async () => {
|
||||
if (this.intentionallyDisconnected) return;
|
||||
// Skip ping while rate-limited to avoid compounding auth failures
|
||||
if (this.isRateLimited()) return;
|
||||
// Back off while the server is down: each consecutive failure skips
|
||||
// more ticks (30s -> 1m -> 2m -> ~5m) instead of retrying flat-out.
|
||||
if (this.pingSkipRemaining > 0) {
|
||||
this.pingSkipRemaining--;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.ping();
|
||||
this.pingFailureCount = 0;
|
||||
this.connectionChangeCallback?.(true);
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitError) {
|
||||
return;
|
||||
}
|
||||
// A sign-out while the ping was in flight - stay down.
|
||||
if (this.intentionallyDisconnected) return;
|
||||
this.pingFailureCount++;
|
||||
this.pingSkipRemaining = Math.min(2 ** this.pingFailureCount, 10) - 1;
|
||||
console.error('Keep-alive ping failed:', error);
|
||||
this.connectionChangeCallback?.(false);
|
||||
try {
|
||||
await this.reconnect();
|
||||
this.pingFailureCount = 0;
|
||||
this.pingSkipRemaining = 0;
|
||||
this.connectionChangeCallback?.(true);
|
||||
} catch (reconnectError) {
|
||||
console.error('Reconnection failed:', reconnectError);
|
||||
@@ -803,10 +826,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
if (this.intentionallyDisconnected) return;
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionallyDisconnected = true;
|
||||
this.stopKeepAlive();
|
||||
this.closePushNotifications();
|
||||
if (this.rateLimitTimeout) {
|
||||
@@ -5758,6 +5783,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private scheduleSSEReconnect(): void {
|
||||
if (this.intentionallyDisconnected) return;
|
||||
const eventSourceUrl = this.getEventSourceUrl();
|
||||
if (!eventSourceUrl) {
|
||||
this.fallbackToPolling();
|
||||
@@ -5783,6 +5809,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private startPollingFallback(): void {
|
||||
if (this.intentionallyDisconnected) return;
|
||||
if (this.isRateLimited()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user