@atolljs/core 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/contract/memoryDevtools.d.ts +30 -0
  3. package/dist/contract/sharedMemory.d.ts +4 -2
  4. package/dist/contract/types.d.ts +11 -0
  5. package/dist/devtools.d.ts +127 -4
  6. package/dist/index.d.ts +8 -4
  7. package/dist/index.js +19 -17
  8. package/dist/index10.js +29 -14
  9. package/dist/index11.js +14 -307
  10. package/dist/index12.js +29 -50
  11. package/dist/index13.js +46 -60
  12. package/dist/index14.js +56 -22
  13. package/dist/index15.js +332 -55
  14. package/dist/index16.js +246 -45
  15. package/dist/index17.js +56 -51
  16. package/dist/index18.js +9 -39
  17. package/dist/index19.js +56 -80
  18. package/dist/index20.js +24 -53
  19. package/dist/index21.js +56 -52
  20. package/dist/index22.js +48 -0
  21. package/dist/index23.js +68 -0
  22. package/dist/index24.js +41 -0
  23. package/dist/index25.js +46 -0
  24. package/dist/index26.js +113 -0
  25. package/dist/index27.js +60 -0
  26. package/dist/index28.js +57 -0
  27. package/dist/index5.js +135 -64
  28. package/dist/index6.js +160 -35
  29. package/dist/index7.js +45 -503
  30. package/dist/index8.js +513 -19
  31. package/dist/index9.js +19 -33
  32. package/dist/pool/dedicatedWorker.d.ts +63 -0
  33. package/dist/pool/devtoolsCommands.d.ts +54 -0
  34. package/dist/pool/subWorkerClient.d.ts +50 -0
  35. package/dist/pool/workerChannel.d.ts +44 -0
  36. package/dist/pool/workerClient.d.ts +36 -8
  37. package/dist/pool/workerPool.d.ts +10 -1
  38. package/dist/reactive.d.ts +15 -0
  39. package/dist/reactiveGraph.d.ts +9 -0
  40. package/dist/userTiming.d.ts +27 -0
  41. package/package.json +2 -1
  42. package/src/contract/memoryDevtools.ts +249 -0
  43. package/src/contract/sharedMemory.ts +20 -6
  44. package/src/contract/types.ts +11 -0
  45. package/src/devtools.ts +251 -6
  46. package/src/index.ts +13 -1
  47. package/src/log.ts +22 -0
  48. package/src/observable.ts +16 -3
  49. package/src/pool/dedicatedWorker.ts +332 -0
  50. package/src/pool/devtoolsCommands.ts +165 -0
  51. package/src/pool/subWorkerClient.ts +70 -0
  52. package/src/pool/workerChannel.ts +99 -0
  53. package/src/pool/workerClient.ts +61 -15
  54. package/src/pool/workerPool.ts +75 -45
  55. package/src/reactive.ts +87 -12
  56. package/src/reactiveGraph.ts +91 -0
  57. package/src/testing/inProcessWorker.ts +4 -3
  58. package/src/userTiming.ts +71 -0
  59. package/src/worker/workerBootstrap.ts +27 -4
@@ -2,6 +2,7 @@ import type { ServiceMethod, TaskRunner } from '../service';
2
2
  import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
3
3
  import type { MemoryConfig, Prettify } from '../contract/types';
4
4
  import { WorkerPool, type RunOptions } from './workerPool';
