diff --git a/docs/superpowers/specs/2026-07-19-runtime-design.md b/docs/superpowers/specs/2026-07-19-runtime-design.md index 52322c9..1664517 100644 --- a/docs/superpowers/specs/2026-07-19-runtime-design.md +++ b/docs/superpowers/specs/2026-07-19-runtime-design.md @@ -29,7 +29,9 @@ cooperatively across them. main→worker and worker→worker. - **Dedicated workers are removed**; bare `await task` dispatches to the global runtime. - **`workers()` remains** as the explicitly-scoped tool for scripts, sharing all mechanisms - with the runtime; using only `workers()` never boots the global runtime. + with the runtime; a program that only uses `workers()`/`run()` and never bare-dispatches + never boots the global runtime. An undispatched streaming task passed as a task arg or + `channel()` source is a bare dispatch and runs on the global runtime. - This is a **semver-major**. ## 1. Concept & API @@ -133,8 +135,10 @@ only its target changes. `workers()` is unchanged in spirit: an explicitly scoped pool for script-like usage, owned by a `using` block. It shares dispatch, handles, balancer machinery, and teardown code with -the runtime. If a program only uses `workers()`/`run()`, the global runtime never boots and -no mesh exists. Tasks running inside a `workers()` pool have no ambient reference to their +the runtime. If a program only uses `workers()`/`run()` and never bare-dispatches, the +global runtime never boots and no mesh exists. Note that an undispatched streaming task +passed as a task arg or `channel()` source is a bare dispatch: it runs on the global +runtime and boots it. Tasks running inside a `workers()` pool have no ambient reference to their own pool (the `runtime` handle refers to the global runtime), so pool scheduling decisions only ever occur on main. @@ -301,7 +305,8 @@ Unchanged: including arg-deserialization semantics); pinning from workers; recursive fork-join at pool size 1 completes (liveness invariant); unref-based natural process exit; `registerRuntime()` after boot throws; two runtimes' - handles cross-used throws; `workers()`-only programs never boot the runtime. + handles cross-used throws; `workers()`-only programs with no bare dispatches never boot + the runtime (an undispatched stream-task arg is a bare dispatch and boots it). - **Perf gates:** dispatch overhead unchanged in `workers()` mode; same-worker shortcut comparable to a local call; first-contact latency benchmarked; shared-counter dispatch accounting no slower than today's Map-based counting. diff --git a/src/channel.ts b/src/channel.ts index 9e4a855..801297f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -51,8 +51,7 @@ export class Channel { constructor(source: AsyncIterable, opts?: ChannelOptions) { this.highWater = opts?.highWaterMark ?? DEFAULT_HIGH_WATER; - const src = - source instanceof AsyncIterableTask ? getRuntime()(source as unknown as Task>) : source; + const src = source instanceof AsyncIterableTask ? getRuntime()(source as Task>) : source; this.iter = src[Symbol.asyncIterator](); } diff --git a/src/execute.ts b/src/execute.ts index ef9b5a1..cbd3354 100644 --- a/src/execute.ts +++ b/src/execute.ts @@ -78,7 +78,7 @@ function prepareArg(arg: unknown): unknown { } // Auto-detect StreamTask args — dispatch to the global runtime, pipe output if (arg instanceof AsyncIterableTask) { - return pipeArgToWorker(getRuntime()(arg as unknown as Task>)); + return pipeArgToWorker(getRuntime()(arg as Task>)); } // Channel wrapper — single distributor loop + per-consumer atomics backpressure. if (arg instanceof Channel) { diff --git a/src/runtime.ts b/src/runtime.ts index 1589c0d..9f92d9e 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,3 +1,4 @@ +import { isMainThread } from 'node:worker_threads'; import { workers } from './worker-pool.ts'; import { getDefineId } from './define.ts'; import { defaultRuntime } from './runtime-definition.ts'; @@ -47,6 +48,12 @@ export function getRuntime(): Runner { throw new Error('The global runtime has been shut down.'); } if (pool === null) { + if (!isMainThread) { + throw new Error( + 'The global runtime cannot boot on a worker thread yet — dispatch from the main thread. ' + + '(Worker-side runtime participation is planned.)', + ); + } const def = registered ?? defaultRuntime; pool = def.size !== undefined diff --git a/src/stream-task.ts b/src/stream-task.ts index 9d98da4..87a24bf 100644 --- a/src/stream-task.ts +++ b/src/stream-task.ts @@ -24,7 +24,8 @@ export class AsyncIterableTask implements AsyncIterable { /** Enables `for await...of` by dispatching to the global runtime. @returns An iterator of yielded values. */ [Symbol.asyncIterator](): AsyncIterator { - const iterable = getRuntime()(this as unknown as Task>); + // cast carries T: the result brand is type-only, so inference from `this` yields Task + const iterable = getRuntime()(this as Task>); return iterable[Symbol.asyncIterator](); } } diff --git a/src/task.ts b/src/task.ts index f3f5ebf..4e35627 100644 --- a/src/task.ts +++ b/src/task.ts @@ -26,6 +26,12 @@ export class PromiseLikeTask implements PromiseLike { onfulfilled?: ((value: T) => T1 | PromiseLike) | null, onrejected?: ((reason: any) => T2 | PromiseLike) | null, ): Promise { - return (getRuntime()(this as unknown as Task) as Promise).then(onfulfilled, onrejected); + try { + return (getRuntime()(this as Task) as Promise).then(onfulfilled, onrejected); + } catch (err) { + // getRuntime() can throw synchronously (e.g. post-shutdown); a thenable's + // then() must return a promise, so surface the error as a rejection. + return Promise.reject(err).then(onfulfilled, onrejected); + } } } diff --git a/test/error.test.ts b/test/error.test.ts index a8f9e7e..8a18e1d 100644 --- a/test/error.test.ts +++ b/test/error.test.ts @@ -5,12 +5,26 @@ import { fail, failType, failCause } from './fixtures/math.ts'; import { failAfterType } from './fixtures/stream-gen.ts'; describe('error handling', () => { - it('rejects with error from dedicated worker', async () => { + it('rejects with error from a runtime worker', async () => { await assert.rejects(async () => fail('boom').then((x) => x), { message: 'boom', }); }); + it('bare await preserves error subclass identity via the runtime', async () => { + await assert.rejects( + async () => failType('bare type').then((v) => v), + (err: unknown) => { + assert.ok(err instanceof TypeError); + assert.equal((err as Error).message, 'bare type'); + const cause = (err as Error).cause as Error; + assert.ok(cause instanceof Error); + assert.match(cause.stack!, /fixtures\/math\.ts/); + return true; + }, + ); + }); + it('rejects with error from pool worker', async () => { await using run = workers(1); await assert.rejects(() => run(fail('pool boom')), { @@ -57,7 +71,7 @@ describe('error handling', () => { } }); - it('preserves error details on dedicated worker', async () => { + it('preserves error details on a runtime worker', async () => { try { await failType('dedicated type'); assert.fail('should have thrown'); @@ -100,7 +114,7 @@ describe('error handling', () => { } }); - it('main-thread stack includes await-task caller on dedicated worker', async () => { + it('main-thread stack includes await-task caller on a runtime worker', async () => { async function someCaller() { await fail('dedicated trace check'); } diff --git a/test/fixtures/exit-dedicated-main.ts b/test/fixtures/exit-runtime-main.ts similarity index 100% rename from test/fixtures/exit-dedicated-main.ts rename to test/fixtures/exit-runtime-main.ts diff --git a/test/fixtures/stream-arg-boots-runtime.ts b/test/fixtures/stream-arg-boots-runtime.ts new file mode 100644 index 0000000..29aefa9 --- /dev/null +++ b/test/fixtures/stream-arg-boots-runtime.ts @@ -0,0 +1,10 @@ +import { workers, runtime } from 'moroutine'; +import { generate, sumStream } from './channel-autodetect.ts'; + +// An undispatched streaming task passed as a task arg is a bare dispatch: +// it runs on the global runtime, booting it — even though the consumer +// task runs on an explicit workers() pool. +using run = workers(1); +const result = await run(sumStream(generate(5))); +console.log('RESULT ' + result); +console.log('RUNTIME-BOOTED ' + (runtime.workers.length >= 1)); diff --git a/test/runtime-lifecycle.test.ts b/test/runtime-lifecycle.test.ts index 4a20002..6f765bf 100644 --- a/test/runtime-lifecycle.test.ts +++ b/test/runtime-lifecycle.test.ts @@ -25,6 +25,16 @@ describe('global runtime lifecycle', () => { assert.match(stdout, /DONE done/); }); + it('an undispatched stream-task arg is a bare dispatch and boots the runtime', async () => { + const { stdout } = await exec( + process.execPath, + ['--no-warnings', join(fixturesDir, 'stream-arg-boots-runtime.ts')], + { timeout: 20000 }, + ); + assert.ok(stdout.includes('RESULT 10')); // 0+1+2+3+4 + assert.ok(stdout.includes('RUNTIME-BOOTED true')); + }); + it('shutdown() fires signal, drains, and locks out further dispatch', async () => { const { stdout } = await exec(process.execPath, ['--no-warnings', join(fixturesDir, 'runtime-shutdown.ts')], { timeout: 15000, diff --git a/test/stream-exit.test.ts b/test/stream-exit.test.ts index 294f4ac..b31bf00 100644 --- a/test/stream-exit.test.ts +++ b/test/stream-exit.test.ts @@ -9,10 +9,10 @@ const exec = promisify(execFile); const fixturesDir = join(fileURLToPath(import.meta.url), '..', 'fixtures'); describe('streaming process exit', () => { - it('process exits after streaming on dedicated worker', async () => { + it('process exits after streaming on the global runtime', async () => { const { stdout } = await exec( process.execPath, - ['--no-warnings', '--experimental-strip-types', join(fixturesDir, 'exit-dedicated-main.ts')], + ['--no-warnings', '--experimental-strip-types', join(fixturesDir, 'exit-runtime-main.ts')], { timeout: 5000 }, ); assert.ok(stdout.includes('DONE'));