fix(sessions): honor offline settings in background catalog refresher

The background model catalog refresher always requested a network refresh,
so sessiond fetched provider catalogs on a schedule even when the operator
set PI_OFFLINE or PI_WEB_OFFLINE. Before the refresher existed, those
settings made every runtime refresh local-only.

Add `offlineModeEnabled()` to the config module and inject the resulting
flag from sessiond's frozen daemon environment, so the refresher schedules
nothing and ignores auth-triggered requests in offline mode. The narrower
PI_SKIP_VERSION_CHECK / PI_WEB_SKIP_VERSION_CHECK keys are deliberately not
included: they only suppress release lookups.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-25 12:40:33 +02:00
parent ed9c2f65bb
commit acda1cc0be
5 changed files with 84 additions and 8 deletions
@@ -31,9 +31,10 @@ function createRuntime() {
}
function createLogger() {
const info = vi.fn();
const warn = vi.fn();
const error = vi.fn();
return { logger: { warn, error }, warn, error };
return { logger: { info, warn, error }, info, warn, error };
}
/** Let a started refresh run to completion, including the finally-queue bookkeeping. */
@@ -123,6 +124,21 @@ describe("ModelCatalogRefresher", () => {
expect(refresh).toHaveBeenCalledOnce();
});
it("never touches the network when offline mode is enabled", async () => {
const runtime = createRuntime();
const { logger, info } = createLogger();
const refresher = new ModelCatalogRefresher({ runtime, logger, offline: true, initialDelayMs: 1_000, intervalMs: 60_000 });
refresher.start();
await vi.advanceTimersByTimeAsync(300_000);
refresher.requestRefresh();
await flushMicrotasks();
expect(runtime.refresh).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith({}, "offline mode is enabled; skipping background model catalog refreshes");
refresher.dispose();
});
it("warns and keeps going when a refresh is aborted by its timeout", async () => {
const { logger, warn, error } = createLogger();
const refresh = vi.fn(() => Promise.resolve({ aborted: true, errors: new Map<string, Error>() }));
+24 -3
View File
@@ -10,8 +10,9 @@ const DEFAULT_INITIAL_DELAY_MS = 15_000;
/** Bound every catalog refresh so a stalled provider fetch can never block for minutes. */
const DEFAULT_TIMEOUT_MS = 15_000;
/** Minimal structured-logging seam for non-fatal refresh failures. */
/** Minimal structured-logging seam for refresh lifecycle and non-fatal failures. */
export interface ModelCatalogRefresherLogger {
info(details: Record<string, unknown>, message: string): void;
warn(details: Record<string, unknown>, message: string): void;
error(details: Record<string, unknown>, message: string): void;
}
@@ -19,12 +20,19 @@ export interface ModelCatalogRefresherLogger {
export interface ModelCatalogRefresherOptions {
runtime: Pick<ModelRuntime, "refresh">;
logger?: ModelCatalogRefresherLogger;
/**
* When true the operator asked for offline behavior, so no network refresh is
* ever attempted. Injected from the daemon environment instead of read from
* `process.env` here, so the decision stays explicit and testable.
*/
offline?: boolean;
intervalMs?: number;
initialDelayMs?: number;
timeoutMs?: number;
}
const noopLogger: ModelCatalogRefresherLogger = {
info() { /* no-op */ },
warn() { /* no-op */ },
error() { /* no-op */ },
};
@@ -38,10 +46,15 @@ const noopLogger: ModelCatalogRefresherLogger = {
* bounded by an abort timeout, serialized through one in-flight run, and off
* any request path. `requestRefresh()` additionally asks for a prompt refresh
* after events that change what should be listed, such as provider logins.
*
* When the operator asked for offline behavior (`PI_OFFLINE` / `PI_WEB_OFFLINE`),
* the refresher performs no network I/O at all and the cached catalogs in
* models-store.json are used as they are.
*/
export class ModelCatalogRefresher {
private readonly runtime: Pick<ModelRuntime, "refresh">;
private readonly logger: ModelCatalogRefresherLogger;
private readonly offline: boolean;
private readonly intervalMs: number;
private readonly initialDelayMs: number;
private readonly timeoutMs: number;
@@ -54,6 +67,7 @@ export class ModelCatalogRefresher {
constructor(options: ModelCatalogRefresherOptions) {
this.runtime = options.runtime;
this.logger = options.logger ?? noopLogger;
this.offline = options.offline ?? false;
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
this.initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -61,15 +75,22 @@ export class ModelCatalogRefresher {
start(): void {
if (this.disposed) return;
if (this.offline) {
this.logger.info({}, "offline mode is enabled; skipping background model catalog refreshes");
return;
}
this.initialTimer = setTimeout(() => { this.requestRefresh(); }, this.initialDelayMs);
this.initialTimer.unref();
this.intervalTimer = setInterval(() => { this.requestRefresh(); }, this.intervalMs);
this.intervalTimer.unref();
}
/** Ask for a refresh, coalescing concurrent and overlapping requests. */
/**
* Ask for a refresh, coalescing concurrent and overlapping requests. A no-op
* in offline mode, so auth changes never trigger network I/O either.
*/
requestRefresh(): void {
if (this.disposed) return;
if (this.disposed || this.offline) return;
if (this.inflight !== undefined) {
this.queued = true;
return;