Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 27 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
112 changes: 112 additions & 0 deletions src/helpers/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<void, MyError>();
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'));
});
});
50 changes: 29 additions & 21 deletions src/helpers/time.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
const signal = abort && (abort instanceof AbortSignal ? abort : abort.signal);
return setTimeoutAsync(ms, undefined, { signal });
}

/**
Expand Down Expand Up @@ -114,26 +105,43 @@ export function stopwatch(): Stopwatch {
return result;
}

export type Waiter<T> = Promise<T> & {
export type Waiter<T = void, E = Error> = Promise<T> & {
/** 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<T = void>(): Waiter<T> {
export function waiter<T = void, E = Error>(): Waiter<T, E> {
let resolveFn: (result: T) => void;
const promise = new Promise<T>(resolve => {
let rejectFn: (error: E) => void;
const promise = new Promise<T>((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);
}
2 changes: 2 additions & 0 deletions src/postgres/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/profiler/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,13 @@ const CpuProfiler: FastifyPluginCallback<Record<never, never>, 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;
Expand Down