danos/docs/character-devices-and-tty.md

9.4 KiB
Raw Blame History

Character devices, the console, and the tty question

A design note for the stream half of the device world. danos has block devices (the USB storage service behind the FAT mount) but no character devices — and three tracks now need them at once: the terminal application, Zig self-hosting Phase 1 ("wire fd 0/1/2 to a console byte stream"), and Python on danos Phase 1. This note settles what a character device is on danos before any of those tracks build one.

The Unix picture, briefly

Unix splits devices in two: block devices are seekable arrays of fixed-size sectors (disks); character devices are unseekable byte streams (keyboards, serial ports, terminals, /dev/null, entropy). A tty is the canonical character device — a byte stream plus a line discipline (echo, line buffering, erase handling, Ctrl-C-to-signal) that lives in the kernel. A pty is a pair of character devices (master/slave) that exists so a userspace program — a terminal emulator — can impersonate terminal hardware to the kernel's in-kernel line discipline.

The identification asked for and confirmed: yes, tty and pty are character devices in this taxonomy.

The realization that shapes everything: danos already has the mechanism

A Unix character device is an in-kernel dispatch table: major/minor numbers route read()/write() to a driver. danos already has exactly that dispatch — the VFS: fs_resolve routes a path to a mounted backend service, and Operation.mount attaches a backend endpoint at a prefix. What is missing is not a device model; it is one node kind with stream semantics. And the protocol already reserved it: NodeKind.character_device = 2 sits unimplemented in vfs-protocol.zig, exactly like symbolic_link.

So the design is small:

A character device on danos is a VFS node, served by an ordinary service over the existing VFS wire protocol, whose read/write have stream semantics.

No device numbers, no /dev special casing, no new syscalls, no new protocol — a service is reachable at a path, clients open it with runtime.fs like any file, and the node kind says what it is. (Since the protocol namespace landed in design, that path is /protocol/console — a protocol node, see os-development/protocol-namespace.md — rather than a mounted device file; the stream semantics below are unchanged.)

Stream semantics (the actual contract change)

For a node whose kind is character_device:

  • offset is ignored on read and write; there is no seek position. (lseek, when the C layer exists, returns ESPIPE.)
  • Reads block until at least one byte is available, then return what is there — short reads are normal, not EOF. A zero-length read reply means the stream is closed (hangup), not end-of-file-at-size.
  • FileStatus.size is 0 and means nothing; mtime may be 0.
  • Writes may be short if the service's buffer is full; the client loops as it already must for the 256-byte message cap.

This is a semantics note on existing operations, not a wire change — the Request and Reply structs are untouched. The one true protocol addition is a control operation (appended to Operation, values stable): a typed request the stream's service interprets. Deliberately not an ioctl grab-bag — the control payloads are enumerated per protocol, starting with the terminal set below.

The first character device is a pseudo-device

The first device is deliberately not hardware: an in-memory loopback — a byte queue served over the stream contract, where bytes written to one end are read from the other. It is the reference implementation of the semantics above (blocking reads, short reads, hangup on close, the control round-trip), it tests deterministically with no QEMU serial scripting, and it keeps hardware off the critical path entirely. null and zero come along nearly for free as degenerate cases. This is a decision, not a convenience: the dead-COM1 boot bug on real hardware already proved serial cannot be assumed present or alive, so nothing in this milestone writes to COM1. (A serial-backed stream node can exist later as one more optional backend for headless debugging; it is on nobody's critical path.)

The loopback is also not throwaway — it is the seed of P5's pipe(), which is the same object with two fds.

The console service

A console service owns the line discipline — in userspace, where a microkernel wants it, not in the kernel as Unix has it:

  • The discipline is a pure library first: bytes and key events in, bytes out, no I/O of its own — developed and host-tested against in-memory buffers, then shared verbatim between the console and the future terminal application.
  • Input: subscribes to keyboard InputEvent IPC (the structured events that exist today) and cooks them into bytes. Cooked mode is the default: echo, line buffering, backspace/erase, so a line is delivered on Enter. Raw mode delivers bytes as they come (the REPL's line editor and any full-screen program need it).
  • Output is a pluggable sink, and the stream contract is independent of it: the bring-up sink is in-memory (readable back by tests, mirrored to the boot log), and the real one is the framebuffer text renderer when the display track's font work lands.
  • Control set (the control payloads): mode raw/cooked, echo on/off, and window-size query — the minimal termios. Ctrl-C-to-signal joins when M17 signals-over-IPC lands; until then Ctrl-C is just a byte.
  • Mounts itself at /device/console as a character_device node.

fd 0/1/2 then stop being special: spawn hands the child three open handles (console by default; anything else if the parent chooses), and runtime's fd table maps 0/1/2 to them. isatty is simply "does status say character_device" — no side channel needed.

The pty answer: there is no pty

The pty exists in Unix because the line discipline is in the kernel — userspace terminal emulators need a kernel gadget to impersonate hardware. On danos the terminal emulator is already a userspace server, so the pair collapses:

The graphical terminal application serves the VFS stream protocol itself and hands its own endpoints to the children it spawns as their fd 0/1/2.

The terminal is the console service for its children — same protocol, same control set, same line discipline code (shared as a library with the boot console). No master/slave device pair, no /dev/pts, no new kernel object. When CPython arrives, the libc's isatty/read/write see a character device and are none the wiser; when xonsh eventually wants job control, that lands as control messages + M17 signals, still with no pty object.

What this costs: programs that specifically manipulate Unix ptys (os.openpty(), pexpect-style tools) have no direct equivalent — the danos answer is "spawn the child yourself with your own stream endpoints," which is the same capability with less machinery. Accepted.

Milestone slicing

  1. pseudo-devices — VFS honors character_device semantics end to end; Operation.control added; the in-memory loopback (plus null/zero) as the first device. QEMU test: one client writes, another reads — open, offsetless read/write, blocking read, short read, hangup on close, control round-trip. No hardware anywhere.
  2. console-service — the line-discipline library (host-tested, pure) plus the console composing keyboard InputEvents with an in-memory output sink; mounted at /device/console. QEMU test injects key events and reads cooked lines and raw bytes back through the sink.
  3. fd-inheritance — spawn passes 0/1/2 handles; runtime fd table; isatty via status; existing binaries' stdout migrates from debug_write to fd 1 (the logger keeps its own path).
  4. terminal-as-server — deferred to the terminal application milestone (Python track P3): the terminal reuses the discipline library and serves its children directly.

Steps 13 are exactly the shared seam that Zig self-hosting Phase 1 and Python Phase 1 both list; neither track repeats them.

Decisions needing sign-off

  • No pty object; the terminal serves its children directly (the section above) — the load-bearing simplification.
  • control as an enumerated, typed operation rather than an ioctl-style opaque pass-through.
  • Line discipline in userspace services (console + terminal, shared library), never in the kernel.