diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-17 19:56:24 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-17 19:56:24 +0200 |
| commit | 126d5b2e55dca93e024f5654fed6fae37a012369 (patch) | |
| tree | a74ad41c98c121a7bc37299b8a4d1d336e0cd2c5 | |
| parent | wm: keep windows in place when the console scrolls beneath them (diff) | |
| download | rs-dos-126d5b2e55dca93e024f5654fed6fae37a012369.tar.gz rs-dos-126d5b2e55dca93e024f5654fed6fae37a012369.zip | |
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
| -rw-r--r-- | CONTEXT.md | 14 | ||||
| -rw-r--r-- | docs/adr/0004-windowed-gui-and-compositor.md | 4 | ||||
| -rw-r--r-- | docs/adr/0005-persistent-terminal-and-disk-controller.md | 48 | ||||
| -rw-r--r-- | rsnix/src/fat.rs | 41 | ||||
| -rw-r--r-- | rsnix/src/fs.rs | 13 |
5 files changed, 105 insertions, 15 deletions
| @@ -113,9 +113,16 @@ The action that destroys a window and ends its app. | |||
| 113 | 113 | ||
| 114 | A task that owns exactly one window and receives input events through its | 114 | A task that owns exactly one window and receives input events through its |
| 115 | message queue. A GUI program is an app; there is no separate "process" | 115 | message queue. A GUI program is an app; there is no separate "process" |
| 116 | concept — the app is its task plus its window. The console is app 0 | 116 | concept — the app is its task plus its window. The home terminal is app 0 |
| 117 | (window 0). | 117 | (window 0). |
| 118 | 118 | ||
| 119 | ## Terminal | ||
| 120 | |||
| 121 | A program that presents a command line in a window: a text terminal | ||
| 122 | emulator plus a shell command loop, rendered into the window's pixel | ||
| 123 | buffer. The home terminal is the persistent full-screen chrome-less | ||
| 124 | desktop; additional terminals are ordinary windows (F1). | ||
| 125 | |||
| 119 | ## Focus | 126 | ## Focus |
| 120 | 127 | ||
| 121 | The single window (if any) that receives keyboard and mouse input. Focus | 128 | 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 | |||
| 142 | space and the kernel's facilities (TempleOS-style, not a process). | 149 | space and the kernel's facilities (TempleOS-style, not a process). |
| 143 | Tasks are preemptively round-robin-scheduled on the 100 Hz PIT tick; | 150 | Tasks are preemptively round-robin-scheduled on the 100 Hz PIT tick; |
| 144 | the shell is task 0 and an idle `hlt` task is the always-ready | 151 | the shell is task 0 and an idle `hlt` task is the always-ready |
| 145 | fallback. A task may sleep, yield, and log to serial/klog; only the | 152 | fallback. A task may sleep, yield, and log to serial/klog; disk access |
| 146 | shell touches the disk, filesystem, and terminal. A panicking or | 153 | is serialized by a shared disk controller (a re-entrant lock), and each |
| 154 | terminal is its own program instance. A panicking or | ||
| 147 | stack-overflowing task dies gracefully (logged, marked done, slot | 155 | stack-overflowing task dies gracefully (logged, marked done, slot |
| 148 | reaped) without taking the kernel down. | 156 | reaped) without taking the kernel down. |
| 149 | 157 | ||
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: | |||
| 74 | - The existing `vt` dirty-cell path is demoted to a per-terminal-window | 74 | - The existing `vt` dirty-cell path is demoted to a per-terminal-window |
| 75 | trigger; the window manager becomes the only writer of the framebuffer | 75 | trigger; the window manager becomes the only writer of the framebuffer |
| 76 | (plus the cursor overlay). | 76 | (plus the cursor overlay). |
| 77 | - Open (not yet decided): the terminal-app design (per-instance vt + shell), | 77 | - Open (not yet decided): the save/image-format + image-viewer design |
| 78 | and the save/image-format + image-viewer design (both deferred). | 78 | (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 @@ | |||
| 1 | # ADR 0005 — Persistent terminal program, per-instance shell/vt, and a disk-write controller | ||
| 2 | |||
| 3 | ## Status | ||
| 4 | |||
| 5 | Accepted. | ||
| 6 | |||
| 7 | ## Context | ||
| 8 | |||
| 9 | The GUI (ADR 0004) left the console as a special "layer 0" rendered | ||
| 10 | straight into the shared shadow, with a single shell (task 0) reading a | ||
| 11 | global keyboard buffer and being the only thing that touches the disk. We | ||
| 12 | want a TempleOS-inspired **persistent terminal program**: a full-screen, | ||
| 13 | always-present command line, plus the ability to spawn additional terminal | ||
| 14 | windows. | ||
| 15 | |||
| 16 | ## Decision | ||
| 17 | |||
| 18 | - **Home terminal** — a real window (own pixel buffer, composited by the | ||
| 19 | WM) that is full-screen, chrome-less (no title bar / minimize / close), | ||
| 20 | bottom-pinned (never raised), and never closes. It is the persistent | ||
| 21 | desktop. | ||
| 22 | - **F1 terminals** — ordinary windows (640×400, title bar, movable, | ||
| 23 | closable via F4 and the close button) running additional shell instances. | ||
| 24 | - **Per-instance** — `vt` becomes a per-instance `Terminal` (cell grid, | ||
| 25 | cursor, ANSI parser, scroll); `tty` and `shell` become per-instance too. | ||
| 26 | Each terminal owns one of each. | ||
| 27 | - **Input** — every terminal's shell reads `TaskMsg::Key` from its own task | ||
| 28 | queue (routed by the WM's input pump to the focused window). No terminal | ||
| 29 | reads the global keyboard buffer; there is one input path for all. | ||
| 30 | - **Rendering** — a terminal renders its text grid into its window's pixel | ||
| 31 | buffer; the WM composites it like any other window. This supersedes the | ||
| 32 | scroll-hook workaround from ADR 0004. | ||
| 33 | - **Disk controller** — `fs`/`fat` remain the disk owner, but every | ||
| 34 | mutating operation is wrapped in a re-entrant preemption-disable lock, | ||
| 35 | serializing concurrent access from multiple shells. Per-call granularity. | ||
| 36 | - **Background** — a terminal's running command keeps producing output into | ||
| 37 | its buffer while unfocused (free from preemptive multitasking plus | ||
| 38 | per-terminal buffers). | ||
| 39 | |||
| 40 | ## Consequences | ||
| 41 | |||
| 42 | - Positive: the console is a first-class program; multiple terminals share | ||
| 43 | one coherent input path; concurrent disk access is safe. | ||
| 44 | - Negative: the preemption-disable disk lock stalls the scheduler during | ||
| 45 | disk I/O (fine on QEMU's RAM-backed disk; revisit for real hardware). | ||
| 46 | - Negative: a substantial refactor of `vt`/`tty`/`shell` from globals to | ||
| 47 | instances. | ||
| 48 | - 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 { | |||
| 62 | unsafe { &mut *core::ptr::addr_of_mut!(ACTIVE_VOL) } | 62 | unsafe { &mut *core::ptr::addr_of_mut!(ACTIVE_VOL) } |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | /// Re-entrant disk lock: serializes filesystem/disk access across tasks. | ||
| 66 | /// Multiple terminals may touch the disk concurrently, so every fat/fs | ||
| 67 | /// operation that touches the shared volume state, the FAT cache, or the | ||
| 68 | /// VFS must hold this. Re-entrant (the preemption-depth counter), so an | ||
| 69 | /// `fs` op can call a `fat` op without deadlocking. | ||
| 70 | pub(crate) struct DiskGuard; | ||
| 71 | |||
| 72 | impl DiskGuard { | ||
| 73 | pub(crate) fn acquire() -> Self { | ||
| 74 | crate::sched::lock(); | ||
| 75 | DiskGuard | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | impl Drop for DiskGuard { | ||
| 80 | fn drop(&mut self) { | ||
| 81 | crate::sched::unlock(); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 65 | pub fn volume_count() -> usize { | 85 | pub fn volume_count() -> usize { |
| 66 | vols().len() | 86 | vols().len() |
| 67 | } | 87 | } |
| @@ -89,9 +109,10 @@ pub fn crash_vol() -> Option<usize> { | |||
| 89 | } | 109 | } |
| 90 | for vol in 0..n { | 110 | for vol in 0..n { |
| 91 | if let Some(label) = volume_label(vol) | 111 | if let Some(label) = volume_label(vol) |
| 92 | && label.eq_ignore_ascii_case("CRASHLOG") { | 112 | && label.eq_ignore_ascii_case("CRASHLOG") |
| 93 | return Some(vol); | 113 | { |
| 94 | } | 114 | return Some(vol); |
| 115 | } | ||
| 95 | } | 116 | } |
| 96 | Some(0) | 117 | Some(0) |
| 97 | } | 118 | } |
| @@ -102,6 +123,7 @@ pub fn crash_vol() -> Option<usize> { | |||
| 102 | /// removed, so VFS disk handles keep their indices. Returns | 123 | /// removed, so VFS disk handles keep their indices. Returns |
| 103 | /// (mounted, unmounted). | 124 | /// (mounted, unmounted). |
| 104 | pub fn rescan() -> (usize, usize) { | 125 | pub fn rescan() -> (usize, usize) { |
| 126 | let _lock = DiskGuard::acquire(); | ||
| 105 | let disks = crate::ahci::disk_count(); | 127 | let disks = crate::ahci::disk_count(); |
| 106 | let mut mounted = 0usize; | 128 | let mut mounted = 0usize; |
| 107 | let mut unmounted = 0usize; | 129 | let mut unmounted = 0usize; |
| @@ -256,11 +278,7 @@ fn is_eoc(c: u32) -> bool { | |||
| 256 | 278 | ||
| 257 | fn next_cluster(c: u32) -> Result<u32, ()> { | 279 | fn next_cluster(c: u32) -> Result<u32, ()> { |
| 258 | let n = read_fat_entry(c); | 280 | let n = read_fat_entry(c); |
| 259 | if is_eoc(n) { | 281 | if is_eoc(n) { Err(()) } else { Ok(n) } |
| 260 | Err(()) | ||
| 261 | } else { | ||
| 262 | Ok(n) | ||
| 263 | } | ||
| 264 | } | 282 | } |
| 265 | 283 | ||
| 266 | /// Allocate a fresh cluster: mark it EOC in the FAT and zero its data. | 284 | /// 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<Vec<DirItem>, ()> { | |||
| 532 | 550 | ||
| 533 | /// Public listing: no `.` / `..`. | 551 | /// Public listing: no `.` / `..`. |
| 534 | pub fn list_dir(vol: usize, dir_cluster: u32) -> Result<Vec<Entry>, ()> { | 552 | pub fn list_dir(vol: usize, dir_cluster: u32) -> Result<Vec<Entry>, ()> { |
| 553 | let _lock = DiskGuard::acquire(); | ||
| 535 | select_vol(vol)?; | 554 | select_vol(vol)?; |
| 536 | Ok(scan_dir(dir_cluster)? | 555 | Ok(scan_dir(dir_cluster)? |
| 537 | .into_iter() | 556 | .into_iter() |
| @@ -558,6 +577,7 @@ fn find_item(parent_dir_cluster: u32, name: &str) -> Result<DirItem, ()> { | |||
| 558 | 577 | ||
| 559 | /// Find an entry by name (case-insensitive). | 578 | /// Find an entry by name (case-insensitive). |
| 560 | pub fn find(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<Entry, ()> { | 579 | pub fn find(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<Entry, ()> { |
| 580 | let _lock = DiskGuard::acquire(); | ||
| 561 | select_vol(vol)?; | 581 | select_vol(vol)?; |
| 562 | find_item(parent_dir_cluster, name).map(|i| Entry { | 582 | find_item(parent_dir_cluster, name).map(|i| Entry { |
| 563 | name: i.name, | 583 | name: i.name, |
| @@ -576,6 +596,7 @@ pub fn read_file( | |||
| 576 | buf: &mut [u8], | 596 | buf: &mut [u8], |
| 577 | pos: u64, | 597 | pos: u64, |
| 578 | ) -> Result<usize, ()> { | 598 | ) -> Result<usize, ()> { |
| 599 | let _lock = DiskGuard::acquire(); | ||
| 579 | select_vol(vol)?; | 600 | select_vol(vol)?; |
| 580 | if buf.is_empty() { | 601 | if buf.is_empty() { |
| 581 | return Ok(0); | 602 | return Ok(0); |
| @@ -622,6 +643,7 @@ pub fn write_file( | |||
| 622 | buf: &[u8], | 643 | buf: &[u8], |
| 623 | pos: u64, | 644 | pos: u64, |
| 624 | ) -> Result<usize, ()> { | 645 | ) -> Result<usize, ()> { |
| 646 | let _lock = DiskGuard::acquire(); | ||
| 625 | select_vol(vol)?; | 647 | select_vol(vol)?; |
| 626 | if buf.is_empty() { | 648 | if buf.is_empty() { |
| 627 | return Ok(0); | 649 | return Ok(0); |
| @@ -685,6 +707,7 @@ pub fn write_file( | |||
| 685 | } | 707 | } |
| 686 | 708 | ||
| 687 | pub fn set_len(vol: usize, parent_dir_cluster: u32, name: &str, len: u64) -> Result<(), ()> { | 709 | pub fn set_len(vol: usize, parent_dir_cluster: u32, name: &str, len: u64) -> Result<(), ()> { |
| 710 | let _lock = DiskGuard::acquire(); | ||
| 688 | select_vol(vol)?; | 711 | select_vol(vol)?; |
| 689 | let it = find_item(parent_dir_cluster, name)?; | 712 | let it = find_item(parent_dir_cluster, name)?; |
| 690 | if it.attr & 0x10 != 0 { | 713 | if it.attr & 0x10 != 0 { |
| @@ -1009,6 +1032,7 @@ fn add_dir_entry(parent: u32, name: &str, is_dir: bool, cluster: u32) -> Result< | |||
| 1009 | } | 1032 | } |
| 1010 | 1033 | ||
| 1011 | pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> Result<(), ()> { | 1034 | pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> Result<(), ()> { |
| 1035 | let _lock = DiskGuard::acquire(); | ||
| 1012 | select_vol(vol)?; | 1036 | select_vol(vol)?; |
| 1013 | if find(vol, parent_dir_cluster, name).is_ok() { | 1037 | if find(vol, parent_dir_cluster, name).is_ok() { |
| 1014 | return Err(()); | 1038 | return Err(()); |
| @@ -1048,6 +1072,7 @@ pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> | |||
| 1048 | } | 1072 | } |
| 1049 | 1073 | ||
| 1050 | pub fn remove(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<(), ()> { | 1074 | pub fn remove(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<(), ()> { |
| 1075 | let _lock = DiskGuard::acquire(); | ||
| 1051 | select_vol(vol)?; | 1076 | select_vol(vol)?; |
| 1052 | let it = find_item(parent_dir_cluster, name)?; | 1077 | let it = find_item(parent_dir_cluster, name)?; |
| 1053 | if it.attr & 0x10 != 0 { | 1078 | 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) { | |||
| 203 | /// its backing info), and tombstone nodes whose volume was unmounted. | 203 | /// its backing info), and tombstone nodes whose volume was unmounted. |
| 204 | /// Returns (added, removed) node ops. | 204 | /// Returns (added, removed) node ops. |
| 205 | pub fn rescan_disks() -> (usize, usize) { | 205 | pub fn rescan_disks() -> (usize, usize) { |
| 206 | let _lock = crate::fat::DiskGuard::acquire(); | ||
| 206 | let mut added = 0usize; | 207 | let mut added = 0usize; |
| 207 | let mut removed = 0usize; | 208 | let mut removed = 0usize; |
| 208 | let disk_dir = match lookup(0, "disk") { | 209 | let disk_dir = match lookup(0, "disk") { |
| @@ -428,6 +429,7 @@ pub fn read(h: usize, buf: &mut [u8], pos: u64) -> Result<usize, ()> { | |||
| 428 | } | 429 | } |
| 429 | 430 | ||
| 430 | pub fn write(h: usize, buf: &[u8], pos: u64) -> Result<usize, ()> { | 431 | pub fn write(h: usize, buf: &[u8], pos: u64) -> Result<usize, ()> { |
| 432 | let _lock = crate::fat::DiskGuard::acquire(); | ||
| 431 | match &mut nodes()[h].data { | 433 | match &mut nodes()[h].data { |
| 432 | NodeData::Regular(v) => { | 434 | NodeData::Regular(v) => { |
| 433 | let p = pos as usize; | 435 | let p = pos as usize; |
| @@ -533,7 +535,10 @@ pub fn write_crash_log(lines: &[&str]) { | |||
| 533 | let mut tb = [0u8; 48]; | 535 | let mut tb = [0u8; 48]; |
| 534 | let tn = { | 536 | let tn = { |
| 535 | let mut w = crate::strbuf::FmtBuf::new(&mut tb); | 537 | let mut w = crate::strbuf::FmtBuf::new(&mut tb); |
| 536 | let _ = core::fmt::write(&mut w, core::format_args!("crashlog: vol {} ({})\r\n", vol, label)); | 538 | let _ = core::fmt::write( |
| 539 | &mut w, | ||
| 540 | core::format_args!("crashlog: vol {} ({})\r\n", vol, label), | ||
| 541 | ); | ||
| 537 | w.len() | 542 | w.len() |
| 538 | }; | 543 | }; |
| 539 | crate::serial::write_raw(core::str::from_utf8(&tb[..tn]).unwrap_or("?")); | 544 | crate::serial::write_raw(core::str::from_utf8(&tb[..tn]).unwrap_or("?")); |
| @@ -555,7 +560,9 @@ pub fn write_crash_log(lines: &[&str]) { | |||
| 555 | } | 560 | } |
| 556 | 561 | ||
| 557 | /// Truncate/resize a regular file. | 562 | /// Truncate/resize a regular file. |
| 558 | pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h].data { | 563 | pub fn set_len(h: usize, len: u64) -> Result<(), ()> { |
| 564 | let _lock = crate::fat::DiskGuard::acquire(); | ||
| 565 | match &mut nodes()[h].data { | ||
| 559 | NodeData::Regular(v) => { | 566 | NodeData::Regular(v) => { |
| 560 | v.resize(len as usize, 0); | 567 | v.resize(len as usize, 0); |
| 561 | Ok(()) | 568 | Ok(()) |
| @@ -567,6 +574,7 @@ pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h]. | |||
| 567 | 574 | ||
| 568 | /// Create a Regular or Dir child. Fails if the name exists or is invalid. | 575 | /// Create a Regular or Dir child. Fails if the name exists or is invalid. |
| 569 | pub fn create(parent: usize, name: &str, kind: FileType) -> Result<usize, ()> { | 576 | pub fn create(parent: usize, name: &str, kind: FileType) -> Result<usize, ()> { |
| 577 | let _lock = crate::fat::DiskGuard::acquire(); | ||
| 570 | if name.is_empty() || name.contains('/') || kind == FileType::Char { | 578 | if name.is_empty() || name.contains('/') || kind == FileType::Char { |
| 571 | return Err(()); | 579 | return Err(()); |
| 572 | } | 580 | } |
| @@ -608,6 +616,7 @@ pub fn create(parent: usize, name: &str, kind: FileType) -> Result<usize, ()> { | |||
| 608 | 616 | ||
| 609 | /// Remove a regular file or an empty directory (tombstone). | 617 | /// Remove a regular file or an empty directory (tombstone). |
| 610 | pub fn remove(h: usize) -> Result<(), ()> { | 618 | pub fn remove(h: usize) -> Result<(), ()> { |
| 619 | let _lock = crate::fat::DiskGuard::acquire(); | ||
| 611 | // Disk nodes: delete through the FAT volume (dirs must be empty). | 620 | // Disk nodes: delete through the FAT volume (dirs must be empty). |
| 612 | if let NodeData::Disk(i) = &nodes()[h].data { | 621 | if let NodeData::Disk(i) = &nodes()[h].data { |
| 613 | if i.name.is_empty() { | 622 | if i.name.is_empty() { |
