@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
package/src/pool/workerClient.ts
CHANGED
|
@@ -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
|
-
/**
|
|
120
|
+
/** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
|
|
105
121
|
concurrency?: number;
|
|
106
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
145
|
+
/** Spawn the worker(s) now (idempotent). */
|
|
119
146
|
start(): void;
|
|
120
|
-
/** Terminate the
|
|
147
|
+
/** Terminate the worker(s); the next method call re-spawns. */
|
|
121
148
|
terminate(): void;
|
|
122
|
-
|
|
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
|
-
*
|
|
167
|
+
* workers: 3, // omit for one dedicated worker
|
|
136
168
|
* });
|
|
137
169
|
*
|
|
138
|
-
* await incidents.queryIncidents(q); // typed dispatch
|
|
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:
|
|
149
|
-
const spawn = ():
|
|
150
|
-
(pool
|
|
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
|
|
package/src/pool/workerPool.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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'
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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, ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
const
|
|
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 =
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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) {
|