po6
v0.1.2
Published
Typed synchronous POSIX syscalls for Node.js on Linux: files, terminals, sockets, namespaces, ioctl, eventfd and errno handling
Maintainers
Readme
node-po6
Typed, synchronous POSIX syscalls for Node.js on Linux, written in TypeScript.
- Errors are values. No function throws for a failing syscall. Each returns the
errno, and once that is checked TypeScript knows the rest of the result is there. - Plain buffers. Data, addresses and option values are
Uint8Arrays (Node.jsBuffers work as well). po6 fills instruct msghdrandstruct iovecforrecvmsg()andsendmsg(). - Checked against the C headers. The layouts of the structures follow the data model of the platform and are compared with the POSIX headers in the tests.
- No native code. po6 performs syscalls and pins buffers with the functions you pass in. Use syscall-napi and buffer2address, or fakes in your tests.
Requirements
- Linux
- Node.js 24 or newer
- syscall-napi to perform the syscalls and buffer2address to pin buffers, both available for x86_64 and arm64
Installation
npm install po6 syscall-napi buffer2addressUsage
Setup
Create the API once, e.g. in a module po6.ts, with the syscalls of syscall-napi, the buffer pinning of buffer2address and the ABI of the host. The ABI also provides the constants and errno values:
import { pinBuffer } from "buffer2address";
import { syscall, syscallNumbers } from "syscall-napi";
import {
createKernelAbiFor,
createLinuxKernelInterface,
createPo6Api,
hostAbi
} from "po6";
const kernelAbi = createKernelAbiFor({ machineAbi: hostAbi });
const po6 = createPo6Api({
kernelInterface: createLinuxKernelInterface({ syscall, syscallNumbers }),
kernelAbi,
memory: { pinBuffer },
});
export {
po6,
kernelAbi
};Reading and writing
import { po6 } from "./po6.ts";
const { errno, fd } = po6.eventfd({ initval: 0, flags: 0 });
if (errno !== undefined) {
throw po6.createErrorFromErrno({ operation: "eventfd()", errno });
}
// the counter of an eventfd is a 64-bit integer in host byte order
const increment = new Uint8Array(new BigUint64Array([3n]).buffer);
const { errno: writeErrno } = po6.write({ fd, data: increment });
if (writeErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "write()", errno: writeErrno });
}
const counter = new BigUint64Array(1);
const { errno: readErrno } = po6.read({ fd, buffer: new Uint8Array(counter.buffer) });
if (readErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "read()", errno: readErrno });
}
console.log(counter[0]); // 3n
po6.close({ fd });Sockets
import { kernelAbi, po6 } from "./po6.ts";
const AF_INET = 2;
const SOCK_DGRAM = 2;
// struct sockaddr_in: the family in host byte order (little endian on x86_64
// and arm64), port and address in network byte order
const sockaddrIn = ({ port, address }: { port: number, address: number[] }) => {
const sockaddr = new Uint8Array(16);
const view = new DataView(sockaddr.buffer);
view.setUint16(0, AF_INET, true);
view.setUint16(2, port);
sockaddr.set(address, 4);
return sockaddr;
};
const { errno: socketErrno, fd } = po6.socket({ domain: AF_INET, type: SOCK_DGRAM, protocol: 0 });
if (socketErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "socket()", errno: socketErrno });
}
try {
// port 0 lets the kernel pick a free port
const { errno: bindErrno } = po6.bind({ fd, sockaddr: sockaddrIn({ port: 0, address: [127, 0, 0, 1] }) });
if (bindErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "bind()", errno: bindErrno });
}
const { errno: nameErrno, sockaddr } = po6.getsockname({ fd });
if (nameErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "getsockname()", errno: nameErrno });
}
// send a datagram to the socket itself
const { errno: sendErrno } = po6.sendmsg({
fd,
data: new TextEncoder().encode("hello"),
msghdr: { msg_name: sockaddr },
});
if (sendErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "sendmsg()", errno: sendErrno });
}
const data = new Uint8Array(1500);
const sender = new Uint8Array(16);
const { errno: recvErrno, bytesReceived, msghdr } = po6.recvmsg({
fd,
data,
msghdr: { msg_name: sender },
flags: kernelAbi.constants.MSG_DONTWAIT,
});
if (recvErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "recvmsg()", errno: recvErrno });
}
console.log(new TextDecoder().decode(data.subarray(0, bytesReceived))); // hello
console.log(msghdr.msg_namelen); // 16, the length of the sender's address in sender
} finally {
po6.close({ fd });
}Terminals
import { kernelAbi, po6 } from "./po6.ts";
const { constants } = kernelAbi;
// O_NOCTTY keeps the terminal from becoming the controlling terminal of the
// process, O_NONBLOCK keeps reads and writes from blocking the event loop
const { errno, fd } = po6.open({
pathname: "/dev/ttyUSB0",
flags: constants.O_RDWR | constants.O_NOCTTY | constants.O_NONBLOCK | constants.O_CLOEXEC,
});
if (errno !== undefined) {
throw po6.createErrorFromErrno({ operation: "open()", errno });
}
const { errno: getErrno, termios } = po6.tcgetattr({ fd });
if (getErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "tcgetattr()", errno: getErrno });
}
const baudRate = 115200n;
const { errno: setErrno } = po6.tcsetattr({
fd,
// apply the settings once the pending output was sent, and discard the pending input
optionalActions: constants.TCSAFLUSH,
termios: {
...termios,
// raw mode like cfmakeraw(): no conversions, line editing, echo or signals
c_iflag: termios.c_iflag & ~(constants.IGNBRK | constants.BRKINT | constants.PARMRK | constants.ISTRIP |
constants.INLCR | constants.IGNCR | constants.ICRNL | constants.IXON | constants.IXOFF | constants.IXANY),
c_oflag: termios.c_oflag & ~constants.OPOST,
c_lflag: termios.c_lflag & ~(constants.ECHO | constants.ECHONL | constants.ICANON | constants.ISIG | constants.IEXTEN),
// 8 data bits, no parity, 1 stop bit, no hardware flow control, modem control lines
// ignored, and the baud rate taken from c_ispeed and c_ospeed (BOTHER)
c_cflag: (termios.c_cflag & ~(constants.CSIZE | constants.PARENB | constants.CSTOPB | constants.CRTSCTS |
constants.CBAUD | constants.CIBAUD)) |
constants.CS8 | constants.CREAD | constants.CLOCAL | constants.BOTHER | (constants.BOTHER << constants.IBSHIFT),
c_ispeed: baudRate,
c_ospeed: baudRate,
},
});
if (setErrno !== undefined) {
throw po6.createErrorFromErrno({ operation: "tcsetattr()", errno: setErrno });
}
// read() and write() the terminal once it is ready, e.g. as uv-poll reports
po6.close({ fd });Network namespaces
unshare() and setns() move the calling thread into other namespaces, which needs CAP_SYS_ADMIN. For Node.js, this is the main thread. File descriptors and sockets stay in the namespace they were created in, also after the thread switched back. So enter a namespace, create the sockets, and leave it again, all synchronously, so no other JavaScript runs inside the namespace in between:
import net from "node:net";
import { kernelAbi, po6 } from "./po6.ts";
const { constants } = kernelAbi;
const throwOnErrno = ({ operation, errno }: { operation: string, errno: number | undefined }) => {
if (errno !== undefined) {
throw po6.createErrorFromErrno({ operation, errno });
}
};
// a handle on the network namespace of the calling thread
const openNetns = () => {
const { errno, fd } = po6.open({ pathname: "/proc/thread-self/ns/net", flags: constants.O_RDONLY | constants.O_CLOEXEC });
throwOnErrno({ operation: "open()", errno });
return fd as number;
};
// runs callback in the network namespace of fd and always switches back
const withNetns = <T>({ fd, callback }: { fd: number, callback: () => T }): T => {
const original = openNetns();
try {
throwOnErrno({ operation: "setns()", errno: po6.setns({ fd, nstype: constants.CLONE_NEWNET }).errno });
return callback();
} finally {
throwOnErrno({ operation: "setns()", errno: po6.setns({ fd: original, nstype: constants.CLONE_NEWNET }).errno });
po6.close({ fd: original });
}
};
// a new network namespace, which exists as long as netnsFd is open
const createNetns = () => {
const original = openNetns();
try {
throwOnErrno({ operation: "unshare()", errno: po6.unshare({ flags: constants.CLONE_NEWNET }).errno });
return openNetns();
} finally {
throwOnErrno({ operation: "setns()", errno: po6.setns({ fd: original, nstype: constants.CLONE_NEWNET }).errno });
po6.close({ fd: original });
}
};
const netnsFd = createNetns();
// a TCP socket listening on port 8080 in the namespace, its interfaces are set up separately, e.g. with node-rtnetlink
const listeningFd = withNetns({
fd: netnsFd,
callback: () => {
const AF_INET = 2;
const SOCK_STREAM = 1;
const { errno, fd } = po6.socket({ domain: AF_INET, type: SOCK_STREAM, protocol: 0 });
if (errno !== undefined) {
throw po6.createErrorFromErrno({ operation: "socket()", errno });
}
// struct sockaddr_in for 0.0.0.0:8080, the port in network byte order
const sockaddr = new Uint8Array(16);
new DataView(sockaddr.buffer).setUint16(0, AF_INET, true);
new DataView(sockaddr.buffer).setUint16(2, 8080);
throwOnErrno({ operation: "bind()", errno: po6.bind({ fd, sockaddr }).errno });
throwOnErrno({ operation: "listen()", errno: po6.listen({ fd, backlog: 128 }).errno });
return fd;
},
});
// Node.js accepts the connections, they belong to the namespace of the listening socket
net.createServer((connection) => {
connection.end("hello from the namespace\n");
}).listen({ fd: listeningFd });Creating a user namespace (CLONE_NEWUSER) fails with EINVAL, as Node.js runs several threads.
Handling errors
import { kernelAbi, po6 } from "./po6.ts";
const { constants, errnoCodes } = kernelAbi;
const fd = -1;
const { errno } = po6.recvmsg({ fd, data: new Uint8Array(1500), flags: constants.MSG_DONTWAIT });
if (errno === errnoCodes.EAGAIN) {
// nothing to receive yet, wait until the socket is readable
} else if (errno !== undefined) {
const error = po6.createErrorFromErrno({ operation: "recvmsg()", errno });
console.log(error.message); // recvmsg() failed with EBADF: Bad file descriptor
console.log(error.errno === errnoCodes.EBADF); // true
}API overview
createPo6Api({ kernelInterface, kernelAbi, memory }) returns the functions below and createErrorFromErrno(). All of them take a single object of arguments. File descriptors are numbers, other integers like flags may be numbers or bigints.
| Function | Arguments | Result on success, besides errno: undefined |
| --- | --- | --- |
| socket() | domain, type, protocol | fd |
| bind() | fd, sockaddr | |
| listen() | fd, backlog | |
| getsockname() | fd | sockaddr, a copy of the address |
| sendmsg() | fd, data, optional msghdr (msg_name, msg_control, msg_flags), optional flags | bytesSent |
| recvmsg() | fd, data, optional msghdr (msg_name, msg_control), optional flags | bytesReceived, msghdr (msg_namelen, msg_controllen, msg_flags) |
| shutdown() | fd, how | |
| getsockopt() | fd, level, optname, optval | optlen, the length the kernel wrote to optval |
| setsockopt() | fd, level, optname, optval | |
| ioctl() | fd, request, args (bigints and Uint8Arrays) | |
| eventfd() | initval, flags | fd |
| open() | pathname, flags, optional mode | fd |
| openat() | dirfd, pathname, flags, optional mode | fd |
| tcgetattr() | fd | termios |
| tcsetattr() | fd, optionalActions (TCSANOW, TCSADRAIN, TCSAFLUSH), termios | |
| tcflush() | fd, queueSelector (TCIFLUSH, TCOFLUSH, TCIOFLUSH) | |
| tcdrain() | fd | |
| read() | fd, buffer | bytesRead |
| write() | fd, data | bytesWritten |
| close() | fd | |
| dup() | fd | fd, the new file descriptor |
| unshare() | flags, the CLONE_NEW* types | |
| setns() | fd, nstype, a CLONE_NEW* type or 0 for any | |
The termios functions use struct termios2 of the kernel. Its c_ispeed and c_ospeed hold the speeds as numbers, which apply with BOTHER in c_cflag, so any baud rate can be set, and c_cc has NCCS (19) entries.
recvmsg() and sendmsg() transfer data with a single struct iovec. recvmsg() writes the address of the sender to msg_name and ancillary data to msg_control, as far as they fit. ioctl() passes Uint8Array arguments as pointers to their data:
const FIONREAD = 0x541b;
const bytesAvailable = new Int32Array(1);
const { errno } = po6.ioctl({ fd, request: FIONREAD, args: [new Uint8Array(bytesAvailable.buffer)] });Results and errors
Every result contains errno. It is undefined if the syscall succeeded and holds the errno if it failed, in which case the other fields of the result are undefined. The result types are exported as TReadResult, TRecvmsgResult and so on.
po6.createErrorFromErrno({ operation, errno }) turns an errno into an Error with a message like read() failed with EAGAIN: Resource temporarily unavailable and the errno attached (TErrorWithErrno). kernelAbi.errnoCodes maps the errno names to their values, including the aliases EWOULDBLOCK, EDEADLOCK and ENOTSUP.
The kernel ABI
createKernelAbiFor({ machineAbi }) describes how the kernel expects data: the layouts of the structures for the data model and byte order of the machine, the errno values in errnoCodes and the values in constants, which are
MSG_*, the flags ofrecvmsg()andsendmsg()SHUT_RD,SHUT_WRandSHUT_RDWRforshutdown()POLL*, the poll eventsSOL_PACKET,PACKET_ADD_MEMBERSHIP,PACKET_DROP_MEMBERSHIPandPACKET_MR_PROMISCfor packet socketsO_*, the flags ofopen()andopenat(), andAT_FDCWD.O_DIRECTORY,O_NOFOLLOW,O_DIRECTandO_LARGEFILEare left out, as arm and arm64 use other values for them than x86CLONE_NEWNET,CLONE_NEWNS,CLONE_NEWUTS,CLONE_NEWIPC,CLONE_NEWUSER,CLONE_NEWPID,CLONE_NEWCGROUPandCLONE_NEWTIME, the namespace types- the termios flags of
c_iflag,c_oflag,c_cflagandc_lflag, the indices ofc_cc(V*) andNCCS, the arguments oftcsetattr()andtcflush(), and the ioctl requestsTCGETS2,TCSETS2,TCSETSW2,TCSETSF2,TCSBRKandTCFLSH
hostAbi is the machine ABI Node.js runs on. The constants and errno values are the ones Linux uses on x86, arm and arm64.
Blocking
All functions perform their syscall synchronously and block until it returns, so a blocking recvmsg() on an empty socket blocks the whole event loop. Use non-blocking file descriptors or MSG_DONTWAIT, and wait for readiness in the event loop, e.g. with uv-poll. po6 has no poll() on purpose, as polling belongs into the event loop.
Buffers and file descriptors
po6 only uses buffers during the call. For recvmsg() and sendmsg() it pins data, msg_name and msg_control while the syscall runs, as struct msghdr refers to them by address. File descriptors are not tracked, so close the ones you create. File descriptors of Node.js, e.g. from fs.openSync(), can be used as well.
Development
npm ci
npm run build # transpile to dist/
npm run type-check
npm run test # mocha with c8, 100% coverage required
npm run lintThe functions in lib/functions/ get their dependencies passed in by createPo6Api() in lib/po6-api.ts:
lib/linux-kernel-interface.ts, the typed syscalls on top of a syscall function and the syscall numberslib/kernel-abi.ts, the layouts of the structures, the constants and the errno values for a machine ABIlib/pinning.ts, pinning buffers to pass their addresses to the kernel
The unit tests run against a fake kernel and fake memory from lib/test-support/ and perform no syscalls. syscall-napi is a dev dependency only, to check that it provides all syscalls of the kernel interface. The layouts of the structures are compared with the POSIX headers by compiling C programs, and a spec builds the package to check its type declarations, so gcc and the C library headers are required.
Releases are published by pushing a tag like v0.1.0, which builds the package, merges package.npm.json into package.json and sets the version.
License
LGPL-2.1, see LICENSE.
