Skip to content

Commit 3d0f128

Browse files
dkulpclaude
andcommitted
feat(multisync): apply sync destination changes without restarting fppd
Ticking a per-remote Unicast checkbox on the MultiSync page, or toggling any of the send-method checkboxes, flagged fppd as needing a restart. It never had to: fppd already re-reads the settings file whenever the web UI writes it (FileMonitor in fppd.cpp), and settings.cpp already has a listener mechanism for reacting to individual keys. The only thing missing was rebuilding the destination list those settings feed, which OpenControlSockets() did once at startup and never again. Split that parsing out into ReloadSyncDestinations() and register it against MultiSyncRemotes, MultiSyncExtraRemotes and the three send-method settings. Hostnames are resolved into a local vector first, since getaddrinfo() is an unbounded lookup and the send path takes m_socketLock on the output thread every frame; the sockaddr and mmsghdr vectors are then swapped in together under that lock, because each mmsghdr points at its own element of the sockaddr vector and the pair must never be observed half-updated. The rebuild holds the same lock UpdateUnicastDestinations() uses, so the "all known remotes" list can't read the static list for its dedupe set mid-swap. The three send-method flags become atomic: the output thread reads them without holding anything. Listeners are unregistered in ShutdownSync(), which is idempotent because the destructor calls it again at static-destruction time; that also acts as a barrier, since unregistering takes the listener list's write lock that the firing loop holds while a callback runs. Two bugs found in the parsing while moving it: - Hostnames have never worked. A 2020 refactor rewrote "does this contain a letter" as `find_if(...) == s.end()`, which is the opposite test, so hostnames took the inet_addr() path, came back INADDR_NONE, and were then installed as a destination of 255.255.255.255 -- every entry that wasn't already a dotted quad silently broadcast its sync packets. An unparseable address is now rejected and logged instead of becoming that destination. - Clearing the remote list stored a literal pair of quote characters, since the page PUTs the value as a JSON string. That parsed as a hostname and cost a full DNS timeout (~4s) on every load. Tokens are unquoted first. Verified on a single-core ARM player. Sync destinations, with tcpdump running across a settings change mid-playback and no restart: packets to the first loopback address stop, the reload logs, packets to the second start, all within 250ms and all under one fppd pid -- the new destination starts and the old one stops. Hostname handling: a remote entered as "localhost" now sends to 127.0.0.1 rather than the broadcast address, and a malformed dotted quad yields zero destinations instead of one bad one. In headless Chrome, clicking a per-remote Unicast checkbox raises no restart banner in 60 samples over 15s (one flash before this change) and the checkbox survives every table re-render. Fixes #2834 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d54e61a commit 3d0f128

4 files changed

Lines changed: 149 additions & 48 deletions

File tree

src/MultiSync.cpp

Lines changed: 123 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,23 @@ int MultiSync::Init(void) {
309309
};
310310
NetworkMonitor::INSTANCE.registerCallback(f);
311311

312+
// The sync send methods and the unicast remote list are picked up live.
313+
// fppd already re-reads /media/settings whenever the web UI writes it
314+
// (FileMonitor in fppd.cpp), so all that was missing was rebuilding the
315+
// destination list these settings feed -- editing the remote list on the
316+
// MultiSync page no longer needs an fppd restart to take effect. The
317+
// control socket itself is unaffected by any of them; if it isn't open yet
318+
// (MultiSync disabled, or nothing has needed it), OpenControlSockets()
319+
// reads the current settings when it does open.
320+
for (const char* s : { "MultiSyncRemotes", "MultiSyncExtraRemotes",
321+
"MultiSyncBroadcast", "MultiSyncMulticast", "MultiSyncUnicast" }) {
322+
registerSettingsListener("MultiSync", s, [this](const std::string& v) {
323+
if (m_controlSock >= 0) {
324+
ReloadSyncDestinations();
325+
}
326+
});
327+
}
328+
312329
return 1;
313330
}
314331

