consoled
consoled is the ns16550 UART driver. It serves the physical console as /dev/cons, one file
over 9P: a write goes out of the UART, and a read returns what was typed, or, when nothing has
been, parks until a key arrives rather than blocking or answering nothing. The physical
console carries no labels, so any caller may read it and only an unlabelled one may write it.
Purpose
The serial line is the box’s physical console: where the first owner is enrolled, where recovery
happens, and where a panic is reported. Its driver must never stop serving because of one client or
one broken device: a read with no input must wait without holding the server, and a device that
claims data for ever must cost a bounded amount of work. Session consoles are served per SSH
channel by sshd, not here.
Interface
/dev/cons
Status: built · partly tested: attacked with a fake UART against the runtime's fake kernel, and in a boot only written to; no test writes as a labelled caller · tested (14)
- bench:r4-host-tests
- host:redoubt-consoled::a_session_s_console_opens_beside_the_one_it_replaces_and_a_third_waits_for_one_to_go
- bench:consoled-build
- bench:init-boot
- bench:init-servers
- host:redoubt-consoled::a_refused_typed_request_leaves_no_handle_behind
- host:redoubt-consoled::typing_on_the_uart_reaches_a_ninep_reader
- host:redoubt-consoled::a_read_with_no_input_waits_and_is_freed_when_its_caller_gives_up
- host:redoubt-consoled::a_multiplexed_read_waits_for_input
- host:redoubt-consoled::writes_go_out_of_the_uart_in_order
- host:redoubt-consoled::writes_go_out_inside_the_console_s_hold_a_chunk_at_a_time
- host:redoubt-consoled::a_flood_of_input_keeps_what_was_typed_first
- host:redoubt-consoled::the_console_refuses_what_it_is_not
- host:redoubt-consoled::the_conformance_vectors_run_against_consoled
/dev/cons is served over the 9P server skeleton as one file
with nothing below it.
- A write sends its bytes out of the UART, in order, each line saying who wrote it
(started by
init), inside the console’s hold 256 bytes at a time, so the kernel’s lines land between those chunks and never inside one; a chunk that leaves a line unfinished gives the hold back saying so, and a line the kernel ended meanwhile starts again at the next write, prefix first (the console’s one writer). A hold the kernel refuses is a write as before. - A read returns the input held, from the start of the queue; the offset is ignored, since a console is a stream. With no input it parks its call with no deadline, because it waits on a person, and is served again, unchanged, when a key arrives; a caller that gives up abandons it and it is freed at once (parked calls). A multiplexed connection’s read waits the same way, as a request in the skeleton, and is answered into its completion call when a key arrives (multiplexed connections).
- One line, one queue. Input goes to whichever waiting read has waited longest. At most
MAX_INPUT(1024) bytes are held; a byte beyond that is dropped and counted, so a flood keeps what was typed first. statreports length 0: a console has no size.- What it refuses: opening with
OEXECorOTRUNC, walking, creating and removing, and a write from a labelled caller, through the label check against the console’s empty label set (R69 (no write down onto the console)). - Beside 9P and
ninep_common,consol’ssizeandresize(below). Any other typed opcode,endedsent as a call among them, is answeredmalformed, and the handles it carried are closed. - Admission: per (account, label set), at most 2 parked calls (waiting reads and
resizecalls together), 2 connections and 6 fids, and 80 multiplexed requests and 2 pages they brought (serving), across at mostbuckets=Nof those;init’s own bucket, by an override on its root badge, holdsMAX_THREADSconnections with 2 fids each instead. A block with nobuckets=N, or one the budget cannot hold, andconsoleddoes not start (init). The two kinds of bucket are derived, not chosen:- A principal’s bucket holds the consoles the steward mints for that account here: the
console principal’s UART session’s, minted through the steward’s own console (a context
over
sshgets the console its relay serves and a lease gets none: the steward’s slot table), admitted in the session’s account from its first request (serving), plus one spare for a session ending while its successor starts: 2. Each console is a share of the bucket, and a share takes one more only while it holds less thanlimit / (n + 1), n the shares holding (a fair share per badge), so the fids are what lets the last console open itsCONSOLE_FIDS(2: the root it attaches and theconsfile it opens) while the others hold theirs:CONSOLE_FIDS × 3, 6; at 4, the successor of an ended session, whose console holds its two until the steward’s cleanup gives it back, would get one fid of two. The bench holds the caps to at least that need over the tree’s manifests (tools/testbench,the_servers_admit_what_a_manifest_lets_one_account_hold). init’s bucket holds every server’s console:initmints each through its one root badge and starts at mostMAX_THREADS - 1servers, and each of those consoles, a system caller’s whose chain came throughinit’s badge, is charged toinit’s bucket however it is used, the steward’s own among them.init’s badge is always 1 here:initmints its own first badge at each server it calls, the first not handed to anyone at that endpoint, and refuses a manifest handingconsoledto any server (started byinit).- The fit, by the worst case of
Admission::with_overrides(init’s bucket at its override’s caps, every other at the defaults): a default bucket at its caps costs 2 parked calls at 64 KiB, 2 connections at 256 bytes and 6 fids at 256, 80 requests at 256 and 2 pages at 4 KiB, 161,792 bytes;init’s the same withMAX_THREADSconnections and their fids, 355,584 bytes at 255 (rv64) and 183,552 at 31 (rv32). The 2 MiB budget holdsinit’s and 10 default buckets on rv64 (1,973,504; an eleventh does not fit) andinit’s and 11 on rv32 (1,963,264); the image’s manifest asks for 5. The entry’s pages follow the entry rule (walfsd) with this constant as the budget: 512 of it, 24 of heap and the stack’s bound of 128, 664, so 768 within its 1,024. - The count:
buckets=Nis the manifest’s principal domains plus one forinit, whose bucket every system caller’s console shares; the steward’s own console isinit’s minting and counts there, not as a bucket of its own.
- A principal’s bucket holds the consoles the steward mints for that account here: the
console principal’s UART session’s, minted through the steward’s own console (a context
over
Two threads and the UART
Status: built · tested: host:redoubt-consoled::a_device_stuck_on_data_ready_does_not_hang_the_server, host:redoubt-consoled::typing_on_the_uart_reaches_a_ninep_reader, host:redoubt-consoled::a_console_with_no_device_does_not_start
- The serving thread owns the UART and the 9P skeleton, answers writes, answers reads from the input it holds, and parks reads when it holds none, so every other client is still served.
- The interrupt thread only receives on the interrupt handle and sends one word to the serving
thread’s endpoint; it touches no register. The UART’s registers are not
Sync, so the split is the type system’s, not a convention, and neither thread needs a lock. The kernel masks the interrupt when it fires and the next receive unmasks it (R5 (interrupts)). - The FIFO is drained whole on each wake-up and before a read parks, so two bytes between two interrupts are both read and no byte is left behind with a reader waiting. A drain reads at most the FIFO’s 16 bytes, so a device that says “data ready” for ever costs a bounded number of register reads per call instead of hanging the server (R70 (a stuck UART never hangs the console)).
- The registers are mapped from the device handle by the runtime and reached through its
bounds-checked accessors, every access volatile; the crate forbids
unsafe.
The consol protocol
Status: built · tested (8)
- bench:consol-size
- bench:steward-ssh-resize
- host:redoubt-consoled::consol_size_is_the_argument_and_a_resize_waits_until_its_caller_gives_up
- host:redoubt-consoled::a_console_with_no_size_refuses_consol
- host:redoubt-consoled::a_refused_typed_request_leaves_no_handle_behind
- host:redoubt-consoled::a_console_with_no_device_does_not_start
- host:redoubt-sshd::consol_size_is_the_pty_s_and_a_resize_is_due_when_it_changes
- host:redoubt-client::a_session_writes_and_reads_the_console
Every server of a /dev/cons (consoled, and sshd for each channel) also serves consol on
the same endpoint, through the runtime’s server::consol:
sizeanswers the console’s columns and rows at once.consoledtakes its size from its arguments,size=COLS,ROWS, each from 1 to 1,024; a malformed size, or two, and it does not start. A UART cannot know the size of the terminal at its far end, so when the manifest names noneconsoleddoes not guess: it refusessizeandresize(malformed), and its clients’ console is of unknown size, as the image’s is.sshdanswers from the channel’s pty request (sshd).resizeanswers the size when it next changes: it parks like a read, with no deadline, and is freed when its caller gives up (parking a typed call). A UART has no window, so onconsoledaresizewaits until its caller gives up. A client learns the size of its own console only; another channel’s waiter stays parked and learns nothing.- What a waiter costs. A waiting
resizeis a parked call of its caller’s, in its bucket and share, beside its waiting reads and, on a multiplexed connection, its session, which holds one of the share too (multiplexed connections). Onconsoleda lone connection’s share is one of the bucket’s 2: a connection with aresizewaiting has no room for a waiting read, and one with a multiplexed session, as the shell’s VM has, has no room for aresize, so the VM’s is refused. Onsshdthe share is two, the session’s and one waiter’s, and the VM’s reads and writes wait inside its session, so itsresizestarves nothing of its own. One more is refused at once (malformed), so however many waiters a client asks for, what they cost the server is the bucket’s fixed slots and their admitted cost, never memory beyond them. endedis the steward’s word that a minted console’s session is over (started byinit); sent as a call it is malformed.
The table: libs/wire/tables/consol.md.
| Opcode | Kind | Message | Fields | Reply |
|---|---|---|---|---|
| 16 | call | size | - | cols: u16, rows: u16 |
| 17 | call | resize | - | cols: u16, rows: u16 |
| 18 | send | ended | - | - |
| Code | Error |
|---|
Started by init
Status: built · tested (17)
- bench:init-boot
- bench:init-servers
- bench:init-console-forgery
- bench:console-kernel-line-mid-line
- bench:bench-init-reporter-forged
- host:redoubt-consoled::the_prefix_is_the_id_in_sixteen_hex_digits
- host:redoubt-consoled::a_root_line_is_bare_and_a_minted_one_carries_its_id_on_every_line
- host:redoubt-consoled::a_line_is_continued_by_its_writer_and_ended_by_any_other
- host:redoubt-consoled::a_line_the_kernel_ended_starts_again_with_its_prefix
- host:redoubt-consoled::an_unfinished_line_is_given_back_as_such_and_starts_again_once_the_kernel_ended_it
- host:redoubt-consoled::whatever_the_uart_takes_no_line_mixes_writers_or_carries_another_s_id
- host:redoubt-rt::the_file_server_learns_the_id_the_requester_got
- host:redoubt-consoled::a_console_with_no_device_does_not_start
- host:redoubt-consoled::init_s_badge_mints_a_console_for_every_server_init_can_start
- host:redoubt-consoled::every_console_init_mints_attaches_and_opens_in_its_one_bucket
- bench:init-refuses-consoled-handed
- host:redoubt-init::no_server_is_handed_a_root_badge_at_consoled
init starts consoled once the manifest and the keys are checked, with the UART’s MMIO region
and interrupt as named handles and its endpoint, placed from the boot manifest’s devices list
(init). consoled parses no device tree; without both handles it
does not start. Only one process ever maps the UART: two would both reach the registers, and two
readers of one FIFO would each take half the line. init writes to the UART itself only before
consoled starts, and unmaps it first.
Every line says who wrote it. A write through init’s own connection goes out as it is.
init holds the only handle to the root, and it never hands the root to a child: its check
refuses a manifest that hands any server an endpoint consoled receives on
(init). A write through
any connection minted under the root starts each of its lines with [con N] , where N is that
connection’s id, the one its requester was given, in 16 lowercase hex digits. init prints the id
of each child’s connection, bare, when it starts the child (init: started NAME, console N), so a
reader of the console can tell init from every program and each program from the others,
as the bench’s log server does today
(rule F). ended on a minted connection releases it:
the steward says so when the console session it started is over. If a write comes from a
different connection than the one that left the last line unfinished, consoled ends that line
first. The kernel is a writer consoled cannot see, and a program’s line can stay unfinished
between two of its writes (a prompt waiting for its answer; a \r sent before the text), when
nobody holds the console: so consoled gives the console’s hold back saying the line is
unfinished, the kernel ends that line before a line of its own, and says so at consoled’s next
take, which starts the program’s line again, prefix first
(the console’s one writer). So a line a program
writes cannot come out bare, not even after a kernel line broke it, and it cannot carry another
connection’s id. A transmitter that takes only part of a prefix leaves the line open, and the rest of the
prefix goes out before any more of the writer’s bytes; ended by another writer, such a line holds
the start of its writer’s prefix and nothing else.
The rule names the handles NAME and NAME-irq from one devices entry
(init); consoled takes uart and uart-irq.
Authority
Status: built · partly tested: no test writes as a labelled caller · tested: host:redoubt-consoled::the_console_refuses_what_it_is_not
consoledholds the UART’s registers and interrupt, its endpoint, and the connections it minted. It makes no calls to other servers.- Any caller with a connection may read the console; only an unlabelled caller may write it.
Security properties
R69 (no write down onto the console)
Status: built · partly tested: no consoled test writes as a labelled caller; the rule rests on the 9P skeleton’s label check, which is attacked in the serving library’s tests · tested: host:redoubt-rt::labels_are_checked_on_every_request, host:redoubt-rt::every_write_needs_equal_labels
The physical console carries no labels, so a caller with any label cannot write to it: no labelled data reaches a screen someone else may be looking at.
R70 (a stuck UART never hangs the console)
Status: built · tested: host:redoubt-consoled::a_device_stuck_on_data_ready_does_not_hang_the_server, host:redoubt-consoled::a_flood_of_input_keeps_what_was_typed_first
Whatever the UART reports, consoled does a bounded amount of work per call and keeps serving: a
drain reads at most the FIFO’s depth, and held input is bounded, dropping new bytes rather than
growing.
Failure and restart
Status: built · partly tested: the restart claims are read from the code, not attacked across a restart · tested: host:redoubt-consoled::a_console_with_no_device_does_not_start, host:redoubt-consoled::a_read_with_no_input_waits_and_is_freed_when_its_caller_gives_up
- No UART or interrupt handle:
consoledexits with a code before serving. - The hold refused:
consoledtakes and gives back the console’s hold once before serving. A kernel that refuses it (WrongObject: the UART is not the kernel’s console;Busy) would leave every line open to a kernel line, soconsoledexits with code 8 (NO_HOLD) instead. - A reader gives up: its parked read is answered and freed at once.
consoledrestarts: held input is lost, parked reads andresizecalls getDead, and clients ask for fresh connections.
Residual risks
- One keyboard, one queue. A client reading
/dev/constakes input another was waiting for; a session that must not share a console gets its own fromsshd. - A parked read, or
resize, has no deadline. It holds one of its caller’s open calls and one ofconsoled’s admission slots until a key arrives (for aresize, never) or its caller gives up. - Anyone with a connection reads the console. What is typed on the physical console is visible to
every holder of a
consoledconnection.
Why
- Park, rather than block or answer empty. A blocked server serves nobody else; an empty answer looks like a closed console; parking costs one slot and nothing more.
- The interrupt thread touches nothing. Keeping the device on one thread removes every lock and every shared-state bug between the two.
- Drain at most the FIFO. A 16550 can hold no more; a device claiming otherwise is broken, and bounding the loop turns it into dropped input instead of a hung server.