Skip to content

Commit d67d5b4

Browse files
laurentftechclaude
andcommitted
Add watchdog timer to detect silent connection drops
URLSession streaming connections can silently die (TCP half-open) without triggering didCompleteWithError. Since ntfy sends keepalive events every ~55s, a watchdog checks for data every 120s and forces reconnect if nothing received. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2239202 commit d67d5b4

File tree

5 files changed

+167
-1
lines changed

5 files changed

+167
-1
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,7 @@ ntfy-mac.code-workspace
4848
.roo/
4949
_bmad/
5050
_bmad-output/
51-
DerivedData/
51+
DerivedData/
52+
53+
# spec-gen analysis artifacts
54+
.spec-gen/

.zed/tasks.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[
2+
{
3+
"label": "Make: build",
4+
"command": "make build"
5+
},
6+
{
7+
"label": "Make: run",
8+
"command": "make run"
9+
},
10+
{
11+
"label": "Make: test",
12+
"command": "make test"
13+
},
14+
{
15+
"label": "Make: clean",
16+
"command": "make clean"
17+
},
18+
{
19+
"label": "Make: open-xcode",
20+
"command": "make open-xcode"
21+
},
22+
{
23+
"label": "SwiftPM: build",
24+
"command": "swift build"
25+
},
26+
{
27+
"label": "SwiftPM: run",
28+
"command": "swift run ntfy-macos"
29+
},
30+
{
31+
"label": "SwiftPM: test",
32+
"command": "swift test"
33+
}
34+
]

.zed/workspace.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "ntfy-macos (Zed Workspace)",
3+
"folders": [
4+
{
5+
"path": "."
6+
}
7+
],
8+
"tasksFile": ".zed/tasks.json",
9+
"editor": {
10+
"exclude": [
11+
".build",
12+
"Build",
13+
"DerivedData",
14+
"xcuserdata",
15+
"xcshareddata",
16+
"Pods",
17+
"Carthage",
18+
".DS_Store"
19+
]
20+
},
21+
"metadata": {
22+
"generatedBy": "assistant",
23+
"purpose": "Non-invasive workspace metadata for Zed. Does not modify source files or build configuration."
24+
}
25+
}

Makefile

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Simple Makefile for common tasks (non-invasive)
2+
# - Uses Swift Package commands when available
3+
# - Can open Xcode project/workspace if present, or fall back to `xed .`
4+
# - Does not modify source files
5+
6+
SWIFT := swift
7+
PKG_NAME := ntfy-macos
8+
9+
SWIFT_BUILD_FLAGS ?=
10+
SWIFT_RUN_FLAGS ?=
11+
SWIFT_TEST_FLAGS ?=
12+
13+
.PHONY: help build clean run test open-xcode open-vscode
14+
15+
help:
16+
@echo "Makefile targets:"
17+
@echo " make build # build via Swift Package Manager"
18+
@echo " make run # run the executable (swift run)"
19+
@echo " make test # run the test suite (swift test)"
20+
@echo " make clean # swift package clean"
21+
@echo " make open-xcode # open Xcode workspace/project if present, else open package with xed"
22+
@echo " make open-vscode # open repository in VS Code (uses 'code' if available)"
23+
24+
# Build using SwiftPM
25+
build:
26+
@echo "Building package..."
27+
@$(SWIFT) build $(SWIFT_BUILD_FLAGS)
28+
29+
# Clean build artifacts
30+
clean:
31+
@echo "Cleaning..."
32+
@$(SWIFT) package clean
33+
34+
# Run the executable via SwiftPM
35+
run:
36+
@echo "Running $(PKG_NAME)..."
37+
@$(SWIFT) run $(SWIFT_RUN_FLAGS) $(PKG_NAME)
38+
39+
# Run tests via SwiftPM
40+
test:
41+
@echo "Running tests..."
42+
@$(SWIFT) test $(SWIFT_TEST_FLAGS)
43+
44+
# Open Xcode workspace/project if present, otherwise open package with xed (if available)
45+
open-xcode:
46+
@echo "Locating Xcode workspace/project..."
47+
@XWORKSPACE=$$(ls *.xcworkspace 2>/dev/null | head -n1 || true); \
48+
if [ -n "$$XWORKSPACE" ]; then \
49+
echo "Opening workspace: $$XWORKSPACE"; open "$$XWORKSPACE"; \
50+
else \
51+
XPROJ=$$(ls *.xcodeproj 2>/dev/null | head -n1 || true); \
52+
if [ -n "$$XPROJ" ]; then \
53+
echo "Opening project: $$XPROJ"; open "$$XPROJ"; \
54+
else \
55+
if command -v xed >/dev/null 2>&1; then \
56+
echo "No Xcode project found — opening package in Xcode via xed ."; \
57+
xed .; \
58+
else \
59+
echo "No Xcode project/workspace found and 'xed' is not available."; \
60+
echo "Open the Package.swift in Xcode manually or generate an Xcode project."; \
61+
exit 1; \
62+
fi; \
63+
fi; \
64+
fi
65+
66+
# Open repository in VS Code (prefers 'code' CLI if available, falls back to opening the app)
67+
open-vscode:
68+
@if command -v code >/dev/null 2>&1; then \
69+
echo "Opening in VS Code (code .)"; \
70+
code .; \
71+
else \
72+
echo "'code' CLI not found — attempting to open VS Code application"; \
73+
open -a "Visual Studio Code" . || (echo "Failed to open VS Code. Install 'code' CLI from the Command Palette in VS Code." && exit 1); \
74+
fi

