Skip to content

Commit 3ff67ab

Browse files
arabkinclaude
andcommitted
Fix timeout implementation and address review feedback
- Kill git process on timeout: use child_process.spawn directly for timeout-eligible operations so we have a ChildProcess handle to send SIGTERM (then SIGKILL after 5s). On Windows, SIGTERM is a forced kill so the SIGKILL fallback is effectively a no-op there. - Fix timeout:0 not working: replace falsy || coalescion with explicit empty-string check so that '0' is not replaced by the default '300'. - Refactor execGit to use an options object instead of 5 positional parameters, eliminating error-prone filler args (false, false, {}). - Pass allowAllExitCodes through to execGitWithTimeout so both code paths have consistent behavior for non-zero exit codes. - Add settled guard to prevent double-reject when both close and error events fire on the spawned process. - Handle null exit code (process killed by signal) as an error rather than silently treating it as success. - Capture stderr in error messages for the timeout path, matching the information level of the non-timeout exec path. - Log SIGKILL failures at debug level instead of empty catch block. - Warn on customListeners being ignored in the timeout path. - Emit core.warning() when invalid input values are silently replaced with defaults, so users know their configuration was rejected. - Add input validation in setTimeout (reject negative values). - Clarify retry-max-attempts semantics: total attempts including the initial attempt (3 = 1 initial + 2 retries). - Remove Kubernetes probe references from descriptions. - Use non-exhaustive list (e.g.) for network operations in docs to avoid staleness if new operations are added. - Add tests for timeout/retry input parsing (defaults, timeout:0, custom values, invalid input with warnings, backoff clamping) and command manager configuration (setTimeout, setRetryConfig, fetch). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5df58a6 commit 3ff67ab

File tree

8 files changed

+555
-120
lines changed

8 files changed

+555
-120
lines changed

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,20 +155,20 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
155155
# Default: true
156156
set-safe-directory: ''
157157

158-
# Timeout in seconds for each git network operation attempt (fetch, lfs-fetch,
159-
# ls-remote). If a single attempt exceeds this, it is killed and retried. Set to 0
160-
# to disable. Default is 300 (5 minutes). Similar to Kubernetes probe
161-
# timeoutSeconds.
158+
# Timeout in seconds for each git network operation attempt (e.g. fetch,
159+
# lfs-fetch, ls-remote). If a single attempt exceeds this, the process is
160+
# terminated. If retries are configured (see retry-max-attempts), the operation
161+
# will be retried. Set to 0 to disable. Default is 300 (5 minutes).
162162
# Default: 300
163163
timeout: ''
164164

165-
# Maximum number of retry attempts for failed git network operations. Similar to
166-
# Kubernetes probe failureThreshold.
165+
# Total number of attempts for each git network operation (including the initial
166+
# attempt). For example, 3 means one initial attempt plus up to 2 retries.
167167
# Default: 3
168168
retry-max-attempts: ''
169169

170170
# Minimum backoff time in seconds between retry attempts. The actual backoff is
171-
# randomly chosen between min and max. Similar to Kubernetes probe periodSeconds.
171+
# randomly chosen between min and max.
172172
# Default: 10
173173
retry-min-backoff: ''
174174

__test__/git-command-manager.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import * as commandManager from '../lib/git-command-manager'
55
let git: commandManager.IGitCommandManager
66
let mockExec = jest.fn()
77

8+
function createMockGit(): Promise<commandManager.IGitCommandManager> {
9+
mockExec.mockImplementation((path, args, options) => {
10+
if (args.includes('version')) {
11+
options.listeners.stdout(Buffer.from('2.18'))
12+
}
13+
return 0
14+
})
15+
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
16+
return commandManager.createCommandManager('test', false, false)
17+
}
18+
819
describe('git-auth-helper tests', () => {
920
beforeAll(async () => {})
1021

@@ -494,3 +505,73 @@ describe('git user-agent with orchestration ID', () => {
494505
)
495506
})
496507
})
508+
509+
describe('timeout and retry configuration', () => {
510+
beforeEach(async () => {
511+
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
512+
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
513+
})
514+
515+
afterEach(() => {
516+
jest.restoreAllMocks()
517+
})
518+
519+
it('setTimeout accepts valid values', async () => {
520+
git = await createMockGit()
521+
git.setTimeout(30)
522+
git.setTimeout(0)
523+
})
524+
525+
it('setTimeout rejects negative values', async () => {
526+
git = await createMockGit()
527+
expect(() => git.setTimeout(-1)).toThrow(/non-negative/)
528+
})
529+
530+
it('setRetryConfig accepts valid parameters', async () => {
531+
git = await createMockGit()
532+
git.setRetryConfig(5, 2, 15)
533+
})
534+
535+
it('setRetryConfig rejects min > max backoff', async () => {
536+
git = await createMockGit()
537+
expect(() => git.setRetryConfig(3, 20, 5)).toThrow(
538+
/min seconds should be less than or equal to max seconds/
539+
)
540+
})
541+
542+
it('fetch without timeout uses exec', async () => {
543+
git = await createMockGit()
544+
// timeout defaults to 0 (disabled)
545+
546+
mockExec.mockClear()
547+
await git.fetch(['refs/heads/main'], {})
548+
549+
// exec.exec is used (via retryHelper) when no timeout
550+
const fetchCalls = mockExec.mock.calls.filter(
551+
(call: any[]) => (call[1] as string[]).includes('fetch')
552+
)
553+
expect(fetchCalls).toHaveLength(1)
554+
})
555+
556+
it('fetch with timeout does not use exec', async () => {
557+
git = await createMockGit()
558+
// Short timeout and single attempt so the test completes quickly
559+
git.setTimeout(1)
560+
git.setRetryConfig(1, 0, 0)
561+
562+
mockExec.mockClear()
563+
564+
// fetch will use spawn path (which will fail/timeout since there's
565+
// no real git repo), but we verify exec.exec was NOT called for fetch
566+
try {
567+
await git.fetch(['refs/heads/main'], {})
568+
} catch {
569+
// Expected: spawn will fail/timeout in test environment
570+
}
571+
572+
const fetchCalls = mockExec.mock.calls.filter(
573+
(call: any[]) => (call[1] as string[]).includes('fetch')
574+
)
575+
expect(fetchCalls).toHaveLength(0)
576+
}, 10000)
577+
})

