Skip to content

Commit c47529f

Browse files
committed
feat: add offline recovery timer with 10 s polling
When a NETWORK_ERROR or TIMEOUT is encountered, pause the normal refresh schedule and start a 10-second polling loop that retries until connectivity is restored, then resume the configured interval. - Add recoveryTimer / recoveryActive module state - startRecoveryTimer(): pause normal refresh, begin 10 s retry loop - _scheduleNextRecovery(): recursive setTimeout; clean gap after each attempt - clearRecoveryTimer(): stop recovery, restore normal setInterval - Guard resetTimer() with `if (recoveryActive) return` to prevent competing timers during config changes while offline - Extend _getState() with recoveryTimerActive / refreshTimerActive flags and expose lifecycle helpers for test-only use - Add 7 unit tests covering timer lifecycle, idempotency, and auto-stop on reconnect (vi.useFakeTimers)
1 parent 991744e commit c47529f

2 files changed

Lines changed: 140 additions & 2 deletions

File tree

src/extension.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ let deactivated = false;
2222
let isOffline = false;
2323
/** @type {Date | null} */
2424
let offlineSince = null;
25+
/** @type {NodeJS.Timeout | undefined} */
26+
let recoveryTimer;
27+
/** @type {boolean} */
28+
let recoveryActive = false;
2529

2630
/**
2731
* @param {vscode.ExtensionContext} context
@@ -58,6 +62,7 @@ function deactivate() {
5862
offlineSince = null;
5963
statusBarItem?.hide();
6064
clearTimer();
65+
clearRecoveryTimer();
6166
}
6267

6368
// ---------------------------------------------------------------------------
@@ -104,6 +109,7 @@ async function refresh(promptSignIn = false, isManual = false) {
104109
lastUpdatedAt = new Date();
105110
isOffline = false;
106111
offlineSince = null;
112+
clearRecoveryTimer();
107113
updateStatusBar(data);
108114
} catch (err) {
109115
const code = err?.code;
@@ -112,6 +118,7 @@ async function refresh(promptSignIn = false, isManual = false) {
112118
if (code !== 'NETWORK_ERROR' && code !== 'TIMEOUT') {
113119
isOffline = false;
114120
offlineSince = null;
121+
clearRecoveryTimer();
115122
}
116123

117124
if (code === 'AUTH') {
@@ -135,6 +142,7 @@ async function refresh(promptSignIn = false, isManual = false) {
135142
if (!isOffline) {
136143
isOffline = true;
137144
offlineSince = new Date();
145+
startRecoveryTimer();
138146
}
139147
if (lastData) {
140148
updateStatusBar(lastData);
@@ -300,6 +308,7 @@ function renderStatus({ text, tooltip, command = undefined, color = undefined })
300308
// ---------------------------------------------------------------------------
301309

302310
function resetTimer() {
311+
if (recoveryActive) return; // recovery mode owns the schedule; don't create a competing timer
303312
clearTimer();
304313
const { refreshIntervalMinutes } = getConfig();
305314
const n = Number(refreshIntervalMinutes);
@@ -314,6 +323,42 @@ function clearTimer() {
314323
}
315324
}
316325

326+
const RECOVERY_INTERVAL_MS = 10 * 1000; // 10 s
327+
328+
function startRecoveryTimer() {
329+
if (recoveryActive) return; // already running
330+
clearTimer(); // pause normal refresh while in recovery mode
331+
recoveryActive = true;
332+
_scheduleNextRecovery();
333+
}
334+
335+
/** Schedule one recovery attempt after RECOVERY_INTERVAL_MS.
336+
* After the attempt completes (success, error, or timeout), reschedules itself
337+
* if still in recovery mode — giving a clean 10 s gap after every outcome.
338+
*/
339+
function _scheduleNextRecovery() {
340+
recoveryTimer = setTimeout(async () => {
341+
recoveryTimer = undefined; // this timeout has fired
342+
if (!recoveryActive) return; // clearRecoveryTimer() was called while we were waiting
343+
if (!isOffline) {
344+
clearRecoveryTimer();
345+
return;
346+
}
347+
await refresh().catch(() => {}); // refresh() handles errors internally; .catch prevents stuck recovery on unexpected throw
348+
if (recoveryActive) _scheduleNextRecovery(); // still offline → retry in 10 s
349+
}, RECOVERY_INTERVAL_MS);
350+
}
351+
352+
function clearRecoveryTimer() {
353+
if (!recoveryActive) return;
354+
recoveryActive = false;
355+
if (recoveryTimer) {
356+
clearTimeout(recoveryTimer);
357+
recoveryTimer = undefined;
358+
}
359+
if (!deactivated) resetTimer(); // restore normal refresh schedule
360+
}
361+
317362
// ---------------------------------------------------------------------------
318363
// Config helper
319364
// ---------------------------------------------------------------------------
@@ -358,5 +403,15 @@ module.exports = {
358403
if ('lastData' in s) lastData = s.lastData;
359404
if ('lastUpdatedAt' in s) lastUpdatedAt = s.lastUpdatedAt;
360405
},
361-
_getState: () => ({ isOffline, offlineSince }),
406+
_getState: () => ({
407+
isOffline,
408+
offlineSince,
409+
recoveryTimerActive: recoveryActive,
410+
refreshTimerActive: !!refreshTimer,
411+
}),
412+
// Test-only: directly invoke timer lifecycle.
413+
_startRecoveryTimer: startRecoveryTimer,
414+
_clearRecoveryTimer: clearRecoveryTimer,
415+
_resetTimer: resetTimer,
416+
_clearTimer: clearTimer,
362417
};

