Repository navigation
Cancelled jobs exit 255 with no signal instead of 128 + signal - #4529
Open
goingforstudying-ctrl wants to merge 1 commit into
Open
goingforstudying-ctrl wants to merge 1 commit into
goingforstudying-ctrl wants to merge 1 commit into
Conversation
When a job command is killed by a signal, Go's ExitError.ExitCode() is -1. That sentinel escaped into exit-code space: it was stored in BUILDKITE_COMMAND_EXIT_STATUS, returned from Executor.Run and passed to os.Exit, which truncates to 8 bits — so cancelled jobs surfaced as exit status 255 with no signal at all (buildkite#1509). The same -1 was also reported to the API verbatim when the bootstrap itself died by signal, where it was indistinguishable from the "command never ran" sentinel, and there was a race that made it reachable: the agent signals the whole process group, so the command can die from the same signal the bootstrap's signal goroutine is still processing. The goroutine only marked the job cancelled after Cancel returned, so the main goroutine could see the signalled command exit first, skip the re-signal-self path, and exit 255. shell.ExitCode now translates a signal death to the shell convention 128 + N (the convention cache_exec already documents), the bootstrap records the cancellation before cancelling and drains any queued signal after Run returns, and the agent reports a signalled bootstrap as 128 + N instead of -1. Checks that used ExitCode(err) == -1 as a killed-by-signal test now say IsExitSignaled, which also recognises the shell.ExitError{Code: -1} form. Verified with new tests against main first: a command of 'kill -TERM $$' made the bootstrap exit 255 and a signalled bootstrap reported exit_status "-1"; both now produce 143 with the signal preserved. Cancel path exercised under -race.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Cancelled jobs sometimes come back with
exit_status255 and no signal, and I've also seen plain-1, where the docs say it should be128 + SIGNAL(so 143 for a SIGTERM cancel). I dug into it and there are two cooperating problems:The
-1sentinel escapes into exit-code space. When the command is killed by a signal, Go'sExitError.ExitCode()is-1. That gets stored inBUILDKITE_COMMAND_EXIT_STATUS, returned fromExecutor.Run, and passed toos.Exit— which truncates to 8 bits, so the bootstrap exits 255 and all signal information is gone. The same-1shows up verbatim in thefinishpayload when the bootstrap itself dies by signal, where it's indistinguishable from the "command never ran" sentinel.A race between the bootstrap's signal goroutine and the executor returning. The agent's
Interrupt()signals the whole process group, so the command can die from the same SIGTERM that the bootstrap's signal goroutine is still processing. The goroutine only setcancelled = trueafterbootstrap.Cancel()returned, so the main goroutine could observe the signalled command exit withcancelled == falseand skip the re-signal-self path — straight to the 255 exit above.The fix:
shell.ExitCodenow translates a signal death into the shell convention128 + N(this is already the conventioncache_execdocuments internally). The three places that usedExitCode(err) == -1as a "killed by signal" check now sayIsExitSignaled(err)instead, which is what they meant anyway — andIsExitSignalednow also recognises theshell.ExitError{Code: -1}form, since a wrapped exit error with code -1 can only mean death by signal.cancelled/receivedunder the mutex before callingCancel, and the main goroutine drains the (buffered) signal channel afterRunreturns, so a queued-but-unprocessed signal still takes the re-signal path.128 + Nas the exit status alongside the signal name instead of-1.I can reproduce both symptoms deterministically on main: a command of
kill -TERM $$makes the bootstrap exit 255, and a signalled bootstrap reportsexit_status: "-1". With this patch both come out as 143 with the signal intact.Context
Fixes #1509
Changes
internal/shell:ExitCodemaps signal deaths to128 + N;IsExitSignaledrecognisesExitError{Code: -1}internal/job:checkout.go/git_errors.gosentinel checks useIsExitSignaledclicommand/bootstrap.go: record cancellation beforeCancel, drain pending signals afterRunagent/run_job.go: report a signalled bootstrap as128 + Ninstead of-1ExitCodeon a signalled process; end-to-end coverage that a self-signalling command exits the bootstrap 143, that a cancelled job reports 143 to hooks and dies by the received signal, and that a signalled bootstrap is reported to the API as143+SIGTERMPublic documentation
Status: not needed
Testing
go test ./...). Buildkite employees may check this if the pipeline has run automatically.go tool gofumpt -extra -w .)Ran the new tests against main first to confirm they capture the reported behaviour (
-1env var, bootstrap exit 255, APIexit_status: "-1"), then against the patch. Also ran the cancel path under-race. Fullgo testoninternal/shell,internal/job,internal/job/integration,clicommand,agent, andagent/integrationpasses; one pre-existing flake (TestCheckoutErrorCaptureDeliveryDoesNotCauseTimeout, a 1s checkout timeout vs slowgit cleanfork) passed on rerun and is unrelated.