Skip to content

NTP Client

In the previous tutorials, we've worked with TCP connections. Now let's explore UDP sockets and event-driven programming by building an NTP (Network Time Protocol) client that queries time servers.

This tutorial covers:

  • UDP sockets - connectionless datagram communication
  • DNS lookup - resolving hostnames to IP addresses
  • Timeouts - setting time limits on I/O operations
  • Select - waiting on multiple events simultaneously

The Code

Replace the contents of src/main.zig with this:

const std = @import("std");
const zio = @import("zio");

fn lookupHost(hostname: []const u8, port: u16) !zio.net.Address {
    const host = try zio.net.HostName.init(hostname);
    var iter = try host.lookup(.{ .port = port });
    defer iter.deinit();

    while (iter.next()) |result| {
        switch (result) {
            .address => |ip_addr| return .{ .ip = ip_addr },
            else => continue,
        }
    }

    return error.NoAddressFound;
}

const NtpPacket = extern struct {
    flags: packed struct(u8) {
        mode: u3 = 3, // Client mode
        version: u3 = 3, // NTP version 3
        leap: u2 = 0, // No leap second warning
    } = .{},
    stratum: u8 = 0,
    poll: u8 = 0,
    precision: u8 = 0,
    root_delay: u32 = 0,
    root_dispersion: u32 = 0,
    reference_id: u32 = 0,
    reference_timestamp: u64 = 0,
    origin_timestamp: u64 = 0,
    receive_timestamp: u64 = 0,
    transmit_timestamp: u64 = 0,
};

fn queryNtpServer(server: []const u8, port: u16, timeout: zio.Timeout) !void {
    const addr = try lookupHost(server, port);
    std.log.info("Querying NTP server {s}:{d} ({f})", .{ server, port, addr });

    // Create UDP socket (bind to any local port)
    const local_addr = try zio.net.IpAddress.parseIp4("0.0.0.0", 0);
    const socket = try local_addr.bind(.{});
    defer socket.close();

    // Prepare and send NTP request
    const request: NtpPacket = .{};

    var buffer: [@sizeOf(NtpPacket)]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buffer);
    try writer.writeStruct(request, .big);

    const sent = try socket.sendTo(addr, &buffer, timeout);
    if (sent != @sizeOf(NtpPacket)) {
        return error.IncompleteSend;
    }

    // Receive response
    const result = socket.receiveFrom(&buffer, timeout) catch |err| {
        std.log.warn("Failed to receive NTP response: {}", .{err});
        return err;
    };

    if (result.len < @sizeOf(NtpPacket)) {
        std.log.warn("Received incomplete NTP packet: {} bytes", .{result.len});
        return error.IncompletePacket;
    }

    var reader = std.Io.Reader.fixed(buffer[0..result.len]);
    const response = try reader.takeStruct(NtpPacket, .big);

    // Parse response (transmit_timestamp is already in native byte order)
    const ntp_time = response.transmit_timestamp;

    if (ntp_time == 0) {
        std.log.warn("Invalid NTP response (zero timestamp)", .{});
        return error.InvalidResponse;
    }

    // NTP timestamp: upper 32 bits = seconds, lower 32 bits = fractional seconds
    const seconds: u32 = @truncate(ntp_time >> 32);
    const milliseconds: u32 = @truncate((ntp_time & 0xFFFFFFFF) * 1000 >> 32);

    // Format time using stdlib helpers (convert NTP epoch to Unix epoch)
    const epoch_secs: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@as(i64, seconds) + std.time.epoch.ntp) };
    const day_seconds = epoch_secs.getDaySeconds();
    const year_day = epoch_secs.getEpochDay().calculateYearDay();
    const month_day = year_day.calculateMonthDay();

    std.log.info("Current time: {d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} UTC", .{
        year_day.year,
        month_day.month.numeric(),
        month_day.day_index + 1,
        day_seconds.getHoursIntoDay(),
        day_seconds.getMinutesIntoHour(),
        day_seconds.getSecondsIntoMinute(),
        milliseconds,
    });
}

pub fn main() !void {
    const gpa = std.heap.smp_allocator;

    const args = try std.process.argsAlloc(gpa);
    defer std.process.argsFree(gpa, args);

    const server = if (args.len > 1) args[1] else "pool.ntp.org";
    const port: u16 = 123;

    var rt = try zio.Runtime.init(gpa, .{});
    defer rt.deinit();

    // Setup SIGINT handler
    var signal = try zio.Signal.init(.interrupt);
    defer signal.deinit();

    const interval: zio.Timeout = .{ .duration = .fromSeconds(30) };
    const request_timeout: zio.Timeout = .{ .duration = .fromSeconds(5) };

    std.log.info("NTP client starting. Press Ctrl+C to stop.", .{});
    std.log.info("Server: {s}:{d}", .{ server, port });
    std.log.info("Update interval: {f}", .{interval});
    std.log.info("Request timeout: {f}", .{request_timeout});

    while (true) {
        // Query NTP server
        queryNtpServer(server, port, request_timeout) catch |err| {
            std.log.err("NTP query failed: {}", .{err});
        };

        // Wait for next query or shutdown signal
        const result = try zio.select(.{
            .interval = &interval,
            .shutdown = &signal,
        });

        switch (result) {
            .interval => continue, // Query again after interval
            .shutdown => break, // Exit on Ctrl+C
        }
    }

    std.log.info("NTP client stopped.", .{});
}

