git: 57ee56578cc5 - main - libc: Test time zone change detection.

From: Dag-Erling Smørgrav <des_at_FreeBSD.org>
Date: Fri, 18 Jul 2025 17:50:22 UTC
The branch main has been updated by des:

URL: https://cgit.FreeBSD.org/src/commit/?id=57ee56578cc55b3da9a0dee64b385507be2dc80a

commit 57ee56578cc55b3da9a0dee64b385507be2dc80a
Author:     Dag-Erling Smørgrav <des@FreeBSD.org>
AuthorDate: 2025-07-18 17:49:13 +0000
Commit:     Dag-Erling Smørgrav <des@FreeBSD.org>
CommitDate: 2025-07-18 17:49:57 +0000

    libc: Test time zone change detection.
    
    While here, clean the detection code up a bit.
    
    Sponsored by:   Klara, Inc.
    Sponsored by:   NetApp, Inc.
    Reviewed by:    markj
    Differential Revision:  https://reviews.freebsd.org/D51343
---
 contrib/tzcode/localtime.c                      |  18 +-
 lib/libc/stdtime/Makefile.inc                   |   1 +
 lib/libc/stdtime/Symbol.map                     |   6 +
 lib/libc/tests/stdtime/Makefile                 |   5 +-
 lib/libc/tests/stdtime/detect_tz_changes_test.c | 281 ++++++++++++++++++++++++
 tools/build/mk/OptionalObsoleteFiles.inc        |   4 +
 6 files changed, 301 insertions(+), 14 deletions(-)

diff --git a/contrib/tzcode/localtime.c b/contrib/tzcode/localtime.c
index 69b5f0183e2c..5c3ef7ed47af 100644
--- a/contrib/tzcode/localtime.c
+++ b/contrib/tzcode/localtime.c
@@ -18,6 +18,7 @@
 #ifndef DETECT_TZ_CHANGES_INTERVAL
 #define DETECT_TZ_CHANGES_INTERVAL 61
 #endif
+int __tz_change_interval = DETECT_TZ_CHANGES_INTERVAL;
 #include <sys/stat.h>
 #endif
 #include <fcntl.h>