Sources/NtfyClient.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ final class NtfyClient: NSObject, @unchecked Sendable {
102102
private var lastMessageTime: Int // Track last message timestamp for fetch_missed
103103
private let lastMessageTimeKey: String // UserDefaults key for persistence
104104

105+
// Watchdog: reconnect if no data received for this long (ntfy sends keepalives every ~55s)
106+
private let watchdogInterval: TimeInterval = 120.0
107+
private var watchdogTimer: Timer?
108+
private var lastDataReceived: Date = .distantPast
109+
105110
init(serverURL: String, topics: [String], authToken: String? = nil, fetchMissed: Bool = false) {
106111
self.serverURL = serverURL
107112
self.topics = topics
@@ -186,12 +191,34 @@ final class NtfyClient: NSObject, @unchecked Sendable {
186191
shouldReconnect = false
187192
reconnectTimer?.invalidate()
188193
reconnectTimer = nil
194+
stopWatchdog()
189195
dataTask?.cancel()
190196
dataTask = nil
191197
isConnecting = false
192198
buffer.removeAll()
193199
}
194200

201+
private func startWatchdog() {
202+
stopWatchdog()
203+
lastDataReceived = Date()
204+
DispatchQueue.main.async { [weak self] in
205+
guard let self else { return }
206+
self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: self.watchdogInterval, repeats: true) { [weak self] _ in
207+
guard let self else { return }
208+
let elapsed = Date().timeIntervalSince(self.lastDataReceived)
209+
if elapsed >= self.watchdogInterval {
210+
Log.info("Watchdog: no data received for \(Int(elapsed))s, reconnecting...")
211+
self.reconnect()
212+
}
213+
}
214+
}
215+
}
216+
217+
private func stopWatchdog() {
218+
watchdogTimer?.invalidate()
219+
watchdogTimer = nil
220+
}
221+
195222
func updateAuthToken(_ token: String?) {
196223
self.authToken = token
197224
if dataTask != nil {
@@ -307,6 +334,7 @@ final class NtfyClient: NSObject, @unchecked Sendable {
307334

308335
extension NtfyClient: URLSessionDataDelegate {
309336
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
337+
lastDataReceived = Date()
310338
buffer.append(data)
311339

312340
while let newlineRange = buffer.range(of: Data("\n".utf8)) {
@@ -321,6 +349,7 @@ extension NtfyClient: URLSessionDataDelegate {
321349

322350
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
323351
isConnecting = false
352+
stopWatchdog()
324353

325354
if let error = error {
326355
// Don't log cancelled errors (normal during reconnect)
@@ -353,6 +382,7 @@ extension NtfyClient: URLSessionDataDelegate {
353382
if httpResponse.statusCode == 200 {
354383
isConnecting = false
355384
reconnectAttempts = 0
385+
startWatchdog()
356386
callDelegate { delegate in
357387
delegate.ntfyClientDidConnect(self)
358388
}

0 commit comments

Comments
 (0)