Skip to content

Commit 0d2fa09

Browse files
ampagenttmm
andcommitted
fix(zero-swift): list the group's running clients in activeClients
Two Zero instances of one client group in one process sent only themselves in initConnection's activeClients, so zero-cache deleted the other client and its queries while it ran. The instances on one ReplicaDatabase now track each other, as ActiveClientsManager does in JS, and when one closes the others of its group send deleteClients for it once. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev>
1 parent 57d8ef6 commit 0d2fa09

4 files changed

Lines changed: 82 additions & 2 deletions

File tree

‎.changeset/active-clients.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-swift': patch
3+
---
4+
5+
Two `Zero` instances of one client group in one process (the same user, `storageKey`, and storage, such as two windows) no longer drop each other's queries: `initConnection` lists every running client of the group in `activeClients`, as in JS, so zero-cache doesn't delete the other one. When one closes, the others send `deleteClients` for it once.

‎swift/Sources/Zero/Client/Engine.swift‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ final class Engine {
9292
isolated deinit {
9393
loop.stop()
9494
for t in lifecycleTimers { t.cancel() }
95-
if !repClosed { database.release() }
95+
if !repClosed {
96+
database.clientStopped(clientID)
97+
database.release()
98+
}
9699
}
97100
private let socketFactory: (any SocketFactory)?
98101
private let socketOrigin: String?
@@ -236,6 +239,14 @@ final class Engine {
236239
store.advance(to: store.snapshot, main: main, pending: replayed, touched: touched))
237240
}
238241
database.onWriteFailure = { [weak self] failure in self?.storageFailed(failure) }
242+
// Another client of the group stopped (the `onDelete` of
243+
// `makeActiveClientsManager`): tell the server, if connected.
244+
database.clientStarted(clientID, clientGroupID: clientGroupID) { [weak self] id in
245+
guard let self else { return }
246+
self.loop.dispatch {
247+
self.sendIfConnected(.deleteClients(DeleteClientsBody(clientIDs: [id])))
248+
}
249+
}
239250
if let openFailure {
240251
log.warn(
241252
"Local store storage failed (\(openFailure.kind.rawValue)) opening \(name); "
@@ -673,7 +684,10 @@ final class Engine {
673684
clientSchema: connectCookie.isNull ? clientSchema : nil, deleted: deleted,
674685
userPushURL: options.mutateURL, userPushHeaders: options.mutateHeaders,
675686
userQueryURL: options.queryURL, userQueryHeaders: options.queryHeaders,
676-
activeClients: [clientID])
687+
activeClients: [clientID]
688+
+ database.activeClients(ofGroup: clientGroupID).filter {
689+
$0 != clientID
690+
})
677691
let authToken = auth.flatMap { $0.isEmpty ? nil : $0 }
678692
var secProtocol = SecProtocols.encode(initConnection: initConnection, authToken: authToken)
679693
if secProtocol.utf8.count > options.maxHeaderLength {
@@ -1012,6 +1026,7 @@ final class Engine {
10121026
pushTimer = nil
10131027
for t in lifecycleTimers { t.cancel() }
10141028
lifecycleTimers = []
1029+
database.clientStopped(clientID)
10151030
database.release()
10161031
connectionManager.closed()
10171032
}

‎swift/Sources/Zero/Storage/ReplicaDatabase.swift‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ final class ReplicaDatabase {
162162
/// Hears about the first write failure.
163163
var onWriteFailure: ((StorageFailure) -> Void)?
164164

165+
/// The clients running on this database in this process, in the order
166+
/// they started (`ActiveClientsManager`, which JS keeps across tabs
167+
/// with Web Locks). Each hears when another of its group stops.
168+
private var running: [(clientID: String, clientGroupID: String, onDelete: (String) -> Void)] =
169+
[]
170+
165171
private static var open: [String: ReplicaDatabase] = [:]
166172
/// The profile ID of memory databases, one per process as in JS.
167173
private static var memoryProfileID: String?
@@ -233,6 +239,29 @@ final class ReplicaDatabase {
233239
sqlite.close()
234240
}
235241

242+
/// Records that `clientID` runs until `clientStopped`. `onDelete` gets
243+
/// the clients of its group that stop before it.
244+
func clientStarted(
245+
_ clientID: String, clientGroupID: String, onDelete: @escaping (String) -> Void
246+
) {
247+
running.append((clientID, clientGroupID, onDelete))
248+
}
249+
250+
/// Tells the other running clients of its group, once, that `clientID`
251+
/// stopped (`ActiveClientsManager.onDelete`).
252+
func clientStopped(_ clientID: String) {
253+
guard let i = running.firstIndex(where: { $0.clientID == clientID }) else { return }
254+
let stopped = running.remove(at: i)
255+
for other in running where other.clientGroupID == stopped.clientGroupID {
256+
other.onDelete(clientID)
257+
}
258+
}
259+
260+
/// The running clients of group `id` (`ActiveClientsManager.activeClients`).
261+
func activeClients(ofGroup id: String) -> [String] {
262+
running.filter { $0.clientGroupID == id }.map(\.clientID)
263+
}
264+
236265
/// Waits for the writes queued so far.
237266
func flush() { sqlite?.flush() }
238267

‎swift/Tests/E2ETests/ClientE2ETests.swift‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,35 @@ struct ClientE2ETests {
103103
#expect(Set(["i1", "i3", "i4"]).isSubset(of: cached.map(\.id)))
104104
#expect(!cached.contains { $0.id == id })
105105
}
106+
107+
/// Two clients of one client group (two windows) both keep their
108+
/// queries: each lists the other in `activeClients`, so zero-cache
109+
/// doesn't delete it.
110+
@Test func twoClientsOfAGroupBothKeepTheirQueries() async throws {
111+
defer { try? FileManager.default.removeItem(atPath: directory) }
112+
let key = UUID().uuidString
113+
let first = try makeZero(storageKey: key)
114+
defer { first.close() }
115+
// Without a TTL, a query of a client the server deletes goes at once.
116+
let open = try first.liveQuery(E2EApp.open(), ttl: .none)
117+
try await eventually("the first client's issues") { open.resultType == .complete }
118+
119+
let second = try makeZero(storageKey: key)
120+
defer { second.close() }
121+
#expect(second.clientGroupID == first.clientGroupID)
122+
let byID = try second.liveQuery(E2EApp.byID("i1"))
123+
try await eventually("the second client's issue") { byID.resultType == .complete }
124+
125+
// The first client's query still syncs a row only it asks for.
126+
let id = "swift-\(UUID().uuidString)"
127+
let created = second.mutate(E2EApp.create(.init(id: id, title: "Two windows", ownerId: nil)))
128+
#expect(try await created.server.value == .success(data: nil))
129+
try await eventually("the first client to see the new issue") {
130+
open.data.contains { $0.id == id }
131+
}
132+
#expect(open.resultType == .complete)
133+
#expect(byID.data?.id == "i1")
134+
let closed = second.mutate(E2EApp.close(id))
135+
#expect(try await closed.server.value == .success(data: nil))
136+
}
106137
}

0 commit comments

Comments
 (0)