__test__/input-helper.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,58 @@ describe('input-helper tests', () => {
144144
const settings: IGitSourceSettings = await inputHelper.getInputs()
145145
expect(settings.workflowOrganizationId).toBe(123456)
146146
})
147+
148+
it('sets timeout and retry defaults', async () => {
149+
const settings: IGitSourceSettings = await inputHelper.getInputs()
150+
expect(settings.timeout).toBe(300)
151+
expect(settings.retryMaxAttempts).toBe(3)
152+
expect(settings.retryMinBackoff).toBe(10)
153+
expect(settings.retryMaxBackoff).toBe(20)
154+
})
155+
156+
it('allows timeout 0 to disable', async () => {
157+
inputs.timeout = '0'
158+
const settings: IGitSourceSettings = await inputHelper.getInputs()
159+
expect(settings.timeout).toBe(0)
160+
})
161+
162+
it('parses custom timeout and retry values', async () => {
163+
inputs.timeout = '30'
164+
inputs['retry-max-attempts'] = '5'
165+
inputs['retry-min-backoff'] = '2'
166+
inputs['retry-max-backoff'] = '15'
167+
const settings: IGitSourceSettings = await inputHelper.getInputs()
168+
expect(settings.timeout).toBe(30)
169+
expect(settings.retryMaxAttempts).toBe(5)
170+
expect(settings.retryMinBackoff).toBe(2)
171+
expect(settings.retryMaxBackoff).toBe(15)
172+
})
173+
174+
it('clamps retry-max-backoff to min when less than min and warns', async () => {
175+
inputs['retry-min-backoff'] = '20'
176+
inputs['retry-max-backoff'] = '5'
177+
const settings: IGitSourceSettings = await inputHelper.getInputs()
178+
expect(settings.retryMaxBackoff).toBe(20)
179+
expect(core.warning).toHaveBeenCalledWith(
180+
expect.stringContaining("'retry-max-backoff' (5) is less than 'retry-min-backoff' (20)")
181+
)
182+
})
183+
184+
it('defaults invalid timeout to 300 and warns', async () => {
185+
inputs.timeout = 'garbage'
186+
const settings: IGitSourceSettings = await inputHelper.getInputs()
187+
expect(settings.timeout).toBe(300)
188+
expect(core.warning).toHaveBeenCalledWith(
189+
expect.stringContaining("Invalid value 'garbage' for 'timeout'")
190+
)
191+
})
192+
193+
it('defaults negative retry-max-attempts to 3 and warns', async () => {
194+
inputs['retry-max-attempts'] = '-1'
195+
const settings: IGitSourceSettings = await inputHelper.getInputs()
196+
expect(settings.retryMaxAttempts).toBe(3)
197+
expect(core.warning).toHaveBeenCalledWith(
198+
expect.stringContaining("Invalid value '-1' for 'retry-max-attempts'")
199+
)
200+
})
147201
})

action.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,20 @@ inputs:
9797
default: true
9898
timeout:
9999
description: >
100-
Timeout in seconds for each git network operation attempt (fetch, lfs-fetch, ls-remote).
101-
If a single attempt exceeds this, it is killed and retried.
100+
Timeout in seconds for each git network operation attempt (e.g. fetch, lfs-fetch, ls-remote).
101+
If a single attempt exceeds this, the process is terminated.
102+
If retries are configured (see retry-max-attempts), the operation will be retried.
102103
Set to 0 to disable. Default is 300 (5 minutes).
103-
Similar to Kubernetes probe timeoutSeconds.
104104
default: 300
105105
retry-max-attempts:
106106
description: >
107-
Maximum number of retry attempts for failed git network operations.
108-
Similar to Kubernetes probe failureThreshold.
107+
Total number of attempts for each git network operation (including the initial attempt).
108+
For example, 3 means one initial attempt plus up to 2 retries.
109109
default: 3
110110
retry-min-backoff:
111111
description: >
112112
Minimum backoff time in seconds between retry attempts.
113113
The actual backoff is randomly chosen between min and max.
114-
Similar to Kubernetes probe periodSeconds.
115114
default: 10
116115
retry-max-backoff:
117116
description: >

0 commit comments

Comments
 (0)