effect-browser 0.3.0-beta.11 → 0.3.0-beta.14

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 (57) hide show
  1. package/README.md +76 -29
  2. package/dist/{Browser-B4qncxe7.mjs → Browser-BBSySyjT.mjs} +3 -3
  3. package/dist/{Browser-B4qncxe7.mjs.map → Browser-BBSySyjT.mjs.map} +1 -1
  4. package/dist/Browser.d.mts +1 -1
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/Cdp.mjs +2 -2
  7. package/dist/Chromium.mjs +2 -2
  8. package/dist/Expect.d.mts +1 -1
  9. package/dist/Expect.mjs +2 -2
  10. package/dist/{Frame-Di2PxyF7.d.mts → Frame-DkNdjjjr.d.mts} +3 -5
  11. package/dist/Frame.d.mts +1 -1
  12. package/dist/Frame.mjs +2 -4
  13. package/dist/Frame.mjs.map +1 -1
  14. package/dist/Moment.d.mts +1 -1
  15. package/dist/Moment.mjs +8 -4
  16. package/dist/Moment.mjs.map +1 -1
  17. package/dist/{Page-B7X6DQUn.d.mts → Page-DL9eylp3.d.mts} +2 -2
  18. package/dist/Page.d.mts +1 -1
  19. package/dist/{Plan-Akcac63g.d.mts → Plan-6As6vcwT.d.mts} +16 -9
  20. package/dist/{Plan-D1wrJVs8.mjs → Plan-CXA2BW-G.mjs} +24 -17
  21. package/dist/Plan-CXA2BW-G.mjs.map +1 -0
  22. package/dist/Plan.d.mts +1 -1
  23. package/dist/Plan.mjs +1 -1
  24. package/dist/Policy.d.mts +1 -1
  25. package/dist/Presentation.d.mts +1 -1
  26. package/dist/Presentation.mjs +1 -1
  27. package/dist/Stage.d.mts +5 -4
  28. package/dist/Stage.mjs +55 -22
  29. package/dist/Stage.mjs.map +1 -1
  30. package/dist/{Supervisor-TNKNOVzu.mjs → Supervisor-BE2sGHBK.mjs} +67 -41
  31. package/dist/Supervisor-BE2sGHBK.mjs.map +1 -0
  32. package/dist/{Supervisor-CYWFK3vM.d.mts → Supervisor-W60pZWHh.d.mts} +24 -8
  33. package/dist/Supervisor.d.mts +2 -2
  34. package/dist/Supervisor.mjs +1 -1
  35. package/dist/{context-B6xr8nVi.mjs → context-BAjRj7Xo.mjs} +3 -1
  36. package/dist/{context-B6xr8nVi.mjs.map → context-BAjRj7Xo.mjs.map} +1 -1
  37. package/dist/index.d.mts +4 -4
  38. package/dist/index.mjs +3 -3
  39. package/dist/{page-BAf__WKX.mjs → page-B92QnRfT.mjs} +201 -172
  40. package/dist/page-B92QnRfT.mjs.map +1 -0
  41. package/package.json +1 -1
  42. package/src/Frame.ts +2 -4
  43. package/src/Moment.ts +14 -3
  44. package/src/Plan.ts +33 -21
  45. package/src/Stage.ts +72 -25
  46. package/src/Supervisor.ts +69 -37
  47. package/src/internal/input/settle.ts +8 -3
  48. package/src/internal/page/context.ts +8 -0
  49. package/src/internal/page/navigation.ts +21 -15
  50. package/src/internal/pictures/capture.ts +172 -188
  51. package/src/internal/pictures/pictures.ts +50 -73
  52. package/src/internal/pictures/quiescence.ts +94 -0
  53. package/src/internal/reading/ready.ts +9 -3
  54. package/src/internal/supervisor/lifecycle.ts +27 -12
  55. package/dist/Plan-D1wrJVs8.mjs.map +0 -1
  56. package/dist/Supervisor-TNKNOVzu.mjs.map +0 -1
  57. package/dist/page-BAf__WKX.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-browser",
3
- "version": "0.3.0-beta.11",
3
+ "version": "0.3.0-beta.14",
4
4
  "description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/Frame.ts CHANGED
