Skip to content

Commit 8af215b

Browse files
committed
Add reserve option to lock ports for the process lifetime
Fixes #73
1 parent c401d13 commit 8af215b

4 files changed

Lines changed: 112 additions & 26 deletions

File tree

β€Žindex.d.tsβ€Ž

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ export type Options = {
1313
*/
1414
readonly exclude?: Iterable<number>;
1515

16+
/**
17+
Reserve the port so that it's locked for the lifetime of the process instead of the default 15-30 seconds.
18+
19+
This is useful when there is a long delay between getting the port and actually binding to it, such as in long-running test suites.
20+
21+
Reserved ports are locked globally by port number for the current process, even if you looked them up with a specific `host` or `ipv6Only` option.
22+
23+
Use {@link clearLockedPorts} to release reserved ports.
24+
25+
@default false
26+
27+
@example
28+
```
29+
import getPort from 'get-port';
30+
31+
const port = await getPort({reserve: true});
32+
// `port` will not be returned again by get-port for the lifetime of the process
33+
```
34+
*/
35+
readonly reserve?: boolean;
36+
1637
/**
1738
The host on which port resolution should be performed. Can be either an IPv4 or IPv6 address.
1839
@@ -62,7 +83,7 @@ console.log(await getPort({port: portNumbers(3000, 3100)}));
6283
export function portNumbers(from: number, to: number): Iterable<number>;
6384

6485
/**
65-
Clear the internal cache of locked ports.
86+
Clear the internal cache of locked ports, including any ports locked with the {@link Options.reserve reserve} option.
6687
6788
This can be useful when you want the results to be unaffected by previous calls.
6889

β€Žindex.jsβ€Ž

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const lockedPorts = {
1717
// and a new young set for locked ports are created.
1818
const releaseOldLockedPortsIntervalMs = 1000 * 15;
1919

20+
// Keep `reserve` deliberately process-wide by port number.
21+
// It is meant to avoid in-process races, not to model every possible
22+
// IPv4/IPv6 or host-specific bind combination.
23+
const reservedPorts = new Set();
24+
2025
const minPort = 1024;
2126
const maxPort = 65_535;
2227

@@ -71,6 +76,8 @@ const getAvailablePort = async (options, hosts) => {
7176
return options.port;
7277
};
7378

79+
const isLockedPort = port => lockedPorts.old.has(port) || lockedPorts.young.has(port) || reservedPorts.has(port);
80+
7481
const portCheckSequence = function * (ports) {
7582
if (ports) {
7683
yield * ports;
@@ -109,6 +116,8 @@ export default async function getPorts(options) {
109116
}
110117
}
111118

119+
const {reserve, ...netOptions} = options ?? {};
120+
112121
if (timeout === undefined) {
113122
timeout = setTimeout(() => {
114123
timeout = undefined;
@@ -131,16 +140,20 @@ export default async function getPorts(options) {
131140
continue;
132141
}
133142

134-
let availablePort = await getAvailablePort({...options, port}, hosts); // eslint-disable-line no-await-in-loop
135-
while (lockedPorts.old.has(availablePort) || lockedPorts.young.has(availablePort)) {
143+
let availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
144+
while (isLockedPort(availablePort)) {
136145
if (port !== 0) {
137146
throw new Locked(port);
138147
}
139148

140-
availablePort = await getAvailablePort({...options, port}, hosts); // eslint-disable-line no-await-in-loop
149+
availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
141150
}
142151

143-
lockedPorts.young.add(availablePort);
152+
if (reserve) {
153+
reservedPorts.add(availablePort);
154+
} else {
155+
lockedPorts.young.add(availablePort);
156+
}
144157

145158
return availablePort;
146159
} catch (error) {
@@ -182,4 +195,5 @@ export function portNumbers(from, to) {
182195
export function clearLockedPorts() {
183196
lockedPorts.old.clear();
184197
lockedPorts.young.clear();
198+
reservedPorts.clear();
185199
}

β€Žreadme.mdβ€Ž

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ Ports that should not be returned.
6868

6969
You could, for example, pass it the return value of the `portNumbers()` function.
7070

71+
##### reserve
72+
73+
Type: `boolean`\
74+
Default: `false`
75+
76+
Reserve the port so that it's locked for the lifetime of the process instead of the default 15-30 seconds.
77+
78+
This is useful when there is a long delay between getting the port and actually binding to it, such as in long-running test suites.
79+
80+
Reserved ports are locked globally by port number for the current process, even if you looked them up with a specific `host` or `ipv6Only` option.
81+
82+
Use [`clearLockedPorts()`](#clearlockedports) to release reserved ports.
83+
7184
##### host
7285

7386
Type: `string`
@@ -103,7 +116,7 @@ The last port of the range. Must be in the range `1024`...`65535` and must be gr
103116

104117
### clearLockedPorts()
105118

106-
Clear the internal cache of locked ports.
119+
Clear the internal cache of locked ports, including any ports locked with the [`reserve`](#reserve) option.
107120

108121
This can be useful when you want the results to be unaffected by previous calls.
109122

@@ -131,7 +144,7 @@ console.log(await getPort({port}));
131144

132145
There is a very tiny chance of a race condition if another process starts using the same port number as you in between the time you get the port number and you actually start using it.
133146

134-
**In-process race conditions** (such as when running parallel Jest tests) are completely eliminated by a lightweight locking mechanism where returned ports are held for 15-30 seconds before being eligible for reuse.
147+
**In-process race conditions** (such as when running parallel Jest tests) are completely eliminated by a lightweight locking mechanism where returned ports are held for 15-30 seconds before being eligible for reuse. If the delay between getting a port and binding to it may exceed this window (for example, in long-running test suites), use the [`reserve`](#reserve) option to lock the port for the lifetime of the process.
135148

136149
**Multi-process race conditions** are extremely rare and will result in an immediate `EADDRINUSE` error when attempting to bind to the port, allowing your application to retry.
137150

β€Žtest.jsβ€Ž

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,24 +158,6 @@ test('exclude throws error if provided iterator contains items which are unsafe
158158
await t.throwsAsync(getPort({exclude}));
159159
});
160160

161-
// TODO: Re-enable this test when ESM supports import hooks.
162-
// test('ports are locked for up to 30 seconds', async t => {
163-
// // Speed up the test by overriding `setInterval`.
164-
// const {setInterval} = global;
165-
// global.setInterval = (fn, timeout) => setInterval(fn, timeout / 100);
166-
167-
// delete require.cache[require.resolve('.')];
168-
// const getPort = require('.');
169-
// const timeout = promisify(setTimeout);
170-
// const port = await getPort();
171-
// const port2 = await getPort({port});
172-
// t.not(port2, port);
173-
// await timeout(300); // 30000 / 100
174-
// const port3 = await getPort({port});
175-
// t.is(port3, port);
176-
// global.setInterval = setInterval;
177-
// });
178-
179161
const bindPort = async ({port, host}) => {
180162
const server = net.createServer();
181163
await promisify(server.listen.bind(server))({port, host});
@@ -194,7 +176,7 @@ test('preferred ports is bound up with different hosts', async t => {
194176
t.is(port, desiredPorts[3]);
195177
});
196178

197-
test('clearLockedPorts()', async t => {
179+
test.serial('clearLockedPorts()', async t => {
198180
const desiredPort = 8088;
199181
const port1 = await getPort({port: desiredPort});
200182
t.is(port1, desiredPort);
@@ -208,3 +190,59 @@ test('clearLockedPorts()', async t => {
208190
const port3 = await getPort({port: desiredPort});
209191
t.is(port3, desiredPort);
210192
});
193+
194+
test.serial('reserve option locks port permanently', async t => {
195+
const desiredPort = 8089;
196+
const port1 = await getPort({port: desiredPort, reserve: true});
197+
t.is(port1, desiredPort);
198+
199+
// Reserved port is not returned again
200+
const port2 = await getPort({port: desiredPort});
201+
t.not(port2, desiredPort);
202+
203+
// Release and verify it's available again
204+
clearLockedPorts();
205+
const port3 = await getPort({port: desiredPort});
206+
t.is(port3, desiredPort);
207+
});
208+
209+
test.serial('reserve option blocks the same port on other hosts too', async t => {
210+
const desiredPort = 8091;
211+
const port1 = await getPort({port: desiredPort, host: '127.0.0.1', reserve: true});
212+
t.is(port1, desiredPort);
213+
214+
const port2 = await getPort({port: desiredPort, host: '::1'});
215+
t.not(port2, desiredPort);
216+
});
217+
218+
test.serial('preferred port with omitted host and ipv6Only still checks all local addresses', async t => {
219+
const desiredPort = 8092;
220+
const server = net.createServer();
221+
await promisify(server.listen.bind(server))({port: desiredPort, host: '127.0.0.1'});
222+
223+
const port = await getPort({port: desiredPort, ipv6Only: true});
224+
t.not(port, desiredPort);
225+
});
226+
227+
test.serial('reserve option with omitted host and ipv6Only still blocks later IPv4 lookups', async t => {
228+
const desiredPort = 8093;
229+
const port1 = await getPort({port: desiredPort, ipv6Only: true, reserve: true});
230+
t.is(port1, desiredPort);
231+
232+
const port2 = await getPort({port: desiredPort, host: '0.0.0.0'});
233+
t.not(port2, desiredPort);
234+
});
235+
236+
test.serial('reserve option with random port', async t => {
237+
const port1 = await getPort({reserve: true});
238+
const port2 = await getPort({reserve: true});
239+
t.not(port1, port2);
240+
});
241+
242+
test.serial('concurrent reserve calls return unique ports', async t => {
243+
const ports = await Promise.all(
244+
Array.from({length: 5}, () => getPort({reserve: true})),
245+
);
246+
247+
t.is(new Set(ports).size, ports.length);
248+
});

0 commit comments

Comments
 (0)