Skip to content

Commit 68dd0be

Browse files
committed
fix: don't capture SwiftData models across the notification Task in textMessageAppPacket
Backtrace-confirmed fix for the "destroyed by ModelContext.reset" crash when switching devices: MyInfoEntity.channels.getter() closure #11 in MeshPackets.textMessageAppPacket(...) MeshPackets.swift:1310 On an incoming text message, both notification branches deferred work to a `Task { @mainactor in ... }` that captured context-bound model objects (`newMessage`, and the fetched `MyInfoEntity`) and read them — `.channels`, `.unreadMessages`, `newMessage.*` — when it ran. A device switch recreates/resets the MeshPackets context between the capture and the Task, so those objects are dead by the time the Task executes and any access traps. Snapshot everything the Task needs before the hop instead: - Channel branch: do the channel mute-check synchronously (context still alive), pre-build the Notification, and capture only plain values. The badge recompute (unreadMessages is @mainactor) re-fetches a fresh, live main-context entity inside the Task rather than touching the captured one. - DM branch: build the Notification (and CarPlay intent) before the Task and capture only the resulting value. No model object is captured across the Task in either branch now.
1 parent f9868d4 commit 68dd0be

1 file changed

Lines changed: 67 additions & 50 deletions

File tree

Meshtastic/Helpers/MeshPackets.swift

Lines changed: 67 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,70 +1263,87 @@ actor MeshPackets {
12631263
}
12641264
}
12651265
if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false {
1266-
// Create an iOS Notification for the received DM message
1266+
// Build the notification from the model objects now, while this context is valid,
1267+
// and capture only the resulting value — a deferred Task must not hold `newMessage`
1268+
// (a context-bound model), since a device switch can reset this context first
1269+
// ("destroyed by ModelContext.reset").
1270+
let senderName = newMessage.fromUser?.longName ?? "Unknown".localized
1271+
var dmNotification = Notification(
1272+
id: ("notification.id.\(newMessage.messageId)"),
1273+
title: "\(senderName)",
1274+
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1275+
content: messageText!,
1276+
target: "messages",
1277+
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1278+
messageId: newMessage.messageId,
1279+
channel: newMessage.channel,
1280+
userNum: Int64(packet.from),
1281+
critical: critical
1282+
)
1283+
#if os(iOS)
1284+
dmNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1285+
#endif
1286+
let notification = dmNotification
12671287
Task {@MainActor in
12681288
let manager = LocalNotificationManager()
1269-
var dmNotification = Notification(
1270-
id: ("notification.id.\(newMessage.messageId)"),
1271-
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
1272-
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1273-
content: messageText!,
1274-
target: "messages",
1275-
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1276-
messageId: newMessage.messageId,
1277-
channel: newMessage.channel,
1278-
userNum: Int64(packet.from),
1279-
critical: critical
1280-
)
1281-
#if os(iOS)
1282-
dmNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1283-
#endif
1284-
manager.notifications = [dmNotification]
1289+
manager.notifications = [notification]
12851290
manager.schedule()
1286-
1287-
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
1291+
Logger.services.debug("iOS Notification Scheduled for text message from \(senderName, privacy: .public)")
12881292
}
12891293
}
12901294
} else if newMessage.fromUser != nil && newMessage.toUser == nil {
12911295
let myInfoFetchDescriptor = FetchDescriptor<MyInfoEntity>(predicate: #Predicate { $0.myNodeNum == connectedNode })
12921296
do {
12931297
let fetchedMyInfo = try modelContext.fetch(myInfoFetchDescriptor)
1294-
if !fetchedMyInfo.isEmpty {
1295-
let ctx = modelContext
1296-
// unreadMessages is O(unread); recomputing it for the badge on every
1297-
// incoming channel message is quadratic over a burst and stalls slower
1298-
// devices. Throttle to ~1/sec — the badge tolerates brief lag and also
1299-
// resyncs on app-active and on read (refreshBadgeCount). Notifications
1300-
// below still fire per message.
1298+
if let myInfo = fetchedMyInfo.first {
1299+
// Snapshot everything off this context NOW, synchronously, and capture only
1300+
// plain values into the deferred Task. The entity (and `newMessage`) must not
1301+
// cross the hop: a device switch can reset this context before the Task runs,
1302+
// which traps with "destroyed by ModelContext.reset" on access.
1303+
//
1304+
// unreadMessages is O(unread); recomputing it for the badge on every incoming
1305+
// channel message is quadratic over a burst and stalls slower devices. Throttle
1306+
// to ~1/sec — the badge tolerates brief lag and resyncs on app-active and on read.
13011307
let recountUnread = shouldRecomputeChannelUnread()
1308+
let connectedNodeNum = connectedNode
1309+
let shouldNotify = UserDefaults.channelMessageNotifications && newMessage.isEmoji == false && myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
1310+
var channelNotification: Notification?
1311+
if shouldNotify {
1312+
var chNotification = Notification(
1313+
id: ("notification.id.\(newMessage.messageId)"),
1314+
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
1315+
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1316+
content: messageText!,
1317+
target: "messages",
1318+
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1319+
messageId: newMessage.messageId,
1320+
channel: newMessage.channel,
1321+
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
1322+
critical: critical
1323+
)
1324+
#if os(iOS)
1325+
chNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1326+
#endif
1327+
channelNotification = chNotification
1328+
}
1329+
let notification = channelNotification
13021330
Task {@MainActor in
13031331
if recountUnread {
1304-
appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: ctx)
1305-
}
1306-
for channel in fetchedMyInfo[0].channels {
1307-
if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications && newMessage.isEmoji == false {
1308-
// Create an iOS Notification for the received channel message
1309-
let manager = LocalNotificationManager()
1310-
var chNotification = Notification(
1311-
id: ("notification.id.\(newMessage.messageId)"),
1312-
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
1313-
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1314-
content: messageText!,
1315-
target: "messages",
1316-
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1317-
messageId: newMessage.messageId,
1318-
channel: newMessage.channel,
1319-
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
1320-
critical: critical
1321-
)
1322-
#if os(iOS)
1323-
chNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1324-
#endif
1325-
manager.notifications = [chNotification]
1326-
manager.schedule()
1327-
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)")
1332+
// Recompute the badge against a freshly re-fetched, live main-context
1333+
// entity (unreadMessages is @MainActor); never touch the captured
1334+
// MeshPackets entity, which a device switch may have invalidated.
1335+
let mainContext = PersistenceController.shared.context
1336+
let liveDescriptor = FetchDescriptor<MyInfoEntity>(predicate: #Predicate { $0.myNodeNum == connectedNodeNum })
1337+
if let liveMyInfo = try? mainContext.fetch(liveDescriptor).first {
1338+
appState?.unreadChannelMessages = liveMyInfo.unreadMessages(context: mainContext)
13281339
}
13291340
}
1341+
if let notification {
1342+
let manager = LocalNotificationManager()
1343+
manager.notifications = [notification]
1344+
manager.schedule()
1345+
Logger.services.debug("iOS Notification Scheduled for channel text message")
1346+
}
13301347
}
13311348
}
13321349
} catch {

0 commit comments

Comments
 (0)