diff --git a/src/git/diff.ts b/src/git/diff.ts index bb55295..ee533a9 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,55 @@ 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', + 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'], { diff --git a/src/git/hook.ts b/src/git/hook.ts index aa4040b..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 { checkGitRepo, getGitExecutable, getStagedDiff } 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'; @@ -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; @@ -32,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; @@ -42,6 +43,7 @@ export interface PrepareCommitMsgHookDeps { writePendingEntryFile: (content: string) => Promise; removePendingEntryFile: () => Promise; warn: (message: string) => void; + timeoutMs?: number; } export interface InstalledCommitHooks { @@ -605,9 +607,9 @@ function buildPendingHookEntry(config: Config, diff: string): string { export async function runPrepareCommitMsgHook( args: PrepareCommitMsgHookArgs, deps: PrepareCommitMsgHookDeps = { - checkGitRepo, + checkGitRepo: checkGitRepoWithSignal, loadConfig, - getStagedDiff, + getStagedDiff: (signal) => getStagedDiffWithSignal(process.cwd(), undefined, signal), buildProfile, generateSuggestions, readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'), @@ -622,39 +624,131 @@ export async function runPrepareCommitMsgHook( return; } - try { - deps.checkGitRepo(); + 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 config = await deps.loadConfig().catch(() => null); - if (!config) { - deps.warn('commit-echo hook: no configuration found; skipping.'); - await clearPendingEntryFile(deps.removePendingEntryFile); + 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 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.'); + if (Date.now() >= deadline) { + abortForTimeout(); + throw timeoutError!; + } + }; + + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortForTimeout(); + reject(timeoutError!); + }, timeoutMs); + }); + + let hookOperation: Promise | undefined; + + try { + hookOperation = (async () => { + await deps.checkGitRepo(controller.signal); + 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 = await deps.getStagedDiff(controller.signal); + ensureWithinDeadline(); + + if (!diffResult.hasChanges) { + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + 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.'); + await clearPendingEntryFile(deps.removePendingEntryFile); + return; + } + + originalMessage = await deps.readMessageFile(args.messageFile); + ensureWithinDeadline(); + + const nextContent = buildHookCommitMessage(selected, originalMessage); + ensureWithinDeadline(); + messageWriteAttempted = true; + await deps.writeMessageFile(args.messageFile, nextContent); + ensureWithinDeadline(); + + await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff)); + ensureWithinDeadline(); + })(); + + await Promise.race([hookOperation, timeout]); + } catch (err) { + if (timedOut) { + if (messageWriteAttempted) { + await hookOperation?.catch(() => {}); + } else { + void hookOperation?.catch(() => {}); + } + + 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; } - 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)); - } 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); } } 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); diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 0963f9a..1a1db23 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,8 @@ export class AnthropicProvider implements Provider { 'Anthropic streaming request', DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, + params.signal, + false, ); if (!response.ok) { 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) { diff --git a/src/providers/openai-compatible.ts b/src/providers/openai-compatible.ts index 415b439..84e0ad2 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,8 @@ export class OpenAICompatibleProvider implements Provider { 'OpenAI-compatible streaming request', DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller, + params.signal, + false, ); if (!response.ok) { diff --git a/src/providers/request.ts b/src/providers/request.ts index eca85b8..433c4ba 100644 --- a/src/providers/request.ts +++ b/src/providers/request.ts @@ -6,11 +6,39 @@ export async function fetchWithTimeout( label: string, timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, controller = new AbortController(), + externalSignal?: AbortSignal, + keepTimeoutThroughBody = true, ): Promise { let timedOut = false; - const timeout = setTimeout(() => { + 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 }); + } + } + + let timeout: ReturnType; + + const cleanup = () => { + clearTimeout(timeout); + if (externalSignal && externalAbortListener) { + externalSignal.removeEventListener('abort', externalAbortListener); + externalAbortListener = undefined; + } + }; + + timeout = setTimeout(() => { timedOut = true; - controller.abort(); + timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`); + controller.abort(timeoutError); }, timeoutMs); try { @@ -18,12 +46,60 @@ export async function fetchWithTimeout( ...init, signal: controller.signal, }); - clearTimeout(timeout); - return response; + + if (!response.body) { + cleanup(); + return response; + } + + if (!keepTimeoutThroughBody) { + clearTimeout(timeout); + } + + const reader = response.body.getReader(); + let readerReleased = false; + const releaseReader = () => { + if (!readerReleased) { + reader.releaseLock(); + readerReleased = true; + } + }; + const wrappedBody = new ReadableStream({ + async pull(streamController) { + try { + const result = await reader.read(); + 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(); + try { + await reader.cancel(reason).catch(() => {}); + } finally { + releaseReader(); + } + }, + }); + + 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; } 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 { diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index c174343..95e461b 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -915,3 +915,179 @@ 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.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; + let resolveConfig; + + const configPromise = new Promise((resolve) => { + resolveConfig = resolve; + }); + + const hookPromise = runPrepareCommitMsgHook( + { messageFile: '/tmp/commit-echo-timeout-test', source: 'template' }, + { + checkGitRepo: () => {}, + loadConfig: async () => { + const config = await configPromise; + configLoaded = true; + return config; + }, + 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, + }, + ); + + 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 () => { + 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 }); + } +}); diff --git a/tests/provider-timeout.test.mjs b/tests/provider-timeout.test.mjs index 43107a1..c128804 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 { @@ -66,3 +73,116 @@ 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({ + pull() { + if (init.signal.aborted) { + return Promise.reject(init.signal.reason); + } + + return new Promise((_resolve, reject) => { + 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 }, + ); + }); + }, + }); + + 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); +}); + + +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) => { + 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 }, + ); + }); + }, + }); + + 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; + } +});