diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d6f77c..bb7843c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,26 +17,27 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules - uses: actions/cache@v2 - env: - cache-name: cache-node-modules + uses: actions/cache@v4 + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false @@ -56,28 +57,29 @@ jobs: PGPASSWORD: postgres PGDATABASE: postgres steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules - uses: actions/cache@v2 - env: - cache-name: cache-node-modules + uses: actions/cache@v4 + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false @@ -108,7 +110,7 @@ jobs: - lint - test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} fetch-depth: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb45b6..8ae3f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +## [1.8.0](https://github.com/hirosystems/api-toolkit/compare/v1.7.5...v1.8.0) (2025-03-25) + + +### Features + +* add `reject` function to `waiter` ([#38](https://github.com/hirosystems/api-toolkit/issues/38)) ([8c56e1a](https://github.com/hirosystems/api-toolkit/commit/8c56e1aabbac026ba18ca3f8792394bd654aa5e6)) + +## [1.7.5](https://github.com/hirosystems/api-toolkit/compare/v1.7.4...v1.7.5) (2025-03-24) + + +### Bug Fixes + +* missing condition for `PostgresError: the database system is not yet accepting connections` ([#36](https://github.com/hirosystems/api-toolkit/issues/36)) ([6423000](https://github.com/hirosystems/api-toolkit/commit/6423000fe9d3cb9424179076ae50e487d7facf38)) + +## [1.7.4](https://github.com/hirosystems/api-toolkit/compare/v1.7.3...v1.7.4) (2025-03-05) + + +### Bug Fixes + +* isFinished never set on `waiter` ([#35](https://github.com/hirosystems/api-toolkit/issues/35)) ([9c1d2b3](https://github.com/hirosystems/api-toolkit/commit/9c1d2b3dcd6519e46324a56df83da2ebb6cc53e5)) + +## [1.7.3](https://github.com/hirosystems/api-toolkit/compare/v1.7.2...v1.7.3) (2025-03-04) + + +### Bug Fixes + +* use the built-in abortable async setTimeout from `node:timers/promises` rather than implementing ourselves ([#33](https://github.com/hirosystems/api-toolkit/issues/33)) ([954e7ea](https://github.com/hirosystems/api-toolkit/commit/954e7eaf47f747a6666e3d1884cf353ab0086f32)) + +## [1.7.2](https://github.com/hirosystems/api-toolkit/compare/v1.7.1...v1.7.2) (2024-10-31) + + +### Bug Fixes + +* memleak in timeout w/ abort signal ([#32](https://github.com/hirosystems/api-toolkit/issues/32)) ([d56a9ad](https://github.com/hirosystems/api-toolkit/commit/d56a9ad24f9850be3c372769b9486b71a85f4ae3)) + +## [1.7.1](https://github.com/hirosystems/api-toolkit/compare/v1.7.0...v1.7.1) (2024-08-19) + + +### Bug Fixes + +* heap snapshot file download ([#30](https://github.com/hirosystems/api-toolkit/issues/30)) ([800982b](https://github.com/hirosystems/api-toolkit/commit/800982b23393946af6c3017063506be1bb4e46df)) + +## [1.7.0](https://github.com/hirosystems/api-toolkit/compare/v1.6.2...v1.7.0) (2024-08-16) + + +### Features + +* add fastify cpu profiler server ([#28](https://github.com/hirosystems/api-toolkit/issues/28)) ([b224e06](https://github.com/hirosystems/api-toolkit/commit/b224e0673f09b71d52b8506f487c91aa60afdce5)) + ## [1.6.2](https://github.com/hirosystems/api-toolkit/compare/v1.6.1...v1.6.2) (2024-07-05) diff --git a/README.md b/README.md index ab7b867..804963b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Please see each tool's source directory for additional documentation * Node.js signal handlers that provide a way to shut down long-running application components gracefully on unhandled exceptions or interrupt signals. -### CPU Profiler +### Profiler server * Fastify server that controls a profiler capable of generating: * `.cpuprofile` files for CPU usage analysis diff --git a/package-lock.json b/package-lock.json index 1be43da..23c0e57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hirosystems/api-toolkit", - "version": "1.6.2", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hirosystems/api-toolkit", - "version": "1.6.2", + "version": "1.8.0", "license": "Apache 2.0", "dependencies": { "@fastify/cors": "^8.0.0", diff --git a/package.json b/package.json index ae828f8..c3b8763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hirosystems/api-toolkit", - "version": "1.6.2", + "version": "1.8.0", "description": "API development toolkit", "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/src/helpers/__tests__/helpers.test.ts b/src/helpers/__tests__/helpers.test.ts new file mode 100644 index 0000000..44e4100 --- /dev/null +++ b/src/helpers/__tests__/helpers.test.ts @@ -0,0 +1,112 @@ +import * as events from 'node:events'; +import { timeout, waiter } from '../time'; + +describe('Helper tests', () => { + test('timeout function should not cause memory leak by accumulating abort listeners on abort', async () => { + const controller = new AbortController(); + const { signal } = controller; + + const countListeners = () => events.getEventListeners(signal, 'abort').length; + + // Ensure the initial listener count is zero + expect(countListeners()).toBe(0); + + // Run enough iterations to detect a pattern + for (let i = 0; i < 100; i++) { + try { + const sleepPromise = timeout(1000, signal); + controller.abort(); // Abort immediately + await sleepPromise; + } catch (err: any) { + expect(err.toString()).toMatch(/aborted/i); + } + + // Assert that listener count does not increase + expect(countListeners()).toBeLessThanOrEqual(1); // 1 listener may temporarily be added and removed + } + + // Final check to confirm listeners are cleaned up + expect(countListeners()).toBe(0); + }); + + test('timeout function should not cause memory leak by accumulating abort listeners on successful completion', async () => { + const controller = new AbortController(); + const { signal } = controller; + + const countListeners = () => events.getEventListeners(signal, 'abort').length; + + // Ensure the initial listener count is zero + expect(countListeners()).toBe(0); + + // Run enough iterations to detect a pattern + for (let i = 0; i < 100; i++) { + await timeout(2, signal); // Complete sleep without abort + + // Assert that listener count does not increase + expect(countListeners()).toBe(0); // No listeners should remain after successful sleep completion + } + + // Final check to confirm listeners are cleaned up + expect(countListeners()).toBe(0); + }); + + test('waiter is resolved', async () => { + const myWaiter = waiter(); + myWaiter.resolve(); + await myWaiter; + expect(myWaiter.isFinished).toBe(true); + expect(myWaiter.isRejected).toBe(false); + expect(myWaiter.isResolved).toBe(true); + }); + + test('waiter is resolved with value', async () => { + const myWaiter = waiter(); + const value = 'my resolve result'; + myWaiter.resolve(value); + const result = await myWaiter; + expect(result).toBe(value); + expect(myWaiter.isFinished).toBe(true); + expect(myWaiter.isRejected).toBe(false); + expect(myWaiter.isResolved).toBe(true); + }); + + test('waiter is finished (ensure finish alias works)', async () => { + const myWaiter = waiter(); + myWaiter.finish(); + await myWaiter; + expect(myWaiter.isFinished).toBe(true); + expect(myWaiter.isRejected).toBe(false); + expect(myWaiter.isResolved).toBe(true); + }); + + test('waiter is rejected', async () => { + const myWaiter = waiter(); + const error = new Error('Waiter was rejected'); + myWaiter.reject(error); + await expect(myWaiter).rejects.toThrow(error); + expect(myWaiter.isFinished).toBe(true); + expect(myWaiter.isRejected).toBe(true); + expect(myWaiter.isResolved).toBe(false); + }); + + test('waiter is rejected with error type', async () => { + class MyError extends Error { + readonly name = 'MyError'; + } + const myWaiter = waiter(); + const error = new MyError('MyError test instance'); + myWaiter.reject(error); + await expect(myWaiter).rejects.toThrow(error); + expect(myWaiter.isFinished).toBe(true); + expect(myWaiter.isRejected).toBe(true); + expect(myWaiter.isResolved).toBe(false); + + // Expect other error types to cause a typescript error + class OtherError extends Error { + readonly name = 'OtherError'; + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + myWaiter.reject(new OtherError('OtherError test instance')); + }); +}); diff --git a/src/helpers/time.ts b/src/helpers/time.ts index 3f4042d..bfe6b4e 100644 --- a/src/helpers/time.ts +++ b/src/helpers/time.ts @@ -1,23 +1,14 @@ +import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; + /** * Wait a set amount of milliseconds or until the timer is aborted. * @param ms - Number of milliseconds to wait - * @param abortController - Abort controller + * @param abort - Abort controller * @returns Promise */ -export function timeout(ms: number, abortController?: AbortController): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - resolve(); - }, ms); - abortController?.signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error(`Timeout aborted`)); - }, - { once: true } - ); - }); +export function timeout(ms: number, abort?: AbortController | AbortSignal): Promise { + const signal = abort && (abort instanceof AbortSignal ? abort : abort.signal); + return setTimeoutAsync(ms, undefined, { signal }); } /** @@ -114,26 +105,43 @@ export function stopwatch(): Stopwatch { return result; } -export type Waiter = Promise & { +export type Waiter = Promise & { + /** Alias for `resolve` */ finish: (result: T) => void; + resolve: (result: T) => void; + reject: (error: E) => void; + /** True if the promise is resolved or rejected */ isFinished: boolean; + /** True only if the promise is resolved */ + isResolved: boolean; + /** True only if the promise is rejected */ + isRejected: boolean; }; /** - * Creates a `Waiter` promise that can be resolved at a later time with a return value. + * Creates a `Waiter` promise that can be resolved or rejected at a later time. * @returns Waiter */ -export function waiter(): Waiter { +export function waiter(): Waiter { let resolveFn: (result: T) => void; - const promise = new Promise(resolve => { + let rejectFn: (error: E) => void; + const promise = new Promise((resolve, reject) => { resolveFn = resolve; + rejectFn = reject; }); const completer = { - finish: (result: T) => { - completer.isFinished = true; + finish: (result: T) => completer.resolve(result), + resolve: (result: T) => { + void Object.assign(promise, { isFinished: true, isResolved: true }); resolveFn(result); }, + reject: (error: E) => { + void Object.assign(promise, { isFinished: true, isRejected: true }); + rejectFn(error); + }, isFinished: false, + isResolved: false, + isRejected: false, }; return Object.assign(promise, completer); } diff --git a/src/postgres/errors.ts b/src/postgres/errors.ts index b2ff28f..1d7cd43 100644 --- a/src/postgres/errors.ts +++ b/src/postgres/errors.ts @@ -41,6 +41,8 @@ export function isPgConnectionError(error: any): string | false { return 'Postgres connection closed due to administrator command'; } else if (msg.includes('password authentication failed')) { return 'Postgres authentication failed'; + } else if (msg.includes('database system is not yet accepting connections')) { + return 'Postgres not yet accepting connections'; } } return false; diff --git a/src/profiler/server.ts b/src/profiler/server.ts index aea8d04..3d3d4c6 100644 --- a/src/profiler/server.ts +++ b/src/profiler/server.ts @@ -155,13 +155,13 @@ const CpuProfiler: FastifyPluginCallback, Server, TypeBoxTy logger.info( `[HeapProfiler] Completed, total snapshot byte size: ${result.totalSnapshotByteSize}` ); + await pipeline(fs.createReadStream(tmpFile), res.raw); await res.headers({ 'Cache-Control': 'no-store', 'Transfer-Encoding': 'chunked', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Type': 'application/json; charset=utf-8', }); - await pipeline(fs.createReadStream(tmpFile), res.raw); } finally { const session = existingSession; existingSession = undefined;