5
+ import { DedicatedWorker } from './dedicatedWorker';
5
6
  import type {
6
7
  WorkerDefinition,
7
8
  WorkerMethodMap,
@@ -92,18 +93,33 @@ export function workerClient<W extends WorkerDefinition>(
92
93
  export interface ConnectWorkerConfig<S extends SharedSpec> {
93
94
  /** Omit for a message-only pool — the SharedArrayBuffer check is skipped. */
94
95
  sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
96
+ /**
97
+ * Ship an existing SharedArrayBuffer to every worker instead of
98
+ * allocating one — e.g. `sharedBuffer: anotherPool.sharedBuffer`, or a
99
+ * bound contract's `contract.buffer` inside a worker shell. A thunk is
100
+ * resolved once at pool construction.
101
+ */
102
+ sharedBuffer?: SharedArrayBuffer | (() => SharedArrayBuffer | undefined);
95
103
  /**
96
104
  * Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`,
97
105
  * or a URL.
98
106
  */
99
107
  worker: (() => Worker) | URL;
108
+ /**
109
+ * How many workers this client needs. `1` (the default) is a single
110
+ * dedicated worker with no pool: calls post straight to it, no queue or
111
+ * scheduler. `N > 1` builds a pool that spreads separate calls across N
112
+ * workers; `'auto'` sizes it to `navigator.hardwareConcurrency ?? 4`.
113
+ */
114
+ workers?: number | 'auto';
115
+ /** @deprecated Use `workers`. Read only when `workers` is absent. */
100
116
  poolSize?: number | 'auto';
101
117
  /** Display label for devtools/dashboards — falls back to a generated id. */
102
118
  name?: string;
103
119
  memory?: MemoryConfig;
104
- /** Max in-flight tasks per worker (default 1); excess calls queue FIFO. */
120
+ /** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
105
121
  concurrency?: number;
106
- /** Max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
122
+ /** Pool only: max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
107
123
  maxQueue?: number;
108
124
  /** Default true — a crashed worker is replaced and its in-flight calls reject. */
109
125
  respawn?: boolean;
@@ -113,13 +129,29 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
113
129
  lazy?: boolean;
114
130
  }
115
131
 
116
- /** A typed worker client plus pool lifecycle controls. */
132
+ /** What a client runs on: a pool (`workers > 1`) or one dedicated worker. */
133
+ export type WorkerRunner<S extends SharedSpec = SharedSpec> = WorkerPool<S> | DedicatedWorker<S>;
134
+
135
+ /** Resolve a `workers` count: `'auto'` is one per core (4 when unknown). */
136
+ export const resolveWorkerCount = (n: number | 'auto' | undefined): number =>
137
+ n === 'auto'
138
+ ? typeof navigator !== 'undefined'
139
+ ? (navigator.hardwareConcurrency ?? 4)
140
+ : 4
141
+ : Math.max(1, Math.floor(n ?? 1));
142
+
143
+ /** A typed worker client plus lifecycle controls. */
117
144
  export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = WorkerMethods<W> & {
118
- /** Spawn the pool now (idempotent). */
145
+ /** Spawn the worker(s) now (idempotent). */
119
146
  start(): void;
120
- /** Terminate the pool; the next method call re-spawns it. */
147
+ /** Terminate the worker(s); the next method call re-spawns. */
121
148
  terminate(): void;
122
- readonly pool: WorkerPool<S> | null;
149
+ /**
150
+ * The runner once spawned: a `DedicatedWorker` for `workers: 1`, a
151
+ * `WorkerPool` otherwise. Both expose `stats()`, `close()`, `terminate()`,
152
+ * `workers`, `sharedBuffer` and `poolId`.
153
+ */
154
+ readonly pool: WorkerRunner<S> | null;
123
155
  readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
124
156
  };
125
157
 
@@ -132,12 +164,15 @@ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = Wor
132
164
  * export const incidents = connectWorker<IncidentsWorker>({
133
165
  * sharedMemory: incidentsMemory,
134
166
  * worker: () => new Worker(new URL('./worker/incidents.worker.ts', import.meta.url), { type: 'module' }),
135
- * poolSize: 1,
167
+ * workers: 3, // omit for one dedicated worker
136
168
  * });
137
169
  *
138
- * await incidents.queryIncidents(q); // typed dispatch to the pool
170
+ * await incidents.queryIncidents(q); // typed dispatch
139
171
  * incidents.terminate(); // next call lazily re-spawns
140
172
  *
173
+ * `workers` states what the work needs and the client picks the runner:
174
+ * 1 (default) → a `DedicatedWorker`, no pool; more → a `WorkerPool`.
175
+ *
141
176
  * Lazy by default: no Worker is touched until the first method call (or
142
177
  * `start()`), so importing the client is SSR-safe.
143
178
  */
@@ -145,22 +180,33 @@ export function connectWorker<
145
180
  W extends WorkerDefinition,
146
181
  S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec,
147
182
  >(config: ConnectWorkerConfig<S>): WorkerClient<W, S> {
148
- let pool: WorkerPool<S> | null = null;
149
- const spawn = (): WorkerPool<S> =>
150
- (pool ??= new WorkerPool<S>({
183
+ let pool: WorkerRunner<S> | null = null;
184
+ const spawn = (): WorkerRunner<S> => {
185
+ if (pool) return pool;
186
+ const count = resolveWorkerCount(config.workers ?? config.poolSize);
187
+ const base = {
151
188
  sharedMemory: config.sharedMemory,
189
+ sharedBuffer: config.sharedBuffer,
152
190
  createWorker:
153
191
  typeof config.worker === 'function'
154
192
  ? config.worker
155
193
  : () => new Worker(config.worker as URL, { type: 'module' }),
156
- poolSize: config.poolSize,
157
194
  name: config.name,
158
195
  memory: config.memory,
159
- concurrency: config.concurrency,
160
- maxQueue: config.maxQueue,
161
196
  respawn: config.respawn,
162
197
  taskTimeout: config.taskTimeout,
163
- }));
198
+ };
199
+ pool =
200
+ count === 1
201
+ ? new DedicatedWorker<S>(base)
202
+ : new WorkerPool<S>({
203
+ ...base,
204
+ poolSize: count,
205
+ concurrency: config.concurrency,
206
+ maxQueue: config.maxQueue,
207
+ });
208
+ return pool;
209
+ };
164
210
 
165
211
  if (config.lazy === false) spawn();
166
212
 
@@ -1,9 +1,12 @@
1
- import { MemoryManager } from './memory';
1
+ import type { MemoryManager } from './memory';
2
2
  import { PoolQueueFullError, TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
3
3
  import { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
4
4
  import { MemoryPersistence, PoolTasks, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
5
- import { devtoolsEnabled, emitDevtools, forwardDevtools, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
5
+ import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
6
+ import { measureTaskCall } from '../userTiming';
7
+ import { forwardWorkerDevtools, sendInit, setupChannelMemory, type ChannelMemory } from './workerChannel';
6
8
  import { fmtBytes, scoped } from '../log';
9
+ import { applyChaos, CHAOS_FAILURE, KILL_MESSAGE, registerDevtoolsRunner, type ChaosConfig } from './devtoolsCommands';
7
10
 
8
11
  const poolLog = scoped('pool');
9
12
  const taskLog = scoped('task');
@@ -86,7 +89,10 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
86
89
  private pending = new Map<number, TaskEntry>();
87
90
  private nextMessageId = 0;
88
91
  private queue: TaskEntry[] = [];
92
+ private readonly mem: ChannelMemory<S>;
89
93
  private memoryManager?: MemoryManager;
94
+ /** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-pool sharing). */
95
+ private injectedBuffer?: SharedArrayBuffer;
90
96
  private workerUrl?: URL;
91
97
  private spawn: () => Worker;
92
98
  private readonly concurrency: number;
@@ -94,6 +100,9 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
94
100
  private readonly respawn: boolean;
95
101
  private readonly taskTimeout?: number;
96
102
  private closed = false;
103
+ /** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
104
+ private chaos: ChaosConfig | null = null;
105
+ private devtoolsOff?: () => void;
97
106
  private drainWaiters: Array<() => void> = [];
98
107
  private statsAgg = {
99
108
  completed: 0,
@@ -103,8 +112,11 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
103
112
  waitMs: agg(),
104
113
  runMs: agg(),
105
114
  };
106
- /** Devtools correlation id ('pool-N') — stamped on every event this pool emits. */
107
- readonly poolId = nextDevtoolsId('pool');
115
+ /**
116
+ * Devtools correlation id, stamped on every event this pool emits:
117
+ * '<name>-p' when the config has a `name`, else 'pool-N'.
118
+ */
119
+ readonly poolId: string;
108
120
  private readonly label?: string;
109
121
  private nextCallId = 0;
110
122
  public readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
@@ -112,13 +124,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
112
124
  public readonly persistence: MemoryPersistence | undefined;
113
125
 
114
126
  constructor(config: WorkerPoolConfig<S, TaskMap>) {
115
- if (config.sharedMemory) {
116
- if (typeof SharedArrayBuffer === 'undefined' || (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated)) {
117
- throw new Error(
118
- 'WorkerPool requires SharedArrayBuffer in a cross-origin isolated context — serve the page with COOP/COEP headers.'
119
- );
120
- }
121
- }
127
+ const mem = setupChannelMemory(config, 'WorkerPool');
122
128
  if (!config.workerUrl && !config.createWorker) {
123
129
  throw new Error('WorkerPool requires either `workerUrl` or `createWorker` in its config.');
124
130
  }
@@ -129,23 +135,25 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
129
135
  this.respawn = config.respawn ?? true;
130
136
  this.taskTimeout = config.taskTimeout;
131
137
  this.label = config.name;
132
-
133
- if (config.sharedMemory) {
134
- this.sharedMemory = config.sharedMemory;
135
- this.memoryManager = new MemoryManager(config.memory);
136
- this.memoryManager.ensureCapacity(this.sharedMemory.totalBytes);
137
- this.sharedMemory.bind(this.memoryManager.getBuffer());
138
- this.persistence = config.persistence?.(config.sharedMemory);
139
- }
138
+ this.poolId = nextDevtoolsId('pool', config.name);
139
+ this.mem = mem;
140
+ this.sharedMemory = mem.sharedMemory;
141
+ this.memoryManager = mem.memoryManager;
142
+ this.injectedBuffer = mem.injectedBuffer;
143
+ this.persistence = mem.persistence;
140
144
 
141
145
  const requestedSize = config.poolSize ?? 'auto';
142
146
  const poolSize =
143
- requestedSize === 'auto' ? (navigator.hardwareConcurrency ?? 4) : requestedSize;
147
+ requestedSize === 'auto'
148
+ ? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? 4 : 4)
149
+ : requestedSize;
144
150
 
145
151
  poolLog.info(
146
152
  this.sharedMemory
147
153
  ? `spawning ${poolSize} worker(s) with ${fmtBytes(this.sharedMemory.totalBytes)} shared buffer`
148
- : `spawning ${poolSize} worker(s), message-only`,
154
+ : this.injectedBuffer
155
+ ? `spawning ${poolSize} worker(s) with ${fmtBytes(this.injectedBuffer.byteLength)} inherited buffer`
156
+ : `spawning ${poolSize} worker(s), message-only`,
149
157
  { workerUrl: config.workerUrl ? String(config.workerUrl) : 'createWorker()' },
150
158
  );
151
159
  poolLog.debug(`tip: setLogLevel('trace') for per-task detail`);
@@ -156,12 +164,28 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
156
164
  label: this.label,
157
165
  poolSize,
158
166
  concurrency: this.concurrency,
159
- memoryBytes: this.sharedMemory?.totalBytes,
167
+ memoryBytes: this.sharedMemory?.totalBytes ?? this.injectedBuffer?.byteLength,
160
168
  });
161
169
 
162
170
  for (let i = 0; i < poolSize; i++) {
163
171
  this.addWorker(this.spawn());
164
172
  }
173
+ if (devtoolsEnabled()) {
174
+ this.devtoolsOff = registerDevtoolsRunner({
175
+ poolId: this.poolId,
176
+ label: this.label,
177
+ dedicated: false,
178
+ size: () => this.slots.length,
179
+ stats: () => this.stats(),
180
+ kill: (slot) => {
181
+ const s = this.slots[slot];
182
+ if (!s) throw new Error(`${this.poolId} has no worker slot ${slot}`);
183
+ this.handleWorkerError(s, { message: KILL_MESSAGE });
184
+ },
185
+ getChaos: () => this.chaos,
186
+ setChaos: (chaos) => { this.chaos = chaos; },
187
+ });
188
+ }
165
189
 
166
190
  // Install first-class task methods: pool.queryIncidents(q) →
167
191
  // runTask(QueryIncidents, q). Typed through PoolTasks<T> at the
@@ -190,10 +214,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
190
214
  // slot produced it so worker-side fetches/heap/memory writes
191
215
  // attribute to a specific worker.
192
216
  if (data.type === 'ATOLL_DEVTOOLS' && data.event) {
193
- forwardDevtools({
194
- ...data.event,
195
- worker: { poolId: this.poolId, slot: this.slots.indexOf(slot) },
196
- });
217
+ forwardWorkerDevtools(this.poolId, this.slots.indexOf(slot), data.event);
197
218
  return;
198
219
  }
199
220
  this.handleReply(slot, data);
@@ -201,15 +222,9 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
201
222
  worker.addEventListener('error', (event: unknown) => {
202
223
  this.handleWorkerError(slot, event);
203
224
  });
204
- // `devtools` tells the worker to forward its instrumentation events back
205
- // on this channel (ATOLL_DEVTOOLS) — only when a sink is live at spawn.
206
- if (this.memoryManager) {
207
- worker.postMessage({ type: 'INIT_MEMORY', memory: this.memoryManager.memory, devtools: devtoolsEnabled() });
208
- } else {
209
- worker.postMessage({ type: 'INIT', devtools: devtoolsEnabled() });
210
- }
225
+ const init = sendInit(worker, this.mem);
211
226
  this.slots.push(slot);
212
- poolLog.debug(`worker spawned, ${this.memoryManager ? 'INIT_MEMORY' : 'INIT'} sent`);
227
+ poolLog.debug(`worker spawned, ${init} sent`);
213
228
  emitDevtools({ type: 'worker:spawn', poolId: this.poolId, slot: this.slots.indexOf(slot) });
214
229
  return slot;
215
230
  }
@@ -224,17 +239,24 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
224
239
  // stats and settlement — the caller was already rejected.
225
240
  if (!entry.done) {
226
241
  entry.done = true;
227
- const runMs = performance.now() - (entry.sentAt ?? entry.enqueuedAt);
242
+ const start = entry.sentAt ?? entry.enqueuedAt;
243
+ const runMs = performance.now() - start;
228
244
  this.statsAgg.runMs.add(runMs);
229
245
  if (data.success) {
230
246
  this.statsAgg.completed++;
231
247
  taskLog.debug(`← ${entry.contract.taskId} ok`);
232
- emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'ok', runMs });
248
+ if (devtoolsEnabled()) {
249
+ emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'ok', runMs, resultBytes: estimateCloneBytes(data.result) });
250
+ measureTaskCall(this.poolId, entry.contract.taskId, entry.callId, this.slots.indexOf(slot), start, start + runMs, true);
251
+ }
233
252
  entry.resolve(data.result);
234
253
  } else {
235
254
  this.statsAgg.failed++;
236
255
  taskLog.warn(`← ${entry.contract.taskId} failed: ${data.error}`);
237
- emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'error', runMs, error: data.error });
256
+ if (devtoolsEnabled()) {
257
+ emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'error', runMs, error: data.error });
258
+ measureTaskCall(this.poolId, entry.contract.taskId, entry.callId, this.slots.indexOf(slot), start, start + runMs, false);
259
+ }
238
260
  entry.reject(new Error(data.error));
