diff --git a/src/handshake.ts b/src/handshake.ts new file mode 100644 index 0000000..5430f5f --- /dev/null +++ b/src/handshake.ts @@ -0,0 +1,37 @@ +/** + * Boot handshake passed to every pool worker via `workerData`, and the + * control-frame vocabulary that rides parentPort alongside task messages. + * Types only — worker-pool writes, worker-entry reads. + */ + +/** workerData for every pool worker. Runtime pools add the runtime fields. */ +export interface WorkerHandshake { + /** This worker's index in its pool. */ + index: number; + /** Pool size. */ + size: number; + /** Shared ActiveCounts backing buffer for the pool. */ + countsBuffer: SharedArrayBuffer; + /** define() id of the runtime definition — present only for runtime pools. */ + runtimeId?: string; + /** serializeArg'd balancer state — present only for runtime pools. */ + balancerState?: unknown; + /** serializeArg'd Int32Atomic counting workers with outstanding outgoing + * dispatches (edge-counted) — present only for runtime pools. */ + busyPeers?: unknown; + /** Transferred copy of the pool's shutdown AbortSignal — present only for + * runtime pools. Worker-side `runtime.signal` IS this signal. */ + shutdownSignal?: AbortSignal; +} + +/** Control frames — discriminated by `__ctrl__`, crash on unknown values. + * This is the ENTIRE control plane: each frame fires at most once per pair. + * `connect`: worker asks main for a channel to `peer`. + * `peer`: main delivers one end of the (single, main-created) pair channel. */ +export type CtrlFrame = + | { __ctrl__: 'connect'; peer: number } + | { __ctrl__: 'peer'; peer: number; port: import('node:worker_threads').MessagePort }; + +export function isCtrlFrame(msg: unknown): msg is CtrlFrame { + return typeof msg === 'object' && msg !== null && '__ctrl__' in msg; +} diff --git a/src/runner.ts b/src/runner.ts index 4b23d61..f7da3c5 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -55,6 +55,8 @@ export interface WorkerOptions { refMode?: 'held' | 'lazy'; /** @internal Pre-computed balancer state (already validated by the runtime). */ balancerState?: unknown; + /** @internal Runtime handshake fields forwarded to workers (runtime pools only). */ + runtimeHandshake?: { runtimeId: string; balancerState?: unknown }; } /** A handle to a specific worker in a pool. Uniform across threads. */ diff --git a/src/runtime.ts b/src/runtime.ts index 494d7d5..2bd4324 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,6 @@ import { isMainThread } from 'node:worker_threads'; import { workers } from './worker-pool.ts'; -import { isSharedValue } from './shared/reconstruct.ts'; +import { isSharedValue, serializeArg } from './shared/reconstruct.ts'; import { getDefineId } from './define.ts'; import { defaultRuntime } from './runtime-definition.ts'; import type { RuntimeDefinition } from './runtime-definition.ts'; @@ -66,6 +66,8 @@ export function getRuntime(): Runner { 'cannot stay consistent across scheduling threads.', ); } + const runtimeId = getDefineId(def)!; // registerRuntime guarantees branding; defaultRuntime is branded + const handshake = { runtimeId, balancerState: state !== undefined ? serializeArg(state) : undefined }; pool = def.size !== undefined ? workers(def.size, { @@ -73,12 +75,14 @@ export function getRuntime(): Runner { shutdownTimeout: def.shutdownTimeout, refMode: 'lazy', balancerState: state, + runtimeHandshake: handshake, }) : workers({ balance: def.balance, shutdownTimeout: def.shutdownTimeout, refMode: 'lazy', balancerState: state, + runtimeHandshake: handshake, }); } return pool; diff --git a/src/worker-pool.ts b/src/worker-pool.ts index 280ca59..fa22a59 100644 --- a/src/worker-pool.ts +++ b/src/worker-pool.ts @@ -1,12 +1,16 @@ import { setTimeout } from 'node:timers/promises'; +import { transferableAbortSignal } from 'node:util'; import { Worker } from 'node:worker_threads'; import { availableParallelism } from 'node:os'; import { setupWorker, execute, dispatchStream } from './execute.ts'; import { AsyncIterableTask } from './stream-task.ts'; import { leastBusy } from './balancers.ts'; import { ActiveCounts } from './active-counts.ts'; +import { int32atomic } from './shared/descriptors.ts'; +import { serializeArg } from './shared/reconstruct.ts'; import type { ChannelOptions } from './channel.ts'; import type { Task, Balancer, Runner, WorkerHandle, WorkerOptions } from './runner.ts'; +import type { WorkerHandshake } from './handshake.ts'; const workerEntryUrl = new URL( import.meta.url.endsWith('.ts') ? './worker-entry.ts' : './worker-entry.js', @@ -35,18 +39,43 @@ export function workers(sizeOrOpts?: number | WorkerOptions, opts?: WorkerOption const balancerState: unknown = opts != null && 'balancerState' in opts ? opts.balancerState : balancer.initialState?.(); + let disposed = false; + const ac = new AbortController(); + const inflight = new Set>(); + const counts = new ActiveCounts(size); + // Edge-counted liveness for worker-originated dispatches: workers add(1) on + // their 0→1 outgoing edge, sub(1)+notify on 1→0. Allocated only for runtime + // pools; main's unref/shutdown paths consult it (Task 4). + const busyPeers = opts?.runtimeHandshake !== undefined ? int32atomic() : null; + const pool: Worker[] = []; for (let i = 0; i < size; i++) { - const worker = new Worker(workerEntryUrl); + let runtimeFields = {}; + let transferList: unknown[] = []; + if (opts?.runtimeHandshake !== undefined) { + // Each worker gets its OWN transferred copy of the shutdown signal — + // transferableAbortSignal marks a signal for one transfer; a fresh copy + // per worker is required. Worker-side runtime.signal IS this signal. + const shutdownSignal = transferableAbortSignal(ac.signal); + runtimeFields = { + runtimeId: opts.runtimeHandshake.runtimeId, + balancerState: opts.runtimeHandshake.balancerState, + busyPeers: serializeArg(busyPeers), + shutdownSignal, + }; + transferList = [shutdownSignal]; + } + const handshake: WorkerHandshake = { + index: i, + size, + countsBuffer: counts.buffer, + ...runtimeFields, + }; + const worker = new Worker(workerEntryUrl, { workerData: handshake, transferList: transferList as any[] }); setupWorker(worker); pool.push(worker); } - let disposed = false; - const ac = new AbortController(); - const inflight = new Set>(); - const counts = new ActiveCounts(size); - // Lazy ref-mode unrefs the whole pool when idle so the process can exit // naturally, and refs the whole pool while any task is in flight. We track // refs/unrefs per pool (not per-worker) since the semantic is "process stays diff --git a/test/fixtures/handshake-probe.ts b/test/fixtures/handshake-probe.ts new file mode 100644 index 0000000..630f741 --- /dev/null +++ b/test/fixtures/handshake-probe.ts @@ -0,0 +1,15 @@ +import { workerData } from 'node:worker_threads'; +import { mo } from 'moroutine'; + +export const readHandshake = mo(import.meta, () => { + const wd = (workerData ?? {}) as Record; + return { + runtimeId: wd.runtimeId as string | undefined, + balancerState: wd.balancerState, + size: wd.size as number | undefined, + index: wd.index as number | undefined, + countsBuffer: wd.countsBuffer instanceof SharedArrayBuffer, + busyPeers: wd.busyPeers !== undefined, + shutdownSignal: wd.shutdownSignal instanceof AbortSignal && !(wd.shutdownSignal as AbortSignal).aborted, + }; +}); diff --git a/test/handshake.test.ts b/test/handshake.test.ts new file mode 100644 index 0000000..a669498 --- /dev/null +++ b/test/handshake.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { workers } from 'moroutine'; +import { readHandshake } from './fixtures/handshake-probe.ts'; + +describe('worker handshake', () => { + it('runtime handshake fields arrive in workerData', async () => { + const run = workers(2, { + runtimeHandshake: { runtimeId: 'test://fake#0', balancerState: 42 }, + } as any); + try { + const h = await run(readHandshake()); + assert.equal(h.runtimeId, 'test://fake#0'); + assert.equal(h.balancerState, 42); + assert.equal(h.size, 2); + assert.equal(typeof h.index, 'number'); + assert.ok(h.index === 0 || h.index === 1); + assert.equal(h.countsBuffer, true); // probe reports presence as boolean + assert.equal(h.busyPeers, true); // shared Int32Atomic rides the handshake + assert.equal(h.shutdownSignal, true); // transferred AbortSignal arrived un-aborted + } finally { + run[Symbol.dispose](); + } + }); + + it('plain pools send no runtime handshake', async () => { + const run = workers(1); + try { + const h = await run(readHandshake()); + assert.equal(h.runtimeId, undefined); + assert.equal(h.busyPeers, false); // plain pools carry no busy-peers counter + assert.equal(h.shutdownSignal, false); // ...nor a shutdown signal + assert.equal(h.size, 1); + assert.equal(h.index, 0); + assert.equal(h.countsBuffer, true); + } finally { + run[Symbol.dispose](); + } + }); +});