Skip to content
sysl-langPublic

About

libuv for sysl — the event loop Node.js is built on: TCP, pipes, terminals, timers, signals, child processes, name resolution and a file system that does not block

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

30 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libuv

libuv for sysl — the event loop Node.js is built on, with TCP, pipes, terminals, timers, signals, child processes, name resolution and a file system that does not block.

An independent sysl package for libuv. It is not affiliated with or endorsed by libuv's authors or trademark holders.

dependencies {
  libuv { git = "github.com/sysl-lang/libuv", version = "0.1.18" }
}
import sh.sysl.libuv.*

main()
    val lp = default_loop()
    val server = tcp(lp).expect("a socket")

    server.bind(ip4("0.0.0.0", 8080).expect("an address")).expect("bound")

    server.listen(128, () ->
        val conn = server.accept().expect("a connection")

        conn.read_start((r) -> r match
            Data(bytes) -> conn.write(bytes)
            End -> conn.close()
            Failed(_) -> conn.close()))

    print("listening on 8080")
    lp.run().expect("the loop ran")

sysl run . is the whole command: libuv installs a libuv.pc beside itself and the manifest asks the machine where the headers and the library are.

Call ignore_sigpipe() first

A server that writes to a client which hung up is killed by SIGPIPE — the default action for it ends the process, with no diagnostic and with whatever was buffered thrown away. libuv does not turn that off for you and neither does anything else, because it is process-wide state and so the program's to set. One line at the top of main turns it into the EPIPE the write callback is meant to carry:

main()
    ignore_sigpipe()
    ...

This package's own suite has the test that found it: without the call, that test does not fail — it ends the test runner.

A handle stays alive until you close it

This is the one rule the rest of the package is built on, and it is worth reading before anything else. libuv's loop owns the handles on it — it keeps them on its own queues and writes into them from the backend — so a handle holds a reference to itself from the moment it is created until close finishes. Three things follow, and all three are what a program wants:

timer()?.start(1000, 0, () -> print("later"))     // nothing binds it, and it still fires
  • A handle need not be bound to a name. The loop is holding it.
  • Dropping the last name for a handle does not stop it, exactly as dropping a setInterval handle in JavaScript does not.
  • close is what ends it. A handle never closed is a handle never freed — and also one the loop is still waiting for, so run never returns. That is libuv's own rule and this binding does not soften it.

A callback is handed nothing

Every callback here takes only what it has to report — a Read, a status, a signal number — and never the handle it belongs to. A closure captures, so a callback that needs the handle captures the handle, which is the same reference the loop is holding and costs nothing:

val t = timer()?

t.start(0, 100, () ->
    n.bump()
    if n.value == 3 then t.close())

That closure and the handle refer to each other, which is a cycle a counted language does not collect. close is what breaks it: closing drops the callbacks and the handle's reference to itself, so the box goes when the last name for it does. A program that closes its handles leaks nothing, and one that does not was never going to return from run anyway.

A closure captures by value, so a counter a callback increments has to be behind a & for the caller to see it afterwards:

struct Counter
    n: int

val seen: &Counter = Counter(0)      // shared, because it is a reference

t.start(0, 10, () -> seen.n += 1)

Two layers, and where the line falls

sh.sysl.libuv.c is libuv declared verbatim: every name and every convention is C's, so a handle is storage the caller supplies, a failure is a negative integer, and a callback is one word of code with a void * beside it. sh.sysl.libuv is what an application imports.

What decides which module a thing goes in is whether an application has to name it, not whether it is a C artifact. libuv's numbers are c const blocks and they live in the pleasant module, in constants.sysl, because a program spells O_RDONLY and SIGINT. What stayed below is the sizes: the [N]u64 a handle is carried in is the binding's business and no consumer ever writes one.

The split keeps two jobs apart. The lower one has to be faithful — a signature that disagrees with the header links perfectly and corrupts the call at run time — so every declaration was read out of uv.h rather than remembered. The upper one has to be pleasant, which is a different question and would otherwise be answered in the same breath.

