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)andwrite(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.
rmunlinks a link for the same reason, andpit qopskips 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.