diff --git a/cf-agent/nfs.c b/cf-agent/nfs.c index da8b5cd78a..6d2810d343 100644 --- a/cf-agent/nfs.c +++ b/cf-agent/nfs.c @@ -578,8 +578,7 @@ bool LoadMountInfo(Seq *list) } free(vbuff); - alarm(0); - signal(SIGALRM, SIG_DFL); + ClearTimeOut(); cf_pclose(pp); return true; } @@ -1175,8 +1174,7 @@ void MountAll() } free(line); - alarm(0); - signal(SIGALRM, SIG_DFL); + ClearTimeOut(); cf_pclose(pp); } diff --git a/cf-agent/verify_exec.c b/cf-agent/verify_exec.c index 15c74d5fee..89ca3a77c8 100644 --- a/cf-agent/verify_exec.c +++ b/cf-agent/verify_exec.c @@ -288,6 +288,9 @@ static ActionResult RepairExec(EvalContext *ctx, const Attributes *a, CommandPrefix(cmdline, comm); + /* Set once the command has been reaped, if its exec_timeout fired. */ + bool timed_out = false; + bool do_work_here = true; #ifndef __MINGW32__ @@ -448,7 +451,25 @@ static ActionResult RepairExec(EvalContext *ctx, const Attributes *a, { int ret = cf_pclose(pfp); - if (ret == -1) + /* Only after cf_pclose(): the read loop ends when the command + * closes its output, which can be long before it exits, so the + * alarm may not fire until cf_pclose() is already waiting. */ + timed_out = (a->contain.timeout != CF_NOINT) && TimeOutHasFired(); + + if (timed_out) + { + /* Classify on the timeout, not the exit status: a command + * killed after its last output, or one whose children alone + * are killed, is reaped with a status VerifyCommandRetcode() + * reads as success. */ + cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_TIMEOUT, pp, a, + TimeOutSignalledProcess() + ? "Command '%s' exceeded exec_timeout of %d seconds and was terminated" + : "Command '%s' exceeded exec_timeout of %d seconds; it was NOT terminated and ran to completion", + pp->promiser, a->contain.timeout); + *result = PromiseResultUpdate(*result, PROMISE_RESULT_TIMEOUT); + } + else if (ret == -1) { cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_FAIL, pp, a, "Finished script '%s' - failed (abnormal termination)", pp->promiser); *result = PromiseResultUpdate(*result, PROMISE_RESULT_FAIL); @@ -472,8 +493,7 @@ static ActionResult RepairExec(EvalContext *ctx, const Attributes *a, if (a->contain.timeout != CF_NOINT) { - alarm(0); - signal(SIGALRM, SIG_DFL); + ClearTimeOut(); } Log(info_or_verbose, "Completed execution of '%s'", cmdline); @@ -492,7 +512,7 @@ static ActionResult RepairExec(EvalContext *ctx, const Attributes *a, } #endif /* !__MINGW32__ */ - return ACTION_RESULT_OK; + return timed_out ? ACTION_RESULT_TIMEOUT : ACTION_RESULT_OK; } /*************************************************************/ diff --git a/cf-monitord/history.c b/cf-monitord/history.c index e0375b2a1b..d012eeb368 100644 --- a/cf-monitord/history.c +++ b/cf-monitord/history.c @@ -374,8 +374,7 @@ static Item *NovaReSample(EvalContext *ctx, int slot, const Attributes *attr, co if (a.contain.timeout != 0) { - alarm(0); - signal(SIGALRM, SIG_DFL); + ClearTimeOut(); } Log(LOG_LEVEL_INFO, "Collected sample of %s", pp->promiser); diff --git a/libpromises/pipes_unix.c b/libpromises/pipes_unix.c index a9aece3778..a5718bd1ad 100644 --- a/libpromises/pipes_unix.c +++ b/libpromises/pipes_unix.c @@ -33,6 +33,7 @@ #include #include #include +#include static bool CfSetuid(uid_t uid, gid_t gid); @@ -236,6 +237,27 @@ static pid_t GenericCreatePipeAndFork(IOPipe *pipes) sigset_t sigmask; sigemptyset(&sigmask); sigprocmask(SIG_SETMASK, &sigmask, NULL); + + /* Lead a new process group so a timeout can signal the command and + * everything it spawns as a unit; setpgid() is async-signal-safe. + * + * Only when a timeout is armed. A child outside the terminal's + * foreground group is stopped by SIGTTIN as soon as it reads the + * terminal, and is out of reach of a terminal SIGINT and of cf-execd's + * agent_expireafter, which kill by group. Without a timeout to bound + * the wait, that is a hang with nothing to end it. */ + if (TimeOutIsArmed()) + { + if (setpgid(0, 0) != 0) + { + /* Only descendants are lost: TimeOut()'s pgid check skips the + * group kill. Log() is not async-signal-safe, so it is + * confined to this branch. */ + Log(LOG_LEVEL_WARNING, + "Could not give the timed command its own process group (setpgid: %s), its descendants will survive a timeout", + GetErrorStr()); + } + } } ALARM_PID = (pid != 0 ? pid : -1); diff --git a/libpromises/timeout.c b/libpromises/timeout.c index 5ad63baf85..f18361df51 100644 --- a/libpromises/timeout.c +++ b/libpromises/timeout.c @@ -26,23 +26,89 @@ #include #include +/* All three are written from the signal handler, hence sig_atomic_t. */ + +/* The alarm fired. */ +static volatile sig_atomic_t TIMEOUT_FIRED = 0; /* GLOBAL_X */ + +/* ...and had a process to signal. The alarm can fire with ALARM_PID already + * cleared, i.e. timed out but never terminated. */ +static volatile sig_atomic_t TIMEOUT_SIGNALLED = 0; /* GLOBAL_X */ + +/* An alarm is pending. */ +static volatile sig_atomic_t TIMEOUT_ARMED = 0; /* GLOBAL_X */ + void SetTimeOut(int timeout) { ALARM_PID = -1; + TIMEOUT_FIRED = 0; + TIMEOUT_SIGNALLED = 0; + TIMEOUT_ARMED = 1; signal(SIGALRM, (void *) TimeOut); alarm(timeout); } +void ClearTimeOut(void) +{ + /* Leaves TIMEOUT_FIRED/TIMEOUT_SIGNALLED readable after the disarm; only + * SetTimeOut() resets them. */ + alarm(0); + signal(SIGALRM, SIG_DFL); + TIMEOUT_ARMED = 0; +} + +bool TimeOutIsArmed(void) +{ + return TIMEOUT_ARMED != 0; +} + +bool TimeOutHasFired(void) +{ + return TIMEOUT_FIRED != 0; +} + +bool TimeOutSignalledProcess(void) +{ + return TIMEOUT_SIGNALLED != 0; +} + /*************************************************************************/ void TimeOut() { alarm(0); + TIMEOUT_FIRED = 1; + TIMEOUT_ARMED = 0; if (ALARM_PID != -1) { + TIMEOUT_SIGNALLED = 1; Log(LOG_LEVEL_VERBOSE, "Time out of process %jd", (intmax_t)ALARM_PID); + +#ifndef __MINGW32__ + /* Must be read before GracefulTerminate(): afterwards getpgid() fails + * with ESRCH. */ + const pid_t pgid = getpgid(ALARM_PID); + if (pgid == -1) + { + Log(LOG_LEVEL_WARNING, + "Could not read the process group of timed-out process %jd (getpgid: %s), not signalling its process group", + (intmax_t)ALARM_PID, GetErrorStr()); + } +#endif + GracefulTerminate(ALARM_PID, PROCESS_START_TIME_UNKNOWN); + +#ifndef __MINGW32__ + /* GracefulTerminate() reaches only the process we started; its + * descendants keep the pipe open. The pgid check matters: if setpgid() + * in cf_popen()'s child did not take effect, the process is still in + * our group and a negative kill() would signal us. */ + if (pgid == ALARM_PID) + { + kill(-ALARM_PID, SIGKILL); + } +#endif } else { diff --git a/libpromises/timeout.h b/libpromises/timeout.h index 33af0f7f05..d31675bc08 100644 --- a/libpromises/timeout.h +++ b/libpromises/timeout.h @@ -26,6 +26,22 @@ #define CFENGINE_TIMEOUT_H void SetTimeOut(int timeout); + +/* Cancel a pending alarm and restore the default handler. Does not clear what + * TimeOutHasFired()/TimeOutSignalledProcess() report; SetTimeOut() does. */ +void ClearTimeOut(void); + +/* True between SetTimeOut() and the disarm. Tells a forking caller whether the + * child needs a process group of its own. */ +bool TimeOutIsArmed(void); + +/* True if the last armed alarm fired, even if the command's exit status reads + * as success. Cleared by SetTimeOut(). */ +bool TimeOutHasFired(void); + +/* True if that alarm also had a process to signal. False means timed out but + * not terminated. */ +bool TimeOutSignalledProcess(void); void TimeOut(void); time_t SetReferenceTime(void); diff --git a/tests/acceptance/08_commands/04_exec_timeout/timeout_after_output_closed.cf b/tests/acceptance/08_commands/04_exec_timeout/timeout_after_output_closed.cf new file mode 100644 index 0000000000..bf679fbd5e --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/timeout_after_output_closed.cf @@ -0,0 +1,61 @@ +####################################################### +# +# Test that exec_timeout is detected even when the command +# closes its output long before it exits. The agent's read +# loop ends at EOF, so it is already waiting for the child +# when the alarm fires; a timeout sampled while output was +# still open misses exactly this shape. The command then runs +# to completion and exits 0, and must still be reported as +# timed out. +# +# Deliberately slow: with its output already closed there is +# no process left registered for the alarm to signal, so the +# child's full 10 second sleep runs out before it is reaped. +# Expect around 12 seconds of wall clock. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "exec_timeout is detected when the command closes its output before it exits"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with a POSIX shell payload"; + + commands: + "/bin/sh" + arglist => { "-c", "exec 1>&- 2>&-; sleep 10; exit 0" }, + contain => exec_timeout_2, + classes => dcs_all_classes("output_closed"); +} + +body contain exec_timeout_2 +{ + exec_timeout => "2"; +} + +####################################################### +bundle agent check +{ + methods: + "any" + usebundle => dcs_passif_expected( + "output_closed_repair_timeout", + "output_closed_promise_kept,output_closed_promise_repaired", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/acceptance/08_commands/04_exec_timeout/timeout_does_not_leak_to_next_promise.cf b/tests/acceptance/08_commands/04_exec_timeout/timeout_does_not_leak_to_next_promise.cf new file mode 100644 index 0000000000..c38a4e0330 --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/timeout_does_not_leak_to_next_promise.cf @@ -0,0 +1,66 @@ +####################################################### +# +# Test that a fired exec_timeout is charged to the promise +# whose command timed out, and only that promise: a +# subsequent commands: promise that finishes inside its own +# timeout must come out repaired, not timed out. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "A fired exec_timeout does not leak into the next commands promise"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with POSIX shell payloads"; + + commands: + "/bin/sh" + arglist => { "-c", "sleep 2.4; exit 0" }, + contain => exec_timeout_2, + classes => dcs_all_classes("leak_first"); + + # Runs after the first command has already timed out, armed with + # its own timeout so that a stale flag surviving from the first + # promise would be sampled here if it leaked. + "/bin/sh" + arglist => { "-c", "exit 0" }, + contain => exec_timeout_10, + classes => dcs_all_classes("leak_second"); +} + +body contain exec_timeout_2 +{ + exec_timeout => "2"; +} + +body contain exec_timeout_10 +{ + exec_timeout => "10"; +} + +####################################################### +bundle agent check +{ + methods: + "any" + usebundle => dcs_passif_expected( + "leak_first_repair_timeout,leak_second_promise_repaired", + "leak_first_promise_kept,leak_first_promise_repaired,leak_second_repair_timeout", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/acceptance/08_commands/04_exec_timeout/timeout_kills_descendants.cf b/tests/acceptance/08_commands/04_exec_timeout/timeout_kills_descendants.cf new file mode 100644 index 0000000000..aef80dc338 --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/timeout_kills_descendants.cf @@ -0,0 +1,98 @@ +####################################################### +# +# Test that a fired exec_timeout also ends the command's +# descendants. The termination ladder only reaches the direct +# child; the sleep the shell spawned inherits the write end of +# the output pipe, and if it survives the timeout it holds that +# pipe open for its full 30 seconds while the agent sits in its +# read loop -- exec_timeout then does not bound the promise's +# wall clock at all. Killing the command's process group ends +# the sleep at the timeout, so the promise completes in well +# under 20 seconds. +# +# The elapsed time is measured between two marker files touched +# immediately before and after the timed promise; commands in a +# bundle run in order of declaration. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent init +{ + files: + # The markers must come from this run: a stale pair could carry a + # bounded-looking mtime difference from an earlier one. + "$(G.testdir)/desc_start" delete => tidy; + "$(G.testdir)/desc_end" delete => tidy; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "A fired exec_timeout terminates the command's descendants"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with POSIX shell payloads"; + + commands: + "/bin/sh" + arglist => { "-c", "touch $(G.testdir)/desc_start" }; + + # The shell waits for its sleep, so at the 2 second timeout the + # sleep is alive holding the pipe. Only ending it too releases + # the pipe before its natural end at 30 seconds. + "/bin/sh" + arglist => { "-c", "sleep 30; exit 0" }, + contain => exec_timeout_2, + classes => dcs_all_classes("desc_timed"); + + "/bin/sh" + arglist => { "-c", "touch $(G.testdir)/desc_end" }; +} + +body contain exec_timeout_2 +{ + exec_timeout => "2"; +} + +####################################################### +bundle agent check +{ + vars: + "start" string => filestat("$(G.testdir)/desc_start", "mtime"); + "end" string => filestat("$(G.testdir)/desc_end", "mtime"); + "elapsed" string => eval("$(end) - $(start)", "math", "infix"); + + classes: + # 20 is well past the ~10 seconds the timeout plus termination + # ladder takes, and well short of the sleep's 30. The fileexists() + # guards keep a missing marker from passing as boundedness. + "bounded" + and => { + fileexists("$(G.testdir)/desc_start"), + fileexists("$(G.testdir)/desc_end"), + islessthan("$(elapsed)", "20") + }, + scope => "namespace"; + + methods: + "any" + usebundle => dcs_passif_expected( + "desc_timed_repair_timeout,bounded", + "desc_timed_promise_kept,desc_timed_promise_repaired", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_exit_zero.cf b/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_exit_zero.cf new file mode 100644 index 0000000000..c5022af938 --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_exit_zero.cf @@ -0,0 +1,58 @@ +####################################################### +# +# Test that a commands: promise whose exec_timeout fires is +# reported as timed out even though the command exits 0. The +# exit status of a command that was signalled does not say +# whether it ran to completion, so a fired timeout must take +# precedence over a successful exit status. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "A fired exec_timeout takes precedence over a successful exit status"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with a POSIX shell payload"; + + commands: + # The sleep outlives the 2 second timeout by less than the + # termination ladder's grace period, so the signalled shell + # still reaps its child and exits 0 -- the shape in which the + # exit status used to win over the fired timeout. + "/bin/sh" + arglist => { "-c", "sleep 2.4; exit 0" }, + contain => exec_timeout_2, + classes => dcs_all_classes("timeout_exit0"); +} + +body contain exec_timeout_2 +{ + exec_timeout => "2"; +} + +####################################################### +bundle agent check +{ + methods: + "any" + usebundle => dcs_passif_expected( + "timeout_exit0_repair_timeout", + "timeout_exit0_promise_kept,timeout_exit0_promise_repaired", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_kept_returncodes.cf b/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_kept_returncodes.cf new file mode 100644 index 0000000000..4f293fe0bb --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/timeout_overrides_kept_returncodes.cf @@ -0,0 +1,66 @@ +####################################################### +# +# Test that kept_returncodes does not resurrect "kept" when +# exec_timeout fired: the timed-out command exits 0, and 0 is +# listed in kept_returncodes, but the promise must still be +# reported as timed out, not kept. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "kept_returncodes does not resurrect kept when exec_timeout fired"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with a POSIX shell payload"; + + commands: + "/bin/sh" + arglist => { "-c", "sleep 2.4; exit 0" }, + contain => exec_timeout_2, + classes => all_classes_kept_0("timeout_kept0"); +} + +body contain exec_timeout_2 +{ + exec_timeout => "2"; +} + +# dcs_all_classes (dcs.sub.cf) carries no returncode attributes and +# classes bodies do not compose, so this is dcs_all_classes plus +# kept_returncodes. +body classes all_classes_kept_0(prefix) +{ + promise_kept => { "$(prefix)_promise_kept" }; + promise_repaired => { "$(prefix)_promise_repaired" }; + repair_failed => { "$(prefix)_repair_failed" }; + repair_denied => { "$(prefix)_repair_denied" }; + repair_timeout => { "$(prefix)_repair_timeout" }; + kept_returncodes => { "0" }; +} + +####################################################### +bundle agent check +{ + methods: + "any" + usebundle => dcs_passif_expected( + "timeout_kept0_repair_timeout", + "timeout_kept0_promise_kept,timeout_kept0_promise_repaired", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/acceptance/08_commands/04_exec_timeout/within_timeout_normal_outcomes.cf b/tests/acceptance/08_commands/04_exec_timeout/within_timeout_normal_outcomes.cf new file mode 100644 index 0000000000..6ea7e32995 --- /dev/null +++ b/tests/acceptance/08_commands/04_exec_timeout/within_timeout_normal_outcomes.cf @@ -0,0 +1,59 @@ +####################################################### +# +# Test that an armed exec_timeout that does not fire leaves +# the outcome of a commands: promise to the exit status: 0 +# repairs the promise and non-zero fails it, exactly as if no +# timeout were set. Guards the normal path against +# regressions from the timed-out path. +# +####################################################### +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +####################################################### +bundle agent test +{ + meta: + "description" + string => "An exec_timeout that does not fire leaves the outcome to the exit status"; + + "test_skip_unsupported" + string => "windows", + comment => "Drives /bin/sh with POSIX shell payloads"; + + commands: + "/bin/sh" + arglist => { "-c", "sleep 1; exit 0" }, + contain => exec_timeout_10, + classes => dcs_all_classes("within_exit0"); + + "/bin/sh" + arglist => { "-c", "sleep 1; exit 3" }, + contain => exec_timeout_10, + classes => dcs_all_classes("within_exit3"); +} + +body contain exec_timeout_10 +{ + exec_timeout => "10"; +} + +####################################################### +bundle agent check +{ + methods: + "any" + usebundle => dcs_passif_expected( + "within_exit0_promise_repaired,within_exit3_repair_failed", + "within_exit0_repair_timeout,within_exit0_repair_failed,within_exit3_repair_timeout,within_exit3_promise_repaired", + $(this.promise_filename) + ), + inherit => "true"; +} + +### PROJECT_ID: core +### CATEGORY_ID: 26 diff --git a/tests/unit/Makefile.am b/tests/unit/Makefile.am index 3864b67876..403bee2687 100644 --- a/tests/unit/Makefile.am +++ b/tests/unit/Makefile.am @@ -170,6 +170,13 @@ noinst_PROGRAMS = redirection_test_stub redirection_test_stub_SOURCES = redirection_test_stub.c endif +# Waits for a real SIGALRM. platform.h declares alarm() for Windows but no +# implementation of it ships in this tree, so the test would have nothing to +# wait for there. +if !NT +check_PROGRAMS += timeout_test +endif + check_SCRIPTS = dynamic_dependency_test.sh \ tar_portability_test.sh diff --git a/tests/unit/timeout_test.c b/tests/unit/timeout_test.c new file mode 100644 index 0000000000..e9cf95c5c3 --- /dev/null +++ b/tests/unit/timeout_test.c @@ -0,0 +1,124 @@ +#include + +#include +#include +#include + +/* The alarm handler interrupts the sleep, so this normally returns as soon as + * it fires. Loop anyway: the alarm may already have fired before we get here, + * and a sleep interrupted by anything else must not end the wait early. */ +static void WaitForAlarm(void) +{ + for (int i = 0; (i < 30) && !TimeOutHasFired(); i++) + { + struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000 * 1000 }; + nanosleep(&ts, NULL); + } +} + +static void test_set_arms_and_resets_the_record(void) +{ + SetTimeOut(3600); + assert_true(TimeOutIsArmed()); + assert_false(TimeOutHasFired()); + assert_false(TimeOutSignalledProcess()); + ClearTimeOut(); +} + +/* A command that finished in time must not leave the flag set for the next, + * unrelated, child: it is what decides whether cf_popen()'s child puts itself + * in a process group of its own. */ +static void test_clear_disarms(void) +{ + SetTimeOut(3600); + ClearTimeOut(); + assert_false(TimeOutIsArmed()); +} + +static void test_fired_alarm_without_a_process(void) +{ + SetTimeOut(1); + WaitForAlarm(); + + assert_true(TimeOutHasFired()); + /* Nothing else disarms on this path -- the handler does it itself. */ + assert_false(TimeOutIsArmed()); + /* SetTimeOut() cleared ALARM_PID, so there was no process to signal and + * the caller must not describe the command as terminated. */ + assert_false(TimeOutSignalledProcess()); +} + +/* ClearTimeOut() runs after the command is reaped, before the caller reports + * on it. Clearing the record there would make a timed-out command whose exit + * status reads as success indistinguishable from one that finished. */ +static void test_clear_preserves_the_record(void) +{ + SetTimeOut(1); + WaitForAlarm(); + assert_true(TimeOutHasFired()); + + ClearTimeOut(); + assert_false(TimeOutIsArmed()); + assert_true(TimeOutHasFired()); + assert_false(TimeOutSignalledProcess()); +} + +/* The other tests never give TimeOut() a process to signal, so they cannot + * tell a ClearTimeOut() that wipes a TRUE TimeOutSignalledProcess() from one + * that leaves it alone -- both look identical when the flag started false. + * Fork a real child and let the alarm reach it. */ +static void test_clear_preserves_a_true_signalled_flag(void) +{ + pid_t child = fork(); + if (child == 0) + { + /* Long enough to still be alive when TimeOut() runs GracefulTerminate() + * on it; short enough that a leaked child does not linger. */ + sleep(5); + _exit(0); + } + assert_true(child > 0); + + SetTimeOut(1); + ALARM_PID = child; + WaitForAlarm(); + + assert_true(TimeOutHasFired()); + assert_true(TimeOutSignalledProcess()); + + ClearTimeOut(); + assert_false(TimeOutIsArmed()); + assert_true(TimeOutSignalledProcess()); + + int status; + waitpid(child, &status, 0); +} + +static void test_next_set_resets_the_record(void) +{ + SetTimeOut(1); + WaitForAlarm(); + assert_true(TimeOutHasFired()); + + SetTimeOut(3600); + assert_true(TimeOutIsArmed()); + assert_false(TimeOutHasFired()); + assert_false(TimeOutSignalledProcess()); + ClearTimeOut(); +} + +int main() +{ + const UnitTest tests[] = + { + unit_test(test_set_arms_and_resets_the_record), + unit_test(test_clear_disarms), + unit_test(test_fired_alarm_without_a_process), + unit_test(test_clear_preserves_the_record), + unit_test(test_clear_preserves_a_true_signalled_flag), + unit_test(test_next_set_resets_the_record) + }; + + PRINT_TEST_BANNER(); + return run_tests(tests); +}