The shim is three shapes, and a few signatures

sh/sysl/libuv/c/shim.c is fifty lines. Most of them are one of the three things C can reach and sysl cannot:

why
struct sockaddr, struct addrinfo the field order is the platform's, not libuv's
uv_stdio_container_t libuv declares it with a union in it
uv_buf_t declared {base, len} on Unix and the other way round on Windows

The rest are the calls whose signature has no sysl spelling: uv_loop_configure, which is variadic; uv_thread_self, which answers with a platform type by value; and uv_thread_create_ex, whose options struct is positional — its stack size is read only when the flag asking for it is set. Beside them is confstr(_CS_PATH), the system's default search path, which a macro names.

Sizes are not answered there: a c const block measures sizeof for the target being built for, which is the same answer with nothing to keep in step. tests.sysl checks each measured size against what the linked library reports at run time, which is what catches headers and a library that are two different versions of libuv.

Handles are &T, addresses are values

A handle carries libuv's own storage inline and must never move — libuv's queues are linked lists through the handles on them — so every one of them is reached through &T and none is a value a program can copy. An Address, by contrast, is a copy of some bytes with nothing pointing at it, so it goes in a val, is passed by value and is returned:

val a = ip4("127.0.0.1", 8080)?

print(a.ip(), a.port(), s"$a")       // 127.0.0.1 8080 127.0.0.1:8080

Errors

Every libuv failure is one negative integer, and libuv carries its own name and sentence for each. So an Error is the code, and the strings are asked for when somebody wants them:

server.bind(addr) match
    Ok(_) -> ...
    Err(e) -> print(s"cannot bind: $e")      // EADDRINUSE: address already in use

Comparing is by code, which is what makes if e.code == EOF the one form a program acting on a failure writes. The codes worth acting on are named in constants.sysl.

What is here

loop default_loop, new_loop, run, stop, alive, now, backend_fd, configure, idle_time
timers timer, start, stop, again, set_repeat, due_in
watchers idle, prepare, check
wake-ups notifier — the one handle another thread may touch; waker, which is the send on its own as a value
signals signal, start, start_once, kill, ignore_sigpipe
file system events watch, fs_event, start (with FS_EVENT_RECURSIVE), stop, path; FsChange with filename, renamed, changed
streams read_start (one read buffer per thread, so a waiting reader holds none), write, try_write, shutdown, listen, back-pressure
TCP tcp, bind (with TCP_IPV6ONLY and TCP_REUSEPORT), listen, accept, connect, nodelay, keepalive, sockname
pipes pipe, pipe_pair, socket_pair, Unix domain sockets, and write_handle / pending_count / pending_type / accept_pending for passing a handle
terminals tty, set_mode, winsize, reset_tty_mode, guess_handle
names resolve — getaddrinfo on the thread pool
the pool queue — a job on a worker thread, answered on the loop; cancel, is_pending
threads thread, thread_with_stack, join, id, current_thread, set_thread_name
locks mutex, recursive_mutex, cond, semaphore, rwlock, barrier
files, blocking open_sync, read_file_sync, write_file_sync, stat_sync, scandir_sync, symlink_sync, …
files, not open, read, write, stat, scandir, chmod, read_file, …
children spawn, Stdio, kill, on_exit
the machine hrtime, hostname, cwd, env, available_parallelism, memory, load

What is not bound yet, and is ordinary work rather than anything blocked: UDP, uv_poll_t, uv_fs_poll_t, getnameinfo, uv_random, and dlopen. Each is a section of uv.h and each would be added the way the ones above were.

Three of libuv's threading calls are left out on purpose. uv_thread_detach would free the box a running body lives in, which is the one thing a binding must not offer; uv_once needs a guard in static storage, which is what shuts a package out of a bare-metal target; and uv_key_* is thread-local storage of a void *, which is a language feature rather than something to reach through a library. Thread priority, affinity and uv_thread_getcpu are scheduling rather than threading, and are the same ordinary work as the list above.

