effect-browser 0.3.0-beta.6 → 0.3.0-beta.8

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.
Files changed (53) hide show
  1. package/README.md +124 -21
  2. package/dist/{Browser-B5AV7zPS.mjs → Browser-CNwK0r0q.mjs} +2 -2
  3. package/dist/{Browser-B5AV7zPS.mjs.map → Browser-CNwK0r0q.mjs.map} +1 -1
  4. package/dist/Browser.d.mts +2 -2
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/{BrowserEvent-DzdNdvoh.d.mts → BrowserEvent-BRu2juEz.d.mts} +3 -1
  7. package/dist/BrowserEvent.d.mts +1 -1
  8. package/dist/BrowserEvent.mjs +2 -0
  9. package/dist/BrowserEvent.mjs.map +1 -1
  10. package/dist/Cdp.mjs +1 -1
  11. package/dist/{Change-DVP2IJWm.d.mts → Change-iMlcuzVD.d.mts} +2 -2
  12. package/dist/Change.d.mts +1 -1
  13. package/dist/Chromium.mjs +1 -1
  14. package/dist/Expect.d.mts +1 -1
  15. package/dist/Expect.mjs +1 -1
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/{Page-DYTdVhYa.d.mts → Page-C30aG2RQ.d.mts} +6 -4
  18. package/dist/Page-D4281dIi.mjs.map +1 -1
  19. package/dist/Page.d.mts +1 -1
  20. package/dist/Plan-BvCS9qAf.d.mts +209 -0
  21. package/dist/{Plan-5MdsRzE5.mjs → Plan-CyQNv49C.mjs} +232 -38
  22. package/dist/Plan-CyQNv49C.mjs.map +1 -0
  23. package/dist/Plan.d.mts +2 -2
  24. package/dist/Plan.mjs +2 -2
  25. package/dist/Policy.d.mts +1 -1
  26. package/dist/Presentation.d.mts +1 -1
  27. package/dist/Presentation.mjs +1 -1
  28. package/dist/Stage.d.mts +1 -1
  29. package/dist/Stage.mjs +1 -1
  30. package/dist/{Supervisor-Im6-wbg5.d.mts → Supervisor-CYWFK3vM.d.mts} +106 -56
  31. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  32. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  33. package/dist/Supervisor.d.mts +2 -2
  34. package/dist/Supervisor.mjs +2 -2
  35. package/dist/index.d.mts +5 -5
  36. package/dist/index.mjs +3 -3
  37. package/dist/{page-BkRcLCWZ.mjs → page-DHeYWEs-.mjs} +27 -14
  38. package/dist/page-DHeYWEs-.mjs.map +1 -0
  39. package/package.json +1 -1
  40. package/src/BrowserEvent.ts +2 -0
  41. package/src/Page.ts +3 -1
  42. package/src/Plan.ts +365 -56
  43. package/src/Supervisor.ts +384 -138
  44. package/src/internal/input/perform.ts +2 -0
  45. package/src/internal/page/bridge.ts +3 -3
  46. package/src/internal/reading/ready.inpage.ts +17 -7
  47. package/src/internal/reading/ready.ts +9 -3
  48. package/src/internal/supervisor/lifecycle.ts +291 -140
  49. package/dist/Plan-5MdsRzE5.mjs.map +0 -1
  50. package/dist/Plan-CNNOU1Tx.d.mts +0 -112
  51. package/dist/Supervisor-BtEG93gR.mjs +0 -420
  52. package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
  53. package/dist/page-BkRcLCWZ.mjs.map +0 -1
