effect-browser 0.3.0-beta.7 → 0.3.0-beta.9

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 (64) hide show
  1. package/README.md +114 -19
  2. package/dist/{Browser-CNwK0r0q.mjs → Browser-Bc_LIqTC.mjs} +184 -16
  3. package/dist/Browser-Bc_LIqTC.mjs.map +1 -0
  4. package/dist/Browser.d.mts +16 -4
  5. package/dist/Browser.mjs +2 -2
  6. package/dist/{BrowserError-DXXIA6Rk.d.mts → BrowserError-Coy7EW1N.d.mts} +10 -5
  7. package/dist/BrowserError.d.mts +2 -2
  8. package/dist/BrowserError.mjs +10 -1
  9. package/dist/BrowserError.mjs.map +1 -1
  10. package/dist/Cdp.d.mts +1 -1
  11. package/dist/Cdp.mjs +3 -3
  12. package/dist/Chromium.d.mts +1 -1
  13. package/dist/Chromium.mjs +2 -2
  14. package/dist/Expect.d.mts +2 -2
  15. package/dist/Expect.mjs +2 -2
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/Moment.mjs +1 -1
  18. package/dist/{Page-D4281dIi.mjs → Page-B5cjaUp5.mjs} +2 -2
  19. package/dist/{Page-D4281dIi.mjs.map → Page-B5cjaUp5.mjs.map} +1 -1
  20. package/dist/{Page-C30aG2RQ.d.mts → Page-BlPjfibv.d.mts} +2 -2
  21. package/dist/Page.d.mts +1 -1
  22. package/dist/Page.mjs +1 -1
  23. package/dist/{Plan-BvCS9qAf.d.mts → Plan-BI8XGvDu.d.mts} +3 -3
  24. package/dist/{Plan-CyQNv49C.mjs → Plan-D1wrJVs8.mjs} +3 -3
  25. package/dist/{Plan-CyQNv49C.mjs.map → Plan-D1wrJVs8.mjs.map} +1 -1
  26. package/dist/Plan.d.mts +1 -1
  27. package/dist/Plan.mjs +1 -1
  28. package/dist/Policy.d.mts +1 -1
  29. package/dist/Policy.mjs +1 -1
  30. package/dist/Presentation.d.mts +1 -1
  31. package/dist/Presentation.mjs +1 -1
  32. package/dist/Stage.d.mts +2 -2
  33. package/dist/Stage.mjs +2 -2
  34. package/dist/{Supervisor-CLJaT7rG.d.mts → Supervisor-CYWFK3vM.d.mts} +105 -55
  35. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  36. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  37. package/dist/Supervisor.d.mts +2 -2
  38. package/dist/Supervisor.mjs +2 -2
  39. package/dist/{context-DAYjATgC.mjs → context-B6xr8nVi.mjs} +134 -10
  40. package/dist/context-B6xr8nVi.mjs.map +1 -0
  41. package/dist/index.d.mts +4 -4
  42. package/dist/index.mjs +4 -4
  43. package/dist/{page-DHeYWEs-.mjs → page-D-OedyBw.mjs} +59 -126
  44. package/dist/page-D-OedyBw.mjs.map +1 -0
  45. package/dist/{url-CcL0K7S0.mjs → url-Cd04Okid.mjs} +6 -4
  46. package/dist/url-Cd04Okid.mjs.map +1 -0
  47. package/package.json +1 -1
  48. package/src/Browser.ts +57 -16
  49. package/src/BrowserError.ts +9 -0
  50. package/src/Supervisor.ts +384 -138
  51. package/src/internal/input/gate.ts +224 -0
  52. package/src/internal/input/guard.ts +3 -2
  53. package/src/internal/input/perform.ts +84 -32
  54. package/src/internal/input/replies.ts +52 -16
  55. package/src/internal/page/context.ts +21 -11
  56. package/src/internal/page/lane.ts +20 -10
  57. package/src/internal/supervisor/lifecycle.ts +291 -140
  58. package/src/internal/timeline/events.ts +1 -0
  59. package/dist/Browser-CNwK0r0q.mjs.map +0 -1
  60. package/dist/Supervisor-BtEG93gR.mjs +0 -420
  61. package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
  62. package/dist/context-DAYjATgC.mjs.map +0 -1
  63. package/dist/page-DHeYWEs-.mjs.map +0 -1
  64. package/dist/url-CcL0K7S0.mjs.map +0 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * A browser's input authority: stop admitting writes, drain admitted operations through their
