statewire 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/client/connection.d.ts +23 -19
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +143 -103
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +30 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +222 -260
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +34 -39
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +380 -373
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +1 -2
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -5
- package/dist/client/timing.js.map +1 -1
- package/dist/host/draft.d.ts +2 -5
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +5 -8
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +23 -32
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts +3 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +484 -424
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +4 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +10 -10
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/statewire.d.ts +29 -24
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +8 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.d.ts +1 -1
- package/dist/transports/fake-sse.d.ts.map +1 -1
- package/dist/transports/fake-sse.js +5 -2
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -2
- package/dist/transports/fixtures.d.ts.map +1 -1
- package/dist/transports/fixtures.js +5 -7
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts +2 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +10 -4
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +32 -46
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +56 -59
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +7 -5
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +41 -14
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -3
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +25 -80
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -4
- package/dist/types.js.map +1 -1
- package/dist/wire/ops.d.ts +1 -5
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -4
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +175 -128
- package/src/client/core.ts +287 -278
- package/src/client/session.ts +474 -480
- package/src/client/sessionStore.ts +1 -0
- package/src/client/timing.ts +0 -6
- package/src/host/draft.ts +1 -3
- package/src/host/host.ts +41 -58
- package/src/host/socket.ts +653 -448
- package/src/host-internal.ts +4 -0
- package/src/host.ts +13 -10
- package/src/index.ts +0 -1
- package/src/statewire.ts +38 -32
- package/src/transports/fake-sse.ts +2 -1
- package/src/transports/fixtures.ts +4 -9
- package/src/transports/remote.ts +12 -5
- package/src/transports/sse.ts +99 -115
- package/src/transports/testing.ts +47 -19
- package/src/transports/ws.ts +44 -115
- package/src/types.ts +15 -14
- package/src/wire/ops.ts +9 -16
package/src/client/session.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
|
-
import type {
|
|
4
|
+
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
|
+
import { encodeParams, encodeValue, reviveResponse } from "../wire/meta";
|
|
5
6
|
import { bindCommandsHost } from "../host/host";
|
|
6
7
|
import type { CommandsInvocation, CommandsListener } from "../host/host";
|
|
7
8
|
import type { Statewire } from "../types";
|
|
@@ -12,128 +13,45 @@ import type {
|
|
|
12
13
|
StatewireSessionStore,
|
|
13
14
|
StatewireSessionStoreOptions,
|
|
14
15
|
} from "./sessionStore";
|
|
15
|
-
import {
|
|
16
|
+
import { backoff } from "./timing";
|
|
16
17
|
import { createMethodProxy } from "../util/methodProxy";
|
|
17
18
|
import { randomId } from "../util/randomId";
|
|
18
19
|
import { StatewireSendError } from "../types";
|
|
19
20
|
import type {
|
|
20
21
|
RemoteLink,
|
|
22
|
+
StatewireFrame,
|
|
21
23
|
StatewireRemoteProps,
|
|
22
|
-
StatewireSubmitResult,
|
|
23
|
-
StatewireWireCommand,
|
|
24
24
|
} from "../transports/sse";
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*/
|
|
32
|
-
type PendingState =
|
|
33
|
-
| { at: "queued"; pendingMarked: boolean }
|
|
34
|
-
| { at: "trying"; pendingMarked: boolean; failedAttempts: number }
|
|
35
|
-
| { at: "posted"; pendingMarked: boolean; failedAttempts: number }
|
|
36
|
-
| { at: "awaiting-result"; posted: boolean; failedAttempts: number }
|
|
37
|
-
| { at: "settled-live"; posted: boolean; failedAttempts: number }
|
|
38
|
-
| { at: "done" };
|
|
26
|
+
type WireCommand = {
|
|
27
|
+
method: string;
|
|
28
|
+
params: unknown[];
|
|
29
|
+
meta?: unknown;
|
|
30
|
+
};
|
|
39
31
|
|
|
40
32
|
type Pending = {
|
|
41
33
|
readonly seq: number;
|
|
42
|
-
readonly command:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
readonly command: WireCommand;
|
|
35
|
+
readonly inv: CommandsInvocation;
|
|
36
|
+
/** A send carrying this statement may have reached the server. */
|
|
37
|
+
attempted: boolean;
|
|
38
|
+
/** In the resend queue when false. */
|
|
39
|
+
posted: boolean;
|
|
40
|
+
/** The server acknowledged admission; the terminal verdict is still due. */
|
|
41
|
+
applied: boolean;
|
|
46
42
|
resolve: (payload: unknown) => void;
|
|
47
43
|
reject: (error: StatewireSendError) => void;
|
|
48
44
|
};
|
|
49
45
|
|
|
50
|
-
type
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const isResolved = (state: PendingState) =>
|
|
55
|
-
state.at === "settled-live" || state.at === "done";
|
|
56
|
-
|
|
57
|
-
const isStateSettled = (state: PendingState) =>
|
|
58
|
-
state.at === "awaiting-result" || state.at === "done";
|
|
59
|
-
|
|
60
|
-
// The pump admits an entry as trying before sending; only the pump itself
|
|
61
|
-
// requeues, and a done entry is never still pending.
|
|
62
|
-
const inFlight = (state: PendingState) => {
|
|
63
|
-
if (state.at === "queued" || state.at === "done") {
|
|
64
|
-
throw new Error(`statewire: ${state.at} entry observed after a send`);
|
|
65
|
-
}
|
|
66
|
-
return state;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const legalTransitions: Record<PendingState["at"], PendingState["at"][]> = {
|
|
70
|
-
queued: ["queued", "trying", "awaiting-result", "settled-live", "done"],
|
|
71
|
-
// trying -> queued: an answered unknown-client proves the seq was never admitted.
|
|
72
|
-
trying: [
|
|
73
|
-
"queued",
|
|
74
|
-
"trying",
|
|
75
|
-
"posted",
|
|
76
|
-
"awaiting-result",
|
|
77
|
-
"settled-live",
|
|
78
|
-
"done",
|
|
79
|
-
],
|
|
80
|
-
posted: ["posted", "trying", "awaiting-result", "settled-live", "done"],
|
|
81
|
-
"awaiting-result": ["awaiting-result", "done"],
|
|
82
|
-
"settled-live": ["settled-live", "done"],
|
|
83
|
-
done: [],
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const transition = (
|
|
87
|
-
s: Session,
|
|
88
|
-
entry: Pending,
|
|
89
|
-
next: PendingState,
|
|
90
|
-
outcome?: Outcome,
|
|
91
|
-
) => {
|
|
92
|
-
const prev = entry.state;
|
|
93
|
-
if (!legalTransitions[prev.at].includes(next.at)) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
`statewire: illegal pending transition ${prev.at} -> ${next.at}`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
const settles = !isResolved(prev) && isResolved(next);
|
|
99
|
-
if (settles && outcome === undefined) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
`statewire: settling transition ${prev.at} -> ${next.at} without an outcome`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const covers = !isStateSettled(prev) && isStateSettled(next);
|
|
105
|
-
entry.state = next;
|
|
106
|
-
if (covers && entry.inv) s.lifecycle?.onCovered?.(entry.inv);
|
|
107
|
-
if (settles && outcome !== undefined) {
|
|
108
|
-
if (outcome.ok) {
|
|
109
|
-
entry.resolve(outcome.payload);
|
|
110
|
-
if (entry.inv)
|
|
111
|
-
s.lifecycle?.onSettle(entry.inv, {
|
|
112
|
-
type: "accepted",
|
|
113
|
-
payload: outcome.payload,
|
|
114
|
-
});
|
|
115
|
-
} else {
|
|
116
|
-
entry.reject(new StatewireSendError(outcome.failure));
|
|
117
|
-
if (entry.inv)
|
|
118
|
-
s.lifecycle?.onSettle(entry.inv, {
|
|
119
|
-
type: "rejected",
|
|
120
|
-
error: new StatewireSendError(outcome.failure),
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (next.at === "done") {
|
|
125
|
-
s.pending = s.pending.filter((p) => p !== entry);
|
|
126
|
-
}
|
|
46
|
+
type CtxPending = {
|
|
47
|
+
readonly seq: number;
|
|
48
|
+
readonly ops: unknown[];
|
|
49
|
+
posted: boolean;
|
|
127
50
|
};
|
|
128
51
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
state.at === "queued"
|
|
133
|
-
? { posted: false, failedAttempts: 0 }
|
|
134
|
-
: state.at === "trying" || state.at === "posted"
|
|
135
|
-
? { posted: state.at === "posted", failedAttempts: state.failedAttempts }
|
|
136
|
-
: { posted: state.posted, failedAttempts: state.failedAttempts };
|
|
52
|
+
const protocolError = (message: string): never => {
|
|
53
|
+
throw new Error(`statewire: protocol error: ${message}`);
|
|
54
|
+
};
|
|
137
55
|
|
|
138
56
|
const newClientId = () => `c-${randomId()}`;
|
|
139
57
|
|
|
@@ -147,15 +65,26 @@ const rejectedSend = (
|
|
|
147
65
|
|
|
148
66
|
type AttachState = "detached" | "attached" | "finished";
|
|
149
67
|
|
|
150
|
-
export type StatewireSessionOptions = StatewireSessionStoreOptions
|
|
68
|
+
export type StatewireSessionOptions = StatewireSessionStoreOptions & {
|
|
69
|
+
onDone?: (() => void) | undefined;
|
|
70
|
+
};
|
|
151
71
|
|
|
152
72
|
type Session = {
|
|
153
73
|
record: StatewireSessionRecord;
|
|
154
74
|
ctx: StatewireRemoteProps;
|
|
155
75
|
recordPersisted: boolean;
|
|
156
76
|
store: StatewireSessionStore;
|
|
157
|
-
|
|
158
|
-
|
|
77
|
+
ephemeral: boolean;
|
|
78
|
+
sessionKnown: boolean;
|
|
79
|
+
durableSeq: number;
|
|
80
|
+
resumeAt: number;
|
|
81
|
+
idle: boolean;
|
|
82
|
+
onDone: (() => void) | undefined;
|
|
83
|
+
ctxOps: unknown[] | undefined;
|
|
84
|
+
ctxPending: CtxPending[];
|
|
85
|
+
lanesOnAttach: boolean;
|
|
86
|
+
attachLaneSeqs: readonly [ReadonlySet<number>, ReadonlySet<number>];
|
|
87
|
+
frameFailures: number;
|
|
159
88
|
pending: Pending[];
|
|
160
89
|
canonical: unknown;
|
|
161
90
|
lastPublished: unknown;
|
|
@@ -169,64 +98,9 @@ type Session = {
|
|
|
169
98
|
waiters: (() => void)[];
|
|
170
99
|
lifecycle: CommandsListener | null;
|
|
171
100
|
notify: () => void;
|
|
172
|
-
emitEvent: (event: unknown) => void;
|
|
173
|
-
send: (command: StatewireWireCommand) => Promise<unknown>;
|
|
174
101
|
commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
|
|
175
102
|
};
|
|
176
103
|
|
|
177
|
-
const createSession = (options: StatewireSessionOptions): Session => {
|
|
178
|
-
const store = options.sessionStore ?? createMemorySessionStore();
|
|
179
|
-
const stored = store.load();
|
|
180
|
-
const record = stored ?? { clientId: newClientId(), nextSeq: 1 };
|
|
181
|
-
const s: Session = {
|
|
182
|
-
record,
|
|
183
|
-
ctx: {
|
|
184
|
-
get clientId() {
|
|
185
|
-
return s.record.clientId;
|
|
186
|
-
},
|
|
187
|
-
onSnapshot: (args) => handleSnapshot(s, args),
|
|
188
|
-
onOps: (args) => handleOps(s, args),
|
|
189
|
-
onFinish: (args) => handleFinish(s, args),
|
|
190
|
-
},
|
|
191
|
-
recordPersisted: stored !== null,
|
|
192
|
-
store,
|
|
193
|
-
lastAck: -1,
|
|
194
|
-
context: undefined,
|
|
195
|
-
pending: [],
|
|
196
|
-
canonical: undefined,
|
|
197
|
-
lastPublished: undefined,
|
|
198
|
-
changed: [],
|
|
199
|
-
source: {
|
|
200
|
-
snapshot: () => s.canonical,
|
|
201
|
-
takeChanged: () => {
|
|
202
|
-
const changed = s.changed;
|
|
203
|
-
s.changed = [];
|
|
204
|
-
return changed;
|
|
205
|
-
},
|
|
206
|
-
setOnChange: (listener) => {
|
|
207
|
-
s.notify = listener ?? (() => {});
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
attach: "detached",
|
|
211
|
-
attachEpoch: 0,
|
|
212
|
-
running: false,
|
|
213
|
-
pumping: false,
|
|
214
|
-
transport: null,
|
|
215
|
-
waiters: [],
|
|
216
|
-
lifecycle: null,
|
|
217
|
-
notify: () => {},
|
|
218
|
-
emitEvent: () => {},
|
|
219
|
-
send: (command) => sendCommand(s, command),
|
|
220
|
-
commands: createMethodProxy((method, params) => s.send({ method, params })),
|
|
221
|
-
};
|
|
222
|
-
bindCommandsHost(s.commands, {
|
|
223
|
-
setListener: (listener) => {
|
|
224
|
-
s.lifecycle = listener;
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
return s;
|
|
228
|
-
};
|
|
229
|
-
|
|
230
104
|
const publish = (s: Session) => {
|
|
231
105
|
if (s.canonical === s.lastPublished) return;
|
|
232
106
|
s.lastPublished = s.canonical;
|
|
@@ -254,17 +128,51 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
|
|
|
254
128
|
s.waiters.push(waiter);
|
|
255
129
|
});
|
|
256
130
|
|
|
257
|
-
const
|
|
131
|
+
const setRecord = (s: Session, record: StatewireSessionRecord) => {
|
|
132
|
+
s.record = record;
|
|
133
|
+
if (!s.ephemeral) s.store.save(record);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const setIdle = (s: Session, level: boolean) => {
|
|
137
|
+
if (level === s.idle) return;
|
|
138
|
+
s.idle = level;
|
|
139
|
+
s.notify();
|
|
140
|
+
if (level) s.onDone?.();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const cover = (s: Session, entry: Pending) => {
|
|
144
|
+
if (entry.applied) return;
|
|
145
|
+
entry.applied = true;
|
|
146
|
+
s.lifecycle?.onApplied(entry.inv);
|
|
147
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const settleEntry = (
|
|
258
151
|
s: Session,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
failure: StatewireClient.SendFailure
|
|
263
|
-
},
|
|
152
|
+
entry: Pending,
|
|
153
|
+
outcome:
|
|
154
|
+
| { ok: true; payload: unknown }
|
|
155
|
+
| { ok: false; failure: StatewireClient.SendFailure },
|
|
264
156
|
) => {
|
|
157
|
+
s.pending = s.pending.filter((p) => p !== entry);
|
|
158
|
+
if (outcome.ok) {
|
|
159
|
+
entry.resolve(outcome.payload);
|
|
160
|
+
s.lifecycle?.onSettle(entry.inv, {
|
|
161
|
+
type: "accepted",
|
|
162
|
+
payload: outcome.payload,
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
entry.reject(new StatewireSendError(outcome.failure));
|
|
166
|
+
s.lifecycle?.onSettle(entry.inv, {
|
|
167
|
+
type: "rejected",
|
|
168
|
+
error: new StatewireSendError(outcome.failure),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const settleAll = (s: Session, detach: "unmount" | "gone" | "superseded") => {
|
|
265
174
|
const cause = (
|
|
266
175
|
{
|
|
267
|
-
evicted: "session-lost",
|
|
268
176
|
unmount: "unmount",
|
|
269
177
|
gone: "stream-gone",
|
|
270
178
|
superseded: "superseded",
|
|
@@ -272,12 +180,9 @@ const settleAll = (
|
|
|
272
180
|
)[detach];
|
|
273
181
|
const batch = s.pending;
|
|
274
182
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
275
|
-
|
|
276
|
-
if (state.at === "awaiting-result")
|
|
277
|
-
return { fate: "result-unavailable", cause };
|
|
278
|
-
if (state.at === "queued") {
|
|
183
|
+
if (!entry.attempted) {
|
|
279
184
|
const predecessorLost = batch.some(
|
|
280
|
-
(p) => p.seq < entry.seq && p.
|
|
185
|
+
(p) => p.seq < entry.seq && p.attempted,
|
|
281
186
|
);
|
|
282
187
|
return predecessorLost
|
|
283
188
|
? { fate: "aborted", cause, predecessorLost }
|
|
@@ -286,221 +191,239 @@ const settleAll = (
|
|
|
286
191
|
return { fate: "lost", cause };
|
|
287
192
|
};
|
|
288
193
|
for (const entry of [...batch]) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
transition(s, entry, { at: "done" }, { ok: false, failure });
|
|
194
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
195
|
+
settleEntry(s, entry, { ok: false, failure: detachFailure(entry) });
|
|
292
196
|
}
|
|
293
197
|
};
|
|
294
198
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
);
|
|
301
|
-
if (claimed.length === 0) return [];
|
|
302
|
-
s.pending = s.pending.filter((p) => !claimed.includes(p));
|
|
303
|
-
return claimed;
|
|
199
|
+
const restateContext = (s: Session) => {
|
|
200
|
+
s.ctxPending = [];
|
|
201
|
+
if (s.ctxOps === undefined) return;
|
|
202
|
+
const seq = s.record.ctxNextSeq;
|
|
203
|
+
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
204
|
+
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
304
205
|
};
|
|
305
206
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
207
|
+
// The server-side record's dedup memory is gone: history that may have
|
|
208
|
+
// reached a server settles lost — the new hello's snapshot is the only
|
|
209
|
+
// truth about what survived — and never-attempted statements re-stamp
|
|
210
|
+
// from 1 on the reset lane clocks.
|
|
211
|
+
const resetRecord = (s: Session, clientId: string) => {
|
|
212
|
+
const survivors: Pending[] = [];
|
|
213
|
+
for (const entry of [...s.pending]) {
|
|
214
|
+
if (entry.attempted || entry.applied) {
|
|
215
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
216
|
+
settleEntry(s, entry, {
|
|
217
|
+
ok: false,
|
|
218
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
survivors.push(entry);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
s.pending = [];
|
|
225
|
+
s.durableSeq = 0;
|
|
226
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
|
|
227
|
+
for (const old of survivors) {
|
|
228
|
+
s.lifecycle?.onCovered?.(old.inv);
|
|
229
|
+
enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
324
230
|
}
|
|
231
|
+
restateContext(s);
|
|
325
232
|
publish(s);
|
|
326
233
|
wake(s);
|
|
327
234
|
};
|
|
328
235
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const spliceAcked = (s: Session, seq: number, settleVoid: boolean) => {
|
|
337
|
-
for (const entry of [...s.pending]) {
|
|
338
|
-
const state = entry.state;
|
|
339
|
-
if (entry.seq > seq || isStateSettled(state)) continue;
|
|
340
|
-
if (state.at === "settled-live") {
|
|
341
|
-
transition(s, entry, { at: "done" });
|
|
342
|
-
} else if (settleVoid && !state.pendingMarked) {
|
|
343
|
-
transition(s, entry, { at: "done" }, { ok: true, payload: undefined });
|
|
344
|
-
} else {
|
|
345
|
-
transition(s, entry, { at: "awaiting-result", ...carried(state) });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
236
|
+
// The record died with the attach: mint a fresh id for the next one.
|
|
237
|
+
const ephemeralDetach = (s: Session) => {
|
|
238
|
+
s.ephemeral = false;
|
|
239
|
+
s.sessionKnown = false;
|
|
240
|
+
resetRecord(s, newClientId());
|
|
348
241
|
};
|
|
349
242
|
|
|
350
|
-
const
|
|
351
|
-
|
|
243
|
+
const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
244
|
+
const code =
|
|
245
|
+
cmd.code === undefined ||
|
|
246
|
+
cmd.code === "unknown-command" ||
|
|
247
|
+
cmd.code === "invalid-params"
|
|
248
|
+
? cmd.code
|
|
249
|
+
: protocolError(`unknown rejected code: ${cmd.code}`);
|
|
250
|
+
return {
|
|
251
|
+
fate: "rejected",
|
|
252
|
+
cause: "server",
|
|
253
|
+
...(code !== undefined && { code }),
|
|
254
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
255
|
+
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
256
|
+
};
|
|
352
257
|
};
|
|
353
258
|
|
|
354
|
-
const
|
|
355
|
-
|
|
259
|
+
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
260
|
+
if (typeof cmd.seq !== "number") protocolError("cmd entry missing seq");
|
|
261
|
+
if (cmd.dur === true) {
|
|
262
|
+
s.durableSeq = Math.max(s.durableSeq, cmd.seq);
|
|
263
|
+
for (const entry of s.pending) {
|
|
264
|
+
if (entry.seq <= s.durableSeq) entry.posted = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (cmd.type === undefined) return;
|
|
268
|
+
const entry = s.pending.find((p) => p.seq === cmd.seq);
|
|
269
|
+
if (cmd.type === "applied") {
|
|
270
|
+
if (entry) cover(s, entry);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
356
273
|
if (!entry) return;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
entry,
|
|
384
|
-
{ at: "done" },
|
|
385
|
-
{
|
|
386
|
-
ok: false,
|
|
387
|
-
failure:
|
|
388
|
-
response.code === "unknown-command"
|
|
389
|
-
? { fate: "unknown-command", cause: "server" }
|
|
390
|
-
: response.code === "invalid-params"
|
|
391
|
-
? {
|
|
392
|
-
fate: "invalid-params",
|
|
393
|
-
cause: "server",
|
|
394
|
-
message:
|
|
395
|
-
response.message ??
|
|
396
|
-
protocolError("rejected response missing message"),
|
|
397
|
-
}
|
|
398
|
-
: {
|
|
399
|
-
fate: "rejected",
|
|
400
|
-
cause: "server",
|
|
401
|
-
message:
|
|
402
|
-
response.message ??
|
|
403
|
-
protocolError("rejected response missing message"),
|
|
404
|
-
...(response.payload !== undefined && {
|
|
405
|
-
payload: response.payload,
|
|
406
|
-
}),
|
|
274
|
+
cover(s, entry);
|
|
275
|
+
const outcome =
|
|
276
|
+
cmd.type === "result"
|
|
277
|
+
? { ok: true as const, payload: reviveResponse(cmd).payload }
|
|
278
|
+
: cmd.type === "rejected"
|
|
279
|
+
? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
|
|
280
|
+
: cmd.type === "failed"
|
|
281
|
+
? {
|
|
282
|
+
ok: false as const,
|
|
283
|
+
failure: {
|
|
284
|
+
fate: "failed" as const,
|
|
285
|
+
cause: "server" as const,
|
|
286
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
: cmd.type === "crashed"
|
|
290
|
+
? {
|
|
291
|
+
ok: false as const,
|
|
292
|
+
failure: { fate: "crashed" as const, cause: "server" as const },
|
|
293
|
+
}
|
|
294
|
+
: cmd.type === "expired"
|
|
295
|
+
? {
|
|
296
|
+
ok: false as const,
|
|
297
|
+
failure: {
|
|
298
|
+
fate: "expired" as const,
|
|
299
|
+
cause: "server" as const,
|
|
407
300
|
},
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
entry,
|
|
417
|
-
state.at === "awaiting-result"
|
|
418
|
-
? { at: "done" }
|
|
419
|
-
: { at: "settled-live", ...carried(state) },
|
|
420
|
-
{ ok: false, failure: { fate: response.type, cause: "server" } },
|
|
421
|
-
);
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
301
|
+
}
|
|
302
|
+
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
303
|
+
settleEntry(s, entry, outcome);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
307
|
+
for (const cmd of cmds) handleCmd(s, cmd);
|
|
308
|
+
wake(s);
|
|
424
309
|
};
|
|
425
310
|
|
|
426
311
|
const ingestOps = (s: Session, ops: unknown[]) => {
|
|
427
312
|
const applied = applyOps(s.canonical, ops);
|
|
428
313
|
s.canonical = applied.state;
|
|
429
314
|
s.changed.push(...applied.changedPaths);
|
|
430
|
-
return applied.events;
|
|
431
315
|
};
|
|
432
316
|
|
|
433
|
-
const publishIngested = (s: Session
|
|
317
|
+
const publishIngested = (s: Session) => {
|
|
434
318
|
publish(s);
|
|
435
|
-
for (const event of events) s.emitEvent(event);
|
|
436
319
|
wake(s);
|
|
437
320
|
};
|
|
438
321
|
|
|
439
|
-
const
|
|
322
|
+
const RECORDS = ["fresh", "resumed", "ephemeral"];
|
|
323
|
+
|
|
324
|
+
// Snap a resumed session's lane clocks onto the record: seqs above the stated
|
|
325
|
+
// watermark were never admitted, so a stale restored clock re-stamps its
|
|
326
|
+
// never-admitted statements consecutively after the watermark.
|
|
327
|
+
const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
328
|
+
const tail = s.pending.filter((p) => p.seq > seqWm);
|
|
329
|
+
const cmdAligned =
|
|
330
|
+
tail.every((p, i) => p.seq === seqWm + 1 + i) &&
|
|
331
|
+
s.record.nextSeq === seqWm + tail.length + 1;
|
|
332
|
+
if (!cmdAligned) {
|
|
333
|
+
s.pending = s.pending.filter((p) => p.seq <= seqWm);
|
|
334
|
+
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
335
|
+
for (const old of tail) {
|
|
336
|
+
s.lifecycle?.onCovered?.(old.inv);
|
|
337
|
+
enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const ctxAligned =
|
|
341
|
+
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
342
|
+
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
343
|
+
if (!ctxAligned) {
|
|
344
|
+
setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
|
|
345
|
+
restateContext(s);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const handleHello = (
|
|
440
350
|
s: Session,
|
|
441
|
-
{ ops,
|
|
351
|
+
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
442
352
|
) => {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
publishIngested(s, ingestOps(s, ops));
|
|
447
|
-
return;
|
|
353
|
+
const record = syn.record ?? "fresh";
|
|
354
|
+
if (!RECORDS.includes(record)) {
|
|
355
|
+
protocolError(`unknown record: ${String(record)}`);
|
|
448
356
|
}
|
|
449
|
-
|
|
357
|
+
ingestOps(s, ops);
|
|
450
358
|
s.attach = "attached";
|
|
451
359
|
s.attachEpoch += 1;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
s.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
if (state.at === "awaiting-result") void pump(s);
|
|
464
|
-
} else if (state.at === "posted") {
|
|
465
|
-
// Above the ack watermark a prior receipt no longer counts.
|
|
466
|
-
transition(s, entry, {
|
|
467
|
-
at: "trying",
|
|
468
|
-
pendingMarked: state.pendingMarked,
|
|
469
|
-
failedAttempts: state.failedAttempts,
|
|
470
|
-
});
|
|
471
|
-
void pump(s);
|
|
472
|
-
} else if (state.at === "settled-live" && state.posted) {
|
|
473
|
-
transition(s, entry, { ...state, posted: false });
|
|
474
|
-
void pump(s);
|
|
360
|
+
s.frameFailures = 0;
|
|
361
|
+
s.resumeAt = 0;
|
|
362
|
+
s.ephemeral = record === "ephemeral";
|
|
363
|
+
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
364
|
+
s.attachLaneSeqs = [new Set(), new Set()];
|
|
365
|
+
if (record === "resumed") {
|
|
366
|
+
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
367
|
+
// re-emit their stored verdicts (applied while still in flight).
|
|
368
|
+
for (const entry of s.pending) {
|
|
369
|
+
entry.posted = attachCmd.has(entry.seq);
|
|
475
370
|
}
|
|
371
|
+
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
372
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
373
|
+
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
374
|
+
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
375
|
+
} else {
|
|
376
|
+
resetRecord(s, s.record.clientId);
|
|
476
377
|
}
|
|
477
|
-
|
|
378
|
+
s.sessionKnown = true;
|
|
379
|
+
publishIngested(s);
|
|
380
|
+
void pump(s);
|
|
478
381
|
};
|
|
479
382
|
|
|
480
383
|
const handleOps = (
|
|
481
384
|
s: Session,
|
|
482
|
-
{
|
|
483
|
-
ops,
|
|
484
|
-
res,
|
|
485
|
-
ack,
|
|
486
|
-
}: { ops: unknown[]; res?: StatewireResponse[]; ack?: number },
|
|
385
|
+
{ ops, cmds }: { ops: unknown[]; cmds: StatewireCmd[] },
|
|
487
386
|
) => {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
387
|
+
ingestOps(s, ops);
|
|
388
|
+
for (const cmd of cmds) handleCmd(s, cmd);
|
|
389
|
+
publishIngested(s);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// A {syn} refusal never settles: reconcile the resend queues against the
|
|
393
|
+
// stated watermarks; retry present means wait, absent means resend now.
|
|
394
|
+
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
395
|
+
const { seq, ctxSeq } = syn;
|
|
396
|
+
if (typeof seq === "number") {
|
|
397
|
+
const watermark = Math.max(seq, s.durableSeq);
|
|
398
|
+
for (const entry of s.pending) entry.posted = entry.seq <= watermark;
|
|
495
399
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (ack !== undefined) {
|
|
500
|
-
spliceAcked(s, ack, true);
|
|
501
|
-
s.lastAck = Math.max(s.lastAck, ack);
|
|
400
|
+
if (typeof ctxSeq === "number") {
|
|
401
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
|
|
402
|
+
for (const c of s.ctxPending) c.posted = false;
|
|
502
403
|
}
|
|
503
|
-
|
|
404
|
+
if (typeof syn.retry === "number") {
|
|
405
|
+
s.resumeAt = Date.now() + Math.max(0, syn.retry);
|
|
406
|
+
}
|
|
407
|
+
const firstCmd = s.pending.find((p) => !p.posted);
|
|
408
|
+
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
|
409
|
+
const clocksAhead =
|
|
410
|
+
(typeof syn.seq === "number" &&
|
|
411
|
+
firstCmd !== undefined &&
|
|
412
|
+
firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1) ||
|
|
413
|
+
(typeof syn.ctxSeq === "number" &&
|
|
414
|
+
firstCtx !== undefined &&
|
|
415
|
+
firstCtx.seq > syn.ctxSeq + 1);
|
|
416
|
+
// Clocks ahead of a record that cannot admit them: reattach first and let
|
|
417
|
+
// the hello's record arbitrate.
|
|
418
|
+
if (clocksAhead) s.transport?.connection.reconnect();
|
|
419
|
+
wake(s);
|
|
420
|
+
void pump(s);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const handleDetach = (s: Session) => {
|
|
424
|
+
if (s.attach === "attached") s.attach = "detached";
|
|
425
|
+
if (s.ephemeral) ephemeralDetach(s);
|
|
426
|
+
wake(s);
|
|
504
427
|
};
|
|
505
428
|
|
|
506
429
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
@@ -513,130 +436,120 @@ const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
|
513
436
|
|
|
514
437
|
const isFinished = (s: Session) => s.attach === "finished";
|
|
515
438
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
439
|
+
type Lanes = {
|
|
440
|
+
frame: StatewireFrame;
|
|
441
|
+
cmdEntries: Pending[];
|
|
442
|
+
ctxEntries: CtxPending[];
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// A lane must be strictly consecutive within a frame: the first unposted run
|
|
446
|
+
// per lane rides now, later runs ride follow-up frames.
|
|
447
|
+
const buildLanes = (s: Session): Lanes | null => {
|
|
448
|
+
const ctxEntries: CtxPending[] = [];
|
|
449
|
+
for (const entry of s.ctxPending) {
|
|
450
|
+
if (entry.posted) continue;
|
|
451
|
+
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
452
|
+
break;
|
|
453
|
+
ctxEntries.push(entry);
|
|
454
|
+
}
|
|
455
|
+
const cmdEntries: Pending[] = [];
|
|
456
|
+
for (const entry of s.pending) {
|
|
457
|
+
if (entry.posted) continue;
|
|
458
|
+
if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
|
|
459
|
+
break;
|
|
460
|
+
cmdEntries.push(entry);
|
|
461
|
+
}
|
|
462
|
+
if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
|
|
463
|
+
return {
|
|
464
|
+
frame: {
|
|
465
|
+
...(ctxEntries.length > 0 && {
|
|
466
|
+
ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
|
|
467
|
+
}),
|
|
468
|
+
...(cmdEntries.length > 0 && {
|
|
469
|
+
cmd: cmdEntries.map((p) => ({ ...p.command, seq: p.seq })),
|
|
470
|
+
}),
|
|
471
|
+
},
|
|
472
|
+
cmdEntries,
|
|
473
|
+
ctxEntries,
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
478
|
+
s.attachLaneSeqs = [new Set(), new Set()];
|
|
479
|
+
const wanted = s.lanesOnAttach;
|
|
480
|
+
s.lanesOnAttach = false;
|
|
481
|
+
if (!wanted || !s.sessionKnown) return null;
|
|
482
|
+
const lanes = buildLanes(s);
|
|
483
|
+
if (!lanes) return null;
|
|
484
|
+
s.attachLaneSeqs = [
|
|
485
|
+
new Set(lanes.cmdEntries.map((p) => p.seq)),
|
|
486
|
+
new Set(lanes.ctxEntries.map((c) => c.seq)),
|
|
487
|
+
];
|
|
488
|
+
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
489
|
+
return lanes.frame;
|
|
490
|
+
};
|
|
521
491
|
|
|
522
492
|
const pump = async (s: Session) => {
|
|
523
493
|
if (s.pumping) return;
|
|
524
494
|
s.pumping = true;
|
|
525
495
|
try {
|
|
526
496
|
while (s.running && !isFinished(s)) {
|
|
527
|
-
const entry = s.pending.find(sendable);
|
|
528
|
-
if (!entry) return;
|
|
529
497
|
const transport = s.transport;
|
|
498
|
+
if (buildLanes(s) === null) return;
|
|
530
499
|
if (s.attach !== "attached" || !transport) {
|
|
500
|
+
if (transport?.connection.status === "idle") {
|
|
501
|
+
transport.connection.reconnect();
|
|
502
|
+
}
|
|
531
503
|
await waitForWake(s);
|
|
532
504
|
continue;
|
|
533
505
|
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
at: "trying",
|
|
539
|
-
pendingMarked: entry.state.pendingMarked,
|
|
540
|
-
failedAttempts: 0,
|
|
541
|
-
});
|
|
506
|
+
const resumeDelay = s.resumeAt - Date.now();
|
|
507
|
+
if (resumeDelay > 0) {
|
|
508
|
+
await sleepUntilWake(s, resumeDelay);
|
|
509
|
+
continue;
|
|
542
510
|
}
|
|
543
|
-
|
|
511
|
+
const lanes = buildLanes(s);
|
|
512
|
+
if (!lanes) continue;
|
|
513
|
+
const epoch = s.attachEpoch;
|
|
514
|
+
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
515
|
+
let result;
|
|
544
516
|
try {
|
|
545
|
-
result = await transport.send(
|
|
546
|
-
|
|
547
|
-
command: entry.command,
|
|
548
|
-
});
|
|
549
|
-
} catch (error) {
|
|
517
|
+
result = await transport.send(lanes.frame);
|
|
518
|
+
} catch {
|
|
550
519
|
if (!s.running || isFinished(s)) return;
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
transition(s, entry, {
|
|
554
|
-
...state,
|
|
555
|
-
failedAttempts: state.failedAttempts + 1,
|
|
556
|
-
});
|
|
557
|
-
await sleepUntilWake(s, retryDelay(error, state.failedAttempts + 1));
|
|
520
|
+
s.frameFailures += 1;
|
|
521
|
+
await sleepUntilWake(s, backoff(s.frameFailures));
|
|
558
522
|
continue;
|
|
559
523
|
}
|
|
560
524
|
if (!s.running) return;
|
|
561
|
-
|
|
562
|
-
if (result.type === "
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
} else if (
|
|
572
|
-
state.at === "awaiting-result" ||
|
|
573
|
-
state.at === "settled-live"
|
|
574
|
-
) {
|
|
575
|
-
transition(s, entry, { ...state, posted: true });
|
|
576
|
-
}
|
|
577
|
-
if (entry.inv) s.lifecycle?.onPosted?.(entry.inv);
|
|
578
|
-
if (response !== undefined) {
|
|
579
|
-
applyResponse(s, response);
|
|
580
|
-
publish(s);
|
|
525
|
+
s.frameFailures = 0;
|
|
526
|
+
if (result.type === "ok") {
|
|
527
|
+
if (epoch === s.attachEpoch) {
|
|
528
|
+
for (const entry of lanes.cmdEntries) {
|
|
529
|
+
if (s.pending.includes(entry)) {
|
|
530
|
+
entry.posted = true;
|
|
531
|
+
s.lifecycle?.onPosted?.(entry.inv);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
for (const entry of lanes.ctxEntries) entry.posted = true;
|
|
581
535
|
}
|
|
582
|
-
} else if (result.type === "
|
|
583
|
-
|
|
584
|
-
s,
|
|
585
|
-
entry,
|
|
586
|
-
{ at: "done" },
|
|
587
|
-
{
|
|
588
|
-
ok: false,
|
|
589
|
-
failure: {
|
|
590
|
-
fate: "rejected",
|
|
591
|
-
cause: "server",
|
|
592
|
-
message: result.message,
|
|
593
|
-
},
|
|
594
|
-
},
|
|
595
|
-
);
|
|
596
|
-
publish(s);
|
|
536
|
+
} else if (result.type === "refusal") {
|
|
537
|
+
if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
|
|
597
538
|
} else if (result.type === "stale-lease") {
|
|
598
|
-
|
|
599
|
-
} else if (result.type === "unknown-client") {
|
|
600
|
-
// Eviction is never decided on the POST leg: re-attach first and let
|
|
601
|
-
// the snapshot's lastSeq deliver the verdict. An answered attempt
|
|
602
|
-
// proves the server never admitted this seq, so a clean entry requeues.
|
|
539
|
+
// A POST outlived its attach: reattach first, the hello arbitrates.
|
|
603
540
|
if (epoch === s.attachEpoch) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
while (
|
|
613
|
-
s.running &&
|
|
614
|
-
!isFinished(s) &&
|
|
615
|
-
epoch === s.attachEpoch &&
|
|
616
|
-
clientId === s.record.clientId
|
|
617
|
-
) {
|
|
541
|
+
transport.connection.reconnect();
|
|
542
|
+
await waitForWake(s);
|
|
543
|
+
}
|
|
544
|
+
} else if (result.type === "no-lease") {
|
|
545
|
+
// No out-of-band write channel: the frame rides a fresh attach.
|
|
546
|
+
if (epoch === s.attachEpoch) {
|
|
547
|
+
s.lanesOnAttach = true;
|
|
548
|
+
transport.connection.reconnect();
|
|
549
|
+
while (s.running && !isFinished(s) && epoch === s.attachEpoch) {
|
|
618
550
|
await waitForWake(s);
|
|
619
551
|
}
|
|
620
552
|
}
|
|
621
|
-
} else if (result.type === "seq-gap") {
|
|
622
|
-
if (clientId === s.record.clientId) {
|
|
623
|
-
console.error(
|
|
624
|
-
`statewire: server reports a seq gap for seq ${entry.seq}; ` +
|
|
625
|
-
"treating the session as corrupt and rotating the client id",
|
|
626
|
-
);
|
|
627
|
-
// Every attempt answered means the server never saw it; a thrown
|
|
628
|
-
// attempt may have left the machine unanswered, so the verdict is "lost".
|
|
629
|
-
const state = inFlight(entry.state);
|
|
630
|
-
const failure: StatewireClient.SendFailure =
|
|
631
|
-
state.at === "awaiting-result"
|
|
632
|
-
? { fate: "result-unavailable", cause: "session-lost" }
|
|
633
|
-
: {
|
|
634
|
-
fate: state.failedAttempts > 0 ? "lost" : "aborted",
|
|
635
|
-
cause: "session-lost",
|
|
636
|
-
};
|
|
637
|
-
evict(s, { entry, failure });
|
|
638
|
-
s.transport?.connection.reconnect();
|
|
639
|
-
}
|
|
640
553
|
}
|
|
641
554
|
}
|
|
642
555
|
} finally {
|
|
@@ -667,38 +580,38 @@ const setTransport = (s: Session, transport: RemoteLink) => {
|
|
|
667
580
|
|
|
668
581
|
const enqueue = (
|
|
669
582
|
s: Session,
|
|
670
|
-
command:
|
|
583
|
+
command: WireCommand,
|
|
584
|
+
params: readonly unknown[],
|
|
671
585
|
resolve: (payload: unknown) => void,
|
|
672
586
|
reject: (error: StatewireSendError) => void,
|
|
673
587
|
) => {
|
|
674
588
|
const seq = s.record.nextSeq;
|
|
675
|
-
s
|
|
676
|
-
s.store.save(s.record);
|
|
589
|
+
setRecord(s, { ...s.record, nextSeq: seq + 1 });
|
|
677
590
|
const entry: Pending = {
|
|
678
591
|
seq,
|
|
679
592
|
command,
|
|
680
|
-
inv:
|
|
681
|
-
command.method
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
state: { at: "queued", pendingMarked: false },
|
|
593
|
+
inv: {
|
|
594
|
+
method: command.method,
|
|
595
|
+
params,
|
|
596
|
+
ts: Date.now(),
|
|
597
|
+
seq,
|
|
598
|
+
clientId: s.record.clientId,
|
|
599
|
+
},
|
|
600
|
+
attempted: false,
|
|
601
|
+
posted: false,
|
|
602
|
+
applied: false,
|
|
691
603
|
resolve,
|
|
692
604
|
reject,
|
|
693
605
|
};
|
|
694
606
|
s.pending.push(entry);
|
|
695
|
-
|
|
607
|
+
s.lifecycle?.onStart(entry.inv);
|
|
696
608
|
void pump(s);
|
|
697
609
|
};
|
|
698
610
|
|
|
699
611
|
const sendCommand = (
|
|
700
612
|
s: Session,
|
|
701
|
-
|
|
613
|
+
method: string,
|
|
614
|
+
params: readonly unknown[],
|
|
702
615
|
): Promise<unknown> => {
|
|
703
616
|
if (!s.running) {
|
|
704
617
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
@@ -707,34 +620,115 @@ const sendCommand = (
|
|
|
707
620
|
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
708
621
|
}
|
|
709
622
|
const result = new Promise<unknown>((resolve, reject) => {
|
|
710
|
-
|
|
623
|
+
const { params: encoded, meta } = encodeParams(params);
|
|
624
|
+
enqueue(
|
|
625
|
+
s,
|
|
626
|
+
{ method, params: encoded, ...(meta !== undefined && { meta }) },
|
|
627
|
+
params,
|
|
628
|
+
resolve,
|
|
629
|
+
reject,
|
|
630
|
+
);
|
|
711
631
|
});
|
|
712
632
|
result.catch(() => {});
|
|
713
633
|
return result;
|
|
714
634
|
};
|
|
715
635
|
|
|
716
|
-
const setContext = (
|
|
717
|
-
s: Session,
|
|
718
|
-
value: Record<string, unknown>,
|
|
719
|
-
): Promise<unknown> => {
|
|
636
|
+
const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
720
637
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
721
638
|
throw new Error("statewire: context must be a plain object");
|
|
722
639
|
}
|
|
723
|
-
|
|
724
|
-
|
|
640
|
+
const { value: encoded, meta } = encodeValue(value);
|
|
641
|
+
s.ctxOps = [
|
|
642
|
+
{
|
|
643
|
+
op: "replace",
|
|
644
|
+
path: [],
|
|
645
|
+
value: encoded,
|
|
646
|
+
...(meta !== undefined && { meta }),
|
|
647
|
+
},
|
|
648
|
+
];
|
|
649
|
+
const seq = s.record.ctxNextSeq;
|
|
650
|
+
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
651
|
+
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
652
|
+
wake(s);
|
|
653
|
+
void pump(s);
|
|
725
654
|
};
|
|
726
655
|
|
|
727
656
|
export const useStatewireSession = (
|
|
728
657
|
options: StatewireSessionOptions,
|
|
729
|
-
emit: (event: unknown) => void,
|
|
730
658
|
): Session => {
|
|
731
|
-
const [
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
659
|
+
const [defaultStore] = useState(createMemorySessionStore);
|
|
660
|
+
const store = options.sessionStore ?? defaultStore;
|
|
661
|
+
const [s] = useState((): Session => {
|
|
662
|
+
const stored = store.load();
|
|
663
|
+
const record = stored ?? {
|
|
664
|
+
clientId: newClientId(),
|
|
665
|
+
nextSeq: 1,
|
|
666
|
+
ctxNextSeq: 1,
|
|
736
667
|
};
|
|
737
|
-
|
|
668
|
+
const session: Session = {
|
|
669
|
+
record,
|
|
670
|
+
ctx: {
|
|
671
|
+
get clientId() {
|
|
672
|
+
return session.record.clientId;
|
|
673
|
+
},
|
|
674
|
+
takeAttachLanes: () => takeAttachLanes(session),
|
|
675
|
+
onHello: (args) => handleHello(session, args),
|
|
676
|
+
onOps: (args) => handleOps(session, args),
|
|
677
|
+
onCmds: (cmds) => handleCmds(session, cmds),
|
|
678
|
+
onRefusal: (syn) => handleRefusal(session, syn),
|
|
679
|
+
onIdle: (level) => setIdle(session, level),
|
|
680
|
+
onDetach: () => handleDetach(session),
|
|
681
|
+
onFinish: (args) => handleFinish(session, args),
|
|
682
|
+
},
|
|
683
|
+
recordPersisted: stored !== null,
|
|
684
|
+
store,
|
|
685
|
+
ephemeral: false,
|
|
686
|
+
sessionKnown: false,
|
|
687
|
+
durableSeq: 0,
|
|
688
|
+
resumeAt: 0,
|
|
689
|
+
idle: false,
|
|
690
|
+
onDone: undefined,
|
|
691
|
+
ctxOps: undefined,
|
|
692
|
+
ctxPending: [],
|
|
693
|
+
lanesOnAttach: false,
|
|
694
|
+
attachLaneSeqs: [new Set(), new Set()],
|
|
695
|
+
frameFailures: 0,
|
|
696
|
+
pending: [],
|
|
697
|
+
canonical: undefined,
|
|
698
|
+
lastPublished: undefined,
|
|
699
|
+
changed: [],
|
|
700
|
+
source: {
|
|
701
|
+
snapshot: () => session.canonical,
|
|
702
|
+
takeChanged: () => {
|
|
703
|
+
const changed = session.changed;
|
|
704
|
+
session.changed = [];
|
|
705
|
+
return changed;
|
|
706
|
+
},
|
|
707
|
+
setOnChange: (listener) => {
|
|
708
|
+
session.notify = listener ?? (() => {});
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
attach: "detached",
|
|
712
|
+
attachEpoch: 0,
|
|
713
|
+
running: false,
|
|
714
|
+
pumping: false,
|
|
715
|
+
transport: null,
|
|
716
|
+
waiters: [],
|
|
717
|
+
lifecycle: null,
|
|
718
|
+
notify: () => {},
|
|
719
|
+
commands: createMethodProxy((method, params) =>
|
|
720
|
+
sendCommand(session, method, params),
|
|
721
|
+
),
|
|
722
|
+
};
|
|
723
|
+
bindCommandsHost(session.commands, {
|
|
724
|
+
setListener: (listener) => {
|
|
725
|
+
session.lifecycle = listener;
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
return session;
|
|
729
|
+
});
|
|
730
|
+
s.store = store;
|
|
731
|
+
s.onDone = options.onDone;
|
|
738
732
|
useEffect(() => {
|
|
739
733
|
start(s);
|
|
740
734
|
return () => stop(s);
|