@@ -90,7 +90,7 @@ const install = (
90
90
  ): PageApi => {
91
91
  const installed = globalThis.__effectBrowser;
92
92
 
93
- if (installed !== undefined && installed.version === 18) return installed;
93
+ if (installed !== undefined && installed.version === 19) return installed;
94
94
  const named = makeNames();
95
95
  const walked = makeWalk(named);
96
96
  const placing = makeContext(named, walked);
@@ -105,10 +105,10 @@ const install = (
105
105
  const located = makeTargets(named, walked, placing);
106
106
  const guarded = makeGuard(named, located, makeEvidence(named, placing));
107
107
  const edited = makeEdit(named, guarded, placing);
108
- const readiness = makeReady(walked, texts, kept);
108
+ const readiness = makeReady(walked, texts, kept, recorder);
109
109
 
110
110
  const api: PageApi = {
111
- version: 18,
111
+ version: 19,
112
112
  snapshot: read.snapshot,
113
113
  find: subjected.find,
114
114
  until: makeExpectations(named, subjected).until,
@@ -6,12 +6,14 @@
6
6
  * frame. So a canvas mounted blank, or a spinner alone, is still loading. A screen that only says
7
7
  * "Loading…" in words reads as ready, and a WebGL canvas drawn once, without keeping its drawing,
8
8
  * reads as blank. Asked for a quiet spell, where the page's changes are recorded, nothing in view
9
- * may have changed for that long either. After input, it waits a task and a frame. See
10
- * `names.inpage.ts` for what a page-side part may use.
9
+ * may have changed for that long either. Content mode starts and refreshes the recorder and
10
+ * observes a full quiet interval from its first check in each document. After input, it waits a
11
+ * task and a frame. See `names.inpage.ts` for what a page-side part may use.
11
12
  */
12
13
  import { Schema } from "effect";
13
14
 
14
15
  import type { History } from "../timeline/history.inpage.ts";
16
+ import type { Recorder } from "../timeline/record.inpage.ts";
15
17
  import type { Texts } from "./text.inpage.ts";
16
18
  import type { Walk } from "./walk.inpage.ts";
17
19
 
@@ -31,7 +33,7 @@ export const ReadinessSchema = Schema.Array(
31
33
 
32
34
  export type Readiness = typeof ReadinessSchema.Type;
33
35
 
34
- export const ready = (walked: Walk, texts: Texts, kept: History) => {
36
+ export const ready = (walked: Walk, texts: Texts, kept: History, recorder: Recorder) => {
35
37
  const { inView, visible } = walked;
36
38
  // A canvas is read through a small copy: reading it directly would give it a context of ours.
37
39
  let copy: OffscreenCanvasRenderingContext2D | null | undefined;
@@ -86,7 +88,7 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
86
88
  };
87
89
 
88
90
  // In a frame, before it is drawn: a canvas drawn every frame still holds its drawing then.
89
- const check = (quietMillis: number): Promise<Readiness> =>
91
+ const check = (quietMillis: number, observed: number): Promise<Readiness> =>
90
92
  painted().then((paints) => {
91
93
  const waiting: Array<Readiness[number]> = [];
92
94
 
@@ -113,7 +115,10 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
113
115
  if (document.fonts.status !== "loaded") waiting.push("fonts");
114
116
  if (showing(document.querySelectorAll("[aria-busy=true]")).length > 0) waiting.push("busy");
115
117
  if (!content()) waiting.push("content");
116
- if (kept.changing() > performance.timeOrigin + performance.now() - quietMillis)
118
+ if (
119
+ Math.max(observed, kept.changing()) >
120
+ performance.timeOrigin + performance.now() - quietMillis
121
+ )
117
122
  waiting.push("changing");
118
123
 
119
124
  return waiting;
@@ -123,11 +128,16 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
123
128
  * Check until the page is ready, or for `millis`, and say what it is still waiting for; with
124
129
  * `quietMillis`, until nothing the record saw in view has changed for that long, too.
125
130
  */
126
- const wait = async (millis: number, quietMillis = 0): Promise<Readiness> => {
131
+ const wait = async (millis: number, quietMillis = 0, contentOnly = false): Promise<Readiness> => {
127
132
  const until = performance.now() + millis;
133
+ let observed = contentOnly ? undefined : Number.NEGATIVE_INFINITY;
128
134
 
129
135
  for (;;) {
130
- const waiting = await check(quietMillis);
136
+ if (contentOnly) {
137
+ recorder.start();
138
+ if (kept.begun()) observed ??= performance.timeOrigin + performance.now();
139
+ }
140
+ const waiting = await check(quietMillis, observed ?? Number.POSITIVE_INFINITY);
131
141
 
132
142
  if (waiting.length === 0 || performance.now() >= until) return waiting;
133
143
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -9,14 +9,16 @@
9
9
  * connection of its own, a round trip there: each answer comes behind every frame sent before it on
10
10
  * its connection, and a frame that arrives first starts the spell again. Where the page's changes
11
11
  * are recorded, its evidence includes them: nothing in view may have changed for the spell, which a
12
- * DOM page's changes show better than its paint, and no stall on the way can hide.
12
+ * DOM page's changes show better than its paint, and no stall on the way can hide. Content
13
+ * stillness starts that record itself and ignores capture silence. Each new document starts a
14
+ * new observation, while retries of destroyed script contexts share the caller's deadline.
13
15
  */
14
16
  import { Duration, Effect, Schedule, Stream } from "effect";
15
17
 
16
18
  import { Timeout } from "../../BrowserError.ts";
17
19
  import type { ReadyOptions } from "../../Page.ts";
18
20
  import { type Bridge, scriptCall } from "../page/bridge.ts";
19
- import { decodeWith, failWith, type PageContext } from "../page/context.ts";
21
+ import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
20
22
  import type * as Capture from "../pictures/capture.ts";
21
23
  import { ReadinessSchema } from "./ready.inpage.ts";
22
24
 
@@ -34,9 +36,11 @@ export const make =
34
36
  "ready",
35
37
  Math.max(0, Math.round(until - page.now())),
36
38
  options.quietMillis ?? 0,
39
+ options.still === "content",
37
40
  ),
38
41
  ),
39
42
  ).pipe(
43
+ Effect.retry({ while: contextGone, schedule: Schedule.spaced(Duration.millis(10)) }),
40
44
  Effect.flatMap(decodeWith("ready", ReadinessSchema)),
41
45
  Effect.tap((waiting) => Effect.annotateCurrentSpan({ waiting: waiting.join(",") })),
42
46
  Effect.filterOrElse(
@@ -95,8 +99,10 @@ export const make =
95
99
  Effect.gen(function* () {
96
100
  const until = page.now() + Duration.toMillis(timeout);
97
101
 
102
+ if (options.still === "content") yield* bridge.registerRecorder("ready");
98
103
  yield* settled(until);
99
- if (options.quietMillis !== undefined) yield* quiet(options.quietMillis, until);
104
+ if (options.quietMillis !== undefined && options.still !== "content")
105
+ yield* quiet(options.quietMillis, until);
100
106
  }).pipe(page.within("ready", timeout)),
101
107
  ),
102
108
  page.span("Page.ready"),
@@ -28,6 +28,12 @@ export class Reopening extends Schema.TaggedClass<Reopening>()("Reopening", {})
28
28
  /** Open: `browser` gives this generation until the next one opens. */
29
29
  export class Open extends Schema.TaggedClass<Open>()("Open", {}) {}
30
30
 
31
+ /** No longer serving; holds end at this host-monotonic deadline or when their owners let go. */
32
+ export class Draining extends Schema.TaggedClass<Draining>()("Draining", {
33
+ reason: Schema.Literals(["rotated", "lost", "retired"]),
34
+ revokeAt: Schema.Finite,
35
+ }) {}
36
+
31
37
  /** Lost: its connection dropped, or its provider ended its session, as `cause` says. */
32
38
  export class Lost extends Schema.TaggedClass<Lost>()("Lost", {
33
39
  cause: DisconnectCause,
@@ -53,7 +59,16 @@ export class Closed extends Schema.TaggedClass<Closed>()("Closed", {
53
59
  */
54
60
  export class Kept extends Schema.TaggedClass<Kept>()("Kept", {}) {}
55
61
 
56
- export const GenerationState = Schema.Union([Opening, Reopening, Open, Lost, Down, Closed, Kept]);
62
+ export const GenerationState = Schema.Union([
63
+ Opening,
64
+ Reopening,
65
+ Open,
66
+ Draining,
67
+ Lost,
68
+ Down,
69
+ Closed,
70
+ Kept,
71
+ ]);
57
72
 
58
73
  export type GenerationState = typeof GenerationState.Type;
59
74
 
@@ -63,168 +78,314 @@ export interface Live<A> {
63
78
  readonly value: A;
64
79
  }
65
80
 
66
- /**
67
- * At most one generation opens and at most one serves. Both do while a rotation makes the next
68
- * before it breaks the current, and `Down` keeps serving one that a rotation failed to replace.
69
- */
70
- export type State<A> =
71
- | { readonly _tag: "Opening"; readonly number: number; readonly serving: Live<A> | undefined }
72
- | { readonly _tag: "Open"; readonly serving: Live<A> }
81
+ export interface Held<A, H> extends Live<A> {
82
+ readonly holds: ReadonlySet<H>;
83
+ }
84
+
85
+ /** Release ownership remains here until the provider and generation scope have finished. */
86
+ export interface Drain<A, H> {
87
+ readonly _tag: "Draining" | "Releasing" | "Leaving";
88
+ readonly live: Held<A, H>;
89
+ readonly ending: Draining;
90
+ }
91
+
92
+ /** One serving generation, one shared acquisition, and prior generations still owned. */
93
+ export type State<A, H = unknown> = (
94
+ | { readonly _tag: "Opening"; readonly number: number; readonly serving: Held<A, H> | undefined }
95
+ | { readonly _tag: "Open"; readonly serving: Held<A, H> }
73
96
  | {
74
97
  readonly _tag: "Down";
75
98
  readonly number: number;
76
99
  readonly detail: string;
77
100
  readonly cause: unknown;
78
- readonly serving: Live<A> | undefined;
101
+ readonly serving: Held<A, H> | undefined;
79
102
  }
80
- | { readonly _tag: "Retired" };
103
+ | { readonly _tag: "Retired" }
104
+ ) & { readonly draining: ReadonlyMap<number, Drain<A, H>> };
81
105
 
82
- export type Input<A> =
106
+ export type Input<A, H = unknown> =
83
107
  | { readonly _tag: "Opened"; readonly live: Live<A> }
84
- /** The open of `number` failed for good. */
85
108
  | {
86
109
  readonly _tag: "Failed";
87
110
  readonly number: number;
88
111
  readonly detail: string;
89
112
  readonly cause: unknown;
90
113
  }
91
- /** The open of `number` was stopped, as on retiring, before it opened. */
92
114
  | { readonly _tag: "Abandoned"; readonly number: number }
93
115
  | { readonly _tag: "Lost"; readonly number: number; readonly cause: DisconnectCause }
94
- /** A caller asks for the next generation, or the rotation time of `due` came. */
95
116
  | { readonly _tag: "Rotate"; readonly due?: number | undefined }
96
117
  | { readonly _tag: "Retire" }
97
- /** Stop as `Retire` does, but leave the serving generation running. */
98
118
  | { readonly _tag: "Keep" }
99
- | { readonly _tag: "Released"; readonly number: number; readonly released: Released };
119
+ | {
120
+ readonly _tag: "Hold";
121
+ readonly number?: number | undefined;
122
+ readonly hold: H;
123
+ }
124
+ | { readonly _tag: "Let"; readonly number: number; readonly hold: H }
125
+ | { readonly _tag: "Revoke"; readonly number: number }
126
+ | {
127
+ readonly _tag: "Released";
128
+ readonly number: number;
129
+ readonly released?: Released | undefined;
130
+ };
100
131
 
101
132
  export type Command<A> =
102
- /** Open `number`, after the release of `after` has finished. */
103
133
  | { readonly _tag: "Open"; readonly number: number; readonly after: number | undefined }
104
- /** Watch a generation that opened for its loss and its rotation time. */
105
134
  | { readonly _tag: "Watch"; readonly live: Live<A> }
106
- | { readonly _tag: "Release"; readonly live: Live<A> }
107
- /** Close its scope without asking its provider to release it. */
108
- | { readonly _tag: "Leave"; readonly live: Live<A> };
109
-
110
- export interface Step<A> {
111
- readonly state: State<A>;
112
- /** Generation numbers and their new states, to publish in this order. */
135
+ /** Signal ending and arrange revocation. Repeated only when loss/retirement accelerates it. */
136
+ | { readonly _tag: "Drain"; readonly live: Live<A>; readonly ending: Draining }
137
+ /** Revoke/close holds, await their Let inputs, release the provider, then close its scope. */
138
+ | { readonly _tag: "Release"; readonly live: Live<A>; readonly ending: Draining }
139
+ | { readonly _tag: "Leave"; readonly live: Live<A>; readonly ending: Draining };
140
+
141
+ export interface Step<A, H = unknown> {
142
+ readonly state: State<A, H>;
113
143
  readonly events: ReadonlyArray<readonly [number, GenerationState]>;
114
144
  readonly commands: ReadonlyArray<Command<A>>;
115
- /** For `Rotate`, the generation to wait for, unless retired. */
116
145
  readonly reply?: number | undefined;
146
+ readonly borrowed?: Live<A> | undefined;
117
147
  }
118
148
 
119
- export const start = <A>(): Step<A> => ({
120
- state: { _tag: "Opening", number: 1, serving: undefined },
149
+ export const start = <A, H = unknown>(): Step<A, H> => ({
150
+ state: { _tag: "Opening", number: 1, serving: undefined, draining: new Map() },
121
151
  events: [[1, new Opening()]],
122
152
  commands: [{ _tag: "Open", number: 1, after: undefined }],
123
153
  });
124
154
 
125
- const servingOf = <A>(state: State<A>) => (state._tag === "Retired" ? undefined : state.serving);
126
-
127
- /** The state after `input`, with the events to publish and the commands to run. */
128
- export const transition = <A>(state: State<A>, input: Input<A>, exclusive: boolean): Step<A> => {
129
- const release = (live: Live<A>): Command<A> => ({ _tag: "Release", live });
130
- const stay: Step<A> = { state, events: [], commands: [] };
131
-
132
- /**
133
- * Open `number`, ending `current` now if it `ends`; exclusive generations open only once its
134
- * release has finished. Otherwise `current` serves until the next one opens.
135
- */
136
- const next = (
137
- number: number,
138
- reopen: boolean,
139
- current: Live<A> | undefined,
140
- ends: boolean,
141
- ): Step<A> => ({
142
- state: { _tag: "Opening", number, serving: ends ? undefined : current },
143
- events: [[number, reopen ? new Reopening() : new Opening()]],
144
- commands: [
145
- ...(ends && current !== undefined ? [release(current)] : []),
146
- { _tag: "Open", number, after: ends && exclusive ? current?.number : undefined },
147
- ],
155
+ const servingOf = <A, H>(state: State<A, H>) =>
156
+ state._tag === "Retired" ? undefined : state.serving;
157
+
158
+ /** The holds still owned for a generation, including one whose release has begun. */
159
+ export const lookup = <A, H>(state: State<A, H>, number: number): Held<A, H> | undefined =>
160
+ servingOf(state)?.number === number ? servingOf(state) : state.draining.get(number)?.live;
161
+
162
+ /** A transition's local result builder; input state and its collections remain immutable. */
163
+ interface Change<A, H> {
164
+ readonly now: number;
165
+ readonly handover: number;
166
+ readonly exclusive: boolean;
167
+ state: State<A, H>;
168
+ events: Array<readonly [number, GenerationState]>;
169
+ commands: Array<Command<A>>;
170
+ reply?: number;
171
+ borrowed?: Live<A>;
172
+ }
173
+
174
+ /** The sole writer of a drain's ending and phase; each release command is emitted once. */
175
+ const draining = <A, H>(change: Change<A, H>, drain: Drain<A, H>) => {
176
+ const before = change.state.draining.get(drain.live.number);
177
+ const { live, ending, _tag } = drain;
178
+
179
+ change.state = {
180
+ ...change.state,
181
+ draining: new Map(change.state.draining).set(live.number, drain),
182
+ };
183
+ if (before?.ending !== ending) {
184
+ change.events.push([live.number, ending]);
185
+ change.commands.push({ _tag: "Drain", live, ending });
186
+ }
187
+ if (_tag !== "Draining" && before?._tag !== _tag)
188
+ change.commands.push({ _tag: _tag === "Leaving" ? "Leave" : "Release", live, ending });
189
+ };
190
+
191
+ const end = <A, H>(
192
+ change: Change<A, H>,
193
+ live: Held<A, H>,
194
+ reason: Draining["reason"],
195
+ keep = false,
196
+ ) => {
197
+ const ending = new Draining({
198
+ reason,
199
+ revokeAt: change.now + (reason === "rotated" ? change.handover : 0),
148
200
  });
149
201
 
202
+ draining(change, {
203
+ live,
204
+ ending,
205
+ _tag: keep
206
+ ? "Leaving"
207
+ : live.holds.size === 0 || ending.revokeAt <= change.now
208
+ ? "Releasing"
209
+ : "Draining",
210
+ });
211
+ };
212
+
213
+ const revoke = <A, H>(change: Change<A, H>, number: number, reason?: "lost" | "retired") => {
214
+ const drain = change.state.draining.get(number);
215
+
216
+ if (drain?._tag !== "Draining" || (reason === undefined && change.now < drain.ending.revokeAt))
217
+ return;
218
+ draining(change, {
219
+ ...drain,
220
+ _tag: "Releasing",
221
+ ending: reason === undefined ? drain.ending : new Draining({ reason, revokeAt: change.now }),
222
+ });
223
+ };
224
+
225
+ const holds = <A, H>(
226
+ change: Change<A, H>,
227
+ input: Extract<Input<A, H>, { readonly _tag: "Hold" | "Let" }>,
228
+ ) => {
229
+ const number = input.number ?? servingOf(change.state)?.number;
230
+ const live = number === undefined ? undefined : lookup(change.state, number);
231
+
232
+ if (live === undefined) return;
233
+ const drain = change.state.draining.get(live.number);
234
+
235
+ if (
236
+ input._tag === "Hold" &&
237
+ drain !== undefined &&
238
+ (drain._tag !== "Draining" || change.now >= drain.ending.revokeAt)
239
+ )
240
+ return;
241
+ if (live.holds.has(input.hold) === (input._tag === "Hold")) return;
242
+ const holds = new Set(live.holds);
243
+
244
+ if (input._tag === "Hold") {
245
+ holds.add(input.hold);
246
+ change.borrowed = live;
247
+ } else holds.delete(input.hold);
248
+ const held = { ...live, holds };
249
+
250
+ if (drain !== undefined)
251
+ draining(change, {
252
+ ...drain,
253
+ live: held,
254
+ _tag: drain._tag === "Draining" && holds.size === 0 ? "Releasing" : drain._tag,
255
+ });
256
+ else if (change.state._tag !== "Retired") change.state = { ...change.state, serving: held };
257
+ };
258
+
259
+ const next = <A, H>(change: Change<A, H>, number: number, reopen: boolean) => {
260
+ const { exclusive } = change;
261
+ const current = servingOf(change.state);
262
+
263
+ const after = exclusive
264
+ ? (current?.number ?? change.state.draining.keys().next().value)
265
+ : undefined;
266
+
267
+ if (current !== undefined && (reopen || exclusive))
268
+ end(change, current, reopen ? "lost" : "rotated");
269
+ change.state = {
270
+ _tag: "Opening",
271
+ number,
272
+ serving: reopen || exclusive ? undefined : current,
273
+ draining: change.state.draining,
274
+ };
275
+ change.events.push([number, reopen ? new Reopening() : new Opening()]);
276
+ change.commands.push({ _tag: "Open", number, after });
277
+ };
278
+
279
+ const lost = <A, H>(
280
+ change: Change<A, H>,
281
+ input: Extract<Input<A, H>, { readonly _tag: "Lost" }>,
282
+ ) => {
283
+ const { state } = change;
284
+ const drain = state.draining.get(input.number);
285
+ const current = servingOf(state);
286
+
287
+ if (drain?._tag === "Draining") {
288
+ change.events.push([input.number, new Lost({ cause: input.cause })]);
289
+ revoke(change, input.number, "lost");
290
+ } else if (state._tag !== "Retired" && current?.number === input.number) {
291
+ change.events.push([input.number, new Lost({ cause: input.cause })]);
292
+ if (state._tag !== "Opening")
293
+ next(change, (state._tag === "Open" ? current.number : state.number) + 1, true);
294
+ else {
295
+ change.state = { ...state, serving: undefined };
296
+ end(change, current, "lost");
297
+ }
298
+ }
299
+ };
300
+
301
+ /** Pure admission and lifecycle changes. A rejected hold preserves state identity. */
302
+ export const transition = <A, H = unknown>(
303
+ state: State<A, H>,
304
+ input: Input<A, H>,
305
+ exclusive: boolean,
306
+ now = 0,
307
+ handover = 30_000,
308
+ ): Step<A, H> => {
309
+ const change: Change<A, H> = {
310
+ state,
311
+ events: [],
312
+ commands: [],
313
+ exclusive,
314
+ handover,
315
+ now,
316
+ };
317
+
150
318
  switch (input._tag) {
319
+ case "Hold":
320
+ case "Let":
321
+ holds(change, input);
322
+ break;
323
+ case "Revoke":
324
+ revoke(change, input.number);
325
+ break;
151
326
  case "Opened": {
152
- const { live } = input;
153
-
154
- // A generation nobody waits for any more, as after `Retire`, is released at once.
155
- if (state._tag !== "Opening" || state.number !== live.number)
156
- return { ...stay, commands: [release(live)] };
157
-
158
- return {
159
- state: { _tag: "Open", serving: live },
160
- events: [[live.number, new Open()]],
161
- commands: [
162
- { _tag: "Watch", live },
163
- ...(state.serving === undefined ? [] : [release(state.serving)]),
164
- ],
165
- };
327
+ const live = { ...input.live, holds: new Set<H>() };
328
+
329
+ if (lookup(state, live.number) !== undefined) break;
330
+ if (state._tag !== "Opening" || state.number !== live.number) end(change, live, "retired");
331
+ else {
332
+ change.events.push([live.number, new Open()]);
333
+ change.commands.push({ _tag: "Watch", live });
334
+ if (state.serving !== undefined) end(change, state.serving, "rotated");
335
+ change.state = { _tag: "Open", serving: live, draining: change.state.draining };
336
+ }
337
+ break;
166
338
  }
167
339
  case "Failed": {
168
340
  const { number, detail, cause } = input;
169
- const events = [[number, new Down({ detail, cause })] as const];
170
-
171
- if (state._tag !== "Opening" || state.number !== input.number) return { ...stay, events };
172
341
 
173
- return {
174
- state: { _tag: "Down", number, detail, cause, serving: state.serving },
175
- events,
176
- commands: [],
177
- };
342
+ if (state._tag === "Opening" && state.number === number)
343
+ change.state = { ...state, _tag: "Down", detail, cause };
344
+ change.events.push([number, new Down({ detail, cause })]);
345
+ break;
178
346
  }
179
347
  case "Abandoned":
180
- return { ...stay, events: [[input.number, new Closed({})]] };
181
- case "Released":
182
- return { ...stay, events: [[input.number, new Closed({ released: input.released })]] };
183
- case "Lost": {
184
- const lost = servingOf(state);
185
-
186
- if (state._tag === "Retired" || lost?.number !== input.number) return stay;
187
- const events = [[lost.number, new Lost({ cause: input.cause })] as const];
188
-
189
- // A rotation already opening the next generation carries on without the lost one.
190
- if (state._tag === "Opening")
191
- return { state: { ...state, serving: undefined }, events, commands: [release(lost)] };
192
-
193
- const reopened = next(
194
- (state._tag === "Open" ? lost.number : state.number) + 1,
195
- true,
196
- lost,
197
- true,
198
- );
199
-
200
- return { ...reopened, events: [...events, ...reopened.events] };
348
+ change.events.push([input.number, new Closed({})]);
349
+ break;
350
+ case "Released": {
351
+ const drain = state.draining.get(input.number);
352
+
353
+ if (drain === undefined || drain._tag === "Draining" || drain.live.holds.size > 0) break;
354
+ const draining = new Map(state.draining);
355
+
356
+ draining.delete(input.number);
357
+ change.state = { ...state, draining };
358
+ change.events.push([
359
+ input.number,
360
+ input.released === undefined ? new Kept() : new Closed({ released: input.released }),
361
+ ]);
362
+ break;
201
363
  }
364
+ case "Lost":
365
+ lost(change, input);
366
+ break;
202
367
  case "Rotate": {
203
- if (state._tag === "Retired") return stay;
204
- // A rotation time comes only for the generation still serving with nothing opening.
205
- if (input.due !== undefined && (state._tag !== "Open" || state.serving.number !== input.due))
206
- return stay;
207
- if (state._tag === "Opening") return { ...stay, reply: state.number };
208
- const number = (state._tag === "Open" ? state.serving.number : state.number) + 1;
209
-
210
- return { ...next(number, false, state.serving, exclusive), reply: number };
368
+ if (
369
+ state._tag === "Retired" ||
370
+ (input.due !== undefined && (state._tag !== "Open" || state.serving.number !== input.due))
371
+ )
372
+ break;
373
+ change.reply =
374
+ state._tag === "Opening"
375
+ ? state.number
376
+ : (state._tag === "Open" ? state.serving.number : state.number) + 1;
377
+ if (state._tag !== "Opening") next(change, change.reply, false);
378
+ break;
211
379
  }
380
+ case "Keep":
212
381
  case "Retire":
213
- case "Keep": {
214
- const current = servingOf(state);
215
-
216
- if (state._tag === "Retired") return stay;
217
- if (current === undefined) return { state: { _tag: "Retired" }, events: [], commands: [] };
218
-
219
- return input._tag === "Retire"
220
- ? { state: { _tag: "Retired" }, events: [], commands: [release(current)] }
221
- : {
222
- state: { _tag: "Retired" },
223
- events: [[current.number, new Kept()]],
224
- commands: [{ _tag: "Leave", live: current }],
225
- };
226
- }
382
+ if (state._tag === "Retired") break;
383
+ for (const number of state.draining.keys()) revoke(change, number, "retired");
384
+ if (state.serving !== undefined) end(change, state.serving, "retired", input._tag === "Keep");
385
+ change.state = { _tag: "Retired", draining: change.state.draining };
227
386
  }
387
+
388
+ return change;
228
389
  };
229
390
 
230
391
  export type Resolution<A> =
@@ -236,30 +397,20 @@ export type Resolution<A> =
236
397
  readonly cause?: unknown;
237
398
  };
238
399
 
239
- /**
240
- * What a caller waiting for generation `target`, or for any when it is undefined, gets in
241
- * `state`; undefined while it should go on waiting.
242
- */
243
- export const resolve = <A>(
244
- state: State<A>,
400
+ /** An unheld browser resolution; use obtains its hold atomically through Hold instead. */
401
+ export const resolve = <A, H>(
402
+ state: State<A, H>,
245
403
  target: number | undefined,
246
404
  ): Resolution<A> | undefined => {
247
- const serve = (live: Live<A>) => ({ _tag: "Serve" as const, live });
248
-
249
- switch (state._tag) {
250
- case "Retired":
251
- return { _tag: "Unavailable", reason: "retired", detail: "the supervisor was retired" };
252
- case "Open":
253
- return target === undefined || state.serving.number >= target
254
- ? serve(state.serving)
255
- : undefined;
256
- case "Opening":
257
- return target === undefined && state.serving !== undefined ? serve(state.serving) : undefined;
258
- case "Down":
259
- if (target === undefined && state.serving !== undefined) return serve(state.serving);
260
-
261
- return target === undefined || state.number >= target
262
- ? { _tag: "Unavailable", reason: "down", detail: state.detail, cause: state.cause }
263
- : undefined;
264
- }
405
+ if (state._tag === "Retired")
406
+ return { _tag: "Unavailable", reason: "retired", detail: "the supervisor was retired" };
407
+ if (
408
+ state.serving !== undefined &&
409
+ (target === undefined || (state._tag === "Open" && state.serving.number >= target))
410
+ )
411
+ return { _tag: "Serve", live: state.serving };
412
+ if (state._tag === "Down" && (target === undefined || state.number >= target))
413
+ return { _tag: "Unavailable", reason: "down", detail: state.detail, cause: state.cause };
414
+
415
+ return undefined;
265
416
  };