One port, several listeners

bind takes TCP_REUSEPORT, which asks the kernel to let more than one socket hold the same port and to distribute incoming connections across them:

server.bind(ip4("0.0.0.0", 8080).expect("an address"), TCP_REUSEPORT).expect("bound")

That is how a server uses every core: a process per core, each with its own loop and its own listener, and no thread accepting connections on behalf of the others. It is SO_REUSEPORT and it is not SO_REUSEADDR, which libuv sets for every listener on its own — that one is about rebinding a port whose last connection is still in TIME_WAIT, and it distributes nothing.

It answers ENOTSUP where the kernel does not distribute, and macOS is one of those. The flag works on Linux 3.9+, DragonFly 3.6+, FreeBSD 12.0+, Solaris 11.4 and AIX 7.2.5+. macOS is refused on purpose rather than by omission: its SO_REUSEPORT lets the duplicate bind succeed and then delivers every connection to the last socket bound, which is not load balancing — so a program developed on a Mac and shipped to Linux would have been silently wrong about what it had. A server that wants the port either way binds again without the flag when the first attempt is refused.

It needs libuv 1.49 or newer, which is where the flag was added.

One listener, several processes — passing a handle

Where TCP_REUSEPORT is refused, the portable answer is to move the socket rather than the listener, and that is what an ipc pipe is for: a supervisor owns the one listener, accepts every connection itself, and hands each accepted socket to a worker over the pipe it spawned that worker with. It is Node's cluster module's default scheduler, and it is the same mechanism underneath — SCM_RIGHTS over a Unix domain socket, with libuv doing the sendmsg.

The channel is a stdio slot, and stdio is not limited to three:

val to_worker = pipe(true)?

spawn(exe, ["--worker"], on_exit,
      [Inherit, Inherit, Inherit, ToPipe(to_worker, READABLE_PIPE | WRITABLE_PIPE)])?

Slot i is the child's descriptor i, so the worker's end is fd 3 and it adopts it with pipe(true)?.open(3). Both ends must be made with pipe(true).

Sending is one call, and the payload is never empty — a handle travels attached to bytes, and a single byte is the usual one. What is in it is the supervisor's business; libuv does not look:

to_worker.write_handle([1], conn, (r) ->
    r.expect("sent")
    conn.close())?

The handle is duplicated, not moved. The descriptor is copied when libuv reaches the sendmsg, which is why conn has to stay open until the write callback and why this side still closes its own copy afterwards.

Receiving happens inside the read callback, which is the only place a pending handle exists:

from_supervisor.read_start((r) -> r match
    Data(_) ->
        if from_supervisor.pending_count() > 0 && from_supervisor.pending_type() == HANDLE_TCP
            val conn = tcp()?

            from_supervisor.accept_pending(conn)?
            serve(conn)
    End -> ...
    Failed(e) -> ...)

pending_type has to be asked before the handle is taken, because accept_pending puts it into whatever handle it is given and only the type says which kind that should be — HANDLE_TCP for a socket, HANDLE_NAMED_PIPE for a pipe or a Unix domain socket. accept_pending with nothing waiting is EAGAIN; write_handle on a pipe that was not made with ipc is EINVAL, which is libuv's own refusal rather than a check this package added.

Work that is too slow for the loop

A loop is one thread and everything on it takes turns, so a callback that hashes a password or compresses an asset is a callback nothing else runs during. queue puts the slow part on one of libuv's worker threads and answers back on the loop:

var out: &sync Digest = Digest([0; 32])

queue(() -> hash_into(out), (r) -> r match             // the job, then the answer
    Ok(_) -> reply(out)
    Err(e) -> print(s"the job did not run: $e"))?

Two callbacks on two threads, and only one of them is ordinary. The completion runs on the loop and captures what any other callback here captures. The job crosses a concurrency domain, so it is a &sync Fn — and the compiler is what holds a caller to that, at the closure rather than at run time:

