3
0
Fork 0
mirror of https://code.forgejo.org/actions/checkout.git synced 2026-03-19 23:03:13 +00:00

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>
This commit is contained in:
Anatoly Rabkin 2026-03-18 19:04:59 +02:00
parent 5df58a66d1
commit 3ff67abc5a
8 changed files with 556 additions and 121 deletions

View file

@ -7,6 +7,7 @@ import * as path from 'path'
import * as refHelper from './ref-helper'
import * as regexpHelper from './regexp-helper'
import * as retryHelper from './retry-helper'
import {spawn} from 'child_process'
import {GitVersion} from './git-version'
// Auth header not supported before 2.9
@ -176,7 +177,7 @@ class GitCommandManager {
}
// Suppress the output in order to avoid flooding annotations with innocuous errors.
await this.execGit(args, false, true, listeners)
await this.execGit(args, {silent: true, customListeners: listeners})
core.debug(`stderr callback is: ${stderr}`)
core.debug(`errline callback is: ${errline}`)
@ -277,7 +278,7 @@ class GitCommandManager {
'--get-regexp',
pattern
],
true
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
@ -321,7 +322,7 @@ class GitCommandManager {
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, false, false, {}, that.timeoutMs)
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
@ -337,10 +338,7 @@ class GitCommandManager {
repositoryUrl,
'HEAD'
],
false,
false,
{},
this.timeoutMs
{timeoutMs: this.timeoutMs}
)
})
@ -386,7 +384,7 @@ class GitCommandManager {
// Note, "branch --show-current" would be simpler but isn't available until Git 2.22
const output = await this.execGit(
['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
true
{allowAllExitCodes: true}
)
return !output.stdout.trim().startsWith('refs/heads/')
}
@ -396,7 +394,7 @@ class GitCommandManager {
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, false, false, {}, that.timeoutMs)
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
@ -406,8 +404,8 @@ class GitCommandManager {
async log1(format?: string): Promise<string> {
const args = format ? ['log', '-1', format] : ['log', '-1']
const silent = format ? false : true
const output = await this.execGit(args, false, silent)
const silent = !format
const output = await this.execGit(args, {silent})
return output.stdout
}
@ -436,7 +434,7 @@ class GitCommandManager {
async shaExists(sha: string): Promise<boolean> {
const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
const output = await this.execGit(args, true)
const output = await this.execGit(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
@ -457,7 +455,10 @@ class GitCommandManager {
args.push('--recursive')
}
await this.execGit(args)
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
@ -471,11 +472,14 @@ class GitCommandManager {
args.push('--recursive')
}
await this.execGit(args)
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async submoduleStatus(): Promise<boolean> {
const output = await this.execGit(['submodule', 'status'], true)
const output = await this.execGit(['submodule', 'status'], {allowAllExitCodes: true})
core.debug(output.stdout)
return output.exitCode === 0
}
@ -486,7 +490,7 @@ class GitCommandManager {
}
async tryClean(): Promise<boolean> {
const output = await this.execGit(['clean', '-ffdx'], true)
const output = await this.execGit(['clean', '-ffdx'], {allowAllExitCodes: true})
return output.exitCode === 0
}
@ -501,7 +505,7 @@ class GitCommandManager {
'--unset-all',
configKey
],
true
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
@ -520,14 +524,14 @@ class GitCommandManager {
}
args.push('--unset', configKey, configValue)
const output = await this.execGit(args, true)
const output = await this.execGit(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
true
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
@ -535,7 +539,7 @@ class GitCommandManager {
async tryGetFetchUrl(): Promise<string> {
const output = await this.execGit(
['config', '--local', '--get', 'remote.origin.url'],
true
{allowAllExitCodes: true}
)
if (output.exitCode !== 0) {
@ -563,7 +567,7 @@ class GitCommandManager {
}
args.push('--get-all', configKey)
const output = await this.execGit(args, true)
const output = await this.execGit(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
@ -588,7 +592,7 @@ class GitCommandManager {
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.execGit(args, true)
const output = await this.execGit(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
@ -601,7 +605,7 @@ class GitCommandManager {
}
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
const output = await this.execGit(['reset', '--hard', 'HEAD'], {allowAllExitCodes: true})
return output.exitCode === 0
}
@ -609,10 +613,23 @@ class GitCommandManager {
return this.gitVersion
}
/**
* Sets the timeout for network git operations.
* @param timeoutSeconds Timeout in seconds. 0 disables the timeout.
*/
setTimeout(timeoutSeconds: number): void {
if (timeoutSeconds < 0) {
throw new Error(`Timeout must be non-negative, got ${timeoutSeconds}`)
}
this.timeoutMs = timeoutSeconds * 1000
}
/**
* Configures retry behavior for network git operations.
* @param maxAttempts Total attempts including the initial one. Must be >= 1.
* @param minBackoffSeconds Minimum backoff between retries. Must be <= maxBackoffSeconds.
* @param maxBackoffSeconds Maximum backoff between retries.
*/
setRetryConfig(
maxAttempts: number,
minBackoffSeconds: number,
@ -641,13 +658,42 @@ class GitCommandManager {
private async execGit(
args: string[],
allowAllExitCodes = false,
silent = false,
customListeners = {},
timeoutMs = 0
options: {
allowAllExitCodes?: boolean
silent?: boolean
customListeners?: {}
timeoutMs?: number
} = {}
): Promise<GitOutput> {
const {
allowAllExitCodes = false,
silent = false,
customListeners = {},
timeoutMs = 0
} = options
fshelper.directoryExistsSync(this.workingDirectory, true)
// Use child_process.spawn directly when timeout is set,
// so we can kill the process on timeout and avoid orphaned git processes.
// Note: customListeners are not supported in the timeout path.
if (timeoutMs > 0) {
if (
customListeners &&
Object.keys(customListeners).length > 0
) {
core.debug(
'customListeners are not supported with timeoutMs and will be ignored'
)
}
return await this.execGitWithTimeout(
args,
timeoutMs,
silent,
allowAllExitCodes
)
}
const result = new GitOutput()
const env = {}
@ -667,7 +713,7 @@ class GitCommandManager {
const mergedListeners = {...defaultListener, ...customListeners}
const stdout: string[] = []
const options = {
const execOptions = {
cwd: this.workingDirectory,
env,
silent,
@ -675,27 +721,7 @@ class GitCommandManager {
listeners: mergedListeners
}
const execPromise = exec.exec(`"${this.gitPath}"`, args, options)
if (timeoutMs > 0) {
let timer: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timer = global.setTimeout(() => {
reject(
new Error(
`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 3).join(' ')}...`
)
)
}, timeoutMs)
})
try {
result.exitCode = await Promise.race([execPromise, timeoutPromise])
} finally {
clearTimeout(timer!)
}
} else {
result.exitCode = await execPromise
}
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, execOptions)
result.stdout = stdout.join('')
@ -705,6 +731,137 @@ class GitCommandManager {
return result
}
/**
* Executes a git command with a timeout. Uses child_process.spawn directly
* (instead of @actions/exec) so we can kill the process on timeout and
* terminate it cleanly. Does not support customListeners.
*/
private async execGitWithTimeout(
args: string[],
timeoutMs: number,
silent: boolean,
allowAllExitCodes: boolean
): Promise<GitOutput> {
const result = new GitOutput()
const env: {[key: string]: string} = {}
for (const key of Object.keys(process.env)) {
env[key] = process.env[key] as string
}
for (const key of Object.keys(this.gitEnv)) {
env[key] = this.gitEnv[key]
}
const stdout: string[] = []
const stderr: string[] = []
return new Promise<GitOutput>((resolve, reject) => {
const child = spawn(this.gitPath, args, {
cwd: this.workingDirectory,
env,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
if (child.stderr) {
child.stderr.on('data', (data: Buffer) => {
stderr.push(data.toString())
if (!silent) {
process.stderr.write(data)
}
})
}
let settled = false
let timedOut = false
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
const cleanup = (): void => {
clearTimeout(timer)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
}
const timer = global.setTimeout(() => {
timedOut = true
// SIGTERM first, then force SIGKILL after 5 seconds.
// On Windows, SIGTERM is equivalent to a forced kill, so
// the SIGKILL fallback is effectively a no-op there.
child.kill('SIGTERM')
forceKillTimer = global.setTimeout(() => {
try {
child.kill('SIGKILL')
} catch (killErr) {
core.debug(
`Failed to SIGKILL git process: ${killErr}`
)
}
}, 5000)
if (forceKillTimer.unref) {
forceKillTimer.unref()
}
}, timeoutMs)
if (timer.unref) {
timer.unref()
}
child.on('close', (code: number | null) => {
if (settled) return
settled = true
cleanup()
if (timedOut) {
reject(
new Error(
`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 5).join(' ')}...`
)
)
return
}
// null code means killed by signal (e.g. OOM killer, external SIGTERM)
if (code === null) {
const stderrText = stderr.join('').trim()
reject(
new Error(
`The process 'git' was killed by a signal` +
(stderrText ? `\n${stderrText}` : '')
)
)
return
}
if (code !== 0 && !allowAllExitCodes) {
const stderrText = stderr.join('').trim()
reject(
new Error(
`The process 'git' failed with exit code ${code}` +
(stderrText ? `\n${stderrText}` : '')
)
)
return
}
result.exitCode = code
result.stdout = stdout.join('')
core.debug(result.exitCode.toString())
core.debug(result.stdout)
resolve(result)
})
child.on('error', (err: Error) => {
if (settled) return
settled = true
cleanup()
reject(err)
})
})
}
private async initializeCommandManager(
workingDirectory: string,
lfs: boolean,