diff --git a/src/fibers/io/fd_backend/core.lua b/src/fibers/io/fd_backend/core.lua index b0b7499f..b908ffc3 100644 --- a/src/fibers/io/fd_backend/core.lua +++ b/src/fibers/io/fd_backend/core.lua @@ -294,6 +294,7 @@ local function build_backend(ops) modes = ops.modes or {}, permissions = ops.permissions or {}, AF_UNIX = ops.AF_UNIX, + AF_INET = ops.AF_INET, SOCK_STREAM = ops.SOCK_STREAM, } end diff --git a/src/fibers/io/fd_backend/ffi.lua b/src/fibers/io/fd_backend/ffi.lua index 3534ffc4..546e6529 100644 --- a/src/fibers/io/fd_backend/ffi.lua +++ b/src/fibers/io/fd_backend/ffi.lua @@ -70,6 +70,23 @@ ffi.cdef [[ int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); + + typedef unsigned short in_port_t; + typedef unsigned int in_addr_t; + + struct in_addr { + in_addr_t s_addr; + }; + + struct sockaddr_in { + sa_family_t sin_family; + in_port_t sin_port; + struct in_addr sin_addr; + unsigned char sin_zero[8]; + }; + + unsigned short htons(unsigned short hostshort); + int inet_pton(int af, const char *src, void *dst); ]] -- POSIX fcntl command numbers on Linux. @@ -105,6 +122,7 @@ local EINPROGRESS = 115 -- Socket constants (Linux ABI). local AF_UNIX = 1 +local AF_INET = 2 local SOCK_STREAM = 1 local SOL_SOCKET = 1 local SO_ERROR = 4 @@ -314,8 +332,8 @@ permissions['rw-r--r--'] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], S_IWGRP, S_IWOTH) permissions['rwxr-xr-x'] = bit.bor( S_IRUSR, S_IWUSR, S_IXUSR, - S_IRGRP, S_IXGRP, - S_IROTH, S_IXOTH + S_IRGRP, S_IXGRP, + S_IROTH, S_IXOTH ) permissions['rwx------'] = bit.bor(S_IRUSR, S_IWUSR, S_IXUSR) @@ -417,21 +435,54 @@ end ---------------------------------------------------------------------- local function make_sockaddr_un(path) - local sa = ffi.new('struct sockaddr_un') - ---@cast sa sockaddr_un_cdata - sa.sun_family = AF_UNIX - - local maxlen = 108 - 1 - local p = path - if #p > maxlen then - p = p:sub(1, maxlen) - end - ffi.fill(sa.sun_path, 108) - ffi.copy(sa.sun_path, p) - - -- Full struct size is fine for bind/connect. - local len = ffi.sizeof('struct sockaddr_un') - return sa, len + local sa = ffi.new('struct sockaddr_un') + ---@cast sa sockaddr_un_cdata + sa.sun_family = AF_UNIX + + local maxlen = 108 - 1 + local p = path + if #p > maxlen then + p = p:sub(1, maxlen) + end + ffi.fill(sa.sun_path, 108) + ffi.copy(sa.sun_path, p) + + -- Full struct size is fine for bind/connect. + local len = ffi.sizeof('struct sockaddr_un') + return sa, len +end + +local function make_sockaddr_in(host, port) + if type(host) ~= 'string' or host == '' then + return nil, nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, nil, 'port must be 0..65535' + end + + local sa = ffi.new('struct sockaddr_in') + sa.sin_family = AF_INET + sa.sin_port = C.htons(tonumber(port)) + + local c_host = ffi.new('char[?]', #host + 1) + ffi.copy(c_host, host) + + local addr = ffi.new('struct in_addr[1]') + local rc = toint(C.inet_pton(AF_INET, c_host, addr)) + if rc ~= 1 then + if rc == 0 then + return nil, nil, 'invalid IPv4 address: ' .. tostring(host) + end + local e = get_errno() + return nil, nil, strerror(e) + end + + sa.sin_addr = addr[0] + ffi.fill(sa.sin_zero, 8) + + return sa, ffi.sizeof('struct sockaddr_in'), nil end local function socket_fd(domain, stype, protocol) @@ -444,11 +495,21 @@ local function socket_fd(domain, stype, protocol) end local function bind_fd(fd, sa) - -- For now, sa is expected to be a UNIX-domain path string. - if type(sa) ~= 'string' then + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token: { family = 'inet', host = '1.2.3.4', port = 1234 } + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return false, serr, nil + end + else return false, 'unsupported sockaddr representation', nil end - local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.bind(fd, ffi.cast('struct sockaddr *', c_sa), len)) if rc ~= 0 then local e = get_errno() @@ -481,14 +542,26 @@ end --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean local function connect_start_fd(fd, sa) - if type(sa) ~= 'string' then + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return nil, serr, false + end + else return nil, 'unsupported sockaddr representation', false end - local c_sa, len = make_sockaddr_un(sa) + local rc = toint(C.connect(fd, ffi.cast('struct sockaddr *', c_sa), len)) if rc == 0 then return true, nil, false end + local e = get_errno() if e == EINPROGRESS then return nil, nil, true @@ -549,6 +622,7 @@ local ops = { permissions = permissions, AF_UNIX = AF_UNIX, + AF_INET = AF_INET, SOCK_STREAM = SOCK_STREAM, is_supported = is_supported, diff --git a/src/fibers/io/fd_backend/nixio.lua b/src/fibers/io/fd_backend/nixio.lua index ec32b5d6..8692c655 100644 --- a/src/fibers/io/fd_backend/nixio.lua +++ b/src/fibers/io/fd_backend/nixio.lua @@ -17,10 +17,10 @@ local EINPROGRESS = const.EINPROGRESS or 115 local EALREADY = const.EALREADY or 114 -- Where available, reuse nixio’s numeric constants so callers see --- sensible AF_* / SOCK_* values. Fall back to standard-ish defaults. +-- sensible AF_* / SOCK_* values. Fall back to standard Linux values. local AF_UNIX = const.AF_UNIX or 1 -local AF_INET = const.AF_INET -local AF_INET6 = const.AF_INET6 +local AF_INET = const.AF_INET or 2 +local AF_INET6 = const.AF_INET6 or 10 local SOCK_STREAM = const.SOCK_STREAM or 1 local SOCK_DGRAM = const.SOCK_DGRAM or 2 @@ -29,8 +29,6 @@ local function errno_msg(default, eno) return default end - -- nixio.strerror expects a number; some nixio APIs return msg/errno in - -- different positions depending on build/version. if type(eno) ~= 'number' then local n = tonumber(eno) if n then @@ -50,6 +48,41 @@ local function errno_msg(default, eno) return s end +-- nixio APIs vary by build/version in whether they return: +-- nil, msg, errno +-- or +-- nil, errno, msg +-- This helper normalises the trailing two values. +local function norm_msg_eno(a, b) + local ta, tb = type(a), type(b) + + if ta == 'number' and tb == 'string' then + return b, a + end + if ta == 'string' and tb == 'number' then + return a, b + end + if ta == 'number' and b == nil then + return nil, a + end + if ta == 'string' and b == nil then + return a, nil + end + if ta == 'number' then + return nil, a + end + if tb == 'number' then + return nil, b + end + if ta == 'string' then + return a, nil + end + if tb == 'string' then + return b, nil + end + return nil, nil +end + -- nixio.open expects perms as a mode string (e.g. "0644" or "rw-r--r--") local DEFAULT_CREATE_PERMS = '0666' -- subject to umask @@ -81,12 +114,13 @@ end -- fd here is a nixio.File or nixio.Socket local function set_nonblock(fd) if fd and fd.setblocking then - local ok, eno = fd:setblocking(false) + local ok, a, b = fd:setblocking(false) if ok ~= nil and ok ~= false then - return true, nil, eno + return true, nil, nil end + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() - return false, errno_msg('setblocking(false) failed', eno), eno + return false, errno_msg(msg or 'setblocking(false) failed', eno), eno end -- If there is no setblocking, treat as already non-blocking. return true, nil, nil @@ -102,17 +136,18 @@ local function read_fd(fd, max) return '', nil end - -- nixio.File:read / Socket:read both follow the same style: - -- data (success/EOF) - -- nil, msg, errno (error) - local data, eno, msg = fd:read(max) + -- nixio.File:read / Socket:read both generally return: + -- data + -- nil, msg, errno OR nil, errno, msg + local data, a, b = fd:read(max) if type(data) == 'string' then -- data may be "" at EOF; that is acceptable to callers. return data, nil end - -- eno = eno or nixio.errno() + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() if eno == EAGAIN or eno == EWOULDBLOCK then -- Would block, signal “not ready yet”. @@ -139,13 +174,14 @@ local function write_fd(fd, str, len) -- For files: File.write(buf, offset, length) -- For sockets: Socket.send / write(buf, offset, length) – same shape. - local n, eno, msg = fd:write(str, 0, len) + local n, a, b = fd:write(str, 0, len) if type(n) == 'number' then return n, nil end - -- eno = eno or nixio.errno() + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() if eno == EAGAIN or eno == EWOULDBLOCK then -- Would block. @@ -173,8 +209,9 @@ local function seek_fd(fd, whence, off) return nil, 'seek not supported on this descriptor' end - local pos, msg, eno = fd:seek(off, whence) + local pos, a, b = fd:seek(off, whence) if pos == nil then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return nil, errno_msg(msg or 'seek failed', eno) end @@ -186,8 +223,9 @@ local function close_fd(fd) return true, nil end - local ok, msg, eno = fd:close() + local ok, a, b = fd:close() if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return false, errno_msg(msg or 'close failed', eno) end @@ -199,7 +237,6 @@ end ---------------------------------------------------------------------- -- Basic symbolic permission presets for mkdir and file creation. --- (Lua has no octal literal; use base-8 parsing.) local function oct(s) return tonumber(s, 8) end @@ -231,64 +268,68 @@ local function norm_open_perms(perms) return perms end --- nixio.fs.mkdir generally expects a numeric mode. +-- nixio.fs.mkdir expects a mode string on some builds ("0755"), not a raw number. local function norm_mkdir_mode(perms) if perms == nil then - return permissions['rwxr-xr-x'] or 493 -- 0755 + return '0755' end + local t = type(perms) + if t == 'number' then - return perms + -- Decimal 511 -> "0777" + return string.format('%04o', perms) end + if t == 'string' then local m = permissions[perms] - if m then return m end - -- Accept "0755" style. - local n = tonumber(perms, 8) or tonumber(perms) - if n then return n end + if m then + return string.format('%04o', m) + end + + -- Accept already-octal strings like "0755" + -- (and leave other strings untouched for nixio to interpret) + return perms end - return permissions['rwxr-xr-x'] or 493 + + return '0755' end local function mkdir_path(path, perms) - -- Default to 0755 for directories. - local mode = norm_perms(perms, permissions['rwxr-xr-x']) - - local ok, msg, eno - if mode == nil then - ok, msg, eno = fs.mkdir(path) - else - ok, msg, eno = fs.mkdir(path, mode) - end + local mode = norm_mkdir_mode(perms) + local ok, a, b = fs.mkdir(path, mode) if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) return false, errno_msg(msg or 'mkdir failed', eno) end + return true, nil end --- For this backend we rely on nixio.open’s mode strings. local function open_file(path, mode, perms) mode = mode or 'r' - local p = norm_perms(perms) + local p = norm_open_perms(perms) -- If this is a creating mode and perms is nil, provide a default. if p == nil and is_create_mode(mode) then p = DEFAULT_CREATE_PERMS end - local f, eno = nixio.open(path, mode, p) + local f, a, b = nixio.open(path, mode, p) if not f then - return nil, errno_msg('open failed', eno) + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'open failed', eno) end return f, nil end local function pipe_fds() - local r, w, eno = nixio.pipe() + local r, w, a, b = nixio.pipe() if not r then - return nil, nil, errno_msg('pipe failed', eno) + local msg, eno = norm_msg_eno(a, b) + return nil, nil, errno_msg(msg or 'pipe failed', eno) end return r, w, nil end @@ -297,15 +338,16 @@ local function mktemp(prefix, perms) local start = math.random(1e7) local last_err - local p = norm_perms(perms) or '0644' + local p = norm_open_perms(perms) or '0644' for i = start, start + 10 do local tmpnam = prefix .. '.' .. i - local f, eno = nixio.open(tmpnam, 'w+', p) + local f, a, b = nixio.open(tmpnam, 'w+', p) if f then return f, tmpnam end - last_err = errno_msg('mktemp open failed', eno) + local msg, eno = norm_msg_eno(a, b) + last_err = errno_msg(msg or 'mktemp open failed', eno) end return nil, last_err or 'mktemp: failed to create temporary file' @@ -315,8 +357,9 @@ local function fsync_fd(fd) if not fd or not fd.sync then return true, nil end - local ok, msg, eno = fd:sync(false) + local ok, a, b = fd:sync(false) if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return false, errno_msg(msg or 'fsync failed', eno) end @@ -324,16 +367,18 @@ local function fsync_fd(fd) end local function rename_file(oldpath, newpath) - local ok, msg, eno = fs.rename(oldpath, newpath) + local ok, a, b = fs.rename(oldpath, newpath) if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) return false, errno_msg(msg or 'rename failed', eno) end return true, nil end local function unlink_file(path) - local ok, msg, eno = fs.unlink(path) + local ok, a, b = fs.unlink(path) if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) return false, errno_msg(msg or 'unlink failed', eno) end return true, nil @@ -348,9 +393,10 @@ end local function ignore_sigpipe() -- Best-effort ignore of SIGPIPE. if nixio.signal and nixio.SIGPIPE then - local ok, eno = nixio.signal(nixio.SIGPIPE, 'ign') + local ok, a, b = nixio.signal(nixio.SIGPIPE, 'ign') if ok == nil or ok == false then - return false, errno_msg('signal(SIGPIPE) failed', eno) + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'signal(SIGPIPE) failed', eno) end end return true, nil @@ -383,35 +429,91 @@ local function stype_to_str(stype) error('fd_backend.nixio: unsupported socket type: ' .. tostring(stype)) end +-- Normalise sockaddr tokens used by fibers.io.socket: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + return 'unix', sa, 0 + end + + if type(sa) ~= 'table' then + return nil, nil, nil, 'unsupported sockaddr representation' + end + + local fam = sa.family or sa.af + if fam == AF_UNIX then fam = 'unix' end + if fam == AF_INET then fam = 'inet' end + if fam == AF_INET6 then fam = 'inet6' end + + if fam == nil then + -- Reasonable fallback: table with port implies inet. + if sa.port ~= nil then + fam = 'inet' + elseif sa.path then + fam = 'unix' + end + end + + if fam == 'unix' then + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, nil, nil, 'invalid unix sockaddr' + end + return 'unix', path, 0 + end + + if fam == 'inet' or fam == 'inet6' then + local host = sa.host + local port = sa.port + if host ~= nil and type(host) ~= 'string' then + return nil, nil, nil, 'invalid ' .. fam .. ' host' + end + port = tonumber(port) + if port == nil then + return nil, nil, nil, 'invalid ' .. fam .. ' port' + end + return fam, host, port + end + + return nil, nil, nil, 'unsupported sockaddr family' +end + --- socket(domain, stype, protocol) -> fd|nil, err|nil, eno|nil local function socket_fd(domain, stype, _) local d = domain_to_str(domain) local t = stype_to_str(stype) - local s, eno = nixio.socket(d, t) + local s, a, b = nixio.socket(d, t) if not s then - return nil, errno_msg('socket failed', eno), eno + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'socket failed', eno), eno end -- Returned “fd” is a nixio.Socket object. return s, nil, nil end ---- bind(fd, sa) where fd is nixio.Socket; sa is e.g. UNIX path string. +--- bind(fd, sa) where fd is nixio.Socket local function bind_fd(fd, sa) if not fd then return false, 'closed socket', nil end - local ok, msg, eno - - if type(sa) == 'string' then - -- For AF_UNIX, host is path, port is ignored. We pass 0 as a dummy. - ok, msg, eno = fd:bind(sa, 0) - else - return false, 'unsupported sockaddr representation', nil + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return false, nerr, nil end + local ok, a, b + ok, a, b = fd:bind(host, port) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return false, errno_msg(msg or 'bind failed', eno), eno end @@ -425,8 +527,9 @@ local function listen_fd(fd) end local backlog = const.SOMAXCONN or 128 - local ok, msg, eno = fd:listen(backlog) + local ok, a, b = fd:listen(backlog) if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return false, errno_msg(msg or 'listen failed', eno), eno end @@ -440,11 +543,13 @@ local function accept_fd(fd) end -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno - local newsock, _, _, msg, eno = fd:accept() + -- Some builds may swap msg/errno on error; normalise. + local newsock, x, y = fd:accept() if newsock then return newsock, nil, false end + local msg, eno = norm_msg_eno(x, y) eno = eno or nixio.errno() if eno == EAGAIN or eno == EWOULDBLOCK then return nil, nil, true @@ -459,19 +564,17 @@ local function connect_start_fd(fd, sa) return nil, 'closed socket', false end - local ok, msg, eno - - if type(sa) == 'string' then - -- For AF_UNIX, host is path, port is ignored. - ok, msg, eno = fd:connect(sa, 0) - else - return nil, 'unsupported sockaddr representation', false + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return nil, nerr, false end + local ok, a, b = fd:connect(host, port) if ok then return true, nil, false end + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then -- Non-blocking connect in progress. @@ -492,12 +595,14 @@ local function connect_finish_fd(fd) return true, nil end - local soerr, msg, eno = fd:getopt('socket', 'error') + local soerr, a, b = fd:getopt('socket', 'error') if soerr == nil then + local msg, eno = norm_msg_eno(a, b) eno = eno or nixio.errno() return false, errno_msg(msg or 'getsockopt(SO_ERROR) failed', eno) end + soerr = tonumber(soerr) or 0 if soerr == 0 then return true, nil end @@ -550,7 +655,10 @@ local ops = { permissions = permissions, AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, is_supported = is_supported, } diff --git a/src/fibers/io/fd_backend/posix.lua b/src/fibers/io/fd_backend/posix.lua index 2e9c7018..4d485342 100644 --- a/src/fibers/io/fd_backend/posix.lua +++ b/src/fibers/io/fd_backend/posix.lua @@ -92,8 +92,6 @@ end -- File-level helpers ---------------------------------------------------------------------- --- Mode and permission tables as before, but using POSIX constants. - local modes = { r = fcntl.O_RDONLY, w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), @@ -117,7 +115,6 @@ local permissions = {} permissions['rw-r--r--'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], pstat.S_IWGRP, pstat.S_IWOTH) --- Directory-friendly defaults (execute bits matter for traversal). permissions['rwxr-xr-x'] = bit.bor( pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IXUSR, pstat.S_IRGRP, pstat.S_IXGRP, @@ -135,7 +132,6 @@ local function mkdir_path(path, perms) p = perms end - -- LuaPosix: mkdir(path, mode) -> 0 | nil, errmsg, errnum local ok, err, eno = pstat.mkdir(path, p) if ok == nil then return false, errno_msg('mkdir failed', err, eno) @@ -175,7 +171,6 @@ local function pipe_fds() end local function mktemp(prefix, perms) - -- Normalise perms: nil -> default, string -> lookup in permissions table. if perms == nil then perms = permissions['rw-r--r--'] elseif type(perms) == 'string' then @@ -264,6 +259,84 @@ end -- Socket helpers on top of posix.sys.socket ---------------------------------------------------------------------- +local AF_UNIX = socket_mod.AF_UNIX +local AF_INET = socket_mod.AF_INET +local AF_INET6 = socket_mod.AF_INET6 +local SOCK_STREAM = socket_mod.SOCK_STREAM +local SOCK_DGRAM = socket_mod.SOCK_DGRAM + +-- Normalise sockaddr tokens used by higher layers into luaposix sockaddr tables. +-- Accepted inputs: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- { family = "inet", addr = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +-- Raw luaposix sockaddr table: +-- { family = socket_mod.AF_INET, addr = "...", port = ... } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + -- Convenience form: UNIX path + return { family = AF_UNIX, path = sa } + end + + if type(sa) ~= 'table' then + return nil, 'unsupported sockaddr representation' + end + + -- If this already looks like a luaposix sockaddr table, accept it. + if type(sa.family) == 'number' then + return sa + end + + local fam = sa.family or sa.af + if fam == 'unix' then + if not AF_UNIX then + return nil, 'AF_UNIX not supported' + end + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, 'invalid unix sockaddr path' + end + return { family = AF_UNIX, path = path } + end + + if fam == 'inet' then + if not AF_INET then + return nil, 'AF_INET not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet sockaddr addr' + end + if port == nil then + return nil, 'invalid inet sockaddr port' + end + return { family = AF_INET, addr = addr, port = port } + end + + if fam == 'inet6' then + if not AF_INET6 then + return nil, 'AF_INET6 not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet6 sockaddr addr' + end + if port == nil then + return nil, 'invalid inet6 sockaddr port' + end + return { family = AF_INET6, addr = addr, port = port } + end + + return nil, 'unsupported sockaddr family' +end + --- Create a socket fd. ---@param domain integer ---@param stype integer @@ -278,23 +351,16 @@ local function socket_fd(domain, stype, protocol) end --- Bind a socket to an address token. ---- ---- For AF_UNIX, we treat sa as a path string. ---@param fd integer ---@param sa any ---@return boolean ok, string|nil err, integer|nil eno local function bind_fd(fd, sa) - local addr - if type(sa) == 'string' then - addr = { family = socket_mod.AF_UNIX, path = sa } - elseif type(sa) == 'table' then - addr = sa - else - return false, 'unsupported sockaddr representation', nil + local addr, aerr = norm_sockaddr(sa) + if not addr then + return false, aerr, nil end local ok, err, eno = socket_mod.bind(fd, addr) - -- LuaPosix returns 0 on success, nil on error. if ok == nil then return false, errno_msg('bind failed', err, eno), eno end @@ -317,7 +383,6 @@ end ---@param fd integer ---@return integer|nil newfd, string|nil err, boolean again local function accept_fd(fd) - -- LuaPosix: accept(fd) -> connfd, addr | nil, errmsg, errnum local newfd, addr_or_err, errnum = socket_mod.accept(fd) if newfd ~= nil then return newfd, nil, false @@ -337,23 +402,17 @@ end ---@param sa any ---@return boolean|nil ok, string|nil err, boolean inprogress local function connect_start_fd(fd, sa) - local addr - if type(sa) == 'string' then - addr = { family = socket_mod.AF_UNIX, path = sa } - elseif type(sa) == 'table' then - addr = sa - else - return nil, 'unsupported sockaddr representation', false + local addr, aerr = norm_sockaddr(sa) + if not addr then + return nil, aerr, false end - -- LuaPosix: connect(fd, addr) -> 0 | nil, errmsg, errnum local ok, err, eno = socket_mod.connect(fd, addr) if ok ~= nil then - -- Successful connect (may still be non-blocking socket, but connect has completed). return true, nil, false end - if eno == errno.EINPROGRESS then + if eno == errno.EINPROGRESS or eno == errno.EALREADY or eno == errno.EAGAIN then return nil, nil, true end @@ -370,14 +429,16 @@ local function connect_finish_fd(fd) if soerr == nil then return false, errno_msg('getsockopt(SO_ERROR) failed', err, eno) end + + soerr = tonumber(soerr) or 0 if soerr == 0 then return true, nil end + return false, 'connect error errno ' .. tostring(soerr) end local function is_supported() - -- If we reached here, luaposix is present; assume support. return true end @@ -412,8 +473,11 @@ local ops = { modes = modes, permissions = permissions, - AF_UNIX = socket_mod.AF_UNIX, - SOCK_STREAM = socket_mod.SOCK_STREAM, + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, + SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, is_supported = is_supported, } diff --git a/src/fibers/io/socket.lua b/src/fibers/io/socket.lua index 997c10af..87a65950 100644 --- a/src/fibers/io/socket.lua +++ b/src/fibers/io/socket.lua @@ -6,15 +6,22 @@ -- socket(domain, stype, protocol?) -> Socket -- listen_unix(path, opts?) -> Socket (listening AF_UNIX) -- connect_unix(path, stype?, proto?) -> Stream +-- listen_inet(host, port, opts?) -> Socket (listening AF_INET) +-- connect_inet(host, port, opts?) -> Stream -- --- Socket (AF_UNIX focus) supports: +-- Socket supports: +-- :bind(sa) +-- :listen() -- :listen_unix(path) --- :accept_op() -> Op (resolves to Stream|nil, err) --- :accept() -> Stream|nil, err --- :connect_op(sa) -> Op (sa currently a UNIX path string) +-- :listen_inet(host, port) +-- :accept_op() +-- :accept() +-- :connect_op(sa) -- :connect(sa) -- :connect_unix_op(path) -- :connect_unix(path) +-- :connect_inet_op(host, port) +-- :connect_inet(host, port) -- :close() -- ---@module 'fibers.io.socket' @@ -40,7 +47,6 @@ Socket.__index = Socket ---@return Stream local function fd_to_stream(fd, filename) local io = fd_backend.new(fd, { filename = filename }) - -- For sockets we assume readable + writable. return stream_mod.open(io, true, true) end @@ -48,7 +54,6 @@ end ---@param fd integer ---@return Socket local function new_socket(fd) - -- Ensure non-blocking behaviour. local ok, err = fd_backend.set_nonblock(fd) if not ok then fd_backend.close_fd(fd) @@ -65,6 +70,27 @@ function Socket:_fd() return fd end +--- Build an AF_INET sockaddr token understood by fd_backend. +---@param host string +---@param port number|string +---@return table|nil sa, any err +local function inet_sa(host, port) + if type(host) ~= 'string' or host == '' then + return nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, 'port must be 0..65535' + end + + return { + family = 'inet', + host = host, + port = math.floor(port), + } +end + ---------------------------------------------------------------------- -- Constructors ---------------------------------------------------------------------- @@ -79,37 +105,82 @@ local function socket(domain, stype, protocol) if not fd then return nil, err end + local ok, nerr = fd_backend.set_nonblock(fd) if not ok then fd_backend.close_fd(fd) return nil, nerr end + return new_socket(fd) end ---------------------------------------------------------------------- --- Listening and address helpers (UNIX domain) +-- Generic bind/listen helpers ---------------------------------------------------------------------- ---- Listen on a UNIX-domain path using this Socket. ----@param path string +--- Bind this socket to an address token (UNIX path string or inet table). +---@param sa any ---@return boolean|nil ok, any err -function Socket:listen_unix(path) +function Socket:bind(sa) local fd = self:_fd() - - local ok, err = fd_backend.bind(fd, path) + local ok, err = fd_backend.bind(fd, sa) if not ok then return nil, ('bind failed: %s'):format(tostring(err)) end + return true +end - ok, err = fd_backend.listen(fd) +--- Mark this socket as listening. +---@return boolean|nil ok, any err +function Socket:listen() + local fd = self:_fd() + local ok, err = fd_backend.listen(fd) if not ok then return nil, ('listen failed: %s'):format(tostring(err)) end - return true end +---------------------------------------------------------------------- +-- Listening and address helpers (UNIX / INET) +---------------------------------------------------------------------- + +--- Listen on a UNIX-domain path using this Socket. +---@param path string +---@return boolean|nil ok, any err +function Socket:listen_unix(path) + local ok, err = self:bind(path) + if not ok then + return nil, err + end + return self:listen() +end + +--- Bind this socket to an IPv4 address/port. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:bind_inet(host, port) + local sa, err = inet_sa(host, port) + if not sa then + return nil, err + end + return self:bind(sa) +end + +--- Listen on an IPv4 address/port using this Socket. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:listen_inet(host, port) + local ok, err = self:bind_inet(host, port) + if not ok then + return nil, err + end + return self:listen() +end + ---------------------------------------------------------------------- -- accept() as an Op ---------------------------------------------------------------------- @@ -126,15 +197,12 @@ function Socket:accept_op() return true, new_fd, nil end if again then - -- Would block: wait for readability. return false end - -- Hard error. return true, nil, err end local function register(task) - -- poller wait on listening fd for read readiness. return P:wait(fd, 'rd', task) end @@ -142,7 +210,6 @@ function Socket:accept_op() if not new_fd then return nil, err end - -- fd_to_stream will mark it non-blocking via fd_backend.new(). return fd_to_stream(new_fd) end @@ -156,11 +223,13 @@ function Socket:accept() end ---------------------------------------------------------------------- --- connect() as an Op (AF_UNIX path as opaque "sa") +-- connect() as an Op (generic sockaddr token) ---------------------------------------------------------------------- --- Build an Op that connects this Socket to an address token. ---- Currently sa is expected to be a UNIX-domain path string. +--- sa may be: +--- * UNIX path string +--- * { family = 'inet', host = '1.2.3.4', port = 1234 } ---@param sa any ---@return Op function Socket:connect_op(sa) @@ -191,7 +260,6 @@ function Socket:connect_op(sa) end local function register(task) - -- Non-blocking connect completion is signalled via writability. return P:wait(fd, 'wr', task) end @@ -200,8 +268,7 @@ function Socket:connect_op(sa) return nil, err end local new_fd = fd - -- Hand ownership of the fd to the Stream; prevent double-close in Socket:close(). - self.fd = nil + self.fd = nil -- hand ownership to Stream return fd_to_stream(new_fd) end @@ -299,6 +366,89 @@ local function connect_unix(path, stype, protocol) return stream end +---------------------------------------------------------------------- +-- AF_INET convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Op +function Socket:connect_inet_op(host, port) + local sa, err = inet_sa(host, port) + if not sa then + error(err, 2) + end + return self:connect_op(sa) +end + +--- Connect synchronously to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Stream|nil stream, any err +function Socket:connect_inet(host, port) + return perform(self:connect_inet_op(host, port)) +end + +--- Listen on an IPv4 address/port and return a listening Socket. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer } +---@return Socket|nil s, any err +local function listen_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_inet(host, port) + if not ok then + s:close() + return nil, lerr + end + + return s +end + +--- Connect to an IPv4 host/port and return a Stream. +--- opts.bind_host / opts.bind_port can be used to bind a source address/port first. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer, bind_host?: string, bind_port?: number|string } +---@return Stream|nil stream, any err +local function connect_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + if opts.bind_host ~= nil or opts.bind_port ~= nil then + local ok, berr = s:bind_inet(opts.bind_host or '0.0.0.0', opts.bind_port or 0) + if not ok then + s:close() + return nil, berr + end + end + + local stream, cerr = s:connect_inet(host, port) + if not stream then + s:close() + return nil, cerr + end + + return stream +end + ---------------------------------------------------------------------- -- Lifecycle ---------------------------------------------------------------------- @@ -320,11 +470,17 @@ end return { socket = socket, + listen_unix = listen_unix, connect_unix = connect_unix, + + listen_inet = listen_inet, + connect_inet = connect_inet, + Socket = Socket, -- re-export useful constants for callers AF_UNIX = fd_backend.AF_UNIX, + AF_INET = fd_backend.AF_INET, SOCK_STREAM = fd_backend.SOCK_STREAM, } diff --git a/tests/test_io-socket.lua b/tests/test_io-socket.lua index 672ca823..172d8c06 100644 --- a/tests/test_io-socket.lua +++ b/tests/test_io-socket.lua @@ -11,77 +11,136 @@ print('testing: fibers.io.socket') -- look one level up package.path = '../src/?.lua;' .. package.path --- test_socket.lua --- --- Simple assertion-based checks for fibers.io.socket over AF_UNIX. - local fibers = require 'fibers' local socket_mod = require 'fibers.io.socket' local perform = fibers.perform +math.randomseed(os.time()) + +local function read_exact(stream, n, who) + local data, cnt, err = perform(stream:core_read_op { + min = n, + max = n, + eof_ok = true, + }) + assert(err == nil, (who or 'read') .. ' error: ' .. tostring(err)) + assert(cnt == n, (who or 'read') .. ' read ' .. tostring(cnt) .. ' bytes, expected ' .. tostring(n)) + return data +end + +local function write_all(stream, s, who) + local n, err = perform(stream:write_op(s)) + assert(err == nil, (who or 'write') .. ' error: ' .. tostring(err)) + assert(n == #s, (who or 'write') .. ' wrote ' .. tostring(n) .. ' bytes, expected ' .. tostring(#s)) +end + +local function close_ok(obj, who) + local ok, err = obj:close() + assert(ok, (who or 'close') .. ' failed: ' .. tostring(err)) +end + +local function pick_listen_port() + -- Avoid privileged ports; choose from a broad high range. + return math.random(30000, 55000) +end + +local function listen_inet_retry(host, tries) + tries = tries or 32 + local last_err + + for _ = 1, tries do + local port = pick_listen_port() + local s, err = socket_mod.listen_inet(host, port) + if s then + return s, port + end + last_err = err + end + + return nil, nil, ('failed to bind IPv4 listener after retries: %s'):format(tostring(last_err)) +end + local function test_unix_socket_roundtrip(scope) -- Construct a unique path under /tmp for this test run. local base = os.getenv('TMPDIR') or '/tmp' local path = string.format('%s/fibers_socket_test.%d.%d', - base, os.time(), math.random(1e6)) + base, os.time(), math.random(1, 1000000)) -- Start listening server. local server, err = socket_mod.listen_unix(path, { ephemeral = true }) assert(server, 'listen_unix failed: ' .. tostring(err)) - -- Server fiber: accept one connection, echo a response, then close. - scope:spawn(function (_) + -- Server fibre: accept one connection, echo a response, then close. + scope:spawn(function () local s, aerr = server:accept() assert(s, 'server accept failed: ' .. tostring(aerr)) - local msg, cnt, rerr = perform(s:core_read_op { - min = 5, - max = 5, - eof_ok = true, - }) + local msg = read_exact(s, 5, 'server(unix) read') + assert(msg == 'hello', ('server(unix) received %q, expected %q'):format(tostring(msg), 'hello')) - assert(rerr == nil, 'server read_string_op error: ' .. tostring(rerr)) - assert(cnt == 5, 'server read_string_op read ' .. tostring(cnt) .. ' bytes, expected 5') - assert(msg == 'hello', ('server received %q, expected %q'):format(tostring(msg), 'hello')) + write_all(s, 'world', 'server(unix) write') - local n, werr = perform(s:write_op('world')) - assert(werr == nil, 'server write_string_op error: ' .. tostring(werr)) - assert(n == 5, 'server write_string_op wrote ' .. tostring(n) .. ' bytes, expected 5') - - local okc, cerr = s:close() - assert(okc, 'server stream close failed: ' .. tostring(cerr)) - - local oks, serr = server:close() - assert(oks, 'server socket close failed: ' .. tostring(serr)) + close_ok(s, 'server(unix) stream close') + close_ok(server, 'server(unix) socket close') end) -- Client side: connect, send "hello", read "world". local client, cerr = socket_mod.connect_unix(path) assert(client, 'connect_unix failed: ' .. tostring(cerr)) - local n, werr = perform(client:write_op('hello')) - assert(werr == nil, 'client write_string_op error: ' .. tostring(werr)) - assert(n == 5, 'client write_string_op wrote ' .. tostring(n) .. ' bytes, expected 5') + write_all(client, 'hello', 'client(unix) write') - local resp, cnt, rerr = perform(client:core_read_op { - min = 5, - max = 5, - eof_ok = true, + local resp = read_exact(client, 5, 'client(unix) read') + assert(resp == 'world', ('client(unix) received %q, expected %q'):format(tostring(resp), 'world')) + + close_ok(client, 'client(unix) stream close') +end + +local function test_inet_socket_roundtrip(scope) + assert(socket_mod.AF_INET, 'AF_INET not exported by fibers.io.socket') + + local host = '127.0.0.1' + + -- Start listening server on a random high port. + local server, port, lerr = listen_inet_retry(host, 64) + assert(server, lerr or 'listen_inet failed') + + -- Server fibre: accept one connection, echo a response, then close. + scope:spawn(function () + local s, aerr = server:accept() + assert(s, 'server accept failed: ' .. tostring(aerr)) + + local msg = read_exact(s, 5, 'server(inet) read') + assert(msg == 'hello', ('server(inet) received %q, expected %q'):format(tostring(msg), 'hello')) + + write_all(s, 'world', 'server(inet) write') + + close_ok(s, 'server(inet) stream close') + close_ok(server, 'server(inet) socket close') + end) + + -- Client side: connect over loopback, explicitly binding source address. + -- bind_port=0 asks the kernel to choose an ephemeral source port. + local client, cerr = socket_mod.connect_inet(host, port, { + bind_host = '127.0.0.1', + bind_port = 0, }) + assert(client, 'connect_inet failed: ' .. tostring(cerr)) + + write_all(client, 'hello', 'client(inet) write') - assert(rerr == nil, 'client read_string_op error: ' .. tostring(rerr)) - assert(cnt == 5, 'client read_string_op read ' .. tostring(cnt) .. ' bytes, expected 5') - assert(resp == 'world', ('client received %q, expected %q'):format(tostring(resp), 'world')) + local resp = read_exact(client, 5, 'client(inet) read') + assert(resp == 'world', ('client(inet) received %q, expected %q'):format(tostring(resp), 'world')) - local okc, cclose_err = client:close() - assert(okc, 'client stream close failed: ' .. tostring(cclose_err)) + close_ok(client, 'client(inet) stream close') end local function main(scope) test_unix_socket_roundtrip(scope) + test_inet_socket_roundtrip(scope) end fibers.run(main) -print('test_socket.lua: all assertions passed') +print('test_io-socket.lua: all assertions passed')