From e17b14c37e0c7db1d6923b290a8ab14ad137315a Mon Sep 17 00:00:00 2001 From: zereight <42544600+zereight@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:27:53 +0900 Subject: [PATCH 1/4] fix: report a poll still in flight at the deadline as a timeout The rebase and wait_for_* pollers give each status request an AbortSignal.timeout for the time left before the deadline. When that request is still running at the deadline (a slow GitLab, or a poll that starts a few milliseconds before it), the abort surfaced as "The operation was aborted due to timeout" instead of the poller's own "Timed out waiting for terminal status". This also made the rebase_merge_request timeout test flaky in CI. Both pollers now share one helper that maps an abort by the deadline signal to the timeout error. --- CHANGELOG.md | 1 + index.ts | 38 +++++++++++++++++++++++-------- test/test-rebase-merge-request.ts | 38 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6fd3efd..3341ef93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - `merge_merge_request` no longer strips `sha`, which GitLab 19.2+ groups may require when merging ([#665](https://github.com/zereight/gitlab-mcp/issues/665)) - `list_job_artifacts` no longer fails when GitLab returns `mode: null` on artifact tree entries ([#661](https://github.com/zereight/gitlab-mcp/issues/661)) - `create_tag` no longer fails when GitLab omits `release` from the tag response ([#653](https://github.com/zereight/gitlab-mcp/issues/653)) +- `wait_for_pipeline`, `wait_for_job`, `play_pipeline_jobs`, and `rebase_merge_request` now report `Timed out waiting for terminal status` when a status request is still running at `timeout_seconds`, instead of `The operation was aborted due to timeout` ##### Deprecated diff --git a/index.ts b/index.ts index b01fadafd..569eda2eb 100644 --- a/index.ts +++ b/index.ts @@ -9262,16 +9262,38 @@ function delayForRebasePoll(delayMs: number): Promise { }); } -async function getMergeRequestRebaseStatusBeforeDeadline( - projectId: string, - mergeRequestIid: string | number, +/** + * Fetch one poll result inside the deadline. When the deadline signal aborts the + * request, the wait has timed out, so report that instead of the abort. + */ +async function fetchStatusBeforeDeadline( + fetchStatus: (signal: AbortSignal) => Promise, deadline: number -): Promise<{ status: string; mergeRequest: GitLabMergeRequest }> { +): Promise { const remaining = deadline - Date.now(); if (remaining <= 0) { throw new Error("Timed out waiting for terminal status"); } - return getMergeRequestRebaseStatus(projectId, mergeRequestIid, AbortSignal.timeout(remaining)); + const signal = AbortSignal.timeout(remaining); + try { + return await fetchStatus(signal); + } catch (error) { + if (signal.aborted) { + throw new Error("Timed out waiting for terminal status", { cause: error }); + } + throw error; + } +} + +async function getMergeRequestRebaseStatusBeforeDeadline( + projectId: string, + mergeRequestIid: string | number, + deadline: number +): Promise<{ status: string; mergeRequest: GitLabMergeRequest }> { + return fetchStatusBeforeDeadline( + signal => getMergeRequestRebaseStatus(projectId, mergeRequestIid, signal), + deadline + ); } /** @@ -9349,11 +9371,7 @@ async function waitForStatus( pollIntervalSeconds = 5 ): Promise { const deadline = Date.now() + timeoutSeconds * 1000; - const fetchBeforeDeadline = async (): Promise => { - const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Timed out waiting for terminal status"); - return fetchStatus(AbortSignal.timeout(remaining)); - }; + const fetchBeforeDeadline = (): Promise => fetchStatusBeforeDeadline(fetchStatus, deadline); let value = await fetchBeforeDeadline(); while (!terminalStatuses.has(value.status)) { const remaining = deadline - Date.now(); diff --git a/test/test-rebase-merge-request.ts b/test/test-rebase-merge-request.ts index a8bf6c66d..55824c9a2 100644 --- a/test/test-rebase-merge-request.ts +++ b/test/test-rebase-merge-request.ts @@ -26,6 +26,7 @@ const MR_FORBIDDEN = "6"; const MR_STALE_THEN_MOVED = "7"; const MR_NOOP = "8"; const MR_RECHECK_TIMEOUT = "9"; +const MR_SLOW_POLL = "10"; function buildMergeRequest(iid: string, overrides: Record = {}) { return { @@ -236,6 +237,7 @@ describe("rebase_merge_request tool", () => { MR_STALE_THEN_MOVED, MR_NOOP, MR_RECHECK_TIMEOUT, + MR_SLOW_POLL, ]) { addRebaseHandler(iid); } @@ -272,6 +274,24 @@ describe("rebase_merge_request tool", () => { { rebase_in_progress: false, sha: NEW_SHA }, ]); + // The head read answers at once; the poll after the rebase answers after the 1s timeout. + const slowPollQueries: Array> = []; + statusQueries.set(MR_SLOW_POLL, slowPollQueries); + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/merge_requests/${MR_SLOW_POLL}`, + (req, res) => { + slowPollQueries.push({ ...req.query }); + if (slowPollQueries.length === 1) { + res.json(buildMergeRequest(MR_SLOW_POLL, { rebase_in_progress: false })); + return; + } + setTimeout(() => { + res.json(buildMergeRequest(MR_SLOW_POLL, { rebase_in_progress: true })); + }, 1500); + } + ); + mockGitLab.addMockHandler( "put", `/projects/${TEST_PROJECT_ID}/merge_requests/${MR_FORBIDDEN}/rebase`, @@ -365,6 +385,24 @@ describe("rebase_merge_request tool", () => { ); }); + test("rebase_merge_request reports a timeout when a poll is still in flight at the deadline", async () => { + await assert.rejects( + () => + callTool( + "rebase_merge_request", + { + project_id: TEST_PROJECT_ID, + merge_request_iid: MR_SLOW_POLL, + timeout_seconds: 1, + poll_interval_seconds: 1, + }, + env() + ), + /Timed out waiting for terminal status/ + ); + assert.strictEqual(statusQueries.get(MR_SLOW_POLL)?.length, 2); + }); + test("rebase_merge_request keeps waiting when the first finished poll still has the old sha", async () => { const result = await callTool( "rebase_merge_request", From 922df7355e9023a3e2f06a121c1b8a9fa0686be5 Mon Sep 17 00:00:00 2001 From: zereight <42544600+zereight@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:10:40 +0900 Subject: [PATCH 2/4] fix: keep errors the deadline signal did not cause An aborted signal only says the deadline passed, not why the status request failed. Map a rejection to the timeout error only when it is the signal's own reason, which is what fetch rejects with on abort, and let any other error through. Move the helper to utils/poll-deadline.ts so it has unit tests, including an unrelated error that lands after the deadline. --- index.ts | 24 +--------- test/utils/poll-deadline.test.ts | 79 ++++++++++++++++++++++++++++++++ utils/poll-deadline.ts | 26 +++++++++++ 3 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 test/utils/poll-deadline.test.ts create mode 100644 utils/poll-deadline.ts diff --git a/index.ts b/index.ts index 569eda2eb..1c5f09e38 100644 --- a/index.ts +++ b/index.ts @@ -228,6 +228,7 @@ import { import { checkForNewVersion } from "./utils/version-check.js"; import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js"; import { fetchWithValidatedRedirects } from "./utils/safe-redirect-fetch.js"; +import { fetchStatusBeforeDeadline } from "./utils/poll-deadline.js"; import { parseGitLabVersionApiResponse, type GitLabInstanceVersionMetadata, @@ -9262,29 +9263,6 @@ function delayForRebasePoll(delayMs: number): Promise { }); } -/** - * Fetch one poll result inside the deadline. When the deadline signal aborts the - * request, the wait has timed out, so report that instead of the abort. - */ -async function fetchStatusBeforeDeadline( - fetchStatus: (signal: AbortSignal) => Promise, - deadline: number -): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - throw new Error("Timed out waiting for terminal status"); - } - const signal = AbortSignal.timeout(remaining); - try { - return await fetchStatus(signal); - } catch (error) { - if (signal.aborted) { - throw new Error("Timed out waiting for terminal status", { cause: error }); - } - throw error; - } -} - async function getMergeRequestRebaseStatusBeforeDeadline( projectId: string, mergeRequestIid: string | number, diff --git a/test/utils/poll-deadline.test.ts b/test/utils/poll-deadline.test.ts new file mode 100644 index 000000000..b3517333d --- /dev/null +++ b/test/utils/poll-deadline.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { fetchStatusBeforeDeadline, POLL_TIMEOUT_MESSAGE } from "../../utils/poll-deadline.js"; + +// AbortSignal.timeout does not keep the process alive, so hold it open until the abort. +function rejectAfterAbort(signal: AbortSignal, error: () => unknown): Promise { + return new Promise((_resolve, reject) => { + const keepAlive = setInterval(() => {}, 1000); + signal.addEventListener( + "abort", + () => { + clearInterval(keepAlive); + reject(error()); + }, + { once: true } + ); + }); +} + +describe("When fetchStatusBeforeDeadline runs", () => { + test("should return the fetched value before the deadline", async () => { + const value = await fetchStatusBeforeDeadline(async () => "finished", Date.now() + 1000); + + assert.equal(value, "finished"); + }); + + test("should time out without fetching once the deadline has passed", async () => { + let fetched = false; + + await assert.rejects( + fetchStatusBeforeDeadline(async () => { + fetched = true; + return "finished"; + }, Date.now() - 1), + { message: POLL_TIMEOUT_MESSAGE } + ); + assert.equal(fetched, false); + }); + + test("should report an abort by the deadline signal as a timeout", async () => { + let reason: unknown; + + await assert.rejects( + fetchStatusBeforeDeadline( + signal => + rejectAfterAbort(signal, () => { + reason = signal.reason; + return signal.reason; + }), + Date.now() + 20 + ), + (error: Error) => { + assert.equal(error.message, POLL_TIMEOUT_MESSAGE); + assert.equal(error.cause, reason); + return true; + } + ); + }); + + test("should keep another error that lands after the deadline", async () => { + const failure = new Error("GitLab API error: 500 Internal Server Error"); + + await assert.rejects( + fetchStatusBeforeDeadline(signal => rejectAfterAbort(signal, () => failure), Date.now() + 20), + error => error === failure + ); + }); + + test("should keep an error that lands before the deadline", async () => { + const failure = new Error("GitLab API error: 404 Not Found"); + + await assert.rejects( + fetchStatusBeforeDeadline(async () => { + throw failure; + }, Date.now() + 1000), + error => error === failure + ); + }); +}); diff --git a/utils/poll-deadline.ts b/utils/poll-deadline.ts new file mode 100644 index 000000000..8cfc86d4a --- /dev/null +++ b/utils/poll-deadline.ts @@ -0,0 +1,26 @@ +export const POLL_TIMEOUT_MESSAGE = "Timed out waiting for terminal status"; + +/** + * Fetch one poll result inside the deadline. When the deadline signal aborts the + * request, the wait has timed out, so report that instead of the abort. Any other + * rejection passes through unchanged, even one that lands after the deadline. + */ +export async function fetchStatusBeforeDeadline( + fetchStatus: (signal: AbortSignal) => Promise, + deadline: number +): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(POLL_TIMEOUT_MESSAGE); + } + const signal = AbortSignal.timeout(remaining); + try { + return await fetchStatus(signal); + } catch (error) { + // fetch rejects with signal.reason itself when this signal aborts it. + if (signal.aborted && error === signal.reason) { + throw new Error(POLL_TIMEOUT_MESSAGE, { cause: error }); + } + throw error; + } +} From bb556fa8eb1f1250a9fe1d8ae983472d3f6a31d3 Mon Sep 17 00:00:00 2001 From: zereight <42544600+zereight@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:27:39 +0900 Subject: [PATCH 3/4] fix: treat a body read aborted by the deadline as a timeout When the deadline fires after the headers arrive but before the body is read, undici throws a new AbortError rather than signal.reason. Map that to the timeout error too, still only when the deadline signal has fired. A test reads the body of a real fetch after the abort. --- test/utils/poll-deadline.test.ts | 40 +++++++++++++++++++++++++++++--- utils/poll-deadline.ts | 9 +++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/test/utils/poll-deadline.test.ts b/test/utils/poll-deadline.test.ts index b3517333d..ccbd79b62 100644 --- a/test/utils/poll-deadline.test.ts +++ b/test/utils/poll-deadline.test.ts @@ -1,22 +1,33 @@ import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { describe, test } from "node:test"; import { fetchStatusBeforeDeadline, POLL_TIMEOUT_MESSAGE } from "../../utils/poll-deadline.js"; // AbortSignal.timeout does not keep the process alive, so hold it open until the abort. -function rejectAfterAbort(signal: AbortSignal, error: () => unknown): Promise { - return new Promise((_resolve, reject) => { +function waitForAbort(signal: AbortSignal): Promise { + return new Promise(resolve => { + if (signal.aborted) { + resolve(); + return; + } const keepAlive = setInterval(() => {}, 1000); signal.addEventListener( "abort", () => { clearInterval(keepAlive); - reject(error()); + resolve(); }, { once: true } ); }); } +async function rejectAfterAbort(signal: AbortSignal, error: () => unknown): Promise { + await waitForAbort(signal); + throw error(); +} + describe("When fetchStatusBeforeDeadline runs", () => { test("should return the fetched value before the deadline", async () => { const value = await fetchStatusBeforeDeadline(async () => "finished", Date.now() + 1000); @@ -57,6 +68,29 @@ describe("When fetchStatusBeforeDeadline runs", () => { ); }); + test("should report a body read that starts after the deadline as a timeout", async () => { + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"status":"running"}'); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + try { + await assert.rejects( + fetchStatusBeforeDeadline(async signal => { + const response = await fetch(`http://127.0.0.1:${port}/`, { signal }); + await waitForAbort(signal); + return response.json(); + }, Date.now() + 500), + { message: POLL_TIMEOUT_MESSAGE } + ); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + }); + test("should keep another error that lands after the deadline", async () => { const failure = new Error("GitLab API error: 500 Internal Server Error"); diff --git a/utils/poll-deadline.ts b/utils/poll-deadline.ts index 8cfc86d4a..419fb12d9 100644 --- a/utils/poll-deadline.ts +++ b/utils/poll-deadline.ts @@ -1,5 +1,9 @@ export const POLL_TIMEOUT_MESSAGE = "Timed out waiting for terminal status"; +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + /** * Fetch one poll result inside the deadline. When the deadline signal aborts the * request, the wait has timed out, so report that instead of the abort. Any other @@ -17,8 +21,9 @@ export async function fetchStatusBeforeDeadline( try { return await fetchStatus(signal); } catch (error) { - // fetch rejects with signal.reason itself when this signal aborts it. - if (signal.aborted && error === signal.reason) { + // fetch rejects with signal.reason itself when this signal aborts it. A body read + // that starts after the abort throws a new AbortError instead. + if (signal.aborted && (error === signal.reason || isAbortError(error))) { throw new Error(POLL_TIMEOUT_MESSAGE, { cause: error }); } throw error; From f6905f290adb93df21a1c776debd813af50e6fac Mon Sep 17 00:00:00 2001 From: zereight <42544600+zereight@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:42:29 +0900 Subject: [PATCH 4/4] test: check that the body-read case is the one that timed out Assert the AbortError cause, which only the body-read path leaves; an abort of the request itself would leave the TimeoutError reason. Give the headers a full second to arrive on a loaded machine. --- test/utils/poll-deadline.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/utils/poll-deadline.test.ts b/test/utils/poll-deadline.test.ts index ccbd79b62..d68780d23 100644 --- a/test/utils/poll-deadline.test.ts +++ b/test/utils/poll-deadline.test.ts @@ -82,8 +82,13 @@ describe("When fetchStatusBeforeDeadline runs", () => { const response = await fetch(`http://127.0.0.1:${port}/`, { signal }); await waitForAbort(signal); return response.json(); - }, Date.now() + 500), - { message: POLL_TIMEOUT_MESSAGE } + }, Date.now() + 1000), + (error: Error) => { + assert.equal(error.message, POLL_TIMEOUT_MESSAGE); + // An abort of the request itself would leave the TimeoutError reason here. + assert.equal((error.cause as Error).name, "AbortError"); + return true; + } ); } finally { server.closeAllConnections();