Skip to content

Commit 4e56d93

Browse files
fix: register link + stateTask atomically in openLink
The link registration and its stateUpdates drain task were stored in two separate linkLock sections split by an await. A stop() racing that window cleared linkStateTasks (not yet holding the new task) and links, then openLink re-inserted the task into the emptied map — orphaning it (link actor kept alive, stale events into the next session, linkId absent from links so linkTeardown couldn't reach it). Build the Task and store both writes inside one critical section; the constructor is synchronous so it's lock-safe, and setPacketCallback's await now follows the unlock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 99e04e6 commit 4e56d93

1 file changed

Lines changed: 18 additions & 14 deletions

File tree

Sources/RNSBackendSwift/SwiftRNSBackend.swift

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -453,30 +453,34 @@ public final class SwiftRNSBackend: RnsBackend, @unchecked Sendable {
453453
// 4. Initiate the link (throws TransportError.noPathAvailable if the path
454454
// evaporated between the check above and here).
455455
let link = try await transport.initiateLink(to: dest, identity: localId)
456-
// Reserve the id and register the link atomically so concurrent
457-
// openLink callers can't collide on nextLinkId / overwrite links.
456+
let cont = eventContinuation
457+
// Reserve the id and register BOTH the link and its stateUpdates drain
458+
// task in one critical section. If these were split (task stored after
459+
// the setPacketCallback await), a stop() racing that window would clear
460+
// the maps in between and then openLink would re-insert an orphaned
461+
// task into the freshly-emptied linkStateTasks — keeping the link actor
462+
// alive and yielding stale events into the next session until the next
463+
// stop(). The Task constructor is synchronous (its awaits run later), so
464+
// it's safe to build under the lock; nothing in the task body touches
465+
// linkLock, so there's no re-entrancy.
458466
linkLock.lock()
459467
let linkId = nextLinkId
460468
nextLinkId += 1
461469
links[linkId] = link
462-
linkLock.unlock()
463-
464-
// 5. Bridge inbound link packets + state transitions onto the event stream.
465-
// stateUpdates yields the terminal .closed(reason) itself, so a separate
466-
// close callback would only duplicate it.
467-
let cont = eventContinuation
468-
await link.setPacketCallback { data, _ in
469-
cont.yield(.linkPacket(linkId: linkId, data: data, t: Date()))
470-
}
471-
let stateTask = Task {
470+
linkStateTasks[linkId] = Task {
472471
for await st in await link.stateUpdates {
473472
let (s, r) = Self.linkStateString(st)
474473
cont.yield(.linkState(linkId: linkId, state: s, reason: r, inbound: false, t: Date()))
475474
}
476475
}
477-
linkLock.lock()
478-
linkStateTasks[linkId] = stateTask
479476
linkLock.unlock()
477+
478+
// 5. Bridge inbound link packets onto the event stream. stateUpdates
479+
// (drained above) yields the terminal .closed(reason) itself, so a
480+
// separate close callback would only duplicate it.
481+
await link.setPacketCallback { data, _ in
482+
cont.yield(.linkPacket(linkId: linkId, data: data, t: Date()))
483+
}
480484
return (true, linkId, "")
481485
}
482486

0 commit comments

Comments
 (0)