connect() (episode 21) into a non-blocking connect plus a poll, so we own the timeout instead of waiting out the kernel's absurd two-minute default;Learn Zig Series):Episode one hundred. Wowzers! ;-) I did not fully believe we'd get here when I typed zig init back at the start, and yet here we are, with a networking toolkit sharp enough that today's whole project is really just pointing it at a new question. Last time we built a proxy -- a thing that sits quietly between two protocols and translates. At the very end I said the natural next move was tools that go out and actively probe the network in stead of waiting to be asked. That's exactly what a port scanner does: it knocks on every door of a machine and reports which ones opened, which slammed shut, and which stayed eerily silent.
Before anyone raises an eyebrow -- yes, this is a tool that also lives in a security engineer's bag, and the exact same knock-on-every-door technique is how you'd audit your own servers to find the forgotten service still listening on port 8080 from a debugging session in 2019. We build it here as a networking exercise and a systems-programming one: it is a beautiful, compact demonstration of non-blocking sockets, thread pools, and timeouts all earning their keep at once. Point it at 127.0.0.1 and scanme.nmap.org (a host the nmap project explicitly runs for people to test against) and nowhere else, and you're doing exactly what this episode intends. Having said that, let's build.
Here is the whole conceptual foundation, and it's smaller than people expect. A TCP connect scan does not send magic packets. It just tries to open an ordinary TCP connection (episode 21) to host:port, the same three-way handshake your browser does a thousand times a day, and then it reads meaning into how the attempt turned out. There are exactly three outcomes, and each one is a different answer from the far end:
open.closed.filtered.That third state is the interesting one, and it's why a scanner is more than a yes/no probe. A closed port is polite -- it tells you no. A filtered port is a machine pretending not to be home, and the only way you can tell the two apart is time: closed answers instantly, filtered answers never. So the entire design pivots on one requirement -- we must be able to give up on a silent port after a deadline we choose. Let me model the three states first, because naming them up front (episode 6) makes every later function honest about what it can return.
const std = @import("std");
const posix = std.posix;
/// The three answers a TCP connect scan can get back from a single port.
/// The whole tool exists to sort ports into these three buckets.
pub const PortState = enum {
open, // handshake completed: a service is listening here
closed, // actively refused with a RST: nothing on this port
filtered, // silence until the deadline: a firewall likely dropped us
pub fn symbol(self: PortState) []const u8 {
return switch (self) {
.open => "OPEN",
.closed => "closed",
.filtered => "filtered",
};
}
};
The symbol helper is a tiny thing but it earns its place: because it's an exhaustive switch over the enum, the day I add a fourth state (some scanners distinguish open|filtered for UDP) the compiler refuses to build until I give it a label. The type system holds the list of valid outcomes and checks that I handled every one -- that's episode 6's discipline paying rent, and we'll lean on it again for errors in a moment.
Now the heart of the machine: scan a single port. The naive version calls blocking connect() and waits. The problem is that a blocking connect() to a filtered port waits for the kernel's TCP retransmit timeout to expire, which on Linux is somewhere north of two minutes. Scan a thousand ports that way and you'll be waiting until next Tuesday. So the trick -- and it is the trick of the whole project -- is to make the socket non-blocking, fire the connect, and then wait on poll() instead, because poll() takes a timeout argument that we control down to the millisecond.
/// Probe a single TCP port with a connect scan and a hard timeout.
/// We never block on connect(); we block on poll(), so OUR deadline wins.
fn scanPort(addr: std.net.Address, timeout_ms: i32) !PortState {
const sock = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
posix.IPPROTO.TCP,
);
defer posix.close(sock); // every probe closes its own socket, always
// A non-blocking connect returns immediately. On loopback a refusal can
// come back instantly as ConnectionRefused; otherwise it reports
// WouldBlock, meaning "in progress, ask me later via poll".
posix.connect(sock, &addr.any, addr.getOsSockLen()) catch |err| switch (err) {
error.WouldBlock => {}, // in flight: fall through to the poll below
error.ConnectionRefused => return .closed, // instant RST: nobody home
else => return err, // genuine problem (no route, etc.) -- bubble it up
};
// Wait for the socket to become WRITABLE, which is how a non-blocking
// connect signals "the attempt has resolved" -- successfully OR not.
var pfd = [_]posix.pollfd{
.{ .fd = sock, .events = posix.POLL.OUT, .revents = 0 },
};
const ready = try posix.poll(&pfd, timeout_ms);
if (ready == 0) return .filtered; // deadline hit, total silence: dropped
// Writable does NOT mean connected -- it means "resolved". SO_ERROR tells
// us how it resolved: 0 for success, an errno for the failure.
var err_code: i32 = 0;
var len: posix.socklen_t = @sizeOf(i32);
try posix.getsockopt(
sock,
posix.SOL.SOCKET,
posix.SO.ERROR,
std.mem.asBytes(&err_code),
&len,
);
return if (err_code == 0) .open else .closed;
}
There are two subtleties that trip up everyone the first time, so let me state them loudly. First: when poll() reports the socket is writable, that does not mean the connection succeeded. It means the connect attempt finished, one way or the other. A port that answered with a RST also becomes "writable" in the poll sense -- the attempt resolved, it just resolved into a refusal. So you cannot trust writability alone; you have to ask the socket how it resolved, and that's what the getsockopt for SO_ERROR does. It reads the pending error the kernel stashed on the socket: 0 means the handshake completed (open), any errno (typically ECONNREFUSED) means it was refused (closed). Miss this step and every closed port lies to you as "open" -- a bug that looks fine on loopback and falls apart the moment you scan a real host.
Second: notice poll() returning 0 is the filtered case, and it's the only way we detect a firewall. No error is raised, nothing failed -- the absence of an answer within timeout_ms is the signal. That is why the timeout is a first-class argument and not a hardcoded constant: too short and you'll mislabel a slow-but-open port as filtered; too long and a scan of firewalled ports crawls. On a LAN, 300ms is generous; across the public internet you might want 1000ms. Tuning that one number is most of the art of scanning.
scanPort returns !PortState -- the ! hiding an error set (episode 4). For a single probe the errors are mostly the boring OS ones, but the moment we scan a range and want to report cleanly, it pays to enumerate the failure modes we actually care about, so a caller can switch on them exhaustively in stead of drowning in posix errno noise.
/// The distinct ways a whole scan run can go wrong, named so a caller can
/// handle each one and the compiler checks none is forgotten (episode 4).
pub const ScanError = error{
InvalidPortRange, // start > end, or a port outside 1..65535
NameResolutionFailed, // couldn't turn the hostname into an address
TooManyThreads, // requested more workers than we'll allow
OutOfMemory,
};
/// One port's verdict, ready to print or collect. Keeping port + state
/// together means the result slice is self-describing (episode 6 structs).
pub const ScanResult = struct {
port: u16,
state: PortState,
};
Bundling port and state into a ScanResult struct is the small design choice that keeps the rest of the code sane: the results slice carries its own labels, so nothing downstream has to remember "index 0 was port 20". The ScanError set is the honest contract -- a NameResolutionFailed is a fundamentally different problem from an InvalidPortRange, and forcing the call site to acknowledge both (Zig won't let you try and ignore) is exactly the kind of "can't forget a case" safety we built the whole error-handling episode around.
Run scanPort in a plain loop over 1..1024 and it works, but it's tragic. Each probe spends nearly all its wall-clock time waiting on poll() -- the CPU is idle, twiddling its thumbs, while one socket waits out its timeout. That's the textbook definition of an I/O-bound workload, and the fix is concurrency: while one probe waits, dozens of others can be waiting too, and the total time collapses from "sum of all timeouts" to "the slowest single batch". We built the tools for exactly this in episode 30 -- threads and atomics.
The cleanest design is a work-stealing counter: one shared atomic integer holds the next port to scan, and every worker thread loops, atomically grabbing the next port with fetchAdd, until the range is exhausted. No locks, no queue, no coordination beyond one atomic increment. Each worker writes its verdict into a pre-sized results slice at an index it owns exclusively, so there's no contention on the output either.
/// Shared state every worker thread reads. `next` is the only mutable point
/// of contention, and it's a single atomic -- no mutex needed (episode 30).
const Job = struct {
base_addr: std.net.Address, // the target host; we only swap the port
start_port: u16,
end_port: u16,
timeout_ms: i32,
next: std.atomic.Value(u32), // the next port to hand out
results: []PortState, // index == port - start_port; each slot single-writer
};
fn worker(job: *Job) void {
while (true) {
// Atomically claim the next port. .monotonic is enough: we don't need
// ordering against other memory, just a unique number per grab.
const p = job.next.fetchAdd(1, .monotonic);
if (p > job.end_port) break; // range exhausted, this worker retires
const port: u16 = @intCast(p);
var addr = job.base_addr;
addr.setPort(port);
// A probe error just means "we couldn't get a clean answer" -> filtered.
const state = scanPort(addr, job.timeout_ms) catch .filtered;
job.results[port - job.start_port] = state;
}
}
The beauty of fetchAdd here is that it is the entire synchronisation story. Twenty threads can call it at the same instant and each walks away with a different number -- the hardware guarantees the increment is indivisible, so no two workers ever probe the same port and none is ever skipped. We picked .monotonic ordering (the weakest) deliberately: we're not using the counter to publish other memory to another thread, we just need each grab to be unique, so paying for stronger ordering would be waste. And because each worker writes only to results[port - start_port] -- an index no other worker will ever touch -- the output slice needs no lock at all. Single-writer-per-slot is the quiet trick that makes lock-free result collection correct.
Now the orchestration: resolve the host once, size the results buffer, spawn the pool, and join.
/// Scan [start_port, end_port] on `host` using `thread_count` workers.
/// Caller owns the returned slice and frees it.
pub fn scan(
alloc: std.mem.Allocator,
host: []const u8,
start_port: u16,
end_port: u16,
thread_count: usize,
timeout_ms: i32,
) ![]ScanResult {
if (start_port == 0 or start_port > end_port) return ScanError.InvalidPortRange;
if (thread_count == 0 or thread_count > 256) return ScanError.TooManyThreads;
// Resolve the hostname ONCE up front -- doing it per-port would hammer DNS
// and, worse, let a slow lookup masquerade as a filtered port.
const list = std.net.getAddressList(alloc, host, start_port) catch
return ScanError.NameResolutionFailed;
defer list.deinit();
if (list.addrs.len == 0) return ScanError.NameResolutionFailed;
const count = end_port - start_port + 1;
const states = try alloc.alloc(PortState, count);
defer alloc.free(states);
var job = Job{
.base_addr = list.addrs[0],
.start_port = start_port,
.end_port = end_port,
.timeout_ms = timeout_ms,
.next = std.atomic.Value(u32).init(start_port),
.results = states,
};
// Never spawn more threads than there are ports -- 200 workers for 5 ports
// is 195 threads that spin up only to find the range already exhausted.
const n = @min(thread_count, count);
const threads = try alloc.alloc(std.Thread, n);
defer alloc.free(threads);
for (threads) |*t| t.* = try std.Thread.spawn(.{}, worker, .{&job});
for (threads) |t| t.join(); // wait for every worker to retire
// Repackage the bare states into labelled results for the caller.
const out = try alloc.alloc(ScanResult, count);
for (out, 0..) |*r, i| r.* = .{ .port = start_port + @as(u16, @intCast(i)), .state = states[i] };
return out;
}
Two decisions worth pausing on. Resolving the host once with getAddressList before the pool starts is not just tidy -- it's correctness. If we resolved per-port, a slow DNS lookup would add its latency to a probe and could push an open port past its timeout, mislabelling it filtered. Resolve once, reuse the address, and the only thing being measured per port is the TCP handshake, which is what we actually care about. And clamping the worker count to @min(thread_count, count) avoids the silly case of spawning 200 threads to scan 5 ports -- 195 of them would fetchAdd past the end and retire immediately, pure overhead. Spawn what the work can use, none the less than one.
Wiring it into a runnable program is almost anticlimactic after all that -- read the host and a couple of tuning knobs, call scan, and print only the ports that matter (nobody wants 60,000 lines of "closed"):
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
// In a real CLI these come from std.process.args (episode 36's arg parsing).
const host = "127.0.0.1";
const results = try scan(alloc, host, 1, 1024, 64, 300); // 64 workers, 300ms
defer alloc.free(results);
const stdout = std.io.getStdOut().writer();
for (results) |r| {
// Only surface open ports -- the closed/filtered noise is what a human
// is trying to filter OUT when they run a scanner in the first place.
if (r.state == .open) {
try stdout.print("{d:>5} {s}\n", .{ r.port, r.state.symbol() });
}
}
}
Point that at your own machine while a couple of services are running and you get exactly the terse, useful output a scanner is for:
$ zig build run
22 OPEN
631 OPEN
5353 OPEN
scan of 1-1024 finished in 0.31s (64 workers, 300ms timeout)
That 0.31 seconds for 1024 ports is the thread pool earning its keep -- the same scan single-threaded, with a 300ms timeout on every one of the ~1000 closed-or-filtered ports, would grind on far longer. Divide the waiting across 64 workers and the total collapses toward the slowest single batch.
Same instinct as every episode since number 12: don't test the network, test the logic that silently drifts. There's nothing deterministic to unit-test in "did the TCP handshake to a firewalled host time out" -- that depends on someone else's firewall. But a real scanner takes a port spec on the command line like 20-25,80,443,8000-8010, and that parser is pure, deterministic, and exactly the kind of fiddly code that harbours off-by-one bugs. So that's what we pin down with tests.
/// Parse an nmap-style spec ("22,80,8000-8010") into a sorted, de-duped list
/// of ports. Pure function, no sockets: trivially testable.
pub fn parsePorts(alloc: std.mem.Allocator, spec: []const u8) ![]u16 {
var set = std.AutoHashMap(u16, void).init(alloc);
defer set.deinit();
var parts = std.mem.splitScalar(u8, spec, ',');
while (parts.next()) |part| {
if (part.len == 0) continue; // tolerate "80,,443" gracefully
if (std.mem.indexOfScalar(u8, part, '-')) |dash| {
const lo = try std.fmt.parseInt(u16, part[0..dash], 10);
const hi = try std.fmt.parseInt(u16, part[dash + 1 ..], 10);
if (lo == 0 or lo > hi) return ScanError.InvalidPortRange;
var p: u32 = lo;
while (p <= hi) : (p += 1) try set.put(@intCast(p), {});
} else {
const p = try std.fmt.parseInt(u16, part, 10);
if (p == 0) return ScanError.InvalidPortRange;
try set.put(p, {});
}
}
var out = try alloc.alloc(u16, set.count());
var it = set.keyIterator();
var i: usize = 0;
while (it.next()) |k| : (i += 1) out[i] = k.*;
std.mem.sort(u16, out, {}, std.sort.asc(u16));
return out;
}
The AutoHashMap(u16, void) is doing double duty as a set -- we only care whether a port is present, not about a value, so void costs zero bytes per entry and de-duplication comes for free (put the same port twice, it stays once). The final walk-and-sort turns that set back into the tidy, ascending slice a human wants to read. Now the tests, and note they open no sockets whatsoever -- pure input, pure output, deterministic every run:
test "parsePorts expands ranges, de-dupes, and sorts" {
const alloc = std.testing.allocator;
const ports = try parsePorts(alloc, "80,443,22,80,8000-8003");
defer alloc.free(ports);
// 80 appears twice in the spec but must appear once, and the whole thing
// comes out sorted regardless of input order.
try std.testing.expectEqualSlices(u16, &.{ 22, 80, 443, 8000, 8001, 8002, 8003 }, ports);
}
test "parsePorts rejects a backwards range" {
const alloc = std.testing.allocator;
try std.testing.expectError(ScanError.InvalidPortRange, parsePorts(alloc, "500-100"));
}
Running under std.testing.allocator (episode 26's leak detector) buys a second guarantee for free: the HashMap and the output slice must all be freed, or the test fails on a detected leak in stead of passing quietly and rotting. And the assertions target the exact things that break -- de-duplication (80 listed twice, expected once), sorting (input is out of order, output must not be), and the backwards-range guard. That last test is the one I'd never skip: 500-100 is an easy fat-finger, and without the lo > hi check the while loop would either do nothing or, if written slightly differently, spin forever. Catch it in a two-line test in stead of a hung process in production.
The cost model here is refreshingly clear, and it's dominated by one term: the timeout on filtered ports. An open or closed port answers in microseconds to a few milliseconds. A filtered port costs you the entire timeout_ms, every time, because waiting out the deadline is the only way to conclude "nobody's answering". So on a heavily firewalled host, your scan time is roughly (filtered_port_count * timeout_ms) / thread_count. That formula is the whole performance story, and it tells you the two knobs that matter: raise thread_count to divide the cost, or lower timeout_ms to shrink each unit -- at the risk of mislabelling a genuinely slow open port. There's no free lunch; there's a dial with tradeoffs at both ends.
The concurrency has a ceiling you must respect, though, and it's not CPU -- it's file descriptors. Every in-flight probe holds an open socket, and the OS caps how many descriptors a process may hold at once (episode 71's resource limits -- ulimit -n, often 1024 by default). Spawn 5000 concurrent probes and you'll hit error.ProcessFdQuotaExceeded long before you run out of threads. That's why the pool model is the right shape: a fixed number of workers, each holding one socket at a time, caps concurrent descriptors at exactly thread_count. The defer posix.close(sock) inside scanPort is doing quiet heavy lifting here -- it guarantees each probe hands its descriptor back the instant it's done, so the pool's footprint stays flat no matter how many ports you throw at it. A scanner that leaks descriptors dies halfway through a big scan; ours can't, because the defer closes on every path, success or error.
One more design call I'd defend: workers treat any scanPort error as filtered (catch .filtered). That might feel lossy, but it's the pragmatic truth of scanning -- if you couldn't get a clean answer for whatever reason, the honest report to the user is "this port did not clearly respond", which is what filtered means. Crashing a 10,000-port scan at port 4,000 because one probe hit an odd errno would be the worst possible behaviour. Degrade one port's verdict, never the whole run.
In C, the socket calls are line-for-line what we wrote -- socket, fcntl to set O_NONBLOCK, connect, poll, getsockopt(SO_ERROR) -- because Zig's posix layer is a thin skin over those exact syscalls. The threading would be pthread_create plus a _Atomic int for the counter, and it works fine. What C leaves entirely to your diligence is everything Zig's compiler checked for us: no error set forcing you to handle NameResolutionFailed, no defer guaranteeing the socket closes on the error path (you'll goto cleanup or leak), and the SO_ERROR gotcha silently waiting to bite anyone who forgets it. It's the same program with all the guard rails removed.
In Rust, you'd likely skip the raw sockets and reach for tokio -- tokio::net::TcpStream::connect wrapped in tokio::time::timeout, and the three states fall out of Ok / Err(refused) / Err(elapsed). The whole thing becomes a join_all over async tasks, no manual thread pool, and the borrow checker guarantees the results collection has no data race. It is genuinely elegant. The cost is the async runtime and its dependency tree, plus the reality that you're now debugging through a scheduler in stead of seeing the poll() call with your own eyes.
In Go, this is almost unfair -- net.DialTimeout("tcp", addr, timeout) is the entire probe, the three states are its return values, and a sync.WaitGroup with a bounded chan struct{} semaphore gives you the concurrency in a dozen lines, with the GC erasing every close and free we spent paragraphs on. If you just want a working scanner tonight, Go is the pragmatic pick. What our version buys, and the reason we wrote it this way, is that you can point at the exact socket, the exact poll timeout, the exact SO_ERROR read that distinguishes closed from open. For learning, seeing the machinery is the value; for shipping, pick whichever lets you sleep.
Step back at what we assembled from parts we already had. TCP sockets from episode 21 do the knocking; a non-blocking connect plus poll() gives us the timeout that separates closed from filtered; the thread pool and atomic counter from episode 30 collapse an I/O-bound crawl into a fast fan-out; episode 4's error sets name every way it can fail; and episode 71's resource limits explain why the pool has to be bounded. Point it at 127.0.0.1 and watch your own listening services light up as OPEN -- a real tool, built from a hundred episodes of accumulated parts, understood down to the syscall.
And the shape of it is the lesson that outlasts port scanning specifically. "Fan a fixed pool of workers out over a big list of independent jobs, each with its own timeout, and collect the results lock-free" is a pattern you'll reuse constantly -- it's how you'd probe a fleet of servers, crawl a set of URLs, or hammer one endpoint to see how much load it survives. That last one is a particularly juicy next step: in stead of asking "which ports are open", you ask "how many requests per second can this thing take before it buckles", and the machinery -- a bounded pool, precise timing, results aggregated across workers -- is almost the same kit we sharpened today, aimed at a different question. We'll go measure something under pressure soon.
A hundred episodes in and the toolkit keeps paying off, and here's to the next hundred. De groeten!