@wardic/core 1.0.0-rc.4 → 1.0.0-rc.6
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/dist/Clock.d.ts.map +1 -1
- package/dist/Clock.js +20 -12
- package/dist/Clock.js.map +1 -1
- package/dist/FrameLoop.d.ts +12 -1
- package/dist/FrameLoop.d.ts.map +1 -1
- package/dist/FrameLoop.js +23 -1
- package/dist/FrameLoop.js.map +1 -1
- package/dist/LiveValue.d.ts +4 -3
- package/dist/LiveValue.d.ts.map +1 -1
- package/dist/LiveValue.js +7 -5
- package/dist/LiveValue.js.map +1 -1
- package/dist/Probe.d.ts +5 -47
- package/dist/Probe.d.ts.map +1 -1
- package/dist/Probe.js +5 -5
- package/dist/Probe.js.map +1 -1
- package/dist/ScrollSource.d.ts +62 -0
- package/dist/ScrollSource.d.ts.map +1 -0
- package/dist/ScrollSource.js +21 -0
- package/dist/ScrollSource.js.map +1 -0
- package/dist/SharedSource.d.ts +35 -15
- package/dist/SharedSource.d.ts.map +1 -1
- package/dist/SharedSource.js +84 -108
- package/dist/SharedSource.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/frameLoop.d.ts +17 -0
- package/dist/internal/frameLoop.d.ts.map +1 -1
- package/dist/internal/frameLoop.js +33 -20
- package/dist/internal/frameLoop.js.map +1 -1
- package/dist/internal/hearing.d.ts +13 -0
- package/dist/internal/hearing.d.ts.map +1 -0
- package/dist/internal/hearing.js +71 -0
- package/dist/internal/hearing.js.map +1 -0
- package/package.json +1 -1
- package/src/Clock.ts +29 -17
- package/src/FrameLoop.ts +24 -1
- package/src/LiveValue.ts +9 -7
- package/src/Probe.ts +8 -54
- package/src/ScrollSource.ts +68 -0
- package/src/SharedSource.ts +113 -146
- package/src/index.ts +2 -1
- package/src/internal/frameLoop.ts +41 -18
- package/src/internal/hearing.ts +76 -0
package/src/SharedSource.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Shared sources, for the Streams an Environment hands out: each hot and counted, so however many
|
|
3
3
|
* subscribe, one underlying source runs, started by the first subscriber and stopped when the last one
|
|
4
4
|
* leaves. `byTarget` keeps one per target, one observer on each element, and `make` one for the whole
|
|
5
|
-
* Environment. An Environment builds its sources with these, as `@wardic/dom` does
|
|
5
|
+
* Environment. An Environment builds its sources with these, as `@wardic/dom` does, and `counted` shares
|
|
6
|
+
* anything else it opens per target.
|
|
6
7
|
*
|
|
7
8
|
* @example
|
|
8
9
|
* import * as SharedSource from "@wardic/core/SharedSource"
|
|
@@ -13,136 +14,133 @@
|
|
|
13
14
|
* Stream.runForEach(resize(card), onScroll)
|
|
14
15
|
*/
|
|
15
16
|
// CONTEXT.md's **Shared source** contract (#37, ADR-0041 *Amendment (#37)*). An Environment's **Input
|
|
16
|
-
// feeds** are shared by `InputFeed.byTarget` instead, with no
|
|
17
|
+
// feeds** are shared by `InputFeed.byTarget` instead, with no fiber (#318).
|
|
17
18
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// `Stream.broadcast` do this too, but put 2.2 KB gzip on every app (`bun run size`), so the pump stays.
|
|
19
|
+
// One shape under both constructors (#518, ADR-0067 *Amendment (#518)*, decision 3): a target's entry in
|
|
20
|
+
// a `counted` map holds its subscribers' hearings and, while any subscriber holds it, a pump that runs the
|
|
21
|
+
// cold source and hands each element to every hearing, which resumes a waiting subscriber inside the
|
|
22
|
+
// call, as the `PubSub` did. The first subscriber opens the entry and starts the pump; the
|
|
23
|
+
// last one's release interrupts it, and the cold source's finalizers run (a listener is removed). The
|
|
24
|
+
// source's end ends every subscriber, and its failure fails them (#618, decision 6). It replaced a
|
|
25
|
+
// `PubSub` per source in Effect's `RcMap` and `RcRef`, whose code every app shipped (4.1 KB gzipped with
|
|
26
|
+
// `Clock.ticks`' and the DOM's scroll containers'), and whose `idleTimeToLive` nothing passed.
|
|
27
27
|
//
|
|
28
28
|
// Tier 2 (ADR-0008), for Environment authors, as `InputSource.heldPointer` is (#31's rule: Wardic ships
|
|
29
29
|
// what makes a new Environment little work). Observability: each constructor emits a
|
|
30
|
-
// `wardic.sharedSource.*` span and a debug log on subscribe; the per-event
|
|
31
|
-
import * as Cause from "effect/Cause"
|
|
32
|
-
import type * as Duration from "effect/Duration"
|
|
30
|
+
// `wardic.sharedSource.*` span and a debug log on subscribe; the per-event path stays quiet.
|
|
33
31
|
import * as Effect from "effect/Effect"
|
|
34
|
-
import * as Equal from "effect/Equal"
|
|
35
32
|
import * as Exit from "effect/Exit"
|
|
36
|
-
import
|
|
37
|
-
import * as
|
|
38
|
-
import * as Match from "effect/Match"
|
|
39
|
-
import * as MutableRef from "effect/MutableRef"
|
|
33
|
+
import * as Fiber from "effect/Fiber"
|
|
34
|
+
import * as Iter from "effect/Iterable"
|
|
40
35
|
import * as Option from "effect/Option"
|
|
41
|
-
import * as
|
|
42
|
-
import * as PubSub from "effect/PubSub"
|
|
43
|
-
import * as RcMap from "effect/RcMap"
|
|
44
|
-
import * as RcRef from "effect/RcRef"
|
|
45
|
-
import type * as Scope from "effect/Scope"
|
|
36
|
+
import * as Scope from "effect/Scope"
|
|
46
37
|
import * as Stream from "effect/Stream"
|
|
47
|
-
import * as
|
|
38
|
+
import * as Hearing from "./internal/hearing.ts"
|
|
48
39
|
import { tracedStream } from "./internal/tracing.ts"
|
|
49
40
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* reuses it. Default: none, stopped at once.
|
|
55
|
-
*/
|
|
56
|
-
// Rc{Map,Ref}'s `idleTimeToLive`.
|
|
57
|
-
readonly idleTimeToLive?: Duration.Input | undefined
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** A source fanned out: its `PubSub`, and how it ended, once it has. */
|
|
61
|
-
interface FannedIn<A> {
|
|
62
|
-
readonly pubsub: PubSub.PubSub<A>
|
|
63
|
-
readonly ended: MutableRef.MutableRef<Option.Option<Exit.Exit<void>>>
|
|
41
|
+
/** A key's value in a `counted` map, and how many hold it. */
|
|
42
|
+
interface Held<V> {
|
|
43
|
+
readonly value: V
|
|
44
|
+
count: number
|
|
64
45
|
}
|
|
65
46
|
|
|
66
47
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
48
|
+
* Values shared by key and counted: the first hold of a key opens its value with `open`, later holds
|
|
49
|
+
* share it, and the last one's release closes it with `close`. A key is held by identity and never
|
|
50
|
+
* hashed, so a DOM node is its own key. `open` runs inline, so it mustn't wait.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // One listener set per container, however many readers hold it
|
|
54
|
+
* const containers = SharedSource.counted(
|
|
55
|
+
* (container: Element) => ({ remove: listenTo(container) }),
|
|
56
|
+
* (listening) => Effect.sync(listening.remove),
|
|
57
|
+
* )
|
|
58
|
+
* const listening = yield* containers(list) // held until the current Scope closes
|
|
72
59
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
60
|
+
// #518: what `RcMap` gave the DOM's scroll containers (#618, M7), with no Scope per entry and no `Hash`.
|
|
61
|
+
export const counted = <K, V>(
|
|
62
|
+
open: (key: K) => V,
|
|
63
|
+
close: (value: V) => Effect.Effect<void>,
|
|
64
|
+
): ((key: K) => Effect.Effect<V, never, Scope.Scope>) => {
|
|
65
|
+
const held = new Map<K, Held<V>>()
|
|
66
|
+
const acquire = (key: K): Effect.Effect<Held<V>> =>
|
|
67
|
+
Effect.sync(() => {
|
|
68
|
+
const entry = Option.getOrElse(Option.fromUndefinedOr(held.get(key)), () => {
|
|
69
|
+
const opened: Held<V> = { value: open(key), count: 0 }
|
|
70
|
+
held.set(key, opened)
|
|
71
|
+
return opened
|
|
72
|
+
})
|
|
73
|
+
entry.count++
|
|
74
|
+
return entry
|
|
75
|
+
})
|
|
76
|
+
const release = (key: K, entry: Held<V>): Effect.Effect<void> =>
|
|
77
|
+
Effect.suspend(() => {
|
|
78
|
+
entry.count--
|
|
79
|
+
if (entry.count > 0) return Effect.void
|
|
80
|
+
if (held.get(key) === entry) held.delete(key)
|
|
81
|
+
return close(entry.value)
|
|
82
|
+
})
|
|
83
|
+
return (key) => Effect.map(Effect.acquireRelease(acquire(key), (entry) => release(key, entry)), (entry) => entry.value)
|
|
84
|
+
}
|
|
87
85
|
|
|
88
|
-
/** A source
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
/** A source shared by its subscribers: their hearings, its pump while it runs, and how it ended, once it has. */
|
|
87
|
+
interface Fanned<A> {
|
|
88
|
+
readonly hearings: Set<Hearing.Hearing<A>>
|
|
89
|
+
pump: Option.Option<Fiber.Fiber<void>>
|
|
90
|
+
ended: Option.Option<Exit.Exit<void>>
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
/**
|
|
96
|
-
const
|
|
97
|
-
Option.match(Option.flatMap(ended, failureOf), { onNone: () => Effect.void, onSome: (cause) => Effect.failCause(cause) })
|
|
93
|
+
/** A fresh source's entry, with no subscriber, no pump and no end. */
|
|
94
|
+
const fanned = <A>(): Fanned<A> => ({ hearings: new Set(), pump: Option.none(), ended: Option.none() })
|
|
98
95
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
/** The pump: `acquire`'s source, each element to every subscriber; its end, kept and handed to each. */
|
|
97
|
+
const pumpOf = <A>(entry: Fanned<A>, acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>): Effect.Effect<void> =>
|
|
98
|
+
Effect.scoped(
|
|
99
|
+
Effect.flatMap(acquire, (source) => Stream.runForEach(source, (a) => Effect.sync(() => Iter.forEach(entry.hearings, (hearing) => hearing.hear(a))))),
|
|
100
|
+
).pipe(
|
|
101
|
+
Effect.onExit((exit) =>
|
|
102
|
+
Effect.sync(() => {
|
|
103
|
+
entry.ended = Option.some(exit)
|
|
104
|
+
Iter.forEach(entry.hearings, (hearing) => hearing.end(exit))
|
|
105
|
+
})
|
|
106
|
+
),
|
|
107
107
|
)
|
|
108
108
|
|
|
109
|
+
/** An entry's pump stopped, its last subscriber gone. */
|
|
110
|
+
const stop = <A>(entry: Fanned<A>): Effect.Effect<void> =>
|
|
111
|
+
Option.match(entry.pump, { onNone: () => Effect.void, onSome: (pump) => Effect.asVoid(Fiber.interrupt(pump)) })
|
|
112
|
+
|
|
109
113
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* distinct targets. This wrapper is equal by reference, with a stable per-instance hash, so `RcMap`
|
|
114
|
-
* never inspects the target and each distinct target is its own entry. One wrapper is cached per
|
|
115
|
-
* target (the `WeakMap` in {@link byTarget}).
|
|
114
|
+
* A subscriber to `key`'s shared source: its hearing on the entry while its Stream runs, the pump started
|
|
115
|
+
* for the first, in `scope`, the shared source's. One that subscribes after the source ended gets the end
|
|
116
|
+
* alone.
|
|
116
117
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
/** The cache's `idleTimeToLive`, when the options give one. */
|
|
139
|
-
const idleOf = (options: Options = {}): { readonly idleTimeToLive?: Duration.Input } =>
|
|
140
|
-
UndefinedOr.match(options.idleTimeToLive, { onUndefined: () => ({}), onDefined: (idleTimeToLive) => ({ idleTimeToLive }) })
|
|
118
|
+
const subscriberOf = <A>(
|
|
119
|
+
hold: (key: unknown) => Effect.Effect<Fanned<A>, never, Scope.Scope>,
|
|
120
|
+
key: unknown,
|
|
121
|
+
acquire: () => Effect.Effect<Stream.Stream<A>, never, Scope.Scope>,
|
|
122
|
+
scope: Scope.Scope,
|
|
123
|
+
): Stream.Stream<A> =>
|
|
124
|
+
Stream.unwrap(
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const entry = yield* hold(key)
|
|
127
|
+
const hearing = Hearing.make<A>()
|
|
128
|
+
if (Option.isSome(entry.ended)) {
|
|
129
|
+
hearing.end(entry.ended.value)
|
|
130
|
+
return hearing.stream
|
|
131
|
+
}
|
|
132
|
+
// Its hearing first, so what the pump hands on as it starts reaches it.
|
|
133
|
+
entry.hearings.add(hearing)
|
|
134
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => entry.hearings.delete(hearing)))
|
|
135
|
+
if (Option.isNone(entry.pump)) entry.pump = Option.some(yield* Effect.forkIn(pumpOf(entry, acquire()), scope, { startImmediately: true }))
|
|
136
|
+
return hearing.stream
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
141
139
|
|
|
142
140
|
/**
|
|
143
141
|
* A shared source per target: a function from a target to a Stream that's hot and counted for that
|
|
144
142
|
* target. The first subscriber for a target starts `lookup(target)`'s source, later ones share it, and it
|
|
145
|
-
* stops when the last one for that target leaves
|
|
143
|
+
* stops when the last one for that target leaves.
|
|
146
144
|
*
|
|
147
145
|
* @example
|
|
148
146
|
* // `resizesOf(target)`: an Environment's own Stream of a target's sizes
|
|
@@ -150,68 +148,37 @@ const idleOf = (options: Options = {}): { readonly idleTimeToLive?: Duration.Inp
|
|
|
150
148
|
*/
|
|
151
149
|
export const byTarget: <A>(
|
|
152
150
|
lookup: (target: unknown) => Effect.Effect<Stream.Stream<A>, never, Scope.Scope>,
|
|
153
|
-
options?: Options,
|
|
154
151
|
) => Effect.Effect<(target: unknown) => Stream.Stream<A>, never, Scope.Scope> = /*#__PURE__*/ Effect.fnUntraced(
|
|
155
|
-
function* <A>(lookup: (target: unknown) => Effect.Effect<Stream.Stream<A>, never, Scope.Scope
|
|
152
|
+
function* <A>(lookup: (target: unknown) => Effect.Effect<Stream.Stream<A>, never, Scope.Scope>) {
|
|
156
153
|
yield* Effect.logDebug("wardic.sharedSource.byTarget: create")
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// safely on its own.
|
|
160
|
-
const keys = new WeakMap<object, TargetKey>()
|
|
161
|
-
const keyFor = (target: unknown): unknown =>
|
|
162
|
-
Option.match(Option.liftPredicate(target, Predicate.isObjectOrArray), {
|
|
163
|
-
onNone: () => target,
|
|
164
|
-
onSome: (object) =>
|
|
165
|
-
Option.getOrElse(Option.fromUndefinedOr(keys.get(object)), () => {
|
|
166
|
-
const key = new TargetKey(object)
|
|
167
|
-
keys.set(object, key)
|
|
168
|
-
return key
|
|
169
|
-
}),
|
|
170
|
-
})
|
|
171
|
-
const map = yield* RcMap.make({
|
|
172
|
-
lookup: (key: unknown) => fanIn(lookup(targetOf(key))),
|
|
173
|
-
...idleOf(options),
|
|
174
|
-
})
|
|
154
|
+
const scope = yield* Scope.Scope
|
|
155
|
+
const hold = counted<unknown, Fanned<A>>(fanned, stop)
|
|
175
156
|
return (target: unknown): Stream.Stream<A> =>
|
|
176
157
|
tracedStream(
|
|
177
|
-
Stream.unwrap(
|
|
178
|
-
Effect.gen(function* () {
|
|
179
|
-
yield* Effect.logDebug("wardic.sharedSource.byTarget: subscribe")
|
|
180
|
-
return subscriberOf(yield* RcMap.get(map, keyFor(target)))
|
|
181
|
-
}),
|
|
182
|
-
),
|
|
158
|
+
Stream.unwrap(Effect.as(Effect.logDebug("wardic.sharedSource.byTarget: subscribe"), subscriberOf(hold, target, () => lookup(target), scope))),
|
|
183
159
|
"wardic.sharedSource.byTarget",
|
|
184
160
|
)
|
|
185
161
|
})
|
|
186
162
|
|
|
187
163
|
/**
|
|
188
164
|
* One shared source for the whole Environment: a Stream that's hot and counted. The first subscriber
|
|
189
|
-
* starts `acquire`'s source, later ones share it, and it stops when the last one leaves
|
|
190
|
-
*
|
|
165
|
+
* starts `acquire`'s source, later ones share it, and it stops when the last one leaves. `byTarget`'s
|
|
166
|
+
* form without a target.
|
|
191
167
|
*
|
|
192
168
|
* @example
|
|
193
169
|
* // `viewportChanges`: an Environment's own Stream of its viewport's size
|
|
194
170
|
* const viewport = yield* SharedSource.make(Effect.succeed(viewportChanges))
|
|
195
171
|
* Stream.runForEach(viewport, onResize) // one listener, however many subscribe
|
|
196
172
|
*/
|
|
197
|
-
// As `RcRef.make` is `RcMap.make`'s.
|
|
198
173
|
export const make: <A>(
|
|
199
174
|
acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>,
|
|
200
|
-
options?: Options,
|
|
201
175
|
) => Effect.Effect<Stream.Stream<A>, never, Scope.Scope> = /*#__PURE__*/ Effect.fnUntraced(
|
|
202
|
-
function* <A>(acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope
|
|
176
|
+
function* <A>(acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>) {
|
|
203
177
|
yield* Effect.logDebug("wardic.sharedSource.make: create")
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
...idleOf(options),
|
|
207
|
-
})
|
|
178
|
+
const scope = yield* Scope.Scope
|
|
179
|
+
const hold = counted<unknown, Fanned<A>>(fanned, stop)
|
|
208
180
|
return tracedStream(
|
|
209
|
-
Stream.unwrap(
|
|
210
|
-
Effect.gen(function* () {
|
|
211
|
-
yield* Effect.logDebug("wardic.sharedSource.make: subscribe")
|
|
212
|
-
return subscriberOf(yield* RcRef.get(ref))
|
|
213
|
-
}),
|
|
214
|
-
),
|
|
181
|
+
Stream.unwrap(Effect.as(Effect.logDebug("wardic.sharedSource.make: subscribe"), subscriberOf(hold, undefined, () => acquire, scope))),
|
|
215
182
|
"wardic.sharedSource.make",
|
|
216
183
|
)
|
|
217
184
|
})
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Each Primitive is a machine (`Machine`), run as a `Stream` over the Clock's ticks
|
|
8
8
|
* (`Machine.toStream`) or on the Frame loop (`Machine.run`). The services (`Clock`,
|
|
9
|
-
* `RefreshFrameSource`, `Renderer`, `InputSource`, `Probe`, `InterpolatorRegistry`, `FrameLoop`,
|
|
9
|
+
* `RefreshFrameSource`, `Renderer`, `InputSource`, `Probe`, `ScrollSource`, `InterpolatorRegistry`, `FrameLoop`,
|
|
10
10
|
* `DefaultTransition`) are each `X.X`, with the shared constructors the Environments build on
|
|
11
11
|
* (`Clock.layer`, `Clock.fromRefreshFrames`, `InputSource.heldPointer`, `InputFeed.byTarget`,
|
|
12
12
|
* `InterpolatorRegistry.layer`, `FrameLoop.layer`, `DefaultTransition.layerOneSpring`). Each module's
|
|
@@ -47,6 +47,7 @@ export * as RefreshFrameSource from "./RefreshFrameSource.ts"
|
|
|
47
47
|
export * as Renderer from "./Renderer.ts"
|
|
48
48
|
export * as Reorder from "./Reorder.ts"
|
|
49
49
|
export * as Scalar from "./Scalar.ts"
|
|
50
|
+
export * as ScrollSource from "./ScrollSource.ts"
|
|
50
51
|
export * as Sequence from "./Sequence.ts"
|
|
51
52
|
export * as SharedSource from "./SharedSource.ts"
|
|
52
53
|
export * as Spring from "./Spring.ts"
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
*
|
|
30
30
|
* Observability: a span per run, named after its Primitive (`machine.span`), and one per burst
|
|
31
31
|
* (`wardic.frameloop`). A run's `<name>: start` debug log, annotated with its span's attributes,
|
|
32
|
-
* built only when a Debug log would be kept (#542). Per tick, no span or log
|
|
33
|
-
* animations and a histogram of ms per tick,
|
|
34
|
-
* runs that settled or failed in it, counted as
|
|
35
|
-
* `afterTick` hook, its first (#542). The histogram ends
|
|
36
|
-
* alone, not the gate's flush (ADR-0033).
|
|
32
|
+
* built only when a Debug log would be kept (#542). Per tick, no span or log. With the metrics an app
|
|
33
|
+
* gives (`FrameLoop.layerMetrics`, #518): a gauge of running animations and a histogram of ms per tick,
|
|
34
|
+
* each one `updateUnsafe` (ADR-0027, decision 6), and the runs that settled or failed in it, counted as
|
|
35
|
+
* they end and added at its end by the loop's own `afterTick` hook, its first (#542). The histogram ends
|
|
36
|
+
* before the hooks, so it measures the steps alone, not the gate's flush (ADR-0033).
|
|
37
37
|
*
|
|
38
38
|
* A run's span is opened without a stack frame (ADR-0040, decision 3): `useSpan` with the span as
|
|
39
39
|
* the `ParentSpan`, not `withSpan`, whose frame Effect stamps on an interrupted run's cause and
|
|
@@ -57,7 +57,7 @@ import * as Exit from "effect/Exit"
|
|
|
57
57
|
import * as FiberHandle from "effect/FiberHandle"
|
|
58
58
|
import { constUndefined, identity } from "effect/Function"
|
|
59
59
|
import * as LogLevel from "effect/LogLevel"
|
|
60
|
-
import * as Metric from "effect/Metric"
|
|
60
|
+
import type * as Metric from "effect/Metric"
|
|
61
61
|
import * as Option from "effect/Option"
|
|
62
62
|
import * as Predicate from "effect/Predicate"
|
|
63
63
|
import * as References from "effect/References"
|
|
@@ -135,12 +135,25 @@ interface Entry {
|
|
|
135
135
|
removed: boolean
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The Frame loop's metrics: the running animations, the runs that settled and failed, and each tick's
|
|
140
|
+
* ms. None unless an app gives them (`FrameLoop.layerMetrics`, #518), so an app that reads none ships and
|
|
141
|
+
* updates none.
|
|
142
|
+
*/
|
|
143
|
+
export interface Metrics {
|
|
144
|
+
readonly animations: Metric.Gauge<number>
|
|
145
|
+
readonly settled: Metric.Counter<number>
|
|
146
|
+
readonly failed: Metric.Counter<number>
|
|
147
|
+
readonly tickMs: Metric.Histogram<number>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Where a run's fiber finds the metrics. The loop takes them from the first run that has them: an app
|
|
152
|
+
* gives them beside its Environment, which the loop's own build doesn't see, as `CheckFrames` is read.
|
|
153
|
+
*/
|
|
154
|
+
// #518, ADR-0067 *Amendment (#518)*, decision 2.
|
|
155
|
+
export const MetricsGiven: Context.Reference<Metrics | undefined> = Context.Reference<Metrics | undefined>("@wardic/core/FrameLoop/Metrics", {
|
|
156
|
+
defaultValue: constUndefined,
|
|
144
157
|
})
|
|
145
158
|
|
|
146
159
|
/**
|
|
@@ -259,6 +272,8 @@ export const make = Effect.fnUntraced(
|
|
|
259
272
|
// once per tick. An interruption is neither, nor a superseded run, which the loop doesn't end.
|
|
260
273
|
let settledCount = 0
|
|
261
274
|
let failedCount = 0
|
|
275
|
+
// The metrics, once a run brings them (#518).
|
|
276
|
+
let metrics: Metrics | undefined
|
|
262
277
|
|
|
263
278
|
/** End `entry`'s run: removed, counted, and its fiber resumed with `exit` (inline, within the tick). */
|
|
264
279
|
const end = (entry: Entry, exit: Exit.Exit<Frame.Frame<unknown>>): void => {
|
|
@@ -270,8 +285,10 @@ export const make = Effect.fnUntraced(
|
|
|
270
285
|
|
|
271
286
|
/** The runs ended since the last count, into the counters: once per tick, and as a burst ends. */
|
|
272
287
|
const countEnds = (): undefined => {
|
|
273
|
-
if (
|
|
274
|
-
|
|
288
|
+
if (Predicate.isNotUndefined(metrics)) {
|
|
289
|
+
if (settledCount > 0) metrics.settled.updateUnsafe(settledCount, buildContext)
|
|
290
|
+
if (failedCount > 0) metrics.failed.updateUnsafe(failedCount, buildContext)
|
|
291
|
+
}
|
|
275
292
|
settledCount = 0
|
|
276
293
|
failedCount = 0
|
|
277
294
|
return undefined
|
|
@@ -317,7 +334,9 @@ export const make = Effect.fnUntraced(
|
|
|
317
334
|
// The first lane's loops stay inline in `tick`: moved into a function shared with the last lane,
|
|
318
335
|
// Node's `chase/loop` ran 55% slower (`bench:core`, ADR-0027 *Amendment (#137)*).
|
|
319
336
|
const tick = (dt: number): boolean => {
|
|
320
|
-
|
|
337
|
+
// The tick's start, read only for the metrics' histogram (#518).
|
|
338
|
+
let t0 = 0
|
|
339
|
+
if (Predicate.isNotUndefined(metrics)) t0 = performance.now()
|
|
321
340
|
// Both counts before any step: a run added during the tick, to either lane, waits for the next.
|
|
322
341
|
const count = entries.length
|
|
323
342
|
const lastCount = lastEntries.length
|
|
@@ -344,8 +363,10 @@ export const make = Effect.fnUntraced(
|
|
|
344
363
|
}
|
|
345
364
|
entries.length = kept
|
|
346
365
|
if (lastEntries.length > 0) kept += compactLast()
|
|
347
|
-
|
|
348
|
-
|
|
366
|
+
if (Predicate.isNotUndefined(metrics)) {
|
|
367
|
+
metrics.animations.updateUnsafe(kept, buildContext)
|
|
368
|
+
metrics.tickMs.updateUnsafe(performance.now() - t0, buildContext)
|
|
369
|
+
}
|
|
349
370
|
// The end of the tick: everything for this Refresh frame is written (ADR-0033). After
|
|
350
371
|
// `tickMs`, which stays the cost of the steps alone.
|
|
351
372
|
const ended = hooks
|
|
@@ -380,7 +401,7 @@ export const make = Effect.fnUntraced(
|
|
|
380
401
|
Arr.forEach(Arr.appendAll(entries.splice(0), lastEntries.splice(0)), (entry) => {
|
|
381
402
|
if (!entry.removed) end(entry, ending)
|
|
382
403
|
})
|
|
383
|
-
animations.updateUnsafe(0, buildContext)
|
|
404
|
+
if (Predicate.isNotUndefined(metrics)) metrics.animations.updateUnsafe(0, buildContext)
|
|
384
405
|
countEnds()
|
|
385
406
|
}
|
|
386
407
|
|
|
@@ -418,6 +439,8 @@ export const make = Effect.fnUntraced(
|
|
|
418
439
|
Effect.withFiber((fiber) => {
|
|
419
440
|
// Its start log only when a Debug log would be kept (#542): Effect's own rule, read once.
|
|
420
441
|
const logged = !LogLevel.isGreaterThan(fiber.getRef(References.MinimumLogLevel), "Debug")
|
|
442
|
+
// The metrics from the first run whose fiber has them (#518); none is read once they're in.
|
|
443
|
+
if (Predicate.isUndefined(metrics)) metrics = fiber.getRef(MetricsGiven)
|
|
421
444
|
if (!isTraced(fiber)) return added(lane, machine, sink, options, undefined, logged)
|
|
422
445
|
return Effect.useSpan(machine.span.name, { attributes: machine.span.attributes }, (span) =>
|
|
423
446
|
Effect.provideService(added(lane, machine, sink, options, span, logged), Tracer.ParentSpan, span))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A subscriber's hearing: its own Stream, fed by plain calls. `hear` hands it a value and `end` ends it,
|
|
3
|
+
* or fails it with its source's failure. A consumer waiting on the Stream is resumed inside the call, as a
|
|
4
|
+
* `PubSub`'s subscriber was, never through the Effect scheduler (ADR-0017, decision 3), and a busy one
|
|
5
|
+
* takes everything handed since, together, at its next pull.
|
|
6
|
+
*/
|
|
7
|
+
// #518, ADR-0067 *Amendment (#518)*, decision 3: under `Clock.ticks` and `SharedSource`, where a `PubSub` per
|
|
8
|
+
// source put its code in every app. Not a `Queue`: its offer schedules a release of its takers on the
|
|
9
|
+
// scheduler, a task a frame, even when they're released at once.
|
|
10
|
+
import * as Arr from "effect/Array"
|
|
11
|
+
import * as Cause from "effect/Cause"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import * as Exit from "effect/Exit"
|
|
14
|
+
import * as Option from "effect/Option"
|
|
15
|
+
import * as Predicate from "effect/Predicate"
|
|
16
|
+
import * as Stream from "effect/Stream"
|
|
17
|
+
|
|
18
|
+
export interface Hearing<A> {
|
|
19
|
+
/** Hands the Stream `a`. */
|
|
20
|
+
readonly hear: (a: A) => void
|
|
21
|
+
/** Ends the Stream after what it was handed: failed with `exit`'s failure, else ended. An interruption ends it. */
|
|
22
|
+
readonly end: (exit: Exit.Exit<void>) => void
|
|
23
|
+
/** What it was handed, in order, until its end. */
|
|
24
|
+
readonly stream: Stream.Stream<A>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A pull's end: the source's failure, else the Stream's end. An interruption ends it. */
|
|
28
|
+
const endOf = (exit: Exit.Exit<void>): Effect.Effect<never, Cause.Done> => {
|
|
29
|
+
if (Exit.isSuccess(exit)) return Cause.done()
|
|
30
|
+
if (Cause.hasInterruptsOnly(exit.cause)) return Cause.done()
|
|
31
|
+
return Effect.failCause(exit.cause)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A fresh hearing, handed nothing yet. */
|
|
35
|
+
export const make = <A>(): Hearing<A> => {
|
|
36
|
+
let heard: Array<A> = []
|
|
37
|
+
let ended: Option.Option<Exit.Exit<void>> = Option.none()
|
|
38
|
+
// The waiting pull's settle, while one waits. Per tick on the Clock's: a statement check, no Option.
|
|
39
|
+
let waiting: (() => boolean) | undefined
|
|
40
|
+
const wake = (): void => {
|
|
41
|
+
if (Predicate.isUndefined(waiting)) return
|
|
42
|
+
const resume = waiting
|
|
43
|
+
waiting = undefined
|
|
44
|
+
resume()
|
|
45
|
+
}
|
|
46
|
+
const take: Effect.Effect<Arr.NonEmptyReadonlyArray<A>, Cause.Done> = Effect.callback((resume) => {
|
|
47
|
+
// What it was handed since, or its end: whether there was either to resume with.
|
|
48
|
+
const settle = (): boolean => {
|
|
49
|
+
if (Arr.isReadonlyArrayNonEmpty(heard)) {
|
|
50
|
+
const taken = heard
|
|
51
|
+
heard = []
|
|
52
|
+
resume(Effect.succeed(taken))
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
if (Option.isNone(ended)) return false
|
|
56
|
+
resume(endOf(ended.value))
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
if (settle()) return
|
|
60
|
+
waiting = settle
|
|
61
|
+
return Effect.sync(() => {
|
|
62
|
+
waiting = undefined
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
return {
|
|
66
|
+
hear: (a) => {
|
|
67
|
+
heard.push(a)
|
|
68
|
+
wake()
|
|
69
|
+
},
|
|
70
|
+
end: (exit) => {
|
|
71
|
+
ended = Option.some(exit)
|
|
72
|
+
wake()
|
|
73
|
+
},
|
|
74
|
+
stream: Stream.fromPull(Effect.succeed(take)),
|
|
75
|
+
}
|
|
76
|
+
}
|