@@ -93,10 +93,8 @@ export class CaptureStats extends Schema.Class<CaptureStats>("effect-browser/Cap
93
93
  */
94
94
  foreignSize: Count,
95
95
  /**
96
- * Frames dropped because they may have been painted, or arrived, while the library took a clipped
97
- * or scaled picture of the page, such as a zoom: Chromium draws those into the screencast too.
98
- * Where Playwright emulates the viewport, the page's own frames from that time are among them; a
99
- * crop on the page's own session leaves out only frames of another size than the page's.
96
+ * Frames discarded while native capture is paused for a library picture and its restoration.
97
+ * A pause can produce no callbacks, so this count may remain zero even when pictures were taken.
100
98
  */
101
99
  duringPictures: Count,
102
100
  /**
package/src/Moment.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  * part it could not read, which it names with why.
20
20
  *
21
21
  * Frames come from a running screencast, as far back as the browser's `frameHistory` keeps them.
22
- * A moment's picture is the newest frame if it was painted after the page's latest input and at
23
- * most 250 ms ago, else a new screenshot timed by its capture.
22
+ * A moment's picture is the newest frame if it was painted after the page's latest input and
23
+ * completed action, at most 250 ms ago, else a new screenshot timed by its capture.
24
24
  *
25
25
  * @since 0.3.0
26
26
  */
@@ -98,6 +98,17 @@ const spread = (frames: ReadonlyArray<Frame>, count: number): ReadonlyArray<Fram
98
98
  );
99
99
  };
100
100
 