error: a closure shared between two domains may be called from either, so every count it captures
has to be atomic — but the 'c' it captures reaches a '&Cell', whose count is not. Hold it as a
'&sync Cell'

So a job may reach scalars and arrays, which are copied into it; a &sync T, which is how two threads share one object; and a *T, which carries no count and asks the writer to have thought about it. A string, a slice and an ordinary &T are refused, and the refusal names the capture. A &sync T makes the reference safe to share, not the object safe to mutate — that still wants sysl.sync.Atomic or a mutex.

A job answers by writing rather than by returning. Its closure is () -> unit, because a value coming back out of a pool thread would be a value crossing a boundary; what it has to say it says through the &sync it captured, and the completion callback is where that is read.

cancel catches a job a thread has not reached yet, and EBUSY says one already running cannot be. Either way the completion callback runs — with ECANCELED for a cancelled job — so a program has one place to release what the job was holding.

The pool is four threads unless UV_THREADPOOL_SIZE says otherwise, and libuv reads that once, at first use — which resolve and every asynchronous file call also count as. There is no call here that sets it: a library function cannot promise what the program did before it, so that is a variable a program's environment carries rather than a function this package could honestly offer. The pool is also shared, so a job that blocks for a second is a second in which one of those four threads resolves no names and reads no files.

Threads

A pool job borrows one of libuv's four worker threads and gives it back, so it is for something slow that has an end. A thread is for something that outlives the call: an actor with a loop of its own, a reader blocked on a device libuv has no backend for, a second event loop serving a second port. thread starts one and join waits for it:

val running: &sync Atomic[int] = Atomic(1)

val t = thread(() -> serve(running))?      // the body is a &sync Fn, like a pool job's

running.store(0)
t.join()?

What may cross into a thread is what may cross into a pool job, for the same reason and with the same refusal: scalars and arrays are copied in, a &sync T is how two threads share one object, a *T carries no count — and a string, a slice or an ordinary &T is refused at the closure, named.

A thread holds a reference to itself while it runs, so a body is never freed underneath a running thread, and join is what drops it. There is no detach, and that is deliberate: the body lives in that box, so a detached thread would be running a closure with nothing keeping it alive. A thread that should not be waited for is given something to wait on instead. Joining twice answers EINVAL rather than the undefined behaviour POSIX would give it.

The mailbox

A thread with a loop of its own is reached the way libuv reaches any loop from outside: leave the message where both threads can see it, and wake the loop with an Async. A handle is a &T and a body may not capture one, so what crosses is a Waker — an address, and uv_async_send is the one libuv call safe from any thread at all:

val t = thread(() ->
    val lp = new_loop().expect("a loop of its own")
    val a = notifier(() -> drain(box), lp).expect("a notifier on it")

    box.wake = Some(a.waker())          // the one thing here another thread may hold
    box.ready.post()

    lp.run().expect("the thread's loop ran"))?

box.ready.wait()

val wake = box.wake.expect("the thread published it")

box.lock.lock()
box.slots[box.held] = 1
box.held += 1
box.lock.unlock()

wake.send()?                            // the loop runs `drain` on its own thread

The mutex guards the queue and the wake-up carries no data, which is the shape libuv is built for: sends coalesce, so the callback's job is to drain whatever is there rather than to be counted. The handle has to outlive every waker taken from it — the arrangement above has the thread owning both, which makes that true by construction.

The queue is an array rather than a Buf, and not by preference: a growable collection owns its elements through a count that is not atomic, so a &sync struct may not hold one and the compiler says so. A fixed array is what a shared queue is made of today.

The locks

None of them is a handle: they belong to no loop, have no close callback, and are released by destroy. Each is a &sync T, because a lock only one thread can reach is not a lock — and because &sync is what a body may capture.

