@atolljs/core 0.1.6 → 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.
- package/README.md +1 -1
- package/dist/contract/memoryDevtools.d.ts +30 -0
- package/dist/contract/sharedMemory.d.ts +6 -2
- package/dist/contract/types.d.ts +13 -0
- package/dist/devtools.d.ts +346 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +19 -16
- package/dist/index10.js +28 -184
- package/dist/index11.js +18 -48
- package/dist/index12.js +29 -63
- package/dist/index13.js +49 -24
- package/dist/index14.js +57 -59
- package/dist/index15.js +335 -45
- package/dist/index16.js +242 -47
- package/dist/index17.js +57 -38
- package/dist/index18.js +9 -86
- package/dist/index19.js +58 -48
- package/dist/index20.js +24 -56
- package/dist/index21.js +61 -0
- 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 +227 -35
- package/dist/index6.js +143 -471
- package/dist/index7.js +51 -20
- package/dist/index8.js +507 -27
- package/dist/index9.js +19 -18
- 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 +38 -8
- package/dist/pool/workerPool.d.ts +13 -0
- package/dist/reactive.d.ts +15 -0
- package/dist/reactiveGraph.d.ts +9 -0
- package/dist/userTiming.d.ts +27 -0
- package/package.json +2 -1
- package/src/contract/memoryDevtools.ts +249 -0
- package/src/contract/sharedMemory.ts +39 -9
- package/src/contract/types.ts +13 -0
- package/src/devtools.ts +735 -0
- package/src/index.ts +27 -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 +64 -15
- package/src/pool/workerPool.ts +115 -36
- 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 +29 -3
package/src/index.ts
CHANGED
|
@@ -62,13 +62,18 @@ export {
|
|
|
62
62
|
TaskTimeoutError,
|
|
63
63
|
WorkerCrashedError,
|
|
64
64
|
} from './pool/errors';
|
|
65
|
-
export {
|
|
65
|
+
export { DedicatedWorker } from './pool/dedicatedWorker';
|
|
66
|
+
export type { DedicatedWorkerConfig } from './pool/dedicatedWorker';
|
|
67
|
+
export { connectWorker, resolveWorkerCount, workerClient } from './pool/workerClient';
|
|
66
68
|
export type {
|
|
67
69
|
ClientMethods,
|
|
68
70
|
ConnectWorkerConfig,
|
|
69
71
|
WorkerClient,
|
|
70
72
|
WorkerMethods,
|
|
73
|
+
WorkerRunner,
|
|
71
74
|
} from './pool/workerClient';
|
|
75
|
+
export { connectSubWorker } from './pool/subWorkerClient';
|
|
76
|
+
export type { ConnectSubWorkerConfig } from './pool/subWorkerClient';
|
|
72
77
|
|
|
73
78
|
/* ── SharedWorker — one worker (and one buffer) across tabs/iframes ──────── */
|
|
74
79
|
|
|
@@ -114,3 +119,24 @@ export { defineTask, isAsyncTask, toTask } from './task';
|
|
|
114
119
|
export type { AsyncTask, TaskSnapshot } from './task';
|
|
115
120
|
export { getLogLevel, log, scoped, setLogLevel, setLogSink } from './log';
|
|
116
121
|
export type { LogEntry, LogLevel, LogSink } from './log';
|
|
122
|
+
export {
|
|
123
|
+
addDevtoolsSink,
|
|
124
|
+
devtoolsEnabled,
|
|
125
|
+
emitDevtools,
|
|
126
|
+
estimateCloneBytes,
|
|
127
|
+
installFetchProbe,
|
|
128
|
+
installMemoryProbe,
|
|
129
|
+
listDevtoolsCommands,
|
|
130
|
+
previewValue,
|
|
131
|
+
registerDevtoolsCommand,
|
|
132
|
+
runDevtoolsCommand,
|
|
133
|
+
setDevtoolsSink,
|
|
134
|
+
} from './devtools';
|
|
135
|
+
export type {
|
|
136
|
+
DevtoolsCommandHandler,
|
|
137
|
+
DevtoolsEvent,
|
|
138
|
+
DevtoolsSink,
|
|
139
|
+
DevtoolsThread,
|
|
140
|
+
EmittedDevtoolsEvent,
|
|
141
|
+
TaskSettleOutcome,
|
|
142
|
+
} from './devtools';
|
package/src/log.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* setLogLevel('debug'); // or 'trace', 'warn', 'off'
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { devtoolsEnabled, emitDevtools, previewValue } from './devtools';
|
|
11
|
+
|
|
10
12
|
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off';
|
|
11
13
|
|
|
12
14
|
const LEVELS: Record<LogLevel, number> = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, off: 5 };
|
|
@@ -46,9 +48,29 @@ export const getLogLevel = (): LogLevel => minLevel;
|
|
|
46
48
|
/** Replace the console sink (e.g. collect entries for a UI panel); null restores it. */
|
|
47
49
|
export const setLogSink = (custom: LogSink | null): void => { sink = custom ?? consoleSink; };
|
|
48
50
|
|
|
51
|
+
/** Set while a log entry is being mirrored, so a transport that logs can't recurse. */
|
|
52
|
+
let teeing = false;
|
|
53
|
+
|
|
49
54
|
export function log(level: Exclude<LogLevel, 'off'>, scope: string, message: string, data?: unknown): void {
|
|
50
55
|
if (LEVELS[level] < LEVELS[minLevel]) return;
|
|
51
56
|
sink({ level, scope, message, data, thread: THREAD, at: performance.now() });
|
|
57
|
+
// Mirror into the devtools stream alongside (never instead of) the log
|
|
58
|
+
// sink. Worker-side entries ride the same ATOLL_DEVTOOLS forwarding as
|
|
59
|
+
// every other worker event once the INIT handshake enables it.
|
|
60
|
+
if (!teeing && devtoolsEnabled()) {
|
|
61
|
+
teeing = true;
|
|
62
|
+
try {
|
|
63
|
+
emitDevtools({
|
|
64
|
+
type: 'log',
|
|
65
|
+
level,
|
|
66
|
+
scope,
|
|
67
|
+
message,
|
|
68
|
+
...(data !== undefined ? { data: previewValue(data, 1024) } : {}),
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
teeing = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
52
74
|
}
|
|
53
75
|
|
|
54
76
|
/** A logger bound to a subsystem scope: `poolLog.info('spawned')`. */
|
package/src/observable.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Connector, PathConnector, SharedMemory, SharedSpec, SpecPath } from './contract/sharedMemory';
|
|
2
|
-
import {
|
|
2
|
+
import { watchNodes, type SliceOptions } from './reactive';
|
|
3
|
+
import { relabelNode } from './reactiveGraph';
|
|
3
4
|
import { scoped } from './log';
|
|
4
5
|
|
|
5
6
|
const obsLog = scoped('observe');
|
|
@@ -57,7 +58,10 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
57
58
|
let pendingBind: (() => void) | null = null;
|
|
58
59
|
let rebindUn: (() => void) | null = null;
|
|
59
60
|
let watched: Connector<T> | null = null;
|
|
61
|
+
/** Devtools graph id of the watch's effect node — relabelled with the subscriber count. */
|
|
62
|
+
let effectNode: string | null = null;
|
|
60
63
|
const subscribers = new Set<(value: Out | undefined) => void>();
|
|
64
|
+
const relabel = () => relabelNode(effectNode, `observe(${String(key)}) · ${subscribers.size} sub${subscribers.size === 1 ? '' : 's'}`);
|
|
61
65
|
|
|
62
66
|
const activate = () => {
|
|
63
67
|
if (unwatch || pendingBind) return;
|
|
@@ -67,12 +71,14 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
67
71
|
} catch {
|
|
68
72
|
// Not bound on this thread yet — activate when it is.
|
|
69
73
|
pendingBind = memory.onBound(() => {
|
|
74
|
+
const un = pendingBind;
|
|
70
75
|
pendingBind = null;
|
|
76
|
+
un?.();
|
|
71
77
|
activate();
|
|
72
78
|
});
|
|
73
79
|
return;
|
|
74
80
|
}
|
|
75
|
-
|
|
81
|
+
const w = watchNodes(
|
|
76
82
|
connector,
|
|
77
83
|
selectFn as (value: T) => Sel,
|
|
78
84
|
(value) => {
|
|
@@ -83,8 +89,12 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
83
89
|
for (const cb of subscribers) cb(value);
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
|
-
options
|
|
92
|
+
options,
|
|
93
|
+
{ sliced: select !== undefined, label: 'observe' }
|
|
87
94
|
);
|
|
95
|
+
unwatch = w.stop;
|
|
96
|
+
effectNode = w.effect;
|
|
97
|
+
relabel();
|
|
88
98
|
watched = connector;
|
|
89
99
|
// bind() can REBIND this contract to a different buffer — in-process
|
|
90
100
|
// harnesses run every worker's bindSharedMemories in one module graph,
|
|
@@ -111,6 +121,7 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
111
121
|
const deactivate = () => {
|
|
112
122
|
unwatch?.();
|
|
113
123
|
unwatch = null;
|
|
124
|
+
effectNode = null;
|
|
114
125
|
watched = null;
|
|
115
126
|
pendingBind?.();
|
|
116
127
|
pendingBind = null;
|
|
@@ -142,9 +153,11 @@ export function observe<S extends SharedSpec, K extends SpecPath<S>, Sel>(
|
|
|
142
153
|
subscribe(onChange) {
|
|
143
154
|
subscribers.add(onChange);
|
|
144
155
|
activate();
|
|
156
|
+
relabel();
|
|
145
157
|
return () => {
|
|
146
158
|
subscribers.delete(onChange);
|
|
147
159
|
if (subscribers.size === 0) deactivate();
|
|
160
|
+
else relabel();
|
|
148
161
|
};
|
|
149
162
|
},
|
|
150
163
|
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { MemoryManager } from './memory';
|
|
2
|
+
import { TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
|
|
3
|
+
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
|
|
4
|
+
import type { MemoryPersistence, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
|
|
5
|
+
import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
|
|
6
|
+
import { fmtBytes, scoped } from '../log';
|
|
7
|
+
import { measureTaskCall } from '../userTiming';
|
|
8
|
+
import type { PoolStats, RunOptions } from './workerPool';
|
|
9
|
+
import { forwardWorkerDevtools, sendInit, setupChannelMemory, type ChannelMemory } from './workerChannel';
|
|
10
|
+
import { applyChaos, CHAOS_FAILURE, KILL_MESSAGE, registerDevtoolsRunner, type ChaosConfig } from './devtoolsCommands';
|
|
11
|
+
|
|
12
|
+
const workerLog = scoped('worker');
|
|
13
|
+
const taskLog = scoped('task');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Config for a single dedicated worker: the WorkerPool config minus the
|
|
17
|
+
* pool-only knobs (size, per-worker concurrency cap, queue bound, task
|
|
18
|
+
* methods). Every call posts straight to the worker.
|
|
19
|
+
*/
|
|
20
|
+
export type DedicatedWorkerConfig<S extends SharedSpec = SharedSpec> = Omit<
|
|
21
|
+
WorkerPoolConfig<S, TaskMap>,
|
|
22
|
+
'poolSize' | 'concurrency' | 'maxQueue' | 'tasks'
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
interface Call {
|
|
26
|
+
contract: TaskContract<any[], any>;
|
|
27
|
+
callId: number;
|
|
28
|
+
sentAt: number;
|
|
29
|
+
resolve: (v: any) => void;
|
|
30
|
+
reject: (e: unknown) => void;
|
|
31
|
+
/** Settled (or orphaned by abort/timeout): a late reply is discarded. */
|
|
32
|
+
done: boolean;
|
|
33
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
onSignalAbort?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const agg = () => ({
|
|
39
|
+
count: 0,
|
|
40
|
+
mean: 0,
|
|
41
|
+
max: 0,
|
|
42
|
+
add(v: number) {
|
|
43
|
+
this.count++;
|
|
44
|
+
this.mean += (v - this.mean) / this.count;
|
|
45
|
+
if (v > this.max) this.max = v;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One worker, no pool: what `connectWorker({ workers: 1 })` (the default)
|
|
51
|
+
* builds. Calls are posted to the worker immediately, with no queue and no
|
|
52
|
+
* scheduler; the worker's own message loop runs them in arrival order (a
|
|
53
|
+
* task that awaits lets the next message start, exactly as in a pool slot
|
|
54
|
+
* with a raised concurrency cap). Kept from the pool: per-call
|
|
55
|
+
* timeout/abort, crash respawn, `stats()`, and the same devtools events,
|
|
56
|
+
* with `pool:init` flagged `dedicated: true`.
|
|
57
|
+
*/
|
|
58
|
+
export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
|
|
59
|
+
/**
|
|
60
|
+
* Devtools correlation id: '<name>-w' when the config has a `name`, else
|
|
61
|
+
* 'worker-N'. The same role a pool's id plays.
|
|
62
|
+
*/
|
|
63
|
+
readonly poolId: string;
|
|
64
|
+
readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
|
|
65
|
+
readonly persistence: MemoryPersistence | undefined;
|
|
66
|
+
private readonly mem: ChannelMemory<S>;
|
|
67
|
+
private readonly spawn: () => Worker;
|
|
68
|
+
private readonly respawn: boolean;
|
|
69
|
+
private readonly taskTimeout?: number;
|
|
70
|
+
private readonly label?: string;
|
|
71
|
+
private worker: Worker | null = null;
|
|
72
|
+
private pending = new Map<number, Call>();
|
|
73
|
+
private nextMessageId = 0;
|
|
74
|
+
private nextCallId = 0;
|
|
75
|
+
private closed = false;
|
|
76
|
+
/** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
|
|
77
|
+
private chaos: ChaosConfig | null = null;
|
|
78
|
+
private devtoolsOff?: () => void;
|
|
79
|
+
private drainWaiters: Array<() => void> = [];
|
|
80
|
+
private statsAgg = { completed: 0, failed: 0, aborted: 0, runMs: agg() };
|
|
81
|
+
|
|
82
|
+
constructor(config: DedicatedWorkerConfig<S>) {
|
|
83
|
+
this.mem = setupChannelMemory(config, 'DedicatedWorker');
|
|
84
|
+
if (!config.workerUrl && !config.createWorker) {
|
|
85
|
+
throw new Error('DedicatedWorker requires either `workerUrl` or `createWorker` in its config.');
|
|
86
|
+
}
|
|
87
|
+
const url = config.workerUrl;
|
|
88
|
+
this.spawn = config.createWorker ?? (() => new Worker(url!, { type: 'module' }));
|
|
89
|
+
this.respawn = config.respawn ?? true;
|
|
90
|
+
this.taskTimeout = config.taskTimeout;
|
|
91
|
+
this.label = config.name;
|
|
92
|
+
this.poolId = nextDevtoolsId('worker', config.name);
|
|
93
|
+
this.sharedMemory = this.mem.sharedMemory;
|
|
94
|
+
this.persistence = this.mem.persistence;
|
|
95
|
+
|
|
96
|
+
const bytes = this.mem.sharedMemory?.totalBytes ?? this.mem.injectedBuffer?.byteLength;
|
|
97
|
+
workerLog.info(
|
|
98
|
+
bytes !== undefined ? `spawning 1 dedicated worker with ${fmtBytes(bytes)} shared buffer` : 'spawning 1 dedicated worker, message-only',
|
|
99
|
+
);
|
|
100
|
+
emitDevtools({
|
|
101
|
+
type: 'pool:init',
|
|
102
|
+
poolId: this.poolId,
|
|
103
|
+
label: this.label,
|
|
104
|
+
poolSize: 1,
|
|
105
|
+
concurrency: 0,
|
|
106
|
+
memoryBytes: bytes,
|
|
107
|
+
dedicated: true,
|
|
108
|
+
});
|
|
109
|
+
this.attach(this.spawn());
|
|
110
|
+
if (devtoolsEnabled()) {
|
|
111
|
+
this.devtoolsOff = registerDevtoolsRunner({
|
|
112
|
+
poolId: this.poolId,
|
|
113
|
+
label: this.label,
|
|
114
|
+
dedicated: true,
|
|
115
|
+
size: () => (this.worker ? 1 : 0),
|
|
116
|
+
stats: () => this.stats(),
|
|
117
|
+
kill: (slot) => {
|
|
118
|
+
if (slot !== 0 || !this.worker) throw new Error(`${this.poolId} has no live worker at slot ${slot}`);
|
|
119
|
+
this.handleError({ message: KILL_MESSAGE });
|
|
120
|
+
},
|
|
121
|
+
getChaos: () => this.chaos,
|
|
122
|
+
setChaos: (chaos) => { this.chaos = chaos; },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private attach(worker: Worker): void {
|
|
128
|
+
this.worker = worker;
|
|
129
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
130
|
+
if (this.worker !== worker) return;
|
|
131
|
+
const data = event.data as TaskResult & { messageId?: number; type?: string; event?: EmittedDevtoolsEvent };
|
|
132
|
+
if (data.type === 'ATOLL_DEVTOOLS' && data.event) {
|
|
133
|
+
forwardWorkerDevtools(this.poolId, 0, data.event);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.handleReply(data);
|
|
137
|
+
});
|
|
138
|
+
worker.addEventListener('error', (event: unknown) => {
|
|
139
|
+
if (this.worker === worker) this.handleError(event);
|
|
140
|
+
});
|
|
141
|
+
const init = sendInit(worker, this.mem);
|
|
142
|
+
workerLog.debug(`dedicated worker spawned, ${init} sent`);
|
|
143
|
+
emitDevtools({ type: 'worker:spawn', poolId: this.poolId, slot: 0 });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private detach(call: Call): void {
|
|
147
|
+
if (call.timer) clearTimeout(call.timer);
|
|
148
|
+
if (call.signal && call.onSignalAbort) call.signal.removeEventListener('abort', call.onSignalAbort);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private handleReply(data: TaskResult & { messageId?: number }): void {
|
|
152
|
+
const call = data.messageId !== undefined ? this.pending.get(data.messageId) : undefined;
|
|
153
|
+
if (!call) return;
|
|
154
|
+
this.pending.delete(data.messageId!);
|
|
155
|
+
this.detach(call);
|
|
156
|
+
if (!call.done) {
|
|
157
|
+
call.done = true;
|
|
158
|
+
const runMs = performance.now() - call.sentAt;
|
|
159
|
+
this.statsAgg.runMs.add(runMs);
|
|
160
|
+
if (data.success) {
|
|
161
|
+
this.statsAgg.completed++;
|
|
162
|
+
taskLog.debug(`← ${call.contract.taskId} ok`);
|
|
163
|
+
if (devtoolsEnabled()) {
|
|
164
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'ok', runMs, resultBytes: estimateCloneBytes(data.result) });
|
|
165
|
+
measureTaskCall(this.poolId, call.contract.taskId, call.callId, 0, call.sentAt, call.sentAt + runMs, true);
|
|
166
|
+
}
|
|
167
|
+
call.resolve(data.result);
|
|
168
|
+
} else {
|
|
169
|
+
this.statsAgg.failed++;
|
|
170
|
+
taskLog.warn(`← ${call.contract.taskId} failed: ${data.error}`);
|
|
171
|
+
if (devtoolsEnabled()) {
|
|
172
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'error', runMs, error: data.error });
|
|
173
|
+
measureTaskCall(this.poolId, call.contract.taskId, call.callId, 0, call.sentAt, call.sentAt + runMs, false);
|
|
174
|
+
}
|
|
175
|
+
call.reject(new Error(data.error));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
this.checkDrained();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private handleError(event: unknown): void {
|
|
182
|
+
if (this.closed) return;
|
|
183
|
+
const e = event as { message?: string; error?: Error } | undefined;
|
|
184
|
+
const message = e?.message ?? (e?.error ? String(e.error) : 'worker error');
|
|
185
|
+
workerLog.warn(`worker error: ${message}`);
|
|
186
|
+
emitDevtools({ type: 'worker:error', poolId: this.poolId, slot: 0, message });
|
|
187
|
+
this.rejectPending(message);
|
|
188
|
+
this.worker?.terminate();
|
|
189
|
+
this.worker = null;
|
|
190
|
+
if (this.respawn) {
|
|
191
|
+
workerLog.info('respawning crashed worker');
|
|
192
|
+
this.attach(this.spawn());
|
|
193
|
+
emitDevtools({ type: 'worker:respawn', poolId: this.poolId, slot: 0 });
|
|
194
|
+
}
|
|
195
|
+
this.checkDrained();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private rejectPending(message: string): void {
|
|
199
|
+
const err = new WorkerCrashedError(message);
|
|
200
|
+
for (const call of this.pending.values()) {
|
|
201
|
+
this.detach(call);
|
|
202
|
+
if (call.done) continue;
|
|
203
|
+
call.done = true;
|
|
204
|
+
this.statsAgg.failed++;
|
|
205
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'crashed', error: message });
|
|
206
|
+
call.reject(err);
|
|
207
|
+
}
|
|
208
|
+
this.pending.clear();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Abort/timeout: the caller rejects now; the worker's eventual reply is discarded. */
|
|
212
|
+
private cancel(call: Call, err: Error): void {
|
|
213
|
+
if (call.done) return;
|
|
214
|
+
call.done = true;
|
|
215
|
+
this.detach(call);
|
|
216
|
+
this.statsAgg.aborted++;
|
|
217
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: err instanceof TaskTimeoutError ? 'timeout' : 'aborted' });
|
|
218
|
+
call.reject(err);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private checkDrained(): void {
|
|
222
|
+
if (this.pending.size > 0) return;
|
|
223
|
+
for (const w of this.drainWaiters.splice(0)) w();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Post the call straight to the worker — no queue, no scheduler. */
|
|
227
|
+
public dispatch<Args extends any[], Result>(
|
|
228
|
+
contract: TaskContract<Args, Result>,
|
|
229
|
+
args: Args,
|
|
230
|
+
options?: RunOptions,
|
|
231
|
+
): Promise<Result> {
|
|
232
|
+
const worker = this.worker;
|
|
233
|
+
if (this.closed || worker === null) {
|
|
234
|
+
return Promise.reject(new WorkerCrashedError('DedicatedWorker has been terminated — no worker available.'));
|
|
235
|
+
}
|
|
236
|
+
if (options?.signal?.aborted) return Promise.reject(new TaskAbortedError());
|
|
237
|
+
return new Promise<Result>((resolve, reject) => {
|
|
238
|
+
const call: Call = {
|
|
239
|
+
contract,
|
|
240
|
+
callId: ++this.nextCallId,
|
|
241
|
+
sentAt: performance.now(),
|
|
242
|
+
resolve,
|
|
243
|
+
reject,
|
|
244
|
+
done: false,
|
|
245
|
+
signal: options?.signal,
|
|
246
|
+
};
|
|
247
|
+
if (options?.signal) {
|
|
248
|
+
call.onSignalAbort = () => this.cancel(call, new TaskAbortedError());
|
|
249
|
+
options.signal.addEventListener('abort', call.onSignalAbort, { once: true });
|
|
250
|
+
}
|
|
251
|
+
const timeout = options?.timeout ?? this.taskTimeout;
|
|
252
|
+
if (timeout !== undefined) call.timer = setTimeout(() => this.cancel(call, new TaskTimeoutError()), timeout);
|
|
253
|
+
|
|
254
|
+
const messageId = this.nextMessageId++;
|
|
255
|
+
this.pending.set(messageId, call);
|
|
256
|
+
taskLog.debug(`→ ${contract.taskId}`, { args });
|
|
257
|
+
emitDevtools({ type: 'task:enqueue', poolId: this.poolId, callId: call.callId, taskId: contract.taskId });
|
|
258
|
+
if (devtoolsEnabled()) {
|
|
259
|
+
emitDevtools({ type: 'task:dispatch', poolId: this.poolId, callId: call.callId, taskId: contract.taskId, slot: 0, waitMs: 0, argBytes: estimateCloneBytes(args) });
|
|
260
|
+
}
|
|
261
|
+
const msg = { type: 'EXECUTE_TASK', messageId, taskId: contract.taskId, args };
|
|
262
|
+
if (this.chaos) {
|
|
263
|
+
applyChaos(this.chaos, {
|
|
264
|
+
live: () => this.pending.get(messageId) === call,
|
|
265
|
+
fail: () => this.handleReply({ messageId, taskId: contract.taskId, success: false, error: CHAOS_FAILURE }),
|
|
266
|
+
timeout: () => this.cancel(call, new TaskTimeoutError()),
|
|
267
|
+
post: () => worker.postMessage(msg),
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
worker.postMessage(msg);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
public runTask<Args extends any[], Result>(contract: TaskContract<Args, Result>, ...args: Args): Promise<Result> {
|
|
276
|
+
return this.dispatch(contract, args);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The shared buffer handed to the worker via INIT_MEMORY; undefined when message-only. */
|
|
280
|
+
public get sharedBuffer(): SharedArrayBuffer | undefined {
|
|
281
|
+
return (this.mem.memoryManager as MemoryManager | undefined)?.getBuffer() ?? this.mem.injectedBuffer;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The worker, as a one-element snapshot (same shape as `WorkerPool.workers`). */
|
|
285
|
+
public get workers(): readonly Worker[] {
|
|
286
|
+
return this.worker ? [this.worker] : [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Same shape as `WorkerPool.stats()`; `queued` and `waitMs` stay zero (there is no queue). */
|
|
290
|
+
public stats(): PoolStats {
|
|
291
|
+
const live = this.worker ? 1 : 0;
|
|
292
|
+
const { runMs } = this.statsAgg;
|
|
293
|
+
return {
|
|
294
|
+
workers: live,
|
|
295
|
+
idle: live && this.pending.size === 0 ? 1 : 0,
|
|
296
|
+
inFlight: this.pending.size,
|
|
297
|
+
queued: 0,
|
|
298
|
+
completed: this.statsAgg.completed,
|
|
299
|
+
failed: this.statsAgg.failed,
|
|
300
|
+
aborted: this.statsAgg.aborted,
|
|
301
|
+
waitMs: { count: 0, mean: 0, max: 0 },
|
|
302
|
+
runMs: { count: runMs.count, mean: runMs.mean, max: runMs.max },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resolves once nothing is in flight, then terminates. */
|
|
307
|
+
public close(): Promise<void> {
|
|
308
|
+
if (this.pending.size === 0) {
|
|
309
|
+
this.terminate();
|
|
310
|
+
return Promise.resolve();
|
|
311
|
+
}
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
this.drainWaiters.push(() => {
|
|
314
|
+
this.terminate();
|
|
315
|
+
resolve();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
public terminate(): void {
|
|
321
|
+
if (this.closed) return;
|
|
322
|
+
this.closed = true;
|
|
323
|
+
this.devtoolsOff?.();
|
|
324
|
+
void this.persistence?.stop()?.catch?.((e: unknown) => workerLog.warn('memory persistence stop failed', e));
|
|
325
|
+
workerLog.info('terminating dedicated worker');
|
|
326
|
+
emitDevtools({ type: 'pool:terminate', poolId: this.poolId });
|
|
327
|
+
this.rejectPending('worker terminated');
|
|
328
|
+
this.worker?.terminate();
|
|
329
|
+
this.worker = null;
|
|
330
|
+
this.checkDrained();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard control over the live runners of this thread: `pool.list`,
|
|
3
|
+
* `pool.stats`, `worker.kill`, `pool.chaos`. Runners (`WorkerPool`,
|
|
4
|
+
* `DedicatedWorker`) register here from their constructor only when
|
|
5
|
+
* `devtoolsEnabled()`, and unregister on terminate — with devtools off
|
|
6
|
+
* nothing is registered and the runners' only cost is one `chaos` field
|
|
7
|
+
* check per dispatch.
|
|
8
|
+
*
|
|
9
|
+
* Scope: one registry per thread. Pools spawned inside workers (sub-pools,
|
|
10
|
+
* nested island clients) register in the worker's own registry, which the
|
|
11
|
+
* dashboard's control channel (main thread only) cannot reach.
|
|
12
|
+
*/
|
|
13
|
+
import { registerDevtoolsCommand } from '../devtools';
|
|
14
|
+
import type { PoolStats } from './workerPool';
|
|
15
|
+
|
|
16
|
+
/** Fault injection applied at dispatch. All rates are probabilities in 0..1. */
|
|
17
|
+
export interface ChaosConfig {
|
|
18
|
+
/** Delay before the call is posted to the worker. */
|
|
19
|
+
delayMs?: number;
|
|
20
|
+
/** Share of calls rejected with `CHAOS_FAILURE` (settle outcome 'error'). */
|
|
21
|
+
failRate?: number;
|
|
22
|
+
/** Share of calls failed through the timeout path (settle outcome 'timeout'). */
|
|
23
|
+
timeoutRate?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Error message of a chaos-injected failure. */
|
|
27
|
+
export const CHAOS_FAILURE = 'chaos: injected failure';
|
|
28
|
+
/** Crash message of a dashboard `worker.kill`. */
|
|
29
|
+
export const KILL_MESSAGE = 'killed from devtools';
|
|
30
|
+
|
|
31
|
+
/** What a runner exposes to the dashboard commands. */
|
|
32
|
+
export interface DevtoolsRunnerHandle {
|
|
33
|
+
poolId: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
dedicated: boolean;
|
|
36
|
+
/** Live worker count. */
|
|
37
|
+
size(): number;
|
|
38
|
+
stats(): PoolStats;
|
|
39
|
+
/** Run the runner's real crash handler for that slot (throws on a bad slot). */
|
|
40
|
+
kill(slot: number): void;
|
|
41
|
+
getChaos(): ChaosConfig | null;
|
|
42
|
+
setChaos(chaos: ChaosConfig | null): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runners = new Map<string, DevtoolsRunnerHandle>();
|
|
46
|
+
let uninstall: (() => void) | null = null;
|
|
47
|
+
|
|
48
|
+
const runnerOf = (args: Record<string, unknown>): DevtoolsRunnerHandle => {
|
|
49
|
+
const id = args.poolId;
|
|
50
|
+
if (typeof id !== 'string') throw new Error('poolId (string) is required');
|
|
51
|
+
const r = runners.get(id);
|
|
52
|
+
if (!r) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
id.includes('~')
|
|
55
|
+
? `runner '${id}' lives inside a worker: nested pools are not controllable from the dashboard`
|
|
56
|
+
: `no live runner '${id}' on this thread`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return r;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const rate = (v: unknown, name: string): number | undefined => {
|
|
63
|
+
if (v === undefined || v === null) return undefined;
|
|
64
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
|
|
65
|
+
throw new Error(`${name} must be a number in 0..1`);
|
|
66
|
+
}
|
|
67
|
+
return v || undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Validate `pool.chaos` args into a config; null when nothing is active (clears). */
|
|
71
|
+
export const parseChaos = (args: Record<string, unknown>): ChaosConfig | null => {
|
|
72
|
+
const { delayMs } = args;
|
|
73
|
+
if (delayMs !== undefined && delayMs !== null && (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs < 0)) {
|
|
74
|
+
throw new Error('delayMs must be a non-negative number');
|
|
75
|
+
}
|
|
76
|
+
const failRate = rate(args.failRate, 'failRate');
|
|
77
|
+
const timeoutRate = rate(args.timeoutRate, 'timeoutRate');
|
|
78
|
+
if ((failRate ?? 0) + (timeoutRate ?? 0) > 1) throw new Error('failRate + timeoutRate must not exceed 1');
|
|
79
|
+
const chaos: ChaosConfig = {};
|
|
80
|
+
if (delayMs) chaos.delayMs = delayMs as number;
|
|
81
|
+
if (failRate) chaos.failRate = failRate;
|
|
82
|
+
if (timeoutRate) chaos.timeoutRate = timeoutRate;
|
|
83
|
+
return Object.keys(chaos).length ? chaos : null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const listRunners = () =>
|
|
87
|
+
[...runners.values()].map((r) => ({
|
|
88
|
+
poolId: r.poolId,
|
|
89
|
+
label: r.label ?? r.poolId,
|
|
90
|
+
size: r.size(),
|
|
91
|
+
dedicated: r.dedicated,
|
|
92
|
+
chaos: r.getChaos(),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const install = (): (() => void) => {
|
|
96
|
+
const offs = [
|
|
97
|
+
registerDevtoolsCommand('pool.list', () => listRunners()),
|
|
98
|
+
registerDevtoolsCommand('pool.stats', (args) => runnerOf(args).stats()),
|
|
99
|
+
registerDevtoolsCommand('worker.kill', (args) => {
|
|
100
|
+
const r = runnerOf(args);
|
|
101
|
+
const slot = args.slot ?? 0;
|
|
102
|
+
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 0) {
|
|
103
|
+
throw new Error('slot must be a non-negative integer');
|
|
104
|
+
}
|
|
105
|
+
r.kill(slot);
|
|
106
|
+
return { poolId: r.poolId, slot, killed: true };
|
|
107
|
+
}),
|
|
108
|
+
registerDevtoolsCommand('pool.chaos', (args) => {
|
|
109
|
+
const r = runnerOf(args);
|
|
110
|
+
r.setChaos(parseChaos(args));
|
|
111
|
+
return r.getChaos();
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
114
|
+
return () => offs.forEach((off) => off());
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register a live runner; returns its unregister. The pool commands are
|
|
119
|
+
* installed with the first runner and removed with the last, so the
|
|
120
|
+
* dashboard's `devtools.commands` only advertises them while there is
|
|
121
|
+
* something to control.
|
|
122
|
+
*/
|
|
123
|
+
export const registerDevtoolsRunner = (handle: DevtoolsRunnerHandle): (() => void) => {
|
|
124
|
+
runners.set(handle.poolId, handle);
|
|
125
|
+
uninstall ??= install();
|
|
126
|
+
return () => {
|
|
127
|
+
if (runners.get(handle.poolId) !== handle) return;
|
|
128
|
+
runners.delete(handle.poolId);
|
|
129
|
+
if (runners.size === 0 && uninstall) {
|
|
130
|
+
uninstall();
|
|
131
|
+
uninstall = null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Hooks a runner supplies to `applyChaos` for one dispatched call. */
|
|
137
|
+
export interface ChaosCall {
|
|
138
|
+
/** Still awaiting its reply (not crashed/terminated away during the delay). */
|
|
139
|
+
live(): boolean;
|
|
140
|
+
/** Settle as a worker-reported failure (the runner's reply path). */
|
|
141
|
+
fail(): void;
|
|
142
|
+
/** Settle through the runner's timeout path. */
|
|
143
|
+
timeout(): void;
|
|
144
|
+
/** Post the call to the worker. */
|
|
145
|
+
post(): void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply an active chaos config to one dispatched call. An injected failure
|
|
150
|
+
* never reaches the worker; an injected timeout settles the caller through
|
|
151
|
+
* the timeout path and still posts the call, exactly like a real timeout
|
|
152
|
+
* (the worker runs it; its late reply frees the slot and is discarded).
|
|
153
|
+
*/
|
|
154
|
+
export const applyChaos = (chaos: ChaosConfig, call: ChaosCall): void => {
|
|
155
|
+
const r = Math.random();
|
|
156
|
+
const fail = r < (chaos.failRate ?? 0);
|
|
157
|
+
const timeout = !fail && r < (chaos.failRate ?? 0) + (chaos.timeoutRate ?? 0);
|
|
158
|
+
if (!chaos.delayMs && !fail && !timeout) return call.post();
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
if (!call.live()) return;
|
|
161
|
+
if (fail) return call.fail();
|
|
162
|
+
if (timeout) call.timeout();
|
|
163
|
+
call.post();
|
|
164
|
+
}, chaos.delayMs ?? 0);
|
|
165
|
+
};
|