summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-17 19:56:24 +0200
committersalmonik <salmonik@tuta.io>2026-08-17 19:56:24 +0200
commit126d5b2e55dca93e024f5654fed6fae37a012369 (patch)
treea74ad41c98c121a7bc37299b8a4d1d336e0cd2c5
parentwm: keep windows in place when the console scrolls beneath them (diff)
downloadrs-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.md14
-rw-r--r--docs/adr/0004-windowed-gui-and-compositor.md4
-rw-r--r--docs/adr/0005-persistent-terminal-and-disk-controller.md48
-rw-r--r--rsnix/src/fat.rs41
-rw-r--r--rsnix/src/fs.rs13
5 files changed, 105 insertions, 15 deletions
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.
113 113
114A task that owns exactly one window and receives input events through its 114A task that owns exactly one window and receives input events through its
115message queue. A GUI program is an app; there is no separate "process" 115message queue. A GUI program is an app; there is no separate "process"
116concept — the app is its task plus its window. The console is app 0 116concept — 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
121A program that presents a command line in a window: a text terminal
122emulator plus a shell command loop, rendered into the window's pixel
123buffer. The home terminal is the persistent full-screen chrome-less
124desktop; additional terminals are ordinary windows (F1).
125
119## Focus 126## Focus
120 127
121The single window (if any) that receives keyboard and mouse input. Focus 128The 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
142space and the kernel's facilities (TempleOS-style, not a process). 149space and the kernel's facilities (TempleOS-style, not a process).
143Tasks are preemptively round-robin-scheduled on the 100 Hz PIT tick; 150Tasks are preemptively round-robin-scheduled on the 100 Hz PIT tick;
144the shell is task 0 and an idle `hlt` task is the always-ready 151the shell is task 0 and an idle `hlt` task is the always-ready
145fallback. A task may sleep, yield, and log to serial/klog; only the 152fallback. A task may sleep, yield, and log to serial/klog; disk access
146shell touches the disk, filesystem, and terminal. A panicking or 153is serialized by a shared disk controller (a re-entrant lock), and each
154terminal is its own program instance. A panicking or
147stack-overflowing task dies gracefully (logged, marked done, slot 155stack-overflowing task dies gracefully (logged, marked done, slot
148reaped) without taking the kernel down. 156reaped) 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
5Accepted.
6
7## Context
8
9The GUI (ADR 0004) left the console as a special "layer 0" rendered
10straight into the shared shadow, with a single shell (task 0) reading a
11global keyboard buffer and being the only thing that touches the disk. We
12want a TempleOS-inspired **persistent terminal program**: a full-screen,
13always-present command line, plus the ability to spawn additional terminal
14windows.
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.
70pub(crate) struct DiskGuard;
71
72impl DiskGuard {
73 pub(crate) fn acquire() -> Self {
74 crate::sched::lock();
75 DiskGuard
76 }
77}
78
79impl Drop for DiskGuard {
80 fn drop(&mut self) {
81 crate::sched::unlock();
82 }
83}
84
65pub fn volume_count() -> usize { 85pub 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).
104pub fn rescan() -> (usize, usize) { 125pub 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
257fn next_cluster(c: u32) -> Result<u32, ()> { 279fn 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 `.` / `..`.
534pub fn list_dir(vol: usize, dir_cluster: u32) -> Result<Vec<Entry>, ()> { 552pub 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).
560pub fn find(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<Entry, ()> { 579pub 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
687pub fn set_len(vol: usize, parent_dir_cluster: u32, name: &str, len: u64) -> Result<(), ()> { 709pub 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
1011pub fn create(vol: usize, parent_dir_cluster: u32, name: &str, is_dir: bool) -> Result<(), ()> { 1034pub 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
1050pub fn remove(vol: usize, parent_dir_cluster: u32, name: &str) -> Result<(), ()> { 1074pub 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.
205pub fn rescan_disks() -> (usize, usize) { 205pub 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
430pub fn write(h: usize, buf: &[u8], pos: u64) -> Result<usize, ()> { 431pub 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.
558pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h].data { 563pub 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.
569pub fn create(parent: usize, name: &str, kind: FileType) -> Result<usize, ()> { 576pub 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).
610pub fn remove(h: usize) -> Result<(), ()> { 618pub 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() {