mutex, recursive_mutex lock, try_lock, unlock, and with(f), which holds it for a closure and answers what the closure did
cond wait(m), timed_wait(m, ns), signal, broadcast
semaphore(n) wait, try_wait, post — and unlike a condition it remembers a post nobody was waiting for
rwlock read_lock, write_lock, their try_ forms, and reading(f) / writing(f)
barrier(n) wait, which answers true for the one thread that may destroy it

unlock on a mutex this thread does not hold is undefined, in libuv and in pthreads under it, and nothing here checks it: the word it would cost is paid by every correct program. with is the form that cannot get it wrong, and the bare pair is for the case that cannot use it — a lock held across a wait, or across a callback that drains a queue.

A condition carries no state, so a signal nobody is waiting for is lost, and a wait may return without one. That is why the test is a loop and never an if:

m.lock()

while queue_is_empty()
    ready.wait(m)

m.unlock()

The file system, both ways round

A file system call blocks, however fast the disk is, so libuv runs the asynchronous form on its thread pool and answers through the loop. The blocking form is the same call with no callback.

val text = read_file_sync("config.hocon")?              // blocking, and usually right

read_file("big.dat", (r) -> r match                     // on the pool, answered on the loop
    Ok(bytes) -> handle(bytes)
    Err(e) -> print(s"cannot read: $e"))?

The suffix is Node's, which is the closest thing libuv has to a convention: the plain name is asynchronous and _sync is the one that blocks. A server should reach for the plain one on any path a request waits on, and nobody should reach for it to read a file at startup.

Watching the file system

watch is told when something under a path changes, by the operating system rather than by a poll: FSEvents for a directory on macOS, kqueue for a file there, inotify on Linux.

val w = watch("site", true, (r) -> r match              // true: subdirectories too
    Ok(change) -> rebuild(change.filename)              // Option[string], relative to "site"
    Err(e) -> print(s"watching stopped: $e"))?

w.close()                                               // what stops it, as for every handle

Each FsChange carries libuv's bits: renamed() is FS_RENAME — a name appeared, went or moved — and changed() is FS_CHANGE, new contents or attributes. filename is an Option because libuv may report a change without saying what it was. A path that is not there is refused at the call, as ENOENT; a failure the backend reports later arrives as an Err in the callback. fs_event(lp) makes a watcher without starting it, and start(path, cb, flags) takes the flags directly; path() answers what it is watching.

What is reported, and how it is batched, is the platform's. A creation is a rename, one write may arrive as several events and several as one, and FSEvents carries a file's recent history in the flags of a later event — so a file made a moment ago and then written through a directory watcher is still reported as a rename. A program that rebuilds on a change wants to debounce, and to look at the file system again rather than trust the event to say what is there.

Recursive watching is macOS's and Windows's. On Linux inotify watches one directory at a time and libuv does not walk the tree, so FS_EVENT_RECURSIVE is not honoured there and each subdirectory needs a watcher of its own. The suite's recursive case asserts macOS's behaviour and has not been run on Linux. FS_EVENT_WATCH_ENTRY and FS_EVENT_STAT are named, and libuv documents both as not implemented yet.

Dropping the last name for a watcher does not stop it, as for every handle here (see above): the loop holds it until close.

Child processes

val out = pipe()?

val child = spawn("git", ["rev-parse", "HEAD"], ignore_exit,
                  [Ignore, ToPipe(out, WRITABLE_PIPE), Inherit])?

child.on_exit((status, signal) ->
    ended.code = i32(status)
    child.close())

out.read_start((r) -> r match
    Data(bytes) -> collect(bytes)
    End -> out.close()
    Failed(_) -> out.close())

args does not carry the program's own name — libuv, like execvp, wants it first, and spawn puts it there. The pipe flags are written from the child's point of view, which is libuv's convention and the one thing here that is easy to get backwards: a pipe the parent reads is one the child writes, so it is WRITABLE_PIPE.

The child is reaped by the loop, which is what SIGCHLD handling would otherwise be for.

The child's environment

