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
142 changes: 135 additions & 7 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,17 +436,26 @@ const PROC_NET_TCP6 = ` sl local_address remote_addres
`;

effectIt.effect(
"reads Linux listeners from /proc when lsof is missing, and stops spawning it",
"prefers Linux /proc over installed lsof, and bounds repeated socket owner lookups",
() => {
let lsofSpawns = 0;
const fdWalks: string[] = [];
const procFiles: Record<string, string> = {
"/proc/net/tcp": PROC_NET_TCP,
"/proc/net/tcp":
PROC_NET_TCP +
PROC_NET_TCP.split("\n")[1]!
.replace("0100007F:223D", "0101007F:1388")
.replace("5001", "5007"),
"/proc/net/tcp6": PROC_NET_TCP6,
"/proc/4242/comm": "python3\n",
};
const procFds: Record<string, Record<string, string>> = {
"4242": { "0": "/dev/null", "3": "socket:[5001]" },
"4242": {
"0": "/dev/null",
"3": "socket:[5001]",
"4": "socket:[5004]",
"5": "socket:[5007]",
},
"77": { "5": "socket:[9999]" },
};
const missing = FileSystem.makeNoop({});
Expand Down Expand Up @@ -475,9 +484,9 @@ effectIt.effect(
return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } }));
}) as typeof globalThis.fetch;
const layer = layerProbeFailure(
(input) => {
() => {
lsofSpawns += 1;
return processProbeFailure(input);
return Effect.succeed(processOutput("p4242\ncpython3\nn*:3001\nn*:8765\n"));
},
fetchFn,
fileSystem,
Expand All @@ -488,18 +497,137 @@ effectIt.effect(
const first = yield* scanner.scan();
expect(
first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })),
).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]);
).toEqual([
{ port: 3001, pid: 4242, processName: "python3", url: "http://localhost:3001" },
{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" },
]);
yield* scanner.scan();
expect(lsofSpawns).toBe(1);
expect(lsofSpawns).toBe(0);
expect(fdWalks).toEqual(["77", "4242", "77", "4242"]);
yield* scanner.scan();
yield* scanner.scan();
expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]);
procFds["77"]!["6"] = "socket:[5006]";
procFiles["/proc/net/tcp6"] =
PROC_NET_TCP6 + PROC_NET_TCP.split("\n")[1]!.replace("5001", "5006");
expect((yield* scanner.scan()).map(({ port }) => port)).toEqual([3001]);
const walksAfterCollision = fdWalks.length;
procFiles["/proc/net/tcp"] = PROC_NET_TCP.split("\n")[0]!;
procFiles["/proc/net/tcp6"] = PROC_NET_TCP6.split("\n")[0]!;
expect(yield* scanner.scan()).toEqual([]);
expect(lsofSpawns).toBe(0);
expect(fdWalks).toHaveLength(walksAfterCollision);
expect(requests).toEqual([]);

procFiles["/proc/net/tcp"] = PROC_NET_TCP;
procFiles["/proc/net/tcp6"] = PROC_NET_TCP6;
expect((yield* scanner.scan()).map(({ pid }) => pid)).toEqual([4242, 4242]);
yield* scanner.retain.pipe(Effect.scoped);
procFds["77"] = procFds["4242"]!;
delete procFds["4242"];
procFiles["/proc/77/comm"] = "python3\n";
yield* scanner.registerTerminalProcesses({
threadId: "scanner-thread",
terminalId: "scanner-terminal",
processIds: [77],
});
expect((yield* scanner.scan()).map(({ port, pid }) => ({ port, pid }))).toEqual([
{ port: 3001, pid: 77 },
{ port: 8765, pid: 77 },
]);
}).pipe(Effect.provide(layer));
},
);

effectIt.effect.each([
{ mode: "missing", fallback: true },
{ mode: "denied", fallback: true },
{ mode: "invalid", fallback: true },
{ mode: "fd-unavailable", fallback: true },
{ mode: "fd-budget", fallback: true },
{ mode: "fd-budget-no-lsof", fallback: true },
{ mode: "ipv6-denied", fallback: true },
{ mode: "ipv6-absent", fallback: false },
{ mode: "empty", fallback: false },
{ mode: "darwin", fallback: true },
] as const)("uses lsof only if Linux /proc is unusable: $mode", ({ mode, fallback }) => {
let lsofSpawns = 0;
let procWalks = 0;
const missing = FileSystem.makeNoop({});
const layer = PortScanner.layer.pipe(
Layer.provide(
Layer.mergeAll(
FileSystem.layerNoop({
readFileString: (path) => {
if (!path.startsWith("/proc/net/tcp") || mode === "missing") {
return missing.readFileString(path);
}
if (mode === "denied" || (mode === "ipv6-denied" && path.endsWith("tcp6"))) {
return Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "readFileString",
}),
);
}
if (mode === "ipv6-absent" && path.endsWith("tcp6")) {
return missing.readFileString(path);
}
if (mode === "invalid") return Effect.succeed("unusable table");
return Effect.succeed(
mode === "fd-unavailable" || mode.startsWith("fd-budget")
? PROC_NET_TCP
: PROC_NET_TCP.split("\n")[0]!,
);
},
readDirectory: (path) => {
if (!mode.startsWith("fd-budget")) return missing.readDirectory(path);
if (path === "/proc") procWalks += 1;
if (mode === "fd-budget-no-lsof") {
if (path === "/proc") return Effect.succeed(["1234", "9999"]);
if (path === "/proc/1234/fd") return Effect.succeed(["3"]);
}
return Effect.succeed(
path === "/proc"
? ["1", "1234"]
: Array.from({ length: 50_000 }, (_, i) => String(i)),
);
},
readLink: (path) =>
mode === "fd-budget-no-lsof" && path === "/proc/1234/fd/3"
? Effect.succeed("socket:[5001]")
: mode.startsWith("fd-budget")
? Effect.succeed("/dev/null")
: missing.readLink(path),
}),
Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) => {
lsofSpawns += 1;
if (mode === "fd-budget-no-lsof") return processProbeFailure(input);
return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`));
},
}),
Layer.succeed(HostProcess.Platform, mode === "darwin" ? "darwin" : "linux"),
FetchHttpClient.layer,
),
),
);
return Effect.gen(function* () {
const scanner = yield* ownedScanner();
expect((yield* scanner.scan()).map(({ port }) => port)).toEqual(
mode === "fd-budget-no-lsof" ? [8765] : fallback ? [LSOF_TEST_PORT] : [],
);
if (mode === "fd-budget-no-lsof") {
for (let i = 0; i < 3; i += 1) {
expect((yield* scanner.scan()).map(({ port }) => port)).toEqual([8765]);
}
expect(procWalks).toBe(4);
}
expect(lsofSpawns).toBe(fallback ? 1 : 0);
}).pipe(Effect.provide(layer));
});

effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => {
let ipv6Fails = true;
const requests: string[] = [];
Expand Down
61 changes: 42 additions & 19 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ const PROC_TCP_LISTEN = "0A";
/** Each 32-bit word of the hex address is little-endian. */
const procLocalAddress = (hex: string): boolean => {
if (hex.length === 8) {
return hex === "00000000" || hex.endsWith("7F");
return hex === "00000000" || hex === "0100007F";
}
if (hex.length !== 32) return false;
if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true;
Expand Down Expand Up @@ -543,11 +543,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
inodes: ReadonlySet<string>,
) {
const owners = new Map<string, { pid: number; processName: string | null }>();
const pids = (yield* fileSystem
.readDirectory("/proc")
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter((entry) =>
/^\d+$/.test(entry),
);
const entries = yield* fileSystem.readDirectory("/proc").pipe(Effect.option);
if (Option.isNone(entries)) return null;
const pids = entries.value.filter((entry) => /^\d+$/.test(entry));
let budget = PROC_FD_WALK_LIMIT;
for (const pidText of pids) {
if (owners.size === inodes.size || budget <= 0) break;
Expand All @@ -571,20 +569,36 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
);
for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName });
}
return owners;
const lsofMissing = yield* Ref.get(lsofMissingRef);
return budget <= 0 && owners.size < inodes.size && !lsofMissing ? null : owners;
});

const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (
resolveOwners: (pids: ReadonlyArray<number>) => Effect.Effect<ReadonlyMap<number, ServerOwner>>,
) {
if (hostPlatform !== "linux") return null;
const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) =>
fileSystem.readFileString(path).pipe(Effect.option),
fileSystem.readFileString(path).pipe(
// Kernels without IPv6 omit tcp6; a denied table can hide listeners on a shared port.
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.succeed(null),
),
Effect.option,
),
);
if (tables.every(Option.isNone)) return null;
const listeners = tables.flatMap((table) =>
Option.isSome(table) ? parseProcNetTcp(table.value) : [],
if (tables.some(Option.isNone)) return null;
const readable = tables.flatMap((table) =>
Option.isSome(table) && table.value !== null ? [table.value] : [],
);
if (
readable.length === 0 ||
readable.some(
(table) => !/^\s*sl\s+local_address\s+(?:rem|remote)_address\s+st\s/.test(table),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)
return null;
const listeners = readable.flatMap(parseProcNetTcp);
const known = yield* Ref.get(socketOwnersRef);
const knownMisses = yield* Ref.get(socketOwnerMissesRef);
const inodes = new Set(listeners.map((listener) => listener.inode));
Expand All @@ -595,6 +609,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
),
);
const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen);
if (found === null) return null;
const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode)));
const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode)));
for (const inode of unseen) {
Expand Down Expand Up @@ -825,11 +840,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
yield* probeWebServers(listeners ?? [], configuredUrls),
);
}
const fromProc = yield* scanProcListeners(resolveOwners);
if (fromProc !== null) return yield* probeWebServers(fromProc, configuredUrls);
Comment thread
voltcrash marked this conversation as resolved.
Resolved
const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
if (yield* Ref.get(lsofMissingRef)) {
const fromProc = yield* scanProcListeners(resolveOwners);
return yield* probeWebServers(fromProc ?? [], configuredUrls);
}
if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers([], configuredUrls);
const lsofResult = yield* processRunner
.run({
command: "lsof",
Expand All @@ -856,8 +870,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
}),
);
if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
const fromProc = yield* scanProcListeners(resolveOwners);
return yield* probeWebServers(fromProc ?? [], configuredUrls);
// A missing lsof makes a bounded, partial /proc owner walk the only available discovery.
const withoutLsof = (yield* Ref.get(lsofMissingRef))
? yield* scanProcListeners(resolveOwners)
: null;
return yield* probeWebServers(withoutLsof ?? [], configuredUrls);
});

const scanSnapshot = (configuredUrls: ReadonlyArray<string>) =>
Expand Down Expand Up @@ -922,8 +939,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
return [retainCount === 0, { ...state, retainCount }];
});
if (!idle) return;
const threads = yield* Ref.getAndSet(agentThreadsRef, new Map());
if ([...threads.values()].some((threadId) => threadId !== null)) agentScanPending = true;
yield* scanSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Ref.set(socketOwnersRef, new Map());
yield* Ref.set(socketOwnerMissesRef, new Map());
const threads = yield* Ref.getAndSet(agentThreadsRef, new Map());
if ([...threads.values()].some((threadId) => threadId !== null)) agentScanPending = true;
}),
);
});

const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(
Expand Down
Loading