From 4a21f036743d8ae9e3c429ac043e29214e312254 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:20 +1000 Subject: [PATCH 01/34] fix: enforce provider body timeouts --- src/providers/request.ts | 66 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/src/providers/request.ts b/src/providers/request.ts index eca85b8..e4cc865 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -6,11 +6,36 @@ export async function fetchWithTimeout( label: string, timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller = new AbortController(), + externalSignal?: AbortSignal, ): Promise { let timedOut = false; + let timeoutError: Error | undefined; + let externalAbortListener: (() => void) | undefined; + + if (externalSignal) { + const abortFromExternal = () => { + controller.abort(externalSignal.reason); + }; + if (externalSignal.aborted) { + abortFromExternal(); + } else { + externalAbortListener = abortFromExternal; + externalSignal.addEventListener('abort', abortFromExternal, { once: true }); + } + } + + const cleanup = () => { + clearTimeout(timeout); + if (externalSignal && externalAbortListener) { + externalSignal.removeEventListener('abort', externalAbortListener); + externalAbortListener = undefined; + } + }; + const timeout = setTimeout(() => { timedOut = true; - controller.abort(); + timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`); + controller.abort(timeoutError); }, timeoutMs); try { @@ -18,12 +43,43 @@ export async function fetchWithTimeout( ...init, signal: controller.signal, }); - clearTimeout(timeout); - return response; + + if (!response.body) { + cleanup(); + return response; + } + + const reader = response.body.getReader(); + const wrappedBody = new ReadableStream({ + async pull(streamController) { + try { + const result = await reader.read(); + if (result.done) { + cleanup(); + streamController.close(); + return; + } + streamController.enqueue(result.value); + } catch (error) { + cleanup(); + streamController.error(timedOut ? timeoutError : error); + } + }, + async cancel(reason) { + cleanup(); + await reader.cancel(reason).catch(() => {}); + }, + }); + + return new Response(wrappedBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); } catch (error) { - clearTimeout(timeout); + cleanup(); if (timedOut) { - throw new Error(`${label} timed out after ${timeoutMs}ms`); + throw timeoutError; } throw error; } From e813cd96e2f440bcd936dbc1773a89f84f87a0b2 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:24 +1000 Subject: [PATCH 02/34] fix: allow provider request cancellation --- src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types.ts b/src/types.ts index cafdbe3..435ee8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,7 @@ export interface ChatParams { maxTokens?: number; apiKey: string; baseUrl: string; + signal?: AbortSignal; } export interface ChatResult { From dbe3b3eea81313a1a30f0e6cfe4e984f75a49b8e Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:28 +1000 Subject: [PATCH 03/34] fix: propagate hook cancellation to LLM calls --- src/llm/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/llm/client.ts b/src/llm/client.ts index b46f15f..9d8cbbb 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -39,6 +39,7 @@ export async function generateSuggestions( profileParam?: StyleProfile, apiKeyParam?: string, precomputedTruncation?: TruncationInfo, + signal?: AbortSignal, ): Promise<{ suggestions: Suggestion[]; profile: StyleProfile; @@ -73,6 +74,7 @@ export async function generateSuggestions( temperature: 0.7, maxTokens: 1024, apiKey, + signal, }); const parsed = parseSuggestions(result.content); From 5d023f9ffe7b23c313807d7a2dc85f9aae45005e Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:32 +1000 Subject: [PATCH 04/34] fix: propagate cancellation in OpenAI requests --- src/providers/openai-compatible.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/providers/openai-compatible.ts b/src/providers/openai-compatible.ts index 415b439..26c7b2d 100644 --- a/src/providers/openai-compatible.ts +++ b/src/providers/openai-compatible.ts @@ -43,6 +43,9 @@ export class OpenAICompatibleProvider implements Provider { body: JSON.stringify(buildOpenAiRequestBody(params)), }, 'OpenAI-compatible API request', + DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, + new AbortController(), + params.signal, ); if (!response.ok) { @@ -89,6 +92,7 @@ export class OpenAICompatibleProvider implements Provider { 'OpenAI-compatible streaming request', DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, + params.signal, ); if (!response.ok) { From f7878df8b221fd4d47d68bd0f1356406e64cba6c Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:37 +1000 Subject: [PATCH 05/34] fix: propagate cancellation in Anthropic requests --- src/providers/anthropic.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 0963f9a..56f05e1 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -48,6 +48,9 @@ export class AnthropicProvider implements Provider { body: JSON.stringify(body), }, 'Anthropic API request', + DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, + new AbortController(), + params.signal, ); if (!response.ok) { @@ -92,6 +95,7 @@ export class AnthropicProvider implements Provider { 'Anthropic streaming request', DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, + params.signal, ); if (!response.ok) { From 087d40a2b540bfa79925c62bc1e38996ce25a1f7 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:41 +1000 Subject: [PATCH 06/34] fix: propagate cancellation in Cohere requests --- src/providers/cohere.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/providers/cohere.ts b/src/providers/cohere.ts index 92fbec7..da14d97 100644 --- a/src/providers/cohere.ts +++ b/src/providers/cohere.ts @@ -1,5 +1,5 @@ import type { ChatParams, ChatResult, Provider } from '../types.js'; -import { fetchWithTimeout } from './request.js'; +import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, fetchWithTimeout } from './request.js'; export class CohereProvider implements Provider { async complete(params: ChatParams): Promise { @@ -40,6 +40,9 @@ export class CohereProvider implements Provider { body: JSON.stringify(body), }, 'Cohere API request', + DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, + new AbortController(), + params.signal, ); if (!response.ok) { From ec77a0fd1f19656e4e7008e401000c5ce0f710bd Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:51:45 +1000 Subject: [PATCH 07/34] fix: bound prepare-commit-msg latency --- src/git/hook.ts | 71 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index aa4040b..37e780d 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -15,6 +15,7 @@ const PREPARE_COMMIT_MSG_HOOK_NAME = 'prepare-commit-msg'; const POST_COMMIT_HOOK_NAME = 'post-commit'; const PENDING_HOOK_ENTRY_FILE = 'commit-echo-pending-entry.json'; const BACKUP_OWNER_MARKER = '# commit-echo managed backup'; +export const PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS = 5_000; export interface PrepareCommitMsgHookArgs { messageFile: string; @@ -29,6 +30,7 @@ export interface PostCommitHookDeps { appendHistoryEntry: (entry: CommitEntry) => Promise; removePendingEntryFile: () => Promise; warn: (message: string) => void; + timeoutMs?: number; } export interface PrepareCommitMsgHookDeps { @@ -638,19 +640,64 @@ export async function runPrepareCommitMsgHook( return; } - const profile = await deps.buildProfile(config.historySize); - const { suggestions } = await deps.generateSuggestions(config, diffResult.diff, profile); - const selected = suggestions[0]; - if (!selected) { - deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.'); - await clearPendingEntryFile(deps.removePendingEntryFile); - return; - } + const controller = new AbortController(); + const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS; + let timeoutError: Error | undefined; + let timeoutId: ReturnType | undefined; + + const ensureWithinDeadline = () => { + if (controller.signal.aborted) { + throw controller.signal.reason instanceof Error + ? controller.signal.reason + : (timeoutError ?? new Error('commit-echo hook request was cancelled')); + } + }; - const existingContent = await deps.readMessageFile(args.messageFile).catch(() => ''); - const nextContent = buildHookCommitMessage(selected, existingContent); - await deps.writeMessageFile(args.messageFile, nextContent); - await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); + const hookOperation = (async () => { + const profile = await deps.buildProfile(config.historySize); + ensureWithinDeadline(); + + const { suggestions } = await deps.generateSuggestions( + config, + diffResult.diff, + profile, + undefined, + undefined, + controller.signal, + ); + ensureWithinDeadline(); + + const selected = suggestions[0]; + if (!selected) { + deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.'); + return; + } + + const existingContent = await deps.readMessageFile(args.messageFile).catch(() => ''); + ensureWithinDeadline(); + + const nextContent = buildHookCommitMessage(selected, existingContent); + ensureWithinDeadline(); + await deps.writeMessageFile(args.messageFile, nextContent); + ensureWithinDeadline(); + await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); + })(); + + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timeoutError = new Error( + `commit-echo hook timed out after ${timeoutMs}ms; leaving commit message unchanged.`, + ); + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + }); + + try { + await Promise.race([hookOperation, timeout]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } } catch (err) { await clearPendingEntryFile(deps.removePendingEntryFile); const message = err instanceof Error ? err.message : String(err); From b9c8086a2547c018cc049c8c8e04da0cddfd65bf Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:52:07 +1000 Subject: [PATCH 08/34] test: cover provider body timeout --- tests/provider-timeout.test.mjs | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index 43107a1..eb30976 100644 --- a/tests/provider-timeout.test.mjs +++ b/tests/provider-timeout.test.mjs @@ -66,3 +66,38 @@ test('aborts provider requests that exceed the timeout', async () => { globalThis.fetch = originalFetch; } }); + +test('times out and aborts a response body that stalls after headers', async () => { + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + let sawAbort = false; + + globalThis.fetch = async (_url, init) => { + init.signal.addEventListener('abort', () => { + sawAbort = true; + }, { once: true }); + + const body = new ReadableStream({ + async pull() { + await new Promise(() => {}); + }, + }); + + return new Response(body); + }; + + try { + const response = await fetchWithTimeout( + 'https://example.invalid/models', + {}, + 'Provider request', + 5, + controller, + ); + await assert.rejects(response.text(), /Provider request timed out after 5ms/); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(sawAbort, true); +}); From d954bffcd296e91c9693d6a021d9886f5f553ad0 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:52:11 +1000 Subject: [PATCH 09/34] test: bound prepare hook latency --- tests/hook.test.mjs | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index c174343..3f5b043 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -915,3 +915,60 @@ test('runPostCommitHook clears pending entry when history append fails', async ( assert.equal(removed, true); }); + +test('runPrepareCommitMsgHook times out LLM work without changing the message', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-timeout-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + const originalMessage = 'original commit title\n'; + writeFileSync(messageFile, originalMessage, 'utf-8'); + + try { + let aborted = false; + let pendingCleared = 0; + let warning = ''; + + const deps = { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ diff: 'diff --git a/file b/file\n+hello', hasChanges: true, staged: true }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async (_config, _diff, _profile, _apiKey, _truncation, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + aborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, content) => writeFileSync(filePath, content, 'utf-8'), + writePendingEntryFile: async () => { + throw new Error('pending entry should not be written after timeout'); + }, + removePendingEntryFile: async () => { + pendingCleared += 1; + }, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }; + + await runPrepareCommitMsgHook({ messageFile, source: 'template' }, deps); + + assert.equal(aborted, true); + assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); + assert.equal(pendingCleared, 1); + assert.match(warning, /timed out after 20ms/); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); From 098a14f2f8d8db07d329b7bf98d39a8b68da682a Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:52:36 +1000 Subject: [PATCH 10/34] fix: avoid uninitialized timeout closure --- src/providers/request.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/providers/request.ts b/src/providers/request.ts index e4cc865..04d335d 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -24,6 +24,8 @@ export async function fetchWithTimeout( } } + let timeout: ReturnType; + const cleanup = () => { clearTimeout(timeout); if (externalSignal && externalAbortListener) { @@ -32,7 +34,7 @@ export async function fetchWithTimeout( } }; - const timeout = setTimeout(() => { + timeout = setTimeout(() => { timedOut = true; timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`); controller.abort(timeoutError); From 2305ce7b610ecf36d3b420dac6ae6293539045be Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:52:40 +1000 Subject: [PATCH 11/34] test: make stalled body honor abort --- tests/provider-timeout.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index eb30976..1314824 100644 --- a/tests/provider-timeout.test.mjs +++ b/tests/provider-timeout.test.mjs @@ -78,8 +78,10 @@ test('times out and aborts a response body that stalls after headers', async () }, { once: true }); const body = new ReadableStream({ - async pull() { - await new Promise(() => {}); + pull() { + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); + }); }, }); From 02d8fd1b4be7bb6fea81333bd0eee60f5b5c7c77 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:53:28 +1000 Subject: [PATCH 12/34] fix: type prepare hook timeout dependency --- src/git/hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 37e780d..bffc832 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -30,7 +30,6 @@ export interface PostCommitHookDeps { appendHistoryEntry: (entry: CommitEntry) => Promise; removePendingEntryFile: () => Promise; warn: (message: string) => void; - timeoutMs?: number; } export interface PrepareCommitMsgHookDeps { @@ -44,6 +43,7 @@ export interface PrepareCommitMsgHookDeps { writePendingEntryFile: (content: string) => Promise; removePendingEntryFile: () => Promise; warn: (message: string) => void; + timeoutMs?: number; } export interface InstalledCommitHooks { From ff103ce9fb59c12763fb558a2f7b1d04eff00025 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:53:57 +1000 Subject: [PATCH 13/34] chore: add temporary hook format inspection workflow --- .github/workflows/debug-hook-format.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/debug-hook-format.yml diff --git a/.github/workflows/debug-hook-format.yml b/.github/workflows/debug-hook-format.yml new file mode 100644 index 0000000..d57a359 --- /dev/null +++ b/.github/workflows/debug-hook-format.yml @@ -0,0 +1,19 @@ +name: debug-hook-format + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24 + - run: npm ci --ignore-scripts + - run: npx prettier src/git/hook.ts | sed -n '1,35p;635,710p' From 6b31f2c660f7eb07d5d57fbaa30d4b734a46d84a Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:54:35 +1000 Subject: [PATCH 14/34] fix: preserve hook state cleanup --- src/git/hook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/git/hook.ts b/src/git/hook.ts index bffc832..04fc598 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -670,6 +670,7 @@ export async function runPrepareCommitMsgHook( const selected = suggestions[0]; if (!selected) { deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.'); + await clearPendingEntryFile(deps.removePendingEntryFile); return; } From 543117f39bbaa6f807bcf5a69b04ab89e2ccc866 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:54:41 +1000 Subject: [PATCH 15/34] chore: run hook formatter inspection on branch pushes --- .github/workflows/debug-hook-format.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/debug-hook-format.yml b/.github/workflows/debug-hook-format.yml index d57a359..2ae40c2 100644 --- a/.github/workflows/debug-hook-format.yml +++ b/.github/workflows/debug-hook-format.yml @@ -2,6 +2,9 @@ name: debug-hook-format on: pull_request: + push: + branches: + - fix/prepare-hook-timeout-271 workflow_dispatch: permissions: From 2444f36c1bd1ade5d9284ccb419eb3c66ac6321e Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:54:57 +1000 Subject: [PATCH 16/34] style: format prepare hook timeout error --- src/git/hook.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 04fc598..a5e6c87 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -686,9 +686,7 @@ export async function runPrepareCommitMsgHook( const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { - timeoutError = new Error( - `commit-echo hook timed out after ${timeoutMs}ms; leaving commit message unchanged.`, - ); + timeoutError = new Error(`commit-echo hook timed out after ${timeoutMs}ms; leaving commit message unchanged.`); controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); From 7d28c28f07b989bdfbfe63680031a0f1eecf048f Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:55:02 +1000 Subject: [PATCH 17/34] chore: remove temporary format inspection workflow --- .github/workflows/debug-hook-format.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/debug-hook-format.yml diff --git a/.github/workflows/debug-hook-format.yml b/.github/workflows/debug-hook-format.yml deleted file mode 100644 index 2ae40c2..0000000 --- a/.github/workflows/debug-hook-format.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: debug-hook-format - -on: - pull_request: - push: - branches: - - fix/prepare-hook-timeout-271 - workflow_dispatch: - -permissions: - contents: read - -jobs: - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24 - - run: npm ci --ignore-scripts - - run: npx prettier src/git/hook.ts | sed -n '1,35p;635,710p' From 8ba65ab794f5bb032735943c93182c320110637d Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:56:40 +1000 Subject: [PATCH 18/34] fix: preserve streaming response semantics --- src/providers/request.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/providers/request.ts b/src/providers/request.ts index 04d335d..47d3839 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -7,6 +7,7 @@ export async function fetchWithTimeout( timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller = new AbortController(), externalSignal?: AbortSignal, + keepTimeoutThroughBody = true, ): Promise { let timedOut = false; let timeoutError: Error | undefined; @@ -46,7 +47,7 @@ export async function fetchWithTimeout( signal: controller.signal, }); - if (!response.body) { + if (!keepTimeoutThroughBody || !response.body) { cleanup(); return response; } From 09b93404814d6847d84885fe6f5585a8194cac4f Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:56:44 +1000 Subject: [PATCH 19/34] fix: keep streaming timeout semantics --- src/providers/openai-compatible.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/providers/openai-compatible.ts b/src/providers/openai-compatible.ts index 26c7b2d..84e0ad2 100644 --- a/src/providers/openai-compatible.ts +++ b/src/providers/openai-compatible.ts @@ -93,6 +93,7 @@ export class OpenAICompatibleProvider implements Provider { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, params.signal, + false, ); if (!response.ok) { From d37446502dec1ed0f07633dc4b6c2a7633913a8b Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:56:48 +1000 Subject: [PATCH 20/34] fix: keep streaming timeout semantics --- src/providers/anthropic.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 56f05e1..1a1db23 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -96,6 +96,7 @@ export class AnthropicProvider implements Provider { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, params.signal, + false, ); if (!response.ok) { From c861534c2c29329a22beeed4414d107f5c3e6429 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:56:52 +1000 Subject: [PATCH 21/34] test: update timeout lifecycle expectation --- tests/provider-timeout.test.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index 1314824..87061b0 100644 --- a/tests/provider-timeout.test.mjs +++ b/tests/provider-timeout.test.mjs @@ -27,7 +27,7 @@ test('preserves the reason when the caller aborts a provider request', async () } }); -test('clears the provider timeout after response headers arrive', async () => { +test('clears the provider timeout after the response body is consumed', async () => { const originalFetch = globalThis.fetch; const controller = new AbortController(); @@ -37,7 +37,14 @@ test('clears the provider timeout after response headers arrive', async () => { }; try { - await fetchWithTimeout('https://example.invalid/models', {}, 'Provider request', 10, controller); + const response = await fetchWithTimeout( + 'https://example.invalid/models', + {}, + 'Provider request', + 10, + controller, + ); + assert.equal(await response.text(), 'ok'); await new Promise((resolve) => setTimeout(resolve, 25)); assert.equal(controller.signal.aborted, false); } finally { From 58bae31ef5cef1a19ae7c3d8dc38120989765d87 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:12 +1000 Subject: [PATCH 22/34] fix: make prepare hook timeout deadline-safe --- src/git/hook.ts | 119 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 38 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index a5e6c87..281cfd5 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -624,36 +624,74 @@ export async function runPrepareCommitMsgHook( return; } - try { - deps.checkGitRepo(); - - const config = await deps.loadConfig().catch(() => null); - if (!config) { - deps.warn('commit-echo hook: no configuration found; skipping.'); - await clearPendingEntryFile(deps.removePendingEntryFile); + const controller = new AbortController(); + const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let timeoutError: Error | undefined; + let timeoutId: ReturnType | undefined; + let timedOut = false; + let originalMessage: string | undefined; + let messageWriteAttempted = false; + + const abortForTimeout = () => { + if (timedOut) { return; } + timedOut = true; + timeoutError = new Error('timed out after ' + timeoutMs + 'ms; leaving commit message unchanged.'); + controller.abort(timeoutError); + }; - const diffResult = deps.getStagedDiff(); - if (!diffResult.hasChanges) { - await clearPendingEntryFile(deps.removePendingEntryFile); - return; + const ensureWithinDeadline = () => { + if (controller.signal.aborted) { + throw controller.signal.reason instanceof Error + ? controller.signal.reason + : (timeoutError ?? new Error('commit-echo hook request was cancelled')); } - const controller = new AbortController(); - const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS; - let timeoutError: Error | undefined; - let timeoutId: ReturnType | undefined; + if (Date.now() >= deadline) { + abortForTimeout(); + throw timeoutError!; + } + }; + + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortForTimeout(); + reject(timeoutError!); + }, timeoutMs); + }); - const ensureWithinDeadline = () => { - if (controller.signal.aborted) { - throw controller.signal.reason instanceof Error - ? controller.signal.reason - : (timeoutError ?? new Error('commit-echo hook request was cancelled')); + let hookOperation: Promise | undefined; + + try { + hookOperation = (async () => { + deps.checkGitRepo(); + ensureWithinDeadline(); + + let config: Config | null; + try { + config = await deps.loadConfig(); + } catch { + ensureWithinDeadline(); + config = null; + } + ensureWithinDeadline(); + + if (!config) { + deps.warn('commit-echo hook: no configuration found; skipping.'); + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + const diffResult = deps.getStagedDiff(); + ensureWithinDeadline(); + + if (!diffResult.hasChanges) { + await clearPendingEntryFile(deps.removePendingEntryFile); + return; } - }; - const hookOperation = (async () => { const profile = await deps.buildProfile(config.historySize); ensureWithinDeadline(); @@ -674,36 +712,41 @@ export async function runPrepareCommitMsgHook( return; } - const existingContent = await deps.readMessageFile(args.messageFile).catch(() => ''); + originalMessage = await deps.readMessageFile(args.messageFile).catch(() => ''); ensureWithinDeadline(); - const nextContent = buildHookCommitMessage(selected, existingContent); + const nextContent = buildHookCommitMessage(selected, originalMessage); ensureWithinDeadline(); + messageWriteAttempted = true; await deps.writeMessageFile(args.messageFile, nextContent); ensureWithinDeadline(); + await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); + ensureWithinDeadline(); })(); - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - timeoutError = new Error(`commit-echo hook timed out after ${timeoutMs}ms; leaving commit message unchanged.`); - controller.abort(timeoutError); - reject(timeoutError); - }, timeoutMs); - }); + await Promise.race([hookOperation, timeout]); + } catch (err) { + if (timedOut) { + await hookOperation?.catch(() => {}); - try { - await Promise.race([hookOperation, timeout]); - } finally { - if (timeoutId) clearTimeout(timeoutId); + if (messageWriteAttempted && originalMessage !== undefined) { + await deps.writeMessageFile(args.messageFile, originalMessage).catch(() => {}); + } + + await clearPendingEntryFile(deps.removePendingEntryFile); + const message = timeoutError?.message ?? (err instanceof Error ? err.message : String(err)); + deps.warn('commit-echo hook: ' + message); + return; } - } catch (err) { + await clearPendingEntryFile(deps.removePendingEntryFile); const message = err instanceof Error ? err.message : String(err); - deps.warn(`commit-echo hook: ${message}`); + deps.warn('commit-echo hook: ' + message); + } finally { + if (timeoutId) clearTimeout(timeoutId); } } - export async function runPostCommitHook( deps: PostCommitHookDeps = { checkGitRepo, From e437c70f3ba68256478aa84e399248758db3fc50 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:32 +1000 Subject: [PATCH 23/34] test: cover prepare hook deadline races --- tests/hook.test.mjs | 111 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 3f5b043..8208bb1 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -967,7 +967,116 @@ test('runPrepareCommitMsgHook times out LLM work without changing the message', assert.equal(aborted, true); assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); assert.equal(pendingCleared, 1); - assert.match(warning, /timed out after 20ms/); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } +}); + + +test('runPrepareCommitMsgHook applies its deadline to config loading', async () => { + let warning = ''; + let configLoaded = false; + + await runPrepareCommitMsgHook( + { messageFile: '/tmp/commit-echo-timeout-test', source: 'template' }, + { + checkGitRepo: () => {}, + loadConfig: async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + configLoaded = true; + return { + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }; + }, + getStagedDiff: () => { + throw new Error('staged diff should not run after the config deadline expires'); + }, + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ suggestions: [] }), + readMessageFile: async () => '', + writeMessageFile: async () => {}, + writePendingEntryFile: async () => {}, + removePendingEntryFile: async () => {}, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }, + ); + + assert.equal(configLoaded, true); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); +}); + +test('runPrepareCommitMsgHook waits for and rolls back a late message write', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'commit-echo-hook-write-timeout-')); + const messageFile = join(repoDir, 'COMMIT_EDITMSG'); + const originalMessage = 'original commit title\n'; + writeFileSync(messageFile, originalMessage, 'utf-8'); + + try { + let releaseWrite; + let signalWriteStarted; + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve; + }); + let pendingCleared = 0; + let warning = ''; + + const hookPromise = runPrepareCommitMsgHook( + { messageFile, source: 'template' }, + { + checkGitRepo: () => {}, + loadConfig: async () => ({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }), + getStagedDiff: () => ({ + diff: 'diff --git a/file b/file\n+hello', + hasChanges: true, + staged: true, + }), + buildProfile: async () => MOCK_PROFILE, + generateSuggestions: async () => ({ + suggestions: [{ index: 1, message: 'feat: generated message' }], + }), + readMessageFile: async (filePath) => readFileSync(filePath, 'utf-8'), + writeMessageFile: async (filePath, nextContent) => { + if (nextContent !== originalMessage) { + await new Promise((resolve) => { + releaseWrite = resolve; + signalWriteStarted(); + }); + } + writeFileSync(filePath, nextContent, 'utf-8'); + }, + writePendingEntryFile: async () => { + throw new Error('pending entry should not be written after the late message write'); + }, + removePendingEntryFile: async () => { + pendingCleared += 1; + }, + warn: (message) => { + warning = message; + }, + timeoutMs: 20, + }, + ); + + await writeStarted; + await new Promise((resolve) => setTimeout(resolve, 30)); + releaseWrite(); + await hookPromise; + + assert.equal(readFileSync(messageFile, 'utf-8'), originalMessage); + assert.equal(pendingCleared, 1); + assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); } finally { rmSync(repoDir, { recursive: true, force: true }); } From 45fa176721f715294d09096b6090e3ee1ee4808e Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:40 +1000 Subject: [PATCH 24/34] fix: preserve external cancellation through response bodies --- src/providers/request.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/providers/request.ts b/src/providers/request.ts index 47d3839..011b24f 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -47,11 +47,15 @@ export async function fetchWithTimeout( signal: controller.signal, }); - if (!keepTimeoutThroughBody || !response.body) { + if (!response.body) { cleanup(); return response; } + if (!keepTimeoutThroughBody) { + clearTimeout(timeout); + } + const reader = response.body.getReader(); const wrappedBody = new ReadableStream({ async pull(streamController) { From e2878a32db2de2c73452e6a956b7449a46840fb6 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:47 +1000 Subject: [PATCH 25/34] test: cover streaming cancellation after headers --- tests/provider-timeout.test.mjs | 68 ++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index 87061b0..1eaa3e4 100644 --- a/tests/provider-timeout.test.mjs +++ b/tests/provider-timeout.test.mjs @@ -86,8 +86,22 @@ test('times out and aborts a response body that stalls after headers', async () const body = new ReadableStream({ pull() { + if (init.signal.aborted) { + return Promise.reject(init.signal.reason); + } + return new Promise((_resolve, reject) => { - init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); + const fallback = setTimeout(() => { + reject(new Error('stalled body mock did not observe the abort signal')); + }, 1000); + init.signal.addEventListener( + 'abort', + () => { + clearTimeout(fallback); + reject(init.signal.reason); + }, + { once: true }, + ); }); }, }); @@ -110,3 +124,55 @@ test('times out and aborts a response body that stalls after headers', async () assert.equal(sawAbort, true); }); + + +test('preserves external cancellation after response headers for streaming requests', async () => { + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + const externalController = new AbortController(); + const reason = new DOMException('Cancelled by caller', 'AbortError'); + let sawInternalAbort = false; + + globalThis.fetch = async (_url, init) => { + init.signal.addEventListener( + 'abort', + () => { + sawInternalAbort = true; + }, + { once: true }, + ); + + const body = new ReadableStream({ + pull() { + if (init.signal.aborted) { + return Promise.reject(init.signal.reason); + } + + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); + }); + }, + }); + + return new Response(body); + }; + + try { + const response = await fetchWithTimeout( + 'https://example.invalid/stream', + {}, + 'Provider streaming request', + 1000, + controller, + externalController.signal, + false, + ); + const bodyPromise = response.text(); + externalController.abort(reason); + + await assert.rejects(bodyPromise, (error) => error === reason); + assert.equal(sawInternalAbort, true); + } finally { + globalThis.fetch = originalFetch; + } +}); From 0781cbcf3e2914a888011f7aa6dea7bfd5e6f68b Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:55 +1000 Subject: [PATCH 26/34] fix: keep prepare hook timeout bounded before mutations --- src/git/hook.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 281cfd5..1811911 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -728,7 +728,9 @@ export async function runPrepareCommitMsgHook( await Promise.race([hookOperation, timeout]); } catch (err) { if (timedOut) { - await hookOperation?.catch(() => {}); + if (messageWriteAttempted) { + await hookOperation?.catch(() => {}); + } if (messageWriteAttempted && originalMessage !== undefined) { await deps.writeMessageFile(args.messageFile, originalMessage).catch(() => {}); From c3a2dd80c9ecdeb7ab659ddfed36147701209f66 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:18:19 +1000 Subject: [PATCH 27/34] style: preserve hook declaration spacing --- src/git/hook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/git/hook.ts b/src/git/hook.ts index 1811911..795dc3a 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -749,6 +749,7 @@ export async function runPrepareCommitMsgHook( if (timeoutId) clearTimeout(timeoutId); } } + export async function runPostCommitHook( deps: PostCommitHookDeps = { checkGitRepo, From ecf81c13d791ced1a604569afdc341ef298c2f78 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:04 +1000 Subject: [PATCH 28/34] fix: make hook git setup abortable --- src/git/diff.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index bb55295..47336b7 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -1,7 +1,8 @@ -import { execFileSync, spawnSync } from 'node:child_process'; +import { execFile, execFileSync, spawnSync } from 'node:child_process'; import { accessSync, copyFileSync, existsSync, constants, mkdtempSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, isAbsolute, join, normalize, resolve } from 'node:path'; +import { promisify } from 'node:util'; export interface DiffResult { diff: string; @@ -31,6 +32,7 @@ const GIT_REPOSITORY_ENV_VARS = [ ] as const; const GIT_EXECUTABLE_NAME = process.platform === 'win32' ? 'git.exe' : 'git'; let gitExecutable: string | undefined; +const execFileAsync = promisify(execFile); function getGitEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -119,6 +121,56 @@ export function checkGitRepo(): void { } } +export async function checkGitRepoWithSignal(signal?: AbortSignal): Promise { + const executable = getGitExecutable(); + try { + await execFileAsync(executable, ['rev-parse', '--git-dir'], { + encoding: 'utf-8', + stdio: 'pipe', + signal, + }); + } catch (err) { + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error('Git repository check was cancelled'); + } + + const nodeErr = err as NodeJS.ErrnoException & { stderr?: string }; + if (nodeErr.code === 'ENOENT') { + throw new Error('git is not installed or not found in a supported location'); + } + + const stderr = nodeErr.stderr?.trim(); + throw new Error(stderr || 'Not a git repository'); + } +} + +export async function getStagedDiffWithSignal( + cwd = process.cwd(), + indexFile?: string, + signal?: AbortSignal, +): Promise { + try { + const { stdout } = await execFileAsync(getGitExecutable(), ['diff', '--cached'], { + cwd, + encoding: 'utf-8', + env: getGitEnv(indexFile ? { GIT_INDEX_FILE: indexFile } : {}), + maxBuffer: GIT_DIFF_MAX_BUFFER, + signal, + }); + + return { + diff: stdout, + hasChanges: stdout.trim().length > 0, + staged: true, + }; + } catch (err) { + if (signal?.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error('Git staged diff was cancelled'); + } + throw err; + } +} + export function hasCommits(): boolean { try { const count = execFileSync(getGitExecutable(), ['rev-list', '--count', 'HEAD'], { From d7ab719b15d3afd39a8e8e0dfcb3e01cf1abcdee Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:08 +1000 Subject: [PATCH 29/34] fix: make prepare hook git setup cancellable --- src/git/hook.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 795dc3a..0cede72 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -4,7 +4,7 @@ import { chmod, copyFile, lstat, mkdir, readFile, readlink, rename, rm, symlink, import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import type { CommitEntry, Config, Suggestion, StyleProfile } from '../types.js'; -import { checkGitRepo, getGitExecutable, getStagedDiff } from './diff.js'; +import { checkGitRepoWithSignal, getGitExecutable, getStagedDiffWithSignal } from './diff.js'; import type { DiffResult } from './diff.js'; import { loadConfig } from '../config/store.js'; import { appendEntry, buildProfile } from '../history/store.js'; @@ -33,9 +33,9 @@ export interface PostCommitHookDeps { } export interface PrepareCommitMsgHookDeps { - checkGitRepo: () => void; + checkGitRepo: (signal?: AbortSignal) => void | Promise; loadConfig: () => Promise; - getStagedDiff: () => DiffResult; + getStagedDiff: (signal?: AbortSignal) => DiffResult | Promise; buildProfile: (historySize: number) => Promise; generateSuggestions: typeof generateSuggestions; readMessageFile: (messageFile: string) => Promise; @@ -607,9 +607,9 @@ function buildPendingHookEntry(config: Config, diff: string): string { export async function runPrepareCommitMsgHook( args: PrepareCommitMsgHookArgs, deps: PrepareCommitMsgHookDeps = { - checkGitRepo, + checkGitRepo: checkGitRepoWithSignal, loadConfig, - getStagedDiff, + getStagedDiff: getStagedDiffWithSignal, buildProfile, generateSuggestions, readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'), @@ -666,7 +666,7 @@ export async function runPrepareCommitMsgHook( try { hookOperation = (async () => { - deps.checkGitRepo(); + await deps.checkGitRepo(controller.signal); ensureWithinDeadline(); let config: Config | null; @@ -684,7 +684,7 @@ export async function runPrepareCommitMsgHook( return; } - const diffResult = deps.getStagedDiff(); + const diffResult = await deps.getStagedDiff(controller.signal); ensureWithinDeadline(); if (!diffResult.hasChanges) { @@ -712,7 +712,7 @@ export async function runPrepareCommitMsgHook( return; } - originalMessage = await deps.readMessageFile(args.messageFile).catch(() => ''); + originalMessage = await deps.readMessageFile(args.messageFile); ensureWithinDeadline(); const nextContent = buildHookCommitMessage(selected, originalMessage); @@ -730,6 +730,8 @@ export async function runPrepareCommitMsgHook( if (timedOut) { if (messageWriteAttempted) { await hookOperation?.catch(() => {}); + } else { + void hookOperation?.catch(() => {}); } if (messageWriteAttempted && originalMessage !== undefined) { From b6c436cacce25c39f0782dfd2deec1bfbce90e3a Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:12 +1000 Subject: [PATCH 30/34] fix: release wrapped provider readers on cleanup --- src/providers/request.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/providers/request.ts b/src/providers/request.ts index 011b24f..433c4ba 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -57,6 +57,13 @@ export async function fetchWithTimeout( } const reader = response.body.getReader(); + let readerReleased = false; + const releaseReader = () => { + if (!readerReleased) { + reader.releaseLock(); + readerReleased = true; + } + }; const wrappedBody = new ReadableStream({ async pull(streamController) { try { @@ -64,17 +71,23 @@ export async function fetchWithTimeout( if (result.done) { cleanup(); streamController.close(); + releaseReader(); return; } streamController.enqueue(result.value); } catch (error) { cleanup(); streamController.error(timedOut ? timeoutError : error); + releaseReader(); } }, async cancel(reason) { cleanup(); - await reader.cancel(reason).catch(() => {}); + try { + await reader.cancel(reason).catch(() => {}); + } finally { + releaseReader(); + } }, }); From 53762aee8d0c02e803217d5362e0d10da2a33217 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:16 +1000 Subject: [PATCH 31/34] test: make config timeout assertion deterministic --- tests/hook.test.mjs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 8208bb1..95e461b 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -977,20 +977,20 @@ test('runPrepareCommitMsgHook times out LLM work without changing the message', test('runPrepareCommitMsgHook applies its deadline to config loading', async () => { let warning = ''; let configLoaded = false; + let resolveConfig; - await runPrepareCommitMsgHook( + const configPromise = new Promise((resolve) => { + resolveConfig = resolve; + }); + + const hookPromise = runPrepareCommitMsgHook( { messageFile: '/tmp/commit-echo-timeout-test', source: 'template' }, { checkGitRepo: () => {}, loadConfig: async () => { - await new Promise((resolve) => setTimeout(resolve, 40)); + const config = await configPromise; configLoaded = true; - return { - provider: 'mock', - model: 'mock-model', - historySize: 3, - maxDiffSize: 4000, - }; + return config; }, getStagedDiff: () => { throw new Error('staged diff should not run after the config deadline expires'); @@ -1008,8 +1008,18 @@ test('runPrepareCommitMsgHook applies its deadline to config loading', async () }, ); - assert.equal(configLoaded, true); + await hookPromise; + assert.equal(configLoaded, false); assert.equal(warning, 'commit-echo hook: timed out after 20ms; leaving commit message unchanged.'); + + resolveConfig({ + provider: 'mock', + model: 'mock-model', + historySize: 3, + maxDiffSize: 4000, + }); + await configPromise; + assert.equal(configLoaded, true); }); test('runPrepareCommitMsgHook waits for and rolls back a late message write', async () => { From 99c250731f03e7a218f3c48d0559157774fa1d86 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:26 +1000 Subject: [PATCH 32/34] test: bound streaming cancellation regression --- tests/provider-timeout.test.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index 1eaa3e4..c128804 100644 --- a/tests/provider-timeout.test.mjs +++ b/tests/provider-timeout.test.mjs @@ -149,7 +149,17 @@ test('preserves external cancellation after response headers for streaming reque } return new Promise((_resolve, reject) => { - init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); + const fallback = setTimeout(() => { + reject(new Error('stalled body mock did not observe the abort signal')); + }, 1000); + init.signal.addEventListener( + 'abort', + () => { + clearTimeout(fallback); + reject(init.signal.reason); + }, + { once: true }, + ); }); }, }); From 6589a04bc969f6dd3e72fb683a2e88caf3c6e6c4 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:59 +1000 Subject: [PATCH 33/34] fix: correct async git exec options --- src/git/diff.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index 47336b7..ee533a9 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -126,7 +126,6 @@ export async function checkGitRepoWithSignal(signal?: AbortSignal): Promise Date: Tue, 22 Sep 2026 13:43:04 +1000 Subject: [PATCH 34/34] fix: wire abortable git helper into hook deps --- src/git/hook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/git/hook.ts b/src/git/hook.ts index 0cede72..3484675 100644 --- a/src/git/hook.ts +++ b/src/git/hook.ts @@ -4,7 +4,7 @@ import { chmod, copyFile, lstat, mkdir, readFile, readlink, rename, rm, symlink, import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import type { CommitEntry, Config, Suggestion, StyleProfile } from '../types.js'; -import { checkGitRepoWithSignal, getGitExecutable, getStagedDiffWithSignal } from './diff.js'; +import { checkGitRepo, checkGitRepoWithSignal, getGitExecutable, getStagedDiffWithSignal } from './diff.js'; import type { DiffResult } from './diff.js'; import { loadConfig } from '../config/store.js'; import { appendEntry, buildProfile } from '../history/store.js'; @@ -609,7 +609,7 @@ export async function runPrepareCommitMsgHook( deps: PrepareCommitMsgHookDeps = { checkGitRepo: checkGitRepoWithSignal, loadConfig, - getStagedDiff: getStagedDiffWithSignal, + getStagedDiff: (signal) => getStagedDiffWithSignal(process.cwd(), undefined, signal), buildProfile, generateSuggestions, readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'),