Skip to content

Commit bca1dda

Browse files
authored
Merge pull request #40 from hirosystems/master
Merge master into develop
2 parents 9ae5336 + 4b0f152 commit bca1dda

File tree

9 files changed

+224
-51
lines changed

9 files changed

+224
-51
lines changed

.github/workflows/ci.yml

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,27 @@ jobs:
1717
lint:
1818
runs-on: ubuntu-latest
1919
steps:
20-
- uses: actions/checkout@v2
20+
- uses: actions/checkout@v4
2121

2222
- name: Use Node.js
23-
uses: actions/setup-node@v2
23+
uses: actions/setup-node@v4
2424
with:
2525
node-version-file: '.nvmrc'
2626

27+
# https://github.com/actions/cache/blob/main/examples.md#node---npm
28+
- name: Get npm cache directory
29+
id: npm-cache-dir
30+
shell: bash
31+
run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT}
32+
2733
- name: Cache node modules
28-
uses: actions/cache@v2
29-
env:
30-
cache-name: cache-node-modules
34+
uses: actions/cache@v4
35+
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
3136
with:
32-
path: |
33-
~/.npm
34-
**/node_modules
35-
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
37+
path: ${{ steps.npm-cache-dir.outputs.dir }}
38+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
3639
restore-keys: |
37-
${{ runner.os }}-build-${{ env.cache-name }}-
38-
${{ runner.os }}-build-
39-
${{ runner.os }}-
40+
${{ runner.os }}-node-
4041
4142
- name: Install deps
4243
run: npm ci --audit=false
@@ -56,28 +57,29 @@ jobs:
5657
PGPASSWORD: postgres
5758
PGDATABASE: postgres
5859
steps:
59-
- uses: actions/checkout@v2
60+
- uses: actions/checkout@v4
6061
with:
6162
fetch-depth: 0
6263

6364
- name: Use Node.js
64-
uses: actions/setup-node@v2
65+
uses: actions/setup-node@v4
6566
with:
6667
node-version-file: '.nvmrc'
6768