239
261
  }
240
262
  }
@@ -315,13 +337,20 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
315
337
  this.pending.set(messageId, entry);
316
338
  const slotIndex = this.slots.indexOf(slot);
317
339
  taskLog.debug(`→ ${entry.contract.taskId}`, { worker: slotIndex, args: entry.args });
318
- emitDevtools({ type: 'task:dispatch', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, slot: slotIndex, waitMs });
319
- slot.worker.postMessage({
320
- type: 'EXECUTE_TASK',
321
- messageId,
322
- taskId: entry.contract.taskId,
323
- args: entry.args,
324
- });
340
+ if (devtoolsEnabled()) {
341
+ emitDevtools({ type: 'task:dispatch', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, slot: slotIndex, waitMs, argBytes: estimateCloneBytes(entry.args) });
342
+ }
343
+ const msg = { type: 'EXECUTE_TASK', messageId, taskId: entry.contract.taskId, args: entry.args };
344
+ if (this.chaos) {
345
+ applyChaos(this.chaos, {
346
+ live: () => this.pending.get(messageId) === entry,
347
+ fail: () => this.handleReply(slot, { messageId, taskId: msg.taskId, success: false, error: CHAOS_FAILURE }),
348
+ timeout: () => this.cancel(entry, new TaskTimeoutError()),
349
+ post: () => slot.worker.postMessage(msg),
350
+ });
351
+ } else {
352
+ slot.worker.postMessage(msg);
353
+ }
325
354
  }
326
355
 
327
356
  /** Detaches signal/timeout listeners from a finalized entry. */
@@ -422,7 +451,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
422
451
  * (e.g. a second pool of HTTP workers via withSharedBuffer).
423
452
  */
424
453
  public get sharedBuffer(): SharedArrayBuffer | undefined {
425
- return this.memoryManager?.getBuffer();
454
+ return this.memoryManager?.getBuffer() ?? this.injectedBuffer;
426
455
  }
427
456
 
428
457
  /**
@@ -466,6 +495,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
466
495
 
467
496
  public terminate(): void {
468
497
  this.closed = true;
498
+ this.devtoolsOff?.();
469
499
  // Final flush + release — fire-and-forget; terminate() stays synchronous.
470
500
  void this.persistence?.stop()?.catch?.((e: unknown) =>
471
501
  poolLog.warn('memory persistence stop failed', e),
package/src/reactive.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createEffect, createMemo, createRenderEffect, createRoot, createSignal } from 'solid-js';
2
2
  import { Connector } from './contract/sharedMemory';
3
3
  import { scoped } from './log';
4
+ import { disposeNodes, graphNode, graphSource } from './reactiveGraph';
4
5
 
5
6
  const reactiveLog = scoped('reactive');
6
7
 
@@ -45,11 +46,39 @@ export interface ReactiveConnector<T> {
45
46
  * write — local or remote, both bump it. waitAsync when available, 50ms poll
46
47
  * otherwise. No solid-js involved: this is the whole remote-write mechanism.
47
48
  */
48
- const watchVersion = <T>(connector: Connector<T>, onBump: () => void): (() => void) => {
49
+ interface VersionWatch {
50
+ stop: () => void;
51
+ /** Devtools graph id of the bridge node (null while devtools is off). */
52
+ node: string | null;
53
+ }
54
+
55
+ const watchVersionNode = <T>(connector: Connector<T>, onBump: () => void): VersionWatch => {
49
56
  const version = connector._version;
50
57
  if (!version) {
51
58
  throw new Error('This connector does not support remote observation.');
52
59
  }
60
+ const waits = typeof Atomics.waitAsync === 'function';
61
+ const node = graphNode('bridge', `${waits ? 'waitAsync' : 'poll'} ${connector._path ?? `@${connector.byteOffset}`}`, [
62
+ graphSource(connector._path),
63
+ ]);
64
+ const stop = watchVersionRaw(connector, version, onBump);
65
+ return {
66
+ node,
67
+ stop: () => {
68
+ stop();
69
+ disposeNodes(node);
70
+ },
71
+ };
72
+ };
73
+
74
+ const watchVersion = <T>(connector: Connector<T>, onBump: () => void): (() => void) =>
75
+ watchVersionNode(connector, onBump).stop;
76
+
77
+ const watchVersionRaw = <T>(
78
+ connector: Connector<T>,
79
+ version: { view: Int32Array; index: number },
80
+ onBump: () => void,
81
+ ): (() => void) => {
53
82
  let stopped = false;
54
83
  let last = Atomics.load(version.view, version.index);
55
84
 
@@ -167,6 +196,39 @@ export function watch<T, S = T>(
167
196
  ): () => void {
168
197
  const select = onChange ? (selOrCb as (value: T) => S) : ((v: T) => v as unknown as S);
169
198
  const cb = (onChange ?? selOrCb) as (slice: S, previous: S | undefined) => void;
199
+ return watchNodes(connector, select, cb, options, { sliced: onChange !== undefined, label: 'watch' }).stop;
200
+ }
201
+
202
+ /** Graph labelling for {@link watchNodes} — `observe()` names its effect. */
203
+ export interface WatchMeta {
204
+ /** A real selector was given (draws a derived node), not the identity. */
205
+ sliced: boolean;
206
+ /** Effect node label prefix: 'watch', 'observe'. */
207
+ label: string;
208
+ }
209
+
210
+ /**
211
+ * `watch()` plus its devtools graph wiring: bridge → [derived slice] →
212
+ * effect. Internal: `observe()` uses it to own the effect node's label.
213
+ */
214
+ export function watchNodes<T, S>(
215
+ connector: Connector<T>,
216
+ select: (value: T) => S,
217
+ cb: (slice: S, previous: S | undefined) => void,
218
+ options: SliceOptions<S> | undefined,
219
+ meta: WatchMeta,
220
+ ): { stop: () => void; effect: string | null } {
221
+ const path = connector._path ?? `@${connector.byteOffset}`;
222
+ // Nodes are created around the version watch below; ids are null while
223
+ // devtools is off, and every graph call no-ops on null.
224
+ let bridge: string | null = null;
225
+ let derived: string | null = null;
226
+ let effect: string | null = null;
227
+ const graph = (): void => {
228
+ derived = meta.sliced ? graphNode('derived', `select(${path})`, [bridge]) : null;
229
+ effect = graphNode('effect', `${meta.label}(${path})`, [derived ?? bridge]);
230
+ };
231
+ const ungraph = (): void => disposeNodes(effect, derived);
170
232
 
171
233
  // SSR solid build (node/worker conditions): effects never re-run, so the
172
234
  // signal pipeline below would silently never emit. Same semantics, driven
@@ -184,20 +246,29 @@ export function watch<T, S = T>(
184
246
  cb(s, old);
185
247
  };
186
248
  emit();
187
- const stop = watchVersion(connector, emit);
249
+ const remote = watchVersionNode(connector, emit);
250
+ bridge = remote.node;
251
+ graph();
188
252
  reactiveLog.debug(`watching connector at offset ${connector.byteOffset} (direct)`);
189
- return () => {
190
- reactiveLog.debug(`watch stopped (offset ${connector.byteOffset})`);
191
- stop();
253
+ return {
254
+ effect,
255
+ stop: () => {
256
+ reactiveLog.debug(`watch stopped (offset ${connector.byteOffset})`);
257
+ remote.stop();
258
+ ungraph();
259
+ },
192
260
  };
193
261
  }
194
262
 
195
- const sig = reactive(connector);
196
- const stopRemote = sig.observeRemote();
263
+ // reactive(connector) + observeRemote(), inlined to keep the bridge id.
264
+ const [value, setValue] = createSignal<T>(connector.read(), { equals: false });
265
+ const remote = watchVersionNode(connector, () => setValue(() => connector.read()));
266
+ bridge = remote.node;
267
+ graph();
197
268
  const dispose = createRoot((d) => {
198
269
  const slice = createMemo(
199
270
  () => {
200
- const v = sig.get() as T | null | undefined;
271
+ const v = value() as T | null | undefined;
201
272
  return v == null ? undefined : select(v);
202
273
  },
203
274
  undefined,
@@ -218,9 +289,13 @@ export function watch<T, S = T>(
218
289
  return d;
219
290
  });
220
291
  reactiveLog.debug(`watching connector at offset ${connector.byteOffset}`);
221
- return () => {
222
- reactiveLog.debug(`watch stopped (offset ${connector.byteOffset})`);
223
- stopRemote();
224
- dispose();
292
+ return {
293
+ effect,
294
+ stop: () => {
295
+ reactiveLog.debug(`watch stopped (offset ${connector.byteOffset})`);
296
+ remote.stop();
297
+ dispose();
298
+ ungraph();
299
+ },
225
300
  };
226
301
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `reactive:node` emission for the dashboard's reactivity graph. The SDK's
3
+ * reactivity is shallow, and the node kinds map onto it directly:
4
+ *
5
+ * - **source**: a shared-memory field, id `mem:<path>`, owner 'shared'.
6
+ * The same id on every thread, so a worker-side writer and a main-thread
7
+ * watcher meet at one node. Two contracts with the same field path
8
+ * share the node (ids carry no contract identity across threads).
9
+ * - **bridge**: a version-counter watcher (`Atomics.waitAsync`, or the
10
+ * 50ms poll fallback): the only thing that crosses threads.
11
+ * - **derived**: a `watch`/`observe` selector slice.
12
+ * - **effect**: a `watch` callback, or an `observe` observable's
13
+ * subscriber fan-out (label carries the subscriber count).
14
+ *
15
+ * Non-source ids are per-thread counters ('b3', 'e7'); the dashboard
16
+ * qualifies them by the event's thread/worker stamp. Everything here is
17
+ * a no-op returning null while devtools is off.
18
+ */
19
+ import { devtoolsEnabled, emitDevtools, registerDevtoolsCommand } from './devtools';
20
+
21
+ export type ReactiveKind = 'source' | 'derived' | 'effect' | 'bridge';
22
+
23
+ interface NodeInfo {
24
+ id: string;
25
+ kind: ReactiveKind;
26
+ label?: string;
27
+ deps?: string[];
28
+ owner?: string;
29
+ }
30
+
31
+ const OWNER =
32
+ typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope ? 'worker' : 'main';
33
+
34
+ let seq = 0;
35
+ const live = new Map<string, NodeInfo>();
36
+ let commandRegistered = false;
37
+
38
+ const emit = (node: NodeInfo, disposed?: boolean): void => {
39
+ emitDevtools({ type: 'reactive:node', ...node, ...(disposed ? { disposed: true } : {}) });
40
+ };
41
+
42
+ const ensureCommand = (): void => {
43
+ if (commandRegistered) return;
44
+ commandRegistered = true;
45
+ // Snapshot for dashboards that joined after the creation events scrolled
46
+ // out of the replay tail. This thread's nodes only (commands run on the
47
+ // main thread; worker nodes arrive through the event stream).
48
+ registerDevtoolsCommand('reactive.nodes', () => [...live.values()]);
49
+ };
50
+
51
+ /** The source node for a shared-memory field — emitted once per thread. */
52
+ export const graphSource = (path: string | undefined): string | null => {
53
+ if (!devtoolsEnabled() || path === undefined) return null;
54
+ ensureCommand();
55
+ const id = `mem:${path}`;
56
+ if (!live.has(id)) {
57
+ const node: NodeInfo = { id, kind: 'source', label: path, owner: 'shared' };
58
+ live.set(id, node);
59
+ emit(node);
60
+ }
61
+ return id;
62
+ };
63
+
64
+ /** Create a node; returns its id, or null while devtools is off. */
65
+ export const graphNode = (kind: ReactiveKind, label: string, deps: (string | null)[]): string | null => {
66
+ if (!devtoolsEnabled()) return null;
67
+ ensureCommand();
68
+ const id = `${kind[0]}${++seq}`;
69
+ const node: NodeInfo = { id, kind, label, deps: deps.filter((d): d is string => d !== null), owner: OWNER };
70
+ live.set(id, node);
71
+ emit(node);
72
+ return id;
73
+ };
74
+
75
+ /** Re-emit a node with a new label (same id — the dashboard replaces it). */
76
+ export const relabelNode = (id: string | null, label: string): void => {
77
+ const node = id === null ? undefined : live.get(id);
78
+ if (!node || node.label === label || !devtoolsEnabled()) return;
79
+ node.label = label;
80
+ emit(node);
81
+ };
82
+
83
+ /** Mark nodes disposed (null ids are skipped). */
84
+ export const disposeNodes = (...ids: (string | null)[]): void => {
85
+ for (const id of ids) {
86
+ const node = id === null ? undefined : live.get(id);
87
+ if (!node) continue;
88
+ live.delete(id!);
89
+ if (devtoolsEnabled()) emit(node, true);
90
+ }
91
+ };
@@ -72,9 +72,10 @@ export class InProcessWorker {
72
72
  return;
73
73
  }
74
74
  if (data.type === 'INIT_MEMORY') {
75
- this.ready ??= this.boot().then(() =>
76
- bindSharedMemories((data.memory as WebAssembly.Memory).buffer as unknown as SharedArrayBuffer),
77
- );
75
+ const buffer = (data.memory as WebAssembly.Memory | undefined)?.buffer ?? data.buffer;
76
+ this.ready ??= this.boot().then(() => {
77
+ if (buffer) bindSharedMemories(buffer as SharedArrayBuffer);
78
+ });
78
79
  try {
79
80
  await this.ready;
80
81
  } catch (err) {