Skip to content

Commit 7c4749e

Browse files
tmmampagent
andauthored
test(zero-swift): fuzz the client with chaos against zero-cache (#111)
* test(zero-swift): fuzz the client with chaos against zero-cache ChaosFuzzTests takes two clients through 40 random steps behind a ChaosProxy: mutations, cuts, outages, stalls, cuts partway through a message, Postgres writes behind their back, and relaunches on the same store. Then the network heals, and the run passes when Postgres holds each mutation once, each client's last mutation ID counts its mutations, and both clients see what Postgres holds. The E2E jobs run 2 seeds; the nightly workflow runs 150 new ones. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): the walking skeleton ignores other suites' open issues It syncs every open issue and expected exactly the fixture's. The chaos fuzz, running beside it, keeps dozens of its own open for each seed, and one run saw them. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * test(zero-swift): run E2ETests only in the end-to-end jobs The main test step ran them a second time beside the conformance suites, whose long synchronous loops (SweepIVMTests.sweepFuzz, the IVM vectors) hold every thread of the cooperative pool for tens of seconds on a 4-core runner. Nothing else on the pool runs meanwhile: a watchdog saw it unresponsive for 23 s and then over 30 s, and gdb showed all four pool threads in those tests. clientReconnectsAfterTheServerGoesAwayMidFrame spent its 15 s waiting for its connect to be scheduled. With E2ETests alone on 4 CPUs it passes; beside the conformance suites it failed. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 62886b4 commit 7c4749e

6 files changed

Lines changed: 321 additions & 4 deletions

File tree

‎.github/workflows/swift-macos.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ jobs:
4040
with:
4141
persist-credentials: false
4242
- run: swift --version
43+
# Not E2ETests, which the end-to-end job runs: see swift.yml.
4344
- name: Build and test (macOS)
44-
run: swift test
45+
run: swift test --skip E2ETests
4546
# On its own: see swift.yml.
4647
- name: ZeroVirtual fuzz
4748
run: ZERO_FUZZ_SEEDS=200 swift test --skip-build --filter FuzzTests

‎.github/workflows/swift-nightly.yml‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,64 @@ jobs:
108108
run: |
109109
echo "Seeds $ZERO_FUZZ_FIRST_SEED to $((ZERO_FUZZ_FIRST_SEED + ZERO_FUZZ_SEEDS - 1))"
110110
swift test --skip-build --filter FuzzTests
111+
112+
# Random cuts, stalls, outages, Postgres writes and relaunches against
113+
# zero-cache, two clients at a time (ChaosFuzzTests).
114+
chaos-fuzz:
115+
name: Chaos fuzz (Linux)
116+
runs-on: ubuntu-24.04
117+
timeout-minutes: 60
118+
steps:
119+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
120+
with:
121+
persist-credentials: false
122+
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
123+
with:
124+
version: 11.11.0
125+
# zero-cache runs from source, which needs Node 24 (`using`).
126+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
127+
with:
128+
node-version: 24.x
129+
package-manager-cache: false
130+
- run: pnpm install --frozen-lockfile
131+
- name: Start Postgres
132+
run: |
133+
sudo systemctl start postgresql.service
134+
sudo -u postgres psql -q -c "ALTER SYSTEM SET wal_level = logical" \
135+
-c "ALTER USER postgres PASSWORD 'postgres'"
136+
sudo systemctl restart postgresql.service
137+
until pg_isready -q -h 127.0.0.1; do sleep 1; done
138+
- name: Install Swift
139+
run: |
140+
curl -sSfL -o "$RUNNER_TEMP/swift.tar.gz" \
141+
https://download.swift.org/swift-6.2.4-release/ubuntu2404/swift-6.2.4-RELEASE/swift-6.2.4-RELEASE-ubuntu24.04.tar.gz
142+
mkdir -p "$RUNNER_TEMP/swift"
143+
tar -xzf "$RUNNER_TEMP/swift.tar.gz" -C "$RUNNER_TEMP/swift" --strip-components=1
144+
echo "$RUNNER_TEMP/swift/usr/bin" >> "$GITHUB_PATH"
145+
- name: Install system libraries
146+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libsqlite3-dev
147+
- name: Build
148+
working-directory: swift
149+
run: swift build --build-tests
150+
- name: Start zero-cache
151+
env:
152+
ZERO_E2E_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres
153+
run: |
154+
node packages/zero-conformance/src/e2e/server.ts > "$RUNNER_TEMP/server.log" 2>&1 &
155+
for _ in $(seq 1 120); do
156+
grep -q '^ready ' "$RUNNER_TEMP/server.log" && exit 0
157+
sleep 1
158+
done
159+
cat "$RUNNER_TEMP/server.log"
160+
exit 1
161+
# About 10 s a seed.
162+
- name: Fuzz
163+
working-directory: swift
164+
env:
165+
ZERO_E2E_URL: ws://127.0.0.1:4848
166+
ZERO_CHAOS_SEEDS: '150'
167+
ZERO_CHAOS_FIRST_SEED: ${{ inputs.first-seed || format('{0}000', github.run_number) }}
168+
run: swift test --skip-build --filter ChaosFuzzTests
169+
- name: Server logs
170+
if: failure()
171+
run: cat "$RUNNER_TEMP/server.log"

‎.github/workflows/swift.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,12 @@ jobs:
9595
run: swift format lint --strict --recursive Sources Tests Package.swift
9696
- name: Build
9797
run: swift build --build-tests
98+
# Not E2ETests: the end-to-end job runs them, apart from the
99+
# conformance suites, whose long synchronous loops hold every thread
100+
# of the cooperative pool for tens of seconds on a 4-core runner and
101+
# starve their timing-sensitive network tests.
98102
- name: Test
99-
run: swift test --skip-build
103+
run: swift test --skip-build --skip E2ETests
100104
# On its own: it holds the main actor long enough to starve the
101105
# timing-sensitive suites if it ran beside them.
102106
- name: ZeroVirtual fuzz

‎swift/DESIGN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ Vector storage and fuzzing:
266266
- Seeded random query and push fuzz cases are generated in CI from fixed seeds (not checked in) and run against Swift.
267267
- `swift-nightly.yml` generates the seeded corpora again each night on new seeds, from the run number, at 5 to 10 times the PR checks' cases, and runs the Swift suites on them. A mismatch names its case and the seeds the job printed; replaying it is setting the same environment variables. A live differential harness (JS drives a Swift CLI over stdio and shrinks failures) is deferred to v1.x.
268268
- ZeroVirtual has no JS oracle for scrolling, so `FuzzTests` checks invariants instead: after each random step settles, a row is on screen, the loaded rows are a run of the list, and rows on screen hold still through changes above them. PRs run 200 seeds, in a CI step of their own; `swift-nightly.yml` runs 10,000 new ones each night.
269+
- `ChaosFuzzTests` takes two clients through 40 random steps against zero-cache behind a `ChaosProxy`: mutations, cuts, outages, stalls, cuts partway through a message, Postgres writes behind their back, and relaunches. Then the network heals and the oracle is convergence: Postgres holds each mutation once, each client's last mutation ID counts its mutations, and both clients see what Postgres holds. The E2E jobs run 2 seeds; the nightly runs 150 new ones.
269270

270271
### JS bugs found, not yet reported upstream
271272

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import Foundation
2+
import Testing
3+
import Zero
4+
5+
/// Random chaos against the real zero-cache. Each seed runs two clients, on
6+
/// stores of their own, through a `ChaosProxy`, and takes 40 random steps:
7+
/// mutations, connections cut, refused, stalled, or cut partway through
8+
/// what the server sends, writes to Postgres behind the clients' back, and
9+
/// relaunches on the same store. Then the network heals, and the oracle is
10+
/// convergence: Postgres holds every mutation once, each client's last
11+
/// mutation ID counts all of its mutations, and both clients see what
12+
/// Postgres holds.
13+
///
14+
/// PRs run `ZERO_CHAOS_SEEDS` seeds (2 by default) from
15+
/// `ZERO_CHAOS_FIRST_SEED` (0); the nightly workflow runs more. To replay
16+
/// one: `ZERO_CHAOS_SEED=<seed> ZERO_E2E_URL=... swift test --filter ChaosFuzzTests`.
17+
@Suite(.enabled(if: E2E.url != nil), .serialized, .timeLimit(.minutes(60)))
18+
@MainActor
19+
struct ChaosFuzzTests {
20+
@Test func randomSeeds() async throws {
21+
let env = ProcessInfo.processInfo.environment
22+
let seeds: Range<UInt64>
23+
if let only = env["ZERO_CHAOS_SEED"].flatMap(UInt64.init) {
24+
seeds = only..<only + 1
25+
} else {
26+
let first = env["ZERO_CHAOS_FIRST_SEED"].flatMap(UInt64.init) ?? 0
27+
seeds = first..<first + (env["ZERO_CHAOS_SEEDS"].flatMap(UInt64.init) ?? 2)
28+
}
29+
var failed = 0
30+
for seed in seeds {
31+
let start = ContinuousClock.now
32+
let failure = try await ChaosRun(seed: seed).run()
33+
print(
34+
"[chaos fuzz] seed \(seed): \(failure ?? "converged") in \(ContinuousClock.now - start)")
35+
guard let failure else { continue }
36+
Issue.record("seed \(seed): \(failure)")
37+
failed += 1
38+
if failed == 3 { return }
39+
}
40+
}
41+
}
42+
43+
/// SplitMix64: the same steps for a seed on every platform.
44+
struct ChaosRandom: RandomNumberGenerator {
45+
var state: UInt64
46+
mutating func next() -> UInt64 {
47+
state &+= 0x9E37_79B9_7F4A_7C15
48+
var z = state
49+
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
50+
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
51+
return z ^ (z >> 31)
52+
}
53+
}
54+
55+
/// One seed's clients, proxy, and model of what Postgres should end up with.
56+
@MainActor
57+
final class ChaosRun {
58+
/// A client and the relaunches on its store.
59+
@MainActor final class Lineage {
60+
let storageKey: String
61+
var zero: Zero<Void>
62+
var open: LiveQuery<[TestIssue]>
63+
/// What the client now running created and hasn't closed: only it may
64+
/// close them, since its mutations apply in order.
65+
var closable: [String] = []
66+
67+
init(zero: Zero<Void>, storageKey: String) throws {
68+
self.zero = zero
69+
self.storageKey = storageKey
70+
open = try zero.liveQuery(E2EApp.open())
71+
}
72+
}
73+
74+
let seed: UInt64
75+
var rng: ChaosRandom
76+
let chaos = Chaos()
77+
var proxy: ChaosProxy!
78+
var lineages: [Lineage] = []
79+
/// The open issues Postgres should end with.
80+
var expectedOpen: Set<String> = []
81+
var created = 0
82+
/// Mutations made by each client ID.
83+
var mutations: [String: Int] = [:]
84+
/// Issues written straight to Postgres and still open.
85+
var written: [String] = []
86+
var steps: [String] = []
87+
88+
init(seed: UInt64) {
89+
self.seed = seed
90+
rng = ChaosRandom(state: seed)
91+
}
92+
93+
func makeZero(_ storageKey: String) throws -> Zero<Void> {
94+
try chaos.makeZero(cacheURL: proxy.cacheURL, storageKey: storageKey) {
95+
$0.pingTimeoutMs = 2000
96+
}
97+
}
98+
99+
/// The problems at the end; nil when everything converged.
100+
func run() async throws -> String? {
101+
defer { chaos.cleanUp() }
102+
proxy = try await ChaosProxy(upstream: E2E.url!)
103+
for _ in 0..<2 {
104+
let key = UUID().uuidString
105+
lineages.append(try Lineage(zero: try makeZero(key), storageKey: key))
106+
}
107+
defer {
108+
for l in lineages { l.zero.close() }
109+
}
110+
for l in lineages { try await chaos.connected(l.zero) }
111+
for n in 0..<40 {
112+
steps.append("\(n): " + (try await step(n)))
113+
try await Task.sleep(for: .milliseconds(Int.random(in: 0...100, using: &rng)))
114+
}
115+
await proxy.set {
116+
$0 = ChaosProxy.Knobs()
117+
}
118+
let problems = try await converge()
119+
await proxy.shutdown()
120+
guard !problems.isEmpty else { return nil }
121+
return problems.joined(separator: "; ")
122+
+ " (last steps: \(steps.suffix(8).joined(separator: " | ")))"
123+
}
124+
125+
func mutate(_ l: Lineage, _ request: MutateRequest<Void>) {
126+
_ = l.zero.mutate(request)
127+
mutations[l.zero.clientID, default: 0] += 1
128+
}
129+
130+
func step(_ n: Int) async throws -> String {
131+
let i = Int.random(in: 0..<lineages.count, using: &rng)
132+
let l = lineages[i]
133+
switch Int.random(in: 0..<100, using: &rng) {
134+
case 0..<35:
135+
let id = "\(chaos.prefix)c\(n)"
136+
mutate(l, E2EApp.create(.init(id: id, title: "Chaos \(n)", ownerId: nil)))
137+
l.closable.append(id)
138+
expectedOpen.insert(id)
139+
created += 1
140+
return "client \(i) creates \(id)"
141+
case 35..<45:
142+
guard !l.closable.isEmpty else { return "client \(i) has nothing to close" }
143+
let id = l.closable.remove(at: Int.random(in: 0..<l.closable.count, using: &rng))
144+
mutate(l, E2EApp.close(id))
145+
expectedOpen.remove(id)
146+
return "client \(i) closes \(id)"
147+
case 45..<53:
148+
await proxy.cutAll()
149+
return "cut every connection"
150+
case 53..<58:
151+
let ms = Int.random(in: 100...1500, using: &rng)
152+
await proxy.set { $0.accept = .refuse }
153+
await proxy.cutAll()
154+
try await Task.sleep(for: .milliseconds(ms))
155+
await proxy.set { $0.accept = .pass }
156+
return "down for \(ms) ms"
157+
case 58..<63:
158+
let ms = Int.random(in: 200...3000, using: &rng)
159+
await proxy.set { $0.downstreamRate = 0 }
160+
try await Task.sleep(for: .milliseconds(ms))
161+
await proxy.set { $0.downstreamRate = nil }
162+
return "server stalled for \(ms) ms"
163+
case 63..<68:
164+
let bytes = Int.random(in: 500...50_000, using: &rng)
165+
let ms = Int.random(in: 200...1500, using: &rng)
166+
await proxy.set { $0.cutAfterDownstreamBytes = bytes }
167+
try await Task.sleep(for: .milliseconds(ms))
168+
await proxy.set { $0.cutAfterDownstreamBytes = nil }
169+
return "cuts after \(bytes) bytes for \(ms) ms"
170+
case 68..<76:
171+
let id = "\(chaos.prefix)pg\(n)"
172+
try Postgres.query(
173+
"""
174+
INSERT INTO issues (id, title, description, closed, owner_id, "createdAt")
175+
VALUES ('\(id)', 'Written \(n)', '', false, NULL, 2000)
176+
""")
177+
written.append(id)
178+
expectedOpen.insert(id)
179+
created += 1
180+
return "Postgres inserts \(id)"
181+
case 76..<82:
182+
guard !written.isEmpty else { return "Postgres has nothing to close" }
183+
let id = written.remove(at: Int.random(in: 0..<written.count, using: &rng))
184+
try Postgres.query("UPDATE issues SET closed = true WHERE id = '\(id)'")
185+
expectedOpen.remove(id)
186+
return "Postgres closes \(id)"
187+
case 82..<90:
188+
l.zero.close()
189+
l.zero = try makeZero(l.storageKey)
190+
l.open = try l.zero.liveQuery(E2EApp.open())
191+
l.closable = []
192+
return "client \(i) relaunches"
193+
default:
194+
let ms = Int.random(in: 0...500, using: &rng)
195+
try await Task.sleep(for: .milliseconds(ms))
196+
return "wait \(ms) ms"
197+
}
198+
}
199+
200+
func ids(_ l: Lineage) -> Set<String> {
201+
Set(l.open.data.map(\.id).filter { $0.hasPrefix(chaos.prefix) })
202+
}
203+
204+
func short(_ got: Set<String>, _ want: Set<String>) -> String {
205+
let missing = want.subtracting(got).sorted()
206+
let extra = got.subtracting(want).sorted()
207+
return "missing \(missing.prefix(5)), extra \(extra.prefix(5))"
208+
}
209+
210+
/// Waits for the clients and Postgres to settle, then compares them with
211+
/// the model.
212+
func converge() async throws -> [String] {
213+
var problems: [String] = []
214+
// Every mutation pushed first: until then, a create closed in the same
215+
// run leaves the open issues right but is missing from Postgres.
216+
for (clientID, count) in mutations.sorted(by: { $0.key < $1.key }) {
217+
let ok = try await within(.seconds(30)) { try Postgres.lastMutationID(clientID) == count }
218+
if !ok {
219+
let last = try Postgres.lastMutationID(clientID).map(String.init) ?? "none"
220+
problems.append(
221+
"client \(clientID) made \(count) mutations; its last mutation ID is \(last)")
222+
}
223+
}
224+
let inPostgres = try await within(.seconds(30)) {
225+
try Postgres.openIssueIDs(self.chaos.prefix) == self.expectedOpen
226+
&& Postgres.issues(self.chaos.prefix).count == self.created
227+
}
228+
if !inPostgres {
229+
let open = try Postgres.openIssueIDs(chaos.prefix)
230+
if open != expectedOpen {
231+
problems.append("Postgres's open issues: \(short(open, expectedOpen))")
232+
}
233+
let all = try Postgres.issues(chaos.prefix).count
234+
if all != created { problems.append("\(all) issues in Postgres, \(created) created") }
235+
}
236+
for (i, l) in lineages.enumerated() {
237+
let ok = try await within(.seconds(60)) {
238+
l.open.resultType == .complete && self.ids(l) == self.expectedOpen
239+
}
240+
if !ok {
241+
problems.append(
242+
"client \(i) (\(l.zero.connection.state.status)) sees \(short(ids(l), expectedOpen))")
243+
}
244+
}
245+
return problems
246+
}
247+
}

‎swift/Tests/E2ETests/WalkingSkeletonTests.swift‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,11 @@ struct WalkingSkeletonTests {
133133
for (table, rows) in rows.sorted(by: { $0.key < $1.key }) {
134134
for row in rows { print("[e2e] \(table) \(JSONSerializer.stringify(.object(row)))") }
135135
}
136-
// The fixture's open issues, their comments, and their owners.
137-
#expect(Set(issues.map { $0["id"]! }) == ["i1", "i3", "i4"])
136+
// The fixture's open issues, their comments, and their owners. Suites
137+
// running beside this one write open issues of their own, under
138+
// `Chaos.prefix`.
139+
let fixtureIssues = issues.filter { $0["id"]?.stringValue?.hasPrefix("chaos-") != true }
140+
#expect(Set(fixtureIssues.map { $0["id"]! }) == ["i1", "i3", "i4"])
138141
#expect(Set(comments.map { $0["id"]! }) == ["c1", "c2", "c3"])
139142
#expect(Set(users.map { $0["id"]! }) == ["u1"])
140143
#expect(issues.first { $0["id"] == "i3" }?["title"] == "Emoji 😀 in titles")

0 commit comments

Comments
 (0)