From 126d5b2e55dca93e024f5654fed6fae37a012369 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 17 Aug 2026 19:56:24 +0200 Subject: Terminal-app design (ADR 0005) + disk-write controller - ADR 0005: persistent home terminal (full-screen, chrome-less, bottom-pinned), F1 terminal windows, per-instance vt/tty/shell, queue-based input for all terminals, and the disk controller - Glossary: add the Terminal term; update Task/App for the new model - Add a re-entrant disk lock (DiskGuard over the preemption-depth counter) and wrap all fs/fat mutating operations per-call --- CONTEXT.md | 14 +++++-- docs/adr/0004-windowed-gui-and-compositor.md | 4 +- ...0005-persistent-terminal-and-disk-controller.md | 48 ++++++++++++++++++++++ rsnix/src/fat.rs | 41 ++++++++++++++---- rsnix/src/fs.rs | 13 +++++- 5 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0005-persistent-terminal-and-disk-controller.md diff --git a/CONTEXT.md b/CONTEXT.md index 9fccbe4..c27f059 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,9 +113,16 @@ The action that destroys a window and ends its app. A task that owns exactly one window and receives input events through its message queue. A GUI program is an app; there is no separate "process" -concept — the app is its task plus its window. The console is app 0 +concept — the app is its task plus its window. The home terminal is app 0 (window 0). +## Terminal + +A program that presents a command line in a window: a text terminal +emulator plus a shell command loop, rendered into the window's pixel +buffer. The home terminal is the persistent full-screen chrome-less +desktop; additional terminals are ordinary windows (F1). + ## Focus The single window (if any) that receives keyboard and mouse input. Focus @@ -142,8 +149,9 @@ address-space separation — all tasks share the one identity-mapped space and the kernel's facilities (TempleOS-style, not a process). Tasks are preemptively round-robin-scheduled on the 100 Hz PIT tick; the shell is task 0 and an idle `hlt` task is the always-ready -fallback. A task may sleep, yield, and log to serial/klog; only the -shell touches the disk, filesystem, and terminal. A panicking or +fallback. A task may sleep, yield, and log to serial/klog; disk access +is serialized by a shared disk controller (a re-entrant lock), and each +terminal is its own program instance. A panicking or stack-overflowing task dies gracefully (logged, marked done, slot reaped) without taking the kernel down. diff --git a/docs/adr/0004-windowed-gui-and-compositor.md b/docs/adr/0004-windowed-gui-and-compositor.md index 3476fab..87f637e 100644 --- a/docs/adr/0004-windowed-gui-and-compositor.md +++ b/docs/adr/0004-windowed-gui-and-compositor.md @@ -74,5 +74,5 @@ Introduce an overlapping-window GUI: - The existing `vt` dirty-cell path is demoted to a per-terminal-window trigger; the window manager becomes the only writer of the framebuffer (plus the cursor overlay). -- Open (not yet decided): the terminal-app design (per-instance vt + shell), - and the save/image-format + image-viewer design (both deferred). +- Open (not yet decided): the save/image-format + image-viewer design + (deferred). The terminal-app design is captured in ADR 0005. diff --git a/docs/adr/0005-persistent-terminal-and-disk-controller.md b/docs/adr/0005-persistent-terminal-and-disk-controller.md new file mode 100644 index 0000000..dee62d1 --- /dev/null +++ b/docs/adr/0005-persistent-terminal-and-disk-controller.md @@ -0,0 +1,48 @@ +# ADR 0005 — Persistent terminal program, per-instance shell/vt, and a disk-write controller + +## Status + +Accepted. + +## Context + +The GUI (ADR 0004) left the console as a special "layer 0" rendered +straight into the shared shadow, with a single shell (task 0) reading a +global keyboard buffer and being the only thing that touches the disk. We +want a TempleOS-inspired **persistent terminal program**: a full-screen, +always-present command line, plus the ability to spawn additional terminal +windows. + +## Decision + +- **Home terminal** — a real window (own pixel buffer, composited by the + WM) that is full-screen, chrome-less (no title bar / minimize / close), + bottom-pinned (never raised), and never closes. It is the persistent + desktop. +- **F1 terminals** — ordinary windows (640×400, title bar, movable, + closable via F4 and the close button) running additional shell instances. +- **Per-instance** — `vt` becomes a per-instance `Terminal` (cell grid, + cursor, ANSI parser, scroll); `tty` and `shell` become per-instance too. + Each terminal owns one of each. +- **Input** — every terminal's shell reads `TaskMsg::Key` from its own task + queue (routed by the WM's input pump to the focused window). No terminal + reads the global keyboard buffer; there is one input path for all. +- **Rendering** — a terminal renders its text grid into its window's pixel + buffer; the WM composites it like any other window. This supersedes the + scroll-hook workaround from ADR 0004. +- **Disk controller** — `fs`/`fat` remain the disk owner, but every + mutating operation is wrapped in a re-entrant preemption-disable lock, + serializing concurrent access from multiple shells. Per-call granularity. +- **Background** — a terminal's running command keeps producing output into + its buffer while unfocused (free from preemptive multitasking plus + per-terminal buffers). + +## Consequences + +- Positive: the console is a first-class program; multiple terminals share + one coherent input path; concurrent disk access is safe. +- Negative: the preemption-disable disk lock stalls the scheduler during + disk I/O (fine on QEMU's RAM-backed disk; revisit for real hardware). +- Negative: a substantial refactor of `vt`/`tty`/`shell` from globals to + instances. +- The "only the shell touches the disk" assumption is removed. diff --git a/rsnix/src/fat.rs b/rsnix/src/fat.rs index b7e0da1..03b9550 100644 --- a/rsnix/src/fat.rs +++ b/rsnix/src/fat.rs @@ -62,6 +62,26 @@ fn active_vol() -> &'static mut usize { unsafe { &mut *core::ptr::addr_of_mut!(ACTIVE_VOL) } } +/// Re-entrant disk lock: serializes filesystem/disk access across tasks. +/// Multiple terminals may touch the disk concurrently, so every fat/fs +/// operation that touches the shared volume state, the FAT cache, or the +/// VFS must hold this. Re-entrant (the preemption-depth counter), so an +/// `fs` op can call a `fat` op without deadlocking. +pub(crate) struct DiskGuard; + +impl DiskGuard { + pub(crate) fn acquire() -> Self { + crate::sched::lock(); + DiskGuard + } +} + +impl Drop for DiskGuard { + fn drop(&mut self) { + crate::sched::unlock(); + } +} + pub fn volume_count() -> usize { vols().len() } @@ -89,9 +109,10 @@ pub fn crash_vol() -> Option { } for vol in 0..n { if let Some(label) = volume_label(vol) - && label.eq_ignore_ascii_case("CRASHLOG") { - return Some(vol); - } + && label.eq_ignore_ascii_case("CRASHLOG") + { + return Some(vol); + } } Some(0) } @@ -102,6 +123,7 @@ pub fn crash_vol() -> Option { /// removed, so VFS disk handles keep their indices. Returns /// (mounted, unmounted). pub fn rescan() -> (usize, usize) { + let _lock = DiskGuard::acquire(); let disks = crate::ahci::disk_count(); let mut mounted = 0usize; let mut unmounted = 0usize; @@ -256,11 +278,7 @@ fn is_eoc(c: u32) -> bool { fn next_cluster(c: u32) -> Result { let n = read_fat_entry(c); - if is_eoc(n) { - Err(()) - } else { - Ok(n) - } + if is_eoc(n) { Err(()) } else { Ok(n) } } /// Allocate a fresh cluster: mark it EOC in the FAT and zero its data. @@ -532,6 +550,7 @@ fn scan_dir(dir_cluster: u32) -> Result, ()> { /// Public listing: no `.` / `..`. pub fn list_dir(vol: usize, dir_cluster: u32) -> Result, ()> { + let _lock = DiskGuard::acquire(); select_vol(vol)?; Ok(scan_dir(dir_cluster)? .into_iter() @@ -558,6 +577,7 @@ fn find_item(parent_dir_cluster: u32, name: &str) -> Result { /// Find an entry by name (case-insensitive). pub fn find(vol: usize, parent_dir_cluster: u32, name: &str) -> Result { + let _lock = DiskGuard::acquire(); select_vol(vol)?; find_item(parent_dir_cluster, name).map(|i| Entry { name: i.name, @@ -576,6 +596,7 @@ pub fn read_file( buf: &mut [u8], pos: u64, ) -> Result { + let _lock = DiskGuard::acquire(); select_vol(vol)?; if buf.is_empty() { return Ok(0); @@ -622,6 +643,7 @@ pub fn write_file( buf: &[u8], pos: u64, ) -> Result { + let _lock = DiskGuard::acquire(); select_vol(vol)?; if buf.is_empty() { return Ok(0); @@ -685,6 +707,7 @@ pub fn write_file( } pub fn set_len(vol: usize, parent_dir_cluster: u32, name: &str, len: u64) -> Result<(), ()> { + let _lock = DiskGuard::acquire(); select_vol(vol)?; let it = find_item(parent_dir_cluster, name)?; if it.attr & 0x10 != 0 { @@ -1009,6 +1032,7 @@ fn add_dir_entry(parent: u32, name: &str, is_dir: bool, cluster: u32) -> Result< } pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> Result<(), ()> { + let _lock = DiskGuard::acquire(); select_vol(vol)?; if find(vol, parent_dir_cluster, name).is_ok() { return Err(()); @@ -1048,6 +1072,7 @@ pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> } pub fn remove(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<(), ()> { + let _lock = DiskGuard::acquire(); select_vol(vol)?; let it = find_item(parent_dir_cluster, name)?; if it.attr & 0x10 != 0 { diff --git a/rsnix/src/fs.rs b/rsnix/src/fs.rs index 24d8fcd..3585c00 100644 --- a/rsnix/src/fs.rs +++ b/rsnix/src/fs.rs @@ -203,6 +203,7 @@ pub fn init() -> (usize, usize) { /// its backing info), and tombstone nodes whose volume was unmounted. /// Returns (added, removed) node ops. pub fn rescan_disks() -> (usize, usize) { + let _lock = crate::fat::DiskGuard::acquire(); let mut added = 0usize; let mut removed = 0usize; let disk_dir = match lookup(0, "disk") { @@ -428,6 +429,7 @@ pub fn read(h: usize, buf: &mut [u8], pos: u64) -> Result { } pub fn write(h: usize, buf: &[u8], pos: u64) -> Result { + let _lock = crate::fat::DiskGuard::acquire(); match &mut nodes()[h].data { NodeData::Regular(v) => { let p = pos as usize; @@ -533,7 +535,10 @@ pub fn write_crash_log(lines: &[&str]) { let mut tb = [0u8; 48]; let tn = { let mut w = crate::strbuf::FmtBuf::new(&mut tb); - let _ = core::fmt::write(&mut w, core::format_args!("crashlog: vol {} ({})\r\n", vol, label)); + let _ = core::fmt::write( + &mut w, + core::format_args!("crashlog: vol {} ({})\r\n", vol, label), + ); w.len() }; crate::serial::write_raw(core::str::from_utf8(&tb[..tn]).unwrap_or("?")); @@ -555,7 +560,9 @@ pub fn write_crash_log(lines: &[&str]) { } /// Truncate/resize a regular file. -pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h].data { +pub fn set_len(h: usize, len: u64) -> Result<(), ()> { + let _lock = crate::fat::DiskGuard::acquire(); + match &mut nodes()[h].data { NodeData::Regular(v) => { v.resize(len as usize, 0); Ok(()) @@ -567,6 +574,7 @@ pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h]. /// Create a Regular or Dir child. Fails if the name exists or is invalid. pub fn create(parent: usize, name: &str, kind: FileType) -> Result { + let _lock = crate::fat::DiskGuard::acquire(); if name.is_empty() || name.contains('/') || kind == FileType::Char { return Err(()); } @@ -608,6 +616,7 @@ pub fn create(parent: usize, name: &str, kind: FileType) -> Result { /// Remove a regular file or an empty directory (tombstone). pub fn remove(h: usize) -> Result<(), ()> { + let _lock = crate::fat::DiskGuard::acquire(); // Disk nodes: delete through the FAT volume (dirs must be empty). if let NodeData::Disk(i) = &nodes()[h].data { if i.name.is_empty() { -- cgit v1.2.3