From 69a3b3f7e0ea4264c9e403963913586af7cbe827 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 19:03:18 +0100 Subject: [PATCH] The forge tools cut a request the forge never answers and refuse a file too big for an answer without fetching it: a 5 MB read stalled every read (issue 1577) --- modules/gitea/client.ts | 46 +++++++++++++++++++++++++++---- modules/gitea/test/paging.test.ts | 45 ++++++++++++++++++++++++++++++ modules/gitea/tools/index.ts | 2 +- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index a36e113f..16571f17 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -174,8 +174,27 @@ function ignoreSecretSetting(cfg: Record, key: string): void { console.error(`the setting ${key} is ignored: it comes from the vault`); } +/** How long the forge may take to answer one request before it is cut and said (novox/hq issue 1577: a request for a 5 MB + * file never ended, and every read through the module timed out behind it). */ +export const FORGE_REQUEST_TIMEOUT_MS = 20_000; + +/** The most a tool's answer carries of one file: the bus's message bound is 1 MiB and a file travels as text (issue 1577). */ +export const MAX_FILE_BYTES = 512 * 1024; + +/** One request to the forge, cut at the limit and failing loud naming the request. */ +async function timedFetch(url: string, init: RequestInit, label: string, timeoutMs: number): Promise { + try { + return await fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) }); + } catch (err) { + if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Gitea API ${label}: no answer in ${timeoutMs / 1000} s, the request was cut`); + throw err; + } +} + export class GiteaClient { readonly baseUrl: string; + /** The limit of one request; a test sets it short. */ + requestTimeoutMs = FORGE_REQUEST_TIMEOUT_MS; private readonly tokens: TokenSource; /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */ @@ -230,14 +249,14 @@ export class GiteaClient { private send(path: string, options: RequestInit, token: string): Promise { checkSentPath(path); - return fetch(`${this.baseUrl}/api/v1${path}`, { + return timedFetch(`${this.baseUrl}/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: `token ${token}`, ...(options.headers as Record | undefined), }, - }); + }, `${options.method ?? "GET"} ${path}`, this.requestTimeoutMs); } /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. @@ -771,6 +790,21 @@ export class GiteaClient { /** One file's contents at a ref (default the repository's default branch), decoded. */ async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> { const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ""; + // The size first, from the file's directory listing, which carries no content: a file over what an answer can carry is + // refused without its body being fetched (issue 1577). A file the listing does not show is asked for as before. + const clean = path.replace(/^\/+|\/+$/g, ""); + const slash = clean.lastIndexOf("/"); + const dir = slash < 0 ? "" : clean.slice(0, slash); + let listing: any[] | null = null; + try { + listing = await this.request(`${repoPath(owner, repo)}/contents${dir ? "/" + pathSegments("file path", dir) : ""}${qs}`); + } catch (err) { + if (!(err instanceof Error) || !/: 404 /.test(err.message)) throw err; + } + const entry = Array.isArray(listing) ? listing.find((e) => e?.path === clean) : undefined; + if (entry && Number(entry.size) > MAX_FILE_BYTES) { + throw new Error(`the file is ${Number(entry.size)} bytes, over the ${MAX_FILE_BYTES / 1024} KiB a tool answer can carry; read it from a clone`); + } const f = await this.request(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`); if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`); const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? ""); @@ -940,14 +974,14 @@ export class GiteaAdmin { * throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an * unexpected status becomes an error, and only where the caller says so. */ private async request(path: string, options: RequestInit = {}): Promise { - const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + const res = await timedFetch(`${this.baseUrl}/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: this.authorization, ...(options.headers as Record | undefined), }, - }); + }, `${options.method ?? "GET"} ${path}`, FORGE_REQUEST_TIMEOUT_MS); const text = await res.text(); let body: any = null; if (text) { @@ -1192,9 +1226,9 @@ export class GiteaAdmin { * other answer rejects (novox/hq issue 120). */ async holdsTeamMember(org: string, team: string, username: string, password: string): Promise { - const me = await fetch(`${this.baseUrl}/api/v1/user`, { + const me = await timedFetch(`${this.baseUrl}/api/v1/user`, { headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") }, - }); + }, "GET /user", FORGE_REQUEST_TIMEOUT_MS); if (me.status === 401 || me.status === 403) return false; if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); diff --git a/modules/gitea/test/paging.test.ts b/modules/gitea/test/paging.test.ts index a9f40a32..d6eba14d 100644 --- a/modules/gitea/test/paging.test.ts +++ b/modules/gitea/test/paging.test.ts @@ -90,3 +90,48 @@ test("a pass pages until it reaches back past its floor, and no further", async server.close(); } }); + +// **One forge request never holds the others, and a file too big for an answer is not downloaded** (novox/hq issue 1577): a +// request for a 4.9 MB file never ended and every read through the module timed out behind it. +test("a request the forge never answers is cut at the limit and says which request", async () => { + const { GiteaClient } = await import("../client.ts"); + const server = createServer((req) => { if (!req.url?.includes("fast")) return; }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + client.requestTimeoutMs = 150; + try { + const started = Date.now(); + await assert.rejects(client.api("/repos/novox/hq/pulls"), /Gitea API GET \/repos\/novox\/hq\/pulls: no answer in 0\.15 s/); + assert.ok(Date.now() - started < 3000); + } finally { + server.closeAllConnections(); + await new Promise((r) => server.close(() => r())); + } +}); + +test("a file over 512 KiB is refused from its directory listing, and its body is never asked for", async () => { + const { GiteaClient } = await import("../client.ts"); + const asked: string[] = []; + const server = createServer((req, res) => { + asked.push(req.url ?? ""); + res.setHeader("Content-Type", "application/json"); + if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin?")) { + return res.end(JSON.stringify([{ path: "bin/big", type: "file", size: 5_000_000 }, { path: "bin/small", type: "file", size: 5 }])); + } + if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin/small")) { + return res.end(JSON.stringify({ type: "file", encoding: "base64", sha: "s", size: 5, content: Buffer.from("hello").toString("base64") })); + } + res.statusCode = 404; + res.end("{}"); + }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + try { + await assert.rejects(client.getFile("novox", "hq", "bin/big", "main"), + /the file is 5000000 bytes, over the 512 KiB a tool answer can carry; read it from a clone/); + assert.ok(!asked.some((u) => u.includes("/contents/bin/big")), asked.join("\n")); + assert.equal((await client.getFile("novox", "hq", "bin/small", "main")).content, "hello"); + } finally { + await new Promise((r) => server.close(() => r())); + } +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 8f21cdb1..a0a6c0b7 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -374,7 +374,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini // ---- Contents and branches ---- { name: "gitea_get_file", - description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).", + description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch). Refuses a file over 512 KiB, which a tool answer cannot carry: read it from a clone.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" },