Skip to content

Commit 173412e

Browse files
author
Alex Holovach
committed
Merge branch 'main' into graphile-queue-driver
2 parents d85115b + 34f3f86 commit 173412e

File tree

2 files changed

+127
-4
lines changed

2 files changed

+127
-4
lines changed

.changeset/fuzzy-boxes-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/utils": patch
3+
---
4+
5+
fix(utils): detect linux ports via /proc

packages/utils/src/get-port.ts

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,118 @@
1+
import { readdir, readFile, readlink } from 'node:fs/promises';
12
import { execa } from 'execa';
23

4+
/**
5+
* Parses a port string and returns it if valid (0-65535), otherwise undefined.
6+
*/
7+
function parsePort(value: string, radix = 10): number | undefined {
8+
const port = parseInt(value, radix);
9+
if (!Number.isNaN(port) && port >= 0 && port <= 65535) {
10+
return port;
11+
}
12+
return undefined;
13+
}
14+
15+
/**
16+
* Gets listening ports for the current process on Linux by reading /proc filesystem.
17+
* This approach requires no external commands and works on all Linux systems.
18+
*/
19+
async function getLinuxPort(pid: number): Promise<number | undefined> {
20+
const listenState = '0A'; // TCP LISTEN state in /proc/net/tcp
21+
const tcpFiles = ['/proc/net/tcp', '/proc/net/tcp6'] as const;
22+
23+
// Step 1: Get socket inodes from /proc/<pid>/fd/ in order
24+
// We preserve order to maintain deterministic behavior (return first port)
25+
// Use both array (for order) and Set (for O(1) lookup)
26+
const socketInodes: string[] = [];
27+
const socketInodesSet = new Set<string>();
28+
const fdPath = `/proc/${pid}/fd`;
29+
30+
try {
31+
const fds = await readdir(fdPath);
32+
// Sort FDs numerically to ensure deterministic order (FDs are always numeric strings)
33+
const sortedFds = fds.sort((a, b) => {
34+
const numA = Number.parseInt(a, 10);
35+
const numB = Number.parseInt(b, 10);
36+
return numA - numB;
37+
});
38+
39+
const results = await Promise.allSettled(
40+
sortedFds.map(async (fd) => {
41+
const link = await readlink(`${fdPath}/${fd}`);
42+
// Socket links look like: socket:[12345]
43+
const match = link.match(/^socket:\[(\d+)\]$/);
44+
return match?.[1] ?? null;
45+
})
46+
);
47+
48+
for (const result of results) {
49+
if (result.status === 'fulfilled' && result.value) {
50+
socketInodes.push(result.value);
51+
socketInodesSet.add(result.value);
52+
}
53+
}
54+
} catch {
55+
// Process might not exist or no permission
56+
return undefined;
57+
}
58+
59+
if (socketInodes.length === 0) {
60+
return undefined;
61+
}
62+
63+
// Step 2: Read /proc/net/tcp and /proc/net/tcp6 to find listening sockets
64+
// Format: sl local_address rem_address st ... inode
65+
// local_address is hex IP:port, st=0A means LISTEN
66+
// We iterate through socket inodes in order to maintain deterministic behavior
67+
for (const tcpFile of tcpFiles) {
68+
try {
69+
const content = await readFile(tcpFile, 'utf8');
70+
const lines = content.split('\n').slice(1); // Skip header
71+
72+
// Build a map of inode -> port for quick lookup
73+
const inodeToPort = new Map<string, number>();
74+
for (const line of lines) {
75+
if (!line.trim()) continue; // Skip empty lines
76+
77+
const parts = line.trim().split(/\s+/);
78+
if (parts.length < 10) continue;
79+
80+
const localAddr = parts[1]; // e.g., "00000000:0BB8" (0.0.0.0:3000)
81+
const state = parts[3]; // "0A" = LISTEN
82+
const inode = parts[9];
83+
84+
if (!localAddr || state !== listenState || !inode) continue;
85+
if (!socketInodesSet.has(inode)) continue;
86+
87+
// Extract port from hex format (e.g., "0BB8" -> 3000)
88+
const colonIndex = localAddr.indexOf(':');
89+
if (colonIndex === -1) continue;
90+
91+
const portHex = localAddr.slice(colonIndex + 1);
92+
if (!portHex) continue;
93+
94+
const port = parsePort(portHex, 16);
95+
if (port !== undefined) {
96+
inodeToPort.set(inode, port);
97+
}
98+
}
99+
100+
// Return the first port matching our socket inodes in order
101+
for (const inode of socketInodes) {
102+
const port = inodeToPort.get(inode);
103+
if (port !== undefined) {
104+
return port;
105+
}
106+
}
107+
} catch {
108+
// File might not exist (e.g., no IPv6 support) - continue to next file
109+
continue;
110+
}
111+
}
112+
113+
return undefined;
114+
}
115+
3116
/**
4117
* Gets the port number that the process is listening on.
5118
* @returns The port number that the process is listening on, or undefined if the process is not listening on any port.
@@ -11,7 +124,10 @@ export async function getPort(): Promise<number | undefined> {
11124

12125
try {
13126
switch (platform) {
14-
case 'linux':
127+
case 'linux': {
128+
port = await getLinuxPort(pid);
129+
break;
130+
}
15131
case 'darwin': {
16132
const lsofResult = await execa('lsof', [
17133
'-a',
@@ -28,7 +144,7 @@ export async function getPort(): Promise<number | undefined> {
28144
input: lsofResult.stdout,
29145
}
30146
);
31-
port = parseInt(awkResult.stdout.trim(), 10);
147+
port = parsePort(awkResult.stdout.trim());
32148
break;
33149
}
34150

@@ -50,8 +166,10 @@ export async function getPort(): Promise<number | undefined> {
50166
.trim()
51167
.match(/^\s*TCP\s+(?:\[[\da-f:]+\]|[\d.]+):(\d+)\s+/i);
52168
if (match) {
53-
port = parseInt(match[1], 10);
54-
break;
169+
port = parsePort(match[1]);
170+
if (port !== undefined) {
171+
break;
172+
}
55173
}
56174
}
57175
}

0 commit comments

Comments
 (0)