Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion shared/core/scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import {applyProviderResult, createProviderState} from './state.js';

export const DEFAULT_POLL_INTERVAL_MS = 180_000;

// A provider request that never settles blocks that provider's queue forever.
// It cannot be given a deadline where it runs: Qt's QML XMLHttpRequest
// implements neither `timeout` nor `ontimeout`, and the QML JS engine has no
// setTimeout. The poll itself is the only clock available, so a request still
// in flight after this long is abandoned on the next attempt.
export const DEFAULT_STALLED_REQUEST_MS = 120_000;

function normalizeProviders(providersInput) {
if (Array.isArray(providersInput)) {
return providersInput.map((provider, index) => {
Expand Down Expand Up @@ -34,6 +41,8 @@ export function createScheduler(options = {}) {
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const nowIso = options.nowIso ?? (() => new Date().toISOString());
const onUpdate = options.onUpdate ?? null;
const nowMs = options.nowMs ?? (() => Date.now());
const stalledRequestMs = options.stalledRequestMs ?? DEFAULT_STALLED_REQUEST_MS;
const setIntervalFn = options.setIntervalFn ?? globalThis.setInterval;
const clearIntervalFn = options.clearIntervalFn ?? globalThis.clearInterval;
const backoffManager = options.backoffManager ?? createBackoffManager();
Expand All @@ -56,6 +65,17 @@ export function createScheduler(options = {}) {
if (!state || !provider || typeof provider.getUsage !== 'function')
return Promise.resolve();

// Abandon a request that has hung: the queue is a promise chain, so
// one that never settles stalls every later refresh. Dropping the
// chain loses the dangling request, not the provider.
if (state.inFlight
&& state.inFlightSinceMs !== null
&& nowMs() - state.inFlightSinceMs >= stalledRequestMs) {
state.inFlight = false;
state.inFlightSinceMs = null;
state.queue = Promise.resolve();
}

if (backoffManager.shouldBackoff(name))
return Promise.resolve();

Expand All @@ -64,6 +84,7 @@ export function createScheduler(options = {}) {

state.queue = state.queue.then(async () => {
state.inFlight = true;
state.inFlightSinceMs = nowMs();
notify();

let result;
Expand All @@ -76,7 +97,14 @@ export function createScheduler(options = {}) {

applyProviderResult(state, result, requestId, nowIso());
backoffManager.recordResult(name, result);
state.inFlight = false;

// Only the newest request owns the flag: an abandoned one that
// finally settles must not clear it out from under its successor.
if (state.latestRequestedRequestId === requestId) {
state.inFlight = false;
state.inFlightSinceMs = null;
}

notify();
});

Expand Down
1 change: 1 addition & 0 deletions shared/core/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function createProviderState(name) {
return {
name,
inFlight: false,
inFlightSinceMs: null,
latestRequestedRequestId: 0,
latestAppliedRequestId: 0,
code: null,
Expand Down
49 changes: 49 additions & 0 deletions test/unit/scheduler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,53 @@ describe('scheduler', () => {
await scheduler.refresh();
expect(calls).toBe(2);
});

test('abandons a request that never settles instead of stalling the queue', async () => {
let nowMs = 0;
let calls = 0;
const hung = deferred();

const scheduler = createScheduler({
nowMs: () => nowMs,
stalledRequestMs: 1000,
providers: {
claude: {
async getUsage() {
calls += 1;

// The first request models a socket left dangling by a
// suspend: it neither resolves nor rejects.
if (calls === 1)
return hung.promise;

return {
ok: true,
data: {
sessionRemainingPct: 45,
weeklyRemainingPct: 25,
},
};
},
},
},
});

void scheduler.refresh();
await flushMicrotasks();
expect(calls).toBe(1);

// Still inside the grace period: the queue is legitimately busy.
nowMs = 999;
void scheduler.refresh();
await flushMicrotasks();
expect(calls).toBe(1);

// Past it. The hung request is dropped and a fresh one goes out.
nowMs = 1000;
await scheduler.refresh();
expect(calls).toBe(2);

const summary = scheduler.getSummary();
expect(summary.providers.claude.data.weeklyRemainingPct).toBe(25);
});
});