@abide/abide 0.100.0 → 0.101.0
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/CHANGELOG.md +15 -0
- package/bin/abide.ts +5 -0
- package/package.json +6 -1
- package/src/compiler/handlersUnder.ts +5 -2
- package/src/server/answerRpc.ts +22 -9
- package/src/server/answerSocket.ts +77 -29
- package/src/server/compressAnswer.ts +45 -11
- package/src/server/devWorker.ts +31 -3
- package/src/server/serveDev.ts +9 -3
- package/src/shared/GLOBAL_GETS.ts +9 -3
- package/src/shared/RPC_SERVER.ts +2 -2
- package/src/shared/reactive/Cursor.ts +3 -9
- package/src/shared/reactive/Keyed.ts +2 -1
- package/src/shared/reactive/Reactive.ts +38 -17
- package/src/shared/reactive/tracking.ts +155 -21
- package/src/shared/reactive/watch.ts +7 -3
- package/src/shared/rpc.ts +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @abide/abide
|
|
2
2
|
|
|
3
|
+
## 0.101.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 128f192: Breaking: an rpc export named as its file answers at the file's path ([`9758128`](https://github.com/briancray/abide/commit/97581285b14d6d01688c963a4332729f6af30d3a))
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 128f192: the executable carries the app as bytecode ([`4a72909`](https://github.com/briancray/abide/commit/4a729091955a28df259005b79109ee2e00593046))
|
|
12
|
+
- 128f192: followers are filed by entry key, and an SSE event is read off its frame ([`7a137e7`](https://github.com/briancray/abide/commit/7a137e7224daf334c90539c75bb4f7ff25701dc8))
|
|
13
|
+
- 128f192: a request's signal aborts when its caller hangs up or its worker is replaced ([`7a57131`](https://github.com/briancray/abide/commit/7a571311b08e2c4d3b14c244395bc7cc29fad4d7))
|
|
14
|
+
- 128f192: a page loads only the framework modules it uses
|
|
15
|
+
- 128f192: disposed readers are counted rather than found, and the probe edge is cursored ([`ef1bafe`](https://github.com/briancray/abide/commit/ef1bafec810f06bea37a88c51713ac2b92e212e7))
|
|
16
|
+
- 128f192: answers of 64 KB or more are compressed off the event loop ([`f484e0d`](https://github.com/briancray/abide/commit/f484e0d140321d9cf2a171cb52dec0b0a3b2c227))
|
|
17
|
+
|
|
3
18
|
## 0.100.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/bin/abide.ts
CHANGED
|
@@ -322,6 +322,11 @@ async function compileExecutable(
|
|
|
322
322
|
// whoever operates it — a minified line prints as the whole page module it sits in. The
|
|
323
323
|
// map is embedded in the executable, which reads it from there.
|
|
324
324
|
sourcemap: 'inline',
|
|
325
|
+
// 38.94 — parsed at build time rather than on every boot. ESM because the entry awaits at
|
|
326
|
+
// its top level, and bun's bytecode default is CommonJS, which refuses that. Nothing in the
|
|
327
|
+
// executable or the build's outputs says bytecode is inside, so no test can see this.
|
|
328
|
+
bytecode: true,
|
|
329
|
+
format: 'esm',
|
|
325
330
|
compile: {
|
|
326
331
|
...(platform === undefined
|
|
327
332
|
? {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.101.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
"#compiler": "./src/compiler/index.ts",
|
|
54
54
|
"#compiler/*": "./src/compiler/*"
|
|
55
55
|
},
|
|
56
|
+
"sideEffects": [
|
|
57
|
+
"./src/server/**",
|
|
58
|
+
"./src/shared/WORK.ts",
|
|
59
|
+
"./src/shared/reactive/Reactive.ts"
|
|
60
|
+
],
|
|
56
61
|
"dependencies": {
|
|
57
62
|
"typescript": "^7.0.2"
|
|
58
63
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 16.2 AND 19.1 — EVERY HANDLER AN APP DECLARES, AND WHERE IT IS ANSWERED. A handler is an export of
|
|
2
2
|
// a module under `server/rpc`, and its address is the module's path below that directory and then
|
|
3
|
-
// the export's name — `default
|
|
3
|
+
// the export's name — `default`, or an export named as its file, answering at the path alone.
|
|
4
4
|
//
|
|
5
5
|
// READ, NOT RUN. The build finds handlers by what their modules SAY rather than by importing them,
|
|
6
6
|
// so a build never runs server code: an app's module may open a database at import, and nothing
|
|
@@ -82,7 +82,10 @@ export async function handlersUnder(
|
|
|
82
82
|
throw new Error(
|
|
83
83
|
`abide: ${source} exports \`${exported}\`, which is not declared as a handler. A module under ${HANDLER_DIRECTORY} exports handlers alone, each declared by abide's \`GET(…)\` (or POST, PUT, PATCH, DELETE); anything else belongs beside it. RULEBOOK 19.15.`,
|
|
84
84
|
)
|
|
85
|
-
const
|
|
85
|
+
const atPath =
|
|
86
|
+
exported === 'default' ||
|
|
87
|
+
exported === path.slice(path.lastIndexOf('/') + 1)
|
|
88
|
+
const address = `${RPC_ADDRESS}/${path}${atPath ? '' : `/${exported}`}`
|
|
86
89
|
const owner = owners.get(address)
|
|
87
90
|
if (owner !== undefined)
|
|
88
91
|
throw new Error(
|
package/src/server/answerRpc.ts
CHANGED
|
@@ -243,24 +243,36 @@ export async function answerRpc(
|
|
|
243
243
|
const caller = head ? undefined : callerOf()
|
|
244
244
|
const seeded =
|
|
245
245
|
caller === undefined ? undefined : takeSeed(caller, handler, args)
|
|
246
|
+
// The entry key of a `global` value, taken once for every read of it below.
|
|
247
|
+
const global = GLOBAL_GETS.get(handler)
|
|
248
|
+
const key = global?.key(args)
|
|
246
249
|
// 21.15 — A `global` PRODUCTION ALREADY HELD, and not loading, answers without a run: a run
|
|
247
250
|
// this call started moves the generation, and one it joined was loading when it came.
|
|
248
251
|
const held =
|
|
249
|
-
seeded === undefined ?
|
|
252
|
+
seeded === undefined && key !== undefined ? global?.at(key) : undefined
|
|
250
253
|
const heldAt = held === undefined ? undefined : heldGenerationOf(held)
|
|
251
254
|
const timing = currentRequestScope()?.timing
|
|
252
255
|
if (timing !== undefined) timing.answering = handler
|
|
253
256
|
const started = performance.now()
|
|
254
257
|
const outcome =
|
|
255
258
|
seeded === undefined ? await outcomeOf(handler(args)) : await seeded
|
|
259
|
+
// The entry as the run left it, which the answer reads too.
|
|
260
|
+
const entry = key === undefined ? undefined : global?.at(key)
|
|
256
261
|
const cached =
|
|
257
262
|
heldAt !== undefined &&
|
|
258
|
-
|
|
263
|
+
entry === held &&
|
|
259
264
|
heldGenerationOf(held) === heldAt
|
|
260
265
|
// 16.38 — A MUTATION A DOCUMENT MADE — a form posted with no script — is answered as a navigation.
|
|
261
266
|
if (!inUrl && prefers(request, 'text/html', 'application/json'))
|
|
262
267
|
return navigated(outcome, request, pathname, pages)
|
|
263
|
-
const answer = await answerOutcome(
|
|
268
|
+
const answer = await answerOutcome(
|
|
269
|
+
outcome,
|
|
270
|
+
request,
|
|
271
|
+
found,
|
|
272
|
+
args,
|
|
273
|
+
key,
|
|
274
|
+
entry,
|
|
275
|
+
)
|
|
264
276
|
// A `Response` the handler built is its own — but the origin answer below is laid over it, so it
|
|
265
277
|
// varies on `Origin` too (21.7), which `handlerHeaders` never reached.
|
|
266
278
|
if (outcome.settled && outcome.value instanceof Response) {
|
|
@@ -308,23 +320,24 @@ async function answerOutcome(
|
|
|
308
320
|
request: Request,
|
|
309
321
|
found: Mounted,
|
|
310
322
|
args: unknown,
|
|
323
|
+
key: string | undefined,
|
|
324
|
+
entry: unknown,
|
|
311
325
|
): Promise<Response> {
|
|
312
326
|
const handler = found.handler
|
|
313
327
|
if (!outcome.settled) return answered(failureOf(outcome.error))
|
|
328
|
+
const value = outcome.value
|
|
314
329
|
// The `global` value the answer came from, or `undefined` for one built per request. 18.22 — or
|
|
315
330
|
// for one whose production has moved past what is answered: a seeded answer is what the render
|
|
316
331
|
// reached (41.13). 18.23 — or for a binary.
|
|
317
|
-
const global = GLOBAL_GETS.get(handler)?.(args)
|
|
318
|
-
const value = outcome.value
|
|
319
332
|
const serving =
|
|
320
|
-
|
|
333
|
+
entry !== undefined &&
|
|
321
334
|
!(
|
|
322
335
|
value instanceof Blob ||
|
|
323
336
|
value instanceof ArrayBuffer ||
|
|
324
337
|
ArrayBuffer.isView(value)
|
|
325
338
|
) &&
|
|
326
|
-
(streams(value) || (
|
|
327
|
-
?
|
|
339
|
+
(streams(value) || (entry as { peek(): unknown }).peek() === value)
|
|
340
|
+
? entry
|
|
328
341
|
: undefined
|
|
329
342
|
// 21.3 — what the handler wrote through `response` is laid over it at the exit (21.10).
|
|
330
343
|
const headers = handlerHeaders()
|
|
@@ -337,7 +350,7 @@ async function answerOutcome(
|
|
|
337
350
|
const etag = serving !== undefined ? `"${versionOf(serving)}"` : undefined
|
|
338
351
|
if (etag !== undefined) {
|
|
339
352
|
headers.set(VERSION_HEADER, etag)
|
|
340
|
-
RPC_SERVER.served?.(handler, serving)
|
|
353
|
+
RPC_SERVER.served?.(handler, serving, key as string)
|
|
341
354
|
}
|
|
342
355
|
// 21.8, 21.9, 21.11 — WHAT IS LEFT OF THE LIFE OF THE PRODUCTION ANSWERED, in place of `no-store`
|
|
343
356
|
// and nothing else. A `ttl` of Infinity is bounded by `invalidate` alone (11.35), which no cache
|
|
@@ -54,19 +54,30 @@ type Follower = {
|
|
|
54
54
|
peer: Peer
|
|
55
55
|
id: number
|
|
56
56
|
args: unknown
|
|
57
|
+
// The entry key its args are filed under (`GLOBAL_GETS`), taken once as it subscribes.
|
|
58
|
+
key: string
|
|
57
59
|
// The `global` entry its productions are pushed from, which is what a refresh would otherwise
|
|
58
60
|
// re-fetch — or `undefined`, and it is sent triggers instead.
|
|
59
61
|
serving: unknown
|
|
60
62
|
stopFeed: (() => void) | undefined
|
|
61
63
|
// 18.6 — the version it was last sent, or named on subscribing.
|
|
62
64
|
sent: number | undefined
|
|
63
|
-
// 13.15 — the literal `tags` the build copied onto the browser's copy, for a sweep to match.
|
|
64
|
-
tags: string[] | undefined
|
|
65
65
|
// 18.8 — whether the handler's rungs let it through when it opened: a guarded `GET` feeds only
|
|
66
66
|
// a follower they admitted, and sends the rest its triggers alone.
|
|
67
67
|
admitted: boolean
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
type Followed = {
|
|
70
|
+
// 13.15 — the literal `tags` the build copied onto the browser's copy, for a sweep to match:
|
|
71
|
+
// the handler's, so one for all of its followers.
|
|
72
|
+
tags: string[] | undefined
|
|
73
|
+
// What a trigger walks, matching each follower's args against its pattern.
|
|
74
|
+
all: Set<Follower>
|
|
75
|
+
// BY ENTRY KEY, so an entry answered walks the followers of its own args alone (18.8). Filed
|
|
76
|
+
// apart from `all` rather than instead of it: a walk of every follower through a set per key
|
|
77
|
+
// measured twice a flat set's.
|
|
78
|
+
byKey: Map<string, Set<Follower>>
|
|
79
|
+
}
|
|
80
|
+
const FOLLOWERS = new Map<object, Followed>()
|
|
70
81
|
|
|
71
82
|
// 13.12 — narrowed by the pattern the trigger was handed, as the handler's own entries are.
|
|
72
83
|
export function broadcast(
|
|
@@ -74,9 +85,9 @@ export function broadcast(
|
|
|
74
85
|
member: 'invalidate' | 'refresh',
|
|
75
86
|
pattern: unknown,
|
|
76
87
|
): void {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
for (const follower of
|
|
88
|
+
const followed = FOLLOWERS.get(rpc)
|
|
89
|
+
if (followed === undefined) return
|
|
90
|
+
for (const follower of followed.all) {
|
|
80
91
|
if (!matchesPattern(follower.args, pattern)) continue
|
|
81
92
|
// A fed copy is refreshed by the production the reload mints (7.18), and invalidated through
|
|
82
93
|
// the cell it is fed from (`FOLLOWED`), whichever trigger reached it — where the entry it is
|
|
@@ -93,13 +104,14 @@ export function swept(
|
|
|
93
104
|
member: 'invalidate' | 'refresh',
|
|
94
105
|
tags: string[] | null,
|
|
95
106
|
): void {
|
|
96
|
-
for (const [rpc,
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
for (const [rpc, followed] of FOLLOWERS) {
|
|
108
|
+
if (tags !== null && !sharesTag(followed.tags, tags)) continue
|
|
109
|
+
for (const follower of followed.all) {
|
|
99
110
|
if (attach(rpc as Mounted['handler'], follower) !== undefined)
|
|
100
111
|
continue
|
|
101
112
|
send(follower.peer, { op: member, id: follower.id })
|
|
102
113
|
}
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
function sharesTag(held: string[] | undefined, swept: string[]): boolean {
|
|
@@ -110,10 +122,14 @@ function sharesTag(held: string[] | undefined, swept: string[]): boolean {
|
|
|
110
122
|
|
|
111
123
|
// 18.8 — AN ENTRY ANSWERED THAT NOBODY IS FED FROM: one made after a follower subscribed for its
|
|
112
124
|
// args, or rebuilt after the one it was fed from was evicted. An entry already fed is the common
|
|
113
|
-
// answer, and asks nothing more.
|
|
114
|
-
export function feedFromServed(
|
|
125
|
+
// answer, and asks nothing more; one that is not reaches the followers filed under its key.
|
|
126
|
+
export function feedFromServed(
|
|
127
|
+
rpc: object,
|
|
128
|
+
serving: unknown,
|
|
129
|
+
key: string,
|
|
130
|
+
): void {
|
|
115
131
|
if (FEEDS.has(serving as object)) return
|
|
116
|
-
const followers = FOLLOWERS.get(rpc)
|
|
132
|
+
const followers = FOLLOWERS.get(rpc)?.byKey.get(key)
|
|
117
133
|
if (followers === undefined) return
|
|
118
134
|
for (const follower of followers)
|
|
119
135
|
attach(rpc as Mounted['handler'], follower)
|
|
@@ -128,7 +144,7 @@ function attach(rpc: Mounted['handler'], follower: Follower): unknown {
|
|
|
128
144
|
const serving =
|
|
129
145
|
rpc.guarded && !follower.admitted
|
|
130
146
|
? undefined
|
|
131
|
-
: GLOBAL_GETS.get(rpc)?.(follower.
|
|
147
|
+
: GLOBAL_GETS.get(rpc)?.at(follower.key)
|
|
132
148
|
if (serving === follower.serving) return serving
|
|
133
149
|
follower.stopFeed?.()
|
|
134
150
|
// A version is one entry's count, so what it was sent of another says nothing about this one.
|
|
@@ -331,26 +347,42 @@ function followWith(
|
|
|
331
347
|
admitted: boolean,
|
|
332
348
|
): () => void {
|
|
333
349
|
const rpc = mounted.handler
|
|
350
|
+
const key = GLOBAL_GETS.get(rpc)?.key(args) ?? ''
|
|
334
351
|
const follower: Follower = {
|
|
335
352
|
peer,
|
|
336
353
|
id,
|
|
337
354
|
args,
|
|
355
|
+
key,
|
|
338
356
|
serving: undefined,
|
|
339
357
|
stopFeed: undefined,
|
|
340
358
|
sent: held,
|
|
341
|
-
tags: mounted.copy?.tags,
|
|
342
359
|
admitted,
|
|
343
360
|
}
|
|
344
361
|
attach(rpc, follower)
|
|
345
|
-
let
|
|
362
|
+
let followed = FOLLOWERS.get(rpc)
|
|
363
|
+
if (followed === undefined) {
|
|
364
|
+
followed = {
|
|
365
|
+
tags: mounted.copy?.tags,
|
|
366
|
+
all: new Set(),
|
|
367
|
+
byKey: new Map(),
|
|
368
|
+
}
|
|
369
|
+
FOLLOWERS.set(rpc, followed)
|
|
370
|
+
}
|
|
371
|
+
const byKey = followed.byKey
|
|
372
|
+
let followers = byKey.get(key)
|
|
346
373
|
if (followers === undefined) {
|
|
347
374
|
followers = new Set()
|
|
348
|
-
|
|
375
|
+
byKey.set(key, followers)
|
|
349
376
|
}
|
|
350
377
|
followers.add(follower)
|
|
378
|
+
const all = followed.all
|
|
379
|
+
all.add(follower)
|
|
351
380
|
return () => {
|
|
352
381
|
follower.stopFeed?.()
|
|
353
|
-
|
|
382
|
+
all.delete(follower)
|
|
383
|
+
// A key nobody follows leaves, or the index grows with every key ever followed.
|
|
384
|
+
if (followers.delete(follower) && followers.size === 0)
|
|
385
|
+
byKey.delete(key)
|
|
354
386
|
}
|
|
355
387
|
}
|
|
356
388
|
|
|
@@ -410,19 +442,35 @@ export function followAsEvents(
|
|
|
410
442
|
})
|
|
411
443
|
}
|
|
412
444
|
|
|
413
|
-
/**
|
|
445
|
+
/**
|
|
446
|
+
* One mux frame as the SSE event it is: `event`, `id` where it names a version, and `data`.
|
|
447
|
+
*
|
|
448
|
+
* 18.6 — READ OFF THE FRAME'S HEAD, never parsed: the body was serialized once for every follower
|
|
449
|
+
* (`sendAll`), and a parse and a stringify per event stream would pay it again per follower. Every
|
|
450
|
+
* frame this module writes is `{"op":…,"id":…`, numbers named `base` or `version`, and at most one
|
|
451
|
+
* body last — so the body is the rest of the text, as it was written.
|
|
452
|
+
*/
|
|
414
453
|
function eventOf(text: string): string {
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
454
|
+
const opEnd = text.indexOf('"', 7)
|
|
455
|
+
const op = text.slice(7, opEnd)
|
|
456
|
+
let id = ''
|
|
457
|
+
let data =
|
|
458
|
+
op === 'value' || op === 'error' ? 'null' : op === 'patch' ? '[]' : ''
|
|
459
|
+
// Past `,"id":` — a number, so the next comma is the next member's.
|
|
460
|
+
let at = text.indexOf(',', opEnd + 6)
|
|
461
|
+
while (at !== -1) {
|
|
462
|
+
const nameEnd = text.indexOf('"', at + 2)
|
|
463
|
+
const name = text.slice(at + 2, nameEnd)
|
|
464
|
+
const from = nameEnd + 2
|
|
465
|
+
if (name !== 'base' && name !== 'version') {
|
|
466
|
+
data = text.slice(from, -1)
|
|
467
|
+
break
|
|
468
|
+
}
|
|
469
|
+
at = text.indexOf(',', from)
|
|
470
|
+
if (name === 'version')
|
|
471
|
+
id = `id: ${text.slice(from, at === -1 ? -1 : at)}\n`
|
|
472
|
+
}
|
|
473
|
+
return `event: ${op}\n${id}data: ${data}\n\n`
|
|
426
474
|
}
|
|
427
475
|
|
|
428
476
|
// 18.6 — ONE WATCH PER VALUE SERVED, however many follow it, and each frame's body serialized once
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// `accept-encoding` in `vary` through `DOCUMENT_HEADERS`, which its `HEAD` and error answers carry as
|
|
8
8
|
// well; a JSON answer names it here. Both say their length: `abide dev` relays a body chunk by chunk
|
|
9
9
|
// from its worker (38.36), and a relayed body with no declared length goes out chunked.
|
|
10
|
-
|
|
10
|
+
// BUN'S GZIP parts from zlib's past ~130 KB, which only the off-loop lane reaches — so that lane takes
|
|
11
|
+
// zlib's, the loop keeps bun's, and the two make the same bytes.
|
|
12
|
+
import { brotliCompress, brotliCompressSync, constants, gzip } from 'node:zlib' // bun has no brotli, and no compression off the loop
|
|
11
13
|
import {
|
|
12
14
|
ENCODINGS,
|
|
13
15
|
type Encoding,
|
|
@@ -17,13 +19,18 @@ import {
|
|
|
17
19
|
// QUALITY 5, NOT 11. This runs per request, and the top brotli level is priced for a build that
|
|
18
20
|
// runs once: on an 18 KB docs page 11 took 15,800 µs against 5's 150, for 15% fewer bytes. 5 is
|
|
19
21
|
// already under gzip's size at twice gzip's 70 µs.
|
|
20
|
-
const
|
|
22
|
+
const BROTLI_OPTIONS = { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } }
|
|
21
23
|
|
|
22
24
|
// 38.69 — the bytes under which a JSON answer goes out as it is. Under it an answer saves tens of
|
|
23
25
|
// bytes that leave in the same packet anyway, for 3–8 µs of a ~57 µs request; at 8 KB the same CPU
|
|
24
26
|
// saves seven kilobytes.
|
|
25
27
|
const COMPRESSION_FLOOR = 1024
|
|
26
28
|
|
|
29
|
+
// 38.96 — the bytes from which an answer is compressed off the event loop. Brotli 5 holds the loop
|
|
30
|
+
// ~360 µs at 64 KB and ~7 ms at 700 KB — every other request and socket waits that long — where
|
|
31
|
+
// the thread pool's hop adds ~20 µs to the answer it carries: 6% at 64 KB, 27% at 16 KB.
|
|
32
|
+
const OFF_LOOP_FLOOR = 64 * 1024
|
|
33
|
+
|
|
27
34
|
const ENCODER = new TextEncoder()
|
|
28
35
|
|
|
29
36
|
function compressed(
|
|
@@ -31,24 +38,48 @@ function compressed(
|
|
|
31
38
|
encoding: Encoding,
|
|
32
39
|
): Uint8Array<ArrayBuffer> {
|
|
33
40
|
return encoding === 'br'
|
|
34
|
-
? (brotliCompressSync(bytes,
|
|
35
|
-
params: { [constants.BROTLI_PARAM_QUALITY]: QUALITY },
|
|
36
|
-
}) as Uint8Array<ArrayBuffer>)
|
|
41
|
+
? (brotliCompressSync(bytes, BROTLI_OPTIONS) as Uint8Array<ArrayBuffer>)
|
|
37
42
|
: Bun.gzipSync(bytes)
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
function compressedOffLoop(
|
|
46
|
+
bytes: Uint8Array<ArrayBuffer>,
|
|
47
|
+
encoding: Encoding,
|
|
48
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const settle = (error: Error | null, result: Uint8Array): void => {
|
|
51
|
+
if (error === null) resolve(result as Uint8Array<ArrayBuffer>)
|
|
52
|
+
else reject(error)
|
|
53
|
+
}
|
|
54
|
+
if (encoding === 'br') brotliCompress(bytes, BROTLI_OPTIONS, settle)
|
|
55
|
+
else gzip(bytes, settle)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function answered(
|
|
60
|
+
body: Uint8Array<ArrayBuffer>,
|
|
61
|
+
encoding: Encoding | undefined,
|
|
62
|
+
headers: Headers,
|
|
63
|
+
status: number,
|
|
64
|
+
): Response {
|
|
65
|
+
if (encoding !== undefined) headers.set('content-encoding', encoding)
|
|
66
|
+
headers.set('content-length', String(body.byteLength))
|
|
67
|
+
return new Response(body, { status, headers })
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
/**
|
|
41
71
|
* `answer` as the `Response` to `request`, in the encoding the request takes where it takes one.
|
|
42
72
|
*
|
|
43
73
|
* THE LANE IS THE BODY'S TYPE: a built document arrives as its bytes, and a JSON value as its text,
|
|
44
|
-
* encoded once here and floored by its length.
|
|
74
|
+
* encoded once here and floored by its length. A `Promise` only where the compression runs off the
|
|
75
|
+
* loop, so an answer under that floor costs no await.
|
|
45
76
|
*/
|
|
46
77
|
export function compressAnswer(
|
|
47
78
|
answer: Uint8Array<ArrayBuffer> | string,
|
|
48
79
|
request: Request,
|
|
49
80
|
headers: Headers,
|
|
50
81
|
status: number,
|
|
51
|
-
): Response {
|
|
82
|
+
): Response | Promise<Response> {
|
|
52
83
|
const json = typeof answer === 'string'
|
|
53
84
|
const bytes = json ? ENCODER.encode(answer) : answer
|
|
54
85
|
const encoding =
|
|
@@ -59,8 +90,11 @@ export function compressAnswer(
|
|
|
59
90
|
ENCODINGS,
|
|
60
91
|
)
|
|
61
92
|
if (json) headers.append('vary', 'accept-encoding')
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
if (encoding === undefined)
|
|
94
|
+
return answered(bytes, encoding, headers, status)
|
|
95
|
+
if (bytes.byteLength >= OFF_LOOP_FLOOR)
|
|
96
|
+
return compressedOffLoop(bytes, encoding).then((body) =>
|
|
97
|
+
answered(body, encoding, headers, status),
|
|
98
|
+
)
|
|
99
|
+
return answered(compressed(bytes, encoding), encoding, headers, status)
|
|
66
100
|
}
|
package/src/server/devWorker.ts
CHANGED
|
@@ -125,8 +125,27 @@ export type Ask = {
|
|
|
125
125
|
function toListener(message: FromWorker): void {
|
|
126
126
|
process.send?.(message)
|
|
127
127
|
}
|
|
128
|
-
//
|
|
129
|
-
|
|
128
|
+
// 38.97 — EACH REQUEST'S SIGNAL, by request id, until its answer ends: the listener holds the
|
|
129
|
+
// connection, so a hang-up reaches this worker as a `cancel` rather than through bun.
|
|
130
|
+
const HANG_UPS = new Map<number, AbortController>()
|
|
131
|
+
|
|
132
|
+
/** Files away the hang-up of the request `id`, whose answer has ended, and hands it back. */
|
|
133
|
+
function answered(id: number): AbortController | undefined {
|
|
134
|
+
const hangUp = HANG_UPS.get(id)
|
|
135
|
+
HANG_UPS.delete(id)
|
|
136
|
+
return hangUp
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// EVERY REQUEST IN FLIGHT HANGS UP WITH THIS WORKER, as each would with `abide start`'s process: a
|
|
140
|
+
// handler that spawned under `request().signal` takes its child down, where an exit alone left it
|
|
141
|
+
// running under launchd. The listener ends this worker on every reload, and its own end — killed
|
|
142
|
+
// rather than ended — is a disconnect.
|
|
143
|
+
function hangUpAndExit(): void {
|
|
144
|
+
for (const hangUp of HANG_UPS.values()) hangUp.abort()
|
|
145
|
+
process.exit(0)
|
|
146
|
+
}
|
|
147
|
+
process.on('disconnect', hangUpAndExit)
|
|
148
|
+
process.on('SIGTERM', hangUpAndExit)
|
|
130
149
|
|
|
131
150
|
// THE COMPILE, AS SOMETHING TO WAIT ON. A request that arrives while the worker is still
|
|
132
151
|
// compiling has to wait for the app rather than be told there is none: the listener forwards
|
|
@@ -159,6 +178,7 @@ async function receive(message: ToWorker): Promise<void> {
|
|
|
159
178
|
if (message.kind === 'cancel') {
|
|
160
179
|
const reading = bodies.get(message.id)
|
|
161
180
|
bodies.delete(message.id)
|
|
181
|
+
answered(message.id)?.abort()
|
|
162
182
|
return reading?.reader.cancel()
|
|
163
183
|
}
|
|
164
184
|
await opening
|
|
@@ -217,10 +237,13 @@ const BODILESS = new Set([101, 204, 205, 304])
|
|
|
217
237
|
* they gave up.
|
|
218
238
|
*/
|
|
219
239
|
async function answerAsk(ask: Ask): Promise<void> {
|
|
240
|
+
const hangUp = new AbortController()
|
|
241
|
+
HANG_UPS.set(ask.id, hangUp)
|
|
220
242
|
const request = new Request(ask.url, {
|
|
221
243
|
method: ask.method,
|
|
222
244
|
headers: ask.headers,
|
|
223
245
|
body: ask.body,
|
|
246
|
+
signal: hangUp.signal,
|
|
224
247
|
})
|
|
225
248
|
ASKS.set(request, ask.id)
|
|
226
249
|
let response: Response
|
|
@@ -251,7 +274,10 @@ async function answerAsk(ask: Ask): Promise<void> {
|
|
|
251
274
|
carried !== undefined && admitsMux(request, response, carried),
|
|
252
275
|
whole,
|
|
253
276
|
})
|
|
254
|
-
if (body === null || whole !== undefined)
|
|
277
|
+
if (body === null || whole !== undefined) {
|
|
278
|
+
answered(ask.id)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
255
281
|
bodies.set(ask.id, {
|
|
256
282
|
reader: body.getReader(),
|
|
257
283
|
html: (response.headers.get('content-type') ?? '').startsWith(
|
|
@@ -274,6 +300,7 @@ async function pull(id: number): Promise<void> {
|
|
|
274
300
|
const { done, value } = await reading.reader.read()
|
|
275
301
|
if (done) {
|
|
276
302
|
bodies.delete(id)
|
|
303
|
+
answered(id)
|
|
277
304
|
toListener({ kind: 'end', id })
|
|
278
305
|
return
|
|
279
306
|
}
|
|
@@ -284,6 +311,7 @@ async function pull(id: number): Promise<void> {
|
|
|
284
311
|
toListener({ kind: 'chunk', id, bytes })
|
|
285
312
|
} catch (thrown) {
|
|
286
313
|
bodies.delete(id)
|
|
314
|
+
answered(id)
|
|
287
315
|
const why = describe(thrown)
|
|
288
316
|
if (reading.html) {
|
|
289
317
|
const shown = new TextEncoder().encode(failureMarkup(why))
|
package/src/server/serveDev.ts
CHANGED
|
@@ -200,8 +200,11 @@ export async function serveDev(
|
|
|
200
200
|
if (!child.killed && child.exitCode === null)
|
|
201
201
|
child.send(message)
|
|
202
202
|
},
|
|
203
|
+
// 38.97 — SIGTERM, which the worker answers by hanging up what it is answering; a worker
|
|
204
|
+
// wedged in a loop never reads it, so a second later it is killed outright.
|
|
203
205
|
terminate() {
|
|
204
206
|
child.kill()
|
|
207
|
+
setTimeout(() => child.kill('SIGKILL'), 1000).unref()
|
|
205
208
|
},
|
|
206
209
|
}
|
|
207
210
|
worker = started
|
|
@@ -391,11 +394,14 @@ export async function serveDev(
|
|
|
391
394
|
})
|
|
392
395
|
}
|
|
393
396
|
|
|
394
|
-
// THE CHECKER
|
|
395
|
-
// holds a temporary project on disk for as long as
|
|
396
|
-
//
|
|
397
|
+
// THE CHECKER AND THE WORKER GO WITH THE COMMAND. Each is a process of its own, and an app with
|
|
398
|
+
// no tsconfig holds a temporary project on disk for as long as the checker is open. The worker
|
|
399
|
+
// is told directly rather than left to its `disconnect`, which a worker busy inside a build
|
|
400
|
+
// reads only when the build lets go — orphaned until then. The signal is raised again once the
|
|
401
|
+
// checker is closed, so the command still ends the way that signal ends a process.
|
|
397
402
|
for (const signal of ['SIGINT', 'SIGTERM'] as const)
|
|
398
403
|
process.once(signal, () => {
|
|
404
|
+
worker?.terminate()
|
|
399
405
|
void closeChecker().finally(() => process.kill(process.pid, signal))
|
|
400
406
|
})
|
|
401
407
|
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every `GET` declared over a `global` value, to the value it is serving for some args.
|
|
3
3
|
*
|
|
4
|
-
* 21.8, 18.5 — `undefined` where no entry holds the args. The answer reads its
|
|
5
|
-
* (`deadlineOf`) and its version (`versionOf`), and the mux feeds a follower from
|
|
4
|
+
* 21.8, 18.5 — `at(key(args))` is `undefined` where no entry holds the args. The answer reads its
|
|
5
|
+
* `max-age` off it (`deadlineOf`) and its version (`versionOf`), and the mux feeds a follower from
|
|
6
|
+
* it. THE KEY IS ITS OWN STEP so each side takes it once: an answer once a request, a follower once
|
|
7
|
+
* as it subscribes — and the mux files its followers by it, so an entry answered reaches the
|
|
8
|
+
* followers of its own args and walks no other (18.8). An unkeyed value has one entry, under `''`.
|
|
6
9
|
*/
|
|
7
|
-
export const GLOBAL_GETS = new WeakMap<
|
|
10
|
+
export const GLOBAL_GETS = new WeakMap<
|
|
11
|
+
object,
|
|
12
|
+
{ key(args: unknown): string; at(key: string): unknown }
|
|
13
|
+
>()
|
package/src/shared/RPC_SERVER.ts
CHANGED
|
@@ -39,12 +39,12 @@ export const RPC_SERVER: {
|
|
|
39
39
|
| ((member: 'invalidate' | 'refresh', tags: string[] | null) => void)
|
|
40
40
|
| null
|
|
41
41
|
/**
|
|
42
|
-
* The `global` entry an answer came from.
|
|
42
|
+
* The `global` entry an answer came from, and the key it is filed under (`GLOBAL_GETS`).
|
|
43
43
|
*
|
|
44
44
|
* 18.8 — so a follower subscribed before it existed, or fed from one since evicted, is fed
|
|
45
45
|
* from this one.
|
|
46
46
|
*/
|
|
47
|
-
served: ((rpc: object, serving: unknown) => void) | null
|
|
47
|
+
served: ((rpc: object, serving: unknown, key: string) => void) | null
|
|
48
48
|
/**
|
|
49
49
|
* How long a handler's own body took inside its rungs.
|
|
50
50
|
*
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
// cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
|
|
9
9
|
// second file.
|
|
10
10
|
|
|
11
|
+
import { PATCHES } from './PATCHES.ts'
|
|
11
12
|
import {
|
|
12
13
|
dropReader,
|
|
13
14
|
newSubscriber,
|
|
14
15
|
type Subscriber,
|
|
15
16
|
type Tracked,
|
|
16
17
|
} from './tracking.ts'
|
|
17
|
-
import { PATCHES } from './PATCHES.ts'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* What a cursor reads, and all of a cell the server is handed (`servedCellOf`).
|
|
@@ -22,15 +22,10 @@ import { PATCHES } from './PATCHES.ts'
|
|
|
22
22
|
* `ring[index]` carries ordinal `ringBase + index + 1`, so a cursor holding an ordinal finds its
|
|
23
23
|
* place in a stream's chunk buffer without the buffer having to hold one.
|
|
24
24
|
*/
|
|
25
|
-
export type Retained = {
|
|
25
|
+
export type Retained = Tracked & {
|
|
26
26
|
/** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
|
|
27
27
|
cap: { holding: boolean } | null
|
|
28
28
|
ordinal: number
|
|
29
|
-
/**
|
|
30
|
-
* Unused by a cursor and declared so a `Cell` still satisfies `Tracked`, which a cursor's
|
|
31
|
-
* subscriber is registered through.
|
|
32
|
-
*/
|
|
33
|
-
probeReaders: Subscriber[]
|
|
34
29
|
ring: unknown[]
|
|
35
30
|
ringBase: number
|
|
36
31
|
servable: boolean
|
|
@@ -41,7 +36,6 @@ export type Retained = {
|
|
|
41
36
|
*/
|
|
42
37
|
stream: object | null
|
|
43
38
|
value: unknown
|
|
44
|
-
readers: Subscriber[]
|
|
45
39
|
}
|
|
46
40
|
|
|
47
41
|
// The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
|
|
@@ -118,7 +112,7 @@ export class Cursor implements AsyncIterator<unknown> {
|
|
|
118
112
|
// reader in the graph carries for the sake of the few that are cursors.
|
|
119
113
|
// Registered outside the tracking protocol, so the source it waits on is declared
|
|
120
114
|
// rather than discovered — see the same shape in `Reactive`'s `settled`.
|
|
121
|
-
this.reader = newSubscriber([source
|
|
115
|
+
this.reader = newSubscriber([source], null)
|
|
122
116
|
// One production per waiting turn, in the order they were asked for: a burst that
|
|
123
117
|
// lands three productions on a consumer holding two turns answers both.
|
|
124
118
|
this.reader.notify = (): void => {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
detachProbes,
|
|
41
41
|
dropReader,
|
|
42
42
|
newSubscriber,
|
|
43
|
+
newTracked,
|
|
43
44
|
type Subscriber,
|
|
44
45
|
TRACKING,
|
|
45
46
|
type Tracked,
|
|
@@ -332,7 +333,7 @@ export function createKeyed<
|
|
|
332
333
|
// entry's probe carries it: a guard asked before a key was reached subscribed to the entries
|
|
333
334
|
// there were, so the key that starts loading next — search-as-you-type — never woke it. One
|
|
334
335
|
// signal per `Keyed`, moved where an entry is reached and where one is released.
|
|
335
|
-
const membership
|
|
336
|
+
const membership = newTracked()
|
|
336
337
|
|
|
337
338
|
function askMembership(): void {
|
|
338
339
|
const reader = TRACKING.reader
|
|
@@ -19,10 +19,10 @@ import { type Found, runSchema, type Schema } from '../Schema.ts'
|
|
|
19
19
|
import { UNEXPECTED } from '../UNEXPECTED.ts'
|
|
20
20
|
import { WORK } from '../WORK.ts'
|
|
21
21
|
import { CELL } from './CELL.ts'
|
|
22
|
+
import { Cursor, hasCursorBehind, keepPlaces, type Retained } from './Cursor.ts'
|
|
22
23
|
import { ENTRY_LIVES } from './ENTRY_LIVES.ts'
|
|
23
24
|
import { armTimer, type Expiry, expiryFor } from './Expiry.ts'
|
|
24
25
|
import { FOLLOWED } from './FOLLOWED.ts'
|
|
25
|
-
import { Cursor, hasCursorBehind, keepPlaces, type Retained } from './Cursor.ts'
|
|
26
26
|
import { isReactive } from './isReactive.ts'
|
|
27
27
|
import { isThenable } from './isThenable.ts'
|
|
28
28
|
import { MUTATION } from './MUTATION.ts'
|
|
@@ -38,16 +38,19 @@ import {
|
|
|
38
38
|
CLEAN,
|
|
39
39
|
DIRTY,
|
|
40
40
|
detach,
|
|
41
|
-
detachProbes,
|
|
42
41
|
dropProbeReader,
|
|
43
42
|
newSubscriber,
|
|
43
|
+
newTracked,
|
|
44
44
|
RELEASING,
|
|
45
|
+
revive,
|
|
45
46
|
type Subscriber,
|
|
46
47
|
TRACKING,
|
|
47
48
|
type Tracked,
|
|
49
|
+
tombstone,
|
|
48
50
|
track,
|
|
49
51
|
trackProbe,
|
|
50
52
|
truncate,
|
|
53
|
+
truncateProbes,
|
|
51
54
|
untracked,
|
|
52
55
|
} from './tracking.ts'
|
|
53
56
|
import { owned, owning, watchOver } from './watch.ts'
|
|
@@ -556,8 +559,12 @@ export function isStreamBody(body: (args: never) => unknown): boolean {
|
|
|
556
559
|
* under JSC; adding a DEAD boolean back put all three within 0.6%, which is the proof it
|
|
557
560
|
* was layout and not work — that same dead boolean then cost the probe walk 6-7%.
|
|
558
561
|
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
562
|
+
* Above the budget is not free under JSC, though: three fields more (the dead-reader
|
|
563
|
+
* counts and `probeCursor`) moved `construct a value` and the derived case +6-8% there,
|
|
564
|
+
* and three UNUSED fields on the old record moved them the same — layout, not work.
|
|
565
|
+
*
|
|
566
|
+
* So the boundary is worth KNOWING and not worth engineering around. A field costs a few
|
|
567
|
+
* ns of construction; getting under costs a structural change that the next field added here
|
|
561
568
|
* silently undoes, and it re-rolls in a different direction for every row. If
|
|
562
569
|
* construction ever needs that 11%, shed fields — do not build machinery to shed them.
|
|
563
570
|
* And when a V8-only move of this size shows up on a graph row, the first thing to check
|
|
@@ -568,20 +575,24 @@ class Cell {
|
|
|
568
575
|
* Who reads this value, on the value edge.
|
|
569
576
|
*
|
|
570
577
|
* ARRAYS, not sets. The mark walk walks this per write and a set's iterator
|
|
571
|
-
* allocates
|
|
578
|
+
* allocates. A disposed reader is left in place and counted rather than found — see
|
|
579
|
+
* `dropFrom` in `tracking.ts` — so a walk over this skips `!alive`.
|
|
572
580
|
*/
|
|
573
581
|
declare readers: Subscriber[]
|
|
582
|
+
declare deadReaders: number
|
|
574
583
|
/**
|
|
575
584
|
* Who asked about this value's PROBES — a different edge from `readers`, and see
|
|
576
585
|
* `markProbes` for what that buys.
|
|
577
586
|
*/
|
|
578
587
|
declare probeReaders: Subscriber[]
|
|
588
|
+
declare deadProbeReaders: number
|
|
579
589
|
declare sources: Cell[]
|
|
580
590
|
/**
|
|
581
591
|
* The values whose PROBES this body asked about — a `Cell` is a `Subscriber`, so it
|
|
582
592
|
* carries the reader half of the probe edge as well as the source half.
|
|
583
593
|
*/
|
|
584
594
|
declare probeSources: Cell[]
|
|
595
|
+
declare probeCursor: number
|
|
585
596
|
declare cursor: number
|
|
586
597
|
/** See `Subscriber.seen`. */
|
|
587
598
|
declare seen: Set<Cell> | null
|
|
@@ -801,9 +812,12 @@ class Cell {
|
|
|
801
812
|
) {
|
|
802
813
|
// Every field, in one order, `undefined` included.
|
|
803
814
|
this.readers = []
|
|
815
|
+
this.deadReaders = 0
|
|
804
816
|
this.probeReaders = []
|
|
817
|
+
this.deadProbeReaders = 0
|
|
805
818
|
this.sources = []
|
|
806
819
|
this.probeSources = []
|
|
820
|
+
this.probeCursor = 0
|
|
807
821
|
this.cursor = 0
|
|
808
822
|
this.seen = null
|
|
809
823
|
this.seenThrough = 0
|
|
@@ -1294,6 +1308,7 @@ function markProbes(cell: Cell, pass: number): void {
|
|
|
1294
1308
|
const readers = cell.readers
|
|
1295
1309
|
for (let index = 0; index < readers.length; index += 1) {
|
|
1296
1310
|
const reader = readers[index] as Subscriber
|
|
1311
|
+
if (!reader.alive) continue
|
|
1297
1312
|
if (!reader.isEffect) markProbes(reader as unknown as Cell, pass)
|
|
1298
1313
|
else if (reader.adopter !== null)
|
|
1299
1314
|
markProbes(reader.adopter as unknown as Cell, pass)
|
|
@@ -2188,6 +2203,8 @@ function run(cell: Cell): void {
|
|
|
2188
2203
|
// where the cascade would try: `a` reads `b` and `b` reads `a`. Without this the
|
|
2189
2204
|
// recursion is unbounded and the failure is a stack overflow rather than a value.
|
|
2190
2205
|
if (cell.running) return
|
|
2206
|
+
// A released orphan read again (12.14) — see `tombstone`.
|
|
2207
|
+
if (!cell.alive) revive(cell as unknown as Subscriber)
|
|
2191
2208
|
cell.generation += 1
|
|
2192
2209
|
cell.state = CLEAN
|
|
2193
2210
|
cell.running = true
|
|
@@ -2199,9 +2216,7 @@ function run(cell: Cell): void {
|
|
|
2199
2216
|
// THE RUN'S CURSOR, spelled out rather than behind a call frame on a path walked once
|
|
2200
2217
|
// per node per write. Zero is also "has diverged nowhere yet": see `tracking.ts`.
|
|
2201
2218
|
reader.cursor = 0
|
|
2202
|
-
|
|
2203
|
-
// to stop waking, and a body reads few probes where it reads many values.
|
|
2204
|
-
if (reader.probeSources.length !== 0) detachProbes(reader)
|
|
2219
|
+
reader.probeCursor = 0
|
|
2205
2220
|
let produced: unknown
|
|
2206
2221
|
try {
|
|
2207
2222
|
// 11.32 — UNDER THE VALUE'S OWN SCOPE, not the ambient one. A drain runs every body a
|
|
@@ -2211,6 +2226,8 @@ function run(cell: Cell): void {
|
|
|
2211
2226
|
} catch (thrown) {
|
|
2212
2227
|
TRACKING.reader = previous
|
|
2213
2228
|
if (reader.cursor < reader.sources.length) truncate(reader)
|
|
2229
|
+
if (reader.probeCursor < reader.probeSources.length)
|
|
2230
|
+
truncateProbes(reader)
|
|
2214
2231
|
cell.running = false
|
|
2215
2232
|
if (thrown instanceof NotLanded) {
|
|
2216
2233
|
cell.loading = true
|
|
@@ -2235,6 +2252,8 @@ function run(cell: Cell): void {
|
|
|
2235
2252
|
// that is the only thing the truncation has to do — so the test is here and the call
|
|
2236
2253
|
// is the rare arm.
|
|
2237
2254
|
if (reader.cursor < reader.sources.length) truncate(reader)
|
|
2255
|
+
// And a guard this run did not take stops waking it.
|
|
2256
|
+
if (reader.probeCursor < reader.probeSources.length) truncateProbes(reader)
|
|
2238
2257
|
cell.running = false
|
|
2239
2258
|
// A VALUE THAT WAS ADOPTING AND IS NOT ANY MORE. Behind the `adoption` load so every
|
|
2240
2259
|
// other recompute pays one field test rather than the brand read below it.
|
|
@@ -3290,6 +3309,7 @@ function owedBelow(cell: Cell): void {
|
|
|
3290
3309
|
const readers = cell.readers
|
|
3291
3310
|
for (let index = 0; index < readers.length; index += 1) {
|
|
3292
3311
|
const reader = readers[index] as Subscriber
|
|
3312
|
+
if (!reader.alive) continue
|
|
3293
3313
|
const below = (reader.isEffect
|
|
3294
3314
|
? reader.adopter
|
|
3295
3315
|
: reader) as unknown as Cell | null
|
|
@@ -3713,14 +3733,13 @@ let compactProducersAt = 1024
|
|
|
3713
3733
|
* subscribes to this, never to each entry — the probe edge `trackProbe` makes, from a node that
|
|
3714
3734
|
* is nothing but that edge.
|
|
3715
3735
|
*/
|
|
3716
|
-
|
|
3717
|
-
const
|
|
3718
|
-
const SIGNALS_BY_TAG = new Map<string, Signal[]>()
|
|
3736
|
+
const SIGNALS = new Map<string, Tracked>()
|
|
3737
|
+
const SIGNALS_BY_TAG = new Map<string, Tracked[]>()
|
|
3719
3738
|
/**
|
|
3720
3739
|
* The bare selection's one signal. Not a key in `SIGNALS`: `{ tags: [] }` canonicalises to the
|
|
3721
3740
|
* empty set and matches nothing, where the bare selection matches every filed load in reach.
|
|
3722
3741
|
*/
|
|
3723
|
-
const EVERY_PRODUCER
|
|
3742
|
+
const EVERY_PRODUCER = newTracked()
|
|
3724
3743
|
|
|
3725
3744
|
function refOf(cell: Cell): WeakRef<Cell> {
|
|
3726
3745
|
let ref = cell.ref
|
|
@@ -3839,13 +3858,13 @@ function trackSignal(tags: string[] | null): void {
|
|
|
3839
3858
|
const reader = TRACKING.reader
|
|
3840
3859
|
if (reader === null) return
|
|
3841
3860
|
if (tags === null) {
|
|
3842
|
-
trackProbe(EVERY_PRODUCER
|
|
3861
|
+
trackProbe(EVERY_PRODUCER, reader)
|
|
3843
3862
|
return
|
|
3844
3863
|
}
|
|
3845
3864
|
const key = JSON.stringify([...new Set(tags)].sort())
|
|
3846
3865
|
let signal = SIGNALS.get(key)
|
|
3847
3866
|
if (signal === undefined) {
|
|
3848
|
-
signal =
|
|
3867
|
+
signal = newTracked()
|
|
3849
3868
|
SIGNALS.set(key, signal)
|
|
3850
3869
|
for (const tag of tags) {
|
|
3851
3870
|
const signals = SIGNALS_BY_TAG.get(tag)
|
|
@@ -3853,7 +3872,7 @@ function trackSignal(tags: string[] | null): void {
|
|
|
3853
3872
|
else signals.push(signal)
|
|
3854
3873
|
}
|
|
3855
3874
|
}
|
|
3856
|
-
trackProbe(signal
|
|
3875
|
+
trackProbe(signal, reader)
|
|
3857
3876
|
}
|
|
3858
3877
|
|
|
3859
3878
|
function markSignals(tags: string[]): void {
|
|
@@ -3936,6 +3955,7 @@ export function refreshOver(tags: string[] | null): void {
|
|
|
3936
3955
|
function readByAny(readers: Subscriber[]): boolean {
|
|
3937
3956
|
for (let index = 0; index < readers.length; index += 1) {
|
|
3938
3957
|
const reader = readers[index] as Subscriber
|
|
3958
|
+
if (!reader.alive) continue
|
|
3939
3959
|
if (!reader.isMirror || reader.adopter !== null) return true
|
|
3940
3960
|
}
|
|
3941
3961
|
return false
|
|
@@ -4368,12 +4388,13 @@ function releaseOrphan(cell: Cell): void {
|
|
|
4368
4388
|
const stream = cell.stream
|
|
4369
4389
|
if (stream !== null && stream.source === null) stopStream(stream)
|
|
4370
4390
|
dropAdoption(cell)
|
|
4371
|
-
detach(cell as unknown as Subscriber)
|
|
4372
4391
|
cell.aborted = false
|
|
4373
4392
|
if (cell.body !== null && stream === null) {
|
|
4393
|
+
// OWED A RUN, so a read that reaches it again runs it — and `run` revives it first.
|
|
4394
|
+
tombstone(cell as unknown as Subscriber)
|
|
4374
4395
|
cell.state = DIRTY
|
|
4375
4396
|
cell.direct = false
|
|
4376
|
-
}
|
|
4397
|
+
} else detach(cell as unknown as Subscriber)
|
|
4377
4398
|
}
|
|
4378
4399
|
|
|
4379
4400
|
RELEASING.unread = (source: Tracked): void => releaseOrphan(source as Cell)
|
|
@@ -87,6 +87,11 @@ export type Subscriber = {
|
|
|
87
87
|
* the identity gate that exists to stop exactly that cannot see a production nobody minted.
|
|
88
88
|
*/
|
|
89
89
|
probeSources: Tracked[]
|
|
90
|
+
/**
|
|
91
|
+
* How far into `probeSources` this run has re-asked — `cursor` for the probe edge, and see
|
|
92
|
+
* `trackProbe` for why it is not the same protocol.
|
|
93
|
+
*/
|
|
94
|
+
probeCursor: number
|
|
90
95
|
/**
|
|
91
96
|
* How far into `sources` this run has matched.
|
|
92
97
|
*
|
|
@@ -111,8 +116,22 @@ export type Subscriber = {
|
|
|
111
116
|
|
|
112
117
|
export type Tracked = {
|
|
113
118
|
readers: Subscriber[]
|
|
119
|
+
/** How many of `readers` are disposed and not yet swept out — see `dropFrom`. */
|
|
120
|
+
deadReaders: number
|
|
114
121
|
/** Who asked about this value's probes. Apart from `readers` — see `probeSources`. */
|
|
115
122
|
probeReaders: Subscriber[]
|
|
123
|
+
/** `deadReaders` for `probeReaders`. */
|
|
124
|
+
deadProbeReaders: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A node that is nothing but its edges — a probe signal with no value behind it. */
|
|
128
|
+
export function newTracked(): Tracked {
|
|
129
|
+
return {
|
|
130
|
+
readers: [],
|
|
131
|
+
deadReaders: 0,
|
|
132
|
+
probeReaders: [],
|
|
133
|
+
deadProbeReaders: 0,
|
|
134
|
+
}
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
/** The reader whose run is on the stack, or `null` where reads subscribe to nothing. */
|
|
@@ -144,6 +163,7 @@ export function newSubscriber(
|
|
|
144
163
|
return {
|
|
145
164
|
sources,
|
|
146
165
|
probeSources: [],
|
|
166
|
+
probeCursor: 0,
|
|
147
167
|
cursor: 0,
|
|
148
168
|
seen: null,
|
|
149
169
|
seenThrough: 0,
|
|
@@ -167,8 +187,9 @@ export function newSubscriber(
|
|
|
167
187
|
* of the loop beside this one is what CLAUDE.md's sibling-seam rule refuses.
|
|
168
188
|
*/
|
|
169
189
|
export function dropReader(source: Tracked, reader: Subscriber): void {
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
const readers = source.readers
|
|
191
|
+
source.deadReaders = dropFrom(readers, reader, source.deadReaders)
|
|
192
|
+
if (readers.length === 0) unread(source)
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
/**
|
|
@@ -218,40 +239,102 @@ export function untracked<First, Second, Result>(
|
|
|
218
239
|
* rather than on a value.
|
|
219
240
|
*/
|
|
220
241
|
export function dropProbeReader(source: Tracked, reader: Subscriber): void {
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
const readers = source.probeReaders
|
|
243
|
+
source.deadProbeReaders = dropFrom(readers, reader, source.deadProbeReaders)
|
|
244
|
+
if (readers.length === 0) unread(source)
|
|
223
245
|
}
|
|
224
246
|
|
|
225
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Takes `reader` off `readers`, of which `dead` are disposed, and returns how many are now.
|
|
249
|
+
*
|
|
250
|
+
* A DISPOSED READER IS LEFT WHERE IT IS AND COUNTED, rather than found. Finding it is a scan of
|
|
251
|
+
* every reader of the value, and a teardown drops them all — n rows reading one page value, torn
|
|
252
|
+
* down, paid n²/2 compares, and a keyed replace is the worst order there is: the new rows are
|
|
253
|
+
* pushed behind the old ones the scan walks past. 10,000 rows went 16-39 ms to 0.2-0.4. Every
|
|
254
|
+
* walk over a reader list skips `!alive`, so a dead slot costs a walk one test until the
|
|
255
|
+
* sweep below takes it out.
|
|
256
|
+
*
|
|
257
|
+
* SWEPT ONCE HALF THE LIST IS DEAD, which keeps a walk to at most twice the live readers and the
|
|
258
|
+
* sweep amortised O(1) a drop — and keeps the invariant every `length` test leans on: a list with
|
|
259
|
+
* entries has a LIVE one, since a list that is all dead is at least half dead. HALF, NOT MORE THAN
|
|
260
|
+
* HALF: a keyed replace ends on exactly half, n old rows dead behind n new ones, and a sweep that
|
|
261
|
+
* waited for one more drop kept the old rows — and through each one's `notify`, its scope and its
|
|
262
|
+
* nodes — reachable until some unrelated reader left. An over-count — a
|
|
263
|
+
* reader dropped twice — only sweeps early, and the sweep recounts from zero.
|
|
264
|
+
*
|
|
265
|
+
* A LIVE reader still scans: that is a run's own truncation or an orphan's release, a reader
|
|
266
|
+
* that may subscribe again, so it cannot be left to read as dead.
|
|
267
|
+
*/
|
|
268
|
+
function dropFrom(
|
|
269
|
+
readers: Subscriber[],
|
|
270
|
+
reader: Subscriber,
|
|
271
|
+
dead: number,
|
|
272
|
+
): number {
|
|
273
|
+
if (!reader.alive) dead += 1
|
|
274
|
+
else purge(readers, reader)
|
|
275
|
+
if (dead * 2 < readers.length) return dead
|
|
276
|
+
let kept = 0
|
|
226
277
|
for (let index = 0; index < readers.length; index += 1) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
278
|
+
const survivor = readers[index] as Subscriber
|
|
279
|
+
if (survivor.alive) {
|
|
280
|
+
readers[kept] = survivor
|
|
281
|
+
kept += 1
|
|
282
|
+
}
|
|
231
283
|
}
|
|
284
|
+
readers.length = kept
|
|
285
|
+
return 0
|
|
232
286
|
}
|
|
233
287
|
|
|
234
288
|
/**
|
|
235
289
|
* Subscribes `reader` to `source`'s probes, once per run.
|
|
236
290
|
*
|
|
237
|
-
* A PROBE EDGE IS
|
|
238
|
-
*
|
|
239
|
-
*
|
|
291
|
+
* A PROBE EDGE IS CURSORED, and kept in the order this run asked. `probeSources[0..probeCursor)`
|
|
292
|
+
* is what this run has asked so far; a source asked again is moved to the cursor rather than
|
|
293
|
+
* dropped and re-pushed, so a run that asks what its last one did touches no source's list at
|
|
294
|
+
* all. It used to be dropped at the start of every run and re-made — which put a scan of the
|
|
295
|
+
* SOURCE'S probe readers behind every rerun, and n guards over one shared `ready()` woken by one
|
|
296
|
+
* flip paid n²: 10,000 went 39 ms to 0.4. A guard that stops being taken still stops waking:
|
|
297
|
+
* what the run did not re-ask is past the cursor at its end, and `truncateProbes` drops it.
|
|
298
|
+
*
|
|
299
|
+
* NOT `track`'s protocol, because the list is a SET in no particular order where `sources` is
|
|
300
|
+
* positional: a re-ask out of order is a swap, never a divergence, and a body asks one to three.
|
|
240
301
|
*/
|
|
241
302
|
export function trackProbe(source: Tracked, reader: Subscriber): void {
|
|
242
303
|
const asked = reader.probeSources
|
|
243
|
-
|
|
304
|
+
const at = reader.probeCursor
|
|
305
|
+
if (asked[at] === source) {
|
|
306
|
+
reader.probeCursor = at + 1
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
for (let index = 0; index < at; index += 1)
|
|
244
310
|
if (asked[index] === source) return
|
|
245
|
-
asked.
|
|
246
|
-
|
|
311
|
+
let found = asked.length
|
|
312
|
+
for (let index = at + 1; index < asked.length; index += 1)
|
|
313
|
+
if (asked[index] === source) {
|
|
314
|
+
found = index
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
if (found === asked.length) {
|
|
318
|
+
asked.push(source)
|
|
319
|
+
source.probeReaders.push(reader)
|
|
320
|
+
}
|
|
321
|
+
asked[found] = asked[at] as Tracked
|
|
322
|
+
asked[at] = source
|
|
323
|
+
reader.probeCursor = at + 1
|
|
247
324
|
}
|
|
248
325
|
|
|
249
|
-
/** Drops
|
|
250
|
-
export function
|
|
326
|
+
/** Drops the probe subscriptions `reader`'s run did not re-ask — the tail past its cursor. */
|
|
327
|
+
export function truncateProbes(reader: Subscriber): void {
|
|
251
328
|
const asked = reader.probeSources
|
|
252
|
-
for (let index =
|
|
329
|
+
for (let index = reader.probeCursor; index < asked.length; index += 1)
|
|
253
330
|
dropProbeReader(asked[index] as Tracked, reader)
|
|
254
|
-
asked.length =
|
|
331
|
+
asked.length = reader.probeCursor
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Drops every probe subscription `reader` holds. */
|
|
335
|
+
export function detachProbes(reader: Subscriber): void {
|
|
336
|
+
reader.probeCursor = 0
|
|
337
|
+
truncateProbes(reader)
|
|
255
338
|
}
|
|
256
339
|
|
|
257
340
|
// How far `kept * dropped` must outgrow `kept + dropped` before `truncate` builds a `Set` over the
|
|
@@ -278,8 +361,9 @@ const SET_OVER_SCAN = 20
|
|
|
278
361
|
* `SET_OVER_SCAN`): at 1000/999 the scan is one probe per dropped slot and building the Set
|
|
279
362
|
* doubled the flip, so the two are priced against each other instead.
|
|
280
363
|
*
|
|
281
|
-
* A duplicate inside the DROPPED tail needs no test — `
|
|
282
|
-
* second call finds none
|
|
364
|
+
* A duplicate inside the DROPPED tail needs no test — `dropFrom` takes one occurrence and the
|
|
365
|
+
* second call finds none, or for a disposed reader counts one dead too many, which only sweeps
|
|
366
|
+
* early.
|
|
283
367
|
*
|
|
284
368
|
* Exported because a tracked run's end is exactly this and nothing more: the TEST that there is a
|
|
285
369
|
* tail is the caller's — two field loads, on the rare arm.
|
|
@@ -416,3 +500,53 @@ export function detach(reader: Subscriber): void {
|
|
|
416
500
|
truncate(reader)
|
|
417
501
|
if (reader.probeSources.length !== 0) detachProbes(reader)
|
|
418
502
|
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Lets go of every subscription `reader` holds as a DISPOSAL — counted dead where it stands
|
|
506
|
+
* (`dropFrom`) rather than scanned for — while keeping its lists, so `revive` can find what it left.
|
|
507
|
+
*
|
|
508
|
+
* FOR A RELEASED ORPHAN (12.14): n rows each deriving off one page value, torn down, scanned the
|
|
509
|
+
* page value's readers once a row. The orphan may be read again, which is what `revive` is for —
|
|
510
|
+
* paid by the read that comes back, which is rare, rather than by the release, which is per row.
|
|
511
|
+
*/
|
|
512
|
+
export function tombstone(reader: Subscriber): void {
|
|
513
|
+
reader.alive = false
|
|
514
|
+
const sources = reader.sources
|
|
515
|
+
for (let index = 0; index < sources.length; index += 1)
|
|
516
|
+
dropReader(sources[index] as Tracked, reader)
|
|
517
|
+
reader.cursor = 0
|
|
518
|
+
const asked = reader.probeSources
|
|
519
|
+
for (let index = 0; index < asked.length; index += 1)
|
|
520
|
+
dropProbeReader(asked[index] as Tracked, reader)
|
|
521
|
+
reader.probeCursor = 0
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Takes a `tombstone`d reader's dead entries out of whatever list has not swept them yet, and
|
|
526
|
+
* makes it live with nothing subscribed — the state a fresh reader starts its run in.
|
|
527
|
+
*/
|
|
528
|
+
export function revive(reader: Subscriber): void {
|
|
529
|
+
reader.alive = true
|
|
530
|
+
const sources = reader.sources
|
|
531
|
+
for (let index = 0; index < sources.length; index += 1) {
|
|
532
|
+
const source = sources[index] as Tracked
|
|
533
|
+
if (purge(source.readers, reader)) source.deadReaders -= 1
|
|
534
|
+
}
|
|
535
|
+
sources.length = 0
|
|
536
|
+
const asked = reader.probeSources
|
|
537
|
+
for (let index = 0; index < asked.length; index += 1) {
|
|
538
|
+
const source = asked[index] as Tracked
|
|
539
|
+
if (purge(source.probeReaders, reader)) source.deadProbeReaders -= 1
|
|
540
|
+
}
|
|
541
|
+
asked.length = 0
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function purge(readers: Subscriber[], reader: Subscriber): boolean {
|
|
545
|
+
for (let index = 0; index < readers.length; index += 1) {
|
|
546
|
+
if (readers[index] !== reader) continue
|
|
547
|
+
readers[index] = readers[readers.length - 1] as Subscriber
|
|
548
|
+
readers.pop()
|
|
549
|
+
return true
|
|
550
|
+
}
|
|
551
|
+
return false
|
|
552
|
+
}
|
|
@@ -20,12 +20,12 @@ import type { Reactive } from './Reactive.ts'
|
|
|
20
20
|
import { currentScope, runInScope } from './scoping.ts'
|
|
21
21
|
import {
|
|
22
22
|
detach,
|
|
23
|
-
detachProbes,
|
|
24
23
|
newSubscriber,
|
|
25
24
|
type Subscriber,
|
|
26
25
|
TRACKING,
|
|
27
26
|
type Tracked,
|
|
28
27
|
truncate,
|
|
28
|
+
truncateProbes,
|
|
29
29
|
untracked,
|
|
30
30
|
} from './tracking.ts'
|
|
31
31
|
|
|
@@ -248,7 +248,7 @@ export function watchOver(
|
|
|
248
248
|
* binding's `Scope` and a `{#try}`'s retry hold their own, and call this direct.
|
|
249
249
|
*
|
|
250
250
|
* A SECOND BODY BESIDE `watchOver` ON PURPOSE, and it reads as the duplication it is: the two
|
|
251
|
-
* differ by the cursor
|
|
251
|
+
* differ by the cursor resets and the tail trims, and the rest is the same lines.
|
|
252
252
|
* ONE BODY WITH A `tracked` FLAG WAS BUILT AND MEASURED, under V8 against this pair, interleaved
|
|
253
253
|
* four runs a side: `write a value that changed` — one bound watch — went 15.6 ns to 21.4 in every
|
|
254
254
|
* run, and `mount 50 readers` +12% (+7% under JSC). Three flag tests cannot cost 6 ns. The likely
|
|
@@ -276,7 +276,7 @@ export function watchTracked(effect: Effect): Disposer {
|
|
|
276
276
|
const previous = TRACKING.reader
|
|
277
277
|
TRACKING.reader = reader
|
|
278
278
|
reader.cursor = 0
|
|
279
|
-
|
|
279
|
+
reader.probeCursor = 0
|
|
280
280
|
reader.running = true
|
|
281
281
|
try {
|
|
282
282
|
WORK.wakes += 1
|
|
@@ -285,6 +285,8 @@ export function watchTracked(effect: Effect): Disposer {
|
|
|
285
285
|
reader.running = false
|
|
286
286
|
TRACKING.reader = previous
|
|
287
287
|
if (reader.cursor < reader.sources.length) truncate(reader)
|
|
288
|
+
if (reader.probeCursor < reader.probeSources.length)
|
|
289
|
+
truncateProbes(reader)
|
|
288
290
|
// The abort reaches here too: an effect that read a value which has
|
|
289
291
|
// not landed is not an effect that failed, it is one whose turn has
|
|
290
292
|
// not come. The read subscribed before it threw, so the value landing
|
|
@@ -299,6 +301,8 @@ export function watchTracked(effect: Effect): Disposer {
|
|
|
299
301
|
reader.running = false
|
|
300
302
|
TRACKING.reader = previous
|
|
301
303
|
if (reader.cursor < reader.sources.length) truncate(reader)
|
|
304
|
+
if (reader.probeCursor < reader.probeSources.length)
|
|
305
|
+
truncateProbes(reader)
|
|
302
306
|
}
|
|
303
307
|
reader.notify()
|
|
304
308
|
return (): void => {
|
package/src/shared/rpc.ts
CHANGED
|
@@ -446,12 +446,17 @@ export function declare(method: Method): Declare {
|
|
|
446
446
|
// value produced, and holds no way back to it. A keyed value is looked up without a reach,
|
|
447
447
|
// so asking starts nothing (11.55).
|
|
448
448
|
if (method === 'GET' && !isForwarder(value))
|
|
449
|
-
GLOBAL_GETS.set(
|
|
449
|
+
GLOBAL_GETS.set(
|
|
450
|
+
rpc,
|
|
450
451
|
keyed
|
|
451
|
-
?
|
|
452
|
-
keyOf(contract, args),
|
|
453
|
-
|
|
454
|
-
|
|
452
|
+
? {
|
|
453
|
+
key: (args) => keyOf(contract, args),
|
|
454
|
+
at: (key) =>
|
|
455
|
+
(
|
|
456
|
+
value as { entries: Map<string, unknown> }
|
|
457
|
+
).entries.get(key),
|
|
458
|
+
}
|
|
459
|
+
: { key: () => '', at: () => value },
|
|
455
460
|
)
|
|
456
461
|
return rpc
|
|
457
462
|
}) as Declare
|