Byte channels, file, and net

io, file, and net share one byte-stream contract. A file, TCP connection, pipe, child-process stream, and TLS stream can all be passed to the same channel functions. Native descriptors, sockets, and platform handles are never part of the portable value.

Byte channels

A byte channel is exactly this record:

{
  read: function(callback, {size}),
  write: function(callback, blob),
  close: function(callback, value)
}

size is optional and defaults to a provider-appropriate chunk size. A read succeeds with a blob. End of stream is (null, "eof"); any other null result is (null, reason). A zero-length blob is data, not EOF.

One primitive write may be partial and answers with the number of bytes accepted. io.write_all(blob) retries the unaccepted suffix. Portable callers which need the entire blob written use write_all, rather than assuming a socket, pipe, or file accepts it in one operation.

All three methods are direct requestors and may return a cancellation function. Completion happens at most once. Cancellation with abandon == true completes the request with (null, reason); later native completion is suppressed. Reads and writes are independently serialized per channel. Closing fails every active or queued operation, invokes the native close once, and settles every close waiter with that one result. net and tls share the same Pit implementation of these rules.

io supplies channel-only policy:

  • read(spec) and write(blob) requestor factories;
  • write_all(blob) for complete writes;
  • slurp(opts) to read to EOF and close;
  • pipe(destination) to copy with backpressure;
  • stream(consumer) for chunk-wise consumption;
  • reader(channel) for buffered exact, line, and integer reads.

None of these functions knows whether the channel is a file or a socket.

Files

file is asynchronous portable policy over the target-selected native file endowment. Its path operations are direct requestors:

file.open(callback, {
  path,
  read, write,
  create, exclusive,
  truncate, append
})
file.stat(callback, path)
file.lstat(callback, path)
file.mkdir(callback, {path, mode})
file.remove(callback, path)
file.list(callback, path)
file.canonical(callback, path)

stat follows symlinks and describes what a path resolves to; lstat describes the path itself. list answers with lstat’s view of each entry — {name, isFile, isDirectory, isLink, size, mtime} — because a listing says what a directory holds, and an entry that is a link is a link.

Traversal

file.enumerate(recurse) and file.globfs(globs) are requestor factories over one directory walk, and the walk has three rules:

  • A symlink below the root is skipped — not listed, not descended. The shop does not use symlinks: nothing in pit creates one, and a link inside a package is not a case to get right but a case to omit. This is also the whole of containment: a directory cycle can only be built from a link, and results can only leave the tree through one, so refusing links is both the cycle check and the escape check. rm unlinks a link for the same reason, and pit qop skips it — a qop has no entry kind for one.
  • A root that IS a symlink is followed. Naming a path is choosing to follow it. This is what lets a shop reach a package through a link, and it is the only exemption.
  • A directory that cannot be read costs only itself. The entry is still listed; what is inside it is not; the rest of the walk stands. Only a root that cannot be listed fails the walk. Both skips are announced on the log, because a file list that goes quietly short is worse than one that fails.

open may accept a path string as shorthand for a read-only open. It returns a byte channel with file-specific requestors:

channel.read_at(callback, {offset, size})
channel.write_at(callback, {offset, data})
channel.seek(callback, {offset, whence})
channel.tell(callback, null)
channel.stat(callback, null)

Sequential reads, writes, and seeks share one coherent cursor and are executed in call order. read_at and write_at are positional and do not change that cursor. This remains true when the native provider completes operations out of order.

Path-level slurp/write/append helpers are composites of open, the io operations, and close; they do not introduce another file-access mechanism.

The target implementation may be io_uring, overlapped I/O and IOCP, an asynchronous Darwin file worker, or an honestly deferred synchronous device API. The portable contract and completion order do not change. Raw synchronous capabilities such as $posix_file may also exist for platform-specific code, but generic file need not use them.

Network streams

net.tcp.connect returns the same byte channel:

net.tcp.connect(callback, {host, address, port})
net.tcp.listen(callback, {address, port, backlog})
listener.accept(callback, null) // {channel, peer}
listener.close(callback, null)

host may require name resolution; address is already numeric. TCP read, write, close, slurp, pipe, stream, and buffered-reader behavior is identical to a file or pipe.

Closing a socket tombstones the actor-visible handle at once — a further operation on it fails cleanly — while the underlying native descriptor stays alive until any in-flight operation that borrowed it retires, so a concurrent read or write already in progress completes rather than touching a freed descriptor. Beyond the byte-channel surface, the raw socket control adds only set_nonblocking(handle, enabled).

UDP is not a byte channel because datagram boundaries and peer addresses are semantic. Targets which support it expose net.udp with receive, send, and close requestors. Unix-domain sockets and other target-only transports are likewise present only on implementations which genuinely support them.