@@ -1372,22 +1373,13 @@ recheck_tzdata()
 {
 	static time_t last_checked;
 	struct timespec now;
-	time_t current_time;
-	int error;
 
-	/*
-	 * We want to recheck the timezone file every 61 sec.
-	 */
-	error = clock_gettime(CLOCK_MONOTONIC, &now);
-	if (error < 0) {
-		/* XXX: Can we somehow report this? */
+	if (clock_gettime(CLOCK_MONOTONIC, &now) < 0)
 		return 0;
-	}
 
-	current_time = now.tv_sec;
-	if ((current_time - last_checked > DETECT_TZ_CHANGES_INTERVAL) ||
-	    (last_checked > current_time)) {
-		last_checked = current_time;
+	if ((now.tv_sec - last_checked >= __tz_change_interval) ||
+	    (last_checked > now.tv_sec)) {
+		last_checked = now.tv_sec;
 		return 1;
 	}
 
diff --git a/lib/libc/stdtime/Makefile.inc b/lib/libc/stdtime/Makefile.inc
index 5d0ce7765b63..647cbe6f40ba 100644
--- a/lib/libc/stdtime/Makefile.inc
+++ b/lib/libc/stdtime/Makefile.inc
@@ -18,6 +18,7 @@ CFLAGS.${src}+=	-I${LIBC_SRCTOP}/stdtime
 CFLAGS.localtime.c+= -DALL_STATE -DTHREAD_SAFE
 .if ${MK_DETECT_TZ_CHANGES} != "no"
 CFLAGS.localtime.c+= -DDETECT_TZ_CHANGES
+CFLAGS.Version.map+= -DDETECT_TZ_CHANGES
 .endif
 
 MAN+=	ctime.3 strftime.3 strptime.3 time2posix.3 tzset.3
diff --git a/lib/libc/stdtime/Symbol.map b/lib/libc/stdtime/Symbol.map
index 669d4d47907b..6a34cd3ea590 100644
--- a/lib/libc/stdtime/Symbol.map
+++ b/lib/libc/stdtime/Symbol.map
@@ -35,3 +35,9 @@ FBSD_1.8 {
 	daylight;
 	timezone;
 };
+
+FBSDprivate_1.0 {
+#ifdef DETECT_TZ_CHANGES
+	__tz_change_interval;
+#endif
+};
diff --git a/lib/libc/tests/stdtime/Makefile b/lib/libc/tests/stdtime/Makefile
index c7a7f5b9436f..adb883cc5b9a 100644
--- a/lib/libc/tests/stdtime/Makefile
+++ b/lib/libc/tests/stdtime/Makefile
@@ -1,6 +1,9 @@
-.include <bsd.own.mk>
+.include <src.opts.mk>
 
 ATF_TESTS_C+=		strptime_test
+.if ${MK_DETECT_TZ_CHANGES} != "no"
+ATF_TESTS_C+=		detect_tz_changes_test
+.endif
 
 TESTSDIR:=	${TESTSBASE}/${RELDIR:C/libc\/tests/libc/}
 
diff --git a/lib/libc/tests/stdtime/detect_tz_changes_test.c b/lib/libc/tests/stdtime/detect_tz_changes_test.c
new file mode 100644
index 000000000000..9722546747fd
--- /dev/null
+++ b/lib/libc/tests/stdtime/detect_tz_changes_test.c
@@ -0,0 +1,281 @@
+/*-
+ * Copyright (c) 2025 Klara, Inc.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <dlfcn.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <atf-c.h>
+
+static const time_t then = 1751328000; /* 2025-07-01 00:00:00 UTC */
+static const char *tz_change_interval_sym = "__tz_change_interval";
+static int *tz_change_interval_p;
+static const int tz_change_interval = 3;
+static int tz_change_timeout = 90;
+
+static bool debugging;
+
+static void
+debug(const char *fmt, ...)
+{
+	va_list ap;
+
+	if (debugging) {
+		va_start(ap, fmt);
+		vfprintf(stderr, fmt, ap);
+		va_end(ap);
+		fputc('\n', stderr);
+	}
+}
+
+static void
+change_tz(const char *tzn)
+{
+	static const char *zfn = "/usr/share/zoneinfo";
+	static const char *tfn = "root/etc/.localtime";
+	static const char *dfn = "root/etc/localtime";
+	ssize_t clen;
+	int zfd, sfd, dfd;
+
+	ATF_REQUIRE((zfd = open(zfn, O_DIRECTORY | O_SEARCH)) >= 0);
+	ATF_REQUIRE((sfd = openat(zfd, tzn, O_RDONLY)) >= 0);
+	ATF_REQUIRE((dfd = open(tfn, O_CREAT | O_TRUNC | O_WRONLY)) >= 0);
+	do {
+		clen = copy_file_range(sfd, NULL, dfd, NULL, SSIZE_MAX, 0);
+		ATF_REQUIRE_MSG(clen != -1, "failed to copy %s/%s: %m",
+		    zfn, tzn);
+	} while (clen > 0);
+	ATF_CHECK_EQ(0, close(dfd));
+	ATF_CHECK_EQ(0, close(sfd));
+	ATF_CHECK_EQ(0, close(zfd));
+	ATF_REQUIRE_EQ(0, rename(tfn, dfn));
+	debug("time zone %s installed", tzn);
+}
+
+/*
+ * Test time zone change detection.
+ *
+ * The parent creates a chroot containing only /etc/localtime, initially
+ * set to UTC.  It then forks a child which enters the chroot, repeatedly
+ * checks the current time zone, and prints it to stdout if it changes
+ * (including once on startup).  Meanwhile, the parent waits for output
+ * from the child.  Every time it receives a line of text from the child,
+ * it checks that it is as expected, then changes /etc/localtime within
+ * the chroot to the next case in the list.  Once it reaches the end of
+ * the list, it closes a pipe to notify the child, which terminates.
+ *
+ * Note that ATF and / or Kyua may have set the timezone before the test
+ * case starts (even unintentionally).  Therefore, we start the test only
+ * after we've received and discarded the first report from the child,
+ * which should come almost immediately on startup.
+ */
+ATF_TC(detect_tz_changes);
+ATF_TC_HEAD(detect_tz_changes, tc)
+{
+	atf_tc_set_md_var(tc, "descr", "Test timezone change detection");
+	atf_tc_set_md_var(tc, "require.user", "root");
+	atf_tc_set_md_var(tc, "timeout", "600");
+}
+ATF_TC_BODY(detect_tz_changes, tc)
+{
+	static const struct tzcase {
+		const char *tzfn;
+		const char *expect;
+	} tzcases[] = {
+		/*
+		 * A handful of time zones and the expected result of
+		 * strftime("%z (%Z)", tm) when that time zone is active
+		 * and tm represents a date in the summer of 2025.
+		 */
+		{ "America/Vancouver",	"-0700 (PDT)"	},
+		{ "America/New_York",	"-0400 (EDT)"	},
+		{ "Europe/London",	"+0100 (BST)"	},
+		{ "Europe/Paris",	"+0200 (CEST)"	},
+		{ "Asia/Kolkata",	"+0530 (IST)"	},
+		{ "Asia/Tokyo",		"+0900 (JST)"	},
+		{ "Australia/Canberra",	"+1000 (AEST)"	},
+		{ "UTC",		"+0000 (UTC)"	},
+		{ 0 },
+	};
+	char obuf[1024] = "";
+	char ebuf[1024] = "";
+	struct pollfd fds[3];
+	int opd[2], epd[2], spd[2];
+	time_t changed, now;
+	const struct tzcase *tzcase = NULL;
+	struct tm *tm;
+	size_t olen = 0, elen = 0;
+	ssize_t rlen;
+	long curoff = LONG_MIN;
+	pid_t pid;
+	int nfds, status;
+
+	/* speed up the test if possible */
+	tz_change_interval_p = dlsym(RTLD_SELF, tz_change_interval_sym);
+	if (tz_change_interval_p != NULL &&
+	    *tz_change_interval_p > tz_change_interval) {
+		debug("reducing detection interval from %d to %d",
+		    *tz_change_interval_p, tz_change_interval);
+		*tz_change_interval_p = tz_change_interval;
+		tz_change_timeout = tz_change_interval * 3;
+	}
+	/* prepare chroot */
+	ATF_REQUIRE_EQ(0, mkdir("root", 0755));
+	ATF_REQUIRE_EQ(0, mkdir("root/etc", 0755));
+	change_tz("UTC");
+	time(&changed);
+	/* output, error, sync pipes */
+	if (pipe(opd) != 0 || pipe(epd) != 0 || pipe(spd) != 0)
+		atf_tc_fail("failed to pipe");
+	/* fork child */
+	if ((pid = fork()) < 0)
+		atf_tc_fail("failed to fork");
+	if (pid == 0) {
+		/* child */
+		dup2(opd[1], STDOUT_FILENO);
+		close(opd[0]);
+		close(opd[1]);
+		dup2(epd[1], STDERR_FILENO);
+		close(epd[0]);
+		close(epd[1]);
+		close(spd[0]);
+		unsetenv("TZ");
+		ATF_REQUIRE_EQ(0, chroot("root"));
+		ATF_REQUIRE_EQ(0, chdir("/"));
+		fds[0].fd = spd[1];
+		fds[0].events = POLLIN;
+		for (;;) {
+			ATF_REQUIRE(poll(fds, 1, 100) >= 0);
+			if (fds[0].revents & POLLHUP) {
+				/* parent closed sync pipe */
+				_exit(0);
+			}
+			ATF_REQUIRE((tm = localtime(&then)) != NULL);
+			if (tm->tm_gmtoff == curoff)
+				continue;
+			olen = strftime(obuf, sizeof(obuf), "%z (%Z)", tm);
+			ATF_REQUIRE(olen > 0);
+			fprintf(stdout, "%s\n", obuf);
+			fflush(stdout);
+			curoff = tm->tm_gmtoff;
+		}
+		_exit(2);
+	}
+	/* parent */
+	close(opd[1]);
+	close(epd[1]);
+	close(spd[1]);
+	/* receive output until child terminates */
+	fds[0].fd = opd[0];
+	fds[0].events = POLLIN;
+	fds[1].fd = epd[0];
+	fds[1].events = POLLIN;
+	fds[2].fd = spd[0];
+	fds[2].events = POLLIN;
+	nfds = 3;
+	for (;;) {
+		ATF_REQUIRE(poll(fds, 3, 1000) >= 0);
+		time(&now);
+		if (fds[0].revents & POLLIN && olen < sizeof(obuf)) {
+			rlen = read(opd[0], obuf + olen, sizeof(obuf) - olen);
+			ATF_REQUIRE(rlen >= 0);
+			olen += rlen;
+		}
+		if (olen > 0) {
+			ATF_REQUIRE_EQ('\n', obuf[olen - 1]);
+			obuf[--olen] = '\0';
+			/* tzcase will be NULL at first */
+			if (tzcase != NULL) {
+				debug("%s", obuf);
+				ATF_REQUIRE_STREQ(tzcase->expect, obuf);
+				debug("change to %s detected after %d s",
+				    tzcase->tzfn, (int)(now - changed));
+				if (tz_change_interval_p != NULL) {
+					ATF_CHECK((int)(now - changed) >=
+					    *tz_change_interval_p - 1);
+					ATF_CHECK((int)(now - changed) <=
+					    *tz_change_interval_p + 1);
+				}
+			}
+			olen = 0;
+			/* first / next test case */
+			if (tzcase == NULL)
+				tzcase = tzcases;
+			else
+				tzcase++;
+			if (tzcase->tzfn == NULL) {
+				/* test is over */
+				break;
+			}
+			change_tz(tzcase->tzfn);
+			changed = now;
+		}
+		if (fds[1].revents & POLLIN && elen < sizeof(ebuf)) {
+			rlen = read(epd[0], ebuf + elen, sizeof(ebuf) - elen);
+			ATF_REQUIRE(rlen >= 0);
+			elen += rlen;
+		}
+		if (elen > 0) {
+			ATF_REQUIRE_EQ(elen, fwrite(ebuf, 1, elen, stderr));
+			elen = 0;
+		}
+		if (nfds > 2 && fds[2].revents & POLLHUP) {
+			/* child closed sync pipe */
+			break;
+		}
+		/*
+		 * The timeout for this test case is set to 10 minutes,
+		 * because it can take that long to run with the default
+		 * 61-second interval.  However, each individual tzcase
+		 * entry should not take much longer than the detection
+		 * interval to test, so we can detect a problem long
+		 * before Kyua terminates us.
+		 */
+		if ((now - changed) > tz_change_timeout) {
+			close(spd[0]);
+			if (tz_change_interval_p == NULL &&
+			    tzcase == tzcases) {
+				/*
+				 * The most likely explanation in this
+				 * case is that libc was built without
+				 * time zone change detection.
+				 */
+				atf_tc_skip("time zone change detection "
+				    "does not appear to be enabled");
+			}
+			atf_tc_fail("timed out waiting for change to %s "
+			    "to be detected", tzcase->tzfn);
+		}
+	}
+	close(opd[0]);
+	close(epd[0]);
+	close(spd[0]); /* this will wake up and terminate the child */
+	if (olen > 0)
+		ATF_REQUIRE_EQ(olen, fwrite(obuf, 1, olen, stdout));
+	if (elen > 0)
+		ATF_REQUIRE_EQ(elen, fwrite(ebuf, 1, elen, stderr));
+	ATF_REQUIRE_EQ(pid, waitpid(pid, &status, 0));
+	ATF_REQUIRE(WIFEXITED(status));
+	ATF_REQUIRE_EQ(0, WEXITSTATUS(status));
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+	debugging = !getenv("__RUNNING_INSIDE_ATF_RUN") &&
+	    isatty(STDERR_FILENO);
+	ATF_TP_ADD_TC(tp, detect_tz_changes);
+	return (atf_no_error());
+}
diff --git a/tools/build/mk/OptionalObsoleteFiles.inc b/tools/build/mk/OptionalObsoleteFiles.inc
index 1e63e4616909..4c127b392138 100644
--- a/tools/build/mk/OptionalObsoleteFiles.inc
+++ b/tools/build/mk/OptionalObsoleteFiles.inc
@@ -1441,6 +1441,10 @@ OLD_LIBS+=${DEBUG_LIBS}
 .endif
 .endif
 
+.if ${MK_DETECT_TZ_CHANGES} == no
+OLD_FILES+=tests/lib/libc/stdtime/detect_tz_changes_test
+.endif
+
 .if ${MK_DIALOG} == no
 OLD_FILES+=usr/bin/dialog
 OLD_FILES+=usr/bin/dpv