Skip to content

Commit 8d1868e

Browse files
committed
Add per-schedule timer tolerance and improve logging
Introduces a tolerance parameter to GlobalTimer schedules, allowing finer control over timer precision for both background and foreground execution. Updates all Logger.process.info calls to use privacy annotations for thread information, improving log output consistency and privacy. Cleans up obsolete comments and ensures tolerance is handled and normalized throughout scheduling logic.
1 parent f6eb7a1 commit 8d1868e

16 files changed

+79
-49
lines changed

RsyncUI/Model/Global/GlobalTimer.swift

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public final class GlobalTimer {
1313

1414
private struct ScheduledItem {
1515
let time: Date
16+
let tolerance: TimeInterval?
1617
let callback: () -> Void
1718
}
1819

@@ -64,27 +65,30 @@ public final class GlobalTimer {
6465
/// - Parameters:
6566
/// - profile: Profile identifier (defaults to "Default").
6667
/// - time: Target execution time.
68+
/// - tolerance: Optional tolerance (seconds). If nil, a reasonable default is used.
69+
/// Pass 0 for best precision. Used for both background activity and the foreground timer
70+
/// when this item is the next due schedule.
6771
/// - callback: Closure executed when due.
68-
public func addSchedule(profile: String?, time: Date, callback: @escaping () -> Void) {
72+
public func addSchedule(profile: String?, time: Date, tolerance: TimeInterval? = nil, callback: @escaping () -> Void) {
6973
let profileName = profile ?? "Default"
70-
Logger.process.info("GlobalTimer: Adding schedule for profile '\(profileName, privacy: .public)' at \(time, privacy: .public)")
74+
Logger.process.info("GlobalTimer: Adding schedule for profile '\(profileName, privacy: .public)' at \(time, privacy: .public) with tolerance \(tolerance ?? -1, privacy: .public)s")
7175

7276
// Cancel and remove any existing background scheduler for this profile.
7377
if let existing = backgroundSchedulers.removeValue(forKey: profileName) {
7478
existing.invalidate()
7579
Logger.process.info("GlobalTimer: Cancelled existing scheduler for '\(profileName, privacy: .public)'")
7680
}
7781

78-
// Store or replace the schedule.
79-
schedules[profileName] = ScheduledItem(time: time, callback: callback)
82+
// Store or replace the schedule (with per-schedule tolerance).
83+
schedules[profileName] = ScheduledItem(time: time, tolerance: normalizedTolerance(tolerance), callback: callback)
8084

8185
// Configure background scheduler for best-effort execution around 'time'.
8286
let interval = time.timeIntervalSince(Date.now)
8387
if interval > 1 {
8488
let scheduler = NSBackgroundActivityScheduler(identifier: "no.blogspot.RsyncUI.\(profileName)")
8589
scheduler.repeats = false
8690
scheduler.interval = interval
87-
scheduler.tolerance = min(60, max(5, interval / 10)) // reasonable flexibility
91+
scheduler.tolerance = resolveBackgroundTolerance(requested: tolerance, interval: interval)
8892
scheduler.qualityOfService = .utility
8993

9094
scheduler.schedule { [weak self] completion in
@@ -104,7 +108,7 @@ public final class GlobalTimer {
104108
}
105109

106110
backgroundSchedulers[profileName] = scheduler
107-
Logger.process.info("GlobalTimer: Background scheduler configured for '\(profileName, privacy: .public)'")
111+
Logger.process.info("GlobalTimer: Background scheduler configured for '\(profileName, privacy: .public)' with tolerance \(scheduler.tolerance, privacy: .public)s")
108112
} else {
109113
Logger.process.warning("GlobalTimer: Scheduled time for '\(profileName, privacy: .public)' is in the past or too soon, skipping background scheduler")
110114
}
@@ -173,13 +177,16 @@ public final class GlobalTimer {
173177
timer?.invalidate()
174178
timer = nil
175179

176-
guard let next = schedules.values.map(\.time).min() else {
180+
guard let nextEntry = schedules.min(by: { $0.value.time < $1.value.time }) else {
177181
Logger.process.info("GlobalTimer: No schedules, foreground timer not needed")
178182
return
179183
}
180184

185+
let nextItem = nextEntry.value
186+
let nextTime = nextItem.time
187+
181188
let now = Date.now
182-
let interval = next.timeIntervalSince(now)
189+
let interval = nextTime.timeIntervalSince(now)
183190

184191
if interval <= 0 {
185192
Logger.process.info("GlobalTimer: Next schedule already due, executing now")
@@ -191,7 +198,8 @@ public final class GlobalTimer {
191198
return
192199
}
193200

194-
Logger.process.info("GlobalTimer: Scheduling one-shot foreground timer in \(interval, privacy: .public) seconds")
201+
let timerTolerance = resolveTimerTolerance(requested: nextItem.tolerance, interval: interval)
202+
Logger.process.info("GlobalTimer: Scheduling one-shot foreground timer in \(interval, privacy: .public) seconds (tolerance \(timerTolerance, privacy: .public)s)")
195203
let t = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
196204
Task { @MainActor in
197205
guard let self else { return }
@@ -200,7 +208,7 @@ public final class GlobalTimer {
200208
self.scheduleNextForegroundTimer()
201209
}
202210
}
203-
t.tolerance = min(60, max(1, interval / 10))
211+
t.tolerance = timerTolerance
204212
RunLoop.main.add(t, forMode: .common)
205213
timer = t
206214
}
@@ -286,4 +294,36 @@ public final class GlobalTimer {
286294
}
287295
}
288296
}
297+
298+
// MARK: - Tolerance helpers
299+
300+
/// Normalizes a requested tolerance (clamps negative to 0).
301+
private func normalizedTolerance(_ requested: TimeInterval?) -> TimeInterval? {
302+
guard let requested else { return nil }
303+
return max(0, requested)
304+
}
305+
306+
/// Resolve default tolerance for background scheduler when none is requested.
307+
private func defaultBackgroundTolerance(for interval: TimeInterval) -> TimeInterval {
308+
// Reasonable flexibility for system optimization.
309+
// At least 5s, at most 60s, ~10% of interval otherwise.
310+
return min(60, max(5, interval / 10))
311+
}
312+
313+
/// Resolve default tolerance for foreground one-shot timer when none is requested.
314+
private func defaultTimerTolerance(for interval: TimeInterval) -> TimeInterval {
315+
// Allow small drift to coalesce timers; at least 1s, at most 60s, ~10% of interval otherwise.
316+
return min(60, max(1, interval / 10))
317+
}
318+
319+
private func resolveBackgroundTolerance(requested: TimeInterval?, interval: TimeInterval) -> TimeInterval {
320+
let req = normalizedTolerance(requested) ?? defaultBackgroundTolerance(for: interval)
321+
// Don't exceed half of the interval to keep reasonable precision for very near events.
322+
return min(req, max(0, interval / 2))
323+
}
324+
325+
private func resolveTimerTolerance(requested: TimeInterval?, interval: TimeInterval) -> TimeInterval {
326+
let req = normalizedTolerance(requested) ?? defaultTimerTolerance(for: interval)
327+
return min(req, max(0, interval / 2))
328+
}
289329
}

RsyncUI/Model/Global/ObservableChartData.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class ObservableChartData {
1515

1616
// Only read logrecords from store once
1717
func readandparselogs(profile: String?, validhiddenIDs: Set<Int>, hiddenID: Int) async {
18-
Logger.process.info("ObservableChartData: readandparselogs() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
18+
Logger.process.info("ObservableChartData: readandparselogs() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
1919
guard parsedlogs == nil else { return }
2020
// Read logrecords
2121
let actorreadlogs = ActorReadLogRecordsJSON()

RsyncUI/Model/Global/ObservableFutureSchedules.swift

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ final class ObservableFutureSchedules {
156156
// Then add new schedule
157157
if let schedultime = schedule.dateRun?.en_date_from_string() {
158158
// The Callback
159-
globalTimer.addSchedule(profile: schedule.profile, time: schedultime) {
159+
globalTimer.addSchedule(profile: schedule.profile, time: schedultime, tolerance: 0) {
160160
self.recomputeschedules()
161161
self.setfirsscheduledate()
162162
// Logger.process.info("ObservableFutureSchedules: initiatetimer() - schedule FIRED INTERNALLY")
@@ -171,13 +171,3 @@ final class ObservableFutureSchedules {
171171
}
172172
}
173173

174-
/*
175-
GlobalTimer.shared.addSchedule(
176-
profile: "HomeBackup",
177-
time: Date.now.addingTimeInterval(3600), // 1 hour from now
178-
callback: {
179-
print("Running home backup!")
180-
// Execute backup logic
181-
}
182-
)
183-
*/

RsyncUI/Model/Newversion/ActorGetversionofRsyncUI.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ actor ActorGetversionofRsyncUI {
1212
@concurrent
1313
nonisolated func getversionsofrsyncui() async -> Bool {
1414
do {
15-
Logger.process.info("GetversionofRsyncUI: getversionsofrsyncui() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
15+
Logger.process.info("GetversionofRsyncUI: getversionsofrsyncui() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
1616
let versions = DecodeGeneric()
1717
if let versionsofrsyncui =
1818
try await versions.decodearraydata(VersionsofRsyncUI.self,
@@ -39,7 +39,7 @@ actor ActorGetversionofRsyncUI {
3939
@concurrent
4040
nonisolated func downloadlinkofrsyncui() async -> String? {
4141
do {
42-
Logger.process.info("GetversionofRsyncUI: downloadlinkofrsyncui() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
42+
Logger.process.info("GetversionofRsyncUI: downloadlinkofrsyncui() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
4343
let versions = DecodeGeneric()
4444
if let versionsofrsyncui =
4545
try await versions.decodearraydata(VersionsofRsyncUI.self,

RsyncUI/Model/Output/ActorCreateOutputforView.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ actor ActorCreateOutputforView {
1111
// From Array[String]
1212
@concurrent
1313
nonisolated func createaoutputforview(_ stringoutputfromrsync: [String]?) async -> [RsyncOutputData] {
14-
Logger.process.info("ActorCreateOutputforView: createaoutputforview() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
14+
Logger.process.info("ActorCreateOutputforView: createaoutputforview() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
1515
if let stringoutputfromrsync {
1616
return stringoutputfromrsync.map { line in
1717
RsyncOutputData(record: line)
@@ -23,7 +23,7 @@ actor ActorCreateOutputforView {
2323
// From Set<String>
2424
@concurrent
2525
nonisolated func createaoutputforview(_ setoutputfromrsync: Set<String>?) async -> [RsyncOutputData] {
26-
Logger.process.info("ActorCreateOutputforView: createaoutputforview() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
26+
Logger.process.info("ActorCreateOutputforView: createaoutputforview() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
2727
if let setoutputfromrsync {
2828
return setoutputfromrsync.map { line in
2929
RsyncOutputData(record: line)
@@ -35,7 +35,7 @@ actor ActorCreateOutputforView {
3535
// Show filelist for Restore, the TrimOutputForRestore prepares list
3636
@concurrent
3737
nonisolated func createoutputforrestore(_ stringoutputfromrsync: [String]?) async -> [RsyncOutputData] {
38-
Logger.process.info("ActorCreateOutputforView: createoutputforrestore() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
38+
Logger.process.info("ActorCreateOutputforView: createoutputforrestore() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
3939
if let stringoutputfromrsync {
4040
if let trimmeddata = await TrimOutputForRestore(stringoutputfromrsync).trimmeddata {
4141
return trimmeddata.map { filename in
@@ -49,7 +49,7 @@ actor ActorCreateOutputforView {
4949
// After a restore, present files
5050
@concurrent
5151
nonisolated func createoutputafterrestore(_ stringoutputfromrsync: [String]?) async -> [RsyncOutputData] {
52-
Logger.process.info("ActorCreateOutputforView: createoutputafterrestore() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
52+
Logger.process.info("ActorCreateOutputforView: createoutputafterrestore() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
5353
if let stringoutputfromrsync {
5454
return stringoutputfromrsync.map { filename in
5555
RsyncOutputData(record: filename)
@@ -61,7 +61,7 @@ actor ActorCreateOutputforView {
6161
// Logfile
6262
@concurrent
6363
nonisolated func createaoutputlogfileforview() async -> [LogfileRecords] {
64-
Logger.process.info("ActorCreateOutputforView: createaoutputlogfileforview() generatedata() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
64+
Logger.process.info("ActorCreateOutputforView: createaoutputlogfileforview() generatedata() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
6565
if let data = await ActorLogToFile(false).readloggfile() {
6666
return data.map { record in
6767
LogfileRecords(line: record)

RsyncUI/Model/Process/Main/ProcessCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,6 @@ extension ProcessCommand {
133133
sequenceFileHandlerTask?.cancel()
134134
sequenceTerminationTask?.cancel()
135135

136-
Logger.process.info("ProcessCommand: process = nil and termination discovered \(Thread.isMain) but on \(Thread.current)")
136+
Logger.process.info("ProcessCommand: process = nil and termination discovered \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
137137
}
138138
}

RsyncUI/Model/Process/Main/ProcessRsyncOpenrsync.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,6 @@ extension ProcessRsyncOpenrsync {
198198
sequenceFileHandlerTask?.cancel()
199199
sequenceTerminationTask?.cancel()
200200

201-
Logger.process.info("ProcessRsyncOpenrsync: process = nil and termination discovered \(Thread.isMain) but on \(Thread.current)")
201+
Logger.process.info("ProcessRsyncOpenrsync: process = nil and termination discovered \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
202202
}
203203
}

RsyncUI/Model/Process/Main/ProcessRsyncVer3x.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,6 @@ extension ProcessRsyncVer3x {
241241
sequenceFileHandlerTask?.cancel()
242242
sequenceTerminationTask?.cancel()
243243

244-
Logger.process.info("ProcessRsyncVer3x: process = nil and termination discovered \(Thread.isMain) but on \(Thread.current)")
244+
Logger.process.info("ProcessRsyncVer3x: process = nil and termination discovered \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
245245
}
246246
}

RsyncUI/Model/Storage/Actors/ActorLogChartsData.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ actor ActorLogChartsData {
2525
@concurrent
2626
nonisolated func parselogrecords(from logrecords: [Log]) async -> [LogEntry] {
2727
// "resultExecuted": "43 files : 0.73 MB in 0.49 seconds"
28-
Logger.process.info("ActorLogChartsData: parselogrecords() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
28+
Logger.process.info("ActorLogChartsData: parselogrecords() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
2929
Logger.process.info("ActorLogChartsData: number of records \(logrecords.count, privacy: .public)")
3030
// return logrecords.compactMap { logrecord in
3131
return logrecords.map { logrecord in
@@ -72,7 +72,7 @@ actor ActorLogChartsData {
7272
// Select the one date with max files transferred, if more records pr date.
7373
@concurrent
7474
nonisolated func parsemaxfilesbydate(from records: [LogEntry]) async -> [LogEntry] {
75-
Logger.process.info("ActorLogChartsData: parsemaxfilesbydate() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
75+
Logger.process.info("ActorLogChartsData: parsemaxfilesbydate() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
7676
Logger.process.info("ActorLogChartsData: number of records IN \(records.count, privacy: .public)")
7777

7878
let calendar = Calendar.current
@@ -122,7 +122,7 @@ actor ActorLogChartsData {
122122
// Select the one date with max data transferred, if more records pr date.
123123
@concurrent
124124
nonisolated func parsemaxfilesbytransferredsize(from records: [LogEntry]) async -> [LogEntry] {
125-
Logger.process.info("ActorLogChartsData: parsemaxfilesbytransferredsize() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
125+
Logger.process.info("ActorLogChartsData: parsemaxfilesbytransferredsize() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
126126
Logger.process.info("ActorLogChartsData: number of records IN \(records.count, privacy: .public)")
127127
let calendar = Calendar.current
128128

RsyncUI/Model/Storage/Actors/ActorLogToFile.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ actor ActorLogToFile {
2828
if let fullpathmacserial = path.fullpathmacserial {
2929
let fullpathmacserialURL = URL(fileURLWithPath: fullpathmacserial)
3030
let logfileURL = fullpathmacserialURL.appendingPathComponent(SharedConstants().logname)
31-
Logger.process.info("LogToFile: writeloggfile() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
31+
Logger.process.info("LogToFile: writeloggfile() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
3232
if let logfiledata = await appendloggfileData(newlogadata, reset) {
3333
do {
3434
try logfiledata.write(to: logfileURL)
@@ -64,7 +64,7 @@ actor ActorLogToFile {
6464

6565
let fullpathmacserialURL = URL(fileURLWithPath: fullpathmacserial)
6666
let logfileURL = fullpathmacserialURL.appendingPathComponent(SharedConstants().logname)
67-
Logger.process.info("LogToFile: readloggfile() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
67+
Logger.process.info("LogToFile: readloggfile() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
6868

6969
do {
7070
let data = try Data(contentsOf: logfileURL)
@@ -92,7 +92,7 @@ actor ActorLogToFile {
9292

9393
let fullpathmacserialURL = URL(fileURLWithPath: fullpathmacserial)
9494
let logfileURL = fullpathmacserialURL.appendingPathComponent(SharedConstants().logname)
95-
Logger.process.info("LogToFile: readloggfileasline() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
95+
Logger.process.info("LogToFile: readloggfileasline() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
9696

9797
do {
9898
let data = try Data(contentsOf: logfileURL)
@@ -118,7 +118,7 @@ actor ActorLogToFile {
118118

119119
let fullpathmacserialURL = URL(fileURLWithPath: fullpathmacserial)
120120
let logfileURL = fullpathmacserialURL.appendingPathComponent(SharedConstants().logname)
121-
Logger.process.info("LogToFile: appendloggfileData() MAIN THREAD: \(Thread.isMain) but on \(Thread.current)")
121+
Logger.process.info("LogToFile: appendloggfileData() MAIN THREAD: \(Thread.isMain, privacy: .public) but on \(Thread.current, privacy: .public)")
122122

123123
if let newdata = newlogadata.data(using: .utf8) {
124124
do {

0 commit comments

Comments
 (0)