Unsupported operations are absent. In particular, browser WebAssembly does not advertise fake TCP or UDP methods; HTTP, fetch, and WebSocket packages may use the browser capabilities they actually receive.

Interface enumeration is deliberately outside transport-only net:

def network_info = use('net::network_info')
var entries = network_info.interfaces()
// [{name, address, family: "ipv4" | "ipv6"}, ...]

On a target where net has an honest transport provider, a provider that cannot truthfully enumerate host interfaces returns []; it does not invent a loopback entry. Targets without net, including Web, omit the module.

TLS streams

TLS is a separate target-selected module rather than part of net. Its one public operation is a strict direct requestor:

tls.connect(callback, {
  host,
  port,
  protocols? // ALPN protocol names
}) // callback({channel, protocol?}) or callback(null, reason)

The returned channel is the same byte channel used by files, TCP sockets, pipes, and process streams. protocol, when present, is the ALPN protocol selected by the peer. Portable cancellation, channel validation, EOF policy, and stream composition remain in Pit; a native provider owns only the TLS session and its callback-first connect, receive, send, and cancellation mechanisms.

Darwin selects the Network.framework-backed implementation. A target with no raw TLS transport has no module arm. Browsers expose HTTPS Fetch and secure WebSocket capabilities instead of raw TLS. Upgrading an existing plaintext channel with STARTTLS is not part of this interface and requires a separately designed operation.

Processes

Process is an explicit desktop package, not part of pitlib or the file standard library. A package that declares it can use process/process on Darwin, Linux, and Windows. Web and Playdate have no process arm.

def process = use('process::process')

process.spawn(function(started, reason) {
  if (started == null) return

  // stdin/stdout/stderr are ordinary optional byte channels.
  started.child.wait(function(status, wait_reason) {
    if (status.code != null) log.console('exit ' + text(status.code))
    else log.console('signal ' + text(status.signal))
  }, null)
}, {
  argv: ["tool", "argument"],
  env: {NAME: "value"},
  cwd: "/working/directory",
  process_group: true,
  stdin: "pipe",   // "pipe", "inherit", or "null"
  stdout: "pipe",
  stderr: "pipe"
})

The default stdin mode is "null"; stdout and stderr default to "pipe". A pipe stream is the ordinary byte channel described above, so io.slurp, io.pipe, io.reader, and io.write_all work without process-specific variants. A child has wait(callback, value), terminate(callback, {force?, tree?}), and, on the POSIX provider, signal(callback, number). Wait cancellation suppresses the completion but does not kill the child. The process/run helper captures both output streams and explicitly terminates its child when that whole-run requestor is cancelled.

terminate reaches the platform honestly. On Windows a graceful stop delivers a CTRL_BREAK_EVENT to the child’s process group, and a forced or whole-tree stop uses the child’s assigned Job object or TerminateProcess; on POSIX the same options map to the explicit signal and terminate operations. The process_group: true option above is what puts the child in its own group so a break can target it.

Fetch

Fetch is another explicit package. It presents one whole-response requestor over the client facility actually supplied by the target:

def fetch = use('fetch')

fetch.fetch('https://example.com', {method: 'GET'})(function(response, reason) {
  if (response == null) return
  log.console(text(response.status))
  log.console(text(response.body))
}, null)

The result is {status, headers, body} and HTTP error statuses such as 404 are successful responses. Apple targets use NSURLSession, Linux dynamically realizes libcurl, Windows uses WinHTTP, Web uses browser Fetch, and Playdate uses its HTTP table. On Linux, missing or incompatible libcurl makes the endowment unrealizable; fetch does not silently import net, TLS, or the HTTP library.

Filesystem watch

pit-watch::watch is available only on desktop targets with an implemented watch facility:

def watch = use('pit-watch::watch')

var stop = watch.monitor(function(events, reason) {
  if (events == null) return
  arrfor(events, function(event) {
    log.console(event.action + ' ' + event.file)
  })
}, {path: '/tmp/project', recursive: true})

// Later:
stop()

Events use relative /-separated paths and actions create, modify, delete, move, or overflow; move events may contain from. The Darwin, Linux, and Windows adapters translate the deliberately different raw FSEvents, inotify, and ReadDirectoryChangesW vocabularies. Web and Playdate have no filesystem-watch module arm.

Endowments and ownership

The standard modules contain policy, not authority. Their target arm claims a raw endowment such as Playdate file/TCP, Linux io_uring, Darwin networking, or Windows IOCP. The shop grants and fulfills that claim when the actor starts.

Native handles are opaque and actor-owned. Close releases them once; actor halt also releases every still-owned handle and cancels pending completions. A portable record never carries a numeric file descriptor or native pointer.