Skip to content

Commit 61a10d5

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). Previously the process was orphaned because @actions/exec does not expose the child process handle. - Fix timeout:0 not working: replace falsy || coalescion with explicit empty-string check so that '0' is not replaced by the default '300'. Users can now set timeout: 0 to disable timeouts as documented. - Refactor execGit to use an options object instead of 5 positional parameters, eliminating error-prone filler args (false, false, {}). - Clarify retry-max-attempts semantics: total attempts including the initial attempt (3 = 1 initial + 2 retries), not number of retries. - Remove Kubernetes probe references from action.yml descriptions and code comments to avoid confusing non-Kubernetes users. - Add tests for timeout/retry input parsing (defaults, timeout:0, custom values, invalid input, backoff clamping) and command manager configuration (setTimeout, setRetryConfig, fetch dispatch). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5df58a6 commit 61a10d5

File tree

8 files changed

+361
-113
lines changed

8 files changed

+361
-113
lines changed

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,18 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
156156
set-safe-directory: ''
157157

158158
# 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.
159+
# ls-remote). If a single attempt exceeds this, the process is terminated and the
160+
# operation is retried. Set to 0 to disable. Default is 300 (5 minutes).
162161
# Default: 300
163162
timeout: ''
164163

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

170169
# Minimum backoff time in seconds between retry attempts. The actual backoff is
171-
# randomly chosen between min and max. Similar to Kubernetes probe periodSeconds.
170+
# randomly chosen between min and max.
172171
# Default: 10
173172
retry-min-backoff: ''
174173

__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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,49 @@ 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', async () => {
182+
inputs.timeout = 'garbage'
183+
const settings: IGitSourceSettings = await inputHelper.getInputs()
184+
expect(settings.timeout).toBe(300)
185+
})
186+
187+
it('defaults negative retry-max-attempts to 3', async () => {
188+
inputs['retry-max-attempts'] = '-1'
189+
const settings: IGitSourceSettings = await inputHelper.getInputs()
190+
expect(settings.retryMaxAttempts).toBe(3)
191+
})
147192
})

action.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,18 @@ inputs:
9898
timeout:
9999
description: >
100100
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.
101+
If a single attempt exceeds this, the process is terminated and the operation is retried.
102102
Set to 0 to disable. Default is 300 (5 minutes).
103-
Similar to Kubernetes probe timeoutSeconds.
104103
default: 300
105104
retry-max-attempts:
106105
description: >
107-
Maximum number of retry attempts for failed git network operations.
108-
Similar to Kubernetes probe failureThreshold.
106+
Total number of attempts for each git network operation (including the initial attempt).
107+
For example, 3 means one initial attempt plus up to 2 retries.
109108
default: 3
110109
retry-min-backoff:
111110
description: >
112111
Minimum backoff time in seconds between retry attempts.
113112
The actual backoff is randomly chosen between min and max.
114-
Similar to Kubernetes probe periodSeconds.
115113
default: 10
116114
retry-max-backoff:
117115
description: >

0 commit comments

Comments
 (0)