3
+ * records and native replies, then retain the stop for its scopes. Reads never enter this gate.
4
+ */
5
+ import { Clock, Deferred, Duration, Effect, Exit, Option, type Scope } from "effect";
6
+
7
+ import { BrowserError, Busy, Closed, InvalidRequest, Suspended } from "../../BrowserError.ts";
8
+ import type { DisconnectCause } from "../../BrowserEvent.ts";
9
+
10
+ interface Stopped {
11
+ readonly since: number;
12
+ readonly at: number;
13
+ }
14
+
15
+ type State =
16
+ | { readonly _tag: "Open"; readonly inputs: ReadonlySet<number> }
17
+ | {
18
+ readonly _tag: "Draining";
19
+ readonly inputs: ReadonlySet<number>;
20
+ readonly claims: ReadonlySet<number>;
21
+ readonly startedAt: number;
22
+ readonly deadline: number;
23
+ }
24
+ | { readonly _tag: "Held"; readonly claims: ReadonlySet<number>; readonly stopped: Stopped };
25
+
26
+ type Input =
27
+ | { readonly _tag: "Enter" | "End" | "Leave"; readonly id: number }
28
+ | { readonly _tag: "Suspend"; readonly id: number; readonly drain: number }
29
+ | { readonly _tag: "Expire" };
30
+
31
+ const without = (set: ReadonlySet<number>, id: number) =>
32
+ new Set([...set].filter((each) => each !== id));
33
+
34
+ const step = (state: State, input: Input, stopped: Stopped): State => {
35
+ switch (input._tag) {
36
+ case "Enter":
37
+ return state._tag === "Open"
38
+ ? { ...state, inputs: new Set([...state.inputs, input.id]) }
39
+ : state;
40
+ case "End": {
41
+ if (state._tag === "Held") return state;
42
+ const inputs = without(state.inputs, input.id);
43
+
44
+ return state._tag === "Draining" && inputs.size === 0
45
+ ? { _tag: "Held", claims: state.claims, stopped }
46
+ : { ...state, inputs };
47
+ }
48
+ case "Suspend":
49
+ if (state._tag !== "Open") return { ...state, claims: new Set([...state.claims, input.id]) };
50
+
51
+ return state.inputs.size === 0
52
+ ? { _tag: "Held", claims: new Set([input.id]), stopped }
53
+ : {
54
+ _tag: "Draining",
55
+ inputs: state.inputs,
56
+ claims: new Set([input.id]),
57
+ startedAt: stopped.at,
58
+ deadline: stopped.at + input.drain,
59
+ };
60
+ case "Leave": {
61
+ if (state._tag === "Open" || !state.claims.has(input.id)) return state;
62
+ const claims = without(state.claims, input.id);
63
+
64
+ return claims.size > 0
65
+ ? { ...state, claims }
66
+ : { _tag: "Open", inputs: state._tag === "Draining" ? state.inputs : new Set() };
67
+ }
68
+ case "Expire":
69
+ return state._tag === "Draining" && stopped.at >= state.deadline
70
+ ? { _tag: "Open", inputs: state.inputs }
71
+ : state;
72
+ }
73
+ };
74
+
75
+ export const make = (options: {
76
+ readonly now: () => number;
77
+ readonly sequence: () => number;
78
+ readonly closedBy: () => DisconnectCause | undefined;
79
+ readonly lost: Effect.Effect<DisconnectCause>;
80
+ readonly clock: Clock.Clock;
81
+ readonly drain: Duration.Duration;
82
+ }) => {
83
+ let state: State = { _tag: "Open", inputs: new Set() };
84
+ let next = 0;
85
+ let refused = Deferred.makeUnsafe<void>();
86
+ let ready = Deferred.makeUnsafe<Stopped, BrowserError>();
87
+
88
+ const error = (operation: string) =>
89
+ new BrowserError({
90
+ operation,
91
+ reason:
92
+ options.closedBy() === undefined
93
+ ? new Suspended()
94
+ : new Closed({ cause: options.closedBy() ?? "connection" }),
95
+ dispatched: false,
96
+ });
97
+
98
+ const stopped = (): Stopped => ({ since: options.sequence(), at: options.now() });
99
+
100
+ // Every change to authority happens here, synchronously with its admission or scope closure.
101
+ const apply = (input: Input): State => {
102
+ if (input._tag !== "Expire" && state._tag === "Draining" && options.now() >= state.deadline)
103
+ apply({ _tag: "Expire" });
104
+ const before = state;
105
+
106
+ state = step(state, input, stopped());
107
+ if (input._tag === "Expire" && before._tag === "Draining" && state._tag === "Open")
108
+ Deferred.doneUnsafe(
109
+ ready,
110
+ Exit.fail(
111
+ new BrowserError({
112
+ operation: "suspend",
113
+ reason: new Busy({
114
+ waitedMillis: Math.round(options.now() - before.startedAt),
115
+ ahead: before.inputs.size,
116
+ }),
117
+ dispatched: false,
118
+ }),
119
+ ),
120
+ );
121
+ if (before._tag === "Open" && state._tag !== "Open") Deferred.doneUnsafe(refused, Exit.void);
122
+ if (before._tag === "Draining" && state._tag === "Held")
123
+ Deferred.doneUnsafe(ready, Exit.succeed(state.stopped));
124
+ if (before._tag !== "Open" && state._tag === "Open") {
125
+ refused = Deferred.makeUnsafe();
126
+ ready = Deferred.makeUnsafe();
127
+ }
128
+
129
+ return state;
130
+ };
131
+
132
+ const check = (operation: string) =>
133
+ Effect.suspend(() =>
134
+ state._tag === "Open" && options.closedBy() === undefined
135
+ ? Effect.void
136
+ : Effect.fail(error(operation)),
137
+ );
138
+
139
+ const suspend = (
140
+ drain: Duration.Input = options.drain,
141
+ ): Effect.Effect<Stopped, BrowserError, Scope.Scope> =>
142
+ Effect.suspend(() => {
143
+ const duration = Duration.fromInput(drain);
144
+
145
+ if (
146
+ Option.isNone(duration) ||
147
+ !Duration.isFinite(duration.value) ||
148
+ Duration.toMillis(duration.value) < 0
149
+ )
150
+ return Effect.fail(
151
+ new BrowserError({
152
+ operation: "suspend",
153
+ reason: new InvalidRequest({ detail: "drain must be a finite nonnegative duration" }),
154
+ dispatched: false,
155
+ }),
156
+ );
157
+ const id = ++next;
158
+ const leave = Effect.sync(() => apply({ _tag: "Leave", id }));
159
+
160
+ return Effect.acquireRelease(
161
+ Effect.gen(function* () {
162
+ if (options.closedBy() !== undefined) return yield* error("suspend");
163
+ const current = apply({ _tag: "Suspend", id, drain: Duration.toMillis(duration.value) });
164
+
165
+ if (current._tag === "Held") return current.stopped;
166
+ if (current._tag !== "Draining")
167
+ return yield* Effect.die("suspension did not retain authority");
168
+ const pending = ready;
169
+
170
+ // Native completion and timer callbacks both check the same monotonic deadline. A
171
+ // timer may wake a fraction early, so it sleeps the remaining time instead of hanging.
172
+ const expire: Effect.Effect<Stopped, BrowserError> = Effect.suspend(() => {
173
+ const left = current.deadline - options.now();
174
+
175
+ return left > 0
176
+ ? Effect.sleep(Duration.millis(left)).pipe(Effect.andThen(expire))
177
+ : Effect.sync(() => apply({ _tag: "Expire" })).pipe(
178
+ Effect.andThen(Deferred.await(pending)),
179
+ );
180
+ });
181
+
182
+ const wait = Effect.raceFirst(Deferred.await(pending), expire);
183
+
184
+ return yield* Effect.raceFirst(
185
+ wait,
186
+ options.lost.pipe(Effect.flatMap(() => Effect.fail(error("suspend")))),
187
+ ).pipe(
188
+ Effect.interruptible,
189
+ Effect.filterOrFail(
190
+ () => options.closedBy() === undefined,
191
+ () => error("suspend"),
192
+ ),
193
+ Effect.onError(() => leave),
194
+ );
195
+ }),
196
+ () => leave,
197
+ );
198
+ }).pipe(Effect.provideService(Clock.Clock, options.clock));
199
+
200
+ return {
201
+ check,
202
+ suspend,
203
+ refused: (operation: string): Effect.Effect<never, BrowserError> =>
204
+ Effect.suspend(() =>
205
+ state._tag === "Open"
206
+ ? Deferred.await(refused).pipe(Effect.andThen(Effect.fail(error(operation))))
207
+ : Effect.fail(error(operation)),
208
+ ),
209
+ enter: (operation: string): Effect.Effect<() => void, BrowserError> =>
210
+ Effect.suspend(() => {
211
+ if (state._tag !== "Open" || options.closedBy() !== undefined)
212
+ return Effect.fail(error(operation));
213
+ const id = ++next;
214
+
215
+ apply({ _tag: "Enter", id });
216
+
217
+ return Effect.succeed(() => {
218
+ apply({ _tag: "End", id });
219
+ });
220
+ }),
221
+ };
222
+ };
223
+
224
+ export type Gate = ReturnType<typeof make>;
@@ -150,8 +150,8 @@ export const make = (page: PageContext, bridge: Bridge) => {
150
150
  return { request, validate };
151
151
  });
152
152
 
153
- // `mayHaveRun` hears of a failure the script may have started before: without its context or
154
- // the library's API in it, the call never ran.
153
+ // `mayHaveRun` hears of a failure or interruption after the script may have started. A known
154
+ // missing context or API means it never ran; acquiring the context itself sends no mutation.
155
155
  const mutate = (
156
156
  operation: string,
157
157
  call: string,
@@ -161,6 +161,7 @@ export const make = (page: PageContext, bridge: Bridge) => {
161
161
  (approval === undefined ? current(operation) : Effect.succeed(approval.contextId)).pipe(
162
162
  Effect.flatMap((contextId) =>
163
163
  evaluateIn(operation, call, contextId).pipe(
164
+ Effect.onInterrupt(() => mayHaveRun),
164
165
  Effect.tapError((error) =>
165
166
  error.reason._tag === "Failed" &&
166
167
  /Cannot find context|__effectBrowser/i.test(error.reason.detail)
@@ -2,7 +2,7 @@
2
2
  * Performing one action: its turn on the page, the input guard's step, the input run that owns its
3
3
  * replies, and the action's record. Its deadline leaves out the time its style takes to show it.
4
4
  */
5
- import { Duration, Effect, Exit, Option, Ref } from "effect";
5
+ import { Deferred, Duration, Effect, Exit, Option, Ref } from "effect";
6
6
 
7
7
  import { BrowserError, PolicyTimeout, Timeout } from "../../BrowserError.ts";
8
8
  import { Action, type ActionOptions, Subject } from "../../BrowserEvent.ts";
@@ -97,10 +97,67 @@ const deadline =
97
97
  return Effect.raceFirst(effect, expiry);
98
98
  });
99
99
 
100
+ /** An admitted operation stays owned through its Action and any unanswered native replies. */
101
+ const admission = (page: PageContext, pending: Set<Deferred.Deferred<void>>, name: string) => {
102
+ let run: Replies.Run | undefined;
103
+ let end: (() => void) | undefined;
104
+
105
+ const enter = Effect.uninterruptible(
106
+ Effect.gen(function* () {
107
+ const released = page.inputGate === undefined ? undefined : yield* page.inputGate.enter(name);
108
+ const done = Deferred.makeUnsafe<void>();
109
+
110
+ pending.add(done);
111
+ end = () => {
112
+ const finish = () => {
113
+ released?.();
114
+ pending.delete(done);
115
+ Deferred.doneUnsafe(done, Exit.void);
116
+ };
117
+
118
+ if (run === undefined) finish();
119
+ else run.afterIdle(finish);
120
+ };
121
+ }),
122
+ );
123
+
124
+ return {
125
+ enter,
126
+ using: (current: Replies.Run) => {
127
+ run = current;
128
+ },
129
+ end: Effect.sync(() => end?.()),
130
+ };
131
+ };
132
+
133
+ /** Close a run on every exit while preserving its native replies for the page's next input. */
134
+ const useRun = <A>(
135
+ page: PageContext,
136
+ sender: Dispatch,
137
+ name: string,
138
+ run: Replies.Run,
139
+ action: Effect.Effect<A, BrowserError>,
140
+ ) =>
141
+ action.pipe(
142
+ Effect.matchEffect({
143
+ onFailure: (error) =>
144
+ run.close.pipe(
145
+ Effect.andThen(sender.flush(name, run).pipe(Effect.ignore)),
146
+ Effect.andThen(Effect.fail(error)),
147
+ ),
148
+ onSuccess: (value) =>
149
+ run.close.pipe(Effect.andThen(sender.flush(name, run)), Effect.as(value)),
150
+ }),
151
+ // Cleanup submits missing releases; the page retains replies after caller interruption.
152
+ Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
153
+ Effect.provideService(Replies.Current, run),
154
+ );
155
+
100
156
  export const make = (page: PageContext, sender: Dispatch) => {
101
157
  const { id, settings, mapping, lane, publish, now, noteInput, span, owned } = page;
102
- const { inputClocks, flush } = sender;
158
+ const { inputClocks } = sender;
103
159
  const input = Replies.make();
160
+ const admitted = new Set<Deferred.Deferred<void>>();
104
161
 
105
162
  // Record the whole operation, but never hold the page's turn or spend its action timeout while
106
163
  // a policy is waiting. Validation binds approval to the document and targets it saw.
@@ -127,6 +184,7 @@ export const make = (page: PageContext, sender: Dispatch) => {
127
184
  let revealed = info.secret !== true;
128
185
  // The time the style has planned to show the input, which the deadline leaves out.
129
186
  let presented = 0;
187
+ const ticket = admission(page, admitted, name);
130
188
 
131
189
  const marks = {
132
190
  style,
@@ -160,27 +218,13 @@ export const make = (page: PageContext, sender: Dispatch) => {
160
218
  input.begin.pipe(
161
219
  // A capture can recalibrate while input is running. One run, including delayed cleanup
162
220
  // releases, keeps one mapping so epoch stamps cannot jump backwards during a stroke.
163
- Effect.tap((run) =>
221
+ Effect.tap((current) =>
164
222
  Effect.sync(() => {
165
- if (estimate !== undefined) inputClocks.set(run, estimate);
223
+ ticket.using(current);
224
+ if (estimate !== undefined) inputClocks.set(current, estimate);
166
225
  }),
167
226
  ),
168
- Effect.flatMap((run) =>
169
- action(run).pipe(
170
- Effect.matchEffect({
171
- onFailure: (error) =>
172
- run.close.pipe(
173
- Effect.andThen(flush(name, run).pipe(Effect.ignore)),
174
- Effect.andThen(Effect.fail(error)),
175
- ),
176
- onSuccess: (value) =>
177
- run.close.pipe(Effect.andThen(flush(name, run)), Effect.as(value)),
178
- }),
179
- // The finalizer only submits missing releases. It cannot wait forever for a
180
- // disconnected peer; the page retains those replies and gates its next action.
181
- Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
182
- ),
183
- ),
227
+ Effect.flatMap((run) => useRun(page, sender, name, run, action(run))),
184
228
  );
185
229
 
186
230
  // How long its turn and the page's replies kept the action waiting, once it had both.
@@ -198,16 +242,19 @@ export const make = (page: PageContext, sender: Dispatch) => {
198
242
  name,
199
243
  timeout,
200
244
  )(
201
- Effect.suspend(() =>
202
- admit.pipe(
203
- Effect.timeoutOrElse({
204
- duration: Duration.millis(
205
- Math.max(0, Duration.toMillis(timeout) - (now() - asked)),
245
+ ticket.enter.pipe(
246
+ Effect.andThen(
247
+ Effect.suspend(() =>
248
+ admit.pipe(
249
+ Effect.timeoutOrElse({
250
+ duration: Duration.millis(
251
+ Math.max(0, Duration.toMillis(timeout) - (now() - asked)),
252
+ ),
253
+ orElse: () => timedOut(name, timeout),
254
+ }),
206
255
  ),
207
- orElse: () => timedOut(name, timeout),
208
- }),
256
+ ),
209
257
  ),
210
- ).pipe(
211
258
  Effect.tap(() =>
212
259
  Effect.sync(() => {
213
260
  queuedMillis = Math.round(now() - asked);
@@ -221,7 +268,7 @@ export const make = (page: PageContext, sender: Dispatch) => {
221
268
  const guard = settings.guard;
222
269
 
223
270
  // Only an approval needs binding: without a guard, input goes straight to the page.
224
- const run =
271
+ const action =
225
272
  guard === undefined
226
273
  ? dispatch(useInput((run) => body({ ...marks, input: run }, undefined)))
227
274
  : Effect.gen(function* () {
@@ -257,7 +304,10 @@ export const make = (page: PageContext, sender: Dispatch) => {
257
304
  // Record the outcome even when the caller interrupts: its input may already be in the page.
258
305
  return yield* Effect.uninterruptibleMask((restore) =>
259
306
  Effect.gen(function* () {
260
- const exit = yield* Effect.exit(restore(run));
307
+ const exit = yield* Effect.exit(
308
+ restore((page.inputGate?.check(name) ?? Effect.void).pipe(Effect.andThen(action))),
309
+ );
310
+
261
311
  const dispatched = yield* Ref.get(sent);
262
312
  const point = yield* Ref.get(at);
263
313
  const { subject, to, box } = yield* Ref.get(acted);
@@ -315,10 +365,12 @@ export const make = (page: PageContext, sender: Dispatch) => {
315
365
 
316
366
  return yield* exit;
317
367
  }),
318
- );
368
+ ).pipe(Effect.ensuring(ticket.end));
319
369
  }).pipe(span(`Page.${name}`, info.target === undefined ? {} : { target: info.target }), owned);
320
370
 
321
- return perform;
371
+ return Object.assign(perform, {
372
+ idle: Effect.suspend(() => Effect.forEach(admitted, Deferred.await, { discard: true })),
373
+ });
322
374
  };
323
375
 
324
376
  export type Perform = ReturnType<typeof make>;
@@ -3,7 +3,7 @@
3
3
  * Canceling a caller cannot cancel a protocol command already sent. The next action waits for
4
4
  * those replies, while a stopped run releases only held keys or buttons whose release was never submitted.
5
5
  */
6
- import { Data, Effect } from "effect";
6
+ import { Context, Data, Effect } from "effect";
7
7
 
8
8
  import { maximumSamples } from "../../Motion.ts";
9
9
 
@@ -14,6 +14,10 @@ export class InputFailure extends Data.TaggedError("InputFailure")<{
14
14
  }> {}
15
15
 
16
16
  export interface Run {
17
+ /** A native call made by this operation: cancellation cannot cancel its actual reply. */
18
+ readonly track: <A>(reply: Promise<A>) => Promise<A>;
19
+ /** End ownership after the record and all this operation's native replies have settled. */
20
+ readonly afterIdle: (ended: () => void) => void;
17
21
  /** Reserve a complete stroke, including its releases, before sending its first event. */
18
22
  readonly reserve: (commands: number) => Effect.Effect<void, InputFailure>;
19
23
  /** Admit every sample before a motion starts its published clock; ordinary input stays bounded separately. */
@@ -30,6 +34,11 @@ export interface Run {
30
34
  readonly close: Effect.Effect<void>;
31
35
  }
32
36
 
37
+ /** Native page calls join their operation's reply ownership, including script mutations. */
38
+ export const Current = Context.Reference<Run | undefined>("effect-browser/internal/input/Current", {
39
+ defaultValue: () => undefined,
40
+ });
41
+
33
42
  export const make = () => {
34
43
  const outstanding = new Set<Promise<void>>();
35
44
 
@@ -46,6 +55,42 @@ export const make = () => {
46
55
  let failure: InputFailure | undefined;
47
56
  let reserved = 0;
48
57
  let closed = false;
58
+ const observed = new WeakMap<Promise<unknown>, { input: boolean }>();
59
+ const endings = new Set<() => void>();
60
+
61
+ const track = <A>(response: Promise<A>, input = false): Promise<A> => {
62
+ const existing = observed.get(response);
63
+
64
+ if (existing !== undefined) {
65
+ existing.input ||= input;
66
+
67
+ return response;
68
+ }
69
+ const entry = { input };
70
+
71
+ observed.set(response, entry);
72
+
73
+ const reply = response
74
+ .then(
75
+ () => undefined,
76
+ (cause: unknown) => {
77
+ if (entry.input) failure ??= new InputFailure({ cause });
78
+ },
79
+ )
80
+ .then(() => {
81
+ pending.delete(reply);
82
+ outstanding.delete(reply);
83
+ if (pending.size === 0) {
84
+ for (const end of endings) end();
85
+ endings.clear();
86
+ }
87
+ });
88
+
89
+ pending.add(reply);
90
+ outstanding.add(reply);
91
+
92
+ return response;
93
+ };
49
94
 
50
95
  const requireOpen = Effect.suspend(() =>
51
96
  closed
@@ -70,21 +115,7 @@ export const make = () => {
70
115
  return;
71
116
  }
72
117
 
73
- // Every rejection is observed immediately; a failure does not abandon later replies.
74
- const reply = response.then(
75
- () => {
76
- pending.delete(reply);
77
- outstanding.delete(reply);
78
- },
79
- (cause: unknown) => {
80
- failure ??= new InputFailure({ cause });
81
- pending.delete(reply);
82
- outstanding.delete(reply);
83
- },
84
- );
85
-
86
- pending.add(reply);
87
- outstanding.add(reply);
118
+ void track(response, true);
88
119
  };
89
120
 
90
121
  const reserve = (commands: number, maximum: number, limit: number) =>
@@ -102,6 +133,11 @@ export const make = () => {
102
133
  });
103
134
 
104
135
  const run: Run = {
136
+ track,
137
+ afterIdle: (end) => {
138
+ if (pending.size === 0) end();
139
+ else endings.add(end);
140
+ },
105
141
  reserve: (commands) => reserve(commands, capacity, capacity),
106
142
  // A dense stroke cannot wait for replies midway without changing its published timing.
107
143
  // Its finite reservation also leaves room for the ordinary pool's already-owned releases.
@@ -20,6 +20,8 @@ import type { CaptureSource } from "../../Browser.ts";
20
20
  import { BrowserError, Closed, Failed, type Reason, Timeout } from "../../BrowserError.ts";
21
21
  import type { BrowserEvent } from "../../BrowserEvent.ts";
22
22
  import type { InputGuard, Point } from "../../Page.ts";
23
+ import type { Gate } from "../input/gate.ts";
24
+ import { Current } from "../input/replies.ts";
23
25
  import type * as BrowserClock from "../pictures/clock.ts";
24
26
  import * as Lane from "./lane.ts";
25
27
  import type { Openings } from "./openings.ts";
@@ -51,6 +53,8 @@ export interface MakeOptions {
51
53
  readonly untilLost: <A>(reply: Promise<A>) => Promise<A>;
52
54
  readonly settings: Settings;
53
55
  readonly clock: Clock.Clock;
56
+ /** Browser-wide input authority; direct internal page fixtures need not provide one. */
57
+ readonly inputGate?: Gate | undefined;
54
58
  /** The browser's epoch mapping: its first capture measures it, and later ones renew it. */
55
59
  readonly mapping: BrowserClock.Mapping;
56
60
  readonly publish: (event: BrowserEvent) => number;
@@ -139,6 +143,9 @@ const CurrentCost = Context.Reference<Cost | undefined>(
139
143
  },
140
144
  );
141
145
 
146
+ /** Track the promise returned to the action, after tracing wraps it, so input counts once. */
147
+ const track = <A>(reply: Promise<A>) => Fiber.getCurrent()?.getRef(Current)?.track(reply) ?? reply;
148
+
142
149
  // A message's size on the wire, give or take its envelope.
143
150
  const sizeOf = (value: unknown) =>
144
151
  value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value));
@@ -217,7 +224,7 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
217
224
  }
218
225
  const reply = options.untilLost(options.cdp.send(method, params));
219
226
 
220
- if (cost === undefined) return reply;
227
+ if (cost === undefined) return track(reply);
221
228
 
222
229
  const settle = (bytesIn: number) => {
223
230
  const at = now();
@@ -228,24 +235,26 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
228
235
  }
229
236
  };
230
237
 
231
- return reply.then(
232
- (result) => {
233
- settle(sizeOf(result));
238
+ return track(
239
+ reply.then(
240
+ (result) => {
241
+ settle(sizeOf(result));
234
242
 
235
- return result;
236
- },
237
- (cause: unknown) => {
238
- settle(0);
243
+ return result;
244
+ },
245
+ (cause: unknown) => {
246
+ settle(0);
239
247
 
240
- return Promise.reject(cause);
241
- },
248
+ return Promise.reject(cause);
249
+ },
250
+ ),
242
251
  );
243
252
  };
244
253
 
245
254
  // Every protocol or Playwright call. Errors are undispatched here; `perform` marks them
246
255
  // dispatched once input has gone out.
247
256
  const native = <A>(operation: string, run: () => Promise<A>, timeoutMillis?: number) =>
248
- call(operation, run, options.closedBy, timeoutMillis);
257
+ call(operation, () => track(run()), options.closedBy, timeoutMillis);
249
258
 
250
259
  /** Fail with `Timeout` once `duration` has passed, the action timeout unless another is given. */
251
260
  const within =
@@ -274,6 +283,7 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
274
283
  documentAt: () => activity.documentAt,
275
284
  gone: (operation) => undispatched(operation, new Closed({ cause: options.closedBy() })),
276
285
  scope,
286
+ inputGate: options.inputGate,
277
287
  });
278
288
 
279
289
  // Where this page's latest move left its pointer, which is where its next plain glide starts and
@@ -17,6 +17,7 @@
17
17
  import { Context, Deferred, Duration, Effect, Exit, type Fiber, type Scope } from "effect";
18
18
 
19
19
  import { BrowserError, Busy } from "../../BrowserError.ts";
20
+ import type { Gate } from "../input/gate.ts";
20
21
 
21
22
  export type Kind = "read" | "write";
22
23
 
@@ -128,6 +129,7 @@ export const make = (options: {
128
129
  readonly gone: (operation: string) => BrowserError;
129
130
  /** The page's scope, which shared reads run in. */
130
131
  readonly scope: Scope.Scope;
132
+ readonly inputGate?: Gate | undefined;
131
133
  }) => {
132
134
  const { now, actionTimeout } = options;
133
135
  let state = initial;
@@ -169,23 +171,30 @@ export const make = (options: {
169
171
  const ticket: Ticket = { id: ++tickets, kind };
170
172
  const askedAt = now();
171
173
 
174
+ if (kind === "write" && options.inputGate !== undefined)
175
+ yield* options.inputGate.check(operation);
176
+
172
177
  if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);
173
178
  const admitted = Deferred.makeUnsafe<void>();
174
179
 
175
180
  admissions.set(ticket.id, admitted);
176
181
  apply({ _tag: "Ask", ticket });
177
182
 
183
+ const waiting = Deferred.await(admitted).pipe(
184
+ Effect.timeoutOrElse({
185
+ duration: wait,
186
+ orElse: () =>
187
+ Deferred.isDoneUnsafe(admitted)
188
+ ? Effect.void
189
+ : Effect.fail(busy(operation, askedAt, ticket.id)),
190
+ }),
191
+ );
192
+
178
193
  const given = yield* Effect.exit(
179
194
  restore(
180
- Deferred.await(admitted).pipe(
181
- Effect.timeoutOrElse({
182
- duration: wait,
183
- orElse: () =>
184
- Deferred.isDoneUnsafe(admitted)
185
- ? Effect.void
186
- : Effect.fail(busy(operation, askedAt, ticket.id)),
187
- }),
188
- ),
195
+ kind === "write" && options.inputGate !== undefined
196
+ ? Effect.raceFirst(waiting, options.inputGate.refused(operation))
197
+ : waiting,
189
198
  ),
190
199
  );
191
200
 
@@ -293,7 +302,8 @@ export const make = (options: {
293
302
  /** A turn alone, for an action: `wait` bounds the wait for it. */
294
303
  write: (operation: string, wait: Duration.Duration) => turn("write", operation, wait),
295
304
  /** A turn beside other reads, waited for within the action timeout. */
296
- read: (operation: string) => turn("read", operation, actionTimeout),
305
+ read: (operation: string, wait: Duration.Duration = actionTimeout) =>
306
+ turn("read", operation, wait),
297
307
  shared,
298
308
  };
299
309
  };