env is a list of "NAME=value" entries, and the last parameter, inherit_env: bool = true, says what it means:

env empty env given
inherit_env = true, the default the child inherits this process's environment env replaces it entirely
inherit_env = false the child has no environment at all env is the child's whole environment

The default is what spawn has always done, so a call written before the parameter existed means what it meant. What false adds is the empty environment, which true cannot express — an empty list is its way of saying "inherit". A given list replaces under either setting, which is libuv's meaning for a non-null env; sysl.process's default adds instead.

spawn("/usr/bin/env", [], on_exit, [], "", [], 0, default_loop(), false)?        // prints nothing
spawn("env", [], on_exit, [], "", ["LANG=C"], 0, default_loop(), false)?         // LANG=C, alone

PATH decides where the program is looked for, before the child exists. A PATH among the entries is searched, as execvp in the child would search it, so a PATH meant for the child's own children wants the program named by an absolute path. A child given an environment with no PATH is looked for on the system's default path, confstr(_CS_PATH) — /usr/bin:/bin:/usr/sbin:/sbin on macOS, /bin:/usr/bin on glibc — and never on this process's PATH: that is sysl.process's rule with inherit_env = false. libuv's own fallback is narrower on macOS (/usr/bin:/bin, so sysctl is not found), which is why spawn resolves the name itself in that one case and hands libuv the full path, leaving argv[0] as written. With inherit_env = true and a list holding no PATH, the fallback is libuv's, as it always was. A program found nowhere is ENOENT from the call either way.

Installing libuv

brew install libuv             # macOS
sudo apt install libuv1-dev    # Debian / Ubuntu
sudo pacman -S libuv           # Arch

libuv 1.49 or newer, which is where UV_TCP_REUSEPORT was added. An older one fails to compile constants.sysl, naming that identifier — which reads as a defect in this package and is a libuv that predates the flag.

The build needs no flags: libuv.pc is installed beside the library everywhere it ships, and the manifest's pkg_config requirement asks for it. --include-path libuv=<dir> and --link-path <dir> still answer it by hand, for a hermetic build or a prefix pkg-config has never heard of.

This package is posix. An event loop is the operating system's — epoll, kqueue, a thread pool, a socket, a signal disposition — so there is no freestanding target it works on, and the manifest says so rather than letting it fail at the link with a message naming uv_run.

Tests

sysl test .

A hundred and seventy-three of them, over nine files, and every public entry point but one is exercised by one. The one is Tty.winsize, which needs a terminal with a slave attached — on a pty master macOS refuses it and Linux allows it, so a test either way would pin a platform rather than this binding, and a test runner has no controlling terminal to use instead. It says so at the site.

The whole raw layer is exercised too, which is a separate file: a declaration nothing calls is a declaration nothing checks, and a signature that disagrees with uv.h links perfectly and corrupts the call at run time. Every one of the 229 externs is now reached, from the pleasant layer or from raw_tests.sysl directly. uv_cancel was the one exception until the thread pool arrived — it may only be called on a request still in flight, and on a finished one it faults, so there was nothing safe to call it from. Work.cancel is that caller now, because a request knows whether it is still the loop's.

The most important test is the one that watches memory. A handle holds a reference to itself so that the loop owns it, and close dropping that is what the whole design rests on — as does a work request, which has no close and releases itself when it reports instead — but a refcount is not something a program can ask about, so a finish_close missing one line would leak every handle a program ever opened with every other test still green. closing_frees churns ten thousand of each handle type per round and asserts resident memory stops growing once the allocator has settled. Removing one line from any single finish_close turns it red; that was checked against three of them.

The read buffer is watched the same way, with the C heap as the observer. Every stream on a thread reads into one shared buffer that libuv asks for only when bytes are ready, so a reader that is waiting holds nothing. Two hundred armed readers on idle sockets grow the heap by about two kilobytes, which is their callbacks. With one 64 KiB buffer per stream they grew it by sixteen megabytes. The tests beside it check that two streams read in one turn of the loop each see their own bytes, that a large read is not cut to a smaller stream's buffer_size, and that stopping or closing a stream from inside its own read callback leaves the others reading.