tests/extension.test.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
'use strict';
22

3-
const { formatTimestamp, getConfig, buildTooltip, computeIsStale, _setState, _getState } = require('../src/extension');
3+
const {
4+
formatTimestamp,
5+
getConfig,
6+
buildTooltip,
7+
computeIsStale,
8+
_setState,
9+
_getState,
10+
_startRecoveryTimer,
11+
_clearRecoveryTimer,
12+
_resetTimer,
13+
_clearTimer,
14+
} = require('../src/extension');
415

516
// ---------------------------------------------------------------------------
617
// formatTimestamp
@@ -218,3 +229,75 @@ describe('offline state transitions', () => {
218229
expect(computeIsStale(isOffline, offlineSince)).toBe(true);
219230
});
220231
});
232+
233+
// ---------------------------------------------------------------------------
234+
// Recovery timer lifecycle
235+
// ---------------------------------------------------------------------------
236+
237+
describe('recovery timer', () => {
238+
beforeEach(() => {
239+
vi.useFakeTimers();
240+
// Reset all state and ensure no timer is running.
241+
_setState({ isOffline: false, offlineSince: null, lastData: null, lastUpdatedAt: null });
242+
_clearRecoveryTimer(); // may call resetTimer internally
243+
_clearTimer(); // ensure normal refresh timer is also cleared
244+
});
245+
246+
afterEach(() => {
247+
_clearRecoveryTimer(); // may call resetTimer internally
248+
_clearTimer(); // clear any refreshTimer left by resetTimer()
249+
vi.useRealTimers();
250+
});
251+
252+
it('startRecoveryTimer pauses the normal refresh timer (refreshTimerActive = false)', () => {
253+
_startRecoveryTimer();
254+
expect(_getState().refreshTimerActive).toBe(false);
255+
});
256+
257+
it('calling startRecoveryTimer twice does not create a second timer', () => {
258+
_startRecoveryTimer();
259+
const spy = vi.spyOn(global, 'setTimeout');
260+
_startRecoveryTimer();
261+
expect(spy).not.toHaveBeenCalled();
262+
spy.mockRestore();
263+
});
264+
265+
it('clearRecoveryTimer sets recoveryTimerActive = false', () => {
266+
_startRecoveryTimer();
267+
_clearRecoveryTimer();
268+
expect(_getState().recoveryTimerActive).toBe(false);
269+
});
270+
271+
it('clearRecoveryTimer restores the normal refresh timer (refreshTimerActive = true)', () => {
272+
_startRecoveryTimer();
273+
_clearRecoveryTimer();
274+
expect(_getState().refreshTimerActive).toBe(true);
275+
});
276+
277+
it('clearRecoveryTimer when no timer is active is a no-op', () => {
278+
expect(() => _clearRecoveryTimer()).not.toThrow();
279+
expect(_getState().recoveryTimerActive).toBe(false);
280+
});
281+
282+
it('resetTimer is a no-op while recovery mode is active (no competing timer)', () => {
283+
_startRecoveryTimer();
284+
expect(_getState().refreshTimerActive).toBe(false);
285+
// Simulate onDidChangeConfiguration calling resetTimer while offline.
286+
_resetTimer();
287+
// Should still have only the recovery timer — no competing normal refresh timer.
288+
expect(_getState().refreshTimerActive).toBe(false);
289+
expect(_getState().recoveryTimerActive).toBe(true);
290+
});
291+
292+
it('timer callback stops recovery and restores normal timer when back online', async () => {
293+
// isOffline is already false (default) — simulates connection restored before first tick.
294+
_startRecoveryTimer();
295+
expect(_getState().recoveryTimerActive).toBe(true);
296+
297+
// Advance past the 10 s interval; the callback runs, sees !isOffline, stops recovery.
298+
await vi.advanceTimersByTimeAsync(10_000);
299+
300+
expect(_getState().recoveryTimerActive).toBe(false);
301+
expect(_getState().refreshTimerActive).toBe(true);
302+
});
303+
});

0 commit comments

Comments
 (0)