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.
- package/README.md +114 -19
- package/dist/{Browser-CNwK0r0q.mjs → Browser-Bc_LIqTC.mjs} +184 -16
- package/dist/Browser-Bc_LIqTC.mjs.map +1 -0
- package/dist/Browser.d.mts +16 -4
- package/dist/Browser.mjs +2 -2
- package/dist/{BrowserError-DXXIA6Rk.d.mts → BrowserError-Coy7EW1N.d.mts} +10 -5
- package/dist/BrowserError.d.mts +2 -2
- package/dist/BrowserError.mjs +10 -1
- package/dist/BrowserError.mjs.map +1 -1
- package/dist/Cdp.d.mts +1 -1
- package/dist/Cdp.mjs +3 -3
- package/dist/Chromium.d.mts +1 -1
- package/dist/Chromium.mjs +2 -2
- package/dist/Expect.d.mts +2 -2
- package/dist/Expect.mjs +2 -2
- package/dist/Moment.d.mts +1 -1
- package/dist/Moment.mjs +1 -1
- package/dist/{Page-D4281dIi.mjs → Page-B5cjaUp5.mjs} +2 -2
- package/dist/{Page-D4281dIi.mjs.map → Page-B5cjaUp5.mjs.map} +1 -1
- package/dist/{Page-C30aG2RQ.d.mts → Page-BlPjfibv.d.mts} +2 -2
- package/dist/Page.d.mts +1 -1
- package/dist/Page.mjs +1 -1
- package/dist/{Plan-BvCS9qAf.d.mts → Plan-BI8XGvDu.d.mts} +3 -3
- package/dist/{Plan-CyQNv49C.mjs → Plan-D1wrJVs8.mjs} +3 -3
- package/dist/{Plan-CyQNv49C.mjs.map → Plan-D1wrJVs8.mjs.map} +1 -1
- package/dist/Plan.d.mts +1 -1
- package/dist/Plan.mjs +1 -1
- package/dist/Policy.d.mts +1 -1
- package/dist/Policy.mjs +1 -1
- package/dist/Presentation.d.mts +1 -1
- package/dist/Presentation.mjs +1 -1
- package/dist/Stage.d.mts +2 -2
- package/dist/Stage.mjs +2 -2
- package/dist/{Supervisor-CLJaT7rG.d.mts → Supervisor-CYWFK3vM.d.mts} +105 -55
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -2
- package/dist/Supervisor.mjs +2 -2
- package/dist/{context-DAYjATgC.mjs → context-B6xr8nVi.mjs} +134 -10
- package/dist/context-B6xr8nVi.mjs.map +1 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -4
- package/dist/{page-DHeYWEs-.mjs → page-D-OedyBw.mjs} +59 -126
- package/dist/page-D-OedyBw.mjs.map +1 -0
- package/dist/{url-CcL0K7S0.mjs → url-Cd04Okid.mjs} +6 -4
- package/dist/url-Cd04Okid.mjs.map +1 -0
- package/package.json +1 -1
- package/src/Browser.ts +57 -16
- package/src/BrowserError.ts +9 -0
- package/src/Supervisor.ts +384 -138
- package/src/internal/input/gate.ts +224 -0
- package/src/internal/input/guard.ts +3 -2
- package/src/internal/input/perform.ts +84 -32
- package/src/internal/input/replies.ts +52 -16
- package/src/internal/page/context.ts +21 -11
- package/src/internal/page/lane.ts +20 -10
- package/src/internal/supervisor/lifecycle.ts +291 -140
- package/src/internal/timeline/events.ts +1 -0
- package/dist/Browser-CNwK0r0q.mjs.map +0 -1
- package/dist/Supervisor-BtEG93gR.mjs +0 -420
- package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
- package/dist/context-DAYjATgC.mjs.map +0 -1
- package/dist/page-DHeYWEs-.mjs.map +0 -1
- 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
|
|
154
|
-
//
|
|
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
|
|
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((
|
|
221
|
+
Effect.tap((current) =>
|
|
164
222
|
Effect.sync(() => {
|
|
165
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
Effect.
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
232
|
-
(
|
|
233
|
-
|
|
238
|
+
return track(
|
|
239
|
+
reply.then(
|
|
240
|
+
(result) => {
|
|
241
|
+
settle(sizeOf(result));
|
|
234
242
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
return result;
|
|
244
|
+
},
|
|
245
|
+
(cause: unknown) => {
|
|
246
|
+
settle(0);
|
|
239
247
|
|
|
240
|
-
|
|
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
|
-
|
|
181
|
-
Effect.
|
|
182
|
-
|
|
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
|
|
305
|
+
read: (operation: string, wait: Duration.Duration = actionTimeout) =>
|
|
306
|
+
turn("read", operation, wait),
|
|
297
307
|
shared,
|
|
298
308
|
};
|
|
299
309
|
};
|