]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
timer: clamp future calendar base after clock jumps back
authorAnanth <antsub@gmail.com>
Wed, 22 Jul 2026 17:49:34 +0000 (17:49 +0000)
committerYu Watanabe <watanabe.yu+github@gmail.com>
Thu, 23 Jul 2026 16:54:40 +0000 (01:54 +0900)
A calendar timer that is already waiting keeps the base timestamp it was
last armed from. When the wall clock is set backwards, that base can end
up in the future relative to the new realtime, and passing it to
calendar_spec_next_usec() schedules the next elapse relative to the old
future time instead of recalculating from now. systemctl list-timers
then keeps showing the stale pre-adjustment elapse (e.g. "3 years left"
after the clock moved back two years) and the timer never catches up.

Clamp the selected calendar base to the current realtime before asking
calendar_spec_next_usec() for the next occurrence. The clamp only kicks
in when the base is genuinely in the future, so Persistent=yes timers
whose last trigger is still in the past keep their catch-up behaviour and
we don't reintroduce the missed-run regression seen after suspend.

Fixes #6036.

src/core/timer.c
test/units/TEST-53-TIMER.clock-change.sh [new file with mode: 0755]

index 5716c4f81d566f0e5c3a60e23a567c9f1376b206..70bcb56b83de3f72acc62b9e788d433e6f8ac7c4 100644 (file)
@@ -431,6 +431,21 @@ static void timer_enter_waiting(Timer *t, bool time_change) {
                                 rebase_after_boot_time = true;
                         }
 
+                        if (b > ts.realtime) {
+                                /* The base we picked is in the future relative to the current realtime. This
+                                 * happens when the wall clock is set backwards while the timer is already
+                                 * waiting: the base was recorded from the old, later time. Feeding a future
+                                 * base to calendar_spec_next_usec() would schedule the next elapse relative to
+                                 * that stale time instead of now, so systemctl list-timers keeps showing the
+                                 * pre-adjustment elapse and the timer never catches up (see #6036). Clamp to
+                                 * now (minus the random offset, matching the "start from now" branch above) so
+                                 * we recalculate from the current time. */
+                                log_unit_debug(UNIT(t),
+                                               "Calendar timer base time %s is in the future, recalculating from the current time.",
+                                               FORMAT_TIMESTAMP(b));
+                                b = ts.realtime - random_offset;
+                        }
+
                         r = calendar_spec_next_usec(v->calendar_spec, b, &v->next_elapse);
                         if (r < 0)
                                 continue;
diff --git a/test/units/TEST-53-TIMER.clock-change.sh b/test/units/TEST-53-TIMER.clock-change.sh
new file mode 100755 (executable)
index 0000000..9a79494
--- /dev/null
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+set -eux
+set -o pipefail
+
+# Provides coverage for:
+#   - https://github.com/systemd/systemd/issues/6036
+#   - https://github.com/systemd/systemd/issues/24984
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+
+WAITING_UNIT="timer-clock-change-waiting-$RANDOM"
+MISSED_UNIT="timer-clock-change-missed-$RANDOM"
+MISSED_MARKER="/tmp/$MISSED_UNIT.ran"
+MISSED_STAMP="/var/lib/systemd/timers/stamp-$MISSED_UNIT.timer"
+
+START_REALTIME="$(date "+%s")"
+START_MONOTONIC="$(cut -d . -f 1 /proc/uptime)"
+
+at_exit() {
+    set +e
+
+    systemctl stop \
+        "$WAITING_UNIT.timer" \
+        "$WAITING_UNIT.service" \
+        "$MISSED_UNIT.timer" \
+        "$MISSED_UNIT.service"
+    systemctl clean --what=state "$MISSED_UNIT.timer"
+    rm -f \
+        "/run/systemd/system/$MISSED_UNIT.timer" \
+        "/run/systemd/system/$MISSED_UNIT.service" \
+        "$MISSED_MARKER" \
+        "$MISSED_STAMP"
+    systemctl daemon-reload
+
+    END_MONOTONIC="$(cut -d . -f 1 /proc/uptime)"
+    date --set="@$((START_REALTIME + END_MONOTONIC - START_MONOTONIC))"
+}
+
+trap at_exit EXIT
+
+timer_delta_s() {
+    local next_elapse next_elapse_s now
+
+    next_elapse="$(systemctl show -P NextElapseUSecRealtime "$1")"
+    next_elapse_s="$(date --date="$next_elapse" "+%s")"
+    now="$(date "+%s")"
+
+    TIMER_NEXT_ELAPSE="$next_elapse"
+    TIMER_NOW="$now"
+    TIMER_DELTA_S="$((next_elapse_s - now))"
+}
+
+assert_timer_due_within() {
+    local ok=0 unit="${1:?}" max_delta_s="${2:?}"
+
+    for _ in {1..50}; do
+        timer_delta_s "$unit"
+
+        if ((TIMER_DELTA_S >= 0 && TIMER_DELTA_S <= max_delta_s)); then
+            ok=1
+            break
+        fi
+
+        sleep .2
+    done
+
+    if ((ok == 0)); then
+        echo "Timer elapse outside of the expected window."
+        echo "  unit=$unit"
+        echo "  next_elapse=$TIMER_NEXT_ELAPSE"
+        echo "  now=$TIMER_NOW"
+        echo "  time_delta=$TIMER_DELTA_S"
+        exit 1
+    fi
+}
+
+: "A waiting calendar timer is recalculated after the clock is set backwards"
+
+date --set="+3 days"
+systemd-run --unit "$WAITING_UNIT" --on-calendar "*:0/15:0" true
+systemctl status "$WAITING_UNIT.timer"
+
+date --set="-3 days"
+assert_timer_due_within "$WAITING_UNIT.timer" 1200
+
+systemctl stop "$WAITING_UNIT.timer" "$WAITING_UNIT.service"
+
+: "A persistent calendar timer still catches up after time advances past a missed elapse"
+
+date --set="19:00:00"
+
+cat >"/run/systemd/system/$MISSED_UNIT.timer" <<EOF
+[Timer]
+OnCalendar=*-*-* 20:30:00
+Persistent=true
+AccuracySec=1ms
+EOF
+
+cat >"/run/systemd/system/$MISSED_UNIT.service" <<EOF
+[Service]
+Type=oneshot
+ExecStart=touch $MISSED_MARKER
+EOF
+
+systemctl daemon-reload
+mkdir -p "$(dirname "$MISSED_STAMP")"
+touch -d "yesterday 20:30:00" "$MISSED_STAMP"
+
+systemctl start "$MISSED_UNIT.timer"
+systemctl status "$MISSED_UNIT.timer"
+test ! -e "$MISSED_MARKER"
+
+date --set="tomorrow 09:00:00"
+timeout 30 bash -xec "until [[ -e '$MISSED_MARKER' ]]; do sleep .5; done"