@@ -1890,6 +1907,21 @@ void MultiSync::removeMultiSyncPlugin(MultiSyncPlugin* p) {
18901907
void MultiSync::ShutdownSync(void) {
18911908
LogDebug(VB_SYNC, "ShutdownSync()\n");
18921909

1910+
// Idempotent, like WLEDAudioSync::Cleanup(): fppd's shutdown calls this and
1911+
// ~MultiSync() calls it again at static-destruction time, by which point the
1912+
// global SettingsConfig may already be gone -- locking its destroyed mutex
1913+
// would throw out of a noexcept destructor. Unregistering also acts as a
1914+
// barrier: unregisterSettingsListener() takes the listener list's write
1915+
// lock, which the firing loop holds for reading while a callback runs, so it
1916+
// cannot return while ReloadSyncDestinations() is still in flight on the
1917+
// settings-reload thread.
1918+
if (!m_settingsListenersRemoved.exchange(true)) {
1919+
for (const char* s : { "MultiSyncRemotes", "MultiSyncExtraRemotes",
1920+
"MultiSyncBroadcast", "MultiSyncMulticast", "MultiSyncUnicast" }) {
1921+
unregisterSettingsListener("MultiSync", s);
1922+
}
1923+
}
1924+
18931925
for (auto a : m_plugins) {
18941926
a->ShutdownSync();
18951927
}
@@ -1966,6 +1998,22 @@ int MultiSync::OpenControlSockets() {
19661998
return 0;
19671999
}
19682000

2001+
ReloadSyncDestinations();
2002+
2003+
FillInInterfaces();
2004+
2005+
return 1;
2006+
}
2007+
2008+
void MultiSync::ReloadSyncDestinations() {
2009+
// Same lock UpdateUnicastDestinations() uses, for the same reason: the
2010+
// resolve step below is slow and runs without m_socketLock, so two
2011+
// overlapping reloads could otherwise finish out of order and leave the
2012+
// older result in place. Holding it also keeps UpdateUnicastDestinations()
2013+
// from reading m_destAddr for its dedupe set mid-swap. Released before the
2014+
// UpdateUnicastDestinations() call at the end, which takes it itself.
2015+
std::unique_lock<std::mutex> updateLock(m_unicastUpdateLock);
2016+
19692017
std::string remotesString = getSetting("MultiSyncRemotes");
19702018
std::string extraRemotes = getSetting("MultiSyncExtraRemotes");
19712019
if (extraRemotes != "") {
@@ -1981,36 +2029,51 @@ int MultiSync::OpenControlSockets() {
19812029
std::set<std::string> remotes;
19822030
for (auto& token : tokens) {
19832031
TrimWhiteSpace(token);
2032+
// The web UI PUTs this setting as a JSON string, so an empty list is
2033+
// stored as a literal pair of quote characters rather than as an empty
2034+
// value. Left in, that becomes a "hostname" of "" and every reload
2035+
// spends a full DNS timeout (~4s, on the settings-reload thread)
2036+
// failing to resolve it.
2037+
while (token.size() >= 2 && token.front() == '"' && token.back() == '"') {
2038+
token = token.substr(1, token.size() - 2);
2039+
TrimWhiteSpace(token);
2040+
}
19842041
if (token != "") {
19852042
remotes.insert(token);
19862043
}
19872044
}
19882045

1989-
if (getSettingInt("MultiSyncBroadcast")) {
1990-
m_sendBroadcast = true;
1991-
}
1992-
1993-
if (getSettingInt("MultiSyncMulticast")) {
1994-
m_sendMulticast = true;
1995-
}
1996-
if (getSettingInt("MultiSyncUnicast")) {
1997-
m_sendUnicast = true;
1998-
}
1999-
if (remotesString == "" && !m_sendBroadcast && !m_sendMulticast && !m_sendUnicast && m_multiSyncEnabled) {
2046+
// Assign rather than |=: this runs again on every settings change, so a
2047+
// method the user just turned off has to go back to false.
2048+
bool sendBroadcast = getSettingInt("MultiSyncBroadcast") != 0;
2049+
bool sendMulticast = getSettingInt("MultiSyncMulticast") != 0;
2050+
bool sendUnicast = getSettingInt("MultiSyncUnicast") != 0;
2051+
if (remotesString == "" && !sendBroadcast && !sendMulticast && !sendUnicast && m_multiSyncEnabled) {
20002052
// No explicit remotes or send method configured; default to multicast.
2001-
m_sendMulticast = true;
2053+
sendMulticast = true;
20022054
}
20032055

2056+
// Resolve into a local list first: getaddrinfo() below is an unbounded
2057+
// network lookup and must not run while m_socketLock is held, since that
2058+
// lock is taken by the send path on the output thread every frame.
2059+
std::vector<struct sockaddr_in> newAddrs;
20042060
for (auto& s : remotes) {
20052061
LogDebug(VB_SYNC, "Setting up Remote Sync for %s\n", s.c_str());
20062062
struct sockaddr_in newRemote;
2063+
memset(&newRemote, 0, sizeof(newRemote));
20072064

20082065
newRemote.sin_family = AF_INET;
20092066
newRemote.sin_port = htons(FPP_CTRL_PORT);
20102067

2011-
bool isAlpha = std::find_if(s.begin(), s.end(), [](char c) { return (isalpha(c) || (c == ' ')); }) == s.end();
2068+
// A letter (or a space) means this is a hostname and has to be resolved;
2069+
// anything else is a dotted-quad to parse directly. The test used to
2070+
// read `... == s.end()`, i.e. "contains no letters", which is backwards:
2071+
// hostnames took the inet_addr() path, came back INADDR_NONE, and were
2072+
// then installed as a destination of 255.255.255.255 -- so every entry
2073+
// that wasn't already an IP address quietly broadcast its sync packets.
2074+
bool isHostname = std::find_if(s.begin(), s.end(), [](char c) { return (isalpha(c) || (c == ' ')); }) != s.end();
20122075
bool valid = true;
2013-
if (isAlpha) {
2076+
if (isHostname) {
20142077
// Use the reentrant getaddrinfo() rather than gethostbyname(), which
20152078
// shares a single static hostent across the process.
20162079
struct addrinfo hints{};
@@ -2028,33 +2091,58 @@ int MultiSync::OpenControlSockets() {
20282091
}
20292092
} else {
20302093
newRemote.sin_addr.s_addr = inet_addr(s.c_str());
2094+
if (newRemote.sin_addr.s_addr == INADDR_NONE) {
2095+
LogErr(VB_SYNC, "Error parsing Remote IP address: %s\n", s.c_str());
2096+
valid = false;
2097+
}
20312098
}
20322099
if (valid) {
2033-
m_destAddr.push_back(newRemote);
2100+
newAddrs.push_back(newRemote);
20342101
}
20352102
}
2036-
for (int x = 0; x < m_destAddr.size(); x++) {
2037-
struct mmsghdr msg;
2038-
memset(&msg, 0, sizeof(msg));
20392103

2040-
msg.msg_hdr.msg_name = &m_destAddr[x];
2041-
msg.msg_hdr.msg_namelen = sizeof(sockaddr_in);
2042-
msg.msg_hdr.msg_iov = &m_destIovec;
2043-
msg.msg_hdr.msg_iovlen = 1;
2044-
msg.msg_len = 0;
2045-
m_destMsgs.push_back(msg);
2046-
}
2047-
2048-
LogDebug(VB_SYNC, "%d Remote Sync systems configured\n",
2049-
m_destAddr.size());
2050-
FillInInterfaces();
2104+
m_sendBroadcast = sendBroadcast;
2105+
m_sendMulticast = sendMulticast;
2106+
m_sendUnicast = sendUnicast;
20512107

2052-
// Seed the "all known remotes" unicast list from anything already known.
2053-
// (Typically empty at startup; it fills in as remotes are discovered.)
2054-
if (m_sendUnicast) {
2108+
{
2109+
// Swap the whole list in at once. Each mmsghdr points at its own
2110+
// element of m_destAddr, so the two vectors must be rebuilt together
2111+
// and never observed half-updated by SendControlPacketViaMsgs().
2112+
std::unique_lock<std::mutex> lock(m_socketLock);
2113+
m_destAddr = std::move(newAddrs);
2114+
m_destMsgs.clear();
2115+
m_destMsgs.reserve(m_destAddr.size());
2116+
for (size_t x = 0; x < m_destAddr.size(); x++) {
2117+
struct mmsghdr msg;
2118+
memset(&msg, 0, sizeof(msg));
2119+
2120+
msg.msg_hdr.msg_name = &m_destAddr[x];
2121+
msg.msg_hdr.msg_namelen = sizeof(sockaddr_in);
2122+
msg.msg_hdr.msg_iov = &m_destIovec;
2123+
msg.msg_hdr.msg_iovlen = 1;
2124+
msg.msg_len = 0;
2125+
m_destMsgs.push_back(msg);
2126+
}
2127+
LogDebug(VB_SYNC, "%d Remote Sync systems configured\n",
2128+
(int)m_destAddr.size());
2129+
2130+
if (!sendUnicast) {
2131+
// "Send to ALL KNOWN remotes" is off, so drop that list rather than
2132+
// leaving a stale copy behind for the next time it is turned on.
2133+
m_unicastDestMsgs.clear();
2134+
m_unicastDestAddr.clear();
2135+
}
2136+
}
2137+
updateLock.unlock();
2138+
2139+
// Rebuild the "all known remotes" list too: it dedupes itself against
2140+
// m_destAddr, which just changed. Must run with both locks released --
2141+
// UpdateUnicastDestinations() takes m_unicastUpdateLock, then m_systemsLock,
2142+
// then m_socketLock, itself.
2143+
if (sendUnicast) {
20552144
UpdateUnicastDestinations();
20562145
}
2057-
return 1;
20582146
}
20592147

20602148
void MultiSync::SendControlPacketViaMsgs(std::vector<struct mmsghdr>& msgs, struct iovec& iovec, void* outBuf, int len) {
@@ -2181,7 +2269,8 @@ void MultiSync::UpdateUnicastDestinations() {
21812269
// m_systemsLock -> m_socketLock order used by Ping().
21822270
std::unique_lock<std::mutex> lock(m_socketLock);
21832271
// Skip any address already covered by the statically-configured remote list
2184-
// (m_destAddr, built once in OpenControlSockets and immutable thereafter) so
2272+
// (m_destAddr, rebuilt by ReloadSyncDestinations(), which holds the same
2273+
// m_unicastUpdateLock we hold here while it swaps) so
21852274
// a remote that is both individually selected for unicast AND picked up by
21862275
// "all known remotes" only receives each packet once. The same set also
21872276
// dedupes the all-known list against itself (e.g. a remote known under both

src/MultiSync.h

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,14 @@ class MultiSync {
346346
// currently-known remote systems that support unicast. Only used when the
347347
// MultiSyncUnicast ("send to ALL known remotes") setting is enabled.
348348
void UpdateUnicastDestinations();
349+
// Re-read the sync send methods (MultiSyncBroadcast/Multicast/Unicast) and
350+
// the statically-configured unicast remote list (MultiSyncRemotes plus
351+
// MultiSyncExtraRemotes) from the settings and swap the resulting
352+
// destination list into place. Called once from OpenControlSockets() and
353+
// again from a settings listener whenever any of those settings change, so
354+
// editing the remote list on the MultiSync page takes effect immediately
355+
// instead of requiring an fppd restart.
356+
void ReloadSyncDestinations();
349357
bool FillInInterfaces();
350358
bool RemoveInterface(const std::string& interface);
351359

@@ -377,14 +385,21 @@ class MultiSync {
377385
std::map<std::string, std::string> m_configuredOutputRanges;
378386

379387
std::map<std::string, NetInterfaceInfo> m_interfaces;
380-
bool m_sendMulticast;
381-
bool m_sendBroadcast;
382-
bool m_sendUnicast = false;
388+
// Which send methods are active. Read on the output thread from
389+
// SendControlPacket() without any lock and rewritten by
390+
// ReloadSyncDestinations() on the settings-reload thread, hence atomic.
391+
std::atomic<bool> m_sendMulticast;
392+
std::atomic<bool> m_sendBroadcast;
393+
std::atomic<bool> m_sendUnicast = false;
383394

384395
int m_broadcastSock;
385396
int m_controlSock;
386397
int m_receiveSock;
387398

399+
// Guards the one-shot listener teardown in ShutdownSync(); see the comment
400+
// there for why the second call has to be a no-op.
401+
std::atomic<bool> m_settingsListenersRemoved{ false };
402+
388403
std::string m_hostname;
389404
struct sockaddr_in m_srcAddr;
390405
struct sockaddr_in m_receiveSrcAddr;
@@ -414,6 +429,9 @@ class MultiSync {
414429

415430
float m_remoteOffset;
416431

432+
// Statically-configured unicast remotes (MultiSyncRemotes plus
433+
// MultiSyncExtraRemotes). Rebuilt by ReloadSyncDestinations() whenever
434+
// those settings change. Guarded by m_socketLock (same as the send path).
417435
struct iovec m_destIovec;
418436
std::vector<struct mmsghdr> m_destMsgs;
419437
std::vector<struct sockaddr_in> m_destAddr;

www/multisync.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3559,17 +3559,15 @@ function ensureSomeSyncMethod() {
35593559
if (!r.ok) throw new Error(r.status);
35603560
settings['MultiSyncRemotes'] = remotes;
35613561
if (verbose) {
3562+
// No restart flag: fppd re-reads /media/settings when it
3563+
// changes and MultiSync rebuilds its unicast destination
3564+
// list from this setting live (MultiSync::ReloadSyncDestinations).
35623565
if (remotes == "") {
3563-
$.jGrowl("Remote List Cleared. You must restart fppd for the changes to take effect.", { themeState: 'success' });
3566+
$.jGrowl("Remote List Cleared.", { themeState: 'success' });
35643567
} else {
3565-
$.jGrowl("Remote List set to: '" + remotes + "'. You must restart fppd for the changes to take effect.", { themeState: 'success' });
3568+
$.jGrowl("Remote List set to: '" + remotes + "'.", { themeState: 'success' });
35663569
}
35673570
}
3568-
//Mark FPPD as needing restart
3569-
SetRestartFlag(2);
3570-
settings['restartFlag'] = 2;
3571-
//Get the resart banner showing
3572-
CheckRestartRebootFlags();
35733571
validateMultiSyncSettings();
35743572
} catch {
35753573
DialogError("Save Remotes", "Save Failed");

www/settings.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2365,7 +2365,6 @@
23652365
"description": "Send MultiSync to ALL remotes via Broadcast",
23662366
"tip": "Use a network broadcast to send MultiSync messages to all remotes.",
23672367
"type": "checkbox",
2368-
"restart": 2,
23692368
"fppModes": [
23702369
"player"
23712370
],
@@ -2379,7 +2378,6 @@
23792378
"description": "Send MultiSync to ALL KNOWN remotes via Unicast",
23802379
"tip": "Unicast MultiSync messages individually to every known FPP remote that is running in Remote mode. Remotes are added to the list automatically as they are discovered (via mDNS or the normal FPP discovery). Non-FPP devices (WLED, ESPixelStick, Falcon controllers, etc.) and FPP instances not in Remote mode are skipped. Useful when multicast/broadcast is unreliable across your network.",
23812380
"type": "checkbox",
2382-
"restart": 2,
23832381
"default": 1,
23842382
"fppModes": [
23852383
"player"
@@ -2442,7 +2440,6 @@
24422440
"description": "MultiSync Unicast Discovery IPs (CSV list)",
24432441
"tip": "FPP will send unicast MultiSync discovery messages to the IPs in this list to allow discovery of devices which are unable to be discovered via FPP's normal multicast discovery. This may include systems on remote networks or connected to certain routers which do not pass multicast packets between wired and wireless interfaces.",
24442442
"level": 1,
2445-
"restart": 2,
24462443
"size": 64,
24472444
"maxlength": 128,
24482445
"type": "text",
@@ -2495,7 +2492,6 @@
24952492
"description": "Send MultiSync to ALL remotes via Multicast (239.70.80.80)",
24962493
"tip": "Send MultiCast to 239.70.80.80 to send MultiSync messages to all remotes.",
24972494
"type": "checkbox",
2498-
"restart": 2,
24992495
"fppModes": [
25002496
"player"
25012497
],

0 commit comments

Comments
 (0)