CFE-4726: Fixed a commands: promise being reported as compliant after exec_timeout fired - #6299
Open
djbclark wants to merge 2 commits into
Open
CFE-4726: Fixed a commands: promise being reported as compliant after exec_timeout fired#6299djbclark wants to merge 2 commits into
djbclark wants to merge 2 commits into
Conversation
…out fired
A commands: promise whose exec_timeout fired was still judged solely on the
child's wait status. RepairExec() handed that status to VerifyCommandRetcode(),
and with the default kept_returncodes an exit status of 0 was reported as
repaired: aggregate compliance 100%, promise_repaired set, repair_timeout not
set, and PromiseResultIsOK() true. Nothing consulted whether the alarm had
fired.
ACTION_RESULT_TIMEOUT was declared in cf-agent/verify_exec.c and
VerifyExecPromise() had a case for it, but no path in the file ever returned
it, so PROMISE_RESULT_TIMEOUT was unreachable for this promise type.
This is a fail-open rather than a reporting nit. A check run under exec_timeout
cannot be distinguished from one that passed, so a policy keying a later
promise off if_ok, if_repaired or depends_on treats "the check never finished"
as "the check succeeded".
The exit status of a signalled command does not say whether it ran to
completion: the shell is signalled, its own child keeps running, the shell
reaps it and exits 0, and that 0 is what the promise was judged on. Shortening
the termination ladder narrows the window but cannot close it.
TimeOut() now records that it fired, in a volatile sig_atomic_t written from
the SIGALRM handler, and SetTimeOut() clears it so the flag describes only the
command just run. RepairExec() classifies on that flag in preference to the
exit status, and returns ACTION_RESULT_TIMEOUT.
The flag is sampled immediately after cf_pclose() returns, and never earlier.
The output read loop ends as soon as the command closes its output, which it
can do long before it exits, so the alarm may not fire until cf_pclose() is
already waiting for the child. Sampling before that wait misses exactly the
shape this is meant to catch.
TimeOut() also records whether it actually had a process to signal.
cf_pclose() clears ALARM_PID before waiting, so a command that closes its
output and then outlives its timeout reaches TimeOut() with nothing to signal:
the alarm fires, the ALARM_PID == -1 branch runs, and the command runs to
completion untouched. Reporting that as a termination would be a false
statement in an error message about a fail-open, so the two cases are worded
differently:
Command '...' exceeded exec_timeout of 2 seconds and was terminated
Command '...' exceeded exec_timeout of 2 seconds; it was NOT terminated
and ran to completion
Both are PROMISE_RESULT_TIMEOUT at 0% compliance; only the wording differs.
That a timed-out command is not always terminated is a separate defect, not
addressed here; the second wording makes it visible rather than mislabelled.
PROMISE_RESULT_TIMEOUT already flowed correctly through PromiseResultUpdate()
(CHANGE + TIMEOUT -> TIMEOUT), so only the missing classification and return
had to be supplied.
Two limits are known and unchanged. A promise with background => "true" is
still reported by the parent as kept, so this does not make exec_timeout
visible on that path. And there is a residual window between cf_pclose()
returning and the alarm being disarmed in which a just-on-time completion could
be labelled a timeout; closing it means blocking SIGALRM, then alarm(0), then
sampling.
Commands finishing within their timeout are unaffected: exit 0 still repairs
the promise and a non-zero exit still fails it.
Ticket: CFE-4726
Changelog: Title
Five tests in the new tests/acceptance/08_commands/04_exec_timeout/, covering
the outcome classification of a commands: promise run under exec_timeout:
- timeout_overrides_exit_zero.cf: a fired timeout is reported as
repair_timeout even though the command exits 0. Without the fix the exit
status wins and the promise is reported repaired at 100% compliance.
- within_timeout_normal_outcomes.cf: an armed timeout that does not fire
leaves the outcome to the exit status -- 0 repairs the promise, non-zero
fails it. Guards the normal path.
- timeout_overrides_kept_returncodes.cf: kept_returncodes => { "0" } does not
resurrect "kept" when the timeout fired.
- timeout_after_output_closed.cf: the timeout is detected even when the command
closes its output long before it exits, so the alarm fires only once the
agent is already waiting for the child. Deliberately slow (about 12
seconds): with output closed there is no process registered for the alarm to
signal, so the child runs to completion.
- timeout_does_not_leak_to_next_promise.cf: a fired timeout is charged to the
promise whose command timed out; the next commands promise, armed with its
own timeout, still comes out repaired.
All five report through dcs_all_classes() from dcs.sub.cf, except the
kept_returncodes test: classes bodies cannot compose, so it carries a local
copy of that body with kept_returncodes added.
Ticket: CFE-4726
Changelog: none
djbclark
added a commit
to frdminc/tendcf
that referenced
this pull request
Aug 17, 2026
Also corrects two stale statements the register itself carried: - The B-8 Item cell still said the promise is reported **kept**. The panel retracted that on 2026-08-17 -- the default outcome for exit 0 is *repaired*, so the accurate word is **compliant**. Same class of error as the never-refile-body-verbatim rule guards against, just in our own record. - B-8's "still needs its own second opinion" note and the stock-libntech gate were both discharged and are now marked so. The gate is genuinely met: the core-acceptance worktree carries stock pointer 5b5d04e1, which is what upstream master 17eb78e6d records. The Fix cell listed only 326bcdb8d; it now names all four commits including the acceptance tests.
This was referenced Aug 17, 2026
Author
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.
A
commands:promise whoseexec_timeoutfires is reported as compliant.The promise result is decided entirely by the child's wait status:
RepairExec()hands it toVerifyCommandRetcode(), and with the defaultkept_returncodesan exit status of 0 is reported as repaired — aggregatecompliance 100%,
promise_repairedset,repair_timeoutnot set, andPromiseResultIsOK()true. Nothing consults whether the alarm fired.ACTION_RESULT_TIMEOUTis declared incf-agent/verify_exec.candVerifyExecPromise()has acasefor it, but no path in the file ever returnsit, so
PROMISE_RESULT_TIMEOUTis unreachable for this promise type.This is a fail-open rather than a reporting nit: a check run under
exec_timeoutcannot be distinguished from one that passed, so a policy keyinga later promise off
if_ok,if_repairedordepends_ontreats "the checknever finished" as "the check succeeded".
Reproducer
Verified on master
17eb78e6d, macOS 26.6.1 arm64. The command has to exitduring the termination ladder rather than before the timeout:
The timeout is logged and then discarded.
This is not a narrow race. The shell is signalled, its own child keeps running,
the shell reaps it and exits 0, and that 0 is what the promise is judged on.
Shortening the termination ladder narrows the window but cannot close it,
because the exit status of a command that was killed is not a reliable report
of whether it was killed.
The change
TimeOut()records that it fired, in avolatile sig_atomic_twritten from theSIGALRMhandler;SetTimeOut()clears it so the flag describes only thecommand just run.
RepairExec()classifies on that flag in preference to theexit status, and returns
ACTION_RESULT_TIMEOUT.The flag is sampled immediately after
cf_pclose()returns, and never earlier.The output read loop ends as soon as the command closes its output, which it can
do long before it exits, so the alarm may not fire until
cf_pclose()isalready waiting for the child — sampling before that wait misses exactly the
shape this is meant to catch.
TimeOut()also records whether it actually had a process to signal.cf_pclose()clearsALARM_PIDbefore waiting, so a command that closes itsoutput and then outlives its timeout reaches
TimeOut()with nothing to signal:the alarm fires, the
ALARM_PID == -1branch runs, and the command runs tocompletion untouched. Reporting that as a termination would be a false statement
in an error message about a fail-open, so the two cases are worded differently:
Both are
PROMISE_RESULT_TIMEOUTat 0% compliance; only the wording differs.PROMISE_RESULT_TIMEOUTalready flows correctly throughPromiseResultUpdate()(
CHANGE+TIMEOUT->TIMEOUT), so only the missing classification andreturn had to be supplied.
Known limits, stated up front
background => "true"is unchanged. The parent still reports the promisekept, so this does not make
exec_timeoutvisible on that path.cf_pclose()returning and the alarm beingdisarmed, in which a just-on-time completion could be labelled a timeout.
Closing it means blocking
SIGALRM, thenalarm(0), then sampling. Not donehere.
not addressed here. The "NOT terminated" wording exists to make it visible
rather than silently mislabelled.
Tests
Five acceptance tests in the new
tests/acceptance/08_commands/04_exec_timeout/(following the existing
01_modules/02_syntax/03_shellsnumbering):the
sleep 2.4shape; the same command finishing inside its timeout (stillrepaired on 0, failed on non-zero);
kept_returncodes => { "0" }notresurrecting "kept"; the output-closed shape; and two sequential
commands:proving the flag does not leak to the next promise.
All five report through
dcs_all_classes()fromdcs.sub.cf, except thekept_returncodestest —classesbodies cannot compose, so it carries a localcopy of that body.
Verified on this branch, macOS 26.6.1 arm64:
Discrimination checked by reverting only the three source files to stock
17eb78e6dand keeping the new tests:The one that still passes is
within_timeout_normal_outcomes.cf, thenormal-path guard — correct, since it pins behaviour this change does not alter.
The sources were then restored and confirmed byte-identical by sha256, with a
clean tree and a clean rebuild.
Notes
Cut from master
17eb78e6d, which is the commit this was verified against. Theonly drift to current master is the libntech submodule bump in #6297, which does
not touch any path in this change.
Tracked as CFE-4726.
Happy to reshape this — split the tests out, close the residual window in the
same PR, or adjust the two message wordings — if you'd prefer it differently.