@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.
Files changed (45) hide show
  1. package/dist/Clock.d.ts.map +1 -1
  2. package/dist/Clock.js +20 -12
  3. package/dist/Clock.js.map +1 -1
  4. package/dist/FrameLoop.d.ts +12 -1
  5. package/dist/FrameLoop.d.ts.map +1 -1
  6. package/dist/FrameLoop.js +23 -1
  7. package/dist/FrameLoop.js.map +1 -1
  8. package/dist/LiveValue.d.ts +4 -3
  9. package/dist/LiveValue.d.ts.map +1 -1
  10. package/dist/LiveValue.js +7 -5
  11. package/dist/LiveValue.js.map +1 -1
  12. package/dist/Probe.d.ts +5 -47
  13. package/dist/Probe.d.ts.map +1 -1
  14. package/dist/Probe.js +5 -5
  15. package/dist/Probe.js.map +1 -1
  16. package/dist/ScrollSource.d.ts +62 -0
  17. package/dist/ScrollSource.d.ts.map +1 -0
  18. package/dist/ScrollSource.js +21 -0
  19. package/dist/ScrollSource.js.map +1 -0
  20. package/dist/SharedSource.d.ts +35 -15
  21. package/dist/SharedSource.d.ts.map +1 -1
  22. package/dist/SharedSource.js +84 -108
  23. package/dist/SharedSource.js.map +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/frameLoop.d.ts +17 -0
  29. package/dist/internal/frameLoop.d.ts.map +1 -1
  30. package/dist/internal/frameLoop.js +33 -20
  31. package/dist/internal/frameLoop.js.map +1 -1
  32. package/dist/internal/hearing.d.ts +13 -0
  33. package/dist/internal/hearing.d.ts.map +1 -0
  34. package/dist/internal/hearing.js +71 -0
  35. package/dist/internal/hearing.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/Clock.ts +29 -17
  38. package/src/FrameLoop.ts +24 -1
  39. package/src/LiveValue.ts +9 -7
  40. package/src/Probe.ts +8 -54
  41. package/src/ScrollSource.ts +68 -0
  42. package/src/SharedSource.ts +113 -146
  43. package/src/index.ts +2 -1
  44. package/src/internal/frameLoop.ts +41 -18
  45. package/src/internal/hearing.ts +76 -0