101
+ // An action ends after its navigation settles, which can follow the last paint. A fresh picture
102
+ // keeps that completed action inside the moment without moving events between adjacent windows.
103
+ const pictureOf = Effect.fnUntraced(function* (page: Page.Page) {
104
+ const frame = yield* page.frame({ after: "input" });
105
+ const events = yield* page.recentEvents;
106
+
107
+ return events.some((event) => event._tag === "Action" && event.at > frame.hostTime)
108
+ ? yield* page.frame({ maxAge: 0 })
109
+ : frame;
110
+ });
111
+
101
112
  export const capture = Effect.fn("Moment.capture")(function* (
102
113
  page: Page.Page,
103
114
  options: CaptureOptions = {},
@@ -116,7 +127,7 @@ export const capture = Effect.fn("Moment.capture")(function* (
116
127
  // outline is taken alongside. What cannot be read is missing from the moment, not its end.
117
128
  const { pictured, outlined } = yield* Effect.all(
118
129
  {
119
- pictured: Effect.result(page.frame({ after: "input" })).pipe(
130
+ pictured: Effect.result(pictureOf(page)).pipe(
120
131
  Effect.flatMap((picture) =>
121
132
  Effect.map(
122
133
  page.window({
package/src/Plan.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * as a named input slot, never the text itself. A navigation to an address whose credentials the
8
8
  * events withheld is an input slot too, so replay goes where the caller says, or nowhere, and
9
9
  * never to the address without them. `replay` takes the steps in turn. Before a step
10
- * that acts on the page, it waits for `Page.ready`; then it finds the one element the step's
10
+ * except an opening navigate, back or reload, it waits for `Page.ready`; then it finds the element its
11
11
  * subject names, by role and name among those that repeat all its recorded context, and acts on
12
12
  * it. Text typed into focus was typed into the focused field, which replay finds the same way, and
13
13
  * text typed into a secret field goes into nothing else. An element step never falls back to
@@ -69,8 +69,9 @@ const Query = Schema.Struct({
69
69
 
70
70
  /**
71
71
  * What a step leaves. `durable` asserts an author-chosen, lasting read-back of this operation,
72
- * such as its uniquely named saved receipt. A toast or an intermediate address is not durable.
73
- * Even a durable check's absence never authorizes repeating unknown consequential input.
72
+ * such as its uniquely named saved receipt or exact operation URL. Only Appeared and an exact
73
+ * Navigated URL allow it; a toast, absence or relative navigation cannot establish recovery.
74
+ * Relative navigation measures observed change from dispatch, without asserting causality.
74
75
  */
75
76
  export const Expected = Schema.Union([
76
77
  Schema.TaggedStruct("Checked", { checked: Schema.Boolean }),
@@ -80,12 +81,16 @@ export const Expected = Schema.Union([
80
81
  }),
81
82
  Schema.TaggedStruct("Gone", {
82
83
  query: Query,
83
- durable: Schema.optional(Schema.Literal(true)),
84
+ durable: Schema.optional(Schema.Never),
84
85
  }),
85
86
  Schema.TaggedStruct("Navigated", {
86
- url: Schema.optional(Schema.String),
87
+ url: Schema.String,
87
88
  durable: Schema.optional(Schema.Literal(true)),
88
89
  }),
90
+ Schema.TaggedStruct("Navigated", {
91
+ url: Schema.optional(Schema.Never),
92
+ durable: Schema.optional(Schema.Never),
93
+ }),
89
94
  ]);
90
95
 
91
96
  export type Expected = typeof Expected.Type;
@@ -377,7 +382,7 @@ const dispatch = (
377
382
  };
378
383
 
379
384
  export interface TakeOptions {
380
- /** How each step waits for the page first, or false not to wait. */
385
+ /** How each step waits first, except an opening navigation, or false. No final-step wait. */
381
386
  readonly settle?: ReadyOptions | false | undefined;
382
387
  readonly inputs?: Readonly<Record<string, string>> | undefined;
383
388
  /**
@@ -444,7 +449,7 @@ const arrived = (page: Page, urls: ReadonlyArray<string>, millis: number) =>
444
449
  const checkEffect = (
445
450
  page: Page,
446
451
  step: Step,
447
- first: { readonly url: string; readonly document: number },
452
+ first?: { readonly url: string; readonly document: number },
448
453
  ) => {
449
454
  const expected = step.expect;
450
455
 
@@ -459,9 +464,11 @@ const checkEffect = (
459
464
  : Effect.succeed(found.state.checked === expected.checked),
460
465
  );
461
466
  case "Navigated":
467
+ if (expected.url === undefined && first === undefined) return Effect.succeed(false);
468
+
462
469
  return Effect.map(page.state, (now) =>
463
470
  expected.url === undefined
464
- ? now.url !== first.url || now.document !== first.document
471
+ ? now.url !== first?.url || now.document !== first?.document
465
472
  : now.url === expected.url,
466
473
  );
467
474
  case "Appeared":
@@ -509,19 +516,27 @@ export const take = Effect.fn("Plan.take")(function* (
509
516
  const unresolved = input === "unknown" && !repeatable(step);
510
517
  const expected = step.expect;
511
518
 
519
+ const durable =
520
+ expected !== undefined &&
521
+ (expected._tag === "Appeared" ||
522
+ (expected._tag === "Navigated" && typeof expected.url === "string")) &&
523
+ expected.durable === true;
524
+
512
525
  if (
513
- unresolved &&
514
- (expected === undefined || expected._tag === "Checked" || expected.durable !== true)
526
+ !durable &&
527
+ (unresolved || (expected !== undefined && "durable" in expected && expected.durable === true))
515
528
  )
516
529
  return yield* uncertain;
517
- const settle = options.settle === false ? Effect.void : page.ready(options.settle);
518
-
519
- if (!["navigate", "back", "reload"].includes(step.action)) yield* settle;
530
+ if (
531
+ options.settle !== false &&
532
+ (index > 0 || !["navigate", "back", "reload"].includes(step.action))
533
+ )
534
+ yield* page.ready(options.settle);
520
535
  const before = plan.steps[index - 1]?.url;
521
536
 
522
537
  if (before !== undefined)
523
538
  yield* arrived(page, unresolved ? [before, step.url] : [before], millis);
524
- const check = checkEffect(page, step, first);
539
+ const check = checkEffect(page, step);
525
540
 
526
541
  const read =
527
542
  millis === 0
@@ -538,7 +553,6 @@ export const take = Effect.fn("Plan.take")(function* (
538
553
 
539
554
  const receipt = (outcome: Receipt["outcome"]) =>
540
555
  Effect.gen(function* () {
541
- if (index === plan.steps.length - 1) yield* settle;
542
556
  yield* arrived(page, [step.url], millis);
543
557
  const now = yield* page.state;
544
558
 
@@ -550,6 +564,7 @@ export const take = Effect.fn("Plan.take")(function* (
550
564
  const text = step.input === undefined ? "" : options.inputs?.[step.input];
551
565
 
552
566
  if (text === undefined) return yield* invalid(`no text for the input "${step.input ?? ""}"`);
567
+ const verify = checkEffect(page, step, yield* page.state);
553
568
  const acted = yield* Effect.exit(dispatch(page, step, text));
554
569
 
555
570
  if (Exit.isFailure(acted)) {
@@ -557,11 +572,8 @@ export const take = Effect.fn("Plan.take")(function* (
557
572
 
558
573
  if (Option.isSome(error) && error.value._tag === "BrowserError" && error.value.dispatched) {
559
574
  input = "unknown";
560
- if (
561
- expected !== undefined &&
562
- (repeatable(step) || (expected._tag !== "Checked" && expected.durable === true))
563
- ) {
564
- const observed = yield* Effect.exit(within(check, millis));
575
+ if (expected !== undefined && (repeatable(step) || durable)) {
576
+ const observed = yield* Effect.exit(within(verify, millis));
565
577
 
566
578
  if (Exit.isSuccess(observed) && observed.value) return yield* receipt("verified");
567
579
  }
@@ -570,7 +582,7 @@ export const take = Effect.fn("Plan.take")(function* (
570
582
  return yield* Effect.failCause(acted.cause);
571
583
  }
572
584
  input = "unknown";
573
- if (expected !== undefined && !(yield* within(check, millis)))
585
+ if (expected !== undefined && !(yield* within(verify, millis)))
574
586
  return yield* new Drifted({ detail: "the step's expected effect did not arrive" });
575
587
 
576
588
  return yield* receipt(expected === undefined ? "taken" : "verified");
package/src/Stage.ts CHANGED
@@ -50,9 +50,9 @@ export interface Cue {
50
50
  }) => Effect.Effect<Presented, BrowserError>;
51
51
  }
52
52
 
53
- /** What the output establishes about its last image; loss ends this page's evidence. */
53
+ /** What the output establishes at nondecreasing host times; frames retain their capture times. */
54
54
  export type Shown =
55
- | { readonly _tag: "Painted"; readonly frame: Frame }
55
+ | { readonly _tag: "Painted"; readonly frame: Frame; readonly at: number }
56
56
  | { readonly _tag: "Still"; readonly frame: Frame; readonly at: number }
57
57
  | { readonly _tag: "Stalled"; readonly frame: Frame; readonly at: number }
58
58
  | { readonly _tag: "Gone"; readonly page: string; readonly at: number };
@@ -65,7 +65,7 @@ export interface Stage {
65
65
  readonly cue: (page: Page) => Effect.Effect<Cue, BrowserError, Scope.Scope>;
66
66
  /** Paint and, after every finite positive hold without paint, current evidence or loss. */
67
67
  readonly shown: (options: { readonly hold: Duration.Input }) => Stream.Stream<Shown>;
68
- /** Current-page paint; a reader more than 16 frames behind loses the oldest. */
68
+ /** Capture-timed current-page paint; use `shown` for output time. Readers retain 16 frames. */
69
69
  readonly frames: Stream.Stream<Frame>;
70
70
  readonly current: Effect.Effect<Option.Option<Page>>;
71
71
  readonly stats: (options?: {
@@ -87,9 +87,17 @@ interface Feed {
87
87
  interface Paint {
88
88
  readonly feed: Feed;
89
89
  readonly frame: Frame;
90
+ readonly at: number;
90
91
  }
91
92
 
92
- const closed = () => undispatched("cue", new Closed({ cause: "released" }));
93
+ interface Evidence {
94
+ readonly feed: Feed;
95
+ readonly frame: Frame;
96
+ readonly value: Shown;
97
+ }
98
+
99
+ const unavailable = () =>
100
+ undispatched("cue", new InvalidRequest({ detail: "the cue's capture is no longer available" }));
93
101
 
94
102
  /** The sole writer of a cue's capture lifecycle; presentation ownership belongs to the stage. */
95
103
  const update = (
@@ -131,13 +139,13 @@ const ended = (feed: Feed): Effect.Effect<never, BrowserError> =>
131
139
  Effect.flatMap((exit) =>
132
140
  Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)
133
141
  ? Effect.failCause(exit.cause)
134
- : Effect.fail(closed()),
142
+ : Effect.fail(unavailable()),
135
143
  ),
136
144
  );
137
145
 
138
146
  const firstOf = (feed: Feed, wait: Duration.Duration) =>
139
147
  Effect.suspend(() => {
140
- if (feed.state._tag === "Stopped") return Effect.fail(closed());
148
+ if (feed.state._tag === "Stopped") return Effect.fail(unavailable());
141
149
  if (feed.page.playwright.isClosed()) return failWith("cue", new Closed({ cause: "page" }));
142
150
  if (frameOf(feed) !== undefined) return Effect.void;
143
151
 
@@ -225,6 +233,8 @@ const evidence = (
225
233
  throw new RangeError("shown hold must be a finite positive duration");
226
234
  const millis = Duration.toMillis(parsed.value);
227
235
  let checkedAt = now();
236
+ let shownAt = Number.NEGATIVE_INFINITY;
237
+ let seen: Pick<Paint, "feed" | "frame"> | undefined = current();
228
238
  let gone: Feed | undefined;
229
239
 
230
240
  const probe = Effect.gen(function* () {
@@ -239,37 +249,67 @@ const evidence = (
239
249
  gone === before.feed ||
240
250
  checkedAt - before.changedAt < millis
241
251
  )
242
- return Option.none<Shown>();
252
+ return Option.none<Evidence>();
243
253
  const { feed } = before;
244
254
 
245
255
  const confirmed = yield* Effect.result(confirm(feed, frame));
246
256
 
247
- if (current()?.feed !== feed || current()?.frame !== frame) return Option.none<Shown>();
257
+ if (current()?.feed !== feed || current()?.frame !== frame) return Option.none<Evidence>();
248
258
  const at = now();
249
259
 
250
260
  checkedAt = at;
251
261
  if (Result.isFailure(confirmed) && consequence(confirmed.failure).lost !== "nothing") {
252
- gone = feed;
253
-
254
- return Option.some<Shown>({ _tag: "Gone", page: feed.page.id, at });
262
+ return Option.some<Evidence>({
263
+ feed,
264
+ frame,
265
+ value: { _tag: "Gone", page: feed.page.id, at },
266
+ });
255
267
  }
256
268
 
257
- return Option.some<Shown>({
258
- _tag: Result.isSuccess(confirmed) && confirmed.success ? "Still" : "Stalled",
269
+ return Option.some<Evidence>({
270
+ feed,
259
271
  frame,
260
- at,
272
+ value: {
273
+ _tag: Result.isSuccess(confirmed) && confirmed.success ? "Still" : "Stalled",
274
+ frame,
275
+ at,
276
+ },
261
277
  });
262
278
  });
263
279
 
264
280
  return Stream.merge(
265
281
  Stream.fromPubSub(output).pipe(
266
- Stream.filter(({ feed }) => gone !== feed),
267
- Stream.map(({ frame }): Shown => ({ _tag: "Painted", frame })),
282
+ Stream.map(({ feed, frame, at }): Evidence => ({
283
+ feed,
284
+ frame,
285
+ value: { _tag: "Painted", frame, at },
286
+ })),
268
287
  ),
269
288
  Stream.fromEffectRepeat(probe).pipe(
270
289
  Stream.filter(Option.isSome),
271
290
  Stream.map(({ value }) => value),
272
291
  ),
292
+ ).pipe(
293
+ // A probe can overtake queued paint while its reader is paused. It establishes nothing
294
+ // about that reader's output until the matching frame has reached it.
295
+ Stream.filter(({ feed, frame, value }) => {
296
+ if (value._tag === "Painted" && gone !== feed) {
297
+ seen = { feed, frame };
298
+
299
+ return true;
300
+ }
301
+ // The subscription may have begun after its first publication.
302
+ seen ??= { feed, frame };
303
+
304
+ return value._tag !== "Painted" && seen?.feed === feed && seen.frame === frame;
305
+ }),
306
+ Stream.map(({ feed, value }) => {
307
+ if (value._tag === "Gone") gone = feed;
308
+ // A frame captured before a silence probe can arrive after the probe's evidence.
309
+ shownAt = Math.max(shownAt, value.at);
310
+
311
+ return value.at === shownAt ? value : { ...value, at: shownAt };
312
+ }),
273
313
  );
274
314
  }),
275
315
  );
@@ -375,15 +415,15 @@ export const make = Effect.fn("Stage.make")(function* (
375
415
 
376
416
  yield* Effect.addFinalizer(() => PubSub.shutdown(output));
377
417
 
378
- const publish = (feed: Feed, frame: Frame) =>
418
+ const publish = (feed: Feed, frame: Frame, at = frame.hostTime) =>
379
419
  Effect.suspend(() => {
380
- painted = Math.max(painted, frame.hostTime);
420
+ painted = Math.max(painted, at);
381
421
  if (showing !== undefined) {
382
422
  showing.changedAt = now();
383
423
  showing.frame = frame;
384
424
  }
385
425
 
386
- return PubSub.publish(output, { feed, frame });
426
+ return PubSub.publish(output, { feed, frame, at: painted });
387
427
  });
388
428
 
389
429
  const commit = (feed: Feed, options: { readonly at?: number | undefined } = {}) =>
@@ -407,7 +447,7 @@ export const make = Effect.fn("Stage.make")(function* (
407
447
  const switchTo = Effect.suspend(() => {
408
448
  const frame = frameOf(feed);
409
449
 
410
- if (frame === undefined) return Effect.fail(closed());
450
+ if (frame === undefined) return Effect.fail(unavailable());
411
451
  const at = Math.max(now(), asked, frame.hostTime, painted);
412
452
 
413
453
  showing = {
@@ -417,10 +457,7 @@ export const make = Effect.fn("Stage.make")(function* (
417
457
  changedAt: at,
418
458
  };
419
459
 
420
- return publish(feed, frame).pipe(
421
- Effect.andThen(old === undefined ? Effect.void : stop(old.feed)),
422
- Effect.as(showing.presented),
423
- );
460
+ return publish(feed, frame, at).pipe(Effect.as(showing.presented));
424
461
  }).pipe(Effect.uninterruptible);
425
462
 
426
463
  const readyToSwitch = firstOf(feed, firstFrame).pipe(
@@ -430,7 +467,17 @@ export const make = Effect.fn("Stage.make")(function* (
430
467
 
431
468
  const before = old === undefined ? undefined : internalsOf(old.feed.page);
432
469
 
433
- return yield* before === undefined ? readyToSwitch : before.afterInput(readyToSwitch);
470
+ return yield* (
471
+ before === undefined ? readyToSwitch : before.afterInput(readyToSwitch)
472
+ ).pipe(
473
+ // The cut owns old-page input through publication. Capture cleanup has its own bound
474
+ // and must finish even if the caller leaves, without turning a completed cut into Timeout.
475
+ Effect.ensuring(
476
+ Effect.suspend(() =>
477
+ showing?.feed === feed && old !== undefined ? stop(old.feed) : Effect.void,
478
+ ),
479
+ ),
480
+ );
434
481
  }).pipe(Effect.provideService(Clock.Clock, clock)),
435
482
  );
436
483
  });
package/src/Supervisor.ts CHANGED
@@ -92,9 +92,21 @@ export interface Opened<A = void> {
92
92
  readonly release?: Effect.Effect<Lifecycle.Released> | undefined;
93
93
  }
94
94
 
95
+ /** Facts available before an acquisition attempt; awaiting `after` never changes release order. */
96
+ export interface Admission {
97
+ readonly number: number;
98
+ /**
99
+ * The preceding generation's release once it stops serving. Undefined on the first open and
100
+ * while a nonexclusive rotation keeps it serving; checked again before each retry.
101
+ */
102
+ readonly after: Effect.Effect<Lifecycle.Released> | undefined;
103
+ }
104
+
95
105
  export interface Options<E, R, A = void> {
96
106
  /** Opens one generation in the scope it is given. */
97
107
  readonly open: Effect.Effect<Opened<A>, E, R>;
108
+ /** Runs before every open attempt, including the first; failures follow `reopen` and `definite`. */
109
+ readonly admit?: ((opening: Admission) => Effect.Effect<void, E, R>) | undefined;
98
110
  /** When to try a failed open again. Defaults to exponential from 1 second, for 5 minutes. */
99
111
  readonly reopen?: Schedule.Schedule<unknown, E> | undefined;
100
112
  /**
@@ -132,6 +144,8 @@ export interface Lease<A> {
132
144
  readonly generation: number;
133
145
  readonly browser: Browser.Service;
134
146
  readonly value: A;
147
+ /** Declare this serving generation unusable and revoke it now; stale declarations do nothing. */
148
+ readonly lose: (detail: string) => Effect.Effect<void>;
135
149
  /** First notice that serving ended; loss or retirement may revoke before its deadline. */
136
150
  readonly ending: Effect.Effect<Superseded>;
137
151
  /** An independent scope; its resources close before provider release. Fails once revoked. */
@@ -167,6 +181,7 @@ interface Held<A> {
167
181
  readonly opened: Opened<A>;
168
182
  readonly ending: Deferred.Deferred<Superseded>;
169
183
  readonly revoked: Deferred.Deferred<Superseded>;
184
+ readonly released: Deferred.Deferred<Lifecycle.Released>;
170
185
  readonly scope: Scope.Closeable;
171
186
  }
172
187
 
@@ -175,6 +190,9 @@ type Apply<A> = (
175
190
  input: Lifecycle.Input<Held<A>, Hold>,
176
191
  ) => Effect.Effect<Lifecycle.Step<Held<A>, Hold>>;
177
192
 
193
+ const superseded = (generation: number, { reason, revokeAt }: Lifecycle.Draining) =>
194
+ new Superseded({ generation, reason, revokeAt });
195
+
178
196
  const retired = () => new Unavailable({ reason: "retired", detail: "the supervisor was retired" });
179
197
 
180
198
  const describe = (cause: Cause.Cause<unknown>) => {
@@ -278,6 +296,8 @@ const leasing = <A>(
278
296
  generation: live.number,
279
297
  browser: live.value.opened.browser,
280
298
  value: live.value.opened.value,
299
+ lose: (detail) =>
300
+ apply({ _tag: "Lost", number: live.number, cause: "declared", detail }).pipe(Effect.asVoid),
281
301
  ending: Deferred.await(live.value.ending),
282
302
  retain: Effect.uninterruptible(
283
303
  Effect.gen(function* () {
@@ -310,9 +330,18 @@ const leasing = <A>(
310
330
  apply({ _tag: "Let", number: live.number, hold }).pipe(Effect.asVoid),
311
331
  );
312
332
 
333
+ const revoked = Deferred.await(live.value.revoked).pipe(Effect.flatMap(Effect.fail));
334
+
313
335
  return yield* Effect.raceFirst(
314
- work(leaseOf(live)),
315
- Deferred.await(live.value.revoked).pipe(Effect.flatMap(Effect.fail)),
336
+ work(leaseOf(live)).pipe(
337
+ // Declaring loss and returning can finish before the race's other fiber runs.
338
+ Effect.tap(() =>
339
+ Deferred.isDone(live.value.revoked).pipe(
340
+ Effect.flatMap((done) => (done ? revoked : Effect.void)),
341
+ ),
342
+ ),
343
+ ),
344
+ revoked,
316
345
  );
317
346
  }),
318
347
  );
@@ -338,6 +367,7 @@ const acquireGeneration = <A, E, R>(
338
367
  scope,
339
368
  ending: Deferred.makeUnsafe<Superseded>(),
340
369
  revoked: Deferred.makeUnsafe<Superseded>(),
370
+ released: Deferred.makeUnsafe<Lifecycle.Released>(),
341
371
  })),
342
372
  ),
343
373
  ),
@@ -369,23 +399,14 @@ const watchGeneration = <A>(
369
399
  return Effect.all([loss, rotation], { concurrency: 2, discard: true });
370
400
  };
371
401
 
372
- // Revocation stops use work; retained resources and use finalizers let go before the provider.
402
+ // After revocation, retained resources and use finalizers let go before the provider.
373
403
  const releaseGeneration = <A>(
374
404
  command: Extract<Lifecycle.Command<Held<A>>, { readonly _tag: "Release" | "Leave" }>,
375
405
  state: SubscriptionRef.SubscriptionRef<State<A>>,
376
406
  apply: Apply<A>,
377
407
  ) =>
378
408
  Effect.gen(function* () {
379
- const { live, ending } = command;
380
-
381
- yield* Deferred.succeed(
382
- live.value.revoked,
383
- new Superseded({
384
- generation: live.number,
385
- reason: ending.reason,
386
- revokeAt: ending.revokeAt,
387
- }),
388
- );
409
+ const { live } = command;
389
410
  const holds = Lifecycle.lookup(state.value, live.number)?.holds ?? [];
390
411
 
391
412
  const cleaned = yield* Effect.forEach(
@@ -414,9 +435,25 @@ const releaseGeneration = <A>(
414
435
  const closed = yield* Effect.exit(Scope.close(live.value.scope, Exit.void));
415
436
 
416
437
  yield* apply({ _tag: "Released", number: live.number, released });
438
+ if (released !== undefined) yield* Deferred.succeed(live.value.released, released);
417
439
  yield* Exit.asVoidAll([...cleaned, closed]);
418
440
  });
419
441
 
442
+ const openedInput = <A, E>(
443
+ number: number,
444
+ exit: Exit.Exit<Held<A>, E>,
445
+ ): Lifecycle.Input<Held<A>, Hold> =>
446
+ Exit.isSuccess(exit)
447
+ ? { _tag: "Opened", live: { number, value: exit.value } }
448
+ : Cause.hasInterruptsOnly(exit.cause)
449
+ ? { _tag: "Abandoned", number }
450
+ : { _tag: "Failed", number, detail: describe(exit.cause), cause: Cause.squash(exit.cause) };
451
+
452
+ const admissionAfter = <A>(previous: Lifecycle.Live<Held<A>> | undefined, state: State<A>) =>
453
+ previous === undefined || (state._tag !== "Retired" && state.serving?.number === previous.number)
454
+ ? undefined
455
+ : Deferred.await(previous.value.released);
456
+
420
457
  const makeScoped = Effect.fn("Supervisor.make")(function* <E, R, A>(
421
458
  options: MakeOptions<E, R, A>,
422
459
  ): Effect.fn.Return<Supervisor<A | void>, never, Scope.Scope | Exclude<R, Scope.Scope>> {
@@ -489,11 +526,7 @@ const makeScoped = Effect.fn("Supervisor.make")(function* <E, R, A>(
489
526
  );
490
527
  break;
491
528
  case "Drain": {
492
- const ending = new Superseded({
493
- generation: command.live.number,
494
- reason: command.ending.reason,
495
- revokeAt: command.ending.revokeAt,
496
- });
529
+ const ending = superseded(command.live.number, command.ending);
497
530
 
498
531
  yield* Deferred.succeed(command.live.value.ending, ending);
499
532
  yield* Effect.forkIn(
@@ -508,40 +541,39 @@ const makeScoped = Effect.fn("Supervisor.make")(function* <E, R, A>(
508
541
  }
509
542
  case "Release":
510
543
  case "Leave":
544
+ yield* Deferred.succeed(
545
+ command.live.value.revoked,
546
+ superseded(command.live.number, command.ending),
547
+ );
511
548
  runRelease(command.live.number, releaseGeneration(command, state, apply));
512
549
  break;
513
550
  }
514
551
  });
515
552
 
516
- const openOnce = acquireGeneration(options.open, own).pipe(Effect.provideContext(context));
553
+ const openGeneration = (number: number, previous: Lifecycle.Live<Held<A | void>> | undefined) => {
554
+ const after = previous === undefined ? undefined : Deferred.await(previous.value.released);
555
+
556
+ const openOnce = acquireGeneration(
557
+ Effect.suspend(
558
+ () =>
559
+ options.admit?.({ number, after: admissionAfter(previous, state.value) }) ?? Effect.void,
560
+ ).pipe(Effect.andThen(options.open)),
561
+ own,
562
+ ).pipe(Effect.provideContext(context));
517
563
 
518
- const openGeneration = (number: number, after: number | undefined) =>
519
- Effect.uninterruptibleMask((restore) =>
564
+ return Effect.uninterruptibleMask((restore) =>
520
565
  restore(
521
- (after === undefined
522
- ? Effect.void
523
- : changedUntil(state, (current) => !current.draining.has(after))
524
- ).pipe(
566
+ (exclusive && after !== undefined ? after : Effect.void).pipe(
525
567
  Effect.andThen(
526
568
  Effect.retry(openOnce, { schedule: reopen, while: (error) => !definite(error) }),
527
569
  ),
528
570
  ),
529
571
  ).pipe(
530
572
  Effect.exit,
531
- Effect.flatMap((exit) =>
532
- Exit.isSuccess(exit)
533
- ? apply({ _tag: "Opened", live: { number, value: exit.value } })
534
- : Cause.hasInterruptsOnly(exit.cause)
535
- ? apply({ _tag: "Abandoned", number })
536
- : apply({
537
- _tag: "Failed",
538
- number,
539
- detail: describe(exit.cause),
540
- cause: Cause.squash(exit.cause),
541
- }),
542
- ),
573
+ Effect.flatMap((exit) => apply(openedInput(number, exit))),
543
574
  ),
544
575
  );
576
+ };
545
577
 
546
578
  const use = leasing(state, apply, waitTimeout, now, clock);
547
579
 
@@ -10,6 +10,7 @@ import { Deferred, Duration, Effect, Exit } from "effect";
10
10
 
11
11
  import { type Bridge, scriptCall } from "../page/bridge.ts";
12
12
  import { contextGone, type PageContext } from "../page/context.ts";
13
+ import { Current } from "./replies.ts";
13
14
 
14
15
  // A document still loading after this, or a closed page, is the next look's to show.
15
16
  const settling = Duration.seconds(5);
@@ -33,9 +34,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
33
34
  const settle = (operation: string, before: ReturnType<typeof mark>) =>
34
35
  Effect.gen(function* () {
35
36
  // A commit ends the call's world; the loop then waits for that document.
36
- yield* bridge
37
- .evaluate(operation, scriptCall("settle"))
38
- .pipe(Effect.catchIf(contextGone, () => Effect.void));
37
+ yield* bridge.evaluate(operation, scriptCall("settle")).pipe(
38
+ // This is a read, not an input acknowledgement. A pending navigation may hold the
39
+ // evaluate after the settling deadline, without retaining browser input authority.
40
+ Effect.provideService(Current, undefined),
41
+ Effect.catchIf(contextGone, () => Effect.void),
42
+ Effect.onError(() => page.stopLoading),
43
+ );
39
44
  while (document() > before.document && parsed < document()) yield* Deferred.await(changed);
40
45
  }).pipe(
41
46
  Effect.timeoutOrElse({ duration: settling, orElse: () => Effect.fail("unsettled") }),
@@ -267,6 +267,13 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
267
267
  orElse: () => failWith(operation, new Timeout({ millis: Duration.toMillis(duration) })),
268
268
  });
269
269
 
270
+ // Stop a navigation whose bounded wait failed. The stop and original mutation keep their
271
+ // actual replies owned; this cleanup does not turn an unanswered acknowledgement into success.
272
+ const stopLoading = native("stopLoading", () => send("Page.stopLoading")).pipe(
273
+ within("stopLoading", options.settings.navigationTimeout),
274
+ Effect.ignore,
275
+ );
276
+
270
277
  // The latest submitted input or page change. A read that must follow it waits for the action in
271
278
  // flight, so only paint from after its latest input is current. An action ends after its input
272
279
  // was handled, so if that input changed the page, newer paint follows; a lost final paint is
@@ -306,6 +313,7 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
306
313
  protocol,
307
314
  native,
308
315
  within,
316
+ stopLoading,
309
317
  activity,
310
318
  noteInput,
311
319
  };