# 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](python-on-danos.md)
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](../library/protocol/vfs/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](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 `InputEvent`s 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 1–3 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.

## Related

- [python-on-danos.md](python-on-danos.md) — consumes this as its Phase 1.
- [zig-self-hosting.md](zig-self-hosting.md) — ditto ("stdio as fds").
- [file-system-development/vfs-protocol.md](file-system-development/vfs-protocol.md) —
  the wire protocol this note extends.
- [file-system-development/file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)
  — the tree the console surfaces in.
- [os-development/protocol-namespace.md](os-development/protocol-namespace.md) —
  supersedes this note's device-node naming: the console lands as a protocol
  (`/protocol/console`, a protocol node), not a `/dev`-style device file. The
  stream semantics designed here (line discipline, cooked/raw modes) carry over
  unchanged.
- [device-driver-development/input.md](device-driver-development/input.md) — the
  `InputEvent` stream the console cooks.
