@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 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.100.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` answering at the path alone.
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 address = `${RPC_ADDRESS}/${path}${exported === 'default' ? '' : `/${exported}`}`
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(
@@ -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 ? GLOBAL_GETS.get(handler)?.(args) : 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
- GLOBAL_GETS.get(handler)?.(args) === held &&
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(outcome, request, found, args)
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
- global !== undefined &&
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) || (global as { peek(): unknown }).peek() === value)
327
- ? global
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
- const FOLLOWERS = new Map<object, Set<Follower>>()
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 followers = FOLLOWERS.get(rpc)
78
- if (followers === undefined) return
79
- for (const follower of followers) {
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, followers] of FOLLOWERS)
97
- for (const follower of followers) {
98
- if (tags !== null && !sharesTag(follower.tags, tags)) continue
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(rpc: object, serving: unknown): void {
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.args)
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 followers = FOLLOWERS.get(rpc)
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
- FOLLOWERS.set(rpc, followers)
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
- followers.delete(follower)
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
- /** One mux frame as the SSE event it is: `event`, `id` where it names a version, and `data`. */
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 frame = JSON.parse(text) as ServerFrame
416
- const data =
417
- frame.op === 'value'
418
- ? JSON.stringify(frame.value ?? null)
419
- : frame.op === 'patch'
420
- ? JSON.stringify(frame.ops ?? [])
421
- : frame.op === 'error'
422
- ? JSON.stringify(frame.error ?? null)
423
- : ''
424
- const id = frame.version === undefined ? '' : `id: ${frame.version}\n`
425
- return `event: ${frame.op}\n${id}data: ${data}\n\n`
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
- import { brotliCompressSync, constants } from 'node:zlib' // bun has gzip and zstd of its own, and no brotli
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 QUALITY = 5
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
- const body = encoding === undefined ? bytes : compressed(bytes, encoding)
63
- if (encoding !== undefined) headers.set('content-encoding', encoding)
64
- headers.set('content-length', String(body.byteLength))
65
- return new Response(body, { status, headers })
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
  }
@@ -125,8 +125,27 @@ export type Ask = {
125
125
  function toListener(message: FromWorker): void {
126
126
  process.send?.(message)
127
127
  }
128
- // The listener gone — its process killed rather than ended — takes this one with it.
129
- process.on('disconnect', () => process.exit(0))
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) return
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))
@@ -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 GOES WITH THE COMMAND. It is a process of its own, and an app with no tsconfig
395
- // holds a temporary project on disk for as long as it is open. The signal is raised again once
396
- // it is closed, so the command still ends the way that signal ends a process.
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 `max-age` off it
5
- * (`deadlineOf`) and its version (`versionOf`), and the mux feeds a follower from it.
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<object, (args: unknown) => unknown>()
10
+ export const GLOBAL_GETS = new WeakMap<
11
+ object,
12
+ { key(args: unknown): string; at(key: string): unknown }
13
+ >()
@@ -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 as unknown as Tracked], null)
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: Tracked = { readers: [], probeReaders: [] }
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
- * So the boundary is worth KNOWING and not worth engineering around. Adding a field
560
- * costs nothing; getting under costs a structural change that the next field added here
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; removal is O(n) against a reader count that is small by construction.
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
- // The probe edge is rebuilt rather than cursored: a guard that stops being taken has
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
- type Signal = { readers: Subscriber[]; probeReaders: Subscriber[] }
3717
- const SIGNALS = new Map<string, Signal>()
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: Signal = { readers: [], probeReaders: [] }
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 as unknown as Tracked, reader)
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 = { readers: [], probeReaders: [] }
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 as unknown as Tracked, reader)
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
- removeFrom(source.readers, reader)
171
- if (source.readers.length === 0) unread(source)
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
- removeFrom(source.probeReaders, reader)
222
- if (source.probeReaders.length === 0) unread(source)
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
- function removeFrom(readers: Subscriber[], reader: Subscriber): void {
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
- if (readers[index] !== reader) continue
228
- readers[index] = readers[readers.length - 1] as Subscriber
229
- readers.pop()
230
- return
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 REBUILT PER RUN rather than cursored. A body reads few probes where it reads
238
- * many values, and a guard that stops being taken must stop waking — so the list is dropped at
239
- * the start of a run and re-made by the reads that happen.
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
- for (let index = 0; index < asked.length; index += 1)
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.push(source)
246
- source.probeReaders.push(reader)
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 every probe subscription `reader` holds. */
250
- export function detachProbes(reader: Subscriber): void {
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 = 0; index < asked.length; index += 1)
329
+ for (let index = reader.probeCursor; index < asked.length; index += 1)
253
330
  dropProbeReader(asked[index] as Tracked, reader)
254
- asked.length = 0
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 — `removeFrom` takes one occurrence and the
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 reset, the probe rebuild and the tail trim, and the rest is the same lines.
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
- if (reader.probeSources.length !== 0) detachProbes(reader)
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(rpc, (args: unknown): unknown =>
449
+ GLOBAL_GETS.set(
450
+ rpc,
450
451
  keyed
451
- ? (value as { entries: Map<string, unknown> }).entries.get(
452
- keyOf(contract, args),
453
- )
454
- : value,
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