Skip to content

Commit f7e8a23

Browse files
authored
fix(zero-swift): harden the client against crashes, data loss, and JS divergences (#38)
Amp-Thread-ID: https://ampcode.com/threads/T-01a17664-8fd0-76be-8fd5-75ac6e7aa0d8
1 parent 26dfa88 commit f7e8a23

68 files changed

Lines changed: 9368 additions & 335 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/harden-client.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+
A mutate from inside a mutator runs after it, as in JS, instead of sharing its mutation ID and dropping it from the push. A poke's `desiredQueriesPatches` apply in the server's order. Closing a connecting socket aborts its handshake, so a timed-out attempt no longer leaks a socket. App errors drop falsy `details`, as JS does.

‎.changeset/harden-queries.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+
A flipped `whereExists` over a subquery with a `limit` no longer crashes the app: `materialize` and `liveQuery` throw "Partitioned take does not allow unpartitioned fetches", and `run` rejects with it, as in JS. View listeners are notified as in JS when one of them changes the client: a `mutate` from a listener runs once the notification is done, so listeners after it no longer end on stale rows and other views see every change; a listener removed during a notification is skipped, one added is called in the same pass, and destroying a view from its own listener still lets its other listeners hear that change. A change a view can't take where JS throws, such as two rows for a `one` relationship or a new row reaching a flipped `whereExists` over a `limit`, no longer crashes the app either: as in JS, the poke fails and the connection goes to the `error` state, or the mutation's `client` outcome is the error, and later mutations, `materialize`, and `run` fail with it.

‎.changeset/harden-storage.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+
Storage hardening. Collecting old databases no longer deletes one a client opens while the collection runs, files of a newer layout or format, or files that aren't Zero databases. A client of a group that is behind another no longer writes an older snapshot over a newer one. `zero.delete()` and `dropAllDatabases` also drop the memory databases clients fell back to after their SQLite store failed. Stored data that doesn't decode now makes the client delete the database and start a new one, calling `onClientStateNotFound` if set, instead of running on memory at every launch. Writes kept because the database was busy are written by `flush()` and `close()`, or reported as a storage failure, instead of being lost. Dropping a database twice no longer deletes the new one a replaced client started.
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+
Bad server data, storage contents, and options no longer crash the app. A poke whose last mutation ID isn't a safe integer, or whose row update moves a row off its primary key, is rejected and the client disconnects; stored mutations with invalid IDs are dropped at launch. Huge or non-finite timer delays fire at once, as `setTimeout` does. A query built with a negative or out-of-range `limit`, a `limit` or `orderBy` in a junction relationship, or `start` or `related` inside `exists` throws JS's message when materialized, run, or preloaded. An `Int` column holding -2^63 fails to read instead of trapping, and `minPageSize` is clamped.

‎packages/zero-conformance/src/client/app.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ export const queries = defineQueries({
3434
zql.issue.where('id', args).related('owner').one(),
3535
),
3636
firstTwo: defineQuery(() => zql.issue.orderBy('title', 'asc').limit(2)),
37+
// A flipped EXISTS over a limited subquery: fetching it throws
38+
// `Partitioned take does not allow unpartitioned fetches`.
39+
// Hydrates, but adding an issue then fetches the limited flipped
40+
// subquery without a constraint, which throws from the push.
41+
withAuthoredComments: defineQuery(() =>
42+
zql.issue
43+
.whereExists('comments', q =>
44+
q.whereExists('author', a => a.limit(3), {flip: true}),
45+
)
46+
.orderBy('id', 'asc'),
47+
),
48+
// `oneComment` is a `one` relationship that several comments can match.
49+
withOneComment: defineQuery(() =>
50+
zql.issue.related('oneComment').orderBy('id', 'asc'),
51+
),
52+
withFirstComment: defineQuery(() =>
53+
zql.issue
54+
.whereExists('comments', q => q.orderBy('id', 'asc').limit(1), {
55+
flip: true,
56+
})
57+
.orderBy('id', 'asc'),
58+
),
3759
},
3860
users: {
3961
all: defineQuery(() => zql.user.orderBy('id', 'asc')),
@@ -118,8 +140,46 @@ const issue = {
118140
),
119141
};
120142

143+
type CRUDOp = {
144+
op: 'insert' | 'upsert' | 'update' | 'delete';
145+
table: string;
146+
value: JSONObject;
147+
};
148+
149+
const harden = {
150+
/**
151+
* Runs `ops` (CRUD operations on any table, values as given), then throws
152+
* `fail` if set, or reads the `read` tables back through the transaction
153+
* and returns them with the mutation ID, or returns `return`. With
154+
* `failOnRebase` it throws before writing when replayed.
155+
*/
156+
ops: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => {
157+
if (args['failOnRebase'] === true && tx.reason === 'rebase') {
158+
throw new ApplicationError('fails on rebase');
159+
}
160+
for (const o of (args['ops'] ?? []) as CRUDOp[]) {
161+
await tx.mutate[o.table][o.op](o.value);
162+
}
163+
if (typeof args['fail'] === 'string') {
164+
throw new ApplicationError(args['fail'], {details: args['details']});
165+
}
166+
const read = args['read'] as string[] | undefined;
167+
if (read) {
168+
const out: Record<string, unknown> = {};
169+
for (const t of read) {
170+
out[t] = await tx.run(
171+
(zql as unknown as Record<string, typeof zql.issue>)[t],
172+
);
173+
}
174+
return {mutationID: tx.mutationID, read: out} as ReadonlyJSONValue;
175+
}
176+
return args['return'];
177+
}),
178+
};
179+
121180
export const mutators = defineMutators({
122181
issue,
182+
harden,
123183
comment: {
124184
add: defineMutator(async ({tx, args}: {tx: Tx; args: JSONObject}) => {
125185
await tx.mutate.comment.insert({
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {test} from 'vitest';
2+
import {emitVectors} from '../vectors.ts';
3+
import {
4+
runScenario,
5+
START_TIME,
6+
type Scenario,
7+
type StepResult,
8+
} from './harness.ts';
9+
import {hardenConnectionScenarios} from './scenarios/harden-connection.ts';
10+
11+
type Case = Scenario & {expect: StepResult[]};
12+
13+
test('client hardening scenarios: connection', {timeout: 600_000}, async () => {
14+
const cases: Case[] = [];
15+
for (const scenario of hardenConnectionScenarios) {
16+
cases.push({...scenario, expect: await runScenario(scenario)});
17+
}
18+
emitVectors(
19+
'client-harden-connection',
20+
'client-scenarios',
21+
'Hardening scenarios for the connection and the sync protocol, run against the JS zero-client like vectors/client.json. See src/client/scenarios/harden-connection.ts.',
22+
cases,
23+
{startTime: START_TIME},
24+
{compact: true},
25+
);
26+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {test} from 'vitest';
2+
import {emitVectors} from '../vectors.ts';
3+
import {
4+
runScenario,
5+
START_TIME,
6+
type Scenario,
7+
type StepResult,
8+
} from './harness.ts';
9+
import {hardenMutationScenarios} from './scenarios/harden-mutations.ts';
10+
11+
type Case = Scenario & {expect: StepResult[]};
12+
13+
test(
14+
'client hardening scenarios for mutations',
15+
{timeout: 600_000},
16+
async () => {
17+
const cases: Case[] = [];
18+
for (const scenario of hardenMutationScenarios) {
19+
cases.push({...scenario, expect: await runScenario(scenario)});
20+
}
21+
emitVectors(
22+
'client-harden-mutations',
23+
'client-scenarios',
24+
'Hardening scenarios for mutations, run against the JS zero-client like client.json (see src/client/harness.ts and src/client/scenarios/harden-mutations.ts).',
25+
cases,
26+
{startTime: START_TIME},
27+
{compact: true},
28+
);
29+
},
30+
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Hardening client scenarios for views and queries, kept apart from
2+
// `client.json`. `swift/Tests/ConformanceTests/HardenQueryTests.swift`
3+
// replays them.
4+
import {test} from 'vitest';
5+
import {emitVectors} from '../vectors.ts';
6+
import {
7+
runScenario,
8+
START_TIME,
9+
type Scenario,
10+
type StepResult,
11+
} from './harness.ts';
12+
import {hardenQueryScenarios} from './scenarios/harden-queries.ts';
13+
14+
type Case = Scenario & {expect: StepResult[]};
15+
16+
test('harden query scenarios', {timeout: 600_000}, async () => {
17+
const cases: Case[] = [];
18+
for (const scenario of hardenQueryScenarios) {
19+
cases.push({...scenario, expect: await runScenario(scenario)});
20+
}
21+
emitVectors(
22+
'harden-queries',
23+
'client-scenarios',
24+
'Hardening client scenarios: re-entrant listeners, odd TTLs, query change throttling, pokes that leave views unchanged. See src/client/harness.ts.',
25+
cases,
26+
{startTime: START_TIME},
27+
{compact: true},
28+
);
29+
});

‎packages/zero-conformance/src/client/harness.ts‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export type Step =
8181
bytes?: number[];
8282
socket?: number;
8383
}
84+
/** A text frame of exactly `text`, which need not be JSON. */
85+
| {op: 'receiveText'; text: string; socket?: number}
86+
/** A binary frame of exactly `bytes`, the message type byte included. */
87+
| {op: 'receiveBinary'; bytes: number[]; socket?: number}
8488
/** The socket closes from the server or network side. */
8589
| {
8690
op: 'socketClose';
@@ -95,6 +99,15 @@ export type Step =
9599
* tab does (`document.visibilityState`).
96100
*/
97101
| {op: 'visibility'; state: 'visible' | 'hidden'}
102+
/**
103+
* Once `connection.state` next becomes `state`, runs `then` (a `connect`
104+
* or `close` step) synchronously from inside the state listener.
105+
*/
106+
| {
107+
op: 'onState';
108+
state: string;
109+
then: {op: 'connect'; auth?: string} | {op: 'close'};
110+
}
98111
/** `zero.connection.connect()`. */
99112
| {op: 'connect'; auth?: string}
100113
| {op: 'close'}
@@ -113,7 +126,22 @@ export type Step =
113126
advance?: number;
114127
options?: ScenarioOptions;
115128
mutators?: 'all' | 'issues';
116-
};
129+
}
130+
/**
131+
* Adds the listener `listener` to `view`. Its calls are reported in
132+
* `listeners`. Once it has been called more than `after` times (default
133+
* 1: the call `addListener` makes), its next call performs `actions`,
134+
* from inside the notification, once.
135+
*/
136+
| {
137+
op: 'listen';
138+
view: string;
139+
listener: string;
140+
after?: number;
141+
actions?: Step[];
142+
}
143+
/** Removes a listener added by `listen`. */
144+
| {op: 'unlisten'; listener: string};
117145

118146
type Args = ReadonlyJSONValue | undefined;
119147

@@ -172,6 +200,14 @@ export type StepResult = {
172200
preloads?: Record<string, {cached: Settled; complete: Settled}>;
173201
/** `onUpdateNeeded` and `onClientStateNotFound` calls. */
174202
events?: ReadonlyJSONValue[];
203+
/**
204+
* Listeners added by `listen`, in order: the data and result type of
205+
* each call during the step.
206+
*/
207+
listeners?: Record<
208+
string,
209+
{calls: {data: ReadonlyJSONValue; resultType: string}[]; threw?: string}
210+
>;
175211
};
176212

177213
class CloseEventPolyfill extends Event {
@@ -355,6 +391,14 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
355391
>();
356392
const sentCounts: number[] = [];
357393
const closedBefore: boolean[] = [];
394+
const listeners = new Map<
395+
string,
396+
{
397+
remove: () => void;
398+
calls: {data: ReadonlyJSONValue; resultType: string}[];
399+
threw?: string | undefined;
400+
}
401+
>();
358402

359403
const targetSocket = (index: number | undefined) => {
360404
const socket = index === undefined ? sockets.at(-1) : sockets[index];
@@ -474,6 +518,18 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
474518
);
475519
return;
476520
}
521+
case 'receiveText':
522+
targetSocket(step.socket).dispatchEvent(
523+
new MessageEvent('message', {data: step.text}),
524+
);
525+
return;
526+
case 'receiveBinary':
527+
targetSocket(step.socket).dispatchEvent(
528+
new MessageEvent('message', {
529+
data: Uint8Array.from(step.bytes).buffer,
530+
}),
531+
);
532+
return;
477533
case 'socketClose':
478534
targetSocket(step.socket).dispatchEvent(
479535
new CloseEvent('close', {
@@ -489,6 +545,21 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
489545
case 'visibility':
490546
document.setVisibility(step.state);
491547
return;
548+
case 'onState': {
549+
const {then} = step;
550+
const unsubscribe = z.connection.state.subscribe(state => {
551+
if (state.name !== step.state) return;
552+
unsubscribe();
553+
if (then.op === 'close') {
554+
void z.close();
555+
} else {
556+
void z.connection.connect(
557+
then.auth === undefined ? undefined : {auth: then.auth},
558+
);
559+
}
560+
});
561+
return;
562+
}
492563
case 'connect':
493564
void z.connection.connect(
494565
step.auth === undefined ? undefined : {auth: step.auth},
@@ -497,6 +568,36 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
497568
case 'close':
498569
void z.close();
499570
return;
571+
case 'listen': {
572+
const view = views.get(step.view)?.view;
573+
if (!view) throw new Error(`No view ${step.view}`);
574+
const after = step.after ?? 1;
575+
let pending = step.actions;
576+
const entry: {
577+
remove: () => void;
578+
calls: {data: ReadonlyJSONValue; resultType: string}[];
579+
threw?: string | undefined;
580+
} = {remove: () => {}, calls: []};
581+
listeners.set(step.listener, entry);
582+
let count = 0;
583+
entry.remove = view.addListener((data, resultType) => {
584+
entry.calls.push({data: json(data), resultType});
585+
if (++count > after && pending) {
586+
const actions = pending;
587+
pending = undefined;
588+
for (const action of actions) {
589+
// The synchronous part of each step runs inside the listener.
590+
perform(action).catch(e => {
591+
entry.threw ??= errorMessage(e);
592+
});
593+
}
594+
}
595+
});
596+
return;
597+
}
598+
case 'unlisten':
599+
listeners.get(step.listener)?.remove();
600+
return;
500601
case 'restart': {
501602
await z.close();
502603
await settle();
@@ -517,6 +618,7 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
517618
placeholders.set(clientGroupID, '$clientGroupID');
518619
ids.set('$clientID', clientID);
519620
views.clear();
621+
listeners.clear();
520622
mutations.clear();
521623
runs.clear();
522624
preloads.clear();
@@ -583,6 +685,15 @@ export async function runScenario(scenario: Scenario): Promise<StepResult[]> {
583685
if (events.length > 0) {
584686
result.events = events.splice(0);
585687
}
688+
if (listeners.size > 0) {
689+
result.listeners = Object.fromEntries(
690+
Array.from(listeners, ([k, v]) => [
691+
k,
692+
{calls: v.calls.splice(0), ...(v.threw ? {threw: v.threw} : {})},
693+
]),
694+
);
695+
for (const v of listeners.values()) v.threw = undefined;
696+
}
586697
results.push(result as StepResult);
587698
}
588699
} finally {

0 commit comments

Comments
 (0)