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, &param);
   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")

Reply via email to