69+
# https://github.com/actions/cache/blob/main/examples.md#node---npm
70+
- name: Get npm cache directory
71+
id: npm-cache-dir
72+
shell: bash
73+
run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT}
74+
6875
- name: Cache node modules
69-
uses: actions/cache@v2
70-
env:
71-
cache-name: cache-node-modules
76+
uses: actions/cache@v4
77+
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
7278
with:
73-
path: |
74-
~/.npm
75-
**/node_modules
76-
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
79+
path: ${{ steps.npm-cache-dir.outputs.dir }}
80+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
7781
restore-keys: |
78-
${{ runner.os }}-build-${{ env.cache-name }}-
79-
${{ runner.os }}-build-
80-
${{ runner.os }}-
82+
${{ runner.os }}-node-
8183
8284
- name: Install deps
8385
run: npm ci --audit=false
@@ -108,7 +110,7 @@ jobs:
108110
- lint
109111
- test
110112
steps:
111-
- uses: actions/checkout@v3
113+
- uses: actions/checkout@v4
112114
with:
113115
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
114116
fetch-depth: 0

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,52 @@
1+
## [1.8.0](https://github.com/hirosystems/api-toolkit/compare/v1.7.5...v1.8.0) (2025-03-25)
2+
3+
4+
### Features
5+
6+
* add `reject` function to `waiter` ([#38](https://github.com/hirosystems/api-toolkit/issues/38)) ([8c56e1a](https://github.com/hirosystems/api-toolkit/commit/8c56e1aabbac026ba18ca3f8792394bd654aa5e6))
7+
8+
## [1.7.5](https://github.com/hirosystems/api-toolkit/compare/v1.7.4...v1.7.5) (2025-03-24)
9+
10+
11+
### Bug Fixes
12+
13+
* 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))
14+
15+
## [1.7.4](https://github.com/hirosystems/api-toolkit/compare/v1.7.3...v1.7.4) (2025-03-05)
16+
17+
18+
### Bug Fixes
19+
20+
* isFinished never set on `waiter` ([#35](https://github.com/hirosystems/api-toolkit/issues/35)) ([9c1d2b3](https://github.com/hirosystems/api-toolkit/commit/9c1d2b3dcd6519e46324a56df83da2ebb6cc53e5))
21+
22+
## [1.7.3](https://github.com/hirosystems/api-toolkit/compare/v1.7.2...v1.7.3) (2025-03-04)
23+
24+
25+
### Bug Fixes
26+
27+
* 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))
28+
29+
## [1.7.2](https://github.com/hirosystems/api-toolkit/compare/v1.7.1...v1.7.2) (2024-10-31)
30+
31+
32+
### Bug Fixes
33+
34+
* memleak in timeout w/ abort signal ([#32](https://github.com/hirosystems/api-toolkit/issues/32)) ([d56a9ad](https://github.com/hirosystems/api-toolkit/commit/d56a9ad24f9850be3c372769b9486b71a85f4ae3))
35+
36+
## [1.7.1](https://github.com/hirosystems/api-toolkit/compare/v1.7.0...v1.7.1) (2024-08-19)
37+
38+
39+
### Bug Fixes
40+
41+
* heap snapshot file download ([#30](https://github.com/hirosystems/api-toolkit/issues/30)) ([800982b](https://github.com/hirosystems/api-toolkit/commit/800982b23393946af6c3017063506be1bb4e46df))
42+
43+
## [1.7.0](https://github.com/hirosystems/api-toolkit/compare/v1.6.2...v1.7.0) (2024-08-16)
44+
45+
46+
### Features
47+
48+
* add fastify cpu profiler server ([#28](https://github.com/hirosystems/api-toolkit/issues/28)) ([b224e06](https://github.com/hirosystems/api-toolkit/commit/b224e0673f09b71d52b8506f487c91aa60afdce5))
49+
150
## [1.6.2](https://github.com/hirosystems/api-toolkit/compare/v1.6.1...v1.6.2) (2024-07-05)
251

352

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Please see each tool's source directory for additional documentation
4343
* Node.js signal handlers that provide a way to shut down long-running application components
4444
gracefully on unhandled exceptions or interrupt signals.
4545

46-
### CPU Profiler
46+
### Profiler server
4747

4848
* Fastify server that controls a profiler capable of generating:
4949
* `.cpuprofile` files for CPU usage analysis

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hirosystems/api-toolkit",
3-
"version": "1.6.2",
3+
"version": "1.8.0",
44
"description": "API development toolkit",
55
"main": "./dist/index.js",
66
"typings": "./dist/index.d.ts",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as events from 'node:events';
2+
import { timeout, waiter } from '../time';
3+
4+
describe('Helper tests', () => {
5+
test('timeout function should not cause memory leak by accumulating abort listeners on abort', async () => {
6+
const controller = new AbortController();
7+
const { signal } = controller;
8+
9+
const countListeners = () => events.getEventListeners(signal, 'abort').length;
10+
11+
// Ensure the initial listener count is zero
12+
expect(countListeners()).toBe(0);
13+
14+
// Run enough iterations to detect a pattern
15+
for (let i = 0; i < 100; i++) {
16+
try {
17+
const sleepPromise = timeout(1000, signal);
18+
controller.abort(); // Abort immediately
19+
await sleepPromise;
20+
} catch (err: any) {
21+
expect(err.toString()).toMatch(/aborted/i);
22+
}
23+
24+
// Assert that listener count does not increase
25+
expect(countListeners()).toBeLessThanOrEqual(1); // 1 listener may temporarily be added and removed
26+
}
27+
28+
// Final check to confirm listeners are cleaned up
29+
expect(countListeners()).toBe(0);
30+
});
31+
32+
test('timeout function should not cause memory leak by accumulating abort listeners on successful completion', async () => {
33+
const controller = new AbortController();
34+
const { signal } = controller;
35+
36+
const countListeners = () => events.getEventListeners(signal, 'abort').length;
37+
38+
// Ensure the initial listener count is zero
39+
expect(countListeners()).toBe(0);
40+
41+
// Run enough iterations to detect a pattern
42+
for (let i = 0; i < 100; i++) {
43+
await timeout(2, signal); // Complete sleep without abort
44+
45+
// Assert that listener count does not increase
46+
expect(countListeners()).toBe(0); // No listeners should remain after successful sleep completion
47+
}
48+
49+
// Final check to confirm listeners are cleaned up
50+
expect(countListeners()).toBe(0);
51+
});
52+
53+
test('waiter is resolved', async () => {
54+
const myWaiter = waiter();
55+
myWaiter.resolve();
56+
await myWaiter;
57+
expect(myWaiter.isFinished).toBe(true);
58+
expect(myWaiter.isRejected).toBe(false);
59+
expect(myWaiter.isResolved).toBe(true);
60+
});
61+
62+
test('waiter is resolved with value', async () => {
63+
const myWaiter = waiter<string>();
64+
const value = 'my resolve result';
65+
myWaiter.resolve(value);
66+
const result = await myWaiter;
67+
expect(result).toBe(value);
68+
expect(myWaiter.isFinished).toBe(true);
69+
expect(myWaiter.isRejected).toBe(false);
70+
expect(myWaiter.isResolved).toBe(true);
71+
});
72+
73+
test('waiter is finished (ensure finish alias works)', async () => {
74+
const myWaiter = waiter();
75+
myWaiter.finish();
76+
await myWaiter;
77+
expect(myWaiter.isFinished).toBe(true);
78+
expect(myWaiter.isRejected).toBe(false);
79+
expect(myWaiter.isResolved).toBe(true);
80+
});
81+
82+
test('waiter is rejected', async () => {
83+
const myWaiter = waiter();
84+
const error = new Error('Waiter was rejected');
85+
myWaiter.reject(error);
86+
await expect(myWaiter).rejects.toThrow(error);
87+
expect(myWaiter.isFinished).toBe(true);
88+
expect(myWaiter.isRejected).toBe(true);
89+
expect(myWaiter.isResolved).toBe(false);
90+
});
91+
92+
test('waiter is rejected with error type', async () => {
93+
class MyError extends Error {
94+
readonly name = 'MyError';
95+
}
96+
const myWaiter = waiter<void, MyError>();
97+
const error = new MyError('MyError test instance');
98+
myWaiter.reject(error);
99+
await expect(myWaiter).rejects.toThrow(error);
100+
expect(myWaiter.isFinished).toBe(true);
101+
expect(myWaiter.isRejected).toBe(true);
102+
expect(myWaiter.isResolved).toBe(false);
103+
104+
// Expect other error types to cause a typescript error
105+
class OtherError extends Error {
106+
readonly name = 'OtherError';
107+
}
108+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
109+
// @ts-expect-error
110+
myWaiter.reject(new OtherError('OtherError test instance'));
111+
});
112+
});

src/helpers/time.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
1+
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
2+
13
/**
24
* Wait a set amount of milliseconds or until the timer is aborted.
35
* @param ms - Number of milliseconds to wait
4-
* @param abortController - Abort controller
6+
* @param abort - Abort controller
57
* @returns Promise
68
*/
7-
export function timeout(ms: number, abortController?: AbortController): Promise<void> {
8-
return new Promise((resolve, reject) => {
9-
const timeout = setTimeout(() => {
10-
resolve();
11-
}, ms);
12-
abortController?.signal.addEventListener(
13-
'abort',
14-
() => {
15-
clearTimeout(timeout);
16-
reject(new Error(`Timeout aborted`));
17-
},
18-
{ once: true }
19-
);
20-
});
9+
export function timeout(ms: number, abort?: AbortController | AbortSignal): Promise<void> {
10+
const signal = abort && (abort instanceof AbortSignal ? abort : abort.signal);
11+
return setTimeoutAsync(ms, undefined, { signal });
2112
}
2213

2314
/**
@@ -114,26 +105,43 @@ export function stopwatch(): Stopwatch {
114105
return result;
115106
}
116107

117-
export type Waiter<T> = Promise<T> & {
108+
export type Waiter<T = void, E = Error> = Promise<T> & {
109+
/** Alias for `resolve` */
118110
finish: (result: T) => void;
111+
resolve: (result: T) => void;
112+
reject: (error: E) => void;
113+
/** True if the promise is resolved or rejected */
119114
isFinished: boolean;
115+
/** True only if the promise is resolved */
116+
isResolved: boolean;
117+
/** True only if the promise is rejected */
118+
isRejected: boolean;
120119
};
121120

122121
/**
123-
* Creates a `Waiter` promise that can be resolved at a later time with a return value.
122+
* Creates a `Waiter` promise that can be resolved or rejected at a later time.
124123
* @returns Waiter
125124
*/
126-
export function waiter<T = void>(): Waiter<T> {
125+
export function waiter<T = void, E = Error>(): Waiter<T, E> {
127126
let resolveFn: (result: T) => void;
128-
const promise = new Promise<T>(resolve => {
127+
let rejectFn: (error: E) => void;
128+
const promise = new Promise<T>((resolve, reject) => {
129129
resolveFn = resolve;
130+
rejectFn = reject;
130131
});
131132
const completer = {
132-
finish: (result: T) => {
133-
completer.isFinished = true;
133+
finish: (result: T) => completer.resolve(result),
134+
resolve: (result: T) => {
135+
void Object.assign(promise, { isFinished: true, isResolved: true });
134136
resolveFn(result);
135137
},
138+
reject: (error: E) => {
139+
void Object.assign(promise, { isFinished: true, isRejected: true });
140+
rejectFn(error);
141+
},
136142
isFinished: false,
143+
isResolved: false,
144+
isRejected: false,
137145
};
138146
return Object.assign(promise, completer);
139147
}

src/postgres/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export function isPgConnectionError(error: any): string | false {
4141
return 'Postgres connection closed due to administrator command';
4242
} else if (msg.includes('password authentication failed')) {
4343
return 'Postgres authentication failed';
44+
} else if (msg.includes('database system is not yet accepting connections')) {
45+
return 'Postgres not yet accepting connections';
4446
}
4547
}
4648
return false;

src/profiler/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,13 @@ const CpuProfiler: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTy
155155
logger.info(
156156
`[HeapProfiler] Completed, total snapshot byte size: ${result.totalSnapshotByteSize}`
157157
);
158+
await pipeline(fs.createReadStream(tmpFile), res.raw);
158159
await res.headers({
159160
'Cache-Control': 'no-store',
160161
'Transfer-Encoding': 'chunked',
161162
'Content-Disposition': `attachment; filename="${filename}"`,
162163
'Content-Type': 'application/json; charset=utf-8',
163164
});
164-
await pipeline(fs.createReadStream(tmpFile), res.raw);
165165
} finally {
166166
const session = existingSession;
167167
existingSession = undefined;

0 commit comments

Comments
 (0)