Merge remote-tracking branch 'origin/farm/1f41837c/timeout-bare-fetches'

This commit is contained in:
can1357
2026-07-02 23:43:11 +02:00
8 changed files with 211 additions and 26 deletions
+1
View File
@@ -100,6 +100,7 @@
- Fixed `models.yml` schema validation failures being treated as valid config data, so invalid custom provider files now surface a warning instead of silently dropping all custom providers. ([#4305](https://github.com/can1357/oh-my-pi/issues/4305))
- Fixed stalled network calls in `omp update`, Hindsight recall, and Smithery registry lookup by adding fetch timeouts. ([#4229](https://github.com/can1357/oh-my-pi/issues/4229))
- Fixed stuttering/latency in speech by running synthesis chunks through the player gaplessly
- Fixed race condition causing EPIPE errors and broken pipes during speech playback
- Fixed interrupted speech audio by ensuring segments queue and drain in order
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { runUpdateCommand } from "./update-cli";
type FetchInput = string | URL | Request;
type FetchInit = RequestInit | BunFetchRequestInit;
describe("runUpdateCommand fetch cancellation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("checks release metadata with a timeout signal", async () => {
let requestSignal: AbortSignal | undefined;
vi.spyOn(console, "log").mockImplementation(() => {});
const fetchStub = Object.assign(
async (_input: FetchInput, init?: FetchInit) => {
requestSignal = init?.signal ?? undefined;
return Response.json({ version: "999.0.0" });
},
{ preconnect: globalThis.fetch.preconnect },
);
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
await runUpdateCommand({ force: false, check: true });
expect(requestSignal).toBeInstanceOf(AbortSignal);
});
});
+26 -2
View File
@@ -12,6 +12,7 @@ import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import chalk from "chalk";
import { theme } from "../modes/theme/theme";
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
const REPO = "can1357/oh-my-pi";
const PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -29,6 +30,8 @@ const MISE_TOOL = "github:can1357/oh-my-pi";
* See #1686.
*/
const NPM_REGISTRY = "https://registry.npmjs.org/";
const RELEASE_METADATA_TIMEOUT_MS = 30_000;
const BINARY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
/**
* Core native addon package. Bumped in lock-step with {@link PACKAGE} so the
@@ -240,7 +243,17 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
* Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
*/
async function getLatestRelease(): Promise<ReleaseInfo> {
const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
let response: Response;
try {
response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`, {
signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out fetching release info after 30s", { cause: err });
}
throw err;
}
if (!response.ok) {
throw new Error(`Failed to fetch release info: ${response.statusText}`);
}
@@ -833,7 +846,18 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
console.log(chalk.dim(`Downloading ${binaryName}…`));
const response = await fetch(url, { redirect: "follow" });
let response: Response;
try {
response = await fetch(url, {
redirect: "follow",
signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
}
throw err;
}
if (!response.ok || !response.body) {
throw new Error(`Download failed: ${response.statusText}`);
}
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { HindsightApi } from "./client";
type FetchInput = string | URL | Request;
type FetchInit = RequestInit | BunFetchRequestInit;
describe("HindsightApi fetch cancellation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("combines caller cancellation with the request timeout", async () => {
let requestSignal: AbortSignal | undefined;
const fetchStub = Object.assign(
async (_input: FetchInput, init?: FetchInit) => {
requestSignal = init?.signal ?? undefined;
return Response.json({ results: [] });
},
{ preconnect: globalThis.fetch.preconnect },
);
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
const caller = new AbortController();
const client = new HindsightApi({ baseUrl: "https://hindsight.example" });
await client.recall("bank", "query", { signal: caller.signal });
expect(requestSignal).toBeInstanceOf(AbortSignal);
expect(requestSignal).not.toBe(caller.signal);
caller.abort(new Error("caller aborted"));
expect(requestSignal?.aborted).toBe(true);
expect(requestSignal?.reason).toBe(caller.signal.reason);
});
});
+42 -22
View File
@@ -8,10 +8,12 @@
* tests to spy on.
*/
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
import type { HindsightConfig } from "./config";
const USER_AGENT = "oh-my-pi-coding-agent";
const DEFAULT_USER_AGENT = USER_AGENT;
const HINDSIGHT_REQUEST_TIMEOUT_MS = 30_000;
export type Budget = "low" | "mid" | "high" | string;
export type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
@@ -24,6 +26,11 @@ export interface HindsightApiOptions {
userAgent?: string;
}
/** Caller cancellation shared by Hindsight request option bags. */
export interface HindsightRequestOptions {
signal?: AbortSignal;
}
export interface RecallResult {
id?: string;
text: string;
@@ -77,7 +84,7 @@ export interface MemoryItemInput {
updateMode?: UpdateMode;
}
export interface RetainOptions {
export interface RetainOptions extends HindsightRequestOptions {
timestamp?: Date | string;
context?: string;
metadata?: Record<string, string>;
@@ -87,7 +94,7 @@ export interface RetainOptions {
updateMode?: UpdateMode;
}
export interface RetainBatchOptions {
export interface RetainBatchOptions extends HindsightRequestOptions {
/** Document id applied to every item that doesn't carry its own. */
documentId?: string;
/** Tags attached to the resulting document(s), not individual items. */
@@ -95,7 +102,7 @@ export interface RetainBatchOptions {
async?: boolean;
}
export interface RecallOptions {
export interface RecallOptions extends HindsightRequestOptions {
types?: string[];
maxTokens?: number;
budget?: Budget;
@@ -103,19 +110,19 @@ export interface RecallOptions {
tagsMatch?: TagsMatch;
}
export interface ReflectOptions {
export interface ReflectOptions extends HindsightRequestOptions {
context?: string;
budget?: Budget;
tags?: string[];
tagsMatch?: TagsMatch;
}
export interface CreateBankOptions {
export interface CreateBankOptions extends HindsightRequestOptions {
reflectMission?: string;
retainMission?: string;
}
export interface ListMemoriesOptions {
export interface ListMemoriesOptions extends HindsightRequestOptions {
limit?: number;
offset?: number;
type?: string;
@@ -123,12 +130,12 @@ export interface ListMemoriesOptions {
consolidationState?: ConsolidationState;
}
export interface ListDocumentsOptions {
export interface ListDocumentsOptions extends HindsightRequestOptions {
limit?: number;
offset?: number;
}
export interface UpdateDocumentOptions {
export interface UpdateDocumentOptions extends HindsightRequestOptions {
tags?: string[];
}
@@ -166,7 +173,7 @@ export interface MentalModelHistoryEntry {
[key: string]: unknown;
}
export interface CreateMentalModelOptions {
export interface CreateMentalModelOptions extends HindsightRequestOptions {
id?: string;
tags?: string[];
maxTokens?: number;
@@ -183,11 +190,11 @@ export interface RefreshMentalModelResponse {
[key: string]: unknown;
}
export interface ListMentalModelsOptions {
export interface ListMentalModelsOptions extends HindsightRequestOptions {
detail?: MentalModelDetail;
}
export interface GetMentalModelOptions {
export interface GetMentalModelOptions extends HindsightRequestOptions {
detail?: MentalModelDetail;
}
@@ -208,6 +215,7 @@ interface RequestOptions {
query?: Record<string, unknown>;
/** Return null instead of throwing on a 404 response. */
allow404?: boolean;
signal?: AbortSignal;
}
export class HindsightApi {
@@ -240,7 +248,10 @@ export class HindsightApi {
"POST",
`/v1/default/banks/${encodeURIComponent(bankId)}/memories`,
"retain",
{ body: { items: [item], async: options?.async } },
{
body: { items: [item], async: options?.async },
signal: options?.signal,
},
);
}
@@ -270,6 +281,7 @@ export class HindsightApi {
document_tags: options?.documentTags,
async: options?.async,
},
signal: options?.signal,
},
);
}
@@ -288,6 +300,7 @@ export class HindsightApi {
tags: options?.tags,
tags_match: options?.tagsMatch,
},
signal: options?.signal,
},
);
}
@@ -305,6 +318,7 @@ export class HindsightApi {
tags: options?.tags,
tags_match: options?.tagsMatch,
},
signal: options?.signal,
},
);
}
@@ -319,6 +333,7 @@ export class HindsightApi {
reflect_mission: options.reflectMission,
retain_mission: options.retainMission,
},
signal: options.signal,
},
);
}
@@ -340,6 +355,7 @@ export class HindsightApi {
limit: options?.limit,
offset: options?.offset,
},
signal: options?.signal,
},
);
}
@@ -350,7 +366,7 @@ export class HindsightApi {
"GET",
`/v1/default/banks/${encodeURIComponent(bankId)}/documents`,
"listDocuments",
{ query: { limit: options?.limit, offset: options?.offset } },
{ query: { limit: options?.limit, offset: options?.offset }, signal: options?.signal },
);
}
@@ -370,7 +386,7 @@ export class HindsightApi {
"PATCH",
`/v1/default/banks/${encodeURIComponent(bankId)}/documents/${encodeURIComponent(documentId)}`,
"updateDocument",
{ body: { tags: options.tags } },
{ body: { tags: options.tags }, signal: options.signal },
);
}
@@ -399,7 +415,7 @@ export class HindsightApi {
"GET",
`/v1/default/banks/${encodeURIComponent(bankId)}/mental-models`,
"listMentalModels",
{ query: { detail: options?.detail ?? "content" } },
{ query: { detail: options?.detail ?? "content" }, signal: options?.signal },
);
}
@@ -413,7 +429,7 @@ export class HindsightApi {
"GET",
`/v1/default/banks/${encodeURIComponent(bankId)}/mental-models/${encodeURIComponent(mentalModelId)}`,
"getMentalModel",
{ query: { detail: options?.detail ?? "content" }, allow404: true },
{ query: { detail: options?.detail ?? "content" }, allow404: true, signal: options?.signal },
);
}
@@ -441,6 +457,7 @@ export class HindsightApi {
max_tokens: options?.maxTokens,
trigger: options?.trigger,
},
signal: options?.signal,
},
);
}
@@ -489,7 +506,11 @@ export class HindsightApi {
if (qs) url += `?${qs}`;
}
const init: RequestInit = { method, headers: this.#headers };
const init: RequestInit = {
method,
headers: this.#headers,
signal: withTimeoutSignal(HINDSIGHT_REQUEST_TIMEOUT_MS, opts?.signal),
};
if (opts?.body !== undefined) {
init.body = JSON.stringify(pruneUndefined(opts.body));
}
@@ -498,11 +519,10 @@ export class HindsightApi {
try {
response = await fetch(url, init);
} catch (err) {
throw new HindsightError(
`${operation} request failed: ${err instanceof Error ? err.message : String(err)}`,
undefined,
err,
);
const message = isTimeoutError(err)
? `${operation} request timed out after 30s`
: `${operation} request failed: ${err instanceof Error ? err.message : String(err)}`;
throw new HindsightError(message, undefined, err);
}
if (opts?.allow404 && response.status === 404) {
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { searchSmitheryRegistry } from "./smithery-registry";
type FetchInput = string | URL | Request;
type FetchInit = RequestInit | BunFetchRequestInit;
describe("searchSmitheryRegistry fetch cancellation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("adds timeout signals to search and detail requests", async () => {
const signals: AbortSignal[] = [];
const fetchStub = Object.assign(
async (input: FetchInput, init?: FetchInit) => {
if (init?.signal instanceof AbortSignal) signals.push(init.signal);
const url = String(input);
if (url.includes("?")) {
return Response.json({
servers: [
{
id: "srv_1",
namespace: "smithery-ai",
slug: "filesystem",
qualifiedName: "@smithery-ai/filesystem",
displayName: "Filesystem",
description: "File access",
useCount: 1,
},
],
});
}
return Response.json({
qualifiedName: "@smithery-ai/filesystem",
displayName: "Filesystem",
description: "File access",
connections: [{ type: "http", deploymentUrl: "https://mcp.example" }],
tools: [],
});
},
{ preconnect: globalThis.fetch.preconnect },
);
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
const results = await searchSmitheryRegistry("filesystem", { limit: 1 });
expect(results[0]?.name).toBe("smithery-ai/filesystem");
expect(signals).toHaveLength(2);
expect(signals.every(signal => signal instanceof AbortSignal)).toBe(true);
});
});
@@ -1,7 +1,9 @@
import { logger } from "@oh-my-pi/pi-utils";
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
import type { MCPServerConfig } from "./types";
const SMITHERY_REGISTRY_BASE_URL = "https://registry.smithery.ai";
const SMITHERY_REGISTRY_TIMEOUT_MS = 10_000;
type SmitherySearchEntry = {
id?: string;
@@ -106,6 +108,7 @@ export interface SmitherySearchOptions {
limit?: number;
apiKey?: string;
includeSemantic?: boolean;
signal?: AbortSignal;
}
export class SmitheryRegistryError extends Error {
@@ -307,13 +310,17 @@ function createConfig(
};
}
async function fetchServerDetails(path: string, options?: { apiKey?: string }): Promise<SmitheryServerDetails | null> {
async function fetchServerDetails(
path: string,
options?: { apiKey?: string; signal?: AbortSignal },
): Promise<SmitheryServerDetails | null> {
const headers = new Headers();
if (options?.apiKey) {
headers.set("Authorization", `Bearer ${options.apiKey}`);
}
const response = await fetch(`${SMITHERY_REGISTRY_BASE_URL}/servers/${path}`, {
headers,
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
});
if (!response.ok) return null;
return (await response.json()) as SmitheryServerDetails;
@@ -411,7 +418,18 @@ export async function searchSmitheryRegistry(
url.searchParams.set("q", query);
url.searchParams.set("pageSize", String(pageSize));
if (page > 1) url.searchParams.set("page", String(page));
const response = await fetch(url.toString(), { headers });
let response: Response;
try {
response = await fetch(url.toString(), {
headers,
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new SmitheryRegistryError("Smithery search timed out after 10s", 0);
}
throw err;
}
if (!response.ok) {
throw new SmitheryRegistryError(`Smithery search failed with status ${response.status}`, response.status);
}
@@ -0,0 +1,10 @@
/** Create an abort signal that fires after a timeout and preserves caller cancellation. */
export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
/** Detect a timeout raised by an abortable fetch. */
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.name === "TimeoutError";
}