Under AddressSanitizer

SYSL_EXTRA_CFLAGS="-fsanitize=address -g" sysl test .

What that reaches is the sysl half. libuv is a library the machine already has, so nothing of libuv's is instrumented — what is, is this binding's own pointer arithmetic, which is where a binding's risk lives anyway. It found a real one: resolve_done copied a whole sockaddr_storage out of a resolver's answer, which points at a sockaddr_in of sixteen bytes in an allocation of exactly that size, so every lookup read 112 bytes past the end of it. The suite was green before and after, because the extra bytes went into a value nothing reads — ai_addrlen is what the copy is bounded by now.

closing_frees fails under the sanitizer and only under it. It asserts that resident memory stops growing, and ASan gives every block a redzone and holds freed ones in a quarantine, so RSS grows whatever the reference counting does. Read that test's answer from a run without the flag.

Loop.fork is tested by actually forking, which took two attempts worth recording: the first version passed with the call under test taken out, because a loop carrying only a timer does not exercise it. What a fork invalidates is the backend's registration of descriptors, so the test now carries a live read across the fork — 2ms with the call, and a failure at its 500ms guard without it.

The thing under test is the binding rather than libuv. What is untested anywhere else is the arrangement this package is built on, and every case is chosen to fail if one of these is not true: a handle's storage stays where it was put, a handle stays alive with nothing naming it and is freed when it is closed, the address libuv hands a callback finds its way back to the right sysl handle, and a struct this side declares has the layout the header does.

The end-to-end ones are real: a TCP echo over the loopback on a port the kernel picked, a Unix domain socket in a temporary directory, localhost resolved on the thread pool, a file written and read back, echo hello spawned with its output read through a pipe, and /bin/pwd run in a directory it was given with an environment it was given. The file system watcher is tested against the real one in a temporary directory: a creation, a modification and a rename each with its bits and its name, a recursive watch seeing into a subdirectory where a plain one does not, stop and close silencing it, and a missing path refused as ENOENT.

Seven things here were wrong until a test said so, which is the argument for writing them: again refuses only a timer that was never started rather than one with no interval; due_in still reports a deadline after stop; idle_time answers zero until the loop is asked to measure it; socket_pair gives Unix domain sockets, so Tcp.open on one connects and then refuses every TCP option; Pipe.chmod takes READABLE/WRITABLE and not the stdio flags of nearly the same name; hrtime does not share a base with clock(CLOCK_MONOTONIC) — seven seconds apart on this machine; and a write to a peer that has gone ends the process unless ignore_sigpipe was called.

An eighth was a compiler bug rather than a mistake here: rendering an AddrInfo used to segfault, because a struct over 128 bytes could not be rendered through str or s"$x" at all while print of one worked. That was card 0305, fixed in sysl 0.0.83; AddrInfo has a Display now, and a test covers it directly.

Two compiler bugs this package used to work around

Both were the same 128-byte boundary, which is where a value stops being passed and returned directly, and both were fixed in sysl 0.0.83 — this package's floor, so neither workaround is in the source any more.

Card 0304 — a ? in a function whose result was larger than 128 bytes emitted a direct return out of a function the ABI made void, and clang refused the compiler's own IR. ip4 and ip6, which answer an Address, now use ? like everything else.

Card 0305 — a struct over 128 bytes could not implement Display usefully: str(x) and s"$x" segfaulted with no diagnostic, while print(x) worked. AddrInfo now has a Display, rendering its address and, when the resolver was asked for one, its canonical name.

License

ISC. libuv itself is MIT and is not vendored here — this package binds whatever the machine has installed.

About

libuv for sysl — the event loop Node.js is built on: TCP, pipes, terminals, timers, signals, child processes, name resolution and a file system that does not block

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages