Simple solution to read a byte with timeout

Simple solution to read a byte with timeout


This is not really a project showcase, it’s a short utility snipped I came up with while trying to solve an issue I had and I felt that sharing it might be of use to someone.

I recently had to use nanomodbus, a modbus protocol library written in C. To use it, you have to implement essentially two functions: readSerial, writeSerial, which accept a timeout parameter. This is a pattern common enough in such libraries. Well, the naive implementation of using a reader does not work, as it does not accept a timeout parameter, and I found the alternative of spawning a separate task with io.concurrent() and starting a timer for each byte to be a bit overkill.

To solve this I reached for the Io.Operation api, which turned out to be surprisingly simple. I tested the below implementation on linux, but it should work on any other operating system.

const std = @import("std");
const Io = std.Io;

pub fn readByteTimeout(io: Io, file: Io.File, duration: Io.Duration) Io.OperateTimeoutError!u8 {
    var ret: [1]u8 = undefined;
    const duration_w_clock: Io.Clock.Duration = .{
        .clock = .awake,
        .raw = duration,
    };
    const operation: Io.Operation = .{ .file_read_streaming = .{
        .file = file,
        .data = &.{&ret},
    } };
    const result = try io.operateTimeout(operation, .{ .duration = duration_w_clock });
    std.log.debug("{any}", .{result});
    return ret[0];
}

pub fn writeByteTimeout(io: Io, file: Io.File, duration: Io.Duration, byte: u8) Io.OperateTimeoutError!void {
    var tmp: [1]u8 = .{byte};

    const duration_w_clock: Io.Clock.Duration = .{
        .clock = .awake,
        .raw = duration,
    };
    const operation: Io.Operation = .{ .file_write_streaming = .{
        .file = file,
        .data = &.{&tmp},
    } };
    const result = try io.operateTimeout(operation, .{ .duration = duration_w_clock });
    std.log.debug("{any}", .{result});
}

test readByteTimeout {
    if (comptime @import("builtin").os.tag != .linux) {
        return error.SkipZigTest;
    }

    const io = std.testing.io;

    var fd: [2]i32 = undefined;
    _ = std.os.linux.pipe2(&fd, .{ .NONBLOCK = false });
    defer _ = std.os.linux.close(fd[0]);
    defer _ = std.os.linux.close(fd[1]);

    const read_file: Io.File = .{ .handle = fd[0], .flags = .{ .nonblocking = false } };
    const result = readByteTimeout(io, read_file, .fromMilliseconds(1));
    try std.testing.expectError(error.Timeout, result);
}

test writeByteTimeout {
    if (comptime @import("builtin").os.tag != .linux) {
        return error.SkipZigTest;
    }

    const io = std.testing.io;

    var fd: [2]i32 = undefined;
    _ = std.os.linux.pipe2(&fd, .{ .NONBLOCK = false });
    defer _ = std.os.linux.close(fd[0]);
    defer _ = std.os.linux.close(fd[1]);

    const write_file: Io.File = .{ .handle = fd[1], .flags = .{ .nonblocking = false } };

    // this should succeed
    try writeByteTimeout(io, write_file, .fromMilliseconds(100), 0xAB);
}

test "roundtrip" {
    if (comptime @import("builtin").os.tag != .linux) {
        return error.SkipZigTest;
    }

    const io = std.testing.io;

    var fd: [2]i32 = undefined;
    _ = std.os.linux.pipe2(&fd, .{ .NONBLOCK = false });
    defer _ = std.os.linux.close(fd[0]);
    defer _ = std.os.linux.close(fd[1]);

    const read_file: Io.File = .{ .handle = fd[0], .flags = .{ .nonblocking = false } };
    const write_file: Io.File = .{ .handle = fd[1], .flags = .{ .nonblocking = false } };

    try writeByteTimeout(io, write_file, .fromMilliseconds(100), 0xAB);
    const byte = try readByteTimeout(io, read_file, .fromMilliseconds(100));
    try std.testing.expectEqual(@as(u8, 0xAB), byte);
}

Then in the nanomodbus interface implementation:

pub fn read_serial(buf: [*c]u8, count: u16, byte_timeout_ms: i32, arg: ?*anyopaque) callconv(.c) i32 {
    const ctx: *NMBS = @ptrCast(@alignCast(arg));

    var bytes_read: u16 = 0;

    while (bytes_read < count) : (bytes_read += 1) {
        buf[bytes_read] = util.readByteTimeout(ctx.io, ctx.tty, .fromMilliseconds(byte_timeout_ms)) catch |err| {
            std.log.err("nmbs: read_serial: readByteTimeout: error: {t}", .{err});
            break;
        };
    }

    return @intCast(bytes_read);
}

I hope someone will find this to be useful, as I’ve found the Io.Operation documentation to be otherwise a bit lacking. If you know of a simpler way on how to do this then I’ll gladly hear about it, because I really feel like there has to be a better way to do this.

8 Likes