@@ -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 `PubSub` or fiber (#318).
17
+ // feeds** are shared by `InputFeed.byTarget` instead, with no fiber (#318).
17
18
  //
18
- // Two constructors, both built on the same primitive (a `PubSub` fed by a `forkScoped` pump that drains
19
- // the cold source), wrapped in Effect's reference-counted resource caches: `byTarget` keys on the target
20
- // (`RcMap`), the cold `lookup` running once per target while a subscriber holds it (an Environment's
21
- // `Probe.observeResize`); `make` is the keyless case (`RcRef`). The cold source is acquired by the first
22
- // subscriber and released when the last subscriber's scope closes: `Stream.unwrap` holds the Rc reference
23
- // for the subscriber stream's lifetime, so when the last one ends the cache releases the entry, the pump
24
- // is interrupted, and the cold source's finalizers run (a listener is removed). The source's end ends
25
- // every subscriber, and its failure fails them (#618, decision 6). Effect's own `Stream.share` and
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 publish path stays quiet.
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 { identity } from "effect/Function"
37
- import * as Hash from "effect/Hash"
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 Predicate from "effect/Predicate"
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 UndefinedOr from "effect/UndefinedOr"
38
+ import * as Hearing from "./internal/hearing.ts"
48
39
  import { tracedStream } from "./internal/tracing.ts"
49
40
 
50
- /** `byTarget`'s and `make`'s options. */
51
- export interface Options {
52
- /**
53
- * How long a source with no subscriber left keeps running before it stops, so a quick re-subscribe
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
- * The hot `PubSub` that fronts a cold source: acquire the source via `acquire`, fork a scoped pump
68
- * that republishes every element into the PubSub, and hand back the PubSub. The pump is bound to
69
- * the Rc cache entry's scope, so when the cache releases the entry the pump is interrupted and the
70
- * cold source's finalizers run. When the source ends, how it ended is kept and the PubSub shut down,
71
- * which ends every subscription. Per-element publishing is un-logged: it's the hot path.
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
- const fanIn: <A>(
74
- acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>,
75
- ) => Effect.Effect<FannedIn<A>, never, Scope.Scope> = /*#__PURE__*/ Effect.fnUntraced(
76
- function* <A>(acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>) {
77
- const pubsub = yield* PubSub.unbounded<A>()
78
- const source = yield* acquire
79
- const ended = MutableRef.make(Option.none<Exit.Exit<void>>())
80
- yield* Effect.forkScoped(
81
- Stream.runForEach(source, (a) => PubSub.publish(pubsub, a)).pipe(
82
- Effect.onExit((exit) => Effect.andThen(Effect.sync(() => MutableRef.set(ended, Option.some(exit))), PubSub.shutdown(pubsub))),
83
- ),
84
- )
85
- return { pubsub, ended }
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's failure, if it failed: an interruption is how its cache ends it, not a failure. */
89
- const failureOf = (exit: Exit.Exit<void>): Option.Option<Cause.Cause<never>> => {
90
- if (Exit.isSuccess(exit)) return Option.none()
91
- if (Cause.hasInterruptsOnly(exit.cause)) return Option.none()
92
- return Option.some(exit.cause as Cause.Cause<never>)
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
- /** How a subscriber ends after its source has: failed with the source's failure, else at once. */
96
- const endOf = (ended: Option.Option<Exit.Exit<void>>): Effect.Effect<void> =>
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
- * A subscriber to a fanned-out source, made as it subscribes: its events, then the source's end. One that
101
- * subscribes after the end gets the end alone.
102
- */
103
- const subscriberOf = <A>({ pubsub, ended }: FannedIn<A>): Stream.Stream<A> =>
104
- Stream.onEnd(
105
- Option.match(MutableRef.get(ended), { onNone: () => Stream.fromPubSub(pubsub), onSome: (): Stream.Stream<A> => Stream.empty }),
106
- Effect.suspend(() => endOf(MutableRef.get(ended))),
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
- * An identity key for `RcMap`. `RcMap` hashes its keys with Effect's structural `Hash`, which
111
- * walks a target's properties: on a platform object that can throw (a DOM node's function
112
- * properties touch `.caller` in strict mode), and it would conflate two structurally equal but
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
- // Module state (#460, Q7): an id counter, only ever compared for distinctness, so every runtime may share it.
118
- let nextTargetHash = 1
119
- class TargetKey implements Equal.Equal {
120
- readonly hash: number
121
- constructor(readonly target: unknown) {
122
- this.hash = nextTargetHash++
123
- }
124
- [Hash.symbol](): number {
125
- return this.hash
126
- }
127
- [Equal.symbol](that: Equal.Equal): boolean {
128
- return this === that
129
- }
130
- }
131
-
132
- /** The target a key addresses: a {@link TargetKey}'s own, else the key itself (a non-object target). */
133
- const targetOf: (key: unknown) => unknown = /*#__PURE__*/ Match.type<unknown>().pipe(
134
- Match.when(Match.instanceOf(TargetKey), (key) => key.target),
135
- Match.orElse(identity),
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, after `idleTimeToLive` if it's given.
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>, options?: Options) {
152
+ function* <A>(lookup: (target: unknown) => Effect.Effect<Stream.Stream<A>, never, Scope.Scope>) {
156
153
  yield* Effect.logDebug("wardic.sharedSource.byTarget: create")
157
- // One stable identity key per target (see TargetKey). Object targets are cached weakly, so the
158
- // key, and the RcMap entry it addresses, share the target's lifetime; a non-object target hashes
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, after
190
- * `idleTimeToLive` if it's given. `byTarget`'s form without a target.
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>, options?: Options) {
176
+ function* <A>(acquire: Effect.Effect<Stream.Stream<A>, never, Scope.Scope>) {
203
177
  yield* Effect.logDebug("wardic.sharedSource.make: create")
204
- const ref = yield* RcRef.make({
205
- acquire: fanIn(acquire),
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: a gauge of running
33
- * animations and a histogram of ms per tick, each one `updateUnsafe` (ADR-0027, decision 6), and the
34
- * runs that settled or failed in it, counted as they end and added at its end by the loop's own
35
- * `afterTick` hook, its first (#542). The histogram ends before the hooks, so it measures the steps
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
- const animations = Metric.gauge("wardic_frameloop_animations", { description: "Primitives running on the Frame loop" })
139
- const settledRuns = Metric.counter("wardic_frameloop_settled", { description: "Runs on the Frame loop that settled" })
140
- const failedRuns = Metric.counter("wardic_frameloop_failed", { description: "Runs on the Frame loop that failed" })
141
- const tickMs = Metric.histogram("wardic_frameloop_tick_ms", {
142
- description: "Milliseconds the Frame loop spends on one tick",
143
- boundaries: Metric.linearBoundaries({ start: 0, width: 0.5, count: 32 }),
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 (settledCount > 0) settledRuns.updateUnsafe(settledCount, buildContext)
274
- if (failedCount > 0) failedRuns.updateUnsafe(failedCount, buildContext)
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
- const t0 = performance.now()
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
- animations.updateUnsafe(kept, buildContext)
348
- tickMs.updateUnsafe(performance.now() - t0, buildContext)
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
+ }