Skip to content

Commit 979778e

Browse files
alexnskcodyclaude
andcommitted
release: v0.3.1 — gesture stream recovery (touch-stream resilience)
Field bug (second laptop, diagnosed from power-debug logs 2026-07-16): the MultitouchSupport stream died 2026-07-15 with NO system sleep afterwards, so v0.3.0's wake-only recovery never fired and gestures stayed dead until relaunch while permissions showed green (input side dead, not policy — route() logged nothing). The specific killer is unconfirmed (owner: probably no display change), so recovery now detects death by evidence instead of one cause: - StreamHealthPolicy (TilerCore, TDD, 15 tests): device-drift set-compare + silence self-heal gates (silent ≥ 10 min, pointer/scroll HID ≤ 60 s old, unlocked, ≥ 10 min cooldown — external-mouse churn capped). - TouchStream: optional MTDeviceGetDeviceID symbol (probe-verified present, IDs stable), attachedSignature captured at start, currentSignature() fresh enumeration (count-only fallback if the symbol ever vanishes), frame liveness clock stamped by the C callback (NSLock-guarded). - AppDelegate guardian: shared rebuildTouchStream(reason:) — wake path folded in; debounced display-reconfiguration rebuild; unlock drift check; 60 s watchdog (drift, then self-heal policy); screen-lock tracking. - Diagnostics (owner: "не жалей отладки"): stream start/rebuild lines with device IDs and reasons, drift old→new, display-change screen counts, self-heal evidence; healthy ticks log nothing; NSLog mirrors rebuilds. Specs: app-shell "Gesture recovery after system wake" broadened to "Gesture stream recovery" (4 triggers, no-false-positive rule); power diagnostic logging gains the stream lifecycle. Change archived as 2026-07-16-fix-touch-stream-resilience; version 0.3.0 → 0.3.1. Verified: 162 non-AX tests green; power-acceptance + run-acceptance (signed .app) ALL PASS; smoke on the signed build — launch v0.3.1 + device-ID line logged, zero spurious rebuilds across a watchdog tick. Field verification of the original death happens on the second laptop after this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6948a7c commit 979778e

15 files changed

Lines changed: 663 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
**Start here every session:**
44
1. Read `openspec/project.md` (context, environment, workflow rules).
55
2. Check the active change listed in `openspec/project.md`. Currently:
6-
**none**`add-power-control` shipped as v0.3.0 (2026-07-10) and is archived
7-
under `openspec/changes/archive/2026-07-10-add-power-control/` with full design
8-
history. Start new work as a fresh change folder. v0.1 (MVP), v0.2 (shell +
9-
calibration), and v0.3 (power control) are all archived under
10-
`openspec/changes/archive/`; `openspec/specs/` is the current merged truth
11-
(scenarios = required tests). The owner's verbatim original assignment is
6+
**none**latest shipped: `fix-touch-stream-resilience` as v0.3.1 (2026-07-16,
7+
gesture stream recovery; field verification on the owner's second laptop still
8+
pending) and `add-power-control` as v0.3.0 (2026-07-10), both archived under
9+
`openspec/changes/archive/` with full design history. Start new work as a fresh
10+
change folder. `openspec/specs/` is the current merged truth (scenarios =
11+
required tests). The owner's verbatim original assignment is
1212
`archive/2026-07-04-add-tiler-mvp/brief.md`.
1313
3. New work starts as a new change folder (kebab-case) with proposal/design/tasks/spec
1414
deltas; update `tasks.md` checkboxes **as you work** and keep specs in sync with

Scripts/make-app.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ cd "$(dirname "$0")/.."
1313

1414
IDENTITY="Apple Development: alexnsk@gmail.com (PHYV972T38)"
1515
BUNDLE_ID="pro.amilabs.tilerx"
16-
VERSION="0.3.0"
16+
VERSION="0.3.1"
1717
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
1818
APP="build/Tiler.app"
1919

Sources/Tiler/AppDelegate.swift

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import TilerSystem
55

66
@MainActor
77
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
8-
static let version = "0.3.0"
8+
static let version = "0.3.1"
99

1010
private var statusItem: NSStatusItem?
1111
private var touchStream: TouchStream?
@@ -52,6 +52,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
5252
private var lastLoggedPower: String?
5353
private var lastOnBattery = false
5454
private var debugStateTimer: DispatchSourceTimer? // 30 s idle-state log while debug logging is on
55+
// Touch-stream guardian (gesture stream recovery spec).
56+
private var streamGuardTimer: DispatchSourceTimer?
57+
private var displayChangeDebounce: DispatchWorkItem?
58+
private var lastStreamRebuild = Date.distantPast
59+
private var screenLockedNow = false
5560

5661
func applicationDidFinishLaunching(_ notification: Notification) {
5762
setUpStatusItem()
@@ -65,10 +70,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
6570
handleDebugWindowArgs()
6671
}
6772

68-
/// The MultitouchSupport contact stream goes stale across a real system sleep
69-
/// (its device refs die), so gestures stop working until relaunch — observed in
70-
/// the diagnostic log at gate 4.2. Rebuild the stream shortly after wake (a small
71-
/// delay lets the HID stack re-enumerate the trackpad first).
73+
/// The MultitouchSupport contact stream goes stale without the app crashing:
74+
/// across a real system sleep (gate 4.2), and — observed in the field
75+
/// 2026-07-15 — with no system sleep at all, after which v0.3.0's wake-only
76+
/// recovery never fired and gestures stayed dead until relaunch. The guardian
77+
/// rebuilds the stream on every trigger that can invalidate device refs (wake,
78+
/// display reconfiguration) and detects silent death by evidence: device-ID
79+
/// drift, or prolonged frame silence while the cursor is demonstrably moving
80+
/// (StreamHealthPolicy decides; ~60 s watchdog).
7281
private func setUpTouchWakeRecovery() {
7382
NSWorkspace.shared.notificationCenter.addObserver(
7483
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
@@ -77,20 +86,104 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
7786
self?.reconcileStuckSleepDisabled() // clear a leftover clamshell flag on wake
7887
self?.logSleepBlockers("wake")
7988
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
80-
self?.restartTouchStreamAfterWake()
89+
self?.rebuildTouchStream(reason: "wake")
8190
}
8291
}
8392
}
93+
// Dock/undock/resolution changes re-enumerate HID devices; the events come
94+
// in bursts, so debounce before the (unconditional) rebuild.
95+
NotificationCenter.default.addObserver(
96+
forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main
97+
) { [weak self] _ in
98+
MainActor.assumeIsolated {
99+
guard let self else { return }
100+
self.displayChangeDebounce?.cancel()
101+
let work = DispatchWorkItem { [weak self] in
102+
guard let self else { return }
103+
self.plog("display change screens=\(NSScreen.screens.count)")
104+
self.rebuildTouchStream(reason: "display change")
105+
}
106+
self.displayChangeDebounce = work
107+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: work)
108+
}
109+
}
110+
// Screen lock state feeds the self-heal policy; unlock also drift-checks
111+
// (a topology change while locked would otherwise wait for the watchdog).
112+
let dnc = DistributedNotificationCenter.default()
113+
dnc.addObserver(forName: Notification.Name("com.apple.screenIsLocked"),
114+
object: nil, queue: .main) { [weak self] _ in
115+
MainActor.assumeIsolated { self?.screenLockedNow = true }
116+
}
117+
dnc.addObserver(forName: Notification.Name("com.apple.screenIsUnlocked"),
118+
object: nil, queue: .main) { [weak self] _ in
119+
MainActor.assumeIsolated {
120+
self?.screenLockedNow = false
121+
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
122+
self?.rebuildTouchStreamIfDrifted(context: "unlock")
123+
}
124+
}
125+
}
126+
let timer = DispatchSource.makeTimerSource(queue: .main)
127+
timer.schedule(deadline: .now() + 60, repeating: 60)
128+
timer.setEventHandler { [weak self] in MainActor.assumeIsolated { self?.streamGuardTick() } }
129+
timer.resume()
130+
streamGuardTimer = timer
131+
}
132+
133+
/// One watchdog pass: a precise drift check first, then the silence heuristic.
134+
private func streamGuardTick() {
135+
guard let stream = touchStream else { return } // no trackpad → nothing to guard
136+
if rebuildTouchStreamIfDrifted(context: "watchdog") { return }
137+
guard let silent = stream.silentSeconds() else { return }
138+
let hidAgo = Self.secondsSinceHIDActivity()
139+
if StreamHealthPolicy.shouldSelfHeal(
140+
silentFor: silent,
141+
hidAgo: hidAgo,
142+
sinceLastRebuild: Date().timeIntervalSince(lastStreamRebuild),
143+
screenLocked: screenLockedNow
144+
) {
145+
plog("touch self-heal: silent=\(Int(silent))s hid=\(hidAgo.map { String(Int($0)) } ?? "?")s")
146+
rebuildTouchStream(reason: "self-heal")
147+
}
84148
}
85149

86-
private func restartTouchStreamAfterWake() {
150+
/// Seconds since the session last saw pointer/scroll HID activity — the
151+
/// "user is at the machine" evidence for the self-heal policy.
152+
private static func secondsSinceHIDActivity() -> Double? {
153+
let types: [CGEventType] = [.mouseMoved, .scrollWheel]
154+
let ages = types.map {
155+
CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: $0)
156+
}.filter { $0 >= 0 }
157+
return ages.min()
158+
}
159+
160+
/// Drift check: rebuild only when a fresh enumeration differs from the devices
161+
/// the stream attached to. Returns true when a rebuild was performed.
162+
@discardableResult
163+
private func rebuildTouchStreamIfDrifted(context: String) -> Bool {
164+
guard let stream = touchStream else { return false }
165+
let attached = stream.attachedSignature
166+
let current = stream.currentSignature()
167+
guard StreamHealthPolicy.deviceDrift(attached: attached, current: current) else { return false }
168+
plog("touch stream drift (\(context)): \(attached) -> \(current ?? [])")
169+
rebuildTouchStream(reason: "device drift")
170+
return true
171+
}
172+
173+
/// Shared rebuild path for every trigger (wake / display change / drift /
174+
/// self-heal): stop + start builds a fresh device list; on failure fall back to
175+
/// a full pipeline rebuild.
176+
private func rebuildTouchStream(reason: String) {
87177
guard let stream = touchStream else { return } // no trackpad → nothing to do
178+
lastStreamRebuild = Date()
88179
stream.stop()
89180
do {
90-
try stream.start() // start() builds a fresh device list
91-
plog("touch stream restarted after wake")
181+
try stream.start()
182+
NSLog("Tiler: touch stream rebuilt (%@)", reason)
183+
plog("touch stream rebuilt (\(reason)) devices=\(stream.attachedSignature)")
92184
} catch {
93-
plog("touch stream restart failed, rebuilding pipeline: \(error)")
185+
NSLog("Tiler: touch stream rebuild failed (%@): %@", reason, "\(error)")
186+
plog("touch stream rebuild failed (\(reason)): \(error), rebuilding pipeline")
94187
startTouchPipeline()
95188
}
96189
}
@@ -288,6 +381,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
288381
+ "power=\(power.percent.map(String.init) ?? "-")% \(power.onBattery ? "battery" : "AC") "
289382
+ "lid=\(SystemPower.lidClosed() ? "closed" : "open") floor=\(settings.batteryFloorPercent) "
290383
+ "display=\(settings.keepDisplayAwake) sleepDisabled=\(DisableSleepGovernor.sleepDisabledNow())")
384+
// The pipeline started before powerLog existed — record the stream identity now.
385+
if let stream = touchStream { plog("touch stream devices=\(stream.attachedSignature)") }
291386
observeSystemPowerEvents()
292387
logSleepBlockers("launch")
293388
refreshDebugStateTimer()
@@ -745,6 +840,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
745840
try stream.start()
746841
touchStream = stream
747842
NSLog("Tiler: touch stream started")
843+
plog("touch stream started devices=\(stream.attachedSignature)")
748844
} catch {
749845
// No trackpad / framework change: gestures unavailable, app stays alive.
750846
NSLog("Tiler: touch stream unavailable: \(error)")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
3+
/// Pure decisions for touch-stream recovery (app-shell "Gesture stream recovery").
4+
/// The system layer feeds in device signatures and ages; this decides whether the
5+
/// stream must be rebuilt. Field context: the MultitouchSupport stream can die
6+
/// without a system sleep (observed 2026-07-15), so recovery cannot key on wake
7+
/// alone — it detects death by evidence: stale device identity, or prolonged frame
8+
/// silence while the user is demonstrably at the machine.
9+
public enum StreamHealthPolicy {
10+
11+
/// A fresh enumeration that differs from what the stream attached to means the
12+
/// attached refs are stale — rebuild. Signatures are device-ID multisets
13+
/// (sorted-compare; order carries no meaning). `current == nil` means the fresh
14+
/// enumeration failed: no information, never drift.
15+
public static func deviceDrift(attached: [UInt64], current: [UInt64]?) -> Bool {
16+
guard let current else { return false }
17+
return attached.sorted() != current.sorted()
18+
}
19+
20+
/// Silence self-heal: rebuild only when the stream has been frame-silent for
21+
/// `minSilence` while pointer/scroll HID activity is at most `maxHIDAge` old
22+
/// (the user is moving the cursor, we hear nothing) on an unlocked screen, and
23+
/// the previous rebuild is at least `cooldown` behind — an external-mouse-only
24+
/// user legitimately produces "silent trackpad + live cursor" forever, and the
25+
/// cooldown caps that churn at one idempotent stop/start per period.
26+
public static func shouldSelfHeal(
27+
silentFor: TimeInterval,
28+
hidAgo: TimeInterval?,
29+
sinceLastRebuild: TimeInterval,
30+
screenLocked: Bool,
31+
minSilence: TimeInterval = 600,
32+
maxHIDAge: TimeInterval = 60,
33+
cooldown: TimeInterval = 600
34+
) -> Bool {
35+
guard !screenLocked, let hidAgo else { return false }
36+
return silentFor >= minSilence && hidAgo <= maxHIDAge && sinceLastRebuild >= cooldown
37+
}
38+
}

Sources/TilerSystem/TouchStream.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public final class TouchStream: @unchecked Sendable {
1313
typealias RegisterCallbackFn = @convention(c) (TLMTDeviceRef, TLMTContactCallback?) -> Void
1414
typealias StartFn = @convention(c) (TLMTDeviceRef, Int32) -> Void
1515
typealias StopFn = @convention(c) (TLMTDeviceRef) -> Void
16+
typealias GetDeviceIDFn = @convention(c) (TLMTDeviceRef, UnsafeMutablePointer<UInt64>) -> Int32
1617

1718
public enum StreamError: Error, CustomStringConvertible {
1819
case frameworkUnavailable(String)
@@ -35,10 +36,18 @@ public final class TouchStream: @unchecked Sendable {
3536
private var deviceList: CFMutableArray?
3637
private var running = false
3738

39+
// Liveness/identity state for the stream guardian. Written from the C callback's
40+
// MultitouchSupport thread and start(); read from the main actor — lock-guarded.
41+
private let stateLock = NSLock()
42+
private var lastFrameTime: CFAbsoluteTime?
43+
private var startedTime: CFAbsoluteTime?
44+
private var attachedIDs: [UInt64] = []
45+
3846
private let createList: CreateListFn
3947
private let registerCallback: RegisterCallbackFn
4048
private let startDevice: StartFn
4149
private let stopDevice: StopFn
50+
private let getDeviceID: GetDeviceIDFn? // optional: absent symbol degrades drift to count-only
4251

4352
/// `handler` is called on the stream's serial queue for every contact frame.
4453
public init(handler: @escaping @Sendable (TouchFrame) -> Void) throws {
@@ -60,6 +69,7 @@ public final class TouchStream: @unchecked Sendable {
6069
registerCallback = try sym("MTRegisterContactFrameCallback", RegisterCallbackFn.self)
6170
startDevice = try sym("MTDeviceStart", StartFn.self)
6271
stopDevice = try sym("MTDeviceStop", StopFn.self)
72+
getDeviceID = dlsym(lib, "MTDeviceGetDeviceID").map { unsafeBitCast($0, to: GetDeviceIDFn.self) }
6373
}
6474

6575
public func start() throws {
@@ -79,6 +89,13 @@ public final class TouchStream: @unchecked Sendable {
7989
startDevice(dev, 0)
8090
}
8191
running = true
92+
93+
let signature = signature(of: devices)
94+
stateLock.lock()
95+
attachedIDs = signature
96+
startedTime = CFAbsoluteTimeGetCurrent()
97+
lastFrameTime = nil
98+
stateLock.unlock()
8299
}
83100

84101
public func stop() {
@@ -97,10 +114,56 @@ public final class TouchStream: @unchecked Sendable {
97114
stop()
98115
}
99116

117+
// MARK: - Guardian support (identity + liveness)
118+
119+
/// Device signature: sorted real device IDs when `MTDeviceGetDeviceID` is
120+
/// available, else the degenerate `[count]` (drift then means count change only).
121+
private func signature(of devs: [TLMTDeviceRef]) -> [UInt64] {
122+
guard let getDeviceID else { return [UInt64(devs.count)] }
123+
return devs.compactMap { dev in
124+
var id: UInt64 = 0
125+
return getDeviceID(dev, &id) == 0 ? id : nil
126+
}.sorted()
127+
}
128+
129+
/// The signature captured by the last successful start().
130+
public var attachedSignature: [UInt64] {
131+
stateLock.lock(); defer { stateLock.unlock() }
132+
return attachedIDs
133+
}
134+
135+
/// Fresh enumeration, same encoding as `attachedSignature`; nil when the device
136+
/// list cannot be built (no information — the policy never treats nil as drift).
137+
public func currentSignature() -> [UInt64]? {
138+
guard let listUnmanaged = createList() else { return nil }
139+
let list = listUnmanaged.takeRetainedValue()
140+
let count = CFArrayGetCount(list)
141+
let devs = (0..<count).map { i in
142+
TLMTDeviceRef(mutating: CFArrayGetValueAtIndex(list, i)!)
143+
}
144+
return signature(of: devs)
145+
}
146+
147+
/// Seconds since the stream last delivered a contact frame — counted from the
148+
/// last start() when no frame arrived yet, so a fresh stream is never
149+
/// instantly "long silent". Nil before the first start().
150+
public func silentSeconds(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) -> Double? {
151+
stateLock.lock(); defer { stateLock.unlock() }
152+
guard let reference = lastFrameTime ?? startedTime else { return nil }
153+
return max(0, now - reference)
154+
}
155+
156+
private func noteFrame() {
157+
stateLock.lock()
158+
lastFrameTime = CFAbsoluteTimeGetCurrent()
159+
stateLock.unlock()
160+
}
161+
100162
// MARK: - C callback plumbing
101163

102164
private static let contactCallback: TLMTContactCallback = { device, touches, numTouches, timestamp, _ in
103165
guard let stream = TouchStream.current else { return 0 }
166+
stream.noteFrame()
104167
let deviceID = UInt64(UInt(bitPattern: device))
105168
var contacts: [Contact] = []
106169
if let touches, numTouches > 0 {

0 commit comments

Comments
 (0)