Skip to content

Commit 33c1ac5

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 33c1ac5

File tree

8 files changed

+493
-115
lines changed

8 files changed

+493
-115
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: 49 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,41 @@ 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 converts seconds to milliseconds', async () => {
520+
git = await createMockGit()
521+
// Should not throw
522+
git.setTimeout(30)
523+
git.setTimeout(0)
524+
})
525+
526+
it('setRetryConfig accepts valid parameters', async () => {
527+
git = await createMockGit()
528+
// Should not throw
529+
git.setRetryConfig(5, 2, 15)
530+
})
531+
532+
it('fetch without timeout uses exec', async () => {
533+
git = await createMockGit()
534+
// timeout defaults to 0 (disabled)
535+
536+
mockExec.mockClear()
537+
await git.fetch(['refs/heads/main'], {})
538+
539+
// exec.exec is used (via retryHelper) when no timeout
540+
const fetchCalls = mockExec.mock.calls.filter(
541+
(call: any[]) => (call[1] as string[]).includes('fetch')
542+
)
543+
expect(fetchCalls.length).toBeGreaterThan(0)
544+
})
545+
})

__test__/input-helper.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,55 @@ 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', 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+
})
180+
181+
it('defaults invalid timeout to 300 and warns', async () => {
182+
inputs.timeout = 'garbage'
183+
const settings: IGitSourceSettings = await inputHelper.getInputs()
184+
expect(settings.timeout).toBe(300)
185+
expect(core.warning).toHaveBeenCalledWith(
186+
expect.stringContaining("Invalid value 'garbage' for 'timeout'")
187+
)
188+
})
189+
190+
it('defaults negative retry-max-attempts to 3 and warns', async () => {
191+
inputs['retry-max-attempts'] = '-1'
192+
const settings: IGitSourceSettings = await inputHelper.getInputs()
193+
expect(settings.retryMaxAttempts).toBe(3)
194+
expect(core.warning).toHaveBeenCalledWith(
195+
expect.stringContaining("Invalid value '-1' for 'retry-max-attempts'")
196+
)
197+
})
147198
})

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)