@atolljs/core 0.1.7 → 0.1.9
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/README.md +1 -1
- package/dist/contract/memoryDevtools.d.ts +30 -0
- package/dist/contract/sharedMemory.d.ts +4 -2
- package/dist/contract/types.d.ts +11 -0
- package/dist/devtools.d.ts +127 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +19 -17
- package/dist/index10.js +29 -14
- package/dist/index11.js +14 -307
- package/dist/index12.js +29 -50
- package/dist/index13.js +46 -60
- package/dist/index14.js +56 -22
- package/dist/index15.js +332 -55
- package/dist/index16.js +246 -45
- package/dist/index17.js +56 -51
- package/dist/index18.js +9 -39
- package/dist/index19.js +56 -80
- package/dist/index20.js +24 -53
- package/dist/index21.js +56 -52
- package/dist/index22.js +48 -0
- package/dist/index23.js +68 -0
- package/dist/index24.js +41 -0
- package/dist/index25.js +46 -0
- package/dist/index26.js +113 -0
- package/dist/index27.js +60 -0
- package/dist/index28.js +57 -0
- package/dist/index5.js +135 -64
- package/dist/index6.js +160 -35
- package/dist/index7.js +45 -503
- package/dist/index8.js +513 -19
- package/dist/index9.js +19 -33
- package/dist/pool/dedicatedWorker.d.ts +63 -0
- package/dist/pool/devtoolsCommands.d.ts +54 -0
- package/dist/pool/subWorkerClient.d.ts +50 -0
- package/dist/pool/workerChannel.d.ts +44 -0
- package/dist/pool/workerClient.d.ts +36 -8
- package/dist/pool/workerPool.d.ts +10 -1
- package/dist/reactive.d.ts +15 -0
- package/dist/reactiveGraph.d.ts +9 -0
- package/dist/userTiming.d.ts +27 -0
- package/package.json +4 -3
- package/src/contract/memoryDevtools.ts +249 -0
- package/src/contract/sharedMemory.ts +20 -6
- package/src/contract/types.ts +11 -0
- package/src/devtools.ts +251 -6
- package/src/index.ts +13 -1
- package/src/log.ts +22 -0
- package/src/observable.ts +16 -3
- package/src/pool/dedicatedWorker.ts +332 -0
- package/src/pool/devtoolsCommands.ts +165 -0
- package/src/pool/subWorkerClient.ts +70 -0
- package/src/pool/workerChannel.ts +99 -0
- package/src/pool/workerClient.ts +61 -15
- package/src/pool/workerPool.ts +75 -45
- package/src/reactive.ts +87 -12
- package/src/reactiveGraph.ts +91 -0
- package/src/testing/inProcessWorker.ts +4 -3
- package/src/userTiming.ts +71 -0
- package/src/worker/workerBootstrap.ts +33 -5
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PoolStats } from './workerPool.js';
|
|
2
|
+
/** Fault injection applied at dispatch. All rates are probabilities in 0..1. */
|
|
3
|
+
export interface ChaosConfig {
|
|
4
|
+
/** Delay before the call is posted to the worker. */
|
|
5
|
+
delayMs?: number;
|
|
6
|
+
/** Share of calls rejected with `CHAOS_FAILURE` (settle outcome 'error'). */
|
|
7
|
+
failRate?: number;
|
|
8
|
+
/** Share of calls failed through the timeout path (settle outcome 'timeout'). */
|
|
9
|
+
timeoutRate?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Error message of a chaos-injected failure. */
|
|
12
|
+
export declare const CHAOS_FAILURE = "chaos: injected failure";
|
|
13
|
+
/** Crash message of a dashboard `worker.kill`. */
|
|
14
|
+
export declare const KILL_MESSAGE = "killed from devtools";
|
|
15
|
+
/** What a runner exposes to the dashboard commands. */
|
|
16
|
+
export interface DevtoolsRunnerHandle {
|
|
17
|
+
poolId: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
dedicated: boolean;
|
|
20
|
+
/** Live worker count. */
|
|
21
|
+
size(): number;
|
|
22
|
+
stats(): PoolStats;
|
|
23
|
+
/** Run the runner's real crash handler for that slot (throws on a bad slot). */
|
|
24
|
+
kill(slot: number): void;
|
|
25
|
+
getChaos(): ChaosConfig | null;
|
|
26
|
+
setChaos(chaos: ChaosConfig | null): void;
|
|
27
|
+
}
|
|
28
|
+
/** Validate `pool.chaos` args into a config; null when nothing is active (clears). */
|
|
29
|
+
export declare const parseChaos: (args: Record<string, unknown>) => ChaosConfig | null;
|
|
30
|
+
/**
|
|
31
|
+
* Register a live runner; returns its unregister. The pool commands are
|
|
32
|
+
* installed with the first runner and removed with the last, so the
|
|
33
|
+
* dashboard's `devtools.commands` only advertises them while there is
|
|
34
|
+
* something to control.
|
|
35
|
+
*/
|
|
36
|
+
export declare const registerDevtoolsRunner: (handle: DevtoolsRunnerHandle) => (() => void);
|
|
37
|
+
/** Hooks a runner supplies to `applyChaos` for one dispatched call. */
|
|
38
|
+
export interface ChaosCall {
|
|
39
|
+
/** Still awaiting its reply (not crashed/terminated away during the delay). */
|
|
40
|
+
live(): boolean;
|
|
41
|
+
/** Settle as a worker-reported failure (the runner's reply path). */
|
|
42
|
+
fail(): void;
|
|
43
|
+
/** Settle through the runner's timeout path. */
|
|
44
|
+
timeout(): void;
|
|
45
|
+
/** Post the call to the worker. */
|
|
46
|
+
post(): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Apply an active chaos config to one dispatched call. An injected failure
|
|
50
|
+
* never reaches the worker; an injected timeout settles the caller through
|
|
51
|
+
* the timeout path and still posts the call, exactly like a real timeout
|
|
52
|
+
* (the worker runs it; its late reply frees the slot and is discarded).
|
|
53
|
+
*/
|
|
54
|
+
export declare const applyChaos: (chaos: ChaosConfig, call: ChaosCall) => void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SharedSpec } from '../contract/sharedMemory.js';
|
|
2
|
+
import type { WorkerDefinition } from '../worker/defineWorker.js';
|
|
3
|
+
import { type ConnectWorkerConfig, type WorkerClient } from './workerClient.js';
|
|
4
|
+
/**
|
|
5
|
+
* Worker-shell side of a nested pool: identical options to
|
|
6
|
+
* {@link ConnectWorkerConfig}, since the pool protocol (INIT / INIT_MEMORY /
|
|
7
|
+
* EXECUTE_TASK) doesn't care which side of the thread boundary spawned the
|
|
8
|
+
* workers. The distinguishing knob is `sharedBuffer` — inside a worker
|
|
9
|
+
* shell, a bound contract's `.buffer` (or the shell pool's `sharedBuffer`)
|
|
10
|
+
* lets sub-workers attach to the SAME shared memory instead of a fresh one.
|
|
11
|
+
*/
|
|
12
|
+
export interface ConnectSubWorkerConfig<S extends SharedSpec> extends ConnectWorkerConfig<S> {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Spawn a worker pool from INSIDE a worker — the worker-shell counterpart
|
|
16
|
+
* of {@link connectWorker}. The sub-worker entry is a normal `defineWorker`
|
|
17
|
+
* module; only the spawn site differs:
|
|
18
|
+
*
|
|
19
|
+
* // shell.worker.ts — runs inside a dedicated worker
|
|
20
|
+
* const sub = connectSubWorker<SubWorker>({
|
|
21
|
+
* worker: () => new Worker(new URL('./sub.worker.ts', import.meta.url), { type: 'module' }),
|
|
22
|
+
* sharedMemory: memory, // fresh buffer per sub-pool
|
|
23
|
+
* // — or attach sub-workers to THIS worker's bound buffer:
|
|
24
|
+
* // sharedBuffer: memory.buffer,
|
|
25
|
+
* workers: 2,
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* methods: { fanOut: (chunks) => Promise.all(chunks.map((c) => sub.crunch(c))) }
|
|
29
|
+
*
|
|
30
|
+
* Memory modes (both supported, pick per config):
|
|
31
|
+
* - `sharedMemory` without `sharedBuffer`: the sub-pool allocates its own
|
|
32
|
+
* WebAssembly.Memory inside the worker and binds the contract to it —
|
|
33
|
+
* a private buffer for this tier.
|
|
34
|
+
* - `sharedBuffer: someContract.buffer`: no allocation — every sub-worker
|
|
35
|
+
* binds the shell's existing buffer, so all three tiers (main, shell,
|
|
36
|
+
* sub) read and write the same fields. Hub-and-spoke sharing.
|
|
37
|
+
*
|
|
38
|
+
* Nested workers need `new Worker` inside a worker context: Chrome and
|
|
39
|
+
* Firefox support it, Safari does not — feature-detect and fall back to
|
|
40
|
+
* dispatching on the main-thread pool. Node's `worker_threads` nests
|
|
41
|
+
* natively; use `worker: () => createNodeWorker(new Worker('./sub.js'))`
|
|
42
|
+
* (a sub-worker entry still needs `@atolljs/node/shim` first).
|
|
43
|
+
*
|
|
44
|
+
* Bundlers detect `new Worker(new URL(...))` inside a worker bundle too:
|
|
45
|
+
* the @atolljs/vite dev plugin rewrites nested entry URLs back onto its
|
|
46
|
+
* `?worker_file` bundling path, and `vite build` emits nested worker
|
|
47
|
+
* chunks. The same rules as the main thread apply — the URL must stay an
|
|
48
|
+
* inline literal, never hoisted or computed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function connectSubWorker<W extends WorkerDefinition, S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec>(config: ConnectSubWorkerConfig<S>): WorkerClient<W, S>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-worker plumbing both runners share: `WorkerPool` (N workers,
|
|
3
|
+
* queue + least-busy scheduling) and `DedicatedWorker` (one worker, no
|
|
4
|
+
* pool). Memory setup, the INIT handshake, and devtools forwarding are the
|
|
5
|
+
* same protocol whichever runner owns the worker.
|
|
6
|
+
*/
|
|
7
|
+
import { MemoryManager } from './memory.js';
|
|
8
|
+
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory.js';
|
|
9
|
+
import type { MemoryPersistence, TaskMap, WorkerPoolConfig } from '../contract/types.js';
|
|
10
|
+
import { type EmittedDevtoolsEvent } from '../devtools.js';
|
|
11
|
+
export interface ChannelMemory<S extends SharedSpec> {
|
|
12
|
+
sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
|
|
13
|
+
memoryManager?: MemoryManager;
|
|
14
|
+
/** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-runner sharing). */
|
|
15
|
+
injectedBuffer?: SharedArrayBuffer;
|
|
16
|
+
persistence: MemoryPersistence | undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the runner's memory once, at construction: a caller-supplied
|
|
20
|
+
* buffer binds the contract to those bytes, otherwise a fresh
|
|
21
|
+
* WebAssembly.Memory is allocated. Message-only runners get neither.
|
|
22
|
+
*/
|
|
23
|
+
export declare function setupChannelMemory<S extends SharedSpec>(config: WorkerPoolConfig<S, TaskMap>, owner: string): ChannelMemory<S>;
|
|
24
|
+
/**
|
|
25
|
+
* The INIT handshake: INIT_MEMORY with the runner's memory (or the injected
|
|
26
|
+
* buffer), bare INIT for message-only runners. `devtools` tells the worker
|
|
27
|
+
* to forward its instrumentation back only when a sink is live at spawn.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sendInit<S extends SharedSpec>(worker: Worker, mem: ChannelMemory<S>): 'INIT' | 'INIT_MEMORY';
|
|
30
|
+
/**
|
|
31
|
+
* Re-emit a worker-forwarded devtools event into this thread's sink.
|
|
32
|
+
*
|
|
33
|
+
* A runner constructed inside a worker mints ids ('pool-1', 'worker-1') on
|
|
34
|
+
* that context's own counters, which collide textually with ids minted on
|
|
35
|
+
* the parent thread. Every id arriving from a worker refers to the worker's
|
|
36
|
+
* local id space, so it is qualified unconditionally as
|
|
37
|
+
* `${runnerId}#${slot}~${id}`: the id then encodes the spawn path and
|
|
38
|
+
* dashboards can draw parent→child edges. Existing `worker` stamps
|
|
39
|
+
* (multi-hop forwarding) are qualified the same way so attribution stays
|
|
40
|
+
* pinned to the deepest emitting worker.
|
|
41
|
+
*/
|
|
42
|
+
export declare function forwardWorkerDevtools(runnerId: string, slot: number, ev: EmittedDevtoolsEvent & {
|
|
43
|
+
poolId?: string;
|
|
44
|
+
}): void;
|
|
@@ -2,6 +2,7 @@ import type { ServiceMethod, TaskRunner } from '../service.js';
|
|
|
2
2
|
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory.js';
|
|
3
3
|
import type { MemoryConfig, Prettify } from '../contract/types.js';
|
|
4
4
|
import { WorkerPool, type RunOptions } from './workerPool.js';
|
|
5
|
+
import { DedicatedWorker } from './dedicatedWorker.js';
|
|
5
6
|
import type { WorkerDefinition, WorkerMethodMap } from '../worker/defineWorker.js';
|
|
6
7
|
/** Maps a worker method's declared type to its async client signature. */
|
|
7
8
|
type Sig<F> = F extends ServiceMethod<infer A extends any[], infer R> ? (...args: A) => Promise<R> : F extends {
|
|
@@ -31,18 +32,33 @@ export declare function workerClient<W extends WorkerDefinition>(runner: TaskRun
|
|
|
31
32
|
export interface ConnectWorkerConfig<S extends SharedSpec> {
|
|
32
33
|
/** Omit for a message-only pool — the SharedArrayBuffer check is skipped. */
|
|
33
34
|
sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
|
|
35
|
+
/**
|
|
36
|
+
* Ship an existing SharedArrayBuffer to every worker instead of
|
|
37
|
+
* allocating one — e.g. `sharedBuffer: anotherPool.sharedBuffer`, or a
|
|
38
|
+
* bound contract's `contract.buffer` inside a worker shell. A thunk is
|
|
39
|
+
* resolved once at pool construction.
|
|
40
|
+
*/
|
|
41
|
+
sharedBuffer?: SharedArrayBuffer | (() => SharedArrayBuffer | undefined);
|
|
34
42
|
/**
|
|
35
43
|
* Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`,
|
|
36
44
|
* or a URL.
|
|
37
45
|
*/
|
|
38
46
|
worker: (() => Worker) | URL;
|
|
47
|
+
/**
|
|
48
|
+
* How many workers this client needs. `1` (the default) is a single
|
|
49
|
+
* dedicated worker with no pool: calls post straight to it, no queue or
|
|
50
|
+
* scheduler. `N > 1` builds a pool that spreads separate calls across N
|
|
51
|
+
* workers; `'auto'` sizes it to `navigator.hardwareConcurrency ?? 4`.
|
|
52
|
+
*/
|
|
53
|
+
workers?: number | 'auto';
|
|
54
|
+
/** @deprecated Use `workers`. Read only when `workers` is absent. */
|
|
39
55
|
poolSize?: number | 'auto';
|
|
40
56
|
/** Display label for devtools/dashboards — falls back to a generated id. */
|
|
41
57
|
name?: string;
|
|
42
58
|
memory?: MemoryConfig;
|
|
43
|
-
/**
|
|
59
|
+
/** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
|
|
44
60
|
concurrency?: number;
|
|
45
|
-
/**
|
|
61
|
+
/** Pool only: max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
|
|
46
62
|
maxQueue?: number;
|
|
47
63
|
/** Default true — a crashed worker is replaced and its in-flight calls reject. */
|
|
48
64
|
respawn?: boolean;
|
|
@@ -51,13 +67,22 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
|
|
|
51
67
|
/** Default true: pool spawns on first method call (or start()). False spawns immediately. */
|
|
52
68
|
lazy?: boolean;
|
|
53
69
|
}
|
|
54
|
-
/**
|
|
70
|
+
/** What a client runs on: a pool (`workers > 1`) or one dedicated worker. */
|
|
71
|
+
export type WorkerRunner<S extends SharedSpec = SharedSpec> = WorkerPool<S> | DedicatedWorker<S>;
|
|
72
|
+
/** Resolve a `workers` count: `'auto'` is one per core (4 when unknown). */
|
|
73
|
+
export declare const resolveWorkerCount: (n: number | 'auto' | undefined) => number;
|
|
74
|
+
/** A typed worker client plus lifecycle controls. */
|
|
55
75
|
export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = WorkerMethods<W> & {
|
|
56
|
-
/** Spawn the
|
|
76
|
+
/** Spawn the worker(s) now (idempotent). */
|
|
57
77
|
start(): void;
|
|
58
|
-
/** Terminate the
|
|
78
|
+
/** Terminate the worker(s); the next method call re-spawns. */
|
|
59
79
|
terminate(): void;
|
|
60
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The runner once spawned: a `DedicatedWorker` for `workers: 1`, a
|
|
82
|
+
* `WorkerPool` otherwise. Both expose `stats()`, `close()`, `terminate()`,
|
|
83
|
+
* `workers`, `sharedBuffer` and `poolId`.
|
|
84
|
+
*/
|
|
85
|
+
readonly pool: WorkerRunner<S> | null;
|
|
61
86
|
readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
|
|
62
87
|
};
|
|
63
88
|
/**
|
|
@@ -69,12 +94,15 @@ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = Wor
|
|
|
69
94
|
* export const incidents = connectWorker<IncidentsWorker>({
|
|
70
95
|
* sharedMemory: incidentsMemory,
|
|
71
96
|
* worker: () => new Worker(new URL('./worker/incidents.worker.ts', import.meta.url), { type: 'module' }),
|
|
72
|
-
*
|
|
97
|
+
* workers: 3, // omit for one dedicated worker
|
|
73
98
|
* });
|
|
74
99
|
*
|
|
75
|
-
* await incidents.queryIncidents(q); // typed dispatch
|
|
100
|
+
* await incidents.queryIncidents(q); // typed dispatch
|
|
76
101
|
* incidents.terminate(); // next call lazily re-spawns
|
|
77
102
|
*
|
|
103
|
+
* `workers` states what the work needs and the client picks the runner:
|
|
104
|
+
* 1 (default) → a `DedicatedWorker`, no pool; more → a `WorkerPool`.
|
|
105
|
+
*
|
|
78
106
|
* Lazy by default: no Worker is touched until the first method call (or
|
|
79
107
|
* `start()`), so importing the client is SSR-safe.
|
|
80
108
|
*/
|
|
@@ -42,7 +42,10 @@ declare class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
42
42
|
private pending;
|
|
43
43
|
private nextMessageId;
|
|
44
44
|
private queue;
|
|
45
|
+
private readonly mem;
|
|
45
46
|
private memoryManager?;
|
|
47
|
+
/** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-pool sharing). */
|
|
48
|
+
private injectedBuffer?;
|
|
46
49
|
private workerUrl?;
|
|
47
50
|
private spawn;
|
|
48
51
|
private readonly concurrency;
|
|
@@ -50,9 +53,15 @@ declare class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
50
53
|
private readonly respawn;
|
|
51
54
|
private readonly taskTimeout?;
|
|
52
55
|
private closed;
|
|
56
|
+
/** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
|
|
57
|
+
private chaos;
|
|
58
|
+
private devtoolsOff?;
|
|
53
59
|
private drainWaiters;
|
|
54
60
|
private statsAgg;
|
|
55
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Devtools correlation id, stamped on every event this pool emits:
|
|
63
|
+
* '<name>-p' when the config has a `name`, else 'pool-N'.
|
|
64
|
+
*/
|
|
56
65
|
readonly poolId: string;
|
|
57
66
|
private readonly label?;
|
|
58
67
|
private nextCallId;
|
package/dist/reactive.d.ts
CHANGED
|
@@ -44,3 +44,18 @@ export declare function reactive<T>(connector: Connector<T>): ReactiveConnector<
|
|
|
44
44
|
*/
|
|
45
45
|
export declare function watch<T>(connector: Connector<T>, onChange: (value: T) => void): () => void;
|
|
46
46
|
export declare function watch<T, S>(connector: Connector<T>, select: (value: T) => S, onChange: (slice: S, previous: S | undefined) => void, options?: SliceOptions<S>): () => void;
|
|
47
|
+
/** Graph labelling for {@link watchNodes} — `observe()` names its effect. */
|
|
48
|
+
export interface WatchMeta {
|
|
49
|
+
/** A real selector was given (draws a derived node), not the identity. */
|
|
50
|
+
sliced: boolean;
|
|
51
|
+
/** Effect node label prefix: 'watch', 'observe'. */
|
|
52
|
+
label: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `watch()` plus its devtools graph wiring: bridge → [derived slice] →
|
|
56
|
+
* effect. Internal: `observe()` uses it to own the effect node's label.
|
|
57
|
+
*/
|
|
58
|
+
export declare function watchNodes<T, S>(connector: Connector<T>, select: (value: T) => S, cb: (slice: S, previous: S | undefined) => void, options: SliceOptions<S> | undefined, meta: WatchMeta): {
|
|
59
|
+
stop: () => void;
|
|
60
|
+
effect: string | null;
|
|
61
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ReactiveKind = 'source' | 'derived' | 'effect' | 'bridge';
|
|
2
|
+
/** The source node for a shared-memory field — emitted once per thread. */
|
|
3
|
+
export declare const graphSource: (path: string | undefined) => string | null;
|
|
4
|
+
/** Create a node; returns its id, or null while devtools is off. */
|
|
5
|
+
export declare const graphNode: (kind: ReactiveKind, label: string, deps: (string | null)[]) => string | null;
|
|
6
|
+
/** Re-emit a node with a new label (same id — the dashboard replaces it). */
|
|
7
|
+
export declare const relabelNode: (id: string | null, label: string) => void;
|
|
8
|
+
/** Mark nodes disposed (null ids are skipped). */
|
|
9
|
+
export declare const disposeNodes: (...ids: (string | null)[]) => void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Timing bridge: `performance.measure` entries for atoll work, so the
|
|
3
|
+
* browser's own Performance panel shows tasks next to the app's frames.
|
|
4
|
+
* Callers gate on `devtoolsEnabled()`; this module never checks it itself.
|
|
5
|
+
*
|
|
6
|
+
* `detail.devtools` is Chrome's extensibility payload: entries land on a
|
|
7
|
+
* custom track inside an 'atoll' track group. Other engines ignore the
|
|
8
|
+
* detail and record a plain measure.
|
|
9
|
+
*/
|
|
10
|
+
/** Chrome's extensibility palette names. */
|
|
11
|
+
export type TimingColor = 'primary' | 'primary-light' | 'secondary' | 'tertiary' | 'error' | 'warning';
|
|
12
|
+
export interface TimingTrack {
|
|
13
|
+
/** Track label inside the 'atoll' group, e.g. a pool id. */
|
|
14
|
+
track: string;
|
|
15
|
+
color?: TimingColor;
|
|
16
|
+
properties?: [string, string | number][];
|
|
17
|
+
tooltipText?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Record `[start, end]` (performance.now() ms on this thread) as `name`.
|
|
21
|
+
* The entry is cleared from the timeline buffer right away: the Performance
|
|
22
|
+
* panel captures it at measure time, and per-task measures would otherwise
|
|
23
|
+
* grow the buffer without bound (and trip Node's buffer-size warning).
|
|
24
|
+
*/
|
|
25
|
+
export declare const measureAtoll: (name: string, start: number, end: number, t: TimingTrack) => void;
|
|
26
|
+
/** Main-thread span of one call, dispatch → settle, on the runner's track. */
|
|
27
|
+
export declare const measureTaskCall: (runnerId: string, taskId: string, callId: number, slot: number, start: number, end: number, ok: boolean) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Typed shared-memory worker pools — defineWorker/connectWorker, SharedArrayBuffer contracts, cross-thread reactivity",
|
|
5
5
|
"homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
|
|
6
6
|
"type": "module",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"stats": "node scripts/bundle-stats.mjs",
|
|
39
39
|
"stats:islands": "node scripts/island-perf.mjs",
|
|
40
40
|
"build:pages": "node scripts/build-pages.mjs",
|
|
41
|
+
"build:extension": "node packages/devtools-extension/scripts/build.mjs",
|
|
41
42
|
"assemble:pages": "node scripts/assemble-pages.mjs",
|
|
42
43
|
"serve:pages": "node scripts/serve-pages.mjs",
|
|
43
44
|
"serve:static": "node scripts/serve-static.mjs"
|
|
@@ -46,8 +47,8 @@
|
|
|
46
47
|
"@angular/core": "^22.2.0",
|
|
47
48
|
"@angular/platform-browser": "^22.2.0",
|
|
48
49
|
"@angular/platform-browser-dynamic": "^22.2.0",
|
|
49
|
-
"@nestjs/common": "^
|
|
50
|
-
"@nestjs/core": "^
|
|
50
|
+
"@nestjs/common": "^12.1.2",
|
|
51
|
+
"@nestjs/core": "^12.1.2",
|
|
51
52
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
52
53
|
"@types/react": "^19.3.0",
|
|
53
54
|
"@types/react-dom": "^19.3.0",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard commands over shared memory: `memory.read` (live values) and
|
|
3
|
+
* watchpoints (`memory.watch` / `memory.unwatch` / `memory.watches`).
|
|
4
|
+
* Registered by `SharedMemory.bind()` the first time a contract binds
|
|
5
|
+
* while devtools is enabled, so apps without devtools never load them
|
|
6
|
+
* into the command table.
|
|
7
|
+
*
|
|
8
|
+
* Watchpoints fire `memory:watch-hit` from two places on the thread that
|
|
9
|
+
* set them (the main thread: commands arrive over its transport):
|
|
10
|
+
* - the connector write path, synchronously, for writes made on this
|
|
11
|
+
* thread (guarded by `memoryWatches.size`, behind the existing
|
|
12
|
+
* `devtoolsEnabled()` check: zero cost with no watches);
|
|
13
|
+
* - a version observer on each watched field, for writes from other
|
|
14
|
+
* threads (workers share the buffer, so the main thread reads their
|
|
15
|
+
* values). Remote bursts between two observations coalesce: only the
|
|
16
|
+
* latest value is checked.
|
|
17
|
+
*/
|
|
18
|
+
import { emitDevtools, previewValue, registerDevtoolsCommand } from '../devtools';
|
|
19
|
+
import type { Connector, SharedMemory } from './sharedMemory';
|
|
20
|
+
|
|
21
|
+
export type WatchRule = { text: string; test: (value: unknown) => boolean };
|
|
22
|
+
|
|
23
|
+
interface WatchState {
|
|
24
|
+
rule: WatchRule;
|
|
25
|
+
/** Per contract: last version checked and last value preview ('change'). */
|
|
26
|
+
seen: WeakMap<SharedMemory<any>, { version: number; preview: string }>;
|
|
27
|
+
stop: () => void;
|
|
28
|
+
hits: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** path → watch. Read by the write path — keep `.size` the only hot check. */
|
|
32
|
+
export const memoryWatches = new Map<string, WatchState>();
|
|
33
|
+
|
|
34
|
+
let contractsOf: () => readonly SharedMemory<any>[] = () => [];
|
|
35
|
+
let registered = false;
|
|
36
|
+
|
|
37
|
+
/* ── value previews ──────────────────────────────────────────────────────── */
|
|
38
|
+
|
|
39
|
+
const LIST_HEAD = 5;
|
|
40
|
+
const ARRAY_HEAD = 16;
|
|
41
|
+
|
|
42
|
+
/** Bounded preview of a field's current value — lists/typed arrays read only a head. */
|
|
43
|
+
export const fieldPreview = (connector: Connector<any>): string => {
|
|
44
|
+
const list = connector as Connector<any> & { readAt?: (i: number) => unknown; recordCount?: number };
|
|
45
|
+
if (typeof list.readAt === 'function' && typeof list.recordCount === 'number') {
|
|
46
|
+
const head = Array.from({ length: Math.min(LIST_HEAD, list.recordCount) }, (_, i) => list.readAt!(i));
|
|
47
|
+
return previewValue({ records: list.recordCount, head }, 1024);
|
|
48
|
+
}
|
|
49
|
+
const value = connector.read();
|
|
50
|
+
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
|
|
51
|
+
const arr = value as unknown as ArrayLike<number | bigint>;
|
|
52
|
+
const head = Array.from({ length: Math.min(ARRAY_HEAD, arr.length) }, (_, i) =>
|
|
53
|
+
typeof arr[i] === 'bigint' ? `${arr[i]}n` : arr[i],
|
|
54
|
+
);
|
|
55
|
+
return `${value.constructor.name}(${arr.length}) ${JSON.stringify(head)}${arr.length > ARRAY_HEAD ? '…' : ''}`;
|
|
56
|
+
}
|
|
57
|
+
return previewValue(value, 1024);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Path lookup without the typed flat-key overload. */
|
|
61
|
+
const conn = (memory: SharedMemory<any>, path: string): Connector<any> =>
|
|
62
|
+
(memory as unknown as { connector(p: string): Connector<any> }).connector(path);
|
|
63
|
+
|
|
64
|
+
const versionOf = (connector: Connector<any>): number =>
|
|
65
|
+
connector._version ? Atomics.load(connector._version.view, connector._version.index) : 0;
|
|
66
|
+
|
|
67
|
+
/* ── rules ───────────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
const parseLiteral = (s: string): unknown => {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(s);
|
|
72
|
+
} catch {
|
|
73
|
+
return s;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const asNumber = (v: unknown): number | null =>
|
|
78
|
+
typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : null;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a watch rule: 'change' | '> n' | '< n' | '>= n' | '<= n' | '== v' |
|
|
82
|
+
* '!= v'. `v` is JSON when it parses ('"down"', 'true', '3'), else a bare
|
|
83
|
+
* string. Numeric comparisons only match number/bigint fields.
|
|
84
|
+
*/
|
|
85
|
+
export const parseWatchRule = (raw: string): WatchRule => {
|
|
86
|
+
const text = raw.trim();
|
|
87
|
+
if (text === 'change') return { text, test: () => true };
|
|
88
|
+
const m = /^(>=|<=|==|!=|>|<)\s*(.+)$/.exec(text);
|
|
89
|
+
if (!m) throw new Error(`memory.watch: bad rule '${raw}' — use change, > n, < n, >= n, <= n, == v, != v`);
|
|
90
|
+
const [, op, operand] = m;
|
|
91
|
+
if (op === '==' || op === '!=') {
|
|
92
|
+
const lit = parseLiteral(operand);
|
|
93
|
+
const eq = (v: unknown) =>
|
|
94
|
+
v !== null && typeof v === 'object' ? previewValue(v) === previewValue(lit) : Object.is(v, lit) || (typeof v === 'bigint' && Number(v) === lit);
|
|
95
|
+
return { text, test: op === '==' ? eq : (v) => !eq(v) };
|
|
96
|
+
}
|
|
97
|
+
const n = Number(operand);
|
|
98
|
+
if (!Number.isFinite(n)) throw new Error(`memory.watch: '${operand}' is not a number`);
|
|
99
|
+
const cmp: Record<string, (a: number) => boolean> = {
|
|
100
|
+
'>': (a) => a > n, '<': (a) => a < n, '>=': (a) => a >= n, '<=': (a) => a <= n,
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
text,
|
|
104
|
+
test: (v) => {
|
|
105
|
+
const a = asNumber(v);
|
|
106
|
+
return a !== null && cmp[op](a);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/* ── matching ────────────────────────────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
const check = (memory: SharedMemory<any>, path: string, w: WatchState, connector: Connector<any>, version: number): void => {
|
|
114
|
+
const last = w.seen.get(memory);
|
|
115
|
+
if (last && last.version >= version) return;
|
|
116
|
+
const change = w.rule.text === 'change';
|
|
117
|
+
// List fields only support 'change' — read() would decode every record.
|
|
118
|
+
const isList = typeof (connector as { readAt?: unknown }).readAt === 'function';
|
|
119
|
+
let value: unknown;
|
|
120
|
+
let preview: string;
|
|
121
|
+
try {
|
|
122
|
+
preview = fieldPreview(connector);
|
|
123
|
+
value = change || isList ? undefined : connector.read();
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
w.seen.set(memory, { version, preview });
|
|
128
|
+
const hit = change ? preview !== last?.preview : !isList && w.rule.test(value);
|
|
129
|
+
if (!hit) return;
|
|
130
|
+
w.hits++;
|
|
131
|
+
emitDevtools({ type: 'memory:watch-hit', path, version, value: preview, rule: w.rule.text });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Write-path hook — only called when `memoryWatches.size > 0`. */
|
|
135
|
+
export const checkWatchOnWrite = (memory: SharedMemory<any>, path: string, version: number): void => {
|
|
136
|
+
const w = memoryWatches.get(path);
|
|
137
|
+
if (!w) return;
|
|
138
|
+
check(memory, path, w, conn(memory, path), version);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Observe the field's version counter on this thread so other threads'
|
|
143
|
+
* writes are checked too. Re-resolves the connector each wake (contracts
|
|
144
|
+
* can rebind to another buffer) and waits with a timeout so a rebind is
|
|
145
|
+
* picked up without a write. Falls back to a 50ms poll without waitAsync.
|
|
146
|
+
*/
|
|
147
|
+
const observeRemote = (path: string, w: WatchState): (() => void) => {
|
|
148
|
+
let stopped = false;
|
|
149
|
+
const sweep = (): void => {
|
|
150
|
+
for (const memory of contractsOf()) {
|
|
151
|
+
if (!memory.bound) continue;
|
|
152
|
+
let connector: Connector<any>;
|
|
153
|
+
try {
|
|
154
|
+
connector = conn(memory, path);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
check(memory, path, w, connector, versionOf(connector));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
if (typeof Atomics.waitAsync === 'function') {
|
|
162
|
+
void (async () => {
|
|
163
|
+
while (!stopped) {
|
|
164
|
+
const first = contractsOf().find((m) => {
|
|
165
|
+
try {
|
|
166
|
+
return m.bound && conn(m, path)._version !== undefined;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
const slot = first ? conn(first, path)._version : undefined;
|
|
172
|
+
if (slot) {
|
|
173
|
+
const res = Atomics.waitAsync(slot.view, slot.index, Atomics.load(slot.view, slot.index), 250);
|
|
174
|
+
if (res.async) await res.value;
|
|
175
|
+
} else {
|
|
176
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
177
|
+
}
|
|
178
|
+
if (!stopped) sweep();
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
return () => { stopped = true; };
|
|
182
|
+
}
|
|
183
|
+
const timer = setInterval(sweep, 50);
|
|
184
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
185
|
+
return () => {
|
|
186
|
+
stopped = true;
|
|
187
|
+
clearInterval(timer);
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/* ── commands ────────────────────────────────────────────────────────────── */
|
|
192
|
+
|
|
193
|
+
const watchList = () =>
|
|
194
|
+
[...memoryWatches.entries()].map(([path, w]) => ({ path, rule: w.rule.text, hits: w.hits }));
|
|
195
|
+
|
|
196
|
+
/** Register the `memory.*` commands once; `contracts` lists every defined contract. */
|
|
197
|
+
export const ensureMemoryCommands = (contracts: () => readonly SharedMemory<any>[]): void => {
|
|
198
|
+
contractsOf = contracts;
|
|
199
|
+
if (registered) return;
|
|
200
|
+
registered = true;
|
|
201
|
+
|
|
202
|
+
registerDevtoolsCommand('memory.read', () => {
|
|
203
|
+
const out: { path: string; value: string; version: number; contract: number }[] = [];
|
|
204
|
+
contractsOf().forEach((memory, contract) => {
|
|
205
|
+
if (!memory.bound) return;
|
|
206
|
+
for (const { path } of memory.fields()) {
|
|
207
|
+
try {
|
|
208
|
+
const connector = conn(memory, path);
|
|
209
|
+
out.push({ path, value: fieldPreview(connector), version: versionOf(connector), contract });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
out.push({ path, value: `[unreadable: ${(err as Error).message}]`, version: -1, contract });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return out;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
registerDevtoolsCommand('memory.watch', (args) => {
|
|
219
|
+
const path = String(args.path ?? '');
|
|
220
|
+
if (!path) throw new Error('memory.watch: `path` is required');
|
|
221
|
+
const known = contractsOf().some((m) => m.fields().some((f) => f.path === path));
|
|
222
|
+
if (!known) throw new Error(`memory.watch: no contract defines a field '${path}'`);
|
|
223
|
+
const rule = parseWatchRule(String(args.rule ?? 'change'));
|
|
224
|
+
memoryWatches.get(path)?.stop();
|
|
225
|
+
const w: WatchState = { rule, seen: new WeakMap(), stop: () => {}, hits: 0 };
|
|
226
|
+
// Baseline: the current value/version is not a hit.
|
|
227
|
+
for (const memory of contractsOf()) {
|
|
228
|
+
if (!memory.bound) continue;
|
|
229
|
+
try {
|
|
230
|
+
const c = conn(memory, path);
|
|
231
|
+
w.seen.set(memory, { version: versionOf(c), preview: fieldPreview(c) });
|
|
232
|
+
} catch {
|
|
233
|
+
/* contract without this field */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
memoryWatches.set(path, w);
|
|
237
|
+
w.stop = observeRemote(path, w);
|
|
238
|
+
return watchList();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
registerDevtoolsCommand('memory.unwatch', (args) => {
|
|
242
|
+
const path = String(args.path ?? '');
|
|
243
|
+
memoryWatches.get(path)?.stop();
|
|
244
|
+
memoryWatches.delete(path);
|
|
245
|
+
return watchList();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
registerDevtoolsCommand('memory.watches', () => watchList());
|
|
249
|
+
};
|