├── .clean-publish ├── benchmark ├── fixtures │ ├── add.mjs │ ├── add-process.mjs │ └── add-worker.mjs ├── simple.bench.ts └── isolate-benchmark.bench.ts ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release-commits.yml │ ├── benchmark.yml │ ├── nodejs.yml │ └── publish.yml ├── test ├── fixtures │ ├── isolated.js │ ├── eval.js │ ├── esm-export.mjs │ ├── stdio.mjs │ ├── resource-limits.js │ ├── multiple.js │ ├── wait-for-notify.js │ ├── simple-workerdata.js │ ├── simple-isworkerthread.js │ ├── workerId.js │ ├── sleep.js │ ├── move.js │ ├── wait-for-others.js │ ├── child_process-communication.mjs │ ├── notify-then-sleep-or.js │ ├── teardown.mjs │ ├── nested-pool.mjs │ └── leak-memory.js ├── globals.test.ts ├── async-context.test.ts ├── idle-timeout.test.ts ├── worker-stdio.test.ts ├── options.test.ts ├── pool-destroy.test.ts ├── teardown.test.ts ├── termination.test.ts ├── isolation.test.ts ├── uncaught-exception-from-handler.test.ts ├── atomic.test.ts ├── resource-limits.test.ts ├── move.test.ts ├── runtime.test.ts ├── simple.test.ts └── task-queue.test.ts ├── .gitignore ├── .npmignore ├── .prettierrc ├── tsdown.config.ts ├── .taprc ├── global.d.ts ├── src ├── utils.ts ├── runtime │ ├── thread-worker.ts │ └── process-worker.ts ├── entry │ ├── utils.ts │ ├── process.ts │ └── worker.ts ├── common.ts └── index.ts ├── vitest.config.ts ├── tsconfig.json ├── LICENSE ├── CONTRIBUTING ├── package.json ├── eslint.config.js ├── CODE_OF_CONDUCT.md └── README.md /.clean-publish: -------------------------------------------------------------------------------- 1 | { 2 | "cleanDocs": true 3 | } 4 | -------------------------------------------------------------------------------- /benchmark/fixtures/add.mjs: -------------------------------------------------------------------------------- 1 | export default ({ a, b }) => a + b 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: aslemammad 2 | github: [aslemammad] 3 | -------------------------------------------------------------------------------- /test/fixtures/isolated.js: -------------------------------------------------------------------------------- 1 | let count = 0 2 | 3 | export default () => count++ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | .vscode 3 | .idea 4 | node_modules 5 | dist 6 | coverage 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .nyc_output 3 | package-lock.json 4 | coverage 5 | examples 6 | -------------------------------------------------------------------------------- /test/fixtures/eval.js: -------------------------------------------------------------------------------- 1 | export default function (code) { 2 | return eval(code) 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/esm-export.mjs: -------------------------------------------------------------------------------- 1 | export default function (code) { 2 | return eval(code) 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "singleQuote": true, 4 | "semi": false, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /benchmark/fixtures/add-process.mjs: -------------------------------------------------------------------------------- 1 | import add from './add.mjs' 2 | 3 | process.on('message', (message) => { 4 | process.send(add(message)) 5 | }) 6 | -------------------------------------------------------------------------------- /test/fixtures/stdio.mjs: -------------------------------------------------------------------------------- 1 | export default function run() { 2 | process.stdout.write('Worker message') 3 | process.stderr.write('Worker error') 4 | } 5 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/entry/*.ts'], 5 | }) 6 | -------------------------------------------------------------------------------- /test/fixtures/resource-limits.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export default () => { 4 | const array = [] 5 | while (true) { 6 | array.push([array]) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/multiple.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export function a() { 4 | return 'a' 5 | } 6 | 7 | export function b() { 8 | return 'b' 9 | } 10 | 11 | export default a 12 | -------------------------------------------------------------------------------- /test/fixtures/wait-for-notify.js: -------------------------------------------------------------------------------- 1 | export default function (i32array) { 2 | Atomics.wait(i32array, 0, 0) 3 | Atomics.store(i32array, 0, -1) 4 | Atomics.notify(i32array, 0, Infinity) 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /benchmark/fixtures/add-worker.mjs: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads' 2 | 3 | import add from './add.mjs' 4 | 5 | parentPort.on('message', (message) => { 6 | parentPort.postMessage(add(message)) 7 | }) 8 | -------------------------------------------------------------------------------- /test/fixtures/simple-workerdata.js: -------------------------------------------------------------------------------- 1 | import Tinypool from '../../dist/index.js' 2 | import assert from 'node:assert' 3 | 4 | assert.strictEqual(Tinypool.workerData, 'ABC') 5 | 6 | export default function () { 7 | return 'done' 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-isworkerthread.js: -------------------------------------------------------------------------------- 1 | import Tinypool from '../../dist/index.js' 2 | import assert from 'node:assert' 3 | 4 | assert.strictEqual(Tinypool.isWorkerThread, true) 5 | 6 | export default function () { 7 | return 'done' 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/workerId.js: -------------------------------------------------------------------------------- 1 | import { workerId } from '../../dist/index.js' 2 | 3 | export default async ({ slow }) => { 4 | if (slow) { 5 | await new Promise((res) => setTimeout(res, 300)) 6 | } 7 | 8 | return workerId 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/sleep.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'node:util' 2 | const sleep = promisify(setTimeout) 3 | 4 | const buf = new Uint32Array(new SharedArrayBuffer(4)) 5 | 6 | export default async ({ time = 100, a }) => { 7 | await sleep(time) 8 | const ret = Atomics.exchange(buf, 0, a) 9 | return ret 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/move.js: -------------------------------------------------------------------------------- 1 | import Tinypool from '../../dist/index.js' 2 | import assert from 'node:assert' 3 | import { types } from 'node:util' 4 | 5 | export default function (moved) { 6 | if (moved !== undefined) { 7 | assert(types.isAnyArrayBuffer(moved)) 8 | } 9 | return Tinypool.move(new ArrayBuffer(10)) 10 | } 11 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | check-coverage: false 2 | color: true 3 | coverage: true 4 | coverage-report: 5 | - html 6 | - text 7 | jobs: 2 8 | no-browser: true 9 | test-env: TS_NODE_PROJECT=test/tsconfig.json 10 | test-ignore: $. 11 | test-regex: ((\/|^)(tests?|__tests?__)\/.*|\.(tests?|spec)|^\/?tests?)\.([mc]js|ts)$ 12 | timeout: 60 13 | ts: true 14 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | // only for tsdown build, excluded from the final tgz 2 | declare namespace NodeJS { 3 | interface Process { 4 | __tinypool_state__: { 5 | isTinypoolWorker: boolean 6 | isWorkerThread?: boolean 7 | isChildProcess?: boolean 8 | workerData: any 9 | workerId: number 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/wait-for-others.js: -------------------------------------------------------------------------------- 1 | import { threadId } from 'node:worker_threads' 2 | 3 | export default function ([i32array, n]) { 4 | Atomics.add(i32array, 0, 1) 5 | Atomics.notify(i32array, 0, Infinity) 6 | let lastSeenValue 7 | while ((lastSeenValue = Atomics.load(i32array, 0)) < n) { 8 | Atomics.wait(i32array, 0, lastSeenValue) 9 | } 10 | return threadId 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/child_process-communication.mjs: -------------------------------------------------------------------------------- 1 | export default async function run() { 2 | let resolve = () => {} 3 | const promise = new Promise((r) => (resolve = r)) 4 | 5 | process.send('Child process started') 6 | 7 | process.on('message', (message) => { 8 | process.send({ received: message, response: 'Hello from worker' }) 9 | resolve() 10 | }) 11 | 12 | await promise 13 | } 14 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function stdout(): NodeJS.WriteStream | undefined { 2 | // @ts-expect-error Node.js maps process.stdout to console._stdout 3 | return console._stdout || process.stdout || undefined 4 | } 5 | 6 | export function stderr(): NodeJS.WriteStream | undefined { 7 | // @ts-expect-error Node.js maps process.stderr to console._stderr 8 | return console._stderr || process.stderr || undefined 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/notify-then-sleep-or.js: -------------------------------------------------------------------------------- 1 | // Set the index-th bith in i32array[0], then wait for it to be un-set again. 2 | export default function ({ i32array, index }) { 3 | Atomics.or(i32array, 0, 1 << index) 4 | Atomics.notify(i32array, 0, Infinity) 5 | do { 6 | const v = Atomics.load(i32array, 0) 7 | if (!(v & (1 << index))) break 8 | Atomics.wait(i32array, 0, v) 9 | } while (true) // eslint-disable-line no-constant-condition -- intentional 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/teardown.mjs: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers/promises' 2 | 3 | let state = 0 4 | 5 | /** @type {import("node:worker_threads").MessagePort } */ 6 | let port 7 | 8 | export default function task(options) { 9 | port ||= options?.port 10 | state++ 11 | 12 | return `Output of task #${state}` 13 | } 14 | 15 | export async function namedTeardown() { 16 | await setTimeout(50) 17 | 18 | port?.postMessage(`Teardown of task #${state}`) 19 | } 20 | -------------------------------------------------------------------------------- /benchmark/simple.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench } from 'vitest' 2 | import Tinypool from '../dist/index' 3 | 4 | bench( 5 | 'simple', 6 | async () => { 7 | const pool = new Tinypool({ 8 | filename: './benchmark/fixtures/add.mjs', 9 | }) 10 | 11 | const tasks: Promise[] = [] 12 | 13 | while (pool.queueSize === 0) { 14 | tasks.push(pool.run({ a: 4, b: 6 })) 15 | } 16 | 17 | await Promise.all(tasks) 18 | await pool.destroy() 19 | }, 20 | { time: 10_000 } 21 | ) 22 | -------------------------------------------------------------------------------- /test/fixtures/nested-pool.mjs: -------------------------------------------------------------------------------- 1 | import { cpus } from 'node:os' 2 | import { Tinypool } from 'tinypool' 3 | 4 | export default async function nestedPool() { 5 | const pool = new Tinypool({ 6 | filename: new URL(import.meta.url, import.meta.url).href, 7 | runtime: 'child_process', 8 | isolateWorkers: true, 9 | minThreads: cpus().length - 1, 10 | maxThreads: cpus().length - 1, 11 | }) 12 | 13 | await Promise.resolve() 14 | void pool.recycleWorkers() 15 | } 16 | 17 | export function entrypoint() {} 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { defineConfig } from 'vitest/config' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | tinypool: resolve(__dirname, './dist/index.js'), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | isolate: false, 16 | 17 | benchmark: { 18 | include: ['**/**.bench.ts'], 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /.github/workflows/release-commits.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | publish: 7 | name: Publish commit 8 | runs-on: ubuntu-latest 9 | if: github.repository == 'tinylibs/tinypool' 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Use Node.js 22.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 22.x 18 | 19 | - uses: pnpm/action-setup@v2 20 | 21 | - name: Install Dependencies 22 | run: pnpm install 23 | 24 | - name: Build 25 | run: pnpm build 26 | 27 | - run: pnpx pkg-pr-new publish --compact 28 | -------------------------------------------------------------------------------- /test/fixtures/leak-memory.js: -------------------------------------------------------------------------------- 1 | /** Enable to see memory leak logging */ 2 | const logOutput = false 3 | 4 | // eslint-disable-next-line prefer-const -- intentional 5 | export let leaks = [] 6 | 7 | /** 8 | * Leak some memory to test memory limit usage. 9 | * The argument `bytes` is not 100% accurate of the leaked bytes but good enough. 10 | */ 11 | export default function run(bytes) { 12 | const before = process.memoryUsage().heapUsed 13 | 14 | for (const _ of Array(bytes).fill()) { 15 | leaks.push(new SharedArrayBuffer(1024)) 16 | } 17 | const after = process.memoryUsage().heapUsed 18 | const diff = after - before 19 | 20 | if (logOutput) { 21 | console.log(`Leaked: ${diff}. Heap used: ${process.memoryUsage().heapUsed}`) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "moduleResolution": "Bundler", 7 | "lib": ["ESNext", "WebWorker"], 8 | "noUncheckedIndexedAccess": true, 9 | "baseUrl": ".", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "types": ["vitest/globals", "@types/node"], 18 | "paths": { 19 | "tinypool": ["./dist/index.d.ts"] 20 | } 21 | }, 22 | "include": ["./*.d.ts", "src/**/*", "test/**/*"], 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | on: [workflow_dispatch] 2 | 3 | name: Benchmark 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | node-version: [20.x, 22.x] 13 | 14 | runs-on: ${{matrix.os}} 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - uses: pnpm/action-setup@v2 24 | 25 | - name: Install Dependencies 26 | run: pnpm install 27 | 28 | - name: Build 29 | run: pnpm build 30 | 31 | - name: Benchmark 32 | run: pnpm bench 33 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | node-version: [20.x, 22.x] 18 | 19 | runs-on: ${{matrix.os}} 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - uses: pnpm/action-setup@v2 29 | 30 | - name: Install Dependencies 31 | run: pnpm install 32 | 33 | - name: Build 34 | run: pnpm build 35 | 36 | - name: Typecheck 37 | run: pnpm typecheck 38 | 39 | - name: Lint 40 | run: pnpm lint 41 | 42 | - name: Test 43 | run: pnpm test 44 | -------------------------------------------------------------------------------- /test/globals.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { Tinypool } from 'tinypool' 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 6 | 7 | describe.each(['worker_threads', 'child_process'] as const)('%s', (runtime) => { 8 | test("doesn't hang when process is overwritten", async () => { 9 | const pool = createPool({ runtime }) 10 | 11 | const result = await pool.run(` 12 | (async () => { 13 | return new Promise(resolve => { 14 | globalThis.process = { exit: resolve }; 15 | process.exit("exit() from overwritten process"); 16 | }); 17 | })(); 18 | `) 19 | expect(result).toBe('exit() from overwritten process') 20 | }) 21 | }) 22 | 23 | function createPool(options: Partial) { 24 | const pool = new Tinypool({ 25 | filename: path.resolve(__dirname, 'fixtures/eval.js'), 26 | minThreads: 1, 27 | maxThreads: 1, 28 | ...options, 29 | }) 30 | 31 | return pool 32 | } 33 | -------------------------------------------------------------------------------- /test/async-context.test.ts: -------------------------------------------------------------------------------- 1 | import { createHook, executionAsyncId } from 'node:async_hooks' 2 | import { Tinypool } from 'tinypool' 3 | import { dirname, resolve } from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | test('postTask() calls the correct async hooks', async () => { 9 | let taskId: number 10 | let initCalls = 0 11 | let beforeCalls = 0 12 | let afterCalls = 0 13 | let resolveCalls = 0 14 | 15 | const hook = createHook({ 16 | init(id, type) { 17 | if (type === 'Tinypool.Task') { 18 | initCalls++ 19 | taskId = id 20 | } 21 | }, 22 | before(id) { 23 | if (id === taskId) beforeCalls++ 24 | }, 25 | after(id) { 26 | if (id === taskId) afterCalls++ 27 | }, 28 | promiseResolve() { 29 | if (executionAsyncId() === taskId) resolveCalls++ 30 | }, 31 | }) 32 | hook.enable() 33 | 34 | const pool = new Tinypool({ 35 | filename: resolve(__dirname, 'fixtures/eval.js'), 36 | }) 37 | 38 | await pool.run('42') 39 | 40 | hook.disable() 41 | expect(initCalls).toBe(1) 42 | expect(beforeCalls).toBe(1) 43 | expect(afterCalls).toBe(1) 44 | expect(resolveCalls).toBe(1) 45 | }) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 James M Snell and the Piscina contributors 4 | 5 | Piscina contributors listed at https://github.com/jasnell/piscina#the-team and 6 | in the README file. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Piscina is an OPEN Open Source Project 2 | 3 | ## What? 4 | 5 | Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. 6 | 7 | ## Rules 8 | 9 | There are a few basic ground-rules for contributors: 10 | 11 | 1. **No `--force` pushes** on `master` or modifying the Git history in any way after a PR has been merged. 12 | 1. **Non-master branches** ought to be used for ongoing work. 13 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors. 14 | 1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 15 | 1. Contributors should attempt to adhere to the prevailing code-style. 16 | 1. 100% code coverage 17 | 1. Semantic Versioning is used. 18 | 19 | ## Releases 20 | 21 | Declaring formal releases remains the prerogative of the project maintainer. 22 | 23 | ## Changes to this arrangement 24 | 25 | This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. 26 | 27 | ----------------------------------------- 28 | -------------------------------------------------------------------------------- /test/idle-timeout.test.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'node:util' 2 | import { dirname, resolve } from 'node:path' 3 | import { Tinypool } from 'tinypool' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | const delay = promisify(setTimeout) 8 | test('idle timeout will let go of threads early', async () => { 9 | const pool = new Tinypool({ 10 | filename: resolve(__dirname, 'fixtures/wait-for-others.js'), 11 | idleTimeout: 500, 12 | minThreads: 1, 13 | maxThreads: 2, 14 | }) 15 | 16 | expect(pool.threads.length).toBe(1) 17 | const buffer = new Int32Array(new SharedArrayBuffer(4)) 18 | 19 | const firstTasks = [pool.run([buffer, 2]), pool.run([buffer, 2])] 20 | expect(pool.threads.length).toBe(2) 21 | 22 | const earlyThreadIds = await Promise.all(firstTasks) 23 | expect(pool.threads.length).toBe(2) 24 | 25 | await delay(2000) 26 | expect(pool.threads.length).toBe(1) 27 | 28 | const secondTasks = [pool.run([buffer, 4]), pool.run([buffer, 4])] 29 | expect(pool.threads.length).toBe(2) 30 | 31 | const lateThreadIds = await Promise.all(secondTasks) 32 | 33 | // One thread should have been idle in between and exited, one should have 34 | // been reused. 35 | expect(earlyThreadIds.length).toBe(2) 36 | expect(lateThreadIds.length).toBe(2) 37 | expect(new Set([...earlyThreadIds, ...lateThreadIds]).size).toBe(3) 38 | }) 39 | -------------------------------------------------------------------------------- /test/worker-stdio.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { stripVTControlCharacters } from 'node:util' 4 | import { Tinypool } from 'tinypool' 5 | 6 | const runtimes = ['worker_threads', 'child_process'] as const 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 8 | 9 | test.each(runtimes)( 10 | "worker's stdout and stderr are piped to main thread when { runtime: '%s' }", 11 | async (runtime) => { 12 | const pool = createPool({ 13 | runtime, 14 | minThreads: 1, 15 | maxThreads: 1, 16 | }) 17 | 18 | const getStdout = captureStandardStream('stdout') 19 | const getStderr = captureStandardStream('stderr') 20 | 21 | await pool.run({}) 22 | 23 | const stdout = getStdout() 24 | const stderr = getStderr() 25 | 26 | expect(stdout).toMatch('Worker message') 27 | 28 | expect(stderr).toMatch('Worker error') 29 | } 30 | ) 31 | 32 | function createPool(options: Partial) { 33 | const pool = new Tinypool({ 34 | filename: path.resolve(__dirname, 'fixtures/stdio.mjs'), 35 | minThreads: 1, 36 | maxThreads: 1, 37 | ...options, 38 | }) 39 | 40 | return pool 41 | } 42 | 43 | function captureStandardStream(type: 'stdout' | 'stderr') { 44 | const spy = vi.fn() 45 | 46 | // eslint-disable-next-line @typescript-eslint/unbound-method 47 | const original = process[type].write 48 | process[type].write = spy 49 | 50 | return function collect() { 51 | process[type].write = original 52 | return stripVTControlCharacters( 53 | spy.mock.calls.map((call) => call[0]).join('') 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release-type: 7 | type: choice 8 | description: Type of the release 9 | options: 10 | - patch 11 | - minor 12 | - major 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | jobs: 19 | publish: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - uses: pnpm/action-setup@v2 27 | 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 22 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | # OICD requires updated npm even when pnpm is used 34 | - name: Update npm 35 | run: | 36 | npm --version 37 | npm install -g npm@latest 38 | npm --version 39 | 40 | - name: Install Dependencies 41 | run: pnpm install 42 | 43 | - name: Build 44 | run: pnpm build 45 | 46 | - name: Typecheck 47 | run: pnpm typecheck 48 | 49 | - name: Lint 50 | run: pnpm lint 51 | 52 | - name: Test 53 | run: pnpm test 54 | 55 | - name: Configure github-actions git 56 | run: | 57 | git config --global user.name 'github-actions' 58 | git config --global user.email 'github-actions@users.noreply.github.com' 59 | 60 | - name: Bump version 61 | run: pnpm version ${{ github.event.inputs.release-type }} 62 | 63 | - name: Push release tag 64 | run: git push origin main --follow-tags 65 | 66 | - name: Publish to npm 67 | run: pnpm publish 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tinypool", 3 | "type": "module", 4 | "version": "2.0.0", 5 | "packageManager": "pnpm@9.0.6", 6 | "description": "A minimal and tiny Node.js Worker Thread Pool implementation, a fork of piscina, but with fewer features", 7 | "license": "MIT", 8 | "homepage": "https://github.com/tinylibs/tinypool#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/tinylibs/tinypool.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/tinylibs/tinypool/issues" 15 | }, 16 | "keywords": [ 17 | "fast", 18 | "worker threads", 19 | "thread pool" 20 | ], 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.ts", 24 | "default": "./dist/index.js" 25 | }, 26 | "./package.json": "./package.json" 27 | }, 28 | "main": "./dist/index.js", 29 | "module": "./dist/index.js", 30 | "types": "./dist/index.d.ts", 31 | "files": [ 32 | "dist" 33 | ], 34 | "engines": { 35 | "node": "^20.0.0 || >=22.0.0" 36 | }, 37 | "scripts": { 38 | "test": "vitest", 39 | "bench": "vitest bench", 40 | "dev": "tsdown --watch ./src", 41 | "build": "tsdown", 42 | "publish": "clean-publish", 43 | "lint": "eslint --max-warnings=0", 44 | "typecheck": "tsc --noEmit" 45 | }, 46 | "devDependencies": { 47 | "@types/node": "^20.12.8", 48 | "clean-publish": "^3.4.4", 49 | "eslint": "^9.4.0", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-prettier": "^5.1.3", 52 | "eslint-plugin-unicorn": "^53.0.0", 53 | "prettier": "^3.3.2", 54 | "tsdown": "^0.11.3", 55 | "typescript": "^5.4.5", 56 | "typescript-eslint": "^7.13.0", 57 | "vite": "^5.2.11", 58 | "vitest": "^4.0.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/options.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from 'vitest' 2 | 3 | let Tinypool: typeof import('tinypool').default 4 | const cpuCount = vi.hoisted(() => 100) 5 | 6 | beforeAll(async () => { 7 | vi.resetModules() 8 | Tinypool = (await import('tinypool')).default 9 | }) 10 | 11 | test('fractional thread limits can be set', async () => { 12 | const min = 0.5 13 | const max = 0.75 14 | const p = new Tinypool({ 15 | minThreads: min, 16 | maxThreads: max, 17 | }) 18 | 19 | expect(p.options.minThreads).toBe(cpuCount * min) 20 | expect(p.options.maxThreads).toBe(cpuCount * max) 21 | }) 22 | 23 | test('fractional thread limits result is 1 for very low fractions', async () => { 24 | const min = 0.00005 25 | const max = 0.00006 26 | const p = new Tinypool({ 27 | minThreads: min, 28 | maxThreads: max, 29 | }) 30 | 31 | expect(p.options.minThreads).toBe(1) 32 | expect(p.options.maxThreads).toBe(1) 33 | }) 34 | 35 | test('fractional thread limits in the wrong order throw an error', async () => { 36 | expect(() => { 37 | new Tinypool({ 38 | minThreads: 0.75, 39 | maxThreads: 0.25, 40 | }) 41 | }).toThrow() 42 | expect(() => { 43 | new Tinypool({ 44 | minThreads: 0.75, 45 | maxThreads: 1, 46 | }) 47 | }).toThrow() 48 | }) 49 | 50 | vi.mock(import('node:os'), async (importOriginal) => { 51 | const original = await importOriginal() 52 | return { 53 | ...original, 54 | availableParallelism: () => cpuCount, 55 | } 56 | }) 57 | 58 | vi.mock(import('node:child_process'), async (importOriginal) => { 59 | const original = await importOriginal() 60 | return { 61 | ...original, 62 | default: { ...original.default, execSync: () => cpuCount as any }, 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /test/pool-destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { createHook } from 'node:async_hooks' 2 | import { dirname, resolve } from 'node:path' 3 | import { Tinypool } from 'tinypool' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | test('can destroy pool while tasks are running', async () => { 9 | const pool = new Tinypool({ 10 | filename: resolve(__dirname, 'fixtures/eval.js'), 11 | }) 12 | setImmediate(() => void pool.destroy()) 13 | await expect(pool.run('while(1){}')).rejects.toThrow( 14 | /Terminating worker thread/ 15 | ) 16 | }) 17 | 18 | test('destroy after initializing should work (#43)', async () => { 19 | const pool = new Tinypool({ 20 | filename: resolve(__dirname, 'fixtures/sleep.js'), 21 | isolateWorkers: true, 22 | }) 23 | 24 | const promise = expect(pool.run({})).rejects.toThrow( 25 | /Terminating worker thread/ 26 | ) 27 | 28 | setImmediate(() => void pool.destroy()) 29 | await promise 30 | }) 31 | 32 | test('cleans up async resources', async () => { 33 | let onCleanup = () => {} 34 | const waitForCleanup = new Promise((r) => (onCleanup = r)) 35 | const timeout = setTimeout(() => { 36 | throw new Error('Timeout waiting for async resource destroying') 37 | }, 2_000).unref() 38 | 39 | const ids = new Set() 40 | 41 | const hook = createHook({ 42 | init(asyncId, type) { 43 | if (type === 'Tinypool') { 44 | ids.add(asyncId) 45 | } 46 | }, 47 | destroy(asyncId) { 48 | if (ids.has(asyncId)) { 49 | ids.delete(asyncId) 50 | onCleanup() 51 | clearTimeout(timeout) 52 | } 53 | }, 54 | }) 55 | hook.enable() 56 | 57 | const pool = new Tinypool({ 58 | filename: resolve(__dirname, 'fixtures/eval.js'), 59 | maxThreads: 1, 60 | minThreads: 1, 61 | }) 62 | 63 | await pool.run('42') 64 | 65 | expect(ids.size).toBe(1) 66 | 67 | await pool.destroy() 68 | await waitForCleanup 69 | 70 | expect(ids.size).toBe(0) 71 | hook.disable() 72 | }) 73 | -------------------------------------------------------------------------------- /test/teardown.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { Tinypool } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | import { MessageChannel } from 'node:worker_threads' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | test('isolated workers call teardown on worker recycle', async () => { 9 | const pool = new Tinypool({ 10 | filename: resolve(__dirname, 'fixtures/teardown.mjs'), 11 | minThreads: 1, 12 | maxThreads: 1, 13 | isolateWorkers: true, 14 | teardown: 'namedTeardown', 15 | }) 16 | 17 | for (const _ of [1, 2, 3, 4, 5]) { 18 | const { port1, port2 } = new MessageChannel() 19 | const promise = new Promise((resolve) => port2.on('message', resolve)) 20 | 21 | const output = await pool.run({ port: port1 }, { transferList: [port1] }) 22 | expect(output).toBe('Output of task #1') 23 | 24 | await expect(promise).resolves.toBe('Teardown of task #1') 25 | } 26 | }) 27 | 28 | test('non-isolated workers call teardown on worker recycle', async () => { 29 | const pool = new Tinypool({ 30 | filename: resolve(__dirname, 'fixtures/teardown.mjs'), 31 | minThreads: 1, 32 | maxThreads: 1, 33 | isolateWorkers: false, 34 | teardown: 'namedTeardown', 35 | }) 36 | 37 | function unexpectedTeardown(message: string) { 38 | assert.fail( 39 | `Teardown should not have been called yet. Received "${message}"` 40 | ) 41 | } 42 | 43 | const { port1, port2 } = new MessageChannel() 44 | 45 | for (const index of [1, 2, 3, 4, 5]) { 46 | port2.on('message', unexpectedTeardown) 47 | 48 | const transferList = index === 1 ? [port1] : [] 49 | 50 | const output = await pool.run({ port: transferList[0] }, { transferList }) 51 | expect(output).toBe(`Output of task #${index}`) 52 | } 53 | 54 | port2.off('message', unexpectedTeardown) 55 | const promise = new Promise((resolve) => port2.on('message', resolve)) 56 | 57 | await pool.destroy() 58 | await expect(promise).resolves.toMatchInlineSnapshot(`"Teardown of task #5"`) 59 | }) 60 | -------------------------------------------------------------------------------- /src/runtime/thread-worker.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { type TransferListItem, Worker } from 'node:worker_threads' 3 | import { type TinypoolWorker, type TinypoolChannel } from '../common' 4 | 5 | export default class ThreadWorker implements TinypoolWorker { 6 | name = 'ThreadWorker' 7 | runtime = 'worker_threads' 8 | thread!: Worker 9 | threadId!: number 10 | channel?: TinypoolChannel 11 | 12 | initialize(options: Parameters[0]) { 13 | this.thread = new Worker( 14 | fileURLToPath(import.meta.url + '/../entry/worker.js'), 15 | options 16 | ) 17 | this.threadId = this.thread.threadId 18 | } 19 | 20 | async terminate() { 21 | const output = await this.thread.terminate() 22 | 23 | this.channel?.onClose?.() 24 | 25 | return output 26 | } 27 | 28 | postMessage(message: any, transferListItem?: Readonly) { 29 | return this.thread.postMessage(message, transferListItem) 30 | } 31 | 32 | on(event: string, callback: (...args: any[]) => void) { 33 | return this.thread.on(event, callback) 34 | } 35 | 36 | once(event: string, callback: (...args: any[]) => void) { 37 | return this.thread.once(event, callback) 38 | } 39 | 40 | emit(event: string, ...data: any[]) { 41 | return this.thread.emit(event, ...data) 42 | } 43 | 44 | ref() { 45 | return this.thread.ref() 46 | } 47 | 48 | unref() { 49 | return this.thread.unref() 50 | } 51 | 52 | setChannel(channel: TinypoolChannel) { 53 | if (channel.onMessage) { 54 | throw new Error( 55 | "{ runtime: 'worker_threads' } doesn't support channel.onMessage. Use transferListItem for listening to messages instead." 56 | ) 57 | } 58 | 59 | if (channel.postMessage) { 60 | throw new Error( 61 | "{ runtime: 'worker_threads' } doesn't support channel.postMessage. Use transferListItem for sending to messages instead." 62 | ) 63 | } 64 | 65 | // Previous channel exists in non-isolated runs 66 | if (this.channel && this.channel !== channel) { 67 | this.channel.onClose?.() 68 | } 69 | 70 | this.channel = channel 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/termination.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { Tinypool } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | const cleanups: (() => Promise)[] = [] 7 | 8 | afterEach(async () => { 9 | await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) 10 | }) 11 | 12 | test('termination timeout throws when worker does not terminate in time', async () => { 13 | const pool = new Tinypool({ 14 | filename: resolve(__dirname, 'fixtures/sleep.js'), 15 | terminateTimeout: 10, 16 | minThreads: 1, 17 | maxThreads: 2, 18 | isolateWorkers: true, 19 | }) 20 | 21 | expect(pool.threads.length).toBe(1) 22 | 23 | const worker = pool.threads[0] 24 | expect(worker).toBeTruthy() 25 | 26 | cleanups.push(worker!.terminate.bind(worker)) 27 | worker!.terminate = () => new Promise(() => {}) 28 | 29 | await expect(pool.run('default')).rejects.toThrowError( 30 | 'Failed to terminate worker' 31 | ) 32 | }) 33 | 34 | test('writing to terminating worker does not crash', async () => { 35 | const listeners: ((msg: any) => void)[] = [] 36 | 37 | const pool = new Tinypool({ 38 | runtime: 'child_process', 39 | filename: resolve(__dirname, 'fixtures/sleep.js'), 40 | minThreads: 1, 41 | maxThreads: 1, 42 | }) 43 | 44 | await pool.run( 45 | {}, 46 | { 47 | channel: { 48 | onMessage: (listener) => listeners.push(listener), 49 | postMessage: () => {}, 50 | }, 51 | } 52 | ) 53 | 54 | const destroyed = pool.destroy() 55 | listeners.forEach((listener) => listener('Hello from main thread')) 56 | 57 | await destroyed 58 | }) 59 | 60 | test('recycling workers while closing pool does not crash', async () => { 61 | const pool = new Tinypool({ 62 | runtime: 'child_process', 63 | filename: resolve(__dirname, 'fixtures/nested-pool.mjs'), 64 | isolateWorkers: true, 65 | minThreads: 1, 66 | maxThreads: 1, 67 | }) 68 | 69 | await Promise.all( 70 | (Array(10) as (() => Promise)[]) 71 | .fill(() => pool.run({})) 72 | .map((fn) => fn()) 73 | ) 74 | 75 | await pool.destroy() 76 | }) 77 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | import eslint from '@eslint/js' 3 | import tseslint from 'typescript-eslint' 4 | import eslintPluginUnicorn from 'eslint-plugin-unicorn' 5 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' 6 | 7 | const tsconfig = JSON.parse(readFileSync('./tsconfig.json', 'utf8')) 8 | 9 | export default defineConfig([ 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked.map((config) => ({ 13 | ...config, 14 | files: tsconfig.include, 15 | })), 16 | { 17 | files: tsconfig.include, 18 | languageOptions: { 19 | parserOptions: { 20 | project: true, 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | }, 25 | { 26 | languageOptions: { 27 | globals: { 28 | process: 'readonly', 29 | }, 30 | }, 31 | plugins: { unicorn: eslintPluginUnicorn }, 32 | rules: { 33 | 'unicorn/prefer-node-protocol': 'error', 34 | '@typescript-eslint/no-unused-vars': [ 35 | 'error', 36 | { varsIgnorePattern: '^_' }, 37 | ], 38 | '@typescript-eslint/consistent-type-imports': [ 39 | 'error', 40 | { 41 | prefer: 'type-imports', 42 | fixStyle: 'inline-type-imports', 43 | disallowTypeAnnotations: false, 44 | }, 45 | ], 46 | 47 | // TODO: Nice-to-have rules 48 | '@typescript-eslint/no-unsafe-argument': 'off', 49 | '@typescript-eslint/no-unsafe-assignment': 'off', 50 | '@typescript-eslint/no-explicit-any': 'off', 51 | '@typescript-eslint/no-unsafe-member-access': 'off', 52 | '@typescript-eslint/no-unsafe-return': 'off', 53 | '@typescript-eslint/no-redundant-type-constituents': 'off', 54 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', 55 | '@typescript-eslint/no-namespace': 'off', 56 | }, 57 | }, 58 | { 59 | files: ['**/*.test.ts'], 60 | rules: { 61 | '@typescript-eslint/require-await': 'off', 62 | }, 63 | }, 64 | { ignores: ['dist'] }, 65 | eslintPluginPrettierRecommended, 66 | ]) 67 | 68 | /** @param config {import('eslint').Linter.Config} */ 69 | function defineConfig(config) { 70 | return config 71 | } 72 | -------------------------------------------------------------------------------- /benchmark/isolate-benchmark.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench } from 'vitest' 2 | import { cpus } from 'node:os' 3 | import { Worker } from 'node:worker_threads' 4 | import { fork } from 'node:child_process' 5 | import Tinypool, { type Options } from '../dist/index' 6 | 7 | const THREADS = cpus().length - 1 8 | const ROUNDS = THREADS * 10 9 | const ITERATIONS = 100 10 | 11 | for (const runtime of [ 12 | 'worker_threads', 13 | 'child_process', 14 | ] as Options['runtime'][]) { 15 | bench( 16 | `Tinypool { runtime: '${runtime}' }`, 17 | async () => { 18 | const pool = new Tinypool({ 19 | runtime, 20 | filename: './benchmark/fixtures/add.mjs', 21 | isolateWorkers: true, 22 | minThreads: THREADS, 23 | maxThreads: THREADS, 24 | }) 25 | 26 | await Promise.all( 27 | Array(ROUNDS) 28 | .fill(0) 29 | .map(() => pool.run({ a: 1, b: 2 })) 30 | ) 31 | 32 | await pool.destroy() 33 | }, 34 | { iterations: ITERATIONS } 35 | ) 36 | } 37 | 38 | for (const { task, name } of [ 39 | { name: 'worker_threads', task: workerThreadTask }, 40 | { name: 'child_process', task: childProcessTask }, 41 | ] as const) { 42 | bench( 43 | `node:${name}`, 44 | async () => { 45 | const pool = Array(ROUNDS).fill(task) 46 | 47 | await Promise.all( 48 | Array(THREADS) 49 | .fill(execute) 50 | .map((_task) => _task()) 51 | ) 52 | 53 | async function execute() { 54 | const _task = pool.shift() 55 | 56 | if (_task) { 57 | await _task() 58 | return execute() 59 | } 60 | } 61 | }, 62 | { iterations: ITERATIONS } 63 | ) 64 | } 65 | 66 | async function workerThreadTask() { 67 | const worker = new Worker('./benchmark/fixtures/add-worker.mjs') 68 | const onMessage = new Promise((resolve, reject) => 69 | worker.on('message', (sum) => (sum === 3 ? resolve() : reject('Not 3'))) 70 | ) 71 | 72 | worker.postMessage({ a: 1, b: 2 }) 73 | await onMessage 74 | 75 | await worker.terminate() 76 | } 77 | 78 | async function childProcessTask() { 79 | const subprocess = fork('./benchmark/fixtures/add-process.mjs') 80 | 81 | const onExit = new Promise((resolve) => subprocess.on('exit', resolve)) 82 | const onMessage = new Promise((resolve, reject) => 83 | subprocess.on('message', (sum) => (sum === 3 ? resolve() : reject('Not 3'))) 84 | ) 85 | 86 | subprocess.send({ a: 1, b: 2 }) 87 | await onMessage 88 | 89 | subprocess.kill() 90 | await onExit 91 | } 92 | -------------------------------------------------------------------------------- /src/entry/utils.ts: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'node:url' 2 | 3 | // Get `import(x)` as a function that isn't transpiled to `require(x)` by 4 | // TypeScript for dual ESM/CJS support. 5 | // Load this lazily, so that there is no warning about the ESM loader being 6 | // experimental (on Node v12.x) until we actually try to use it. 7 | let importESMCached: (specifier: string) => Promise | undefined 8 | 9 | function getImportESM() { 10 | if (importESMCached === undefined) { 11 | // eslint-disable-next-line @typescript-eslint/no-implied-eval -- intentional 12 | importESMCached = new Function( 13 | 'specifier', 14 | 'return import(specifier)' 15 | ) as typeof importESMCached 16 | } 17 | return importESMCached 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/ban-types -- Intentional general type 21 | type Handler = Function 22 | const handlerCache: Map = new Map() 23 | 24 | // Look up the handler function that we call when a task is posted. 25 | // This is either going to be "the" export from a file, or the default export. 26 | export async function getHandler( 27 | filename: string, 28 | name: string 29 | ): Promise { 30 | let handler = handlerCache.get(`${filename}/${name}`) 31 | if (handler !== undefined) { 32 | return handler 33 | } 34 | 35 | try { 36 | const handlerModule = await import(filename) 37 | 38 | // Check if the default export is an object, because dynamic import 39 | // resolves with `{ default: { default: [Function] } }` for CJS modules. 40 | handler = 41 | (typeof handlerModule.default !== 'function' && handlerModule.default) || 42 | handlerModule 43 | 44 | if (typeof handler !== 'function') { 45 | handler = await (handler as any)[name] 46 | } 47 | } catch { 48 | // Ignore error and retry import 49 | } 50 | if (typeof handler !== 'function') { 51 | handler = await getImportESM()(pathToFileURL(filename).href) 52 | if (typeof handler !== 'function') { 53 | handler = await (handler as any)[name] 54 | } 55 | } 56 | if (typeof handler !== 'function') { 57 | return null 58 | } 59 | 60 | // Limit the handler cache size. This should not usually be an issue and is 61 | // only provided for pathological cases. 62 | if (handlerCache.size > 1000) { 63 | const [handler] = handlerCache 64 | const key = handler![0] 65 | handlerCache.delete(key) 66 | } 67 | 68 | handlerCache.set(`${filename}/${name}`, handler) 69 | return handler 70 | } 71 | 72 | export function throwInNextTick(error: Error) { 73 | process.nextTick(() => { 74 | throw error 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /test/isolation.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { Tinypool } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | describe.each(['worker_threads', 'child_process'] as const)('%s', (runtime) => { 8 | test('idle workers can be recycled', async () => { 9 | const pool = new Tinypool({ 10 | runtime, 11 | filename: resolve(__dirname, 'fixtures/sleep.js'), 12 | minThreads: 4, 13 | maxThreads: 4, 14 | isolateWorkers: false, 15 | }) 16 | 17 | function getThreadIds() { 18 | return pool.threads.map((thread) => thread.threadId).sort((a, b) => a - b) 19 | } 20 | 21 | expect(pool.threads).toHaveLength(4) 22 | const initialThreadIds = getThreadIds() 23 | 24 | await Promise.all(times(4)(() => pool.run({}))) 25 | expect(getThreadIds()).toStrictEqual(initialThreadIds) 26 | 27 | await pool.recycleWorkers() 28 | expect(pool.threads).toHaveLength(4) 29 | 30 | const newThreadIds = getThreadIds() 31 | initialThreadIds.forEach((id) => expect(newThreadIds).not.toContain(id)) 32 | 33 | await Promise.all(times(4)(() => pool.run({}))) 34 | initialThreadIds.forEach((id) => expect(newThreadIds).not.toContain(id)) 35 | expect(getThreadIds()).toStrictEqual(newThreadIds) 36 | }) 37 | 38 | test('running workers can recycle after task execution finishes', async () => { 39 | const pool = new Tinypool({ 40 | runtime, 41 | filename: resolve(__dirname, 'fixtures/sleep.js'), 42 | minThreads: 4, 43 | maxThreads: 4, 44 | isolateWorkers: false, 45 | }) 46 | 47 | function getThreadIds() { 48 | return pool.threads.map((thread) => thread.threadId).sort((a, b) => a - b) 49 | } 50 | 51 | expect(pool.threads).toHaveLength(4) 52 | const initialThreadIds = getThreadIds() 53 | 54 | const tasks = [ 55 | ...times(2)(() => pool.run({ time: 1 })), 56 | ...times(2)(() => pool.run({ time: 2000 })), 57 | ] 58 | 59 | // Wait for first two tasks to finish 60 | await Promise.all(tasks.slice(0, 2)) 61 | 62 | await pool.recycleWorkers() 63 | const threadIds = getThreadIds() 64 | 65 | // Idle workers should have been recycled immediately 66 | // Running workers should not have recycled yet 67 | expect(intersection(threadIds, initialThreadIds)).toHaveLength(2) 68 | 69 | await Promise.all(tasks) 70 | 71 | // All workers should have recycled now 72 | const newThreadIds = getThreadIds() 73 | initialThreadIds.forEach((id) => expect(newThreadIds).not.toContain(id)) 74 | }) 75 | }) 76 | 77 | function times(count: number) { 78 | return function run(fn: () => T): T[] { 79 | return Array(count).fill(0).map(fn) 80 | } 81 | } 82 | 83 | function intersection(a: T[], b: T[]) { 84 | return a.filter((value) => b.includes(value)) 85 | } 86 | -------------------------------------------------------------------------------- /test/uncaught-exception-from-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve, sep } from 'node:path' 2 | import { Tinypool } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | import { once } from 'node:events' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | test('uncaught exception resets Worker', async () => { 9 | const pool = new Tinypool({ 10 | filename: resolve(__dirname, 'fixtures/eval.js'), 11 | }) 12 | 13 | await expect(pool.run('throw new Error("not_caught")')).rejects.toThrow( 14 | /not_caught/ 15 | ) 16 | }) 17 | 18 | test('uncaught exception in immediate resets Worker', async () => { 19 | const pool = new Tinypool({ 20 | filename: resolve(__dirname, 'fixtures/eval.js'), 21 | }) 22 | 23 | await expect( 24 | pool.run(` 25 | setImmediate(() => { throw new Error("not_caught") }); 26 | new Promise(() => {}) // act as if we were doing some work 27 | `) 28 | ).rejects.toThrow(/not_caught/) 29 | }) 30 | 31 | test('uncaught exception in immediate after task yields error event', async () => { 32 | const pool = new Tinypool({ 33 | filename: resolve(__dirname, 'fixtures/eval.js'), 34 | maxThreads: 1, 35 | useAtomics: false, 36 | }) 37 | 38 | const errorEvent: Promise = once(pool, 'error') 39 | 40 | const taskResult = pool.run(` 41 | setTimeout(() => { throw new Error("not_caught") }, 500); 42 | 42 43 | `) 44 | 45 | expect(await taskResult).toBe(42) 46 | 47 | // Hack a bit to make sure we get the 'exit'/'error' events. 48 | expect(pool.threads.length).toBe(1) 49 | pool.threads[0]!.ref?.() 50 | 51 | // This is the main aassertion here. 52 | expect((await errorEvent)[0]!.message).toEqual('not_caught') 53 | }) 54 | 55 | test('using parentPort is treated as an error', async () => { 56 | const pool = new Tinypool({ 57 | filename: resolve(__dirname, 'fixtures/eval.js'), 58 | }) 59 | await expect( 60 | pool.run(` 61 | (async () => { 62 | console.log(); 63 | const parentPort = (await import('worker_threads')).parentPort; 64 | parentPort.postMessage("some message"); 65 | new Promise(() => {}) /* act as if we were doing some work */ 66 | })() 67 | `) 68 | ).rejects.toThrow(/Unexpected message on Worker: 'some message'/) 69 | }) 70 | 71 | test('no named handler found from worker', async () => { 72 | const pool = new Tinypool({ 73 | filename: resolve(__dirname, 'fixtures/eval.js'), 74 | }) 75 | 76 | let errorMessage = 'Worker did not throw error' 77 | 78 | try { 79 | await pool.run('', { name: 'someHandler' }) 80 | } catch (error) { 81 | errorMessage = error instanceof Error ? error.message : String(error) 82 | } 83 | 84 | expect( 85 | errorMessage.replace(process.cwd(), '').replaceAll(sep, '/') 86 | ).toMatchInlineSnapshot( 87 | `"No handler function "someHandler" exported from "/test/fixtures/eval.js""` 88 | ) 89 | }) 90 | -------------------------------------------------------------------------------- /test/atomic.test.ts: -------------------------------------------------------------------------------- 1 | import Tinypool from 'tinypool' 2 | import { dirname, resolve } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | test('coverage test for Atomics optimization', async () => { 8 | const pool = new Tinypool({ 9 | filename: resolve(__dirname, 'fixtures/notify-then-sleep-or.js'), 10 | minThreads: 2, 11 | maxThreads: 2, 12 | concurrentTasksPerWorker: 2, 13 | }) 14 | 15 | const tasks = [] 16 | let v: number 17 | 18 | // Post 4 tasks, and wait for all of them to be ready. 19 | const i32array = new Int32Array(new SharedArrayBuffer(4)) 20 | for (let index = 0; index < 4; index++) { 21 | tasks.push(pool.run({ i32array, index })) 22 | } 23 | 24 | // Wait for 2 tasks to enter 'wait' state. 25 | do { 26 | v = Atomics.load(i32array, 0) 27 | if (popcount8(v) >= 2) break 28 | Atomics.wait(i32array, 0, v) 29 | } while (true) // eslint-disable-line no-constant-condition -- intentional 30 | 31 | // The check above could also be !== 2 but it's hard to get things right 32 | // sometimes and this gives us a nice assertion. Basically, at this point 33 | // exactly 2 tasks should be in Atomics.wait() state. 34 | expect(popcount8(v)).toBe(2) 35 | // Wake both tasks up as simultaneously as possible. The other 2 tasks should 36 | // then start executing. 37 | Atomics.store(i32array, 0, 0) 38 | Atomics.notify(i32array, 0, Infinity) 39 | 40 | // Wait for the other 2 tasks to enter 'wait' state. 41 | do { 42 | v = Atomics.load(i32array, 0) 43 | if (popcount8(v) >= 2) break 44 | Atomics.wait(i32array, 0, v) 45 | } while (true) // eslint-disable-line no-constant-condition -- intentional 46 | 47 | // At this point, the first two tasks are definitely finished and have 48 | // definitely posted results back to the main thread, and the main thread 49 | // has definitely not received them yet, meaning that the Atomics check will 50 | // be used. Making sure that that works is the point of this test. 51 | 52 | // Wake up the remaining 2 tasks in order to make sure that the test finishes. 53 | // Do the same consistency check beforehand as above. 54 | expect(popcount8(v)).toBe(2) 55 | Atomics.store(i32array, 0, 0) 56 | Atomics.notify(i32array, 0, Infinity) 57 | 58 | await Promise.all(tasks) 59 | }) 60 | 61 | // Inefficient but straightforward 8-bit popcount 62 | function popcount8(v: number): number { 63 | v &= 0xff 64 | if (v & 0b11110000) return popcount8(v >>> 4) + popcount8(v & 0xb00001111) 65 | if (v & 0b00001100) return popcount8(v >>> 2) + popcount8(v & 0xb00000011) 66 | if (v & 0b00000010) return popcount8(v >>> 1) + popcount8(v & 0xb00000001) 67 | return v 68 | } 69 | 70 | test('avoids unbounded recursion', async () => { 71 | const pool = new Tinypool({ 72 | filename: resolve(__dirname, 'fixtures/simple-isworkerthread.js'), 73 | minThreads: 2, 74 | maxThreads: 2, 75 | }) 76 | 77 | const tasks = [] 78 | for (let i = 1; i <= 10000; i++) { 79 | tasks.push(pool.run(null)) 80 | } 81 | 82 | await Promise.all(tasks) 83 | }) 84 | -------------------------------------------------------------------------------- /test/resource-limits.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { Tinypool } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | test('resourceLimits causes task to reject', async () => { 8 | const worker = new Tinypool({ 9 | filename: resolve(__dirname, 'fixtures/resource-limits.js'), 10 | resourceLimits: { 11 | maxOldGenerationSizeMb: 4, 12 | maxYoungGenerationSizeMb: 2, 13 | codeRangeSizeMb: 4, 14 | }, 15 | }) 16 | worker.on('error', () => { 17 | // Ignore any additional errors that may occur. 18 | // This may happen because when the Worker is 19 | // killed a new worker is created that may hit 20 | // the memory limits immediately. When that 21 | // happens, there is no associated Promise to 22 | // reject so we emit an error event instead. 23 | // We don't care so much about that here. We 24 | // could potentially avoid the issue by setting 25 | // higher limits above but rather than try to 26 | // guess at limits that may work consistently, 27 | // let's just ignore the additional error for 28 | // now. 29 | }) 30 | const limits: any = worker.options.resourceLimits 31 | expect(limits.maxOldGenerationSizeMb).toBe(4) 32 | expect(limits.maxYoungGenerationSizeMb).toBe(2) 33 | expect(limits.codeRangeSizeMb).toBe(4) 34 | await expect(worker.run(null)).rejects.toThrow( 35 | /Worker terminated due to reaching memory limit: JS heap out of memory/ 36 | ) 37 | }) 38 | 39 | describe.each(['worker_threads', 'child_process'] as const)('%s', (runtime) => { 40 | test('worker is recycled after reaching maxMemoryLimitBeforeRecycle', async () => { 41 | const pool = new Tinypool({ 42 | filename: resolve(__dirname, 'fixtures/leak-memory.js'), 43 | maxMemoryLimitBeforeRecycle: 10_000_000, 44 | isolateWorkers: false, 45 | minThreads: 1, 46 | maxThreads: 1, 47 | runtime, 48 | }) 49 | 50 | const originalWorkerId = pool.threads[0]?.threadId 51 | expect(originalWorkerId).toBeGreaterThan(0) 52 | 53 | let finalThreadId = originalWorkerId 54 | let rounds = 0 55 | 56 | // This is just an estimate of how to leak "some" memory - it's not accurate. 57 | // Running 100 loops should be enough to make the worker reach memory limit and be recycled. 58 | // Use the `rounds` to make sure we don't reach the limit on the first round. 59 | for (const _ of Array(100).fill(0)) { 60 | await pool.run(10_000) 61 | 62 | if (pool.threads[0]) { 63 | finalThreadId = pool.threads[0].threadId 64 | } 65 | 66 | if (finalThreadId !== originalWorkerId) { 67 | break 68 | } 69 | 70 | rounds++ 71 | } 72 | 73 | // Test setup should not reach max memory on first round 74 | expect(rounds).toBeGreaterThan(1) 75 | 76 | // Thread should have been recycled 77 | expect(finalThreadId).not.toBe(originalWorkerId) 78 | }) 79 | 80 | test('recycled workers should not crash pool (regression)', async () => { 81 | const pool = new Tinypool({ 82 | filename: resolve(__dirname, 'fixtures/leak-memory.js'), 83 | maxMemoryLimitBeforeRecycle: 10, 84 | isolateWorkers: false, 85 | minThreads: 2, 86 | maxThreads: 2, 87 | runtime, 88 | }) 89 | 90 | // This should not crash the pool 91 | await Promise.all( 92 | Array(10) 93 | .fill(0) 94 | .map(() => pool.run(10_000)) 95 | ) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /src/entry/process.ts: -------------------------------------------------------------------------------- 1 | import { stderr, stdout } from '../utils' 2 | import { 3 | type ReadyMessage, 4 | type RequestMessage, 5 | type ResponseMessage, 6 | type StartupMessage, 7 | type TinypoolWorkerMessage, 8 | } from '../common' 9 | import { getHandler, throwInNextTick } from './utils' 10 | 11 | type IncomingMessage = 12 | | (StartupMessage & TinypoolWorkerMessage<'pool'>) 13 | | (RequestMessage & TinypoolWorkerMessage<'port'>) 14 | 15 | type OutgoingMessage = 16 | | (ReadyMessage & TinypoolWorkerMessage<'pool'>) 17 | | (ResponseMessage & TinypoolWorkerMessage<'port'>) 18 | 19 | process.__tinypool_state__ = { 20 | isChildProcess: true, 21 | isTinypoolWorker: true, 22 | workerData: null, 23 | workerId: Number(process.env.TINYPOOL_WORKER_ID), 24 | } 25 | 26 | const memoryUsage = process.memoryUsage.bind(process) 27 | const send = process.send!.bind(process) 28 | 29 | process.on('message', (message: IncomingMessage) => { 30 | // Message was not for port or pool 31 | // It's likely end-users own communication between main and worker 32 | if (!message || !message.__tinypool_worker_message__) return 33 | 34 | if (message.source === 'pool') { 35 | const { filename, name } = message 36 | 37 | ;(async function () { 38 | if (filename !== null) { 39 | await getHandler(filename, name) 40 | } 41 | 42 | send( 43 | { 44 | ready: true, 45 | source: 'pool', 46 | __tinypool_worker_message__: true, 47 | }, 48 | () => { 49 | // Ignore errors coming from closed channel 50 | } 51 | ) 52 | })().catch(throwInNextTick) 53 | 54 | return 55 | } 56 | 57 | if (message.source === 'port') { 58 | onMessage(message).catch(throwInNextTick) 59 | return 60 | } 61 | 62 | throw new Error(`Unexpected TinypoolWorkerMessage ${JSON.stringify(message)}`) 63 | }) 64 | 65 | async function onMessage(message: IncomingMessage & { source: 'port' }) { 66 | const { taskId, task, filename, name } = message 67 | let response: OutgoingMessage & Pick 68 | 69 | try { 70 | const handler = await getHandler(filename, name) 71 | if (handler === null) { 72 | throw new Error( 73 | `No handler function "${name}" exported from "${filename}"` 74 | ) 75 | } 76 | const result = await handler(task) 77 | response = { 78 | source: 'port', 79 | __tinypool_worker_message__: true, 80 | taskId, 81 | result, 82 | error: null, 83 | usedMemory: memoryUsage().heapUsed, 84 | } 85 | 86 | // If the task used e.g. console.log(), wait for the stream to drain 87 | // before potentially entering the `Atomics.wait()` loop, and before 88 | // returning the result so that messages will always be printed even 89 | // if the process would otherwise be ready to exit. 90 | if (stdout()?.writableLength! > 0) { 91 | await new Promise((resolve) => process.stdout.write('', resolve)) 92 | } 93 | if (stderr()?.writableLength! > 0) { 94 | await new Promise((resolve) => process.stderr.write('', resolve)) 95 | } 96 | } catch (error) { 97 | response = { 98 | source: 'port', 99 | __tinypool_worker_message__: true, 100 | taskId, 101 | result: null, 102 | error: serializeError(error), 103 | usedMemory: memoryUsage().heapUsed, 104 | } 105 | } 106 | 107 | send(response) 108 | } 109 | 110 | function serializeError(error: unknown) { 111 | if (error instanceof Error) { 112 | return { 113 | ...error, 114 | name: error.name, 115 | stack: error.stack, 116 | message: error.message, 117 | } 118 | } 119 | 120 | return String(error) 121 | } 122 | -------------------------------------------------------------------------------- /test/move.test.ts: -------------------------------------------------------------------------------- 1 | import { Tinypool, isMovable, markMovable, isTransferable } from 'tinypool' 2 | import { types } from 'node:util' 3 | import { MessageChannel, MessagePort } from 'node:worker_threads' 4 | import { dirname, resolve } from 'node:path' 5 | import { fileURLToPath } from 'node:url' 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)) 8 | 9 | const transferableSymbol = Tinypool.transferableSymbol as never 10 | const valueSymbol = Tinypool.valueSymbol as never 11 | 12 | test('Marking an object as movable works as expected', async () => { 13 | const obj: any = { 14 | get [transferableSymbol](): object { 15 | return {} 16 | }, 17 | get [valueSymbol](): object { 18 | return {} 19 | }, 20 | } 21 | expect(isTransferable(obj)).toBe(true) 22 | expect(!isMovable(obj)).toBe(true) // It's not movable initially 23 | markMovable(obj) 24 | expect(isMovable(obj)).toBe(true) // It is movable now 25 | }) 26 | 27 | test('Marking primitives and null works as expected', async () => { 28 | expect(Tinypool.move(null!)).toBe(null) 29 | expect(Tinypool.move(1 as any)).toBe(1) 30 | expect(Tinypool.move(false as any)).toBe(false) 31 | expect(Tinypool.move('test' as any)).toBe('test') 32 | }) 33 | 34 | test('Using Tinypool.move() returns a movable object', async () => { 35 | const obj: any = { 36 | get [transferableSymbol](): object { 37 | return {} 38 | }, 39 | get [valueSymbol](): object { 40 | return {} 41 | }, 42 | } 43 | expect(!isMovable(obj)).toBe(true) // It's not movable initially 44 | const movable = Tinypool.move(obj) 45 | expect(isMovable(movable)).toBe(true) // It is movable now 46 | }) 47 | 48 | test('Using ArrayBuffer works as expected', async () => { 49 | const ab = new ArrayBuffer(5) 50 | const movable = Tinypool.move(ab) 51 | expect(isMovable(movable)).toBe(true) 52 | expect(types.isAnyArrayBuffer(movable[valueSymbol])).toBe(true) 53 | expect(types.isAnyArrayBuffer(movable[transferableSymbol])).toBe(true) 54 | expect(movable[transferableSymbol]).toEqual(ab) 55 | }) 56 | 57 | test('Using TypedArray works as expected', async () => { 58 | const ab = new Uint8Array(5) 59 | const movable = Tinypool.move(ab) 60 | expect(isMovable(movable)).toBe(true) 61 | expect(types.isArrayBufferView(movable[valueSymbol])).toBe(true) 62 | expect(types.isAnyArrayBuffer(movable[transferableSymbol])).toBe(true) 63 | expect(movable[transferableSymbol]).toEqual(ab.buffer) 64 | }) 65 | 66 | test('Using MessagePort works as expected', async () => { 67 | const mc = new MessageChannel() 68 | const movable = Tinypool.move(mc.port1) 69 | expect(isMovable(movable)).toBe(true) 70 | expect((movable[valueSymbol] as unknown) instanceof MessagePort).toBe(true) 71 | expect((movable[transferableSymbol] as unknown) instanceof MessagePort).toBe( 72 | true 73 | ) 74 | expect(movable[transferableSymbol]).toEqual(mc.port1) 75 | }) 76 | 77 | test('Moving works', async () => { 78 | const pool = new Tinypool({ 79 | filename: resolve(__dirname, 'fixtures/move.js'), 80 | }) 81 | 82 | { 83 | const ab = new ArrayBuffer(10) 84 | const ret = await pool.run(Tinypool.move(ab)) 85 | expect(ab.byteLength).toBe(0) // It was moved 86 | expect(types.isAnyArrayBuffer(ret)).toBe(true) 87 | } 88 | 89 | { 90 | // Test with empty transferList 91 | const ab = new ArrayBuffer(10) 92 | const ret = await pool.run(Tinypool.move(ab), { transferList: [] }) 93 | expect(ab.byteLength).toBe(0) // It was moved 94 | expect(types.isAnyArrayBuffer(ret)).toBe(true) 95 | } 96 | 97 | { 98 | // Test with empty transferList 99 | const ab = new ArrayBuffer(10) 100 | const ret = await pool.run(Tinypool.move(ab)) 101 | expect(ab.byteLength).toBe(0) // It was moved 102 | expect(types.isAnyArrayBuffer(ret)).toBe(true) 103 | } 104 | 105 | { 106 | // Test with empty transferList 107 | const ab = new ArrayBuffer(10) 108 | const ret = await pool.run(Tinypool.move(ab), { transferList: [] }) 109 | expect(ab.byteLength).toBe(0) // It was moved 110 | expect(types.isAnyArrayBuffer(ret)).toBe(true) 111 | } 112 | }) 113 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import type { MessagePort, TransferListItem } from 'node:worker_threads' 2 | 3 | /** Channel for communicating between main thread and workers */ 4 | export interface TinypoolChannel { 5 | /** Workers subscribing to messages */ 6 | onMessage?: (callback: (message: any) => void) => void 7 | 8 | /** Called with worker's messages */ 9 | postMessage?: (message: any) => void 10 | 11 | /** Called when channel can be closed */ 12 | onClose?: () => void 13 | } 14 | 15 | export interface TinypoolWorker { 16 | runtime: string 17 | initialize(options: { 18 | env?: Record 19 | argv?: string[] 20 | execArgv?: string[] 21 | resourceLimits?: any 22 | workerData: TinypoolData 23 | trackUnmanagedFds?: boolean 24 | }): void 25 | terminate(): Promise 26 | postMessage(message: any, transferListItem?: TransferListItem[]): void 27 | setChannel?: (channel: TinypoolChannel) => void 28 | on(event: string, listener: (...args: any[]) => void): void 29 | once(event: string, listener: (...args: any[]) => void): void 30 | emit(event: string, ...data: any[]): void 31 | ref?: () => void 32 | unref?: () => void 33 | threadId: number 34 | } 35 | 36 | /** 37 | * Tinypool's internal messaging between main thread and workers. 38 | * - Utilizers can use `__tinypool_worker_message__` property to identify 39 | * these messages and ignore them. 40 | */ 41 | export interface TinypoolWorkerMessage< 42 | T extends 'port' | 'pool' = 'port' | 'pool', 43 | > { 44 | __tinypool_worker_message__: true 45 | source: T 46 | } 47 | 48 | export interface StartupMessage { 49 | filename: string | null 50 | name: string 51 | port: MessagePort 52 | sharedBuffer: Int32Array 53 | useAtomics: boolean 54 | } 55 | 56 | export interface RequestMessage { 57 | taskId: number 58 | task: any 59 | filename: string 60 | name: string 61 | } 62 | 63 | export interface ReadyMessage { 64 | ready: true 65 | } 66 | 67 | export interface ResponseMessage { 68 | taskId: number 69 | result: any 70 | error: unknown | null 71 | usedMemory: number 72 | } 73 | 74 | export interface TinypoolPrivateData { 75 | workerId: number 76 | } 77 | 78 | export type TinypoolData = [TinypoolPrivateData, any] // [{ ... }, workerData] 79 | 80 | // Internal symbol used to mark Transferable objects returned 81 | // by the Tinypool.move() function 82 | const kMovable = Symbol('Tinypool.kMovable') 83 | export const kTransferable = Symbol.for('Tinypool.transferable') 84 | export const kValue = Symbol.for('Tinypool.valueOf') 85 | export const kQueueOptions = Symbol.for('Tinypool.queueOptions') 86 | 87 | // True if the object implements the Transferable interface 88 | export function isTransferable(value: any): boolean { 89 | return ( 90 | value != null && 91 | typeof value === 'object' && 92 | kTransferable in value && 93 | kValue in value 94 | ) 95 | } 96 | 97 | // True if object implements Transferable and has been returned 98 | // by the Tinypool.move() function 99 | export function isMovable(value: any): boolean { 100 | return isTransferable(value) && value[kMovable] === true 101 | } 102 | 103 | export function markMovable(value: object): void { 104 | Object.defineProperty(value, kMovable, { 105 | enumerable: false, 106 | configurable: true, 107 | writable: true, 108 | value: true, 109 | }) 110 | } 111 | 112 | export interface Transferable { 113 | readonly [kTransferable]: object 114 | readonly [kValue]: object 115 | } 116 | 117 | export interface Task { 118 | readonly [kQueueOptions]: object | null 119 | cancel(): void 120 | } 121 | 122 | export interface TaskQueue { 123 | readonly size: number 124 | shift(): Task | null 125 | remove(task: Task): void 126 | push(task: Task): void 127 | cancel(): void 128 | } 129 | 130 | export function isTaskQueue(value: any): boolean { 131 | return ( 132 | typeof value === 'object' && 133 | value !== null && 134 | 'size' in value && 135 | typeof value.shift === 'function' && 136 | typeof value.remove === 'function' && 137 | typeof value.push === 'function' 138 | ) 139 | } 140 | 141 | export const kRequestCountField = 0 142 | export const kResponseCountField = 1 143 | export const kFieldCount = 2 144 | -------------------------------------------------------------------------------- /src/runtime/process-worker.ts: -------------------------------------------------------------------------------- 1 | import { type ChildProcess, fork } from 'node:child_process' 2 | import { MessagePort, type TransferListItem } from 'node:worker_threads' 3 | import { fileURLToPath } from 'node:url' 4 | import { 5 | type TinypoolChannel, 6 | type TinypoolWorker, 7 | type TinypoolWorkerMessage, 8 | } from '../common' 9 | 10 | const __tinypool_worker_message__ = true 11 | const SIGKILL_TIMEOUT = 1000 12 | 13 | export default class ProcessWorker implements TinypoolWorker { 14 | name = 'ProcessWorker' 15 | runtime = 'child_process' 16 | process!: ChildProcess 17 | threadId!: number 18 | port?: MessagePort 19 | channel?: TinypoolChannel 20 | waitForExit!: Promise 21 | isTerminating = false 22 | 23 | initialize(options: Parameters[0]) { 24 | this.process = fork( 25 | fileURLToPath(import.meta.url + '/../entry/process.js'), 26 | options.argv, 27 | { 28 | ...options, 29 | stdio: 'pipe', 30 | env: { 31 | ...options.env, 32 | TINYPOOL_WORKER_ID: options.workerData[0].workerId.toString(), 33 | }, 34 | } 35 | ) 36 | 37 | process.stdout.setMaxListeners(1 + process.stdout.getMaxListeners()) 38 | process.stderr.setMaxListeners(1 + process.stderr.getMaxListeners()) 39 | this.process.stdout?.pipe(process.stdout) 40 | this.process.stderr?.pipe(process.stderr) 41 | 42 | this.threadId = this.process.pid! 43 | 44 | this.process.on('exit', this.onUnexpectedExit) 45 | this.waitForExit = new Promise((r) => this.process.on('exit', r)) 46 | } 47 | 48 | onUnexpectedExit = () => { 49 | this.process.emit('error', new Error('Worker exited unexpectedly')) 50 | } 51 | 52 | async terminate() { 53 | this.isTerminating = true 54 | this.process.off('exit', this.onUnexpectedExit) 55 | 56 | const sigkillTimeout = setTimeout( 57 | () => this.process.kill('SIGKILL'), 58 | SIGKILL_TIMEOUT 59 | ) 60 | 61 | this.process.kill() 62 | await this.waitForExit 63 | 64 | this.process.stdout?.unpipe(process.stdout) 65 | this.process.stderr?.unpipe(process.stderr) 66 | this.port?.close() 67 | this.channel?.onClose?.() 68 | clearTimeout(sigkillTimeout) 69 | } 70 | 71 | setChannel(channel: TinypoolChannel) { 72 | // Previous channel exists in non-isolated runs 73 | if (this.channel && this.channel !== channel) { 74 | this.channel.onClose?.() 75 | } 76 | 77 | this.channel = channel 78 | 79 | // Mirror channel's messages to process 80 | this.channel.onMessage?.((message: any) => { 81 | this.send(message) 82 | }) 83 | } 84 | 85 | private send(message: Parameters>[0]) { 86 | if (!this.isTerminating) { 87 | this.process.send(message) 88 | } 89 | } 90 | 91 | postMessage(message: any, transferListItem?: Readonly) { 92 | transferListItem?.forEach((item) => { 93 | if (item instanceof MessagePort) { 94 | this.port = item 95 | this.port.start() 96 | } 97 | }) 98 | 99 | // Mirror port's messages to process 100 | if (this.port) { 101 | this.port.on('message', (message) => 102 | this.send(>{ 103 | ...message, 104 | source: 'port', 105 | __tinypool_worker_message__, 106 | }) 107 | ) 108 | } 109 | 110 | return this.send(>{ 111 | ...message, 112 | source: 'pool', 113 | __tinypool_worker_message__, 114 | }) 115 | } 116 | 117 | on(event: string, callback: (...args: any[]) => void) { 118 | return this.process.on(event, (data: TinypoolWorkerMessage) => { 119 | // All errors should be forwarded to the pool 120 | if (event === 'error') { 121 | return callback(data) 122 | } 123 | 124 | if (!data || !data.__tinypool_worker_message__) { 125 | return this.channel?.postMessage?.(data) 126 | } 127 | 128 | if (data.source === 'pool') { 129 | callback(data) 130 | } else if (data.source === 'port') { 131 | this.port!.postMessage(data) 132 | } 133 | }) 134 | } 135 | 136 | once(event: string, callback: (...args: any[]) => void) { 137 | return this.process.once(event, callback) 138 | } 139 | 140 | emit(event: string, ...data: any[]) { 141 | return this.process.emit(event, ...data) 142 | } 143 | 144 | ref() { 145 | return this.process.ref() 146 | } 147 | 148 | unref() { 149 | this.port?.unref() 150 | 151 | // The forked child_process adds event listener on `process.on('message)`. 152 | // This requires manual unreffing of its channel. 153 | this.process.channel?.unref?.() 154 | 155 | if (hasUnref(this.process.stdout)) { 156 | this.process.stdout.unref() 157 | } 158 | 159 | if (hasUnref(this.process.stderr)) { 160 | this.process.stderr.unref() 161 | } 162 | 163 | return this.process.unref() 164 | } 165 | } 166 | 167 | // unref is untyped for some reason 168 | function hasUnref(stream: null | object): stream is { unref: () => void } { 169 | return ( 170 | stream != null && 'unref' in stream && typeof stream.unref === 'function' 171 | ) 172 | } 173 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jasnell@gmail.com, anna@addaleax.net, or matteo.collina@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/entry/worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parentPort, 3 | type MessagePort, 4 | receiveMessageOnPort, 5 | workerData as tinypoolData, 6 | } from 'node:worker_threads' 7 | import { 8 | type ReadyMessage, 9 | type RequestMessage, 10 | type ResponseMessage, 11 | type StartupMessage, 12 | type TinypoolData, 13 | kResponseCountField, 14 | kRequestCountField, 15 | isMovable, 16 | kTransferable, 17 | kValue, 18 | } from '../common' 19 | import { stderr, stdout } from '../utils' 20 | import { getHandler, throwInNextTick } from './utils' 21 | 22 | const [tinypoolPrivateData, workerData] = tinypoolData as TinypoolData 23 | 24 | process.__tinypool_state__ = { 25 | isWorkerThread: true, 26 | isTinypoolWorker: true, 27 | workerData: workerData, 28 | workerId: tinypoolPrivateData.workerId, 29 | } 30 | 31 | const memoryUsage = process.memoryUsage.bind(process) 32 | let useAtomics: boolean = process.env.PISCINA_DISABLE_ATOMICS !== '1' 33 | 34 | // We should only receive this message once, when the Worker starts. It gives 35 | // us the MessagePort used for receiving tasks, a SharedArrayBuffer for fast 36 | // communication using Atomics, and the name of the default filename for tasks 37 | // (so we can pre-load and cache the handler). 38 | parentPort!.on('message', (message: StartupMessage) => { 39 | useAtomics = 40 | process.env.PISCINA_DISABLE_ATOMICS === '1' ? false : message.useAtomics 41 | 42 | const { port, sharedBuffer, filename, name } = message 43 | 44 | ;(async function () { 45 | if (filename !== null) { 46 | await getHandler(filename, name) 47 | } 48 | 49 | const readyMessage: ReadyMessage = { ready: true } 50 | parentPort!.postMessage(readyMessage) 51 | 52 | port.start() 53 | 54 | port.on('message', onMessage.bind(null, port, sharedBuffer)) 55 | atomicsWaitLoop(port, sharedBuffer) 56 | })().catch(throwInNextTick) 57 | }) 58 | 59 | let currentTasks: number = 0 60 | let lastSeenRequestCount: number = 0 61 | function atomicsWaitLoop(port: MessagePort, sharedBuffer: Int32Array) { 62 | if (!useAtomics) return 63 | 64 | // This function is entered either after receiving the startup message, or 65 | // when we are done with a task. In those situations, the *only* thing we 66 | // expect to happen next is a 'message' on `port`. 67 | // That call would come with the overhead of a C++ → JS boundary crossing, 68 | // including async tracking. So, instead, if there is no task currently 69 | // running, we wait for a signal from the parent thread using Atomics.wait(), 70 | // and read the message from the port instead of generating an event, 71 | // in order to avoid that overhead. 72 | // The one catch is that this stops asynchronous operations that are still 73 | // running from proceeding. Generally, tasks should not spawn asynchronous 74 | // operations without waiting for them to finish, though. 75 | while (currentTasks === 0) { 76 | // Check whether there are new messages by testing whether the current 77 | // number of requests posted by the parent thread matches the number of 78 | // requests received. 79 | Atomics.wait(sharedBuffer, kRequestCountField, lastSeenRequestCount) 80 | lastSeenRequestCount = Atomics.load(sharedBuffer, kRequestCountField) 81 | 82 | // We have to read messages *after* updating lastSeenRequestCount in order 83 | // to avoid race conditions. 84 | let entry 85 | while ((entry = receiveMessageOnPort(port)) !== undefined) { 86 | onMessage(port, sharedBuffer, entry.message) 87 | } 88 | } 89 | } 90 | 91 | function onMessage( 92 | port: MessagePort, 93 | sharedBuffer: Int32Array, 94 | message: RequestMessage 95 | ) { 96 | currentTasks++ 97 | const { taskId, task, filename, name } = message 98 | 99 | ;(async function () { 100 | let response: ResponseMessage 101 | let transferList: any[] = [] 102 | try { 103 | const handler = await getHandler(filename, name) 104 | if (handler === null) { 105 | throw new Error( 106 | `No handler function "${name}" exported from "${filename}"` 107 | ) 108 | } 109 | let result = await handler(task) 110 | if (isMovable(result)) { 111 | transferList = transferList.concat(result[kTransferable]) 112 | result = result[kValue] 113 | } 114 | response = { 115 | taskId, 116 | result: result, 117 | error: null, 118 | usedMemory: memoryUsage().heapUsed, 119 | } 120 | 121 | // If the task used e.g. console.log(), wait for the stream to drain 122 | // before potentially entering the `Atomics.wait()` loop, and before 123 | // returning the result so that messages will always be printed even 124 | // if the process would otherwise be ready to exit. 125 | if (stdout()?.writableLength! > 0) { 126 | await new Promise((resolve) => process.stdout.write('', resolve)) 127 | } 128 | if (stderr()?.writableLength! > 0) { 129 | await new Promise((resolve) => process.stderr.write('', resolve)) 130 | } 131 | } catch (error) { 132 | response = { 133 | taskId, 134 | result: null, 135 | // It may be worth taking a look at the error cloning algorithm we 136 | // use in Node.js core here, it's quite a bit more flexible 137 | error, 138 | usedMemory: memoryUsage().heapUsed, 139 | } 140 | } 141 | currentTasks-- 142 | 143 | // Post the response to the parent thread, and let it know that we have 144 | // an additional message available. If possible, use Atomics.wait() 145 | // to wait for the next message. 146 | port.postMessage(response, transferList) 147 | Atomics.add(sharedBuffer, kResponseCountField, 1) 148 | atomicsWaitLoop(port, sharedBuffer) 149 | })().catch(throwInNextTick) 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tinypool - the node.js worker pool 🧵 2 | 3 | > Piscina: A fast, efficient Node.js Worker Thread Pool implementation 4 | 5 | Tinypool is a fork of piscina. What we try to achieve in this library, is to eliminate some dependencies and features that our target users don't need (currently, our main user will be Vitest). Tinypool's install size (38KB) can then be smaller than Piscina's install size (6MB when Tinypool was created, Piscina has since reduced it's size to ~800KB). If you need features like [utilization](https://github.com/piscinajs/piscina#property-utilization-readonly) or OS-specific thread priority setting, [Piscina](https://github.com/piscinajs/piscina) is a better choice for you. We think that Piscina is an amazing library, and we may try to upstream some of the dependencies optimization in this fork. 6 | 7 | - ✅ Smaller install size, 38KB 8 | - ✅ Minimal 9 | - ✅ No dependencies 10 | - ✅ Physical cores instead of Logical cores with [physical-cpu-count](https://www.npmjs.com/package/physical-cpu-count) 11 | - ✅ Supports `worker_threads` and `child_process` 12 | - ❌ No utilization 13 | - ❌ No OS-specific thread priority setting 14 | 15 | - Written in TypeScript, and ESM support only. For Node.js 18.x and higher. 16 | 17 | _In case you need more tiny libraries like tinypool or tinyspy, please consider submitting an [RFC](https://github.com/tinylibs/rfcs)_ 18 | 19 | ## Example 20 | 21 | ### Using `node:worker_threads` 22 | 23 | #### Basic usage 24 | 25 | ```js 26 | // main.mjs 27 | import Tinypool from 'tinypool' 28 | 29 | const pool = new Tinypool({ 30 | filename: new URL('./worker.mjs', import.meta.url).href, 31 | }) 32 | const result = await pool.run({ a: 4, b: 6 }) 33 | console.log(result) // Prints 10 34 | 35 | // Make sure to destroy pool once it's not needed anymore 36 | // This terminates all pool's idle workers 37 | await pool.destroy() 38 | ``` 39 | 40 | ```js 41 | // worker.mjs 42 | export default ({ a, b }) => { 43 | return a + b 44 | } 45 | ``` 46 | 47 | #### Main thread <-> worker thread communication 48 | 49 |
50 | See code 51 | 52 | ```js 53 | // main.mjs 54 | import Tinypool from 'tinypool' 55 | import { MessageChannel } from 'node:worker_threads' 56 | 57 | const pool = new Tinypool({ 58 | filename: new URL('./worker.mjs', import.meta.url).href, 59 | }) 60 | const { port1, port2 } = new MessageChannel() 61 | const promise = pool.run({ port: port1 }, { transferList: [port1] }) 62 | 63 | port2.on('message', (message) => console.log('Main thread received:', message)) 64 | setTimeout(() => port2.postMessage('Hello from main thread!'), 1000) 65 | 66 | await promise 67 | 68 | port1.close() 69 | port2.close() 70 | ``` 71 | 72 | ```js 73 | // worker.mjs 74 | export default ({ port }) => { 75 | return new Promise((resolve) => { 76 | port.on('message', (message) => { 77 | console.log('Worker received:', message) 78 | 79 | port.postMessage('Hello from worker thread!') 80 | resolve() 81 | }) 82 | }) 83 | } 84 | ``` 85 | 86 |
87 | 88 | ### Using `node:child_process` 89 | 90 | #### Basic usage 91 | 92 |
93 | See code 94 | 95 | ```js 96 | // main.mjs 97 | import Tinypool from 'tinypool' 98 | 99 | const pool = new Tinypool({ 100 | runtime: 'child_process', 101 | filename: new URL('./worker.mjs', import.meta.url).href, 102 | }) 103 | const result = await pool.run({ a: 4, b: 6 }) 104 | console.log(result) // Prints 10 105 | ``` 106 | 107 | ```js 108 | // worker.mjs 109 | export default ({ a, b }) => { 110 | return a + b 111 | } 112 | ``` 113 | 114 |
115 | 116 | #### Main process <-> worker process communication 117 | 118 |
119 | See code 120 | 121 | ```js 122 | // main.mjs 123 | import Tinypool from 'tinypool' 124 | 125 | const pool = new Tinypool({ 126 | runtime: 'child_process', 127 | filename: new URL('./worker.mjs', import.meta.url).href, 128 | }) 129 | 130 | const messages = [] 131 | const listeners = [] 132 | const channel = { 133 | onMessage: (listener) => listeners.push(listener), 134 | postMessage: (message) => messages.push(message), 135 | } 136 | 137 | const promise = pool.run({}, { channel }) 138 | 139 | // Send message to worker 140 | setTimeout( 141 | () => listeners.forEach((listener) => listener('Hello from main process')), 142 | 1000 143 | ) 144 | 145 | // Wait for task to finish 146 | await promise 147 | 148 | console.log(messages) 149 | // [{ received: 'Hello from main process', response: 'Hello from worker' }] 150 | ``` 151 | 152 | ```js 153 | // worker.mjs 154 | export default async function run() { 155 | return new Promise((resolve) => { 156 | process.on('message', (message) => { 157 | // Ignore Tinypool's internal messages 158 | if (message?.__tinypool_worker_message__) return 159 | 160 | process.send({ received: message, response: 'Hello from worker' }) 161 | resolve() 162 | }) 163 | }) 164 | } 165 | ``` 166 | 167 |
168 | 169 | ## API 170 | 171 | We have a similar API to Piscina, so for more information, you can read Piscina's detailed [documentation](https://github.com/piscinajs/piscina#piscina---the-nodejs-worker-pool) and apply the same techniques here. 172 | 173 | ### Tinypool specific APIs 174 | 175 | #### Pool constructor options 176 | 177 | - `isolateWorkers`: Disabled by default. Always starts with a fresh worker when running tasks to isolate the environment. 178 | - `terminateTimeout`: Disabled by default. If terminating a worker takes `terminateTimeout` amount of milliseconds to execute, an error is raised. 179 | - `maxMemoryLimitBeforeRecycle`: Disabled by default. When defined, the worker's heap memory usage is compared against this value after task has been finished. If the current memory usage exceeds this limit, worker is terminated and a new one is started to take its place. This option is useful when your tasks leak memory and you don't want to enable `isolateWorkers` option. 180 | - `runtime`: Used to pick worker runtime. Default value is `worker_threads`. 181 | - `worker_threads`: Runs workers in [`node:worker_threads`](https://nodejs.org/api/worker_threads.html). For `main thread <-> worker thread` communication you can use [`MessagePort`](https://nodejs.org/api/worker_threads.html#class-messageport) in the `pool.run()` method's [`transferList` option](https://nodejs.org/api/worker_threads.html#portpostmessagevalue-transferlist). See [example](#main-thread---worker-thread-communication). 182 | - `child_process`: Runs workers in [`node:child_process`](https://nodejs.org/api/child_process.html). For `main thread <-> worker process` communication you can use `TinypoolChannel` in the `pool.run()` method's `channel` option. For filtering out the Tinypool's internal messages see `TinypoolWorkerMessage`. See [example](#main-process---worker-process-communication). 183 | - `teardown`: name of the function in file that should be called before worker is terminated. Must be named exported. 184 | 185 | #### Pool methods 186 | 187 | - `cancelPendingTasks()`: Gracefully cancels all pending tasks without stopping or interfering with on-going tasks. This method is useful when your tasks may have side effects and should not be terminated forcefully during task execution. If your tasks don't have any side effects you may want to use [`{ signal }`](https://github.com/piscinajs/piscina#cancelable-tasks) option for forcefully terminating all tasks, including the on-going ones, instead. 188 | - `recycleWorkers(options)`: Waits for all current tasks to finish and re-creates all workers. Can be used to force isolation imperatively even when `isolateWorkers` is disabled. Accepts `{ runtime }` option as argument. 189 | 190 | #### Exports 191 | 192 | - `workerId`: Each worker now has an id ( <= `maxThreads`) that can be imported from `tinypool` in the worker itself (or `process.__tinypool_state__.workerId`). 193 | 194 | ## Authors 195 | 196 | |
Mohammad Bagher
| 197 | | ------------------------------------------------------------------------------------------------------------------------------------------------ | 198 | 199 | ## Sponsors 200 | 201 | Your sponsorship can make a huge difference in continuing our work in open source! 202 | 203 |

204 | 205 | 206 | 207 |

208 | 209 | ## Credits 210 | 211 | [The Vitest team](https://vitest.dev/) for giving me the chance of creating and maintaing this project for vitest. 212 | 213 | [Piscina](https://github.com/piscinajs/piscina), because Tinypool is not more than a friendly fork of piscina. 214 | -------------------------------------------------------------------------------- /test/runtime.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { Tinypool } from 'tinypool' 4 | import EventEmitter from 'node:events' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | describe('worker_threads', () => { 9 | test('runs code in worker_threads', async () => { 10 | const pool = createPool({ runtime: 'worker_threads' }) 11 | 12 | const result = await pool.run(` 13 | (async () => { 14 | const workerThreads = await import("worker_threads"); 15 | 16 | return { 17 | sum: 11 + 12, 18 | isMainThread: workerThreads.isMainThread, 19 | pid: process.pid, 20 | } 21 | })() 22 | `) 23 | expect(result.sum).toBe(23) 24 | expect(result.isMainThread).toBe(false) 25 | expect(result.pid).toBe(process.pid) 26 | }) 27 | 28 | test('sets tinypool state', async () => { 29 | const pool = createPool({ runtime: 'worker_threads' }) 30 | 31 | const result = await pool.run('process.__tinypool_state__') 32 | expect(result.isTinypoolWorker).toBe(true) 33 | expect(result.isWorkerThread).toBe(true) 34 | expect(result.isChildProcess).toBe(undefined) 35 | }) 36 | 37 | test("worker's threadId is used as threadId", async () => { 38 | const pool = createPool({ runtime: 'worker_threads' }) 39 | const threadId = pool.threads[0]!.threadId 40 | 41 | const result = await pool.run(` 42 | (async () => { 43 | const workerThreads = await import("worker_threads"); 44 | return workerThreads.threadId; 45 | })() 46 | `) 47 | expect(result).toBe(threadId) 48 | }) 49 | 50 | test('channel is closed when isolated', async () => { 51 | const pool = createPool({ 52 | runtime: 'worker_threads', 53 | isolateWorkers: true, 54 | minThreads: 2, 55 | maxThreads: 2, 56 | }) 57 | 58 | const events: string[] = [] 59 | 60 | await pool.run('', { channel: { onClose: () => events.push('call #1') } }) 61 | expect(events).toStrictEqual(['call #1']) 62 | 63 | await pool.run('', { channel: { onClose: () => events.push('call #2') } }) 64 | expect(events).toStrictEqual(['call #1', 'call #2']) 65 | 66 | await pool.run('', { channel: { onClose: () => events.push('call #3') } }) 67 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 68 | 69 | await pool.destroy() 70 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 71 | }) 72 | 73 | test('channel is closed when non-isolated', async () => { 74 | const pool = createPool({ 75 | runtime: 'worker_threads', 76 | isolateWorkers: false, 77 | minThreads: 2, 78 | maxThreads: 2, 79 | }) 80 | 81 | const events: string[] = [] 82 | 83 | await pool.run('', { channel: { onClose: () => events.push('call #1') } }) 84 | expect(events).toStrictEqual([]) 85 | 86 | await pool.run('', { channel: { onClose: () => events.push('call #2') } }) 87 | expect(events).toStrictEqual(['call #1']) 88 | 89 | await pool.run('', { channel: { onClose: () => events.push('call #3') } }) 90 | expect(events).toStrictEqual(['call #1', 'call #2']) 91 | 92 | await pool.destroy() 93 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 94 | }) 95 | }) 96 | 97 | describe('child_process', () => { 98 | test('runs code in child_process', async () => { 99 | const pool = createPool({ runtime: 'child_process' }) 100 | 101 | const result = await pool.run(` 102 | (async () => { 103 | const workerThreads = await import("worker_threads"); 104 | 105 | return { 106 | sum: 11 + 12, 107 | isMainThread: workerThreads.isMainThread, 108 | pid: process.pid, 109 | } 110 | })() 111 | `) 112 | expect(result.sum).toBe(23) 113 | expect(result.isMainThread).toBe(true) 114 | expect(result.pid).not.toBe(process.pid) 115 | }) 116 | 117 | test('sets tinypool state', async () => { 118 | const pool = createPool({ runtime: 'child_process' }) 119 | 120 | const result = await pool.run('process.__tinypool_state__') 121 | expect(result.isTinypoolWorker).toBe(true) 122 | expect(result.isChildProcess).toBe(true) 123 | expect(result.isWorkerThread).toBe(undefined) 124 | }) 125 | 126 | test("sub-process's process ID is used as threadId", async () => { 127 | const pool = createPool({ runtime: 'child_process' }) 128 | const threadId = pool.threads[0]!.threadId 129 | 130 | const result = await pool.run('process.pid') 131 | expect(result).toBe(threadId) 132 | }) 133 | 134 | test('child process workerId should be internal tinypool workerId', async () => { 135 | const pool = createPool({ runtime: 'child_process' }) 136 | const workerId = await pool.run('process.__tinypool_state__.workerId') 137 | expect(workerId).toBe(1) 138 | }) 139 | 140 | test('errors are serialized', async () => { 141 | const pool = createPool({ runtime: 'child_process' }) 142 | 143 | const error = await pool 144 | .run("throw new TypeError('Test message');") 145 | .catch((e) => e) 146 | 147 | expect(error.name).toBe('TypeError') 148 | expect(error.message).toBe('Test message') 149 | expect(error.stack).toMatch('fixtures/eval.js') 150 | }) 151 | 152 | test('can send messages to port', async () => { 153 | const pool = createPool({ 154 | runtime: 'child_process', 155 | filename: path.resolve( 156 | __dirname, 157 | 'fixtures/child_process-communication.mjs' 158 | ), 159 | }) 160 | 161 | const emitter = new EventEmitter() 162 | 163 | const startup = new Promise((resolve) => 164 | emitter.on( 165 | 'response', 166 | (message) => message === 'Child process started' && resolve() 167 | ) 168 | ) 169 | 170 | const runPromise = pool.run('default', { 171 | channel: { 172 | onMessage: (callback) => emitter.on('message', callback), 173 | postMessage: (message) => emitter.emit('response', message), 174 | }, 175 | }) 176 | 177 | // Wait for the child process to start 178 | await startup 179 | 180 | const response = new Promise((resolve) => 181 | emitter.on( 182 | 'response', 183 | (message) => message !== 'Hello from main' && resolve(message) 184 | ) 185 | ) 186 | 187 | // Send message to child process 188 | emitter.emit('message', 'Hello from main') 189 | 190 | // Wait for task to finish 191 | await runPromise 192 | 193 | // Wait for response from child 194 | const result = await response 195 | 196 | expect(result).toMatchObject({ 197 | received: 'Hello from main', 198 | response: 'Hello from worker', 199 | }) 200 | }) 201 | 202 | test('channel is closed when isolated', async () => { 203 | const pool = createPool({ 204 | runtime: 'child_process', 205 | isolateWorkers: true, 206 | minThreads: 2, 207 | maxThreads: 2, 208 | }) 209 | 210 | const events: string[] = [] 211 | 212 | await pool.run('', { channel: { onClose: () => events.push('call #1') } }) 213 | expect(events).toStrictEqual(['call #1']) 214 | 215 | await pool.run('', { channel: { onClose: () => events.push('call #2') } }) 216 | expect(events).toStrictEqual(['call #1', 'call #2']) 217 | 218 | await pool.run('', { channel: { onClose: () => events.push('call #3') } }) 219 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 220 | 221 | await pool.destroy() 222 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 223 | }) 224 | 225 | test('channel is closed when non-isolated', async () => { 226 | const pool = createPool({ 227 | runtime: 'child_process', 228 | isolateWorkers: false, 229 | minThreads: 2, 230 | maxThreads: 2, 231 | }) 232 | 233 | const events: string[] = [] 234 | 235 | await pool.run('', { channel: { onClose: () => events.push('call #1') } }) 236 | expect(events).toStrictEqual([]) 237 | 238 | await pool.run('', { channel: { onClose: () => events.push('call #2') } }) 239 | expect(events).toStrictEqual(['call #1']) 240 | 241 | await pool.run('', { channel: { onClose: () => events.push('call #3') } }) 242 | expect(events).toStrictEqual(['call #1', 'call #2']) 243 | 244 | await pool.destroy() 245 | expect(events).toStrictEqual(['call #1', 'call #2', 'call #3']) 246 | }) 247 | }) 248 | 249 | test('runtime can be changed after recycle', async () => { 250 | const pool = createPool({ runtime: 'worker_threads' }) 251 | const getState = 'process.__tinypool_state__' 252 | 253 | await expect( 254 | Promise.all([pool.run(getState), pool.run(getState)]) 255 | ).resolves.toMatchObject([{ isWorkerThread: true }, { isWorkerThread: true }]) 256 | 257 | await pool.recycleWorkers({ runtime: 'child_process' }) 258 | 259 | await expect( 260 | Promise.all([pool.run(getState), pool.run(getState)]) 261 | ).resolves.toMatchObject([{ isChildProcess: true }, { isChildProcess: true }]) 262 | 263 | await pool.recycleWorkers({ runtime: 'worker_threads' }) 264 | 265 | expect(await pool.run(getState)).toMatchObject({ 266 | isWorkerThread: true, 267 | }) 268 | }) 269 | 270 | test('isolated idle workers change runtime after recycle', async () => { 271 | const pool = createPool({ 272 | runtime: 'worker_threads', 273 | minThreads: 2, 274 | maxThreads: 2, 275 | isolateWorkers: true, 276 | }) 277 | const getState = 'process.__tinypool_state__' 278 | 279 | await expect(pool.run(getState)).resolves.toMatchObject({ 280 | isWorkerThread: true, 281 | }) 282 | 283 | await pool.recycleWorkers({ runtime: 'child_process' }) 284 | 285 | await expect( 286 | Promise.all([pool.run(getState), pool.run(getState)]) 287 | ).resolves.toMatchObject([{ isChildProcess: true }, { isChildProcess: true }]) 288 | }) 289 | 290 | function createPool(options: Partial) { 291 | const pool = new Tinypool({ 292 | filename: path.resolve(__dirname, 'fixtures/eval.js'), 293 | minThreads: 1, 294 | maxThreads: 1, 295 | ...options, 296 | }) 297 | 298 | return pool 299 | } 300 | -------------------------------------------------------------------------------- /test/simple.test.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events' 2 | import { cpus } from 'node:os' 3 | import { dirname, resolve } from 'node:path' 4 | import Tinypool from 'tinypool' 5 | import { fileURLToPath, pathToFileURL } from 'node:url' 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)) 8 | const sleep = async (num: number) => 9 | await new Promise((res) => setTimeout(res, num)) 10 | 11 | test('basic test', async () => { 12 | const worker = new Tinypool({ 13 | filename: resolve(__dirname, 'fixtures/simple-isworkerthread.js'), 14 | }) 15 | const result = await worker.run(null) 16 | expect(result).toBe('done') 17 | }) 18 | 19 | test('isWorkerThread correct value', async () => { 20 | expect(Tinypool.isWorkerThread).toBe(false) 21 | }) 22 | 23 | test('Tinypool instance is an EventEmitter', async () => { 24 | const piscina = new Tinypool() 25 | expect(piscina instanceof EventEmitter).toBe(true) 26 | }) 27 | 28 | test('Tinypool constructor options are correctly set', async () => { 29 | const piscina = new Tinypool({ 30 | minThreads: 10, 31 | maxThreads: 20, 32 | maxQueue: 30, 33 | }) 34 | 35 | expect(piscina.options.minThreads).toBe(10) 36 | expect(piscina.options.maxThreads).toBe(20) 37 | expect(piscina.options.maxQueue).toBe(30) 38 | }) 39 | // 40 | test('trivial eval() handler works', async () => { 41 | const worker = new Tinypool({ 42 | filename: resolve(__dirname, 'fixtures/eval.js'), 43 | }) 44 | const result = await worker.run('42') 45 | expect(result).toBe(42) 46 | }) 47 | 48 | test('async eval() handler works', async () => { 49 | const worker = new Tinypool({ 50 | filename: resolve(__dirname, 'fixtures/eval.js'), 51 | }) 52 | const result = await worker.run('Promise.resolve(42)') 53 | expect(result).toBe(42) 54 | }) 55 | 56 | test('filename can be provided while posting', async () => { 57 | const worker = new Tinypool() 58 | const result = await worker.run('Promise.resolve(42)', { 59 | filename: resolve(__dirname, 'fixtures/eval.js'), 60 | }) 61 | expect(result).toBe(42) 62 | }) 63 | 64 | test('filename can be null when initially provided', async () => { 65 | const worker = new Tinypool({ filename: null }) 66 | const result = await worker.run('Promise.resolve(42)', { 67 | filename: resolve(__dirname, 'fixtures/eval.js'), 68 | }) 69 | expect(result).toBe(42) 70 | }) 71 | 72 | test('filename must be provided while posting', async () => { 73 | const worker = new Tinypool() 74 | await expect(worker.run('doesn’t matter')).rejects.toThrow( 75 | /filename must be provided to run\(\) or in options object/ 76 | ) 77 | }) 78 | 79 | test('passing env to workers works', async () => { 80 | const pool = new Tinypool({ 81 | filename: resolve(__dirname, 'fixtures/eval.js'), 82 | env: { A: 'foo' }, 83 | }) 84 | 85 | const env = await pool.run('({...process.env})') 86 | expect(env).toEqual({ A: 'foo' }) 87 | }) 88 | 89 | test('passing argv to workers works', async () => { 90 | const pool = new Tinypool({ 91 | filename: resolve(__dirname, 'fixtures/eval.js'), 92 | argv: ['a', 'b', 'c'], 93 | }) 94 | 95 | const env = await pool.run('process.argv.slice(2)') 96 | expect(env).toEqual(['a', 'b', 'c']) 97 | }) 98 | 99 | test('passing argv to child process', async () => { 100 | const pool = new Tinypool({ 101 | runtime: 'child_process', 102 | filename: resolve(__dirname, 'fixtures/eval.js'), 103 | argv: ['a', 'b', 'c'], 104 | }) 105 | 106 | const env = await pool.run('process.argv.slice(2)') 107 | expect(env).toEqual(['a', 'b', 'c']) 108 | }) 109 | 110 | test('passing execArgv to workers works', async () => { 111 | const pool = new Tinypool({ 112 | filename: resolve(__dirname, 'fixtures/eval.js'), 113 | execArgv: ['--no-warnings'], 114 | }) 115 | 116 | const env = await pool.run('process.execArgv') 117 | expect(env).toEqual(['--no-warnings']) 118 | }) 119 | 120 | test('passing valid workerData works', async () => { 121 | const pool = new Tinypool({ 122 | filename: resolve(__dirname, 'fixtures/simple-workerdata.js'), 123 | workerData: 'ABC', 124 | }) 125 | expect(Tinypool.workerData).toBe(undefined) 126 | 127 | await pool.run(null) 128 | }) 129 | 130 | test('filename can be a file:// URL', async () => { 131 | const worker = new Tinypool({ 132 | filename: pathToFileURL(resolve(__dirname, 'fixtures/eval.js')).href, 133 | }) 134 | const result = await worker.run('42') 135 | expect(result).toBe(42) 136 | }) 137 | 138 | test('filename can be a file:// URL to an ESM module', async () => { 139 | const worker = new Tinypool({ 140 | filename: pathToFileURL(resolve(__dirname, 'fixtures/esm-export.mjs')).href, 141 | }) 142 | const result = await worker.run('42') 143 | expect(result).toBe(42) 144 | }) 145 | 146 | test('named tasks work', async () => { 147 | const worker = new Tinypool({ 148 | filename: resolve(__dirname, 'fixtures/multiple.js'), 149 | }) 150 | 151 | expect(await worker.run({}, { name: 'a' })).toBe('a') 152 | expect(await worker.run({}, { name: 'b' })).toBe('b') 153 | expect(await worker.run({})).toBe('a') 154 | }) 155 | 156 | test('named tasks work', async () => { 157 | const worker = new Tinypool({ 158 | filename: resolve(__dirname, 'fixtures/multiple.js'), 159 | name: 'b', 160 | }) 161 | 162 | expect(await worker.run({}, { name: 'a' })).toBe('a') 163 | expect(await worker.run({}, { name: 'b' })).toBe('b') 164 | expect(await worker.run({})).toBe('b') 165 | }) 166 | 167 | test('isolateWorkers: false', async () => { 168 | const pool = new Tinypool({ 169 | filename: resolve(__dirname, 'fixtures/isolated.js'), 170 | isolateWorkers: false, 171 | }) 172 | 173 | expect(await pool.run({})).toBe(0) 174 | expect(await pool.run({})).toBe(1) 175 | expect(await pool.run({})).toBe(2) 176 | }) 177 | 178 | test('isolateWorkers: true', async () => { 179 | const pool = new Tinypool({ 180 | filename: resolve(__dirname, 'fixtures/isolated.js'), 181 | isolateWorkers: true, 182 | }) 183 | 184 | expect(await pool.run({})).toBe(0) 185 | expect(await pool.run({})).toBe(0) 186 | expect(await pool.run({})).toBe(0) 187 | }) 188 | 189 | test('workerId should never be more than maxThreads=1', async () => { 190 | const maxThreads = 1 191 | const pool = new Tinypool({ 192 | filename: resolve(__dirname, 'fixtures/workerId.js'), 193 | isolateWorkers: true, 194 | maxThreads: maxThreads, 195 | }) 196 | await pool.destroy() 197 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 198 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 199 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 200 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 201 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 202 | 203 | await sleep(300) 204 | }) 205 | 206 | test('workerId should never be more than maxThreads', async () => { 207 | const maxThreads = Math.floor(Math.random() * (4 - 1 + 1) + 1) 208 | const pool = new Tinypool({ 209 | filename: resolve(__dirname, 'fixtures/workerId.js'), 210 | isolateWorkers: true, 211 | maxThreads: maxThreads, 212 | }) 213 | await pool.destroy() 214 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 215 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 216 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 217 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 218 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 219 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 220 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 221 | await expect(pool.run({})).resolves.toBeLessThanOrEqual(maxThreads) 222 | 223 | await sleep(300) 224 | }) 225 | 226 | test('worker count should never be below minThreads when using isolateWorkers', async () => { 227 | const minThreads = 4 228 | const pool = new Tinypool({ 229 | filename: resolve(__dirname, 'fixtures/workerId.js'), 230 | isolateWorkers: true, 231 | minThreads, 232 | }) 233 | await pool.run({}) 234 | expect(pool.threads.length).toBe(minThreads) 235 | await pool.run({}) 236 | expect(pool.threads.length).toBe(minThreads) 237 | await pool.run({}) 238 | expect(pool.threads.length).toBe(minThreads) 239 | await pool.run({}) 240 | expect(pool.threads.length).toBe(minThreads) 241 | await pool.run({}) 242 | expect(pool.threads.length).toBe(minThreads) 243 | await pool.run({}) 244 | expect(pool.threads.length).toBe(minThreads) 245 | 246 | await sleep(300) 247 | }) 248 | 249 | test('workerId should never be duplicated', async () => { 250 | const maxThreads = cpus().length + 4 251 | // console.log('maxThreads', maxThreads) 252 | const pool = new Tinypool({ 253 | filename: resolve(__dirname, 'fixtures/workerId.js'), 254 | isolateWorkers: true, 255 | // challenge tinypool 256 | maxThreads, 257 | }) 258 | let duplicated = false 259 | const workerIds: number[] = [] 260 | 261 | function addWorkerId(workerId: number) { 262 | if (workerIds.includes(workerId)) { 263 | duplicated = true 264 | // console.log('fucked') 265 | } 266 | workerIds.push(workerId) 267 | } 268 | 269 | const createWorkerId = async (): Promise => { 270 | const result = await pool.run({}) 271 | addWorkerId(result) 272 | return result 273 | } 274 | 275 | for (let i = 0; i < 20; i++) { 276 | if (duplicated) { 277 | continue 278 | } 279 | await Promise.all( 280 | new Array(maxThreads - 2).fill(0).map(() => createWorkerId()) 281 | ) 282 | workerIds.length = 0 283 | 284 | expect(duplicated).toBe(false) 285 | } 286 | 287 | await pool.destroy() 288 | await sleep(3000) 289 | }, 30000) 290 | 291 | test('isolateWorkers: true with minThreads of 0 should not halt (#42)', async () => { 292 | const minThreads = 0, 293 | maxThreads = 6 294 | const pool = new Tinypool({ 295 | filename: resolve(__dirname, 'fixtures/isolated.js'), 296 | minThreads, 297 | maxThreads, 298 | isolateWorkers: true, 299 | }) 300 | // https://github.com/tinylibs/tinypool/pull/44#discussion_r1070169279 301 | const promises = [] 302 | for (let i = 0; i < maxThreads + 1; i++) { 303 | promises.push(pool.run({})) 304 | } 305 | await Promise.all(promises) 306 | }) 307 | -------------------------------------------------------------------------------- /test/task-queue.test.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'node:path' 2 | import { Tinypool, type Task, type TaskQueue } from 'tinypool' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | 7 | test('will put items into a task queue until they can run', async () => { 8 | const pool = new Tinypool({ 9 | filename: resolve(__dirname, 'fixtures/wait-for-notify.js'), 10 | minThreads: 2, 11 | maxThreads: 3, 12 | }) 13 | expect(pool.threads.length).toBe(2) 14 | expect(pool.queueSize).toBe(0) 15 | 16 | const buffers = [ 17 | new Int32Array(new SharedArrayBuffer(4)), 18 | new Int32Array(new SharedArrayBuffer(4)), 19 | new Int32Array(new SharedArrayBuffer(4)), 20 | new Int32Array(new SharedArrayBuffer(4)), 21 | ] 22 | 23 | const results = [] 24 | 25 | results.push(pool.run(buffers[0])) 26 | expect(pool.threads.length).toBe(2) 27 | expect(pool.queueSize).toBe(0) 28 | 29 | results.push(pool.run(buffers[1])) 30 | expect(pool.threads.length).toBe(2) 31 | expect(pool.queueSize).toBe(0) 32 | 33 | results.push(pool.run(buffers[2])) 34 | expect(pool.threads.length).toBe(3) 35 | expect(pool.queueSize).toBe(0) 36 | 37 | results.push(pool.run(buffers[3])) 38 | expect(pool.threads.length).toBe(3) 39 | expect(pool.queueSize).toBe(1) 40 | 41 | for (const buffer of buffers) { 42 | Atomics.store(buffer, 0, 1) 43 | Atomics.notify(buffer, 0, 1) 44 | } 45 | 46 | await results[0] 47 | expect(pool.queueSize).toBe(0) 48 | 49 | await Promise.all(results) 50 | }) 51 | 52 | test('will reject items over task queue limit', async () => { 53 | const pool = new Tinypool({ 54 | filename: resolve(__dirname, 'fixtures/eval.js'), 55 | minThreads: 0, 56 | maxThreads: 1, 57 | maxQueue: 2, 58 | }) 59 | const promises: Promise[] = [] 60 | 61 | expect(pool.threads.length).toBe(0) 62 | expect(pool.queueSize).toBe(0) 63 | 64 | promises.push( 65 | expect(pool.run('while (true) {}')).rejects.toThrow( 66 | /Terminating worker thread/ 67 | ) 68 | ) 69 | 70 | expect(pool.threads.length).toBe(1) 71 | expect(pool.queueSize).toBe(0) 72 | 73 | promises.push( 74 | expect(pool.run('while (true) {}')).rejects.toThrow( 75 | /Terminating worker thread/ 76 | ) 77 | ) 78 | expect(pool.threads.length).toBe(1) 79 | expect(pool.queueSize).toBe(1) 80 | 81 | promises.push( 82 | expect(pool.run('while (true) {}')).rejects.toThrow( 83 | /Terminating worker thread/ 84 | ) 85 | ) 86 | expect(pool.threads.length).toBe(1) 87 | expect(pool.queueSize).toBe(2) 88 | 89 | promises.push( 90 | expect(pool.run('while (true) {}')).rejects.toThrow( 91 | /Task queue is at limit/ 92 | ) 93 | ) 94 | 95 | await pool.destroy() 96 | await Promise.all(promises) 97 | }) 98 | 99 | test('will reject items when task queue is unavailable', async () => { 100 | const pool = new Tinypool({ 101 | filename: resolve(__dirname, 'fixtures/eval.js'), 102 | minThreads: 0, 103 | maxThreads: 1, 104 | maxQueue: 0, 105 | }) 106 | const promises: Promise[] = [] 107 | 108 | expect(pool.threads.length).toBe(0) 109 | expect(pool.queueSize).toBe(0) 110 | 111 | promises.push( 112 | expect(pool.run('while (true) {}')).rejects.toThrow( 113 | /Terminating worker thread/ 114 | ) 115 | ) 116 | expect(pool.threads.length).toBe(1) 117 | expect(pool.queueSize).toBe(0) 118 | 119 | promises.push( 120 | expect(pool.run('while (true) {}')).rejects.toThrow( 121 | /No task queue available and all Workers are busy/ 122 | ) 123 | ) 124 | 125 | await pool.destroy() 126 | await Promise.all(promises) 127 | }) 128 | 129 | test('will reject items when task queue is unavailable (fixed thread count)', async () => { 130 | const pool = new Tinypool({ 131 | filename: resolve(__dirname, 'fixtures/eval.js'), 132 | minThreads: 1, 133 | maxThreads: 1, 134 | maxQueue: 0, 135 | }) 136 | const promises: Promise[] = [] 137 | 138 | expect(pool.threads.length).toBe(1) 139 | expect(pool.queueSize).toBe(0) 140 | 141 | promises.push( 142 | expect(pool.run('while (true) {}')).rejects.toThrow( 143 | /Terminating worker thread/ 144 | ) 145 | ) 146 | expect(pool.threads.length).toBe(1) 147 | expect(pool.queueSize).toBe(0) 148 | 149 | promises.push( 150 | expect(pool.run('while (true) {}')).rejects.toThrow( 151 | /No task queue available and all Workers are busy/ 152 | ) 153 | ) 154 | 155 | await pool.destroy() 156 | await Promise.all(promises) 157 | }) 158 | 159 | test('tasks can share a Worker if requested (both tests blocking)', async () => { 160 | const pool = new Tinypool({ 161 | filename: resolve(__dirname, 'fixtures/wait-for-notify.js'), 162 | minThreads: 0, 163 | maxThreads: 1, 164 | maxQueue: 0, 165 | concurrentTasksPerWorker: 2, 166 | }) 167 | const promises: Promise[] = [] 168 | 169 | expect(pool.threads.length).toBe(0) 170 | expect(pool.queueSize).toBe(0) 171 | 172 | promises.push( 173 | expect( 174 | pool.run(new Int32Array(new SharedArrayBuffer(4))) 175 | ).rejects.toBeTruthy() 176 | ) 177 | expect(pool.threads.length).toBe(1) 178 | expect(pool.queueSize).toBe(0) 179 | 180 | promises.push( 181 | expect( 182 | pool.run(new Int32Array(new SharedArrayBuffer(4))) 183 | ).rejects.toBeTruthy() 184 | ) 185 | expect(pool.threads.length).toBe(1) 186 | expect(pool.queueSize).toBe(0) 187 | 188 | await pool.destroy() 189 | await Promise.all(promises) 190 | }) 191 | 192 | test('tasks can share a Worker if requested (both tests finish)', async () => { 193 | const pool = new Tinypool({ 194 | filename: resolve(__dirname, 'fixtures/wait-for-notify.js'), 195 | minThreads: 1, 196 | maxThreads: 1, 197 | maxQueue: 0, 198 | concurrentTasksPerWorker: 2, 199 | }) 200 | 201 | const buffers = [ 202 | new Int32Array(new SharedArrayBuffer(4)), 203 | new Int32Array(new SharedArrayBuffer(4)), 204 | ] as const 205 | 206 | expect(pool.threads.length).toBe(1) 207 | expect(pool.queueSize).toBe(0) 208 | 209 | const firstTask = pool.run(buffers[0]) 210 | expect(pool.threads.length).toBe(1) 211 | expect(pool.queueSize).toBe(0) 212 | 213 | const secondTask = pool.run(buffers[1]) 214 | expect(pool.threads.length).toBe(1) 215 | expect(pool.queueSize).toBe(0) 216 | 217 | Atomics.store(buffers[0] as any, 0, 1) 218 | Atomics.store(buffers[1] as any, 0, 1) 219 | Atomics.notify(buffers[0] as any, 0, 1) 220 | Atomics.notify(buffers[1] as any, 0, 1) 221 | Atomics.wait(buffers[0] as any, 0, 1) 222 | Atomics.wait(buffers[1] as any, 0, 1) 223 | 224 | await firstTask 225 | expect(buffers[0][0]).toBe(-1) 226 | await secondTask 227 | expect(buffers[1][0]).toBe(-1) 228 | 229 | expect(pool.threads.length).toBe(1) 230 | expect(pool.queueSize).toBe(0) 231 | }) 232 | 233 | test('custom task queue works', async () => { 234 | let sizeCalled: boolean = false 235 | let shiftCalled: boolean = false 236 | let pushCalled: boolean = false 237 | 238 | class CustomTaskPool implements TaskQueue { 239 | tasks: Task[] = [] 240 | 241 | get size(): number { 242 | sizeCalled = true 243 | return this.tasks.length 244 | } 245 | 246 | shift(): Task | null { 247 | shiftCalled = true 248 | return this.tasks.length > 0 ? (this.tasks.shift() as Task) : null 249 | } 250 | 251 | push(task: Task): void { 252 | pushCalled = true 253 | this.tasks.push(task) 254 | 255 | expect(Tinypool.queueOptionsSymbol in task).toBeTruthy() 256 | if ((task as any).task.a === 3) { 257 | // @ts-expect-error -- intentional 258 | expect(task[Tinypool.queueOptionsSymbol]).toBeNull() 259 | } else { 260 | // @ts-expect-error -- intentional 261 | expect(task[Tinypool.queueOptionsSymbol].option).toEqual( 262 | (task as any).task.a 263 | ) 264 | } 265 | } 266 | 267 | remove(task: Task): void { 268 | const index = this.tasks.indexOf(task) 269 | this.tasks.splice(index, 1) 270 | } 271 | 272 | cancel() {} 273 | } 274 | 275 | const pool = new Tinypool({ 276 | filename: resolve(__dirname, 'fixtures/eval.js'), 277 | taskQueue: new CustomTaskPool(), 278 | // Setting maxThreads low enough to ensure we queue 279 | maxThreads: 1, 280 | minThreads: 1, 281 | }) 282 | 283 | function makeTask(task: any, option: any) { 284 | return { ...task, [Tinypool.queueOptionsSymbol]: { option } } 285 | } 286 | 287 | const ret = await Promise.all([ 288 | pool.run(makeTask({ a: 1 }, 1)), 289 | pool.run(makeTask({ a: 2 }, 2)), 290 | pool.run({ a: 3 }), // No queueOptionsSymbol attached 291 | ]) 292 | 293 | expect(ret[0].a).toBe(1) 294 | expect(ret[1].a).toBe(2) 295 | expect(ret[2].a).toBe(3) 296 | 297 | expect(sizeCalled).toBeTruthy() 298 | expect(pushCalled).toBeTruthy() 299 | expect(shiftCalled).toBeTruthy() 300 | }) 301 | 302 | test('queued tasks can be cancelled', async () => { 303 | const pool = new Tinypool({ 304 | filename: resolve(__dirname, 'fixtures/sleep.js'), 305 | minThreads: 0, 306 | maxThreads: 1, 307 | }) 308 | 309 | const time = 2000 310 | const taskCount = 10 311 | 312 | const promises = [] 313 | let finishedTasks = 0 314 | let cancelledTasks = 0 315 | 316 | for (const _ of Array(taskCount)) { 317 | const promise = pool 318 | .run({ time }) 319 | .then(() => { 320 | finishedTasks++ 321 | }) 322 | .catch((error) => { 323 | if (error.message !== 'The task has been cancelled') { 324 | throw error 325 | } 326 | cancelledTasks++ 327 | }) 328 | promises.push(promise) 329 | } 330 | 331 | // Wait for the first task to start 332 | await new Promise((resolve) => setTimeout(resolve, time / 2)) 333 | expect(pool.queueSize).toBe(taskCount - 1) 334 | 335 | // One task is running, cancel the pending ones 336 | pool.cancelPendingTasks() 337 | 338 | // The first task should still be on-going, pending ones should have started their cancellation 339 | expect(finishedTasks).toBe(0) 340 | expect(pool.queueSize).toBe(0) 341 | 342 | await Promise.all(promises) 343 | 344 | expect({ finishedTasks, cancelledTasks }).toEqual({ 345 | finishedTasks: 1, 346 | cancelledTasks: taskCount - 1, 347 | }) 348 | }) 349 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageChannel, 3 | type MessagePort, 4 | receiveMessageOnPort, 5 | } from 'node:worker_threads' 6 | import { once, EventEmitterAsyncResource } from 'node:events' 7 | import { AsyncResource } from 'node:async_hooks' 8 | import { fileURLToPath, URL } from 'node:url' 9 | import { join } from 'node:path' 10 | import { inspect, types } from 'node:util' 11 | import assert from 'node:assert' 12 | import { performance } from 'node:perf_hooks' 13 | import { readFileSync } from 'node:fs' 14 | import { availableParallelism } from 'node:os' 15 | import { 16 | type ReadyMessage, 17 | type RequestMessage, 18 | type ResponseMessage, 19 | type StartupMessage, 20 | kResponseCountField, 21 | kRequestCountField, 22 | kFieldCount, 23 | type Transferable, 24 | type Task, 25 | type TaskQueue, 26 | kQueueOptions, 27 | isTransferable, 28 | markMovable, 29 | isMovable, 30 | kTransferable, 31 | kValue, 32 | type TinypoolData, 33 | type TinypoolWorker, 34 | type TinypoolChannel, 35 | } from './common' 36 | import ThreadWorker from './runtime/thread-worker' 37 | import ProcessWorker from './runtime/process-worker' 38 | 39 | declare global { 40 | namespace NodeJS { 41 | interface Process { 42 | __tinypool_state__: { 43 | isTinypoolWorker: boolean 44 | isWorkerThread?: boolean 45 | isChildProcess?: boolean 46 | workerData: any 47 | workerId: number 48 | } 49 | } 50 | } 51 | } 52 | 53 | const cpuCount: number = availableParallelism() 54 | 55 | interface AbortSignalEventTargetAddOptions { 56 | once: boolean 57 | } 58 | 59 | interface AbortSignalEventTarget { 60 | addEventListener: ( 61 | name: 'abort', 62 | listener: () => void, 63 | options?: AbortSignalEventTargetAddOptions 64 | ) => void 65 | removeEventListener: (name: 'abort', listener: () => void) => void 66 | aborted?: boolean 67 | } 68 | interface AbortSignalEventEmitter { 69 | off: (name: 'abort', listener: () => void) => void 70 | once: (name: 'abort', listener: () => void) => void 71 | } 72 | type AbortSignalAny = AbortSignalEventTarget | AbortSignalEventEmitter 73 | function onabort(abortSignal: AbortSignalAny, listener: () => void) { 74 | if ('addEventListener' in abortSignal) { 75 | abortSignal.addEventListener('abort', listener, { once: true }) 76 | } else { 77 | abortSignal.once('abort', listener) 78 | } 79 | } 80 | class AbortError extends Error { 81 | constructor() { 82 | super('The task has been aborted') 83 | } 84 | 85 | get name() { 86 | return 'AbortError' 87 | } 88 | } 89 | 90 | class CancelError extends Error { 91 | constructor() { 92 | super('The task has been cancelled') 93 | } 94 | 95 | get name() { 96 | return 'CancelError' 97 | } 98 | } 99 | 100 | type ResourceLimits = Worker extends { 101 | resourceLimits?: infer T 102 | } 103 | ? T 104 | : object 105 | 106 | class ArrayTaskQueue implements TaskQueue { 107 | tasks: Task[] = [] 108 | 109 | get size() { 110 | return this.tasks.length 111 | } 112 | 113 | shift(): Task | null { 114 | return this.tasks.shift() as Task 115 | } 116 | 117 | push(task: Task): void { 118 | this.tasks.push(task) 119 | } 120 | 121 | remove(task: Task): void { 122 | const index = this.tasks.indexOf(task) 123 | assert.notStrictEqual(index, -1) 124 | this.tasks.splice(index, 1) 125 | } 126 | 127 | cancel(): void { 128 | while (this.tasks.length > 0) { 129 | const task = this.tasks.pop() 130 | task?.cancel() 131 | } 132 | } 133 | } 134 | 135 | interface Options { 136 | filename?: string | null 137 | runtime?: 'worker_threads' | 'child_process' 138 | name?: string 139 | minThreads?: number 140 | maxThreads?: number 141 | idleTimeout?: number 142 | terminateTimeout?: number 143 | maxQueue?: number | 'auto' 144 | concurrentTasksPerWorker?: number 145 | useAtomics?: boolean 146 | resourceLimits?: ResourceLimits 147 | maxMemoryLimitBeforeRecycle?: number 148 | argv?: string[] 149 | execArgv?: string[] 150 | env?: Record 151 | workerData?: any 152 | taskQueue?: TaskQueue 153 | trackUnmanagedFds?: boolean 154 | isolateWorkers?: boolean 155 | teardown?: string 156 | } 157 | 158 | interface FilledOptions extends Options { 159 | filename: string | null 160 | name: string 161 | runtime: NonNullable 162 | minThreads: number 163 | maxThreads: number 164 | idleTimeout: number 165 | maxQueue: number 166 | concurrentTasksPerWorker: number 167 | useAtomics: boolean 168 | taskQueue: TaskQueue 169 | } 170 | 171 | const kDefaultOptions: FilledOptions = { 172 | filename: null, 173 | name: 'default', 174 | runtime: 'worker_threads', 175 | minThreads: Math.max(cpuCount / 2, 1), 176 | maxThreads: cpuCount, 177 | idleTimeout: 0, 178 | maxQueue: Infinity, 179 | concurrentTasksPerWorker: 1, 180 | useAtomics: true, 181 | taskQueue: new ArrayTaskQueue(), 182 | trackUnmanagedFds: true, 183 | } 184 | 185 | interface RunOptions { 186 | transferList?: TransferList 187 | channel?: TinypoolChannel 188 | filename?: string | null 189 | signal?: AbortSignalAny | null 190 | name?: string | null 191 | runtime?: Options['runtime'] 192 | } 193 | 194 | interface FilledRunOptions extends RunOptions { 195 | transferList: TransferList | never 196 | filename: string | null 197 | signal: AbortSignalAny | null 198 | name: string | null 199 | } 200 | 201 | const kDefaultRunOptions: FilledRunOptions = { 202 | transferList: undefined, 203 | filename: null, 204 | signal: null, 205 | name: null, 206 | } 207 | 208 | class DirectlyTransferable implements Transferable { 209 | #value: object 210 | constructor(value: object) { 211 | this.#value = value 212 | } 213 | 214 | get [kTransferable](): object { 215 | return this.#value 216 | } 217 | 218 | get [kValue](): object { 219 | return this.#value 220 | } 221 | } 222 | 223 | class ArrayBufferViewTransferable implements Transferable { 224 | #view: ArrayBufferView 225 | constructor(view: ArrayBufferView) { 226 | this.#view = view 227 | } 228 | 229 | get [kTransferable](): object { 230 | return this.#view.buffer 231 | } 232 | 233 | get [kValue](): object { 234 | return this.#view 235 | } 236 | } 237 | 238 | let taskIdCounter = 0 239 | 240 | type TaskCallback = (err: Error, result: any) => void 241 | // Grab the type of `transferList` off `MessagePort`. At the time of writing, 242 | // only ArrayBuffer and MessagePort are valid, but let's avoid having to update 243 | // our types here every time Node.js adds support for more objects. 244 | type TransferList = MessagePort extends { 245 | postMessage(value: any, transferList: infer T): any 246 | } 247 | ? T 248 | : never 249 | type TransferListItem = TransferList extends (infer T)[] ? T : never 250 | 251 | function maybeFileURLToPath(filename: string): string { 252 | return filename.startsWith('file:') 253 | ? fileURLToPath(new URL(filename)) 254 | : filename 255 | } 256 | 257 | // Extend AsyncResource so that async relations between posting a task and 258 | // receiving its result are visible to diagnostic tools. 259 | class TaskInfo extends AsyncResource implements Task { 260 | callback: TaskCallback 261 | task: any 262 | transferList: TransferList 263 | channel?: TinypoolChannel 264 | filename: string 265 | name: string 266 | taskId: number 267 | abortSignal: AbortSignalAny | null 268 | abortListener: (() => void) | null = null 269 | workerInfo: WorkerInfo | null = null 270 | created: number 271 | started: number 272 | cancel: () => void 273 | 274 | constructor( 275 | task: any, 276 | transferList: TransferList, 277 | filename: string, 278 | name: string, 279 | callback: TaskCallback, 280 | abortSignal: AbortSignalAny | null, 281 | triggerAsyncId: number, 282 | channel?: TinypoolChannel 283 | ) { 284 | super('Tinypool.Task', { requireManualDestroy: true, triggerAsyncId }) 285 | this.callback = callback 286 | this.task = task 287 | this.transferList = transferList 288 | this.cancel = () => this.callback(new CancelError(), null) 289 | this.channel = channel 290 | 291 | // If the task is a Transferable returned by 292 | // Tinypool.move(), then add it to the transferList 293 | // automatically 294 | if (isMovable(task)) { 295 | // This condition should never be hit but typescript 296 | // complains if we dont do the check. 297 | /* istanbul ignore if */ 298 | if (this.transferList == null) { 299 | this.transferList = [] 300 | } 301 | this.transferList = this.transferList.concat(task[kTransferable]) 302 | this.task = task[kValue] 303 | } 304 | 305 | this.filename = filename 306 | this.name = name 307 | this.taskId = taskIdCounter++ 308 | this.abortSignal = abortSignal 309 | this.created = performance.now() 310 | this.started = 0 311 | } 312 | 313 | releaseTask(): any { 314 | const ret = this.task 315 | this.task = null 316 | return ret 317 | } 318 | 319 | done(err: unknown | null, result?: any): void { 320 | this.emitDestroy() // `TaskInfo`s are used only once. 321 | this.runInAsyncScope(this.callback, null, err, result) 322 | // If an abort signal was used, remove the listener from it when 323 | // done to make sure we do not accidentally leak. 324 | if (this.abortSignal && this.abortListener) { 325 | if ('removeEventListener' in this.abortSignal && this.abortListener) { 326 | this.abortSignal.removeEventListener('abort', this.abortListener) 327 | } else { 328 | ;(this.abortSignal as AbortSignalEventEmitter).off( 329 | 'abort', 330 | this.abortListener 331 | ) 332 | } 333 | } 334 | } 335 | 336 | get [kQueueOptions](): object | null { 337 | return kQueueOptions in this.task ? this.task[kQueueOptions] : null 338 | } 339 | } 340 | 341 | abstract class AsynchronouslyCreatedResource { 342 | onreadyListeners: (() => void)[] | null = [] 343 | 344 | markAsReady(): void { 345 | const listeners = this.onreadyListeners 346 | assert(listeners !== null) 347 | this.onreadyListeners = null 348 | for (const listener of listeners) { 349 | listener() 350 | } 351 | } 352 | 353 | isReady(): boolean { 354 | return this.onreadyListeners === null 355 | } 356 | 357 | onReady(fn: () => void) { 358 | if (this.onreadyListeners === null) { 359 | fn() // Zalgo is okay here. 360 | return 361 | } 362 | this.onreadyListeners.push(fn) 363 | } 364 | 365 | abstract currentUsage(): number 366 | } 367 | 368 | class AsynchronouslyCreatedResourcePool< 369 | T extends AsynchronouslyCreatedResource, 370 | > { 371 | pendingItems = new Set() 372 | readyItems = new Set() 373 | maximumUsage: number 374 | onAvailableListeners: ((item: T) => void)[] 375 | 376 | constructor(maximumUsage: number) { 377 | this.maximumUsage = maximumUsage 378 | this.onAvailableListeners = [] 379 | } 380 | 381 | add(item: T) { 382 | this.pendingItems.add(item) 383 | item.onReady(() => { 384 | /* istanbul ignore else */ 385 | if (this.pendingItems.has(item)) { 386 | this.pendingItems.delete(item) 387 | this.readyItems.add(item) 388 | this.maybeAvailable(item) 389 | } 390 | }) 391 | } 392 | 393 | delete(item: T) { 394 | this.pendingItems.delete(item) 395 | this.readyItems.delete(item) 396 | } 397 | 398 | findAvailable(): T | null { 399 | let minUsage = this.maximumUsage 400 | let candidate = null 401 | for (const item of this.readyItems) { 402 | const usage = item.currentUsage() 403 | if (usage === 0) return item 404 | if (usage < minUsage) { 405 | candidate = item 406 | minUsage = usage 407 | } 408 | } 409 | return candidate 410 | } 411 | 412 | *[Symbol.iterator]() { 413 | yield* this.pendingItems 414 | yield* this.readyItems 415 | } 416 | 417 | get size() { 418 | return this.pendingItems.size + this.readyItems.size 419 | } 420 | 421 | maybeAvailable(item: T) { 422 | /* istanbul ignore else */ 423 | if (item.currentUsage() < this.maximumUsage) { 424 | for (const listener of this.onAvailableListeners) { 425 | listener(item) 426 | } 427 | } 428 | } 429 | 430 | onAvailable(fn: (item: T) => void) { 431 | this.onAvailableListeners.push(fn) 432 | } 433 | } 434 | 435 | type ResponseCallback = (response: ResponseMessage) => void 436 | 437 | const Errors = { 438 | ThreadTermination: () => new Error('Terminating worker thread'), 439 | FilenameNotProvided: () => 440 | new Error('filename must be provided to run() or in options object'), 441 | TaskQueueAtLimit: () => new Error('Task queue is at limit'), 442 | NoTaskQueueAvailable: () => 443 | new Error('No task queue available and all Workers are busy'), 444 | } 445 | 446 | class WorkerInfo extends AsynchronouslyCreatedResource { 447 | worker: TinypoolWorker 448 | workerId: number 449 | freeWorkerId: () => void 450 | taskInfos: Map 451 | idleTimeout: NodeJS.Timeout | null = null 452 | port: MessagePort 453 | sharedBuffer: Int32Array 454 | lastSeenResponseCount: number = 0 455 | usedMemory?: number 456 | onMessage: ResponseCallback 457 | shouldRecycle?: boolean 458 | filename?: string | null 459 | teardown?: string 460 | 461 | constructor( 462 | worker: TinypoolWorker, 463 | port: MessagePort, 464 | workerId: number, 465 | freeWorkerId: () => void, 466 | onMessage: ResponseCallback, 467 | filename?: string | null, 468 | teardown?: string 469 | ) { 470 | super() 471 | this.worker = worker 472 | this.workerId = workerId 473 | this.freeWorkerId = freeWorkerId 474 | this.teardown = teardown 475 | this.filename = filename 476 | this.port = port 477 | this.port.on('message', (message: ResponseMessage) => 478 | this._handleResponse(message) 479 | ) 480 | this.onMessage = onMessage 481 | this.taskInfos = new Map() 482 | this.sharedBuffer = new Int32Array( 483 | new SharedArrayBuffer(kFieldCount * Int32Array.BYTES_PER_ELEMENT) 484 | ) 485 | } 486 | 487 | async destroy(timeout?: number): Promise { 488 | let resolve: () => void 489 | let reject: (err: Error) => void 490 | 491 | const ret = new Promise((res, rej) => { 492 | resolve = res 493 | reject = rej 494 | }) 495 | 496 | if (this.teardown && this.filename) { 497 | const { teardown, filename } = this 498 | 499 | await new Promise((resolve, reject) => { 500 | this.postTask( 501 | new TaskInfo( 502 | {}, 503 | [], 504 | filename, 505 | teardown, 506 | (error, result) => (error ? reject(error) : resolve(result)), 507 | null, 508 | 1, 509 | undefined 510 | ) 511 | ) 512 | }) 513 | } 514 | 515 | const timer = timeout 516 | ? setTimeout( 517 | () => reject(new Error('Failed to terminate worker')), 518 | timeout 519 | ) 520 | : null 521 | 522 | void this.worker.terminate().then(() => { 523 | if (timer !== null) { 524 | clearTimeout(timer) 525 | } 526 | 527 | this.port.close() 528 | this.clearIdleTimeout() 529 | for (const taskInfo of this.taskInfos.values()) { 530 | taskInfo.done(Errors.ThreadTermination()) 531 | } 532 | this.taskInfos.clear() 533 | 534 | resolve() 535 | }) 536 | 537 | return ret 538 | } 539 | 540 | clearIdleTimeout(): void { 541 | if (this.idleTimeout !== null) { 542 | clearTimeout(this.idleTimeout) 543 | this.idleTimeout = null 544 | } 545 | } 546 | 547 | ref(): WorkerInfo { 548 | this.port.ref() 549 | return this 550 | } 551 | 552 | unref(): WorkerInfo { 553 | // Note: Do not call ref()/unref() on the Worker itself since that may cause 554 | // a hard crash, see https://github.com/nodejs/node/pull/33394. 555 | this.port.unref() 556 | return this 557 | } 558 | 559 | _handleResponse(message: ResponseMessage): void { 560 | this.usedMemory = message.usedMemory 561 | this.onMessage(message) 562 | 563 | if (this.taskInfos.size === 0) { 564 | // No more tasks running on this Worker means it should not keep the 565 | // process running. 566 | this.unref() 567 | } 568 | } 569 | 570 | postTask(taskInfo: TaskInfo) { 571 | assert(!this.taskInfos.has(taskInfo.taskId)) 572 | const message: RequestMessage = { 573 | task: taskInfo.releaseTask(), 574 | taskId: taskInfo.taskId, 575 | filename: taskInfo.filename, 576 | name: taskInfo.name, 577 | } 578 | 579 | try { 580 | if (taskInfo.channel) { 581 | this.worker.setChannel?.(taskInfo.channel) 582 | } 583 | this.port.postMessage(message, taskInfo.transferList) 584 | } catch (err) { 585 | // This would mostly happen if e.g. message contains unserializable data 586 | // or transferList is invalid. 587 | taskInfo.done(err) 588 | return 589 | } 590 | 591 | taskInfo.workerInfo = this 592 | this.taskInfos.set(taskInfo.taskId, taskInfo) 593 | this.ref() 594 | this.clearIdleTimeout() 595 | 596 | // Inform the worker that there are new messages posted, and wake it up 597 | // if it is waiting for one. 598 | Atomics.add(this.sharedBuffer, kRequestCountField, 1) 599 | Atomics.notify(this.sharedBuffer, kRequestCountField, 1) 600 | } 601 | 602 | processPendingMessages() { 603 | // If we *know* that there are more messages than we have received using 604 | // 'message' events yet, then try to load and handle them synchronously, 605 | // without the need to wait for more expensive events on the event loop. 606 | // This would usually break async tracking, but in our case, we already have 607 | // the extra TaskInfo/AsyncResource layer that rectifies that situation. 608 | const actualResponseCount = Atomics.load( 609 | this.sharedBuffer, 610 | kResponseCountField 611 | ) 612 | if (actualResponseCount !== this.lastSeenResponseCount) { 613 | this.lastSeenResponseCount = actualResponseCount 614 | 615 | let entry 616 | while ((entry = receiveMessageOnPort(this.port)) !== undefined) { 617 | this._handleResponse(entry.message) 618 | } 619 | } 620 | } 621 | 622 | isRunningAbortableTask(): boolean { 623 | // If there are abortable tasks, we are running one at most per Worker. 624 | if (this.taskInfos.size !== 1) return false 625 | const [first] = this.taskInfos 626 | const [, task] = first || [] 627 | return task?.abortSignal !== null 628 | } 629 | 630 | currentUsage(): number { 631 | if (this.isRunningAbortableTask()) return Infinity 632 | return this.taskInfos.size 633 | } 634 | } 635 | 636 | class ThreadPool { 637 | publicInterface: Tinypool 638 | workers: AsynchronouslyCreatedResourcePool 639 | workerIds: Map // Map 640 | options: FilledOptions 641 | taskQueue: TaskQueue 642 | skipQueue: TaskInfo[] = [] 643 | completed: number = 0 644 | start: number = performance.now() 645 | inProcessPendingMessages: boolean = false 646 | startingUp: boolean = false 647 | workerFailsDuringBootstrap: boolean = false 648 | 649 | constructor(publicInterface: Tinypool, options: Options) { 650 | this.publicInterface = publicInterface 651 | this.taskQueue = options.taskQueue || new ArrayTaskQueue() 652 | 653 | const filename = options.filename 654 | ? maybeFileURLToPath(options.filename) 655 | : null 656 | this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 } 657 | // The >= and <= could be > and < but this way we get 100 % coverage 🙃 658 | if ( 659 | options.maxThreads !== undefined && 660 | this.options.minThreads >= options.maxThreads 661 | ) { 662 | this.options.minThreads = options.maxThreads 663 | } 664 | if ( 665 | options.minThreads !== undefined && 666 | this.options.maxThreads <= options.minThreads 667 | ) { 668 | this.options.maxThreads = options.minThreads 669 | } 670 | if (options.maxQueue === 'auto') { 671 | this.options.maxQueue = this.options.maxThreads ** 2 672 | } else { 673 | this.options.maxQueue = options.maxQueue ?? kDefaultOptions.maxQueue 674 | } 675 | 676 | this.workerIds = new Map( 677 | new Array(this.options.maxThreads).fill(0).map((_, i) => [i + 1, true]) 678 | ) 679 | 680 | this.workers = new AsynchronouslyCreatedResourcePool( 681 | this.options.concurrentTasksPerWorker 682 | ) 683 | this.workers.onAvailable((w: WorkerInfo) => this._onWorkerAvailable(w)) 684 | 685 | this.startingUp = true 686 | this._ensureMinimumWorkers() 687 | this.startingUp = false 688 | } 689 | _ensureEnoughWorkersForTaskQueue(): void { 690 | while ( 691 | this.workers.size < this.taskQueue.size && 692 | this.workers.size < this.options.maxThreads 693 | ) { 694 | this._addNewWorker() 695 | } 696 | } 697 | 698 | _ensureMaximumWorkers(): void { 699 | while (this.workers.size < this.options.maxThreads) { 700 | this._addNewWorker() 701 | } 702 | } 703 | 704 | _ensureMinimumWorkers(): void { 705 | while (this.workers.size < this.options.minThreads) { 706 | this._addNewWorker() 707 | } 708 | } 709 | 710 | _addNewWorker(): void { 711 | const workerIds = this.workerIds 712 | 713 | let workerId: number 714 | 715 | workerIds.forEach((isIdAvailable, _workerId) => { 716 | if (isIdAvailable && !workerId) { 717 | workerId = _workerId 718 | workerIds.set(_workerId, false) 719 | } 720 | }) 721 | const tinypoolPrivateData = { workerId: workerId! } 722 | 723 | const worker = 724 | this.options.runtime === 'child_process' 725 | ? new ProcessWorker() 726 | : new ThreadWorker() 727 | 728 | worker.initialize({ 729 | env: this.options.env, 730 | argv: this.options.argv, 731 | execArgv: this.options.execArgv, 732 | resourceLimits: this.options.resourceLimits, 733 | workerData: [ 734 | tinypoolPrivateData, 735 | this.options.workerData, 736 | ] as TinypoolData, 737 | trackUnmanagedFds: this.options.trackUnmanagedFds, 738 | }) 739 | 740 | const onMessage = (message: ResponseMessage) => { 741 | const { taskId, result } = message 742 | // In case of success: Call the callback that was passed to `runTask`, 743 | // remove the `TaskInfo` associated with the Worker, which marks it as 744 | // free again. 745 | const taskInfo = workerInfo.taskInfos.get(taskId) 746 | workerInfo.taskInfos.delete(taskId) 747 | 748 | // Mark worker as available if it's not about to be removed 749 | if (!this.shouldRecycleWorker(taskInfo)) { 750 | this.workers.maybeAvailable(workerInfo) 751 | } 752 | 753 | /* istanbul ignore if */ 754 | if (taskInfo === undefined) { 755 | const err = new Error( 756 | `Unexpected message from Worker: ${inspect(message)}` 757 | ) 758 | this.publicInterface.emit('error', err) 759 | } else { 760 | taskInfo.done(message.error, result) 761 | } 762 | 763 | this._processPendingMessages() 764 | } 765 | 766 | const { port1, port2 } = new MessageChannel() 767 | const workerInfo = new WorkerInfo( 768 | worker, 769 | port1, 770 | workerId!, 771 | () => workerIds.set(workerId, true), 772 | onMessage, 773 | this.options.filename, 774 | this.options.teardown 775 | ) 776 | if (this.startingUp) { 777 | // There is no point in waiting for the initial set of Workers to indicate 778 | // that they are ready, we just mark them as such from the start. 779 | workerInfo.markAsReady() 780 | } 781 | 782 | const message: StartupMessage = { 783 | filename: this.options.filename, 784 | name: this.options.name, 785 | port: port2, 786 | sharedBuffer: workerInfo.sharedBuffer, 787 | useAtomics: this.options.useAtomics, 788 | } 789 | 790 | worker.postMessage(message, [port2]) 791 | 792 | worker.on('message', (message: ReadyMessage) => { 793 | if (message.ready === true) { 794 | port1.start() 795 | 796 | if (workerInfo.currentUsage() === 0) { 797 | workerInfo.unref() 798 | } 799 | 800 | if (!workerInfo.isReady()) { 801 | workerInfo.markAsReady() 802 | } 803 | return 804 | } 805 | 806 | worker.emit( 807 | 'error', 808 | new Error(`Unexpected message on Worker: ${inspect(message)}`) 809 | ) 810 | }) 811 | 812 | worker.on('error', (err: Error) => { 813 | // Work around the bug in https://github.com/nodejs/node/pull/33394 814 | worker.ref = () => {} 815 | 816 | // In case of an uncaught exception: Call the callback that was passed to 817 | // `postTask` with the error, or emit an 'error' event if there is none. 818 | const taskInfos = [...workerInfo.taskInfos.values()] 819 | workerInfo.taskInfos.clear() 820 | 821 | // Remove the worker from the list and potentially start a new Worker to 822 | // replace the current one. 823 | void this._removeWorker(workerInfo) 824 | 825 | if (workerInfo.isReady() && !this.workerFailsDuringBootstrap) { 826 | this._ensureMinimumWorkers() 827 | } else { 828 | // Do not start new workers over and over if they already fail during 829 | // bootstrap, there's no point. 830 | this.workerFailsDuringBootstrap = true 831 | } 832 | 833 | if (taskInfos.length > 0) { 834 | for (const taskInfo of taskInfos) { 835 | taskInfo.done(err, null) 836 | } 837 | } else { 838 | this.publicInterface.emit('error', err) 839 | } 840 | }) 841 | 842 | worker.unref() 843 | port1.on('close', () => { 844 | // The port is only closed if the Worker stops for some reason, but we 845 | // always .unref() the Worker itself. We want to receive e.g. 'error' 846 | // events on it, so we ref it once we know it's going to exit anyway. 847 | worker.ref() 848 | }) 849 | 850 | this.workers.add(workerInfo) 851 | } 852 | 853 | _processPendingMessages() { 854 | if (this.inProcessPendingMessages || !this.options.useAtomics) { 855 | return 856 | } 857 | 858 | this.inProcessPendingMessages = true 859 | try { 860 | for (const workerInfo of this.workers) { 861 | workerInfo.processPendingMessages() 862 | } 863 | } finally { 864 | this.inProcessPendingMessages = false 865 | } 866 | } 867 | 868 | _removeWorker(workerInfo: WorkerInfo): Promise { 869 | workerInfo.freeWorkerId() 870 | 871 | this.workers.delete(workerInfo) 872 | 873 | return workerInfo.destroy(this.options.terminateTimeout) 874 | } 875 | 876 | _onWorkerAvailable(workerInfo: WorkerInfo): void { 877 | while ( 878 | (this.taskQueue.size > 0 || this.skipQueue.length > 0) && 879 | workerInfo.currentUsage() < this.options.concurrentTasksPerWorker 880 | ) { 881 | // The skipQueue will have tasks that we previously shifted off 882 | // the task queue but had to skip over... we have to make sure 883 | // we drain that before we drain the taskQueue. 884 | const taskInfo = 885 | this.skipQueue.shift() || (this.taskQueue.shift() as TaskInfo) 886 | // If the task has an abortSignal and the worker has any other 887 | // tasks, we cannot distribute the task to it. Skip for now. 888 | if (taskInfo.abortSignal && workerInfo.taskInfos.size > 0) { 889 | this.skipQueue.push(taskInfo) 890 | break 891 | } 892 | const now = performance.now() 893 | taskInfo.started = now 894 | workerInfo.postTask(taskInfo) 895 | this._maybeDrain() 896 | return 897 | } 898 | 899 | if ( 900 | workerInfo.taskInfos.size === 0 && 901 | this.workers.size > this.options.minThreads 902 | ) { 903 | workerInfo.idleTimeout = setTimeout(() => { 904 | assert.strictEqual(workerInfo.taskInfos.size, 0) 905 | if (this.workers.size > this.options.minThreads) { 906 | void this._removeWorker(workerInfo) 907 | } 908 | }, this.options.idleTimeout).unref() 909 | } 910 | } 911 | 912 | runTask(task: any, options: RunOptions): Promise { 913 | let { filename, name } = options 914 | const { transferList = [], signal = null, channel } = options 915 | 916 | if (filename == null) { 917 | filename = this.options.filename 918 | } 919 | if (name == null) { 920 | name = this.options.name 921 | } 922 | if (typeof filename !== 'string') { 923 | return Promise.reject(Errors.FilenameNotProvided()) 924 | } 925 | filename = maybeFileURLToPath(filename) 926 | 927 | let resolve: (result: any) => void 928 | let reject: (err: Error) => void 929 | 930 | const ret = new Promise((res, rej) => { 931 | resolve = res 932 | reject = rej 933 | }) 934 | const taskInfo = new TaskInfo( 935 | task, 936 | transferList, 937 | filename, 938 | name, 939 | (err: Error | null, result: any) => { 940 | this.completed++ 941 | if (err !== null) { 942 | reject(err) 943 | } 944 | 945 | if (this.shouldRecycleWorker(taskInfo)) { 946 | this._removeWorker(taskInfo.workerInfo!) 947 | .then(() => this._ensureMinimumWorkers()) 948 | .then(() => this._ensureEnoughWorkersForTaskQueue()) 949 | .then(() => resolve(result)) 950 | .catch(reject) 951 | } else { 952 | resolve(result) 953 | } 954 | }, 955 | signal, 956 | this.publicInterface.asyncResource.asyncId(), 957 | channel 958 | ) 959 | 960 | if (signal !== null) { 961 | // If the AbortSignal has an aborted property and it's truthy, 962 | // reject immediately. 963 | if ((signal as AbortSignalEventTarget).aborted) { 964 | return Promise.reject(new AbortError()) 965 | } 966 | taskInfo.abortListener = () => { 967 | // Call reject() first to make sure we always reject with the AbortError 968 | // if the task is aborted, not with an Error from the possible 969 | // thread termination below. 970 | reject(new AbortError()) 971 | 972 | if (taskInfo.workerInfo !== null) { 973 | // Already running: We cancel the Worker this is running on. 974 | void this._removeWorker(taskInfo.workerInfo) 975 | this._ensureMinimumWorkers() 976 | } else { 977 | // Not yet running: Remove it from the queue. 978 | this.taskQueue.remove(taskInfo) 979 | } 980 | } 981 | onabort(signal, taskInfo.abortListener) 982 | } 983 | 984 | // If there is a task queue, there's no point in looking for an available 985 | // Worker thread. Add this task to the queue, if possible. 986 | if (this.taskQueue.size > 0) { 987 | const totalCapacity = this.options.maxQueue + this.pendingCapacity() 988 | if (this.taskQueue.size >= totalCapacity) { 989 | if (this.options.maxQueue === 0) { 990 | return Promise.reject(Errors.NoTaskQueueAvailable()) 991 | } else { 992 | return Promise.reject(Errors.TaskQueueAtLimit()) 993 | } 994 | } else { 995 | if (this.workers.size < this.options.maxThreads) { 996 | this._addNewWorker() 997 | } 998 | this.taskQueue.push(taskInfo) 999 | } 1000 | 1001 | return ret 1002 | } 1003 | 1004 | // Look for a Worker with a minimum number of tasks it is currently running. 1005 | let workerInfo: WorkerInfo | null = this.workers.findAvailable() 1006 | 1007 | // If we want the ability to abort this task, use only workers that have 1008 | // no running tasks. 1009 | if (workerInfo !== null && workerInfo.currentUsage() > 0 && signal) { 1010 | workerInfo = null 1011 | } 1012 | 1013 | // If no Worker was found, or that Worker was handling another task in some 1014 | // way, and we still have the ability to spawn new threads, do so. 1015 | let waitingForNewWorker = false 1016 | if ( 1017 | (workerInfo === null || workerInfo.currentUsage() > 0) && 1018 | this.workers.size < this.options.maxThreads 1019 | ) { 1020 | this._addNewWorker() 1021 | waitingForNewWorker = true 1022 | } 1023 | 1024 | // If no Worker is found, try to put the task into the queue. 1025 | if (workerInfo === null) { 1026 | if (this.options.maxQueue <= 0 && !waitingForNewWorker) { 1027 | return Promise.reject(Errors.NoTaskQueueAvailable()) 1028 | } else { 1029 | this.taskQueue.push(taskInfo) 1030 | } 1031 | 1032 | return ret 1033 | } 1034 | 1035 | const now = performance.now() 1036 | taskInfo.started = now 1037 | workerInfo.postTask(taskInfo) 1038 | this._maybeDrain() 1039 | 1040 | return ret 1041 | } 1042 | 1043 | shouldRecycleWorker(taskInfo?: TaskInfo): boolean { 1044 | // Worker could be set to recycle by pool's imperative methods 1045 | if (taskInfo?.workerInfo?.shouldRecycle) { 1046 | return true 1047 | } 1048 | 1049 | // When `isolateWorkers` is enabled, remove the worker after task is finished 1050 | if (this.options.isolateWorkers && taskInfo?.workerInfo) { 1051 | return true 1052 | } 1053 | 1054 | // When `maxMemoryLimitBeforeRecycle` is enabled, remove workers that have exceeded the memory limit 1055 | if ( 1056 | !this.options.isolateWorkers && 1057 | this.options.maxMemoryLimitBeforeRecycle !== undefined && 1058 | (taskInfo?.workerInfo?.usedMemory || 0) > 1059 | this.options.maxMemoryLimitBeforeRecycle 1060 | ) { 1061 | return true 1062 | } 1063 | 1064 | return false 1065 | } 1066 | 1067 | pendingCapacity(): number { 1068 | return ( 1069 | this.workers.pendingItems.size * this.options.concurrentTasksPerWorker 1070 | ) 1071 | } 1072 | 1073 | _maybeDrain() { 1074 | if (this.taskQueue.size === 0 && this.skipQueue.length === 0) { 1075 | this.publicInterface.emit('drain') 1076 | } 1077 | } 1078 | 1079 | async destroy() { 1080 | while (this.skipQueue.length > 0) { 1081 | const taskInfo: TaskInfo = this.skipQueue.shift() as TaskInfo 1082 | taskInfo.done(new Error('Terminating worker thread')) 1083 | } 1084 | while (this.taskQueue.size > 0) { 1085 | const taskInfo: TaskInfo = this.taskQueue.shift() as TaskInfo 1086 | taskInfo.done(new Error('Terminating worker thread')) 1087 | } 1088 | 1089 | const exitEvents: Promise[] = [] 1090 | while (this.workers.size > 0) { 1091 | const [workerInfo] = this.workers 1092 | // @ts-expect-error -- TODO Fix 1093 | exitEvents.push(once(workerInfo.worker, 'exit')) 1094 | // @ts-expect-error -- TODO Fix 1095 | void this._removeWorker(workerInfo) 1096 | } 1097 | 1098 | await Promise.all(exitEvents) 1099 | } 1100 | 1101 | async recycleWorkers(options: Pick = {}) { 1102 | const runtimeChanged = 1103 | options?.runtime && options.runtime !== this.options.runtime 1104 | 1105 | if (options?.runtime) { 1106 | this.options.runtime = options.runtime 1107 | } 1108 | 1109 | // Worker's are automatically recycled when isolateWorkers is enabled. 1110 | // Idle workers still need to be recycled if runtime changed 1111 | if (this.options.isolateWorkers && !runtimeChanged) { 1112 | return 1113 | } 1114 | 1115 | const exitEvents: Promise[] = [] 1116 | 1117 | Array.from(this.workers).filter((workerInfo) => { 1118 | // Remove idle workers 1119 | if (workerInfo.currentUsage() === 0) { 1120 | // @ts-expect-error -- TODO Fix 1121 | exitEvents.push(once(workerInfo.worker, 'exit')) 1122 | void this._removeWorker(workerInfo) 1123 | } 1124 | // Mark on-going workers for recycling. 1125 | // Note that we don't need to wait for these ones to finish 1126 | // as pool.shouldRecycleWorker will do it once task has finished 1127 | else { 1128 | workerInfo.shouldRecycle = true 1129 | } 1130 | }) 1131 | 1132 | await Promise.all(exitEvents) 1133 | 1134 | this._ensureMinimumWorkers() 1135 | } 1136 | } 1137 | 1138 | class Tinypool extends EventEmitterAsyncResource { 1139 | #pool: ThreadPool 1140 | 1141 | constructor(options: Options = {}) { 1142 | // convert fractional option values to int 1143 | if ( 1144 | options.minThreads !== undefined && 1145 | options.minThreads > 0 && 1146 | options.minThreads < 1 1147 | ) { 1148 | options.minThreads = Math.max( 1149 | 1, 1150 | Math.floor(options.minThreads * cpuCount) 1151 | ) 1152 | } 1153 | if ( 1154 | options.maxThreads !== undefined && 1155 | options.maxThreads > 0 && 1156 | options.maxThreads < 1 1157 | ) { 1158 | options.maxThreads = Math.max( 1159 | 1, 1160 | Math.floor(options.maxThreads * cpuCount) 1161 | ) 1162 | } 1163 | 1164 | super({ ...options, name: 'Tinypool' }) 1165 | 1166 | if ( 1167 | options.minThreads !== undefined && 1168 | options.maxThreads !== undefined && 1169 | options.minThreads > options.maxThreads 1170 | ) { 1171 | throw new RangeError( 1172 | 'options.minThreads and options.maxThreads must not conflict' 1173 | ) 1174 | } 1175 | 1176 | this.#pool = new ThreadPool(this, options) 1177 | } 1178 | 1179 | run(task: any, options: RunOptions = kDefaultRunOptions) { 1180 | const { transferList, filename, name, signal, runtime, channel } = options 1181 | 1182 | return this.#pool.runTask(task, { 1183 | transferList, 1184 | filename, 1185 | name, 1186 | signal, 1187 | runtime, 1188 | channel, 1189 | }) 1190 | } 1191 | 1192 | async destroy() { 1193 | await this.#pool.destroy() 1194 | this.emitDestroy() 1195 | } 1196 | 1197 | get options(): FilledOptions { 1198 | return this.#pool.options 1199 | } 1200 | 1201 | get threads(): TinypoolWorker[] { 1202 | const ret: TinypoolWorker[] = [] 1203 | for (const workerInfo of this.#pool.workers) { 1204 | ret.push(workerInfo.worker) 1205 | } 1206 | return ret 1207 | } 1208 | 1209 | get queueSize(): number { 1210 | const pool = this.#pool 1211 | return Math.max(pool.taskQueue.size - pool.pendingCapacity(), 0) 1212 | } 1213 | 1214 | cancelPendingTasks() { 1215 | const pool = this.#pool 1216 | pool.taskQueue.cancel() 1217 | } 1218 | 1219 | async recycleWorkers(options: Pick = {}) { 1220 | await this.#pool.recycleWorkers(options) 1221 | } 1222 | 1223 | get completed(): number { 1224 | return this.#pool.completed 1225 | } 1226 | 1227 | get duration(): number { 1228 | return performance.now() - this.#pool.start 1229 | } 1230 | 1231 | static get isWorkerThread(): boolean { 1232 | return process.__tinypool_state__?.isWorkerThread || false 1233 | } 1234 | 1235 | static get workerData(): any { 1236 | return process.__tinypool_state__?.workerData || undefined 1237 | } 1238 | 1239 | static get version(): string { 1240 | const { version } = JSON.parse( 1241 | readFileSync(join(__dirname, '../package.json'), 'utf-8') 1242 | ) as typeof import('../package.json') 1243 | return version 1244 | } 1245 | 1246 | static move( 1247 | val: 1248 | | Transferable 1249 | | TransferListItem 1250 | | ArrayBufferView 1251 | | ArrayBuffer 1252 | | MessagePort 1253 | ) { 1254 | if (val != null && typeof val === 'object' && typeof val !== 'function') { 1255 | if (!isTransferable(val)) { 1256 | if (types.isArrayBufferView(val)) { 1257 | val = new ArrayBufferViewTransferable(val as ArrayBufferView) 1258 | } else { 1259 | val = new DirectlyTransferable(val) 1260 | } 1261 | } 1262 | markMovable(val) 1263 | } 1264 | return val 1265 | } 1266 | 1267 | static get transferableSymbol() { 1268 | return kTransferable 1269 | } 1270 | 1271 | static get valueSymbol() { 1272 | return kValue 1273 | } 1274 | 1275 | static get queueOptionsSymbol() { 1276 | return kQueueOptions 1277 | } 1278 | } 1279 | 1280 | const _workerId = process.__tinypool_state__?.workerId 1281 | 1282 | export * from './common' 1283 | export { Tinypool, Options, _workerId as workerId } 1284 | export default Tinypool 1285 | --------------------------------------------------------------------------------