Now build and run it:

$ zig build run
info: NTP client starting. Press Ctrl+C to stop.
info: Server: pool.ntp.org:123
info: Update interval: 30s
info: Request timeout: 5s
info: Querying NTP server pool.ntp.org:123 (162.159.200.123:123)
info: Current time: 2026-01-31 19:45:32.847 UTC
info: Querying NTP server pool.ntp.org:123 (162.159.200.1:123)
info: Current time: 2026-01-31 19:46:02.851 UTC
^C
info: NTP client stopped.

You can specify a different NTP server:

$ zig build run -- time.google.com

How It Works

This program demonstrates several networking concepts working together: DNS resolution, UDP communication, timeouts, and event multiplexing.

DNS Lookup

Before we can contact an NTP server, we need to resolve its hostname to an IP address:

fn lookupHost(hostname: []const u8, port: u16) !zio.net.Address {
    const host = try zio.net.HostName.init(hostname);
    var iter = try host.lookup(.{ .port = port });
    defer iter.deinit();

    while (iter.next()) |result| {
        switch (result) {
            .address => |ip_addr| return .{ .ip = ip_addr },
            else => continue,
        }
    }

    return error.NoAddressFound;
}

HostName.lookup() returns an iterator that yields DNS results. The resolver may return multiple addresses (IPv4 and IPv6), and we take the first one. This happens asynchronously - the task suspends while DNS queries are performed.

UDP Sockets

Unlike TCP which provides reliable, ordered, connection-oriented streams, UDP sends individual datagrams without establishing a connection:

    // Create UDP socket (bind to any local port)
    const local_addr = try zio.net.IpAddress.parseIp4("0.0.0.0", 0);
    const socket = try local_addr.bind(.{});
    defer socket.close();

bind() creates a UDP socket. Binding to 0.0.0.0:0 means "listen on any interface, on any available port" - the OS will assign a random port.

With TCP, we called connect() to establish a connection. With UDP, there's no connection - we just send datagrams to any address we want.

Sending and Receiving Datagrams

To send a datagram:

    const sent = try socket.sendTo(addr, &buffer, timeout);

sendTo() sends data to a specific address. UDP doesn't guarantee delivery - the datagram might get lost, arrive out of order, or be duplicated. For NTP, this is acceptable since we query periodically.

To receive a datagram:

    // Receive response
    const result = socket.receiveFrom(&buffer, timeout) catch |err| {
        std.log.warn("Failed to receive NTP response: {}", .{err});
        return err;
    };

receiveFrom() returns both the data and the sender's address. This is important for UDP since we can receive datagrams from any address.

Timeouts

Both sendTo() and receiveFrom() accept a Timeout parameter:

const request_timeout: zio.Timeout = .{ .duration = .fromSeconds(5) };

const result = socket.receiveFrom(&buffer, request_timeout) catch |err| {
    // Handle timeout or other error
    return err;
};

If the operation doesn't complete within 5 seconds, it returns error.Timeout. This prevents the program from hanging indefinitely if the server doesn't respond.

You can also use .none for no timeout (wait indefinitely), or .{ .deadline = timestamp } to wait until a specific time.

The NTP Protocol

NTP uses a simple binary protocol. We define the packet structure as an extern struct:

const NtpPacket = extern struct {
    flags: packed struct(u8) {
        mode: u3 = 3,      // Client mode
        version: u3 = 3,   // NTP version 3
        leap: u2 = 0,      // No leap second warning
    } = .{},
    stratum: u8 = 0,
    poll: u8 = 0,
    precision: u8 = 0,
    root_delay: u32 = 0,
    root_dispersion: u32 = 0,
    reference_id: u32 = 0,
    reference_timestamp: u64 = 0,
    origin_timestamp: u64 = 0,
    receive_timestamp: u64 = 0,
    transmit_timestamp: u64 = 0,
};

The extern keyword ensures the struct has C-compatible layout with no padding. We serialize it to bytes using writeStruct():

const request: NtpPacket = .{};
var buffer: [@sizeOf(NtpPacket)]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
try writer.writeStruct(request, .big);

The .big endianness ensures multi-byte fields are in network byte order (big-endian).

When we receive the response, we deserialize it:

var reader = std.Io.Reader.fixed(buffer[0..result.len]);
const response = try reader.takeStruct(NtpPacket, .big);

The timestamp is in NTP format (seconds since 1900-01-01), which we convert to Unix time and print in a human-readable format.

Select: Waiting on Multiple Events

The main loop needs to handle two events: the periodic timer and the shutdown signal (Ctrl+C):

        // Wait for next query or shutdown signal
        const result = try zio.select(.{
            .interval = &interval,
            .shutdown = &signal,
        });

        switch (result) {
            .interval => continue, // Query again after interval
            .shutdown => break, // Exit on Ctrl+C
        }

select() suspends the task until one of the events occurs. You pass a struct where each field is a pointer to something that can be waited on:

  • JoinHandle - fires when the task completes
  • Timeout - fires when the duration elapses
  • Signal - fires when the signal is received
  • Channel - fires when data is available to receive
  • And more

select() returns a union indicating which event fired, so you can handle each case appropriately.

This is more efficient than spawning separate tasks and using channels. When you need to wait on multiple events in a single task, select() is the right tool.