Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package nqptp for openSUSE:Factory checked in at 2026-10-15 16:12:28 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/nqptp (Old) and /work/SRC/openSUSE:Factory/.nqptp.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "nqptp" Thu Oct 15 16:12:28 2026 rev:4 rq:1384883 version:1.2.9 Changes: -------- --- /work/SRC/openSUSE:Factory/nqptp/nqptp.changes 2026-06-04 18:57:04.807723079 +0200 +++ /work/SRC/openSUSE:Factory/.nqptp.new.3698736/nqptp.changes 2026-10-15 16:12:55.571981170 +0200 @@ -1,0 +2,13 @@ +Thu Oct 15 04:48:35 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to version 1.2.9: + * Shared Memory Interface is now version 11: update NQPTP + first, then Shairport Sync 5.6, restarting both in that + order + * Improve grandmaster-clock change handling, passing clock + differences and correction_field data on without + restarting smoothing + * Restore per-client handling so multiple instances can + share an AirPlay 2 group + +------------------------------------------------------------------- Old: ---- nqptp-1.2.8.tar.gz New: ---- nqptp-1.2.9.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ nqptp.spec ++++++ --- /var/tmp/diff_new_pack.gQ6mqD/_old 2026-10-15 16:12:56.317012343 +0200 +++ /var/tmp/diff_new_pack.gQ6mqD/_new 2026-10-15 16:12:56.319012427 +0200 @@ -17,7 +17,7 @@ Name: nqptp -Version: 1.2.8 +Version: 1.2.9 Release: 0 Summary: Not Quite PTP License: GPL-2.0-only ++++++ nqptp-1.2.8.tar.gz -> nqptp-1.2.9.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/RELEASE_NOTES.md new/nqptp-1.2.9/RELEASE_NOTES.md --- old/nqptp-1.2.8/RELEASE_NOTES.md 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/RELEASE_NOTES.md 2026-10-14 12:42:13.000000000 +0200 @@ -1,3 +1,41 @@ +## Version 1.2.9, smi11 +* This is essentially 1.2.9-dev-14-g0c6e601. +## Version: 1.2.9-dev-14-g0c6e601, smi11 +**BugFix** +* On macOS (Darwin), stop setting the priority of the main thread in Linux fashion. It actually demotes the thread's priority to a very low level on macOS (Darwin). ATM, we don't set the thread priority on macOS. + +## Version: 1.2.9-dev-12-gdbfd3ba, smi11 + +**Support macOS (Darwin)** +* Define `CONFIG_FOR_DARWIN`, and don't require `librt`. +* Use the BSD code paths on Darwin: `AF_LINK` for the device MAC address and plain `MAP_SHARED` `mmap` for the shared memory. +* `shm_ftruncate()`: macOS allows a POSIX shared memory object to be sized only once, so `ftruncate()` on a segment left over from a previous run fails with `EINVAL`. Skip the resize if the segment is already big enough. +* `check-gitversion`: use `printf` rather than the non-portable `echo -n`, which macOS's `/bin/sh` writes out literally, breaking `gitversion.h`. +* Use `CLOCK_UPTIME_RAW` as the basis for timing. This is effectively the same clock as is used by Shairport Sync on macOS. +* Use the arrival time of a `UDP` packet provided by the kernel instead of the less precise time at which the thread resumes after receiving the packet. This should improve timing accuracy and stability. + +Ports 319 and 320 can be bound without `root` on macOS, so NQPTP can run as an ordinary user. + +Note that Macs running on Apple Silicon have AirPlay playback built in and don't need Shairport Sync or NQPTP. And a Mac can not be both an AirPlay source (e.g. from Apple Music) and an AirPlay receiver (e.g. Shairport Sync) at the same time. + +Sincere thanks to [Filipe](https://github.com/filipef101) for the [PR](https://github.com/mikebrady/nqptp/pull/51). + +## Version: 1.2.9-dev-7-g49c419c, smi11 + +This update in the `development` branch includes a number of updates to make clock handoff smoother. Clock handoff can occur when you add another player, such as a HomePod or Apple TV, to the output devices. The clocks in such devices may "take over" clocking at that point. + +* When a clock grandmaster changes (and its offset from local time changes), the difference between the old and new offsets is passed to Shairport Sync. +* The PTP `correction_field` is used in calculating precise timing. +* Smoothing continues even when grandmother clocks change. +* Code has been added to explore standard added TLVs (Tag-Length-Value items) in PTP `FollowUp` messages. + +* The Shared Memory Interface (SMI) of the updated NQPTP is now 11, i.e. `smi11`: +``` +$ nqptp -V +Version: 1.2.9-dev-7-g49c419c. Shared Memory Interface Version: smi11. +``` +Please note that the SMI version in NQPTP and Shairport Sync must match -- you may need to update Shairport Sync. At this time, the `development` branch of Shairport Sync is at `smi11`. + ## Version 1.2.8 * A new `man` file. Thanks again to [Chris Boot](https://github.com/bootc) for the [PR](https://github.com/mikebrady/nqptp/pull/48). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/check-gitversion new/nqptp-1.2.9/check-gitversion --- old/nqptp-1.2.8/check-gitversion 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/check-gitversion 2026-10-14 12:42:13.000000000 +0200 @@ -12,7 +12,7 @@ echo $GD > gitversion-stamp echo "// Do not edit!" > gitversion.h echo "// This file is automatically generated." >> gitversion.h - echo -n " char git_version_string[] = \"" >> gitversion.h + printf "%s" " char git_version_string[] = \"" >> gitversion.h ## the tr is because we need to remove the trailing newline cat gitversion-stamp | tr -d '[[:space:]]' >> gitversion.h echo "\";" >> gitversion.h diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/configure.ac new/nqptp-1.2.9/configure.ac --- old/nqptp-1.2.8/configure.ac 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/configure.ac 2026-10-14 12:42:13.000000000 +0200 @@ -2,7 +2,7 @@ # Process this file with autoconf to produce a configure script. AC_PREREQ([2.68]) -AC_INIT([nqptp], [1.2.8], [[email protected]]) +AC_INIT([nqptp], [1.2.9], [[email protected]]) : ${CFLAGS="-O3"} : ${CXXFLAGS="-O3"} AM_INIT_AUTOMAKE @@ -11,6 +11,7 @@ build_linux=no build_freebsd=no build_openbsd=no +build_darwin=no # Detect the target system case "${host_os}" in @@ -23,6 +24,9 @@ openbsd*) build_openbsd=yes ;; + darwin*) + build_darwin=yes + ;; *) AC_MSG_ERROR(["OS $host_os is not supported"]) ;; @@ -32,6 +36,7 @@ AM_CONDITIONAL([BUILD_FOR_LINUX], [test "$build_linux" = "yes"]) AM_CONDITIONAL([BUILD_FOR_FREEBSD], [test "$build_freebsd" = "yes"]) AM_CONDITIONAL([BUILD_FOR_OPENBSD], [test "$build_openbsd" = "yes"]) +AM_CONDITIONAL([BUILD_FOR_DARWIN], [test "$build_darwin" = "yes"]) if test "x$build_linux" = "xyes" ; then AC_DEFINE([CONFIG_FOR_LINUX], 1, [Build for Linux.]) @@ -42,6 +47,9 @@ if test "x$build_openbsd" = "xyes" ; then AC_DEFINE([CONFIG_FOR_OPENBSD], 1, [Build for OpenBSD.]) fi +if test "x$build_darwin" = "xyes" ; then + AC_DEFINE([CONFIG_FOR_DARWIN], 1, [Build for macOS.]) +fi AC_CHECK_PROGS([GIT], [git]) if test -n "$GIT" && test -e ".git/index" ; then @@ -75,7 +83,7 @@ # Checks for libraries. AC_CHECK_LIB([pthread],[pthread_create], , AC_MSG_ERROR(pthread library needed)) -if test "x$build_openbsd" = "xno" ; then +if test "x$build_openbsd" = "xno" && test "x$build_darwin" = "xno" ; then # part of libc AC_CHECK_LIB([rt],[clock_gettime], , AC_MSG_ERROR(librt needed for shared memory library)) fi diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/general-utilities.c new/nqptp-1.2.9/general-utilities.c --- old/nqptp-1.2.8/general-utilities.c 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/general-utilities.c 2026-10-14 12:42:13.000000000 +0200 @@ -21,6 +21,8 @@ #include "debug.h" #include <arpa/inet.h> // ntohl etc. #include <string.h> // memcpy +#include <sys/stat.h> // fstat +#include <unistd.h> // ftruncate void hcton64(uint64_t num, uint8_t *p) { uint64_t numc = num; @@ -77,6 +79,7 @@ return tnfpsec + tnfpnsec; } +/* uint64_t get_time_now() { struct timespec tn; #ifdef CLOCK_MONOTONIC_RAW @@ -86,3 +89,22 @@ #endif return timespec_to_ns(&tn); } +*/ +uint64_t get_time_now() { + struct timespec tn; +#if defined(CONFIG_FOR_DARWIN) + clock_gettime(CLOCK_UPTIME_RAW, &tn); +#elif defined(CLOCK_MONOTONIC_RAW) + clock_gettime(CLOCK_MONOTONIC_RAW, &tn); +#else + clock_gettime(CLOCK_MONOTONIC, &tn); +#endif + return (uint64_t)tn.tv_sec * 1000000000u + (uint64_t)tn.tv_nsec; +} + +int shm_ftruncate(int fd, off_t length) { + struct stat st; + if ((fstat(fd, &st) == 0) && (st.st_size >= length)) + return 0; + return ftruncate(fd, length); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/general-utilities.h new/nqptp-1.2.9/general-utilities.h --- old/nqptp-1.2.8/general-utilities.h 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/general-utilities.h 2026-10-14 12:42:13.000000000 +0200 @@ -25,6 +25,7 @@ #include <inttypes.h> #include <sys/socket.h> +#include <sys/types.h> #include <time.h> // struct sockaddr_in6 is bigger than struct sockaddr. derp @@ -47,4 +48,9 @@ uint64_t ntoh64(const uint64_t n); +// Like ftruncate(), but tolerates a shared memory segment that is already +// large enough. macOS only allows a POSIX shared memory object to be sized +// once, so resizing a segment left over from a previous run fails there. +int shm_ftruncate(int fd, off_t length); + #endif \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-clock-sources.c new/nqptp-1.2.9/nqptp-clock-sources.c --- old/nqptp-1.2.8/nqptp-clock-sources.c 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-clock-sources.c 2026-10-14 12:42:13.000000000 +0200 @@ -35,7 +35,7 @@ #include <sys/select.h> // for fd_set #include <sys/stat.h> // umask -#ifdef CONFIG_FOR_FREEBSD +#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_DARWIN) #include <netinet/in.h> #endif @@ -43,13 +43,10 @@ #define FIELD_SIZEOF(t, f) (sizeof(((t *)0)->f)) #endif -int shm_fd; -struct shm_structure *shared_memory; - -clock_source_private_data clocks_private[MAX_CLOCKS]; +// Each client owns its clock table and its shared memory interface -- there is +// deliberately no daemon-wide clock table or shared memory region any more. client_record clients[MAX_CLIENTS]; -/* const char *get_client_name(int client_id) { if ((client_id >= 0) && (client_id < MAX_CLIENTS)) { return clients[client_id].shm_interface_name; @@ -57,7 +54,12 @@ return ""; } } -*/ + +int client_is_in_use(int client_id) { + if ((client_id >= 0) && (client_id < MAX_CLIENTS)) + return clients[client_id].shm_interface_name[0] != '\0'; + return 0; +} int get_client_id(char *client_shared_memory_interface_name) { int response = -1; // signify not found @@ -95,11 +97,11 @@ } (void)umask(oldumask); - if (ftruncate(clients[i].shm_fd, sizeof(struct shm_structure)) == -1) { + if (shm_ftruncate(clients[i].shm_fd, sizeof(struct shm_structure)) == -1) { die("failed to set size of shared memory \"%s\".", client_shared_memory_interface_name); } -#ifdef CONFIG_FOR_FREEBSD +#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_DARWIN) clients[i].shared_memory = (struct shm_structure *)mmap(NULL, sizeof(struct shm_structure), PROT_READ | PROT_WRITE, MAP_SHARED, clients[i].shm_fd, 0); @@ -123,10 +125,11 @@ memset(clients[i].shared_memory, 0, sizeof(struct shm_structure)); clients[i].shared_memory->version = NQPTP_SHM_STRUCTURES_VERSION; - // for (i = 0; i < MAX_CLOCKS; i++) { - // clocks_private[i].client_flags[response] = - // 0; // turn off all client flags in every clock for this client - // } + // start this client with an empty timing peer group of its own + memset(clients[i].clocks_private, 0, sizeof(clients[i].clocks_private)); + clients[i].clock_is_active = 0; + clients[i].clock_validity_expiration_time = 0; + clients[i].reset_clock_smoothing = 0; } else { debug(1, "could not create a client record for client \"%s\".", client_shared_memory_interface_name); @@ -227,8 +230,22 @@ return response; } -void update_master_clock_info(uint64_t master_clock_id, const char *ip, uint64_t local_time, - uint64_t local_to_master_offset, uint64_t mastership_start_time) { +void update_master_clock_info(int client_id, uint64_t master_clock_id, const char *ip, + uint64_t local_time, + uint64_t local_to_master_offset, + uint64_t clock_change_offset, + uint64_t mastership_start_time) { + // The update lands in the requesting client's own shared memory interface + // only. Writing it to a single daemon-wide region is what let one client's + // "stop timing" blank the master clock every other client was reading. + if (client_is_in_use(client_id) == 0) { + debug(1, "update_master_clock_info for unknown client id %d ignored.", client_id); + return; + } + struct shm_structure *shared_memory = clients[client_id].shared_memory; + if (shared_memory == NULL) + return; + // to ensure that a full update has taken place, the // reader must ensure that the main and secondary // structures are identical @@ -238,10 +255,12 @@ shared_memory->main.master_clock_start_time = mastership_start_time; shared_memory->main.local_time = local_time; shared_memory->main.local_to_master_time_offset = local_to_master_offset; + shared_memory->main.clock_change_offset = clock_change_offset; } else { shared_memory->main.master_clock_start_time = 0; shared_memory->main.local_time = 0; shared_memory->main.local_to_master_time_offset = 0; + shared_memory->main.clock_change_offset = 0; } __sync_synchronize(); shared_memory->secondary = shared_memory->main; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-clock-sources.h new/nqptp-1.2.9/nqptp-clock-sources.h --- old/nqptp-1.2.8/nqptp-clock-sources.h 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-clock-sources.h 2026-10-14 12:42:13.000000000 +0200 @@ -33,7 +33,7 @@ int announcements_without_followups; // add 1 for every announce, reset with a followup uint64_t clock_id; uint64_t previous_offset, previous_offset_time, previous_offset_grandmaster, - previous_preciseOriginTimestamp; + previous_preciseOriginTimestamp, previous_follow_up_reception_time; uint64_t mastership_start_time; // set to the time of the first sample used as master // for garbage collection @@ -52,6 +52,7 @@ uint8_t grandmasterPriority2; uint64_t grandmasterIdentity; uint16_t stepsRemoved; + int64_t grandmasterChangeOffset; int identical_previous_preciseOriginTimestamp_count; int wakeup_sent; @@ -62,12 +63,25 @@ int shm_fd; struct shm_structure *shared_memory; // the client's individual smi interface char shm_interface_name[64]; // it's name - int client_id; // the 1-based index number of clocks' client_flags field associated with this - // interface + + // Everything below is per-client state that used to be file-scope globals in + // nqptp-message-handlers.c, shared by every client on the host. + // + // That sharing is the bug this restores multi-client support to fix. A bare + // "T" (stop timing) from ANY client ran "delete all the clocks" over the one + // global table and zeroed the one global master clock, so a room leaving an + // AirPlay 2 group silently stopped playback in every other room on the same + // machine -- the departing instance deleted the clock its neighbours were + // still using. Each client now has its own clock table, its own activity + // state and its own shared memory interface, so its control messages can + // only ever affect itself. + clock_source_private_data clocks_private[MAX_CLOCKS]; + int clock_is_active; + uint64_t clock_validity_expiration_time; + int reset_clock_smoothing; } client_record; -extern int shm_fd; -extern struct shm_structure *shared_memory; +extern client_record clients[MAX_CLIENTS]; int find_clock_source_record(char *sender_string, clock_source_private_data *clocks_private_info); @@ -79,12 +93,15 @@ int get_client_id(char *client_shared_memory_interface_name); const char *get_client_name(int client_id); +// non-zero if this client slot currently holds a live named interface +int client_is_in_use(int client_id); int delete_client(int client_id); int delete_clients(); -extern clock_source_private_data clocks_private[MAX_CLOCKS]; - -void update_master_clock_info(uint64_t master_clock_id, const char *ip, uint64_t local_time, - uint64_t local_to_master_offset, uint64_t mastership_start_time); +void update_master_clock_info(int client_id, uint64_t master_clock_id, const char *ip, + uint64_t local_time, + uint64_t local_to_master_offset, + uint64_t clock_change_offset, + uint64_t mastership_start_time); #endif diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-message-handlers.c new/nqptp-1.2.9/nqptp-message-handlers.c --- old/nqptp-1.2.8/nqptp-message-handlers.c 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-message-handlers.c 2026-10-14 12:42:13.000000000 +0200 @@ -30,9 +30,6 @@ #include "nqptp-utilities.h" char hexcharbuffer[16384]; -int reset_clock_smoothing = 0; -uint64_t clock_validity_expiration_time = 0; -int clock_is_active = 0; char *hex_string(void *buf, size_t buf_len) { char *tbuf = (char *)buf; @@ -47,9 +44,7 @@ return hexcharbuffer; } -void handle_control_port_messages(char *buf, ssize_t recv_len, - clock_source_private_data *clock_private_info, - uint64_t reception_time) { +void handle_control_port_messages(char *buf, ssize_t recv_len, uint64_t reception_time) { if (recv_len != -1) { if ((buf != NULL) && (recv_len > 0)) { buf[recv_len - 1] = 0; // we know it's not empty, so make sure there's a null in it. @@ -59,6 +54,18 @@ char *smi_name = strsep(&ip_list, " "); char *command = NULL; if (smi_name != NULL) { + // Every client prefixes its shared memory interface name to every + // control message it sends. Honouring it -- rather than parsing it off + // and discarding it -- is what keeps one client's commands from + // reaching another client's clocks. + int client_id = get_client_id(smi_name); // creates the record if it doesn't exist + if (client_id == -1) { + warn("Could not find or create a record for SMI Interface \"%s\".", smi_name); + return; + } + client_record *client = &clients[client_id]; + clock_source_private_data *clock_private_info = client->clocks_private; + if (ip_list != NULL) command = strsep(&ip_list, " "); @@ -76,7 +83,7 @@ // If we know the clock is already active or // if it's only been a short time since we know it was last active // then we will not reset the clock. - if (clock_is_active) { + if (client->clock_is_active) { debug(2, "clock is already active"); } else { // Find out if the clock is active i.e. not sleeping. @@ -84,11 +91,12 @@ // We also know it is active for brief periods after the "T" and "E" commands are // received. If it is not definitely active, we will reset smoothing. int will_ask_for_a_reset = 0; - if (clock_validity_expiration_time == 0) { + if (client->clock_validity_expiration_time == 0) { debug(1, "no clock_validity_expiration_time."); will_ask_for_a_reset = 1; } else { - int64_t time_to_clock_expiration = clock_validity_expiration_time - reception_time; + int64_t time_to_clock_expiration = + client->clock_validity_expiration_time - reception_time; // timings obtained with an iPhone Xs Max on battery save // around 30 seconds at a buffered audio pause on an iphone. @@ -103,18 +111,18 @@ } if (will_ask_for_a_reset != 0) { debug(2, "Reset clock smoothing"); - reset_clock_smoothing = 1; + client->reset_clock_smoothing = 1; } } - clock_is_active = 1; - clock_validity_expiration_time = 0; + client->clock_is_active = 1; + client->clock_validity_expiration_time = 0; } else if ((strcmp(command, "E") == 0) && (ip_list == NULL)) { debug(2, "Stop"); - if (clock_is_active) { + if (client->clock_is_active) { debug(2, "reset clock_validity_expiration_time to 2.25 seconds in the future."); - clock_validity_expiration_time = + client->clock_validity_expiration_time = reception_time + 2250000000; // expiration time can be very soon after an "E" - clock_is_active = 0; + client->clock_is_active = 0; } else { debug(2, "clock is already inactive."); } @@ -123,25 +131,24 @@ // A pause always seems to turn into a Stop in now more than a few seconds, and the // clock keeps going, it seems so there is nothing to do here. } else if ((command == NULL) || ((strcmp(command, "T") == 0) && (ip_list == NULL))) { - debug(2, "Stop Timing"); - clock_is_active = 0; + debug(2, "Stop Timing for client \"%s\".", get_client_name(client_id)); + client->clock_is_active = 0; debug(2, "Clear timing peer group."); - // dirty experimental hack -- delete all the clocks + // Clear only THIS client's clocks. When this cleared a daemon-wide + // table, a room leaving an AirPlay 2 group took every other room on + // the host down with it. int gc; for (gc = 0; gc < MAX_CLOCKS; gc++) { memset(&clock_private_info[gc], 0, sizeof(clock_source_private_data)); } - update_master_clock_info(0, NULL, 0, 0, 0); // the SMI may have obsolete stuff in it + // this client's SMI may have obsolete stuff in it + update_master_clock_info(client_id, 0, NULL, 0, 0, 0, 0); } else { - debug(2, "Start Timing"); - // dirty experimental hack -- delete all the clocks + debug(2, "Start Timing for client \"%s\".", get_client_name(client_id)); int gc; for (gc = 0; gc < MAX_CLOCKS; gc++) { memset(&clock_private_info[gc], 0, sizeof(clock_source_private_data)); } - debug(2, "get or create new record for \"%s\".", smi_name); - // client_id = get_client_id(smi_name); // create the record if it doesn't exist - // if (client_id != -1) { if (strcmp(command, "T") == 0) { int i; for (i = 0; i < MAX_CLOCKS; i++) { @@ -168,15 +175,11 @@ } // a new clock timing record will be started now debug(2, "reset clock_validity_expiration_time to 5.0 seconds in the future."); - clock_validity_expiration_time = + client->clock_validity_expiration_time = reception_time + 5000000000L; // clock can stop as soon as 6 seconds after a "T" } else { warn("Unrecognised string on the control port."); } - // } else { - // warn("Could not find or create a record for SMI Interface \"%s\".", - // smi_name); - // } } } } else { @@ -292,7 +295,11 @@ } void handle_follow_up(char *buf, ssize_t recv_len, clock_source_private_data *clock_private_info, - uint64_t reception_time) { + int client_id, uint64_t reception_time) { + // The same Follow_Up on the wire is offered to every client that has this + // clock in its own timing peer group; each keeps its own smoothing state and + // writes its own shared memory interface. + client_record *client = &clients[client_id]; if (clock_private_info->clock_id == 0) { debug(2, "Follow_Up received before announcement -- discarded."); } else { @@ -341,7 +348,6 @@ clock_private_info->identical_previous_preciseOriginTimestamp_count = 0; } - clock_private_info->previous_preciseOriginTimestamp = preciseOriginTimestamp; // clang-format off @@ -364,6 +370,8 @@ correction_field = correction_field / 65536; // might be signed uint64_t correctedPreciseOriginTimestamp = preciseOriginTimestamp + correction_field; + + preciseOriginTimestamp = correctedPreciseOriginTimestamp; if (clock_private_info->follow_up_number < 100) clock_private_info->follow_up_number++; @@ -391,13 +399,32 @@ if (clock_private_info->previous_offset_grandmaster != clock_private_info->grandmasterIdentity) { clock_private_info->previous_offset_time = 0; - if (clock_private_info->previous_offset_grandmaster == 0) - debug(1, "grandmaster is %" PRIx64 ".", clock_private_info->grandmasterIdentity); - else - debug(1, "grandmaster has changed from %" PRIx64 " to %" PRIx64 ".", + if (clock_private_info->previous_offset_grandmaster == 0) { + debug(2, "grandmaster is %" PRIx64 ".", clock_private_info->grandmasterIdentity); + } else { + debug(2, "grandmaster has changed from %" PRIx64 " to %" PRIx64 ".", clock_private_info->previous_offset_grandmaster, clock_private_info->grandmasterIdentity); + // calculate the difference between offsets using the smoothed previous offset + int64_t alternative_new_to_old_clock_offset = clock_private_info->previous_offset - offset; + debug(2, "alternative_new_to_old_clock_offset offset is %" PRId64 " ns, %.6f sec.", alternative_new_to_old_clock_offset, alternative_new_to_old_clock_offset * 1E-9); + // calculate the difference in the clocks + int64_t difference_in_reception_times = reception_time - clock_private_info->previous_follow_up_reception_time; + // debug(1, "difference in reception times: %" PRId64 ", %.3f sec.", difference_in_reception_times, difference_in_reception_times * 1e-9); + int64_t new_to_old_clock_offset = clock_private_info->previous_preciseOriginTimestamp - (preciseOriginTimestamp - difference_in_reception_times); + clock_private_info->grandmasterChangeOffset = alternative_new_to_old_clock_offset; + debug(2, "new_to_old offset is %" PRId64 " ns, %.6f sec.", clock_private_info->grandmasterChangeOffset, clock_private_info->grandmasterChangeOffset * 1E-9); + debug(2, "alternative-to-standard difference is %" PRId64 " ns, %.6f sec.", alternative_new_to_old_clock_offset - new_to_old_clock_offset, (alternative_new_to_old_clock_offset - new_to_old_clock_offset) * 1E-9); + + // pretend + clock_private_info->previous_offset_time = clock_private_info->previous_follow_up_reception_time; + clock_private_info->previous_offset = offset; + } } + + + // debug(1, "previous precise origin timestamp: %" PRIu64 ", present precise origin timestamp: %" PRIu64 ", difference: %" PRIu64 ".", clock_private_info->previous_preciseOriginTimestamp, preciseOriginTimestamp, preciseOriginTimestamp - clock_private_info->previous_preciseOriginTimestamp); + clock_private_info->previous_preciseOriginTimestamp = preciseOriginTimestamp; // Do acceptance checking and smoothing. @@ -412,7 +439,7 @@ // This seems to be quite stable - if (reset_clock_smoothing == 0) { + if (client->reset_clock_smoothing == 0) { if (clock_private_info->previous_offset_time != 0) { time_since_previous_offset = reception_time - clock_private_info->previous_offset_time; @@ -487,22 +514,25 @@ "%u. Source: %s", clock_private_info->clock_id, clock_private_info->grandmasterIdentity, offset, smoothed_offset, 0.000001 * delta, preciseOriginTimestamp, - clock_is_active != 0 ? ". " : "*.", correction_field, + client->clock_is_active != 0 ? ". " : "*.", correction_field, 0.000001 * time_since_previous_offset, ntohs(msg->header.sequenceId), clock_private_info->follow_up_number, clock_private_info->ip); - if (clock_is_active) { - update_master_clock_info(clock_private_info->grandmasterIdentity, + if (client->clock_is_active) { + update_master_clock_info(client_id, clock_private_info->grandmasterIdentity, (const char *)&clock_private_info->ip, reception_time, - smoothed_offset, clock_private_info->mastership_start_time); + smoothed_offset, + clock_private_info->grandmasterChangeOffset, + clock_private_info->mastership_start_time); } else { - update_master_clock_info(0, NULL, 0, 0, 0); // the SMI may have obsolete stuff in it + // this client's SMI may have obsolete stuff in it + update_master_clock_info(client_id, 0, NULL, 0, 0, 0, 0); } clock_private_info->previous_offset = smoothed_offset; clock_private_info->previous_offset_time = reception_time; } else { - reset_clock_smoothing = 0; + client->reset_clock_smoothing = 0; clock_private_info->mastership_start_time = 0; clock_private_info->previous_offset = 0; clock_private_info->previous_offset_time = @@ -513,23 +543,90 @@ // now do some quick calculations on the possible "Universal Time" // debug_print_buffer(1, "", buf, recv_len); + uint8_t *tlv = (uint8_t *)&msg->follow_up.tlvs[0]; - uint8_t *lastGmPhaseChange = tlv + 16; - uint64_t lpt = nctoh64(lastGmPhaseChange + 4); - uint64_t last_tlv_clock = nctoh64((uint8_t *)buf + 86); - uint64_t huh = offset - lpt; - debug_print_buffer(2, buf, (size_t)recv_len); - debug(2, - "%" PRIx64 ", %" PRIx64 ", %s, Origin: %016" PRIx64 ", LPT: %016" PRIx64 - ", Offset: %016" PRIx64 ", Universal Offset: %016" PRIx64 ", packet length: %u.", - clock_private_info->clock_id, last_tlv_clock, hex_string(lastGmPhaseChange, 12), - preciseOriginTimestamp, lpt, offset, huh, recv_len); - // debug(1,"Clock: %" PRIx64 ", UT: %016" PRIx64 ", correctedPOT: %016" PRIx64 ", part of - // lastGMPhaseChange: %016" PRIx64 ".", packet_clock_id, correctedPOT - lpt, correctedPOT, - // lpt); + size_t consumed = (size_t)(tlv - (uint8_t *)buf); // bytes already used before this TLV + + // Follow_Up Information TLV (IEEE 802.1AS Table 11-11) layout, relative to tlv: + // 0 tlvType (2 bytes) -- must be 0x0003 (ORGANIZATION_EXTENSION) + // 2 lengthField (2 bytes) -- length of everything from organizationId onward + // 4 organizationId (3 bytes) -- must be 00:80:C2 for the standard TLV + // 7 organizationSubType (3 bytes) -- must be 1 + // 10 cumulativeScaledRateOffset (4 bytes) + // 14 gmTimeBaseIndicator (2 bytes) + // 16 lastGmPhaseChange (12 bytes) + // 28 scaledLastGmFreqChange (4 bytes) + // Minimum lengthField for all of the above = 28. + + const size_t kTlvHeaderLen = 4; // tlvType + lengthField + const size_t kMinValueLen = 28; // orgId..scaledLastGmFreqChange + + if ((size_t)recv_len < consumed + kTlvHeaderLen) { + debug(2, "FollowUp TLV truncated -- no room for TLV header (recv_len %u, consumed %zu).", + recv_len, consumed); + } else { + uint16_t tlvType = (uint16_t)((tlv[0] << 8) | tlv[1]); + uint16_t tlvLen = (uint16_t)((tlv[2] << 8) | tlv[3]); + + if ((size_t)recv_len < consumed + kTlvHeaderLen + tlvLen) { + debug(2, + "FollowUp TLV truncated -- declared length %u exceeds packet (recv_len %u, " + "consumed %zu).", + tlvLen, recv_len, consumed); + } else if (tlvType != 0x0003 || tlvLen < kMinValueLen) { + debug(2, "FollowUp TLV is not a usable Follow_Up Information TLV (type 0x%04x, len %u).", + tlvType, tlvLen); + } else { + // type is 3, ORGANIZATION_EXTENSION , Table 52, IEEE 2019 + uint8_t *val = tlv + kTlvHeaderLen; // start of organizationId + + uint32_t organizationId = ((uint32_t)val[0] << 16) | ((uint32_t)val[1] << 8) | val[2]; + uint32_t organizationSubType = + ((uint32_t)val[3] << 16) | ((uint32_t)val[4] << 8) | val[5]; + + if (organizationId != 0x0080C2 || organizationSubType != 1) { + debug(2, + "TLV has an Organization Extension format but isn't the standard 802.1AS " + "Follow_Up Information TLV (orgId 0x%06x, subType %u).", + organizationId, organizationSubType); + } else { + uint32_t cumulativeScaledRateOffset = + ((uint32_t)val[6] << 24) | ((uint32_t)val[7] << 16) | ((uint32_t)val[8] << 8) | + val[9]; + uint16_t gmTimeBaseIndicator = (uint16_t)((val[10] << 8) | val[11]); + uint8_t *lastGmPhaseChange = val + 12; // 12-byte field + uint64_t lpt = nctoh64(lastGmPhaseChange + 4); // low 64 bits, as before + uint32_t scaledLastGmFreqChange = ((uint32_t)val[24] << 24) | + ((uint32_t)val[25] << 16) | + ((uint32_t)val[26] << 8) | val[27]; + + uint64_t last_tlv_clock = nctoh64((uint8_t *)buf + 86); + uint64_t huh = offset - lpt; + + debug_print_buffer(2, clock_private_info->ip, buf, (size_t)recv_len); + debug(2, + "Clock ID: %" PRIx64 ", %" PRIx64 ", %s, Origin: %016" PRIx64 ", LPT: %016" PRIx64 + ", Offset: %016" PRIx64 ", Universal Offset: %016" PRIx64 ", packet length: %u.", + clock_private_info->clock_id, last_tlv_clock, hex_string(lastGmPhaseChange, 12), + preciseOriginTimestamp, lpt, offset, huh, recv_len); + + debug(2, + "Follow_Up Information TLV -- " + "cumulativeScaledRateOffset: 0x%08x, " + "gmTimeBaseIndicator: %u, " + "lastGmPhaseChange: %s, " + "scaledLastGmFreqChange: 0x%08x.\n", + cumulativeScaledRateOffset, + gmTimeBaseIndicator, + hex_string(lastGmPhaseChange, 12), + scaledLastGmFreqChange); + } + } + } } else { - debug(1, "Follow_Up message is too small to be valid."); + debug(2, "Follow_Up message is too small to be valid."); } } + clock_private_info->previous_follow_up_reception_time = reception_time; } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-message-handlers.h new/nqptp-1.2.9/nqptp-message-handlers.h --- old/nqptp-1.2.8/nqptp-message-handlers.h 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-message-handlers.h 2026-10-14 12:42:13.000000000 +0200 @@ -31,10 +31,8 @@ uint64_t reception_time); void handle_follow_up(char *buf, ssize_t recv_len, clock_source_private_data *clock_private_info, - uint64_t reception_time); + int client_id, uint64_t reception_time); -void handle_control_port_messages(char *buf, ssize_t recv_len, - clock_source_private_data *clock_private_info, - uint64_t reception_time); +void handle_control_port_messages(char *buf, ssize_t recv_len, uint64_t reception_time); #endif \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-shm-structures.h new/nqptp-1.2.9/nqptp-shm-structures.h --- old/nqptp-1.2.8/nqptp-shm-structures.h 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-shm-structures.h 2026-10-14 12:42:13.000000000 +0200 @@ -1,6 +1,6 @@ /* * This file is part of the nqptp distribution (https://github.com/mikebrady/nqptp). - * Copyright (c) 2021--2023 Mike Brady. + * Copyright (c) 2021--2026 Mike Brady. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,7 +22,7 @@ #define NQPTP_INTERFACE_NAME "/nqptp" -#define NQPTP_SHM_STRUCTURES_VERSION 10 +#define NQPTP_SHM_STRUCTURES_VERSION 11 #define NQPTP_CONTROL_PORT 9000 // The control port expects a UDP packet with the first character being a command letter @@ -56,10 +56,12 @@ #include <pthread.h> typedef struct { - uint64_t master_clock_id; // the current master clock - uint64_t local_time; // the time when the offset was calculated - uint64_t local_to_master_time_offset; // add this to the local time to get master clock time - uint64_t master_clock_start_time; // this is when the master clock became master + uint64_t master_clock_id; // the current master clock + uint64_t local_time; // the time when the offset was calculated + uint64_t local_to_master_time_offset; // add this to the local time to get master clock time + uint64_t clock_change_offset; // when the clock changes while the clock is in use + // this is the difference between the old and new offsets + uint64_t master_clock_start_time; // this is when the master clock became master } shm_structure_set; // The actual interface comprises a shared memory region of type struct shm_structure. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-utilities.c new/nqptp-1.2.9/nqptp-utilities.c --- old/nqptp-1.2.8/nqptp-utilities.c 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-utilities.c 2026-10-14 12:42:13.000000000 +0200 @@ -28,7 +28,7 @@ #include <linux/if_packet.h> // sockaddr_ll #endif -#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_OPENBSD) +#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_OPENBSD) || defined(CONFIG_FOR_DARWIN) #include <sys/types.h> #include <unistd.h> #include <net/if_dl.h> @@ -83,6 +83,15 @@ } #endif +#if defined(CONFIG_FOR_DARWIN) && defined(SO_TIMESTAMP_MONOTONIC) && defined(SCM_TIMESTAMP_MONOTONIC) + // debug(1, "Setting SO_TIMESTAMP_MONOTONIC on port %u:", port); + if (port == 319 || port == 320) { + int on = 1; + if (setsockopt(fd, SOL_SOCKET, SO_TIMESTAMP_MONOTONIC, &on, sizeof on) < 0) + debug(1, "SO_TIMESTAMP_MONOTONIC failed on port %u: %s", port, strerror(errno)); + } +#endif + if (!ret) ret = bind(fd, p->ai_addr, p->ai_addrlen); @@ -113,14 +122,15 @@ } } -void debug_print_buffer(int level, char *buf, size_t buf_len) { +void debug_print_buffer(int level, char * sender, char *buf, size_t buf_len) { if (debug_level() >= level) { // printf("Received %u bytes in a packet from %s:%d\n", buf_len, inet_ntoa(si_other.sin_addr), // ntohs(si_other.sin_port)); char *obf = - malloc(buf_len * 4 + 1); // to be on the safe side -- 4 characters on average for each byte + malloc(strlen(sender) +1 + buf_len * 4 + 1); // to be on the safe side -- 4 characters on average for each byte if (obf != NULL) { - char *obfp = obf; + snprintf(obf, strlen(sender) + 2, "%s ", sender); + char *obfp = obf + strlen(sender) + 1; unsigned int obfc; for (obfc = 0; obfc < buf_len; obfc++) { snprintf(obfp, 3, "%02X", buf[obfc]); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp-utilities.h new/nqptp-1.2.9/nqptp-utilities.h --- old/nqptp-1.2.8/nqptp-utilities.h 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp-utilities.h 2026-10-14 12:42:13.000000000 +0200 @@ -39,6 +39,6 @@ } sockets_open_bundle; void open_sockets_at_port(const char *node, uint16_t port, sockets_open_bundle *sockets_open_stuff); -void debug_print_buffer(int level, char *buf, size_t buf_len); +void debug_print_buffer(int level, char *sender, char *buf, size_t buf_len); uint64_t get_self_clock_id(); // a clock ID based on a MAC address #endif \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/nqptp.c new/nqptp-1.2.9/nqptp.c --- old/nqptp-1.2.8/nqptp.c 2026-05-13 16:33:47.000000000 +0200 +++ new/nqptp-1.2.9/nqptp.c 2026-10-14 12:42:13.000000000 +0200 @@ -48,7 +48,7 @@ #include <netdb.h> #include <sys/socket.h> -#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_OPENBSD) +#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_OPENBSD) || defined(CONFIG_FOR_DARWIN) #include <netinet/in.h> #include <sys/socket.h> #endif @@ -59,6 +59,31 @@ #include <pwd.h> #endif +#if defined(CONFIG_FOR_DARWIN) && defined(SO_TIMESTAMP_MONOTONIC) && defined(SCM_TIMESTAMP_MONOTONIC) +#include <mach/mach_time.h> +#define NQPTP_KERNEL_RX_STAMPS 1 + +// Returns the kernel's arrival stamp in ns on get_time_now()'s clock +// (CLOCK_UPTIME_RAW == mach_absolute_time), or 0 if there isn't a usable one. +static uint64_t kernel_rx_time(struct msghdr *m) { + static mach_timebase_info_data_t tb; + if (tb.denom == 0) + mach_timebase_info(&tb); + if (m->msg_flags & MSG_CTRUNC) + return 0; + for (struct cmsghdr *c = CMSG_FIRSTHDR(m); c; c = CMSG_NXTHDR(m, c)) { + if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_TIMESTAMP_MONOTONIC && + c->cmsg_len >= CMSG_LEN(sizeof(uint64_t))) { + uint64_t ticks; + memcpy(&ticks, CMSG_DATA(c), sizeof ticks); + uint64_t t = ticks * tb.numer / tb.denom; + return (t <= get_time_now()) ? t : 0; // a stamp can't be in the future + } + } + return 0; +} +#endif + #ifndef FIELD_SIZEOF #define FIELD_SIZEOF(t, f) (sizeof(((t *)0)->f)) #endif @@ -96,24 +121,11 @@ for (i = 0; i < sockets_open_stuff.sockets_open; i++) close(sockets_open_stuff.sockets[i].number); - // close off shared memory interface + // unmap and unlink every client's own shared memory interface. There is no + // separate daemon-wide region any more -- a client that uses the default + // name simply gets a client record called NQPTP_INTERFACE_NAME. delete_clients(); - // close off new smi - // mmap cleanup - if (munmap(shared_memory, sizeof(struct shm_structure)) != 0) { - debug(1, "error unmapping shared memory \"%s\": \"%s\".", NQPTP_INTERFACE_NAME, - strerror(errno)); - } - // shm_open cleanup - if (shm_unlink(NQPTP_INTERFACE_NAME) == -1) { - debug(1, "error unlinking shared memory \"%s\": \"%s\".", NQPTP_INTERFACE_NAME, - strerror(errno)); - } - - if (shm_fd != -1) - close(shm_fd); - if (epoll_fd != -1) close(epoll_fd); @@ -188,12 +200,15 @@ // try to set a real time scheduling policy with a priority of -6 + // but not on macOS +#ifndef CONFIG_FOR_DARWIN int policy = SCHED_FIFO; struct sched_param param; param.sched_priority = 5; int s = pthread_setschedparam(pthread_self(), policy, ¶m); if (s != 0) debug(1, "pthread_setschedparam failed: %d -- \"%s\".", s, strerror(s)); +#endif sockets_open_stuff.sockets_open = 0; @@ -235,43 +250,16 @@ } #endif - // open the SMI - - shm_fd = -1; - - mode_t oldumask = umask(0); - shm_fd = shm_open(NQPTP_INTERFACE_NAME, O_RDWR | O_CREAT, 0644); - if (shm_fd == -1) { - die("nqptp cannot open the shared memory \"%s\" for writing. Is another copy of nqptp (e.g. an nqptp daemon) running already?", NQPTP_INTERFACE_NAME); - } - (void)umask(oldumask); - - if (ftruncate(shm_fd, sizeof(struct shm_structure)) == -1) { - die("failed to set size of shared memory \"%s\".", NQPTP_INTERFACE_NAME); - } - -#if defined(CONFIG_FOR_FREEBSD) || defined(CONFIG_FOR_OPENBSD) - shared_memory = (struct shm_structure *)mmap(NULL, sizeof(struct shm_structure), - PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); -#endif - -#ifdef CONFIG_FOR_LINUX - shared_memory = - (struct shm_structure *)mmap(NULL, sizeof(struct shm_structure), PROT_READ | PROT_WRITE, - MAP_LOCKED | MAP_SHARED, shm_fd, 0); -#endif - - if (shared_memory == (struct shm_structure *)-1) { - die("failed to mmap shared memory \"%s\".", NQPTP_INTERFACE_NAME); - } - - if (shm_fd == -1) { - warn("error closing \"%s\" after mapping.", shm_fd); - } - - // zero it - memset(shared_memory, 0, sizeof(struct shm_structure)); - shared_memory->version = NQPTP_SHM_STRUCTURES_VERSION; + // No shared memory interface is created here any more. Each client's SMI is + // created on demand by get_client_id() when that client first sends a control + // message naming it -- which every client already does, and which is exactly + // what shairport-sync's initial "T" is for. A single client using the default + // name gets a region called NQPTP_INTERFACE_NAME, so single-instance + // behaviour is unchanged. + // + // Note this also removes the "is another copy of nqptp running already?" + // check that creating the fixed region used to provide; binding the control + // port below is now the only thing that detects a second daemon. ssize_t recv_len; @@ -285,7 +273,7 @@ uint64_t broadcasting_task(uint64_t call_time, void *private_data); timed_tasks[0].trigger_time = get_time_now() + 100000000; // start after 100 ms - timed_tasks[0].private_data = (void *)&clocks_private; + timed_tasks[0].private_data = NULL; // broadcasting_task walks every client itself timed_tasks[0].task = broadcasting_task; // now, get down to business @@ -340,6 +328,7 @@ uint16_t receiver_port = 0; // int msgsize = recv(udpsocket_fd, &msg_buffer, 4, 0); + uint64_t packet_time = reception_time; // if we can't do better... recv_len = recvmsg(socket_number, &msg, MSG_DONTWAIT); if (recv_len != -1) { @@ -349,6 +338,23 @@ if (socket_number == sockets_open_stuff.sockets[jp].number) receiver_port = sockets_open_stuff.sockets[jp].port; } +// This is for Darwin/macOS, which supplies the kernel reception time of a packet, and we use it instead of the lass-precise "reception time", which is really the time after the call thread has resumed having received the packet. +#ifdef NQPTP_KERNEL_RX_STAMPS + if (receiver_port == 319 || receiver_port == 320) { + uint64_t k = kernel_rx_time(&msg); + if (k != 0) { + packet_time = k; + if ((int64_t)(reception_time - packet_time) > 150000) + debug(3, "actual packet reception time difference from so-called reception time: %g ms.", (int64_t)(reception_time - packet_time) * 1E-6); + } else { + static int warned = 0; + if (!warned) { + warned = 1; + debug(1, "no kernel receive timestamp; using select() time."); + } + } + } +#endif } if (recv_len == -1) { if (errno == EAGAIN) { @@ -359,10 +365,8 @@ // check if it's a control port message before checking for the length of the // message. } else if (receiver_port == NQPTP_CONTROL_PORT) { - handle_control_port_messages( - buf, recv_len, (clock_source_private_data *)&clocks_private, reception_time); + handle_control_port_messages(buf, recv_len, packet_time); } else if (recv_len >= (ssize_t)sizeof(struct ptp_common_message_header)) { - debug_print_buffer(2, buf, recv_len); // check its credentials // the sending and receiving ports must be the same (i.e. 319 -> 319 or 320 -> 320) @@ -391,36 +395,40 @@ char sender_string[256]; memset(sender_string, 0, sizeof(sender_string)); inet_ntop(connection_ip_family, sender_addr, sender_string, sizeof(sender_string)); - // now, find the record for this ip - int the_clock = find_clock_source_record( - sender_string, (clock_source_private_data *)&clocks_private); - // not sure about requiring a Sync before creating it... - // if ((the_clock == -1) && ((buf[0] & 0xF) == Sync)) { - /* - if (the_clock == -1) { - the_clock = create_clock_source_record( - sender_string, (clock_source_private_data *)&clocks_private); + // One packet on the wire can be relevant to several clients: the + // same sender may be in more than one client's timing peer + // group. Offer it to every client that is monitoring this IP, + // and let each keep its own smoothing state. A client that has + // never heard of this IP is left completely untouched -- that + // isolation is the point of the whole exercise. + int client_id; + for (client_id = 0; client_id < MAX_CLIENTS; client_id++) { + if (client_is_in_use(client_id) == 0) + continue; + clock_source_private_data *clocks_private = clients[client_id].clocks_private; + int the_clock = find_clock_source_record(sender_string, clocks_private); + if (the_clock != -1) { + clocks_private[the_clock].time_of_last_use = + packet_time; // for garbage collection + // debug_print_buffer(1, sender_string, buf, recv_len); + switch (buf[0] & 0xF) { + case Announce: + handle_announce(buf, recv_len, &clocks_private[the_clock], packet_time); + break; + case Follow_Up: + handle_follow_up(buf, recv_len, &clocks_private[the_clock], client_id, + packet_time); + break; + case Sync: + handle_sync(buf, recv_len, &clocks_private[the_clock], packet_time); + break; + default: + debug_print_buffer(2, sender_string, buf, + recv_len); // unusual messages will have debug level 1. + break; + } + } // otherwise, just forget it for this client } - */ - if (the_clock != -1) { - clocks_private[the_clock].time_of_last_use = - reception_time; // for garbage collection - switch (buf[0] & 0xF) { - case Announce: - handle_announce(buf, recv_len, &clocks_private[the_clock], reception_time); - break; - case Follow_Up: - handle_follow_up(buf, recv_len, &clocks_private[the_clock], reception_time); - break; - case Sync: - handle_sync(buf, recv_len, &clocks_private[the_clock], reception_time); - break; - default: - debug_print_buffer(2, buf, - recv_len); // unusual messages will have debug level 1. - break; - } - } // otherwise, just forget it } } } @@ -508,8 +516,6 @@ debug(1, "failed to resolve remote socket address (err=%d)", err); } else { // here, we have the destination, so send it - - // debug_print_buffer(1, (char *)msg, msg_length); int ret = sendto(s, msg, msg_length, 0, res->ai_addr, res->ai_addrlen); if (ret == -1) debug(1, "result of sendto is %d.", ret); @@ -537,42 +543,40 @@ } uint64_t broadcasting_task(uint64_t call_time, __attribute__((unused)) void *private_data) { - clock_source_private_data *clocks_private = (clock_source_private_data *)private_data; - int i; - for (i = 0; i < MAX_CLOCKS; i++) { - - /* - int is_a_master = 0; - int temp_client_id; - - for (temp_client_id = 0; temp_client_id < MAX_CLIENTS; temp_client_id++) - if ((clocks_private->client_flags[temp_client_id] & (1 << clock_is_master)) != 0) - is_a_master = 1; - // only process it if it's a master somewhere... - if ((is_a_master != 0) && (clocks_private[i].announcements_without_followups == 3)) { - */ - if (clocks_private[i].announcements_without_followups == 3) { - if (clocks_private[i].follow_up_number == 0) { - debug(1, - "Attempt to awaken a silent clock %" PRIx64 - ", index %u, at follow_up_number %u at IP %s.", - clocks_private[i].clock_id, i, clocks_private[i].follow_up_number, - clocks_private[i].ip); - - // send an Announce message to attempt to waken this silent PTP clock by - // getting it to negotiate with an apparently better clock - // that then immediately sends another Announce message indicating that it's inferior - - clocks_private[i].announcements_without_followups++; // set to 4 to indicate done/parked - send_awakening_announcement_sequence( - clocks_private[i].clock_id, clocks_private[i].ip, clocks_private[i].family, - clocks_private[i].grandmasterPriority1, clocks_private[i].grandmasterPriority2); - } else { - debug(1, - "Silent clock %" PRIx64 - " detected, index %u, at follow_up_number %u at IP %s. No attempt to awaken it.", - clocks_private[i].clock_id, i, clocks_private[i].follow_up_number, - clocks_private[i].ip); + // Every client's clock table is swept: a silent clock is a per-client + // condition now, since two clients can be watching the same IP and only one + // of them may have stopped hearing from it. + int client_id; + for (client_id = 0; client_id < MAX_CLIENTS; client_id++) { + if (client_is_in_use(client_id) == 0) + continue; + clock_source_private_data *clocks_private = clients[client_id].clocks_private; + int i; + for (i = 0; i < MAX_CLOCKS; i++) { + if (clocks_private[i].announcements_without_followups == 3) { + if (clocks_private[i].follow_up_number == 0) { + debug(1, + "Attempt to awaken a silent clock %" PRIx64 + ", index %u, at follow_up_number %u at IP %s for client \"%s\".", + clocks_private[i].clock_id, i, clocks_private[i].follow_up_number, + clocks_private[i].ip, get_client_name(client_id)); + + // send an Announce message to attempt to waken this silent PTP clock by + // getting it to negotiate with an apparently better clock + // that then immediately sends another Announce message indicating that it's inferior + + clocks_private[i].announcements_without_followups++; // set to 4 to indicate done/parked + send_awakening_announcement_sequence( + clocks_private[i].clock_id, clocks_private[i].ip, clocks_private[i].family, + clocks_private[i].grandmasterPriority1, clocks_private[i].grandmasterPriority2); + } else { + debug(3, + "Silent clock %" PRIx64 + " detected, index %u, at follow_up_number %u at IP %s for client \"%s\". No " + "attempt to awaken it.", + clocks_private[i].clock_id, i, clocks_private[i].follow_up_number, + clocks_private[i].ip, get_client_name(client_id)); + } } } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nqptp-1.2.8/test-multi-client-isolation.py new/nqptp-1.2.9/test-multi-client-isolation.py --- old/nqptp-1.2.8/test-multi-client-isolation.py 1970-01-01 01:00:00.000000000 +0100 +++ new/nqptp-1.2.9/test-multi-client-isolation.py 2026-10-14 12:42:13.000000000 +0200 @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Regression test: one nqptp client's control messages must not disturb another's. + +This is the bug that multi-client support exists to prevent. Before per-client +state was restored, nqptp parsed the shared memory interface name off the front +of every control message and then discarded it, keeping ONE clock table and ONE +shared memory region for the whole host. A bare "T" (stop timing) -- which +shairport-sync sends when a room leaves an AirPlay 2 group -- ran "delete all +the clocks" and zeroed the master clock that every OTHER instance on the box was +still reading. Those instances stopped playing and never recovered. + +The test drives nqptp's control port directly, so it needs no AirPlay sender and +no PTP traffic. Each client's master clock is poisoned with a distinct value by +mmapping its region, standing in for a clock it had acquired. Then one client +sends the bare "T" a departing room sends, and we check who got cleared. + + sudo ./test-multi-client-isolation.py [./nqptp] + +Exits non-zero on failure. Run it against a pre-fix binary and it fails at +"kitchen's master clock survived", which is the regression itself. +""" +import mmap +import os +import socket +import struct +import subprocess +import sys +import time + +CONTROL_PORT = 9000 +SHM_SIZE = 72 # sizeof(struct shm_structure) +VERSION_OFF = 0 # uint16_t version +MAIN_OFF = 8 # shm_structure_set main +SECONDARY_OFF = 40 # shm_structure_set secondary +MASTER_CLOCK_ID_OFF = 0 # first field of a shm_structure_set +EXPECTED_SMI_VERSION = 10 + +KITCHEN = "/nqptp-test-kitchen" +FOYER = "/nqptp-test-foyer" +PEER_IP = "192.168.1.209" + +failures = [] + + +def check(ok, description): + print(" %s %s" % ("ok " if ok else "FAIL", description)) + if not ok: + failures.append(description) + return ok + + +def send(sock, message): + # shairport-sync sends the interface name, a space, then the command. + sock.sendto(message.encode() + b"\0", ("127.0.0.1", CONTROL_PORT)) + time.sleep(0.25) + + +def shm_path(name): + return "/dev/shm/" + name.lstrip("/") + + +def open_region(name): + fd = os.open(shm_path(name), os.O_RDWR) + try: + return mmap.mmap(fd, SHM_SIZE) + finally: + os.close(fd) + + +def read_master_clock(name, which=MAIN_OFF): + with open(shm_path(name), "rb") as f: + data = f.read(SHM_SIZE) + return struct.unpack_from("<Q", data, which + MASTER_CLOCK_ID_OFF)[0] + + +def read_version(name): + with open(shm_path(name), "rb") as f: + return struct.unpack_from("<H", f.read(SHM_SIZE), VERSION_OFF)[0] + + +def poison(name, value): + """Stand in for a master clock this client had acquired from real PTP traffic.""" + region = open_region(name) + for which in (MAIN_OFF, SECONDARY_OFF): + struct.pack_into("<Q", region, which + MASTER_CLOCK_ID_OFF, value) + region.flush() + region.close() + + +def main(): + binary = sys.argv[1] if len(sys.argv) > 1 else "./nqptp" + if os.geteuid() != 0: + sys.exit("must run as root: nqptp binds PTP ports 319/320 and creates shared memory") + + for name in (KITCHEN, FOYER): + if os.path.exists(shm_path(name)): + os.unlink(shm_path(name)) + + daemon = subprocess.Popen([binary, "-vv"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + time.sleep(0.6) + if daemon.poll() is not None: + sys.exit("nqptp exited immediately:\n" + daemon.stdout.read().decode()) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + print("two clients, each starting timing and playing:") + for name in (KITCHEN, FOYER): + send(sock, "%s T %s" % (name, PEER_IP)) # start timing on a peer + send(sock, "%s B" % name) # play + + both_exist = all(os.path.exists(shm_path(n)) for n in (KITCHEN, FOYER)) + check(both_exist, "each client got its own named shared memory interface") + if not both_exist: + # A pre-fix nqptp ignores the interface name and keeps one region for + # the whole host. Show what that costs rather than just bailing out. + shared = "/nqptp" + if os.path.exists(shm_path(shared)): + poison(shared, 0xAAAAAAAAAAAAAAAA) + send(sock, "%s T" % FOYER) # foyer leaves the group + clobbered = read_master_clock(shared) == 0 + print(" -> this nqptp keeps ONE region, %s, for every client." % shared) + print(" -> foyer's departure %s the master clock kitchen was reading." + % ("ZEROED" if clobbered else "left")) + sock.close() + return + + check(read_version(KITCHEN) == EXPECTED_SMI_VERSION and + read_version(FOYER) == EXPECTED_SMI_VERSION, + "both regions carry SMI version %d" % EXPECTED_SMI_VERSION) + + poison(KITCHEN, 0xAAAAAAAAAAAAAAAA) + poison(FOYER, 0xBBBBBBBBBBBBBBBB) + check(read_master_clock(KITCHEN) == 0xAAAAAAAAAAAAAAAA and + read_master_clock(FOYER) == 0xBBBBBBBBBBBBBBBB, + "the two regions are distinct memory") + + # This is the exact message a room sends as it leaves a group. + print("foyer leaves the group (bare \"T\"):") + send(sock, "%s T" % FOYER) + + check(read_master_clock(FOYER) == 0, + "foyer's own master clock was cleared, as it asked") + check(read_master_clock(KITCHEN) == 0xAAAAAAAAAAAAAAAA, + "kitchen's master clock survived -- THE REGRESSION") + check(read_master_clock(KITCHEN, SECONDARY_OFF) == 0xAAAAAAAAAAAAAAAA, + "kitchen's secondary record survived too") + + # An "E" (stop) from one client must likewise leave the other alone. + poison(FOYER, 0xCCCCCCCCCCCCCCCC) + print("kitchen stops (\"E\"):") + send(sock, "%s E" % KITCHEN) + check(read_master_clock(FOYER) == 0xCCCCCCCCCCCCCCCC, + "foyer untouched by kitchen's stop") + + sock.close() + finally: + daemon.terminate() + try: + daemon.wait(timeout=5) + except subprocess.TimeoutExpired: + daemon.kill() + for name in (KITCHEN, FOYER): + if os.path.exists(shm_path(name)): + os.unlink(shm_path(name)) + + +if __name__ == "__main__": + main() + if failures: + print("\nFAILED: %d check(s): %s" % (len(failures), "; ".join(failures))) + sys.exit(1) + print("\nALL MULTI-CLIENT ISOLATION CHECKS PASSED")
