Skip to content

Commit 99a9c9c

Browse files
ampagenttmm
andcommitted
fix(zero-swift): list the group's running clients in activeClients
Two Zero instances in one client group, in one process, list each other in initConnection's activeClients, and the survivor sends deleteClients for one that closes, as JS's ActiveClientsManager does. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev>
1 parent 48ca692 commit 99a9c9c

5 files changed

Lines changed: 160 additions & 1 deletion

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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ final class Engine {
9494
for t in lifecycleTimers { t.cancel() }
9595
if !repClosed {
9696
database.removeWriteFailureListener(clientID)
97+
database.clientStopped(clientID)
9798
database.release()
9899
}
99100
}
@@ -253,6 +254,14 @@ final class Engine {
253254
database.addWriteFailureListener(clientID) { [weak self] failure in
254255
self?.storageFailed(failure)
255256
}
257+
// Another client of the group stopped (the `onDelete` of
258+
// `makeActiveClientsManager`): tell the server, if connected.
259+
database.clientStarted(clientID, clientGroupID: clientGroupID) { [weak self] id in
260+
guard let self else { return }
261+
self.loop.dispatch {
262+
self.sendIfConnected(.deleteClients(DeleteClientsBody(clientIDs: [id])))
263+
}
264+
}
256265
if let openFailure {
257266
log.warn(
258267
"Local store storage failed (\(openFailure.kind.rawValue)) opening \(name); "
@@ -690,7 +699,10 @@ final class Engine {
690699
clientSchema: connectCookie.isNull ? clientSchema : nil, deleted: deleted,
691700
userPushURL: options.mutateURL, userPushHeaders: options.mutateHeaders,
692701
userQueryURL: options.queryURL, userQueryHeaders: options.queryHeaders,
693-
activeClients: [clientID])
702+
activeClients: [clientID]
703+
+ database.activeClients(ofGroup: clientGroupID).filter {
704+
$0 != clientID
705+
})
694706
let authToken = auth.flatMap { $0.isEmpty ? nil : $0 }
695707
var secProtocol = SecProtocols.encode(initConnection: initConnection, authToken: authToken)
696708
if secProtocol.utf8.count > options.maxHeaderLength {
@@ -1030,6 +1042,7 @@ final class Engine {
10301042
for t in lifecycleTimers { t.cancel() }
10311043
lifecycleTimers = []
10321044
database.removeWriteFailureListener(clientID)
1045+
database.clientStopped(clientID)
10331046
database.release()
10341047
connectionManager.closed()
10351048
}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ final class ReplicaDatabase {
163163
private var writeFailureListeners: [(clientID: String, listener: (StorageFailure) -> Void)] =
164164
[]
165165

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

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

‎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
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Foundation
2+
import Testing
3+
import ZeroTesting
4+
5+
@testable import Zero
6+
7+
/// Two clients of one client group in one process, checked against the JS
8+
/// client (`packages/zero-client` active clients).
9+
@Suite(.serialized) @MainActor struct SweepActiveClientsTests {
10+
static let create = defineMutator("issue.create") { (tx, id: String) in
11+
try tx.mutate.issue.insert([
12+
"id": .string(id), "title": "", "description": "", "closed": false, "ownerId": .null,
13+
"createdAt": 1,
14+
])
15+
}
16+
17+
func makeZero(
18+
storageKey: String, sockets: MockSocketFactory, scheduler: ManualScheduler
19+
) throws -> Zero<Void> {
20+
var options = ZeroOptions(
21+
cacheURL: "http://zero.test", userID: "u", storageKey: storageKey, storage: .memory)
22+
options.logSink = RecordingLogSink()
23+
return try Zero(
24+
schema: .test, mutators: [Self.create], options: options, scheduler: scheduler,
25+
socketFactory: sockets)
26+
}
27+
28+
// MARK: - Two clients in one client group
29+
30+
/// Opens the newest socket and acknowledges it.
31+
func connect(_ sockets: MockSocketFactory, _ scheduler: ManualScheduler) throws -> MockSocket {
32+
scheduler.runDueTimers()
33+
let socket = try #require(sockets.sockets.last)
34+
socket.receive(text: #"["connected",{"wsid":"w"}]"#)
35+
scheduler.runDueTimers()
36+
return socket
37+
}
38+
39+
/// JS lists every client of the group running in the process
40+
/// (`ActiveClientsManager`; without `navigator.locks` its fallback lock
41+
/// manager is process-wide). zero-cache deletes the clients of the group
42+
/// that are not in `activeClients` (`view-syncer.ts`), so listing only
43+
/// itself makes the server delete the other client's queries while it
44+
/// runs.
45+
@Test func initConnectionListsTheOtherActiveClientOfTheGroup() throws {
46+
let scheduler = ManualScheduler(now: 1_700_000_000_000)
47+
let sockets = MockSocketFactory()
48+
let key = randomID()
49+
let first = try makeZero(storageKey: key, sockets: sockets, scheduler: scheduler)
50+
_ = try connect(sockets, scheduler)
51+
let second = try makeZero(storageKey: key, sockets: sockets, scheduler: scheduler)
52+
#expect(second.clientGroupID == first.clientGroupID)
53+
let socket = try connect(sockets, scheduler)
54+
let initConnection = try SecProtocols.decode(#require(socket.secProtocol))[
55+
"initConnectionMessage"]?[1]
56+
let active = Set(initConnection?["activeClients"]?.arrayValue?.compactMap(\.stringValue) ?? [])
57+
#expect(active == [first.clientID, second.clientID])
58+
first.close()
59+
second.close()
60+
}
61+
62+
/// JS: "We should send a deleteClient when a Zero instance is closed"
63+
/// (`zero.test.ts`): the other client of the group sends `deleteClients`
64+
/// for the closed one, once.
65+
@Test func closingAClientMakesTheOtherOfTheGroupDeleteIt() throws {
66+
let scheduler = ManualScheduler(now: 1_700_000_000_000)
67+
let sockets = MockSocketFactory()
68+
let key = randomID()
69+
let first = try makeZero(storageKey: key, sockets: sockets, scheduler: scheduler)
70+
_ = try connect(sockets, scheduler)
71+
let second = try makeZero(storageKey: key, sockets: sockets, scheduler: scheduler)
72+
let socket = try connect(sockets, scheduler)
73+
#expect(second.clientGroupID == first.clientGroupID)
74+
75+
first.close()
76+
scheduler.runDueTimers()
77+
let deletes = socket.messages.filter { $0.hasPrefix(#"["deleteClients""#) }
78+
#expect(deletes == [#"["deleteClients",{"clientIDs":["\#(first.clientID)"]}]"#])
79+
second.close()
80+
}
81+
}

0 commit comments

Comments
 (0)