Skip to content

Commit 8be9e16

Browse files
authored
Fix issue with sync intervals (#884)
* For sync v1 clients, fixes issue where sync intervals are not properly respected. * Moves to dynamic `pushNotifications` initialization. * Fixes `santactl status` display issues * Fixes a very rare sync service startup race where the initial sync reschedule could be set for 4 hours into the future.
1 parent 41e4ca0 commit 8be9e16

14 files changed

Lines changed: 289 additions & 72 deletions

Source/common/SNTConfigurator.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ extern NSString* _Nonnull const kEnableMenuItemUserOverride;
768768
///
769769
/// Set the full sync interval as received from a sync server.
770770
///
771-
- (void)setFullSyncInterval:(NSUInteger)interval;
771+
- (void)setSyncServerFullSyncInterval:(NSUInteger)interval;
772772

773773
///
774774
/// Full sync interval in seconds while listening for push notifications.
@@ -779,7 +779,7 @@ extern NSString* _Nonnull const kEnableMenuItemUserOverride;
779779
///
780780
/// Set the push notifications full sync interval as received from a sync server.
781781
///
782-
- (void)setPushNotificationsFullSyncInterval:(NSUInteger)interval;
782+
- (void)setSyncServerPushNotificationsFullSyncInterval:(NSUInteger)interval;
783783

784784
///
785785
/// Enable statistics uploading to polaris.northpole.security.

Source/common/SNTConfigurator.mm

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,7 @@ - (NSUInteger)fullSyncInterval {
13481348
return kDefaultFullSyncInterval;
13491349
}
13501350

1351-
- (void)setFullSyncInterval:(NSUInteger)interval {
1351+
- (void)setSyncServerFullSyncInterval:(NSUInteger)interval {
13521352
[self updateSyncStateForKey:kFullSyncInterval value:@(interval)];
13531353
}
13541354

@@ -1360,8 +1360,10 @@ - (NSUInteger)pushNotificationsFullSyncInterval {
13601360
return kDefaultPushNotificationsFullSyncInterval;
13611361
}
13621362

1363-
- (void)setPushNotificationsFullSyncInterval:(NSUInteger)interval {
1364-
[self updateSyncStateForKey:kFCMFullSyncInterval value:@(interval)];
1363+
- (void)setSyncServerPushNotificationsFullSyncInterval:(NSUInteger)interval {
1364+
// A value of 0 means "not applicable" (e.g. sync v1). Clear the key so
1365+
// the getter falls back to the default rather than storing a sentinel.
1366+
[self updateSyncStateForKey:kFCMFullSyncInterval value:interval ? @(interval) : nil];
13651367
}
13661368

13671369
- (NSString*)machineOwner {

Source/santactl/Commands/SNTCommandStatus.mm

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ - (void)runWithArguments:(NSArray*)arguments {
386386
@"full_sync_interval_seconds" : @(fullSyncInterval),
387387
} mutableCopy];
388388

389-
if (configurator.fcmEnabled || configurator.enablePushNotifications) {
389+
if (configurator.fcmEnabled || (isSyncV2Enabled && configurator.enablePushNotifications)) {
390390
stats[@"sync"][@"push_notifications_full_sync_interval_seconds"] =
391391
@(pushNotificationsFullSyncInterval);
392392
}
@@ -511,10 +511,10 @@ - (void)runWithArguments:(NSArray*)arguments {
511511
printf(" %-40s | %s\n", "Last Successful Full Sync", [fullSyncLastSuccessStr UTF8String]);
512512
printf(" %-40s | %s\n", "Last Successful Rule Sync", [ruleSyncLastSuccessStr UTF8String]);
513513

514-
// If push notifications are enabled, show the push notifications full
515-
// sync interval since it's the active configuration.
514+
// Show the push notifications full sync interval for FCM clients or
515+
// sync v2 clients where push has not been explicitly disabled.
516516
NSString* fullSyncIntervalStr = FormatInterval(fullSyncInterval);
517-
if (configurator.fcmEnabled || configurator.enablePushNotifications) {
517+
if (configurator.fcmEnabled || (isSyncV2Enabled && configurator.enablePushNotifications)) {
518518
fullSyncIntervalStr =
519519
[NSString stringWithFormat:@"%@ (with Push Notifications)",
520520
FormatInterval(pushNotificationsFullSyncInterval)];

Source/santad/SNTDaemonControlController.mm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,11 +591,11 @@ - (void)updateSyncSettings:(SNTConfigBundle*)result reply:(void (^)(void))reply
591591
}];
592592

593593
[result fullSyncInterval:^(NSUInteger val) {
594-
[configurator setFullSyncInterval:val];
594+
[configurator setSyncServerFullSyncInterval:val];
595595
}];
596596

597597
[result pushNotificationsFullSyncInterval:^(NSUInteger val) {
598-
[configurator setPushNotificationsFullSyncInterval:val];
598+
[configurator setSyncServerPushNotificationsFullSyncInterval:val];
599599
}];
600600

601601
reply();

Source/santasyncservice/BUILD

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,6 @@ objc_library(
424424
"//Source/common:MOLXPCConnection",
425425
"//Source/common:SNTDropRootPrivs",
426426
"//Source/common:SNTExportConfiguration",
427-
"//Source/common:SNTKVOManager",
428427
"//Source/common:SNTLogging",
429428
"//Source/common:SNTStrengthify",
430429
"//Source/common:SNTXPCControlInterface",

Source/santasyncservice/SNTPushClientNATS.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616

1717
@interface SNTPushClientNATS : NSObject <SNTPushNotificationsClientDelegate>
1818
- (instancetype)initWithSyncDelegate:(id<SNTPushNotificationsSyncDelegate>)syncDelegate;
19+
- (void)disconnectWithCompletion:(void (^)(void))completion;
1920
@property(nonatomic, readonly, copy) NSString* pushServer;
2021
@end

Source/santasyncservice/SNTPushClientNATS.mm

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,13 @@ - (void)handlePreflightSyncState:(SNTSyncState*)syncState {
982982

983983
// Now attempt to connect
984984
[self connect];
985+
986+
// Only update the sync interval when we have valid push configuration.
987+
// Without this guard, sync v1 clients (with no push infrastructure) would
988+
// have their interval overwritten to the minimum (60s).
989+
if (syncState.pushNotificationsFullSyncInterval) {
990+
self.fullSyncInterval = syncState.pushNotificationsFullSyncInterval.unsignedIntegerValue;
991+
}
985992
} else {
986993
NSMutableArray* missing = [NSMutableArray array];
987994
if (!syncState.pushServer) [missing addObject:@"server"];
@@ -992,15 +999,13 @@ - (void)handlePreflightSyncState:(SNTSyncState*)syncState {
992999
[missing componentsJoinedByString:@", "]);
9931000
}
9941001

1002+
// HMAC key is independent of connection credentials — update or clear
1003+
// regardless of whether the full push config was present.
9951004
if (syncState.pushHMACKey) {
9961005
self.hmacKey = syncState.pushHMACKey;
9971006
} else {
9981007
LOGW(@"NATS: No push HMAC key received from preflight");
999-
}
1000-
1001-
// Update sync interval to avoid polling Workshop.
1002-
if (syncState.pushNotificationsFullSyncInterval) {
1003-
self.fullSyncInterval = syncState.pushNotificationsFullSyncInterval.unsignedIntegerValue;
1008+
self.hmacKey = nil;
10041009
}
10051010
}
10061011

Source/santasyncservice/SNTPushClientNATSTest.mm

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,12 @@ - (void)testHandlePreflightSyncStateUpdatesInterval {
219219
self.client = [[SNTPushClientNATS alloc] initWithSyncDelegate:self.mockSyncDelegate];
220220
NSUInteger originalInterval = self.client.fullSyncInterval;
221221

222-
// When: Preflight sync state with new interval is handled
222+
// When: Preflight sync state with new interval and valid push config is handled
223223
SNTSyncState* syncState = [[SNTSyncState alloc] init];
224+
syncState.pushServer = @"workshop";
225+
syncState.pushNKey = @"test-nkey";
226+
syncState.pushJWT = @"test-jwt";
227+
syncState.pushDeviceID = @"test-device-id";
224228
syncState.pushNotificationsFullSyncInterval = @(7200); // 2 hours
225229

226230
[self.client handlePreflightSyncState:syncState];
@@ -230,6 +234,21 @@ - (void)testHandlePreflightSyncStateUpdatesInterval {
230234
XCTAssertNotEqual(self.client.fullSyncInterval, originalInterval);
231235
}
232236

237+
- (void)testHandlePreflightSyncStateDoesNotUpdateIntervalWithoutPushConfig {
238+
// Given: Client is initialized
239+
self.client = [[SNTPushClientNATS alloc] initWithSyncDelegate:self.mockSyncDelegate];
240+
NSUInteger originalInterval = self.client.fullSyncInterval;
241+
242+
// When: Preflight sync state has interval but no push config fields
243+
SNTSyncState* syncState = [[SNTSyncState alloc] init];
244+
syncState.pushNotificationsFullSyncInterval = @(7200);
245+
246+
[self.client handlePreflightSyncState:syncState];
247+
248+
// Then: Interval should NOT be updated (no push config)
249+
XCTAssertEqual(self.client.fullSyncInterval, originalInterval);
250+
}
251+
233252
#pragma mark - Full Sync Interval Tests
234253

235254
- (void)testFullSyncIntervalDefaultValue {

Source/santasyncservice/SNTSyncManager.mm

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,19 @@ - (instancetype)initWithDaemonConnection:(MOLXPCConnection*)daemonConn {
8484
if (config.fcmEnabled) {
8585
LOGD(@"Using FCM push notifications");
8686
_pushNotifications = [[SNTPushClientFCM alloc] initWithSyncDelegate:self];
87-
} else if (config.enablePushNotifications) {
88-
// Unless explicitly disabled, initialize the NATS push client. The client
89-
// will wait for configuration during a valid V2 preflight.
90-
LOGD(@"Using NATS push notifications");
91-
_pushNotifications = [[SNTPushClientNATS alloc] initWithSyncDelegate:self];
9287
}
88+
// NATS push client is created dynamically in preflightWithSyncState: after
89+
// the server provides a validated token chain (isSyncV2) and push config.
9390

9491
_fullSyncTimer = [self createSyncTimerWithBlock:^{
95-
[self rescheduleTimerQueue:self.fullSyncTimer
96-
secondsFromNow:_pushNotifications ? _pushNotifications.fullSyncInterval
97-
: kDefaultFullSyncInterval];
92+
// Reschedule as a fallback before starting the sync. On success,
93+
// preflightWithSyncState: will override with the server-informed interval.
94+
// On failure, the failed-preflight path corrects it as well. This is just
95+
// a safety net for the case where the sync fails before reaching any
96+
// rescheduling logic.
97+
NSUInteger interval = self.pushNotifications ? self.pushNotifications.fullSyncInterval
98+
: kDefaultFullSyncInterval;
99+
[self rescheduleTimerQueue:self.fullSyncTimer secondsFromNow:interval];
98100
[self syncType:SNTSyncTypeNormal withReply:NULL];
99101
}];
100102
_ruleSyncTimer = [self createSyncTimerWithBlock:^{
@@ -523,47 +525,76 @@ - (SNTSyncStatusType)preflightWithSyncState:(SNTSyncState*)syncState {
523525

524526
self.eventBatchSize = syncState.eventBatchSize;
525527

526-
// Start listening for push notifications with a full sync every
527-
// pushNotificationsFullSyncInterval.
528+
// Dynamic NATS lifecycle: create when server validates sync v2,
529+
// tear down when sync v2 is no longer valid.
530+
if (syncState.isSyncV2 && !self.pushNotifications) {
531+
// Check kill switch — respect explicit admin disable
532+
if ([[SNTConfigurator configurator] enablePushNotifications]) {
533+
LOGI(@"Creating NATS push client after successful sync v2 preflight");
534+
self.pushNotifications = [[SNTPushClientNATS alloc] initWithSyncDelegate:self];
535+
}
536+
} else if (!syncState.isSyncV2 && self.pushNotifications &&
537+
[self.pushNotifications isKindOfClass:[SNTPushClientNATS class]]) {
538+
// Token chain no longer valid — tear down NATS client.
539+
// The NATS C library holds unretained pointers to the client via __bridge
540+
// callbacks, so we must disconnect (which destroys the C-level connection)
541+
// before the object is deallocated. disconnectWithCompletion: dispatches
542+
// async onto connectionQueue and the block's implicit capture of self (the
543+
// NATS client) keeps it alive until cleanup completes.
544+
LOGI(@"Sync v2 no longer active, tearing down NATS push client");
545+
[(SNTPushClientNATS*)self.pushNotifications disconnectWithCompletion:nil];
546+
self.pushNotifications = nil;
547+
}
548+
549+
// pushNotificationsFullSyncInterval is only meaningful when push notifications
550+
// are in use. Clear it otherwise so the postflight persists 0 to the daemon,
551+
// overriding any stale default (14400).
552+
if (!self.pushNotifications) {
553+
syncState.pushNotificationsFullSyncInterval = @(0);
554+
}
555+
556+
// Hand off push credentials to the push client (if present).
528557
if (self.pushNotifications) {
529558
NSUInteger oldInterval = self.pushNotifications.fullSyncInterval;
530559
[self.pushNotifications handlePreflightSyncState:syncState];
531560

532-
// Clear all push credentials from syncState after handoff to push client
533-
// These are no longer needed and should not be accessible to other sync stages
561+
// Clear all push credentials from syncState after handoff to push client.
562+
// These are no longer needed and should not be accessible to other sync stages.
534563
syncState.pushNKey = nil;
535564
syncState.pushJWT = nil;
536565
syncState.pushIssuerJWT = nil;
537566
syncState.pushHMACKey = nil;
538567

539-
// If push interval changed, mark log the difference.
540568
if (oldInterval != self.pushNotifications.fullSyncInterval) {
541569
LOGD(
542570
@"Push notification sync interval changed from %lu to %lu seconds. Rescheduling timer.",
543571
oldInterval, self.pushNotifications.fullSyncInterval);
544572
}
573+
}
545574

546-
// Always reschedule
575+
// Use the push notification interval when the server provided one and we
576+
// have an active push client. syncState.pushNotificationsFullSyncInterval
577+
// is nil when the server does not set push_notification_full_sync_interval_seconds
578+
// (e.g. sync v1). In that case, fall back to the server's regular full_sync_interval.
579+
if (self.pushNotifications && syncState.pushNotificationsFullSyncInterval) {
547580
[self rescheduleTimerQueue:self.fullSyncTimer
548581
secondsFromNow:self.pushNotifications.fullSyncInterval];
549582
} else {
550583
NSUInteger interval = syncState.fullSyncInterval
551584
? syncState.fullSyncInterval.unsignedIntegerValue
552585
: kDefaultFullSyncInterval;
553-
LOGD(@"Push notifications are not enabled. Sync every %lu min.", interval / 60);
554-
555-
// Always reschedule
586+
LOGD(@"Push notifications not configured by server. Sync every %lu min.", interval / 60);
556587
[self rescheduleTimerQueue:self.fullSyncTimer secondsFromNow:interval];
557588
}
558589

559590
if (syncState.preflightOnly) return SNTSyncStatusTypeSuccess;
560591
return [self eventUploadWithSyncState:syncState];
561-
} else if (_pushNotifications) {
592+
} else if (self.pushNotifications) {
562593
// If preflight failed and push notifications are enabled, force a reschedule for
563594
// the smaller of the default sync interval (default 10 minutes) and whatever the
564595
// last push full sync interval was set to (default 4 hours).
565596
// If push notifications are not enabled, the default sync interval was already set (10m).
566-
auto interval = std::min(_pushNotifications.fullSyncInterval, kDefaultFullSyncInterval);
597+
auto interval = std::min(self.pushNotifications.fullSyncInterval, kDefaultFullSyncInterval);
567598
[self rescheduleTimerQueue:self.fullSyncTimer secondsFromNow:interval];
568599
}
569600

@@ -621,6 +652,10 @@ - (dispatch_source_t)createSyncTimerWithBlock:(void (^)(void))block {
621652
block();
622653
}
623654
});
655+
// Arm with DISPATCH_TIME_FOREVER so the timer does not fire until explicitly
656+
// scheduled via rescheduleTimerQueue:secondsFromNow: (e.g. syncSecondsFromNow:15).
657+
// Without this, the default fire time races with the initial schedule call.
658+
dispatch_source_set_timer(timerQueue, DISPATCH_TIME_FOREVER, DISPATCH_TIME_FOREVER, 0);
624659
dispatch_resume(timerQueue);
625660
return timerQueue;
626661
}

Source/santasyncservice/SNTSyncManagerNATSTest.mm

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,47 +49,40 @@ - (void)tearDown {
4949
[super tearDown];
5050
}
5151

52-
- (void)testSyncManagerInitializesNATSClientWhenEnabled {
53-
// Given: FCM is disabled, NATS is enabled (default), and syncBaseURL is pinned
52+
- (void)testSyncManagerDoesNotCreateNATSClientAtStartup {
53+
// NATS client should NOT be created at startup, even when enablePushNotifications is YES.
54+
// It is created dynamically after preflight token chain validation.
5455
OCMStub([self.mockConfigurator fcmEnabled]).andReturn(NO);
5556
OCMStub([self.mockConfigurator enablePushNotifications]).andReturn(YES);
5657
OCMStub([self.mockConfigurator syncBaseURL])
5758
.andReturn([NSURL URLWithString:@"https://example.workshop.cloud"]);
5859

59-
// When: Sync manager is initialized
6060
self.syncManager = [[SNTSyncManager alloc] initWithDaemonConnection:self.mockDaemonConn];
6161

62-
// Then: Push notifications should be NATS client
63-
XCTAssertNotNil(self.syncManager.pushNotifications);
64-
XCTAssertTrue([self.syncManager.pushNotifications isKindOfClass:[SNTPushClientNATS class]]);
62+
XCTAssertNil(self.syncManager.pushNotifications, @"NATS client should not be created at startup");
6563
}
6664

67-
- (void)testSyncManagerFCMTakesPrecedenceOverNATS {
68-
// Given: Both FCM and NATS are enabled
65+
- (void)testSyncManagerFCMStillCreatedAtStartup {
66+
// FCM is a legacy client and should still be created at startup.
6967
OCMStub([self.mockConfigurator fcmEnabled]).andReturn(YES);
70-
OCMStub([self.mockConfigurator enablePushNotifications]).andReturn(YES);
7168
OCMStub([self.mockConfigurator syncBaseURL])
7269
.andReturn([NSURL URLWithString:@"https://example.workshop.cloud"]);
7370

74-
// When: Sync manager is initialized
7571
self.syncManager = [[SNTSyncManager alloc] initWithDaemonConnection:self.mockDaemonConn];
7672

77-
// Then: Push notifications should be FCM client (FCM takes precedence)
7873
XCTAssertNotNil(self.syncManager.pushNotifications);
7974
XCTAssertTrue([self.syncManager.pushNotifications isKindOfClass:[SNTPushClientFCM class]]);
8075
}
8176

82-
- (void)testSyncManagerNoPushClientWhenAllDisabled {
83-
// Given: All push notification systems are disabled
77+
- (void)testSyncManagerNoPushClientWhenFCMDisabled {
78+
// No push client at startup when FCM is disabled (NATS is dynamic now).
8479
OCMStub([self.mockConfigurator fcmEnabled]).andReturn(NO);
8580
OCMStub([self.mockConfigurator enablePushNotifications]).andReturn(NO);
8681
OCMStub([self.mockConfigurator syncBaseURL])
8782
.andReturn([NSURL URLWithString:@"https://example.workshop.cloud"]);
8883

89-
// When: Sync manager is initialized
9084
self.syncManager = [[SNTSyncManager alloc] initWithDaemonConnection:self.mockDaemonConn];
9185

92-
// Then: Push notifications should be nil
9386
XCTAssertNil(self.syncManager.pushNotifications);
9487
}
9588

0 commit comments

Comments
 (0)