integ: merge 2dbb6079 merge/demo-desktop (C: desktop demo + procfs/pty/cursor/dpr/hostx fixes)

This commit is contained in:
velzie committed 2026-10-12 13:52:52 -04:00
commit 3a61c3d8f9
39 files changed
+3564 -170

No files matched your search

+3
View File
@@ -4,6 +4,7 @@ tests/corpus/bin/
web/node_modules/
web/dist/
web/dist-sp/
web/dist-demo/
web/public/gex/
web/public/rootfs/
web/public/closure/
@@ -18,3 +19,5 @@ logs/
core.[0-9]*
hs_err_pid*.log
TestResults.qpa
web/public/pkgs/
web/public/gex-apt/
+148
View File
@@ -62,6 +62,81 @@ pub struct Remote {
pub wait_ready: extern "C" fn(ppid: i32, want: i32) -> i32,
pub request_halt: extern "C" fn(code: i32),
pub halt_code: extern "C" fn() -> i32,
/// What `/proc/<pid>` says about a process: see [`ProcTable::set_info`].
pub set_info: extern "C" fn(pid: i32, at: crate::PAddr, len: u32),
/// Every process, encoded by [`encode_entries`], into `cap` bytes at `at`;
/// returns the full length (larger than `cap`: ask again with more room).
pub snapshot: extern "C" fn(at: crate::PAddr, cap: u32) -> i32,
}
/// One process as another process's `/proc` sees it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcEntry {
pub pid: i32,
pub ppid: i32,
pub pgid: i32,
pub sid: i32,
/// Exited and not yet collected by its parent.
pub zombie: bool,
/// What the process published: its name, a newline, then its command
/// line (NUL-separated arguments). Empty until it has said.
pub info: Vec<u8>,
}
impl ProcEntry {
/// The name `/proc/<pid>/comm` and `stat` give (at most 15 bytes).
pub fn comm(&self) -> String {
let end = self.info.iter().position(|&b| b == b'\n').unwrap_or(self.info.len());
let name = String::from_utf8_lossy(&self.info[..end]).into_owned();
if name.is_empty() {
return "gex".into();
}
name.chars().take(15).collect()
}
/// `/proc/<pid>/cmdline`.
pub fn cmdline(&self) -> &[u8] {
match self.info.iter().position(|&b| b == b'\n') {
Some(i) => &self.info[i + 1..],
None => &[],
}
}
}
/// The wire form of a process list: per entry five `i32`s (pid, ppid, pgid,
/// sid, zombie), the info's length, the info, padded to four bytes.
pub fn encode_entries(entries: &[ProcEntry]) -> Vec<u8> {
let mut out = Vec::new();
for e in entries {
for v in [e.pid, e.ppid, e.pgid, e.sid, i32::from(e.zombie), e.info.len() as i32] {
out.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&e.info);
while out.len() % 4 != 0 {
out.push(0);
}
}
out
}
pub fn decode_entries(mut b: &[u8]) -> Vec<ProcEntry> {
let mut out = Vec::new();
let word = |b: &[u8], i: usize| i32::from_le_bytes([b[i * 4], b[i * 4 + 1], b[i * 4 + 2], b[i * 4 + 3]]);
while b.len() >= 24 {
let n = word(b, 5).max(0) as usize;
if b.len() < 24 + n {
break;
}
out.push(ProcEntry {
pid: word(b, 0),
ppid: word(b, 1),
pgid: word(b, 2),
sid: word(b, 3),
zombie: word(b, 4) != 0,
info: b[24..24 + n].to_vec(),
});
b = &b[(24 + n + 3) & !3..];
}
out
}
/// One process, as the table sees it.
@@ -87,6 +162,8 @@ struct Proc {
/// raised), and the pid that sent each. Under the table's lock.
killed: u64,
senders: [i32; 64],
/// See [`ProcTable::set_info`].
info: Vec<u8>,
}
struct Table {
@@ -143,6 +220,7 @@ impl ProcTable {
pending: Arc::new(AtomicU64::new(0)),
killed: 0,
senders: [0; 64],
info: Vec::new(),
}],
}),
}
@@ -175,10 +253,56 @@ impl ProcTable {
pending: Arc::new(AtomicU64::new(0)),
killed: 0,
senders: [0; 64],
info: Vec::new(),
});
pid
}
/// What `/proc/<pid>` shows for `pid` in every other process: its name
/// and command line (see [`ProcEntry`]), said by the process itself when
/// it starts and again when it execs.
pub fn set_info(&self, pid: i32, info: &[u8]) {
let info = &info[..info.len().min(4096)];
if let Some(r) = self.remote {
return (r.set_info)(pid, info.as_ptr() as usize as crate::PAddr, info.len() as u32);
}
if let Some(p) = self.lock().procs.iter_mut().find(|p| p.pid == pid) {
p.info = info.to_vec();
}
}
/// Every process in the table, for `/proc`'s listing.
pub fn entries(&self) -> Vec<ProcEntry> {
if let Some(r) = self.remote {
let mut cap = 16 * 1024usize;
for _ in 0..4 {
let mut buf = vec![0u8; cap];
let n = (r.snapshot)(buf.as_mut_ptr() as usize as crate::PAddr, cap as u32);
if n < 0 {
return Vec::new();
}
if n as usize <= cap {
buf.truncate(n as usize);
return decode_entries(&buf);
}
cap = n as usize;
}
return Vec::new();
}
self.lock()
.procs
.iter()
.map(|p| ProcEntry {
pid: p.pid,
ppid: p.ppid,
pgid: p.pgid,
sid: p.sid,
zombie: p.child.status.lock().map(|s| s.is_some()).unwrap_or(false),
info: p.info.clone(),
})
.collect()
}
/// The handle a host needs to publish `pid`'s exit status.
pub fn handle(&self, pid: i32) -> Option<ChildHandle> {
self.lock()
@@ -534,6 +658,15 @@ impl ProcTable {
self.lock().procs.iter().any(|p| p.pid == pid)
}
/// Whether `pid` is in the table and has not exited: [`ProcTable::exists`]
/// without the zombies, which are in the table until reaped.
pub fn alive(&self, pid: i32) -> bool {
self.lock()
.procs
.iter()
.any(|p| p.pid == pid && p.child.status.lock().map(|s| s.is_none()).unwrap_or(true))
}
/// Whether a `wait4` by `ppid` for `want` would answer now: a child with a
/// status ready, or no matching child at all (`ECHILD`). Separate from
/// `collect`, which *takes* the child -- asking must not reap.
@@ -597,6 +730,21 @@ fn errno_or(v: i32) -> Result<i32, Errno> {
mod tests {
use super::*;
#[test]
fn the_table_lists_processes_with_what_they_said() {
let t = ProcTable::new();
let a = t.spawn(INIT_PID);
t.set_info(a, b"sh\n/bin/sh\0-c\0x\0");
let e = t.entries();
assert_eq!(e.len(), 2);
let mine = e.iter().find(|e| e.pid == a).unwrap();
assert_eq!(mine.comm(), "sh");
assert_eq!(mine.cmdline(), b"/bin/sh\0-c\0x\0");
assert_eq!(decode_entries(&encode_entries(&e)), e);
t.exit(a, 0);
assert!(t.entries().iter().find(|e| e.pid == a).unwrap().zombie);
}
#[test]
fn a_child_inherits_its_parents_group_and_session() {
let t = ProcTable::new();
+102 -4
View File
@@ -123,8 +123,11 @@ impl Pty {
pub fn master_read(&self, buf: &mut [u8]) -> SysResult {
let mut s = self.lock();
if s.to_master.is_empty() {
// Every slave closed: a hangup, which Linux reports to the master
// as EIO rather than end-of-file (`os.read` on a pty.fork child
// that has exited, xterm noticing its shell is gone).
return if s.slave_ever && s.slave_open == 0 {
Ok(0)
Err(Errno::EIO)
} else {
Err(Errno::EAGAIN)
};
@@ -211,6 +214,46 @@ fn drain(q: &mut VecDeque<u8>, buf: &mut [u8]) -> u64 {
n as u64
}
/// `st_rdev` of `/dev/pts/N` (major 136), in Linux's encoding.
pub const fn pts_rdev(n: u32) -> u64 {
let (major, minor) = (136u64, n as u64);
(minor & 0xff) | (major << 8) | ((minor & !0xff) << 12)
}
/// `st_rdev` of `/dev/ptmx` (5:2).
pub const PTMX_RDEV: u64 = (5 << 8) | 2;
/// `st_ino` of `/dev/pts/N`: the same from `stat` on the name and `fstat` on
/// a descriptor, which is how `ttyname` recognises the one as the other.
pub const fn pts_ino(n: u32) -> u64 {
0x7074_0000 + n as u64
}
/// `st_ino` of `/dev/ptmx`.
pub const PTMX_INO: u64 = 0x7074_ffff;
/// What `/proc/<pid>/fd/N` says for a pty end gex names `pty:[id:m|s]`
/// internally: `/dev/ptmx` for a master, `/dev/pts/N` for a slave (the id is
/// the pts number). Anything else as it is.
pub fn link_name(path: &str) -> String {
let parsed = path
.strip_prefix("pty:[")
.and_then(|v| v.strip_suffix(']'))
.and_then(|v| v.split_once(':'))
.and_then(|(id, side)| Some((id.parse::<u32>().ok()?, side)));
match parsed {
Some((_, "m")) => "/dev/ptmx".to_string(),
Some((n, "s")) => format!("/dev/pts/{n}"),
_ => path.to_string(),
}
}
/// `/proc/<pid>/stat`'s `tty_nr` for a controlling terminal that is
/// `/dev/pts/N`: the device number, as an int.
pub const fn tty_nr(n: u32) -> i32 {
pts_rdev(n) as i32
}
/// A pty pair that lives in another wasm module, reached by number and a table
/// of `extern "C"` calls -- the [`crate::pipe::Remote`] shape, with the terminal
/// added. `id` names the pair; `master` picks the end. Data calls copy through
@@ -298,15 +341,19 @@ impl crate::vfs::FileHandle for RemotePty {
}
}
/// The node `/dev/pts/N` (or `/dev/ptmx`) as `stat` on its name gives
/// it: the same inode and device number, or `ttyname` finds no name.
fn metadata(&self) -> Result<crate::vfs::Metadata, Errno> {
let n = (self.remote.ptn)(self.id).max(0) as u32;
let (ino, rdev, mode) = if self.master { (PTMX_INO, PTMX_RDEV, 0o666) } else { (pts_ino(n), pts_rdev(n), 0o620) };
Ok(crate::vfs::Metadata {
file_type: crate::vfs::FileType::CharDevice,
size: 0,
mode: 0o620,
ino: 0,
mode,
ino,
mtime: 0,
nlink: 1,
rdev: 0,
rdev,
times: Default::default(),
})
}
@@ -353,6 +400,13 @@ impl crate::vfs::FileHandle for RemotePty {
}
fn ioctl(&self, m: &mut crate::GuestMem<'_>, request: u64, arg: u64) -> Option<SysResult> {
// The descriptor's own ioctls are the kernel's to answer, as they are
// for any file. Answering them here with ENOTTY failed libuv's
// `uv__nonblock` (FIONBIO) on a pty: nvim's input stream never opened,
// so it drew in an xterm and took no keys.
if descriptor_level(request) {
return None;
}
Some(self.do_ioctl(m, request, arg))
}
@@ -366,6 +420,50 @@ impl crate::vfs::FileHandle for RemotePty {
}
}
/// FIONBIO, FIONCLEX, FIOCLEX, FIOASYNC: properties of the descriptor, not
/// the device.
fn descriptor_level(request: u64) -> bool {
matches!(request, 0x5421 | 0x5450 | 0x5451 | 0x5452)
}
#[cfg(test)]
mod tests {
/// The master sees the slave side going away as EIO, as on Linux: once
/// its output is read, not before, and not before a slave was ever open.
#[test]
fn the_master_reads_eio_once_every_slave_has_closed() {
let p = super::Pty::new(0);
let mut buf = [0u8; 8];
assert_eq!(p.master_read(&mut buf), Err(crate::errno::Errno::EAGAIN), "no slave yet: wait");
p.open_slave();
p.slave_write(b"bye");
p.close_slave();
assert_eq!(p.master_read(&mut buf), Ok(3), "what it wrote first");
assert_eq!(p.master_read(&mut buf), Err(crate::errno::Errno::EIO));
assert!(p.master_readable(), "a hangup is readable, so poll wakes the reader");
}
#[test]
fn a_pty_end_has_the_name_a_program_can_stat() {
assert_eq!(super::link_name("pty:[4:s]"), "/dev/pts/4");
assert_eq!(super::link_name("pty:[4:m]"), "/dev/ptmx");
assert_eq!(super::link_name("pipe:[2]"), "pipe:[2]");
assert_eq!(super::pts_rdev(4), 0x8804);
assert_eq!(super::pts_rdev(300), (300 & 0xff) | (136 << 8) | ((300 & !0xff) << 12));
}
#[test]
fn the_descriptor_ioctls_are_left_to_the_kernel() {
for req in [0x5421, 0x5450, 0x5451, 0x5452] {
assert!(super::descriptor_level(req), "{req:#x}");
}
// TCGETS, TIOCGWINSZ, TIOCGPTN are the terminal's.
for req in [0x5401, 0x5413, 0x8004_5430] {
assert!(!super::descriptor_level(req), "{req:#x}");
}
}
}
// ioctl numbers, the terminal subset a pty answers. The `_IOC`-encoded ones
// (pty and termios2) carry direction and struct size in the number.
mod ioc {
+62 -13
View File
@@ -6264,6 +6264,22 @@ impl Kernel {
Ok(fd)
}
/// The epoll set an open file is, if it is one.
pub fn epoll_of_file(&self, file: &OpenFile) -> Option<Arc<unix::EpollSet>> {
let key = Arc::as_ptr(&file.handle) as *const () as usize;
self.epolls.get(&key).cloned()
}
/// An open epoll set holding `watches`, registered here: what a fork's
/// child rebuilds its parent's set as.
pub fn adopt_epoll(&mut self, watches: Vec<unix::Watch>, flags: u64) -> Arc<OpenFile> {
let set = Arc::new(unix::EpollSet::with_watches(watches));
let handle: Arc<dyn crate::vfs::FileHandle> = set.clone();
let key = Arc::as_ptr(&handle) as *const () as usize;
self.epolls.insert(key, set);
OpenFile::new(handle, "anon_inode:[eventpoll]".into(), flags, true, true)
}
/// The epoll set behind a descriptor.
///
/// Kept in a side table for the same reason sockets are: an
@@ -7477,6 +7493,20 @@ pub struct ProcState {
pub maps_generation: u64,
/// Which version of the descriptor table `fds` was listed from.
pub fds_generation: u64,
/// The process table, for `/proc`'s view of the other processes.
pub procs: Option<Arc<ProcTable>>,
/// The image whose name and command line the table was last told.
pub info_exe: Option<String>,
/// The system's pseudoterminals, for `stat`'s `tty_nr`.
pub ptys: Option<crate::pty::Remote>,
}
/// The session's controlling terminal: its pts number (the pair's id) and
/// foreground group.
fn tty_of(ptys: Option<crate::pty::Remote>, sid: i32) -> Option<(u32, i32)> {
let r = ptys?;
let id = (r.for_sid)(sid);
(id >= 0).then(|| (id as u32, (r.fg)(id)))
}
/// A `/proc` source backed by a snapshot the kernel refreshes.
@@ -7506,18 +7536,17 @@ impl ProcSource for KernelProcSource {
}
fn stat(&self) -> String {
let s = self.0.lock().unwrap();
// pid, comm, state, ppid, pgrp, session, then the rest, which nothing
// seen so far reads. `ps` and every "who is my parent" one-liner cut
// fields out of this by number, so the first six have to be right
// even when the remainder is zeroes.
format!(
"{} ({}) R {} {} {} 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 0 0 0\n",
s.pid,
s.exe.rsplit('/').next().unwrap_or("gex"),
s.ppid,
s.pgid,
s.sid
)
let comm: String = s.exe.rsplit('/').next().unwrap_or("gex").chars().take(15).collect();
let tty = tty_of(s.ptys, s.sid);
crate::vfs::procfs::stat_line(s.pid, &comm, 'R', s.ppid, s.pgid, s.sid, tty)
}
fn others(&self) -> Vec<crate::proc::ProcEntry> {
let procs = self.0.lock().unwrap().procs.clone();
procs.map(|p| p.entries()).unwrap_or_default()
}
fn tty_of(&self, sid: i32) -> Option<(u32, i32)> {
let ptys = self.0.lock().unwrap().ptys;
tty_of(ptys, sid)
}
fn status(&self) -> String {
let s = self.0.lock().unwrap();
@@ -7539,6 +7568,10 @@ impl Kernel {
s.ppid = self.procs.ppid(self.pid);
s.pgid = self.procs.pgid(self.pid, 0).unwrap_or(self.pid);
s.sid = self.procs.sid(self.pid, 0).unwrap_or(self.pid);
s.ptys = self.ptys;
if s.procs.as_ref().is_none_or(|p| !Arc::ptr_eq(p, &self.procs)) {
s.procs = Some(Arc::clone(&self.procs));
}
// Only when the map has actually changed. Formatting the VMA tree is
// the single most expensive thing this function does -- half of all
// execution in a browser run of an X server, which polls in a tight
@@ -7554,13 +7587,18 @@ impl Kernel {
// are already there.
if s.exe != self.exe_path {
s.exe = self.exe_path.clone();
// An exec: a new name and command line, for this process's /proc
// and for every other's.
s.cmdline.clear();
}
if s.cwd != self.vfs.cwd {
s.cwd.clone_from(&self.vfs.cwd);
}
let fds_generation = self.fds.generation();
if s.fds_generation != fds_generation || s.fds.is_empty() {
s.fds = self.fds.list();
// A pty end under the name a program can open and `stat`, which is
// how `ttyname` and `ps` find it.
s.fds = self.fds.list().into_iter().map(|(fd, p)| (fd, crate::pty::link_name(&p))).collect();
s.fds_generation = fds_generation;
}
// argv and envp never change, so they are built once.
@@ -7576,6 +7614,17 @@ impl Kernel {
.flat_map(|a| a.bytes().chain(std::iter::once(0)))
.collect();
}
// Said to the process table once per image, for the other processes'
// `/proc/<pid>` (`ps`, `htop`): the name, then the command line.
if s.info_exe.as_deref() != Some(s.exe.as_str()) {
let name = s.exe.rsplit('/').next().unwrap_or("gex");
let mut info = Vec::with_capacity(name.len() + 1 + s.cmdline.len());
info.extend_from_slice(name.as_bytes());
info.push(b'\n');
info.extend_from_slice(&s.cmdline);
self.procs.set_info(self.pid, &info);
s.info_exe = Some(s.exe.clone());
}
}
}
+47
View File
@@ -1035,6 +1035,38 @@ impl EpollSet {
}
}
/// A set already holding `watches`: a fork's child's copy of its
/// parent's (see [`EpollSet::describe`]).
pub fn with_watches(watches: Vec<Watch>) -> EpollSet {
EpollSet { watched: Mutex::new(watches) }
}
/// The registrations as text, `fd,events,data` joined by `;`, for a set
/// that travels to another process by name.
pub fn describe(&self) -> String {
self.watches()
.iter()
.map(|w| format!("{},{},{}", w.fd, w.events, w.data))
.collect::<Vec<_>>()
.join(";")
}
/// The other half of [`EpollSet::describe`]. `None` for text it did not write.
pub fn parse(text: &str) -> Option<Vec<Watch>> {
if text.is_empty() {
return Some(Vec::new());
}
text.split(';')
.map(|w| {
let mut it = w.split(',');
let fd = it.next()?.parse().ok()?;
let events = it.next()?.parse().ok()?;
let data = it.next()?.parse().ok()?;
Some(Watch { fd, events, data })
})
.collect()
}
pub fn watches(&self) -> Vec<Watch> {
self.watched
.lock()
@@ -1066,6 +1098,21 @@ impl FileHandle for EpollSet {
mod epoll_tests {
use super::*;
#[test]
fn a_set_travels_by_its_description() {
let e = EpollSet::new();
e.ctl(epoll_op::ADD, 3, 1, 0xaa).unwrap();
e.ctl(epoll_op::ADD, 9, 5, u64::MAX).unwrap();
let text = e.describe();
let copy = EpollSet::with_watches(EpollSet::parse(&text).unwrap());
let w = copy.watches();
assert_eq!(w.len(), 2);
assert_eq!((w[0].fd, w[0].events, w[0].data), (3, 1, 0xaa));
assert_eq!((w[1].fd, w[1].events, w[1].data), (9, 5, u64::MAX));
assert!(EpollSet::parse("").unwrap().is_empty());
assert!(EpollSet::parse("x").is_none());
}
#[test]
fn add_modify_and_delete() {
let e = EpollSet::new();
+92 -1
View File
@@ -95,11 +95,43 @@ pub struct DevFs {
/// Nodes the host added whose every open is a handle of its own (an
/// audio device: each open is a stream with its own format and buffer).
per_open: Vec<(String, Arc<dyn Fn() -> Arc<dyn FileHandle> + Send + Sync>)>,
/// The pts numbers in use, when the system has pseudoterminals: `/dev/pts`
/// lists them and `stat` answers for each (and for `/dev/ptmx`). Opening
/// one is the kernel's (`open_pty`), not this filesystem's.
pts: Option<Arc<dyn Fn() -> Vec<u32> + Send + Sync>>,
}
impl DevFs {
pub fn new() -> DevFs {
DevFs { extra: Vec::new(), per_open: Vec::new() }
DevFs { extra: Vec::new(), per_open: Vec::new(), pts: None }
}
/// `/dev/ptmx` and `/dev/pts`, with `list` saying which pts numbers exist.
pub fn add_pts(&mut self, list: Arc<dyn Fn() -> Vec<u32> + Send + Sync>) {
self.pts = Some(list);
}
fn pts_node(&self, path: &str) -> Option<Metadata> {
let list = self.pts.as_ref()?;
let (ino, rdev, mode) = if path == "ptmx" {
(crate::pty::PTMX_INO, crate::pty::PTMX_RDEV, 0o666)
} else {
let n: u32 = path.strip_prefix("pts/")?.parse().ok()?;
if !list().contains(&n) {
return None;
}
(crate::pty::pts_ino(n), crate::pty::pts_rdev(n), 0o620)
};
Some(Metadata {
file_type: FileType::CharDevice,
size: 0,
mode,
ino,
mtime: 0,
nlink: 1,
rdev,
times: Default::default(),
})
}
/// Add a device the host provides. The handle is shared, not cloned per
@@ -154,8 +186,12 @@ impl FileSystem for DevFs {
if path.is_empty() {
return Ok(Arc::new(DevDir {
extra: self.extra.iter().map(|(n, _)| n.clone()).chain(self.per_open.iter().map(|(n, _)| n.clone())).collect(),
pts: self.pts.is_some(),
}));
}
if let (Some(list), "pts") = (self.pts.as_ref(), path) {
return Ok(Arc::new(PtsDir { numbers: list() }));
}
Err(Errno::ENOENT)
}
@@ -178,6 +214,12 @@ impl FileSystem for DevFs {
times: Default::default(),
});
}
if path == "pts" && self.pts.is_some() {
return Ok(Metadata::dir());
}
if let Some(md) = self.pts_node(path) {
return Ok(md);
}
if matches!(path, "stdin" | "stdout" | "stderr" | "fd") {
return Ok(Metadata {
file_type: FileType::Symlink,
@@ -208,6 +250,33 @@ impl FileSystem for DevFs {
struct DevDir {
/// Names the host added, so `ls /dev` shows them.
extra: Vec<String>,
/// Whether `ptmx` and `pts` exist.
pts: bool,
}
/// `/dev/pts`: one character device per pseudoterminal in use.
#[derive(Debug)]
struct PtsDir {
numbers: Vec<u32>,
}
impl FileHandle for PtsDir {
fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> SysResult {
Err(Errno::EISDIR)
}
fn metadata(&self) -> Result<Metadata, Errno> {
Ok(Metadata::dir())
}
fn read_dir(&self) -> Result<Vec<DirEntry>, Errno> {
let mut out = vec![
DirEntry { name: ".".into(), file_type: FileType::Directory, ino: 1 },
DirEntry { name: "..".into(), file_type: FileType::Directory, ino: 1 },
];
for &n in &self.numbers {
out.push(DirEntry { name: n.to_string(), file_type: FileType::CharDevice, ino: crate::pty::pts_ino(n) });
}
Ok(out)
}
}
impl FileHandle for DevDir {
@@ -243,6 +312,10 @@ impl FileHandle for DevDir {
ino: 1,
});
}
if self.pts {
out.push(DirEntry { name: "ptmx".into(), file_type: FileType::CharDevice, ino: crate::pty::PTMX_INO });
out.push(DirEntry { name: "pts".into(), file_type: FileType::Directory, ino: 1 });
}
for name in ["stdin", "stdout", "stderr", "fd"] {
out.push(DirEntry {
name: name.into(),
@@ -330,6 +403,24 @@ mod tests {
/// A device the host adds is opened like any other, and the same handle
/// comes back every time -- which is what lets one keep state across the
/// several times a program opens it.
/// `ttyname` compares `fstat` of the descriptor with `stat` of the name
/// `/proc/self/fd` gives: `/dev/pts/N` must exist, as the same node.
#[test]
fn pts_lists_the_pairs_in_use_with_their_device_numbers() {
let mut fs = DevFs::new();
assert_eq!(fs.metadata("pts").err(), Some(Errno::ENOENT), "no pseudoterminals, no /dev/pts");
fs.add_pts(Arc::new(|| vec![0, 3]));
assert_eq!(fs.metadata("pts").unwrap().file_type, FileType::Directory);
let names: Vec<String> = fs.open("pts", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
assert_eq!(names, [".", "..", "0", "3"]);
let md = fs.metadata("pts/3").unwrap();
assert_eq!((md.file_type, md.rdev, md.ino), (FileType::CharDevice, 0x8803, crate::pty::pts_ino(3)));
assert_eq!(fs.metadata("pts/1").err(), Some(Errno::ENOENT));
assert_eq!(fs.metadata("ptmx").unwrap().rdev, (5 << 8) | 2);
let root: Vec<String> = fs.open("", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
assert!(root.iter().any(|n| n == "pts") && root.iter().any(|n| n == "ptmx"));
}
#[test]
fn a_host_device_is_openable_and_shared() {
#[derive(Debug)]
+203 -5
View File
@@ -25,6 +25,47 @@ pub trait ProcSource: Send + Sync {
fn fds(&self) -> Vec<(i32, String)>;
fn stat(&self) -> String;
fn status(&self) -> String;
/// Every process on the system, this one included, for `/proc/<pid>`
/// and the listing of `/proc` (what `ps` and `htop` read). Empty where
/// the kernel knows only this process.
fn others(&self) -> Vec<crate::proc::ProcEntry> {
Vec::new()
}
/// A session's controlling terminal, as its pts number and the terminal's
/// foreground process group (`stat`'s `tty_nr` and `tpgid`).
fn tty_of(&self, _sid: i32) -> Option<(u32, i32)> {
None
}
}
/// A `/proc/<pid>/stat` line: all 52 fields, as Linux has them. `ps` and
/// `htop` parse it strictly -- by position after the `)` -- and a short line
/// is a process they skip (htop listed none) or a fatal error (`ps`: "fatal
/// library error, reap").
pub fn stat_line(pid: i32, comm: &str, state: char, ppid: i32, pgid: i32, sid: i32, tty: Option<(u32, i32)>) -> String {
let (tty_nr, tpgid) = tty.map_or((0, -1), |(n, fg)| (crate::pty::tty_nr(n), if fg > 0 { fg } else { -1 }));
// pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt
// majflt cmajflt utime stime cutime cstime priority nice num_threads
// itrealvalue starttime vsize rss rsslim startcode endcode startstack
// kstkesp kstkeip signal blocked sigignore sigcatch wchan nswap cnswap
// exit_signal processor rt_priority policy delayacct_blkio_ticks
// guest_time cguest_time start_data end_data start_brk arg_start arg_end
// env_start env_end exit_code
format!(
"{pid} ({comm}) {state} {ppid} {pgid} {sid} {tty_nr} {tpgid} 4194560 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 \
18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n"
)
}
/// `/proc/<pid>/status`, the fields programs look for.
pub fn status_text(pid: i32, comm: &str, state: char, ppid: i32) -> String {
let st = if state == 'Z' { "Z (zombie)" } else { "R (running)" };
format!(
"Name:\t{comm}\nUmask:\t0022\nState:\t{st}\nTgid:\t{pid}\nNgid:\t0\nPid:\t{pid}\n\
PPid:\t{ppid}\nTracerPid:\t0\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nFDSize:\t64\n\
Groups:\t\nVmRSS:\t 0 kB\nThreads:\t1\nvoluntary_ctxt_switches:\t0\n\
nonvoluntary_ctxt_switches:\t0\n"
)
}
/// A generated file: contents are captured when it is opened.
@@ -85,17 +126,43 @@ impl ProcFs {
}
/// Rewrite a `<pid>/…` path to `self/…` so both spellings share one
/// implementation, as they do on Linux for the current process.
/// implementation, as they do on Linux for the current process. A
/// thread's directory (`<pid>/task/<tid>/…`) is its process's: every
/// process here has the one thread `/proc` reports.
fn canonical(&self, path: &str) -> String {
let path = untask(path);
let pid = self.source.pid().to_string();
match path.strip_prefix(&pid) {
Some(rest) if rest.is_empty() || rest.starts_with('/') => {
format!("self{rest}")
}
_ => path.to_string(),
_ => path,
}
}
/// Another process's `/proc/<pid>/…`: its entry and the rest of the path.
fn other(&self, path: &str) -> Option<(crate::proc::ProcEntry, String)> {
let (first, rest) = path.split_once('/').unwrap_or((path, ""));
let pid: i32 = first.parse().ok()?;
let e = self.source.others().into_iter().find(|e| e.pid == pid)?;
Some((e, rest.to_string()))
}
fn generate_other(&self, e: &crate::proc::ProcEntry, rest: &str) -> Option<Vec<u8>> {
let comm = e.comm();
let state = if e.zombie { 'Z' } else { 'S' };
Some(match rest {
"stat" => stat_line(e.pid, &comm, state, e.ppid, e.pgid, e.sid, self.source.tty_of(e.sid)).into_bytes(),
"status" => status_text(e.pid, &comm, state, e.ppid).into_bytes(),
"statm" => b"0 0 0 0 0 0 0\n".to_vec(),
"cmdline" => e.cmdline().to_vec(),
"comm" => format!("{comm}\n").into_bytes(),
// Another process's memory and environment are not ours to show.
"environ" | "maps" => Vec::new(),
_ => return None,
})
}
fn generate(&self, path: &str) -> Option<Vec<u8>> {
let s = &self.source;
Some(match path {
@@ -104,6 +171,11 @@ impl ProcFs {
"self/environ" => s.environ(),
"self/stat" => s.stat().into_bytes(),
"self/status" => s.status().into_bytes(),
"self/statm" => b"0 0 0 0 0 0 0\n".to_vec(),
"loadavg" => {
let n = s.others().len().max(1);
format!("0.00 0.00 0.00 1/{n} {}\n", s.pid()).into_bytes()
}
"self/comm" => {
let exe = s.exe();
let name = exe.rsplit('/').next().unwrap_or("gex").to_string();
@@ -160,10 +232,52 @@ impl FileSystem for ProcFs {
if let Some(data) = self.generate(&path) {
return Ok(Arc::new(Generated { data }));
}
if let Some((e, rest)) = self.other(&path) {
if let Some(data) = self.generate_other(&e, &rest) {
return Ok(Arc::new(Generated { data }));
}
let mut out = base_entries();
let names: &[&str] = match rest.as_str() {
"" => &["stat", "status", "statm", "cmdline", "comm", "environ", "maps", "task"],
"task" => {
out.push(DirEntry { name: e.pid.to_string(), file_type: FileType::Directory, ino: 1 });
&[]
}
_ => return Err(Errno::ENOENT),
};
for name in names {
let file_type = if *name == "task" { FileType::Directory } else { FileType::Regular };
out.push(DirEntry { name: name.to_string(), file_type, ino: 1 });
}
return Ok(Arc::new(GenDir { entries: out }));
}
if path == "self/task" {
let mut out = base_entries();
out.push(DirEntry { name: self.source.pid().to_string(), file_type: FileType::Directory, ino: 1 });
return Ok(Arc::new(GenDir { entries: out }));
}
let entries = match path.as_str() {
"" => vec!["self", "meminfo", "cpuinfo", "version", "uptime", "stat", "sys"],
"" => {
// Every process, as Linux lists them: what `ps` and `htop`
// scan for. This one too, under its number as well as `self`.
let mut out = base_entries();
let mut pids: Vec<i32> = self.source.others().iter().map(|e| e.pid).collect();
if !pids.contains(&self.source.pid()) {
pids.push(self.source.pid());
}
pids.sort_unstable();
for pid in pids {
out.push(DirEntry { name: pid.to_string(), file_type: FileType::Directory, ino: 1 });
}
for name in ["self", "meminfo", "cpuinfo", "version", "uptime", "stat", "loadavg", "sys"] {
let file_type = if matches!(name, "self" | "sys") { FileType::Directory } else { FileType::Regular };
out.push(DirEntry { name: name.to_string(), file_type, ino: 1 });
}
return Ok(Arc::new(GenDir { entries: out }));
}
"self" => vec![
"maps", "cmdline", "environ", "stat", "status", "comm", "exe", "cwd", "fd",
"maps", "cmdline", "environ", "stat", "status", "statm", "comm", "exe", "cwd", "fd",
"task",
],
"self/fd" => {
let mut out = base_entries();
@@ -206,8 +320,24 @@ impl FileSystem for ProcFs {
times: Default::default(),
});
}
if let Some((e, rest)) = self.other(&path) {
return match rest.as_str() {
"" | "task" => Ok(Metadata::dir()),
r if self.generate_other(&e, r).is_some() => Ok(Metadata {
file_type: FileType::Regular,
size: 0,
mode: 0o444,
ino: 1,
mtime: 0,
nlink: 1,
rdev: 0,
times: Default::default(),
}),
_ => Err(Errno::ENOENT),
};
}
match path.as_str() {
"" | "self" | "self/fd" | "sys" | "sys/kernel" | "sys/vm" => Ok(Metadata::dir()),
"" | "self" | "self/fd" | "self/task" | "sys" | "sys/kernel" | "sys/vm" => Ok(Metadata::dir()),
"self/exe" | "self/cwd" => Ok(Metadata {
file_type: FileType::Symlink,
size: 0,
@@ -243,6 +373,21 @@ impl FileSystem for ProcFs {
}
}
/// `<pid>/task/<tid>/rest` -> `<pid>/rest`, and `<pid>/task/<tid>` ->
/// `<pid>`: a thread's directory is its process's.
fn untask(path: &str) -> String {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 3 && parts[1] == "task" && parts[2].parse::<i32>().is_ok() {
let mut out = parts[0].to_string();
for p in &parts[3..] {
out.push('/');
out.push_str(p);
}
return out;
}
path.to_string()
}
fn base_entries() -> Vec<DirEntry> {
vec![
DirEntry {
@@ -291,6 +436,59 @@ mod tests {
fn status(&self) -> String {
"Name:\tprog\nPid:\t42\n".into()
}
fn others(&self) -> Vec<crate::proc::ProcEntry> {
let e = |pid, ppid, info: &[u8]| crate::proc::ProcEntry {
pid,
ppid,
pgid: pid,
sid: 1,
zombie: false,
info: info.to_vec(),
};
vec![e(1, 0, b"bash\n/bin/bash\0/etc/session\0"), e(7, 1, b"xterm\nxterm\0-e\0bash\0"), e(42, 7, b"prog\n")]
}
}
fn read(f: &ProcFs, path: &str) -> String {
let h = f.open(path, 0, 0).unwrap();
let mut buf = [0u8; 4096];
let n = h.read_at(0, &mut buf).unwrap() as usize;
String::from_utf8_lossy(&buf[..n]).into_owned()
}
#[test]
fn the_root_lists_every_process() {
let names: Vec<String> = fs().open("", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
for pid in ["1", "7", "42", "self", "loadavg"] {
assert!(names.iter().any(|n| n == pid), "{pid} missing from {names:?}");
}
}
#[test]
fn another_process_has_its_stat_and_cmdline() {
let f = fs();
let stat = read(&f, "7/stat");
assert!(stat.starts_with("7 (xterm) S 1 7 1 "), "{stat}");
// htop and procps parse by position after the ')': all 52 fields.
assert_eq!(stat.split_whitespace().count(), 52, "{stat}");
assert_eq!(read(&f, "7/cmdline"), "xterm\0-e\0bash\0");
assert_eq!(read(&f, "1/comm"), "bash\n");
assert!(read(&f, "1/status").contains("PPid:\t0\n"));
// A thread's directory is its process's.
assert_eq!(read(&f, "7/task/7/stat"), stat);
let task: Vec<String> = f.open("7/task", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
assert!(task.contains(&"7".to_string()));
assert!(f.metadata("7").unwrap().file_type == FileType::Directory);
assert_eq!(f.open("99/stat", 0, 0).err().map(|_| ()), Some(()));
}
#[test]
fn this_process_keeps_its_own_view() {
// Its own pid is `self`, served from its own state, not the table's.
assert_eq!(read(&fs(), "42/cmdline"), "/bin/prog\0arg\0");
assert_eq!(stat_line(1, "x", 'R', 0, 1, 1, None).split_whitespace().count(), 52);
let f: Vec<String> = stat_line(5, "sh", 'S', 1, 5, 5, Some((3, 7))).split_whitespace().map(String::from).collect();
assert_eq!((f[6].as_str(), f[7].as_str()), ("34819", "7"), "tty_nr is 136:3, tpgid the foreground group");
}
fn fs() -> ProcFs {
+76 -6
View File
@@ -183,6 +183,13 @@ pub struct SurfaceInfo {
pub xid: u32,
/// The pixels are not at `addr`: the host knows where they are.
pub external: bool,
/// `wl_surface.set_buffer_scale`: buffer pixels per surface unit (1 unless
/// the client said otherwise).
pub buffer_scale: i32,
/// The output scale this client was told (`wl_output.scale`): device
/// pixels per surface unit. The host draws a surface at
/// `width * client_scale / buffer_scale` device pixels.
pub client_scale: i32,
}
/// Interfaces the registry advertises, with the version offered. A toolkit
@@ -417,6 +424,10 @@ struct SurfaceState {
/// `wp_viewport.set_destination`: the size the surface is shown at, when
/// the client set one.
viewport: Option<(i32, i32)>,
/// `set_buffer_scale`, pending until commit, and the committed value
/// (0 is 1).
pending_scale: Option<i32>,
buffer_scale: i32,
}
/// Whether a surface's pixels are the host's. A free function because the
@@ -443,8 +454,11 @@ pub struct Client {
/// Event serials, handed out with sync/configure/frame callbacks, counting
/// up.
next_serial: u32,
/// The size a toplevel is told it is.
/// The size a toplevel is told it is, in device pixels.
default_size: (i32, i32),
/// Device pixels per surface unit: what `wl_output.scale` says. A
/// toplevel's configure is in surface units, so `default_size / scale`.
scale: i32,
/// Positioner state, by object id: what a popup asked to be placed against.
positioners: HashMap<u32, Positioner>,
/// Surfaces whose scene state changed since the host last published it, so
@@ -510,6 +524,7 @@ impl Client {
frames: Vec::new(),
next_serial: 1,
default_size: (640, 480),
scale: 1,
positioners: HashMap::new(),
dirty: false,
failed: None,
@@ -532,6 +547,13 @@ impl Client {
self.default_size = (w, h);
}
/// The output scale this client is told (`wl_output.scale`), for a host
/// whose output is in device pixels on a high-density display. Set before
/// the client binds the output.
pub fn set_scale(&mut self, scale: i32) {
self.scale = scale.clamp(1, 8);
}
/// Feed bytes and descriptors that arrived together.
pub fn receive(&mut self, bytes: &[u8], fds: &[u32], buffers: &dyn Buffers) {
self.inbox.extend_from_slice(bytes);
@@ -847,7 +869,7 @@ impl Client {
// mode(flags=current|preferred, width, height, refresh mHz)
let ev = Writer::new(id, 1).uint(0b11).int(w_px).int(h_px).int(60000);
self.send(ev);
let ev = Writer::new(id, 3).int(1);
let ev = Writer::new(id, 3).int(self.scale);
self.send(ev);
let ev = Writer::new(id, 2);
self.send(ev);
@@ -1052,7 +1074,9 @@ impl Client {
return false;
};
if let Some(s) = self.surfaces.get_mut(&object) {
let rect = Damage { x, y, w, h };
// Surface units, and the host's damage is in buffer pixels.
let k = s.pending_scale.unwrap_or(s.buffer_scale).max(1);
let rect = Damage { x: x * k, y: y * k, w: w * k, h: h * k };
s.pending_damage = Some(match s.pending_damage {
Some(mut held) => {
held.merge(rect);
@@ -1079,8 +1103,17 @@ impl Client {
self.commit(object, buffers);
true
}
// set_buffer_transform, set_buffer_scale, offset
7 | 8 | 10 => true,
// set_buffer_scale(scale): buffer pixels per surface unit, from
// the next commit.
8 => {
let Some(k) = r.int() else { return false };
if let Some(s) = self.surfaces.get_mut(&object) {
s.pending_scale = Some(k.clamp(1, 8));
}
true
}
// set_buffer_transform, offset
7 | 10 => true,
// damage_buffer(x, y, width, height), in buffer coordinates. The
// same thing as `damage` at scale 1 and no transform, which is
// every surface here -- and it is the one toolkits actually use.
@@ -1107,6 +1140,15 @@ impl Client {
/// Apply a surface's pending state, the only moment anything becomes visible.
fn commit(&mut self, surface: u32, buffers: &dyn Buffers) {
// The buffer scale is double-buffered state like the rest.
if let Some(s) = self.surfaces.get_mut(&surface) {
if let Some(k) = s.pending_scale.take() {
if k != s.buffer_scale.max(1) {
self.dirty = true;
}
s.buffer_scale = k;
}
}
let Some(state) = self.surfaces.get(&surface).cloned() else {
return;
};
@@ -1364,7 +1406,8 @@ impl Client {
// compositor sends, and a window that has just appeared is the
// active one.
let (w, h) = self.default_size;
self.send_toplevel_configure(id, object, w, h, true);
let k = self.scale.max(1);
self.send_toplevel_configure(id, object, w / k, h / k, true);
true
}
// get_popup(id, parent, positioner)
@@ -1994,6 +2037,8 @@ impl Client {
x11: s.x11,
xid: s.xid,
external: external.is_some(),
buffer_scale: s.buffer_scale.max(1),
client_scale: self.scale,
});
}
// A stable order, so a host that keys textures by index rather than by
@@ -3109,6 +3154,31 @@ mod tests {
);
}
#[test]
fn a_surface_carries_its_buffer_scale_and_its_clients_output_scale() {
// The cursor surface from above, drawn at 2x for a 2x output: the host
// shows it at width * client_scale / buffer_scale, 1:1 here.
let pool = Placed(0x8000, 4096);
let (mut c, _) = placed_window(64, 64, 256);
c.set_scale(2);
c.receive(
&Writer::new(2, 0).uint(3).string("wl_seat").uint(5).new_id(50).finish(),
&[],
&pool,
);
c.receive(&Writer::new(50, 0).new_id(51).finish(), &[], &pool);
c.receive(&Writer::new(3, 0).new_id(52).finish(), &[], &pool);
c.receive(&Writer::new(9, 0).new_id(53).int(0).int(24).int(24).int(96).uint(0).finish(), &[], &pool);
c.receive(&Writer::new(51, 0).uint(1).object(52).int(4).int(3).finish(), &[], &pool);
c.receive(&Writer::new(52, 1).object(53).int(0).int(0).finish(), &[], &pool);
c.receive(&Writer::new(52, 8).int(2).finish(), &[], &pool); // set_buffer_scale(2)
let before = c.scene().into_iter().find(|s| s.surface == 52);
assert!(before.is_none() || before.unwrap().buffer_scale == 1, "pending until commit");
c.receive(&Writer::new(52, 6).finish(), &[], &pool);
let s = c.scene().into_iter().find(|s| s.surface == 52).expect("no cursor surface");
assert_eq!((s.buffer_scale, s.client_scale), (2, 2));
}
#[test]
fn an_x11_window_is_a_window_on_the_managers_word() {
let pool = Placed(0x9000, 8192);
+180
View File
@@ -1596,3 +1596,183 @@ The cache saves the **global kernel's module store** and the slot numbering its
- On edger, in the page, measure cold `node -e 0` and `apt install ripgrep tcc libc6-dev`.
- Load twice in one browser profile. The second load runs with `?xcache=1` for "after", and without it for "before". This needs site.mjs to have a mode that reloads with a persistent profile.
- Report translation counts as well as times.
## 9. The demo: a desktop, a small base, the rest on demand (plan, 2026-10-11)
What a visitor opens is one page that looks like a desktop, not a list of
demos. It is its own build target, `web/build-demo.sh` → `web/dist-demo`,
always stripped, and no test runs on it (one smoke leg: the desktop comes
up with both windows).
**The page.** The compositor's output (`compositor.js`, WebGPU scene mode,
which draws window decorations itself) fills the page. The shell chrome --
a panel with the launcher button, a clock and the console toggle -- is DOM,
drawn by the page: it costs the guest's rootfs nothing and needs no guest
process. Two windows are open at start:
- **xterm running bash.** Debian's arm64 xterm (translated) on the page's
X server (`hostX`, `web/public/gex/xwayland/`), its shell our wasm32 bash.
- **The launcher**, a page-side window listing what can be added: gears,
the GTK 3 demo, foot, nvim, node, apt, and the programs of the old demo
list worth keeping. Choosing one downloads it (once), adds it to the
filesystem and runs it.
pid 1 is bash reading commands from the page's terminal (hidden; the panel
shows it as a console). The launcher starts a program by writing its command
line there, so no guest-side launcher exists.
**The base rootfs** is what bash and xterm need and nothing else:
`mkrootfs.py --roots base-files,base-passwd,ncurses-base --extra xterm
--ours bash,mawk,sysvinit-utils` (mawk and sysvinit-utils only satisfy
base-files' and x11-common's dependencies), then pruned of the fonts xterm
does not use (core fonts come from the X server; DejaVu Sans Mono stays for
`-fa`), X11 locales other than C/en_US.UTF-8, and debconf's perl.
**Everything else comes at run time** as zstd-compressed downloads, in one
of two forms, and the numbers decide which the launcher uses:
- (a) a **tar overlay** per launcher item: the item's closure minus the
base, built by `mkrootfs.py` as base+item and diffed against the base. The
page fetches `pkgs/ITEM.tar.zst`, decodes it with zstddec and writes the
files straight into the global kernel's filesystem from a Worker
(`k_fs_add`, `k_fs_symlink`, `k_fs_chmod`), while the guest runs. Its dpkg
status stanzas go along, so a later `apt` sees the item as installed.
- (b) **.deb**: `dpkg -i` of prefetched .debs, or `apt install` from the
network (Wisp). apt and dpkg are themselves an overlay (they are not in
the base).
Measured on edger for two or three items (apt from the network, dpkg -i of a
prefetched .deb, the tar overlay); the expectation is tar overlays for the
launcher and apt for what the user installs, but the measurement decides.
**Stripped, always.** `wasm-tools strip` takes debug sections off every
.wasm (the name section stays: a trap still names its function). Runtime
.wasm files ship only as `.wasm.zst` (`zstd --ultra -22`) and a demo build's
page decodes them with zstddec: one `fetch` wrapper in the page and in every
Worker, so the shim, kernel, providers, Xwayland and Mesa all go through it.
The base rootfs ships as `rootfs/base.tar.zst` (`--ultra -22`).
**Milestones**, each served at a directly openable https URL (100.64.0.3):
1. base rootfs and the stripped build (sizes, compressed and not);
2. the desktop with both windows;
3. the launcher and on-demand packages, with the apt / dpkg / tar numbers;
4. time to desktop and download size, on edger.
### 9.1 As built and measured (2026-10-11)
`web/build-demo.sh` (items: `web/demo-items.json`, overlays:
`tools/gexcc/debian/mkoverlay.py`); the smoke and every number below:
`tests/demo/desktop.mjs` (`DEMO_STEPS`), headless Chromium on edger's
Quadro P620, fresh profile per visit, the dist served from 127.0.0.1 (so
fetch times are local-disk, not a real network).
**Base rootfs** (user, same day: "the base first-load should have coreutils
at least"): bash, dash, xterm and its arm64 closure, and ours coreutils,
grep, sed, findutils, procps, less, ncurses-bin, gzip, tar, util-linux,
hostname, nano, debianutils: **38.8 MiB tree / 38.8 MiB tar / 8.3 MiB zstd**
(bash + xterm alone: 20.6 / 20.4 / 5.7). What each tool adds is listed in
build-demo.sh; the rest (file, gawk, diffutils, zstd, xz, bsdextrautils,
tree: 1.5 MiB) is the launcher's "More tools".
**Runtime modules**, stripped (name section kept) then zstd -22: shim
50.5 → 5.7 → 1.2 MiB, kernel 15.2 → 0.4 → 0.1, Xwayland 2.0 → 2.0 → 0.5,
its bridge 11.9 → 0.2 → 0.05; Mesa (fetched on the first GL item only)
19.6 → 19.6 → 3.3. **First load: 11.4 MiB** (runtime 1.9, rootfs 8.3, page
JS about 1).
**Time to desktop** (navigation → xterm mapped, launcher open): 4.4–5.1 s
over 12 visits (median about 4.6 s). Downloads and unpack are done by about
0.6 s and pid 1 starts by about 0.9 s; the rest is the X server and the
translated xterm/libX11 coming up.
**Launcher items, three ways** (same items, ms; tar = fetch + zstd decode +
write into the running kernel; dpkg/apt = the guest's own clock, after the
apt overlay, 10.1 MiB zstd, 0.4–0.5 s):
| item | packages | tar overlay | dpkg -i of prefetched .debs | apt-get install (network) |
|---|---|---|---|---|
| htop | 1 | 26–41 (0.27 MB) | 1,176–1,381 (.debs 0.25 MB) | – (ours; pinned) |
| x11-apps | 18 | 106–112 (2.5 MB) | 20,218–21,115 (4.8 MB) | 26,138–26,353 |
| neovim | 12 | 317–352 (7.2 MB) | 13,420–13,449 (7.6 MB) | 28,617–28,737 |
`apt-get update` alone (trixie arm64 indexes over Wisp): 9.1–9.3 s on
wss://anura.pro/, 19.0–26.0 s on wss://wisp.mercurywork.shop/. dpkg -i takes 29 to 199 times as long as the overlay
for the same files (maintainer scripts, triggers and dpkg itself run
translated), and apt adds the network on top. So: **the launcher uses tar
overlays; apt stays for what the user installs themselves.** Overlays also
record their packages in dpkg's status, so apt sees launcher items as
installed.
Two fixes the measurements forced: the base needs dash (dpkg refuses to
run without `sh`), and the trees keep debconf (`man-db`'s postinst sources
`/usr/share/debconf/confmodule`). apt installing a Debian package that
depends on one only we build (man-db → bsdextrautils) needs our .debs:
the apt overlay registers the whole pool (56 MB) as lazy files under
/var/lib/gex-apt, fetched from the page only when apt reads one.
### 9.2 What the first users hit, and the fixes (2026-10-12)
- **Blurry output.** The canvas was sized in CSS pixels. It is now sized in
device pixels, and guest Wayland clients are told the rounded
devicePixelRatio as `wl_output.scale`. `set_buffer_scale` is honoured, and
each record carries the buffer and client scales in its flags word, bits
8–15 and 16–23. X windows stay at scale 1 in device pixels, and xterm is
sized through `/root/.Xdefaults` (`Xft.dpi`). Where nothing is scaled, a
draw samples nearest, so a terminal's glyphs are its own pixels.
- **Two pointers.** No cursor is composited any more. The client's cursor
surface (or Xwayland's) becomes the canvas's CSS cursor as a `url()` with
its hotspot, cached by content; a null cursor becomes `none`, and the
desktop and window frames get `default`.
- **glxgears from the xterm: "couldn't get an RGB, Double-buffered visual".**
Debian's glvnd `libGL` had no vendor library, because the Mesa drivers were
pruned from the trees. The gears item maps gex's marshallers onto
Debian's `libGL.so.1`, `libEGL.so.1` and `libGLESv2.so.2`, and the page
starts Mesa on a guest's first GL request (`glLazy`).
- **htop with no processes.** `/proc` listed only `self`. Each process now
tells the process table its name and command line once per image, and
`/proc` lists every pid with `stat` (all 52 fields), `status`, `statm`,
`cmdline` and `comm`, plus `loadavg`.
- **nvim ignored typing.** The pty answered FIONBIO with ENOTTY, so libuv's
`uv_pipe_open` failed on the terminal. FIONBIO and the close-on-exec
ioctls (FIONCLEX, FIOCLEX, FIOASYNC) are now left to the kernel.
- **Terminals and fork.** A fork's child keeps its parent's epoll sets.
`/dev/pts` and `/dev/ptmx` exist; `ttyname` and `tty` work; `/proc/<pid>/stat`
carries `tty_nr` and `tpgid`, so ps's TTY column shows `pts/N`. The pty
master reads EIO once every slave has closed, as on Linux. Before, it
waited until the session leader was reaped, so a reader that only
reaps after EIO (`pty.fork`, read until EIO, then `waitpid`) never
stopped.
- **Known gap: epoll across fork is a copy.** The child rebuilds the set from
its registrations (the same descriptor numbers name the same files). Linux
shares one instance between parent and child. Here an `epoll_ctl` on
either side after the fork is not seen by the other, and the two
`epoll_wait`s do not share wakeups. Real sharing needs the set in the global
kernel, keyed by open file description; it comes with W's kernel-locking
step 2 (per-object wait queues in the global kernel, docs/kernel-locking.md).
- **xterm showing the frame before the one it drew.** The X server in the
page closes a pool's descriptor as soon as it has sent it to the
compositor, and its next pool gets the same number. The compositor was
handed the number, so a buffer of an older pool was located, at its next
commit, in whichever pool held the number now. Typing into the xterm
reuses its two buffers, which then shared one pool's memory: the window
showed the frame before the last until something made a fresh pool
(clicking off and back on did). A cursor pool on the reused number made the
commit fail its size check and be dropped. `web/src/hostx.js` now gives the
compositor its own handle per descriptor sent, as `SCM_RIGHTS` would.
The leg types into the xterm, waits, and compares the X server's copy of
the window (`xwd`, through `tests/demo/guest/xsig.py`) with the
compositor's output. Before the fix every round differed by the last
character and the cursor: 47 dark pixels at dpr 1, 228 at dpr 2. After
the fix every round matches exactly. `?hostxcount=1` logs, once a second,
the bytes the X server took from and sent to each client, its bytes to the
compositor and its scenes published.
- **Known gap: pool memory.** A pool's pixels are never freed: the
compositor may still read them after the X server closes the descriptor.
Xwayland drops a buffer after a second unused and makes a new one, so an
xterm printing a line every 1.3 s allocates about 0.9 MB per line.
Freeing needs the mapping's lifetime (`gex_shm_unmap` in
`web/xwayland/gexos.c` is a no-op) and a release from the compositor.
Legs: `node tests/demo/desktop.mjs web/dist-demo` (the desktop, then the
cursor, presentation and xterm-contents legs; `DEMO_DPR=2` for the device-pixel output).
+6 -1
View File
@@ -1383,6 +1383,7 @@ export class Gex {
this.#libprovConfig();
this.#shim.exports.gex_set_wl?.(this.#wl ? 1 : 0);
if (this.#wlOut) this.#shim.exports.gex_set_wl_output?.(...this.#wlOut);
if (this.#wlOut) this.#shim.exports.gex_set_wl_scale?.(this.#wlScale);
// `kernel.net` is decided inside `gex_load` from this flag, so it must be
// set before it (not merely in `useNetwork`, before the shim exists).
this.#shim.exports.gex_set_network?.(this.#net ? 1 : 0);
@@ -1872,6 +1873,7 @@ export class Gex {
this.#shim.exports.gex_set_gl?.(this.#gl ? 1 : 0);
this.#shim.exports.gex_set_wl?.(this.#wl ? 1 : 0);
if (this.#wlOut) this.#shim.exports.gex_set_wl_output?.(...this.#wlOut);
if (this.#wlOut) this.#shim.exports.gex_set_wl_scale?.(this.#wlScale);
this.#shim.exports.gex_set_network?.(this.#net ? 1 : 0);
// The unit cache and ELF export providers, as `load` gives a process its
// own: a forked child's table starts empty while its GOT holds what
@@ -4140,15 +4142,18 @@ export class Gex {
}
/** Say how large the compositor's output is, before the guest runs. */
wlOutput(w, h) {
wlOutput(w, h, scale = 1) {
// Kept, and given again to every shim instance this process makes: a
// fresh instance (a fork child's, an exec's) starts at the default
// 640x480, and W3 starts every Windows program by fork and exec.
this.#wlOut = [w | 0, h | 0];
this.#wlScale = Math.max(1, scale | 0);
this.#shim?.exports?.gex_set_wl_output?.(w | 0, h | 0);
this.#shim?.exports?.gex_set_wl_scale?.(this.#wlScale);
}
#wlOut = null;
#wlScale = 1;
/**
* This process's name in the compositor's scene arena: its pid, which every
+17
View File
@@ -384,6 +384,23 @@ export class ProcessHost {
return this.#stacks.area + n * this.#stacks.size;
}
/**
* A kernel stack slice for something that is not a guest Worker but calls
* into the global kernel from a realm of its own (the demo's overlay
* Worker, `web/src/overlay.js`). Undefined until the first Worker has said
* where the stacks are. Give it back with `releaseKernelStack(key)`.
*/
takeKernelStack(key) {
return this.#nextKernelStack(`ext:${key}`);
}
releaseKernelStack(key) {
const slice = this.#stackOf.get(`ext:${key}`);
if (slice === undefined) return;
this.#stackOf.delete(`ext:${key}`);
this.#stacksFree.push(slice);
}
/** A Worker has ended: stop it, and put its kernel stack back. */
#release(key, w) {
this.#workers.delete(key);
+7 -2
View File
@@ -89,6 +89,8 @@ export const EV_MOVED = 10;
// meta: what the compositor decided, read by every process.
const M_OUT_W = 0;
const M_OUT_H = 1;
/** Device pixels per surface unit for guest Wayland clients (wl_output.scale). */
const M_SCALE = 2;
const M_WORDS = 8;
// Block header: the token that claimed it, a heartbeat so a dead process's
@@ -122,15 +124,17 @@ export function makeWlChannel() {
}
/** Tell the guests how large the output is, so a toplevel is configured to it. */
export function setOutputSize(channel, w, h) {
export function setOutputSize(channel, w, h, scale) {
const a = new Int32Array(channel.arena);
Atomics.store(a, M_OUT_W, w | 0);
Atomics.store(a, M_OUT_H, h | 0);
if (scale) Atomics.store(a, M_SCALE, Math.max(1, scale | 0));
}
export function outputSize(channel) {
const a = new Int32Array(channel.arena);
return { w: Atomics.load(a, M_OUT_W) || 640, h: Atomics.load(a, M_OUT_H) || 480 };
return { w: Atomics.load(a, M_OUT_W) || 640, h: Atomics.load(a, M_OUT_H) || 480,
scale: Atomics.load(a, M_SCALE) || 1 };
}
const decoder = new TextDecoder();
@@ -145,6 +149,7 @@ const titleBytes = new Uint8Array(R_TITLE_WORDS * 4);
* light per surface and does no work at all for a block that has not changed
* since the caller last acked it -- `changed` says which those were.
*/
export function readScene(channel, { onlyChanged = false } = {}) {
const a = new Int32Array(channel.arena);
const out = [];
+1 -1
View File
@@ -478,7 +478,7 @@ async function handle(msg) {
gex.useWl(proxy);
// The output size a toplevel is configured to, before the guest runs.
const out = outputSize(msg.wlChannel);
gex.wlOutput(out.w, out.h);
gex.wlOutput(out.w, out.h, out.scale);
// The compositor reads this process's pixels out of this memory, so it
// needs the memory itself. Sent once, with the token that names this
// process's block in the arena.
+59 -4
View File
@@ -250,6 +250,8 @@ extern "C" {
fn k_setpgid(caller: i32, target: i32, pgid: i32) -> i32;
fn k_request_halt(code: i32);
fn k_halt_code() -> i32;
fn k_proc_set_info(pid: i32, at: gex_kernel::PAddr, len: u32);
fn k_proc_snapshot(at: gex_kernel::PAddr, cap: u32) -> i32;
// The filesystem. Every address below is in *this* process's memory; the
// other side copies across with `memory.copy`, which is the only way it
@@ -645,6 +647,12 @@ extern "C" fn r_request_halt(code: i32) {
extern "C" fn r_halt_code() -> i32 {
unsafe { k_halt_code() }
}
extern "C" fn r_set_info(pid: i32, at: gex_kernel::PAddr, len: u32) {
unsafe { k_proc_set_info(pid, at, len) }
}
extern "C" fn r_snapshot(at: gex_kernel::PAddr, cap: u32) -> i32 {
unsafe { k_proc_snapshot(at, cap) }
}
/// The process table every process shares, if the host gave us one.
///
@@ -676,6 +684,8 @@ fn global_procs() -> Option<Arc<gex_kernel::proc::ProcTable>> {
setpgid: r_setpgid,
request_halt: r_request_halt,
halt_code: r_halt_code,
set_info: r_set_info,
snapshot: r_snapshot,
},
)))
}
@@ -1374,6 +1384,22 @@ impl FileHandle for StdinQueue {
fn mount_devices(kernel: &mut Kernel) -> Option<crate::wl::WaylandDevice> {
let mut devfs = gex_kernel::vfs::DevFs::new();
devfs.add("tty", Arc::new(Tty));
if unsafe { k_present() } != 0 {
// `/dev/pts`: the pairs some process still holds an end of (the global
// kernel numbers them from 0 and the id is the pts number).
devfs.add_pts(Arc::new(|| {
let mut out = Vec::new();
for id in 0.. {
if unsafe { k_pty_ptn(id) } < 0 {
break;
}
if unsafe { k_pty_counts(id) } != 0 {
out.push(id as u32);
}
}
out
}));
}
if crate::gl::have_gl() {
devfs.add("gex-gl", crate::gl::GlDevice::new());
}
@@ -7814,8 +7840,10 @@ fn stage_fork_fds(h: &Host, child: i32) {
/// copy looks like the last reference and the reader sees a false EOF. A file
/// the global kernel holds goes as a duplicated id, which survives the unlink
/// of a file the receiver would otherwise fail to reopen by name; anything
/// else with a path, by path. `None`: nothing with a name -- a local socket,
/// an epoll set.
/// else with a path, by path. An epoll set goes as its registrations (the
/// same descriptor numbers name the same files in a fork's child): a copy, so
/// an `epoll_ctl` on one side after the fork is not seen by the other, where
/// Linux shares the one set. `None`: nothing with a name -- a local socket.
fn carry_name(k: &gex_kernel::syscalls::Kernel, file: &gex_kernel::file::OpenFile, holder: i32) -> Option<String> {
if let Some(id) = pipe_id(&file.path) {
unsafe { k_pipe_open_end(id, file.writable as i32, holder) };
@@ -7841,9 +7869,20 @@ fn carry_name(k: &gex_kernel::syscalls::Kernel, file: &gex_kernel::file::OpenFil
let dup = sock.dup_id()?;
return Some(format!("sock:{dup}:{}", sock.listening as i32));
}
if let Some(set) = k.epoll_of_file(file) {
let key = Arc::as_ptr(&file.handle) as *const () as usize;
return Some(format!("epoll:{key}:{}", set.describe()));
}
file.path.starts_with('/').then(|| file.path.clone())
}
thread_local! {
/// While `gex_adopt_fds` runs: the epoll sets rebuilt so far, by the
/// parent's key, so two descriptors for one set stay one set.
static ADOPTED_EPOLLS: RefCell<Option<std::collections::HashMap<String, Arc<gex_kernel::file::OpenFile>>>> =
const { RefCell::new(None) };
}
/// The console descriptors, rebuilt in a child from the name alone. A fork
/// stages each descriptor as a path for the child to reopen, which works for a
/// file but not the terminal -- a Worker's own `Console`, named `/dev/stdin`
@@ -7906,7 +7945,8 @@ pub extern "C" fn gex_adopt_fds(ptr: u32, len: u32) -> i32 {
return -1;
};
let text = text.to_string();
with_host(|h| {
ADOPTED_EPOLLS.with(|m| *m.borrow_mut() = Some(Default::default()));
let n = with_host(|h| {
let mut n = 0;
for line in text.lines() {
let mut parts = line.splitn(5, ' ');
@@ -7937,7 +7977,9 @@ pub extern "C" fn gex_adopt_fds(ptr: u32, len: u32) -> i32 {
}
}
n
})
});
ADOPTED_EPOLLS.with(|m| *m.borrow_mut() = None);
n
}
/// The other half of [`carry_name`]: the open file a name stands for, in
@@ -7985,6 +8027,19 @@ fn rebuild_carried(
);
f.set_offset(offset);
f
} else if let Some(rest) = path.strip_prefix("epoll:") {
let (key, watches) = rest.split_once(':').ok_or("bad epoll name")?;
let watches = gex_kernel::unix::EpollSet::parse(watches).ok_or("bad epoll registrations")?;
if let Some(f) = ADOPTED_EPOLLS.with(|m| m.borrow().as_ref().and_then(|m| m.get(key).cloned())) {
return Ok(f);
}
let f = k.adopt_epoll(watches, flags);
ADOPTED_EPOLLS.with(|m| {
if let Some(m) = m.borrow_mut().as_mut() {
m.insert(key.to_string(), Arc::clone(&f));
}
});
f
} else if let Some(rest) = path.strip_prefix("sock:") {
let r = global_sockets().ok_or("no global kernel")?;
let (id, listening) = rest.split_once(':').ok_or("bad socket name")?;
+13 -1
View File
@@ -192,6 +192,15 @@ fn output_size() -> (i32, i32) {
unsafe { OUTPUT }
}
static OUTPUT_SCALE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
/// Device pixels per surface unit (`wl_output.scale`): the page's
/// devicePixelRatio, rounded, when its output is in device pixels.
#[no_mangle]
pub extern "C" fn gex_set_wl_scale(scale: i32) {
OUTPUT_SCALE.store(scale.clamp(1, 8), std::sync::atomic::Ordering::Relaxed);
}
/// This process's name in the compositor's scene arena: its pid.
///
/// It has to be the *process's* and not the Worker's. A process's threads are
@@ -672,7 +681,9 @@ fn push_record(out: &mut Vec<i32>, client: u32, info: &SurfaceInfo) {
r[13] = info.damage.y;
r[14] = info.damage.w;
r[15] = info.damage.h;
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1);
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1)
| ((info.buffer_scale & 0xff) << 8)
| ((info.client_scale & 0xff) << 16);
// Which X window this is, for a host that knows a drawable and not a
// surface -- an OpenGL client naming what it draws into.
r[17] = info.xid as i32;
@@ -695,6 +706,7 @@ impl HostListener for WaylandDevice {
let mut client = Client::new();
let (w, h) = output_size();
client.set_default_size(w, h);
client.set_scale(OUTPUT_SCALE.load(std::sync::atomic::Ordering::Relaxed));
let idx = {
let mut n = self.inner.next_idx.lock().unwrap();
*n += 1;
+3 -1
View File
@@ -478,7 +478,9 @@ fn push_record(out: &mut Vec<i32>, client: u32, info: &SurfaceInfo) {
r[13] = info.damage.y;
r[14] = info.damage.w;
r[15] = info.damage.h;
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1);
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1)
| ((info.buffer_scale & 0xff) << 8)
| ((info.client_scale & 0xff) << 16);
// The X window this surface is, for a GL client that names a drawable.
r[17] = info.xid as i32;
let mut n = info.title.len().min(TITLE_BYTES);
+32 -4
View File
@@ -1820,6 +1820,30 @@ pub extern "C" fn k_halt_code() -> i32 {
procs().halt_code().unwrap_or(-1)
}
/// A process's name and command line, for the others' `/proc/<pid>`.
#[no_mangle]
pub extern "C" fn k_proc_set_info(pid: i32, at: gex_kmem::PAddr, len: u32) {
let len = len.min(4096);
let mut buf = vec![0u8; len as usize];
if len > 0 {
unsafe { gex_kmem::pull_checked(buf.as_mut_ptr(), at, len) };
}
procs().set_info(pid, &buf);
}
/// Every process, encoded (`gex_kernel::proc::encode_entries`), into up to
/// `cap` bytes at `at`. Returns the whole length: more than `cap` means ask
/// again with that much room.
#[no_mangle]
pub extern "C" fn k_proc_snapshot(at: gex_kmem::PAddr, cap: u32) -> i32 {
let b = gex_kernel::proc::encode_entries(&procs().entries());
let n = (b.len() as u32).min(cap);
if n > 0 {
unsafe { gex_kmem::push_checked(at, b.as_ptr(), n) };
}
b.len() as i32
}
// -- pipes -------------------------------------------------------------------
//
// A pipe crosses processes as a *number*: each file table holds an id and the
@@ -2192,9 +2216,13 @@ pub extern "C" fn k_pty_forget(pid: i32) -> u32 {
/// above opens *before* `TIOCSCTTY`, when no session owns it yet. Either one
/// alive means the slave is merely between descriptors.
fn hangup_is_real(p: &gex_kernel::pty::Pty, id: i32) -> bool {
// Alive, not merely in the table: a session leader that exited is a
// zombie until its parent waits, and the parent waiting is often the very
// reader that needs the hangup to stop reading (`pty.fork`, then read the
// master until EIO, then `waitpid`).
let sid = p.tty.sid();
if sid > 0 {
return !procs().exists(sid);
return !procs().alive(sid);
}
let who = last_slave_closer()
.lock()
@@ -2202,7 +2230,7 @@ fn hangup_is_real(p: &gex_kernel::pty::Pty, id: i32) -> bool {
.get(&id)
.copied();
match who {
Some(pid) if pid > 0 => !procs().exists(pid),
Some(pid) if pid > 0 => !procs().alive(pid),
// Nobody has closed one yet, or the closer is unknown: the old rule.
_ => true,
}
@@ -2243,10 +2271,10 @@ pub extern "C" fn k_pty_read(id: i32, master: i32, at: gex_kmem::PAddr, len: u32
let mut buf = vec![0u8; len as usize];
let r = if master != 0 {
match p.master_read(&mut buf) {
// End of file, but the session that owns the slave is still there:
// Hung up, but the session that owns the slave is still there:
// this is the reopen window, not a hangup. See
// `hangup_is_real`.
Ok(0) if !hangup_is_real(&p, id) => Err(gex_kernel::errno::Errno(11)),
Err(e) if e == gex_kernel::errno::Errno::EIO && !hangup_is_real(&p, id) => Err(gex_kernel::errno::Errno(11)),
other => other,
}
} else {
+540
View File
@@ -0,0 +1,540 @@
// The demo desktop in a real browser: the demo build's one test (docs/gexcc.md §9).
//
// node tests/demo/desktop.mjs [DIST] (default web/dist-demo)
//
// Serves DIST (COOP/COEP, on 127.0.0.1), opens it in headless Chromium and
// waits for the desktop: both windows up (the compositor has mapped the
// xterm; the launcher is the page's). Prints the page's marks (ms from
// navigation): runtime fetched, rootfs fetched, system up, desktop.
//
// DEMO_STEPS, a JSON list, then drives it (the measurements for §9):
// {"launch": "ID"} the launcher's item ID (download + overlay + run)
// {"install": "ID"} only its overlay, timed
// {"run": "CMD", "label": "L"} CMD in pid 1's session, timed in the guest
// ($EPOCHREALTIME); waits for it to end
// {"windows": N} wait for N mapped windows
// {"hashes": N, "every": MS} N readouts of the output MS apart with no
// input; "expect": "change" fails if all equal
// {"cursor": "none|default|image"} the canvas's CSS cursor after a pointer move
// onto the focused window (no composited one)
// {"move": [X, Y]} the pointer to CSS pixel X, Y
// {"click": [X, Y]} a left click there
// {"keys": ["Escape", ...]} named keys (KeyboardEvent.code)
// {"truth": true, "rounds": N} type into the xterm, then compare the X server's
// copy of it (xwd) with the compositor's output
// (needs the python and xeyes overlays)
// A run step with "show": true prints the command's output tail even on success.
// Env: CHROME, DEMO_WALL (s, default 300), DEMO_WEBGPU=sw (SwiftShader),
// DEMO_QUERY (appended to the URL), DEMO_PROFILE (a profile dir to keep),
// DEMO_DPR (--force-device-scale-factor), DEMO_CONSOLE (a file for every
// console line), DEMO_CHROME_FLAGS (more browser flags, space-separated).
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync, mkdtempSync, rmSync } from 'node:fs';
import { join, extname, resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { Cdp, browserWsUrl } from '../../web/test/cdp.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const DIST = resolve(process.argv[2] || join(HERE, '../../web/dist-demo'));
const CHROME = process.env.CHROME || '/usr/bin/chromium';
const WALL = Number(process.env.DEMO_WALL || 300) * 1000;
const STEPS = JSON.parse(process.env.DEMO_STEPS || '[]');
const t0 = Date.now();
const log = (s) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${s}`);
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
'.wasm': 'application/wasm', '.json': 'application/json', '.zst': 'application/zstd' };
let served = 0;
const server = createServer((req, res) => {
let p = join(DIST, decodeURIComponent(new URL(req.url, 'http://x').pathname));
if (!p.startsWith(DIST)) return res.writeHead(403).end();
if (existsSync(p) && statSync(p).isDirectory()) p = join(p, 'index.html');
if (!existsSync(p)) p = join(DIST, 'index.html');
const size = statSync(p).size;
served += size;
res.writeHead(200, { 'Content-Type': TYPES[extname(p)] || 'application/octet-stream', 'Content-Length': size,
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Resource-Policy': 'same-origin', 'Cache-Control': 'no-cache' });
createReadStream(p).pipe(res);
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/${process.env.DEMO_QUERY ? `?${process.env.DEMO_QUERY}` : ''}`;
const prof = process.env.DEMO_PROFILE || mkdtempSync(join(tmpdir(), 'gex-desktop-'));
const port = 9400 + Math.floor(Math.random() * 500);
const sw = process.env.DEMO_WEBGPU === 'sw';
const chrome = spawn(CHROME, [
// DEMO_HEADED=1: not headless mode but the full browser on Ozone's headless
// platform -- the display compositor a person's browser has, whose
// screenshots hold the WebGPU canvas (headless mode's do not).
...(process.env.DEMO_HEADED ? ['--ozone-platform=headless'] : ['--headless']), '--no-sandbox', '--disable-gpu-sandbox', '--enable-unsafe-webgpu',
...(sw ? ['--use-angle=swiftshader-webgl', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader']
: ['--use-angle=vulkan', '--enable-features=Vulkan']),
...(process.env.DEMO_DPR ? [`--force-device-scale-factor=${process.env.DEMO_DPR}`] : []),
...(process.env.DEMO_CHROME_FLAGS ? process.env.DEMO_CHROME_FLAGS.split(/\s+/).filter(Boolean) : []),
'--window-size=1280,800', '--enable-logging=stderr', '--v=0',
`--remote-debugging-port=${port}`, '--remote-allow-origins=*', `--user-data-dir=${prof}`, 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
chrome.stderr.on('data', (d) => (stderr += d));
const cleanup = () => {
try { chrome.kill('SIGKILL'); } catch {}
if (!process.env.DEMO_PROFILE) try { rmSync(prof, { recursive: true, force: true }); } catch {}
};
process.on('exit', cleanup);
const wall = setTimeout(() => {
log(`FAIL wall of ${WALL / 1000}s`);
dump();
process.exit(1);
}, WALL);
let ws = null;
for (let i = 0; i < 100 && !ws; i++) {
ws = await browserWsUrl(port);
if (!ws) await new Promise((r) => setTimeout(r, 100));
}
if (!ws) throw new Error('no browser');
const b = await Cdp.connect(ws);
let page = null;
for (let i = 0; i < 50 && !page; i++) {
const { targetInfos } = await b.send('Target.getTargets');
page = targetInfos.find((t) => t.type === 'page');
if (!page) await new Promise((r) => setTimeout(r, 100));
}
if (!page) page = { targetId: (await b.send('Target.createTarget', { url: 'about:blank' })).targetId };
const { sessionId: s } = await b.send('Target.attachToTarget', { targetId: page.targetId, flatten: true });
const consoleLines = [];
b.on('Runtime.consoleAPICalled', (p) => {
const line = p.args.map((a) => a.value ?? a.description ?? '').join(' ');
consoleLines.push(line);
if (/\[desktop\]|\[hostx-count\]|error|Error/.test(line) && p.type !== 'debug') log(`console: ${line.slice(0, 300)}`);
});
b.on('Runtime.exceptionThrown', (p) => log(`exception: ${p.exceptionDetails?.exception?.description?.slice(0, 400)}`));
await b.send('Runtime.enable', {}, s);
await b.send('Page.enable', {}, s);
const ev = async (expr) => (await b.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }, s)).result?.value;
const until = async (expr, what, ms = WALL) => {
const end = Date.now() + ms;
for (;;) {
const v = await ev(expr).catch(() => undefined);
if (v) return v;
const err = await ev('globalThis.gexDesktop?.error').catch(() => undefined);
if (err) throw new Error(`the page failed: ${err}`);
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await new Promise((r) => setTimeout(r, 200));
}
};
async function shot(path, after) {
await new Promise((r) => setTimeout(r, after));
const { data } = await b.send('Page.captureScreenshot', { format: 'png' }, s);
const fs = await import('node:fs');
fs.writeFileSync(path, Buffer.from(data, 'base64'));
// And the compositor's own output, read back: a headless screenshot may
// not hold a WebGPU canvas.
const r = await ev('globalThis.__gexCompositor?.readoutBase64?.()');
if (r) fs.writeFileSync(path.replace(/\.png$/, '.out.png'), png(r.w, r.h, Buffer.from(r.b64, 'base64')));
log(`screenshot ${path}${r ? ` and the compositor's ${r.w}x${r.h} output` : ''}`);
}
function png(w, h, rgba) {
const zlib = globalThis.process.getBuiltinModule('node:zlib');
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const crc = (buf) => { let c = 0xffffffff; for (const x of buf) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const chunk = (type, data) => { const l = Buffer.alloc(4); l.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]); };
const raw = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
/** A character's `KeyboardEvent.code`, and whether it needs Shift (US layout). */
function keyCode(ch) {
if (/[a-z]/.test(ch)) return [`Key${ch.toUpperCase()}`, false];
if (/[A-Z]/.test(ch)) return [`Key${ch}`, true];
if (/[0-9]/.test(ch)) return [`Digit${ch}`, false];
const plain = { ' ': 'Space', '-': 'Minus', '/': 'Slash', '.': 'Period', '=': 'Equal', ';': 'Semicolon',
',': 'Comma', "'": 'Quote', '`': 'Backquote', '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash' };
if (plain[ch]) return [plain[ch], false];
const shifted = { '|': 'Backslash', '_': 'Minus', '+': 'Equal', ':': 'Semicolon', '"': 'Quote', '~': 'Backquote',
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5', '^': 'Digit6', '&': 'Digit7',
'*': 'Digit8', '(': 'Digit9', ')': 'Digit0', '<': 'Comma', '>': 'Period', '?': 'Slash', '{': 'BracketLeft', '}': 'BracketRight' };
return [shifted[ch] ?? '', !!shifted[ch]];
}
/** The first window's centre and an empty spot of desktop, in CSS pixels. */
async function places() {
return ev(`(() => {
const c = document.getElementById('out').getBoundingClientRect();
const k = c.width / document.getElementById('out').width || 1;
return globalThis.__gexCompositor.describe().then((d) => {
const m = /role=1 (\\d+)x(\\d+)@(-?\\d+),(-?\\d+)/.exec(d.list.join(' '));
if (!m) return null;
const [w, h, x, y] = m.slice(1).map(Number);
return { win: [c.left + (x + w / 2) * k, c.top + (y + h / 2) * k],
desk: [c.right - 40, c.bottom - 40], k };
});
})()`);
}
/** The page's screenshot, hashed: what the browser displays, where its
* screenshots can see a worker's WebGPU canvas (headless ones cannot). */
async function screenHash() {
const { data } = await b.send('Page.captureScreenshot', { format: 'png' }, s);
let h = 2166136261;
for (let i = 0; i < data.length; i++) h = Math.imul(h ^ data.charCodeAt(i), 16777619) >>> 0;
return h;
}
/** What the page's canvas holds now: the placeholder's last frame, drawn
* into a 2D canvas on the page and hashed. A canvas handed to a worker shows
* what the worker last committed; this reads that, not the worker's scene. */
async function canvasHash() {
return ev(`(() => {
const c = document.getElementById('out');
const t = new OffscreenCanvas(c.width, c.height);
const g = t.getContext('2d', { willReadFrequently: true });
g.drawImage(c, 0, 0);
const d = g.getImageData(0, 0, c.width, c.height).data;
let h = 2166136261, lit = 0;
for (let i = 0; i < d.length; i += 4) {
h = Math.imul(h ^ d[i] ^ (d[i + 1] << 8) ^ (d[i + 2] << 16), 16777619) >>> 0;
if (d[i] + d[i + 1] + d[i + 2] > 600) lit++;
}
return h + ':' + lit;
})()`);
}
/** One key into the focused window, as the page gets it from a person. */
async function typeKey(ch) {
if (ch === '\n') {
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
return;
}
const [code, shift] = keyCode(ch);
const m = shift ? 8 : 0;
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
}
async function click(x, y) {
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, s);
}
/** A command in pid 1's session (not the xterm's); its output's lines. */
async function guestRun(cmd, wall = 120000) {
const tag = `g${Math.floor(Math.random() * 1e9)}`;
await ev(`globalThis.gexDesktop.run(${JSON.stringify(`{ ${cmd} ; } >/tmp/${tag}.out 2>&1; echo "${tag} rc=$?"; cat /tmp/${tag}.out; echo "${tag} end"`)})`);
await until(`globalThis.gexTerminalText().includes(${JSON.stringify(`${tag} end`)})`, cmd.slice(0, 40), wall);
const t = (await ev('globalThis.gexTerminalText()')) || '';
const body = t.slice(t.search(new RegExp(`${tag} rc=`)));
const rc = Number(/rc=(\d+)/.exec(body)[1]);
return { rc, lines: body.split('\n').slice(1).filter((l) => !l.includes(`${tag} end`)) };
}
const SIG_COLS = 16, SIG_ROWS = 8, SIG_EDGE = 2;
/** The same signature as tests/demo/guest/xsig.py, of what the compositor
* composed where the window is. */
async function composedSig(x, y, w, h) {
const r = await ev('globalThis.__gexCompositor.readoutBase64()');
const px = Buffer.from(r.b64, 'base64');
const cells = new Array(SIG_COLS * SIG_ROWS).fill(0);
let exact = 2166136261;
for (let j = 0; j < h; j++) {
const cy = Math.floor(j * SIG_ROWS / h) * SIG_COLS;
for (let i = 0; i < w; i++) {
const o = ((y + j) * r.w + (x + i)) * 4;
const dark = Math.max(px[o], px[o + 1], px[o + 2]) < 128 ? 1 : 0;
if (dark) cells[cy + Math.floor(i * SIG_COLS / w)]++;
exact = Math.imul(exact ^ dark, 16777619) >>> 0;
}
}
cells.exact = exact;
return cells;
}
/**
* Bug 3 as a leg: what the xterm drew is what the page shows. Type into the
* focused xterm at a person's pace -- each key a frame, so Xwayland draws into
* the buffers it already has rather than making new ones -- then leave it
* alone, and compare the X server's own copy of the window (xwd, in the guest)
* with the compositor's output where the window is.
*/
async function truth(step) {
const script = (await import('node:fs')).readFileSync(new URL('./guest/xsig.py', import.meta.url)).toString('base64');
const put = await guestRun(`echo ${script} | base64 -d > /tmp/xsig.py`);
if (put.rc) throw new Error('could not write /tmp/xsig.py');
const p = await places();
await click(...p.win);
await new Promise((r) => setTimeout(r, 1000));
let bad = 0;
const rounds = step.rounds ?? 4;
for (let n = 0; n < rounds; n++) {
for (const ch of `echo round ${n} the quick brown fox ${'x'.repeat(n * 3)}\n`) {
await typeKey(ch);
await new Promise((r) => setTimeout(r, step.pace ?? 60));
}
// The pointer across the window's edge and back: the X server makes a
// cursor pool for each shape it shows, between the xterm's frames.
for (let i = 0; i < (step.wiggle ?? 3); i++) {
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.desk[0], y: p.desk[1] }, s);
await new Promise((r) => setTimeout(r, 150));
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.win[0], y: p.win[1] }, s);
await new Promise((r) => setTimeout(r, 150));
await typeKey(String.fromCharCode(97 + i));
}
await new Promise((r) => setTimeout(r, step.settle ?? 2000));
const d = await ev('globalThis.__gexCompositor.describe()');
const line = d.list.find((l) => / role=1 /.test(l));
const [, w, h, x, y] = /(\d+)x(\d+)@(-?\d+),(-?\d+)/.exec(line).map(Number);
const title = d.titles[0];
const got = await guestRun(`python3.13 /tmp/xsig.py ${JSON.stringify(title)}`);
const size = got.lines.find((l) => l.startsWith('xsize'));
if (got.rc || !size) throw new Error(`xsig: rc=${got.rc} ${got.lines.slice(0, 4).join(' | ')}`);
const drawn = [];
for (let r = 0; r < SIG_ROWS; r++) {
const l = got.lines.find((v) => v.startsWith(`xs${r} `));
drawn.push(...l.slice(l.indexOf(' ') + 1).split(',').map(Number));
}
// The X window inside the surface: Xwayland's buffer carries the
// window's border, which xwd leaves out -- so the best of a few offsets.
const [xw, xh, xexact] = size.split(' ').slice(1).map(Number);
let best = null;
for (const [dx, dy] of [[0, 0], [1, 1], [-1, -1], [1, 0], [0, 1], [2, 2]]) {
const sig = await composedSig(x + dx + SIG_EDGE, y + dy + SIG_EDGE, xw - 2 * SIG_EDGE, xh - 2 * SIG_EDGE);
const d = drawn.reduce((a, v, i) => a + Math.abs(v - sig[i]), 0) + (sig.exact === xexact ? 0 : 0.5);
if (!best || d < best.d) best = { d, sig, at: `${dx},${dy}` };
if (!d) break;
}
const shown = best.sig;
const diff = best.d;
const cells = drawn.filter((v, i) => v !== shown[i]).length;
// Equal counts and an equal bitmap: a cursor block one character along
// stays in its cell, and only the bitmap tells.
const ok = cells === 0 && shown.exact === xexact;
if (!ok) bad++;
if (!ok && process.env.DEMO_SIG) {
for (let r = 0; r < SIG_ROWS; r++) {
log(` X ${drawn.slice(r * SIG_COLS, (r + 1) * SIG_COLS).join(',')}`);
log(` C ${shown.slice(r * SIG_COLS, (r + 1) * SIG_COLS).join(',')}`);
}
}
log(` round ${n}: X window ${size.split(' ').slice(1).join('x')} vs composed ${w}x${h}@${x},${y}: `
+ `${ok ? 'same' : `${cells} of ${drawn.length} cells differ (${Math.floor(diff)} dark pixels), bitmaps ${shown.exact === xexact ? 'equal' : 'differ'}`} at +${best.at} (serial ${/serial=(\d+)/.exec(line)[1]})`);
}
if (bad) failed = true;
log(`${bad ? 'FAIL' : 'ok'} xterm shows what it drew: ${rounds - bad} of ${rounds} rounds`);
}
async function legs() {
const p = await places();
if (!p) throw new Error('no window to point at');
const cursorAt = async ([x, y], want) => {
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
let css = '';
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 150));
css = (await ev("getComputedStyle(document.getElementById('out')).cursor")) || '';
if (want.test(css)) break;
}
return css;
};
const overWin = await cursorAt(p.win, /url\(|image-set/);
const overDesk = await cursorAt(p.desk, /^default$/);
const okCursor = /url\(|image-set/.test(overWin) && overDesk === 'default';
if (!okCursor) failed = true;
log(`${okCursor ? 'ok' : 'FAIL'} cursor leg: over the xterm ${JSON.stringify(overWin.slice(0, 60))}, over the desktop ${JSON.stringify(overDesk)}`);
// Back over the xterm, focused, and a loop that prints with no input.
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.win[0], y: p.win[1] }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.win[0], y: p.win[1], button: 'left', clickCount: 1 }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p.win[0], y: p.win[1], button: 'left', clickCount: 1 }, s);
// The shell may still be starting: wait for its prompt to draw.
let prev = await canvasHash();
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const h = await canvasHash();
if (h === prev) break;
prev = h;
}
for (const ch of 'for i in 1 2 3 4 5; do sleep 1; echo tick$i; done\n') {
if (ch === '\n') {
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
continue;
}
const [code, shift] = keyCode(ch);
const m = shift ? 8 : 0;
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
}
await new Promise((r) => setTimeout(r, 1500));
const d0 = await ev('globalThis.__gexCompositor.describe()');
// What the page's canvas holds (its last committed frame, drawn into a 2D
// canvas), not a screenshot: headless Chromium's screenshots never contain
// a WebGPU canvas a worker draws, so they cannot tell shown from not.
const held = [await canvasHash()];
const shown = [await screenHash()];
for (let i = 0; i < 4; i++) {
await new Promise((r) => setTimeout(r, 1000));
held.push(await canvasHash());
shown.push(await screenHash());
}
const shots = shown.slice(1).filter((h, i) => h !== shown[i]).length;
const d1 = await ev('globalThis.__gexCompositor.describe()');
const changes = held.slice(1).filter((h, i) => h !== held[i]).length;
const okShown = changes >= 3;
if (!okShown) failed = true;
log(`${okShown ? 'ok' : 'FAIL'} presentation leg: the page's canvas changed ${changes} times in 4 s of the xterm printing with no input`
+ ` (compositor: ${d1.frames - d0.frames} frames drawn in ${d1.turns - d0.turns} turns; screenshots changed ${shots} times)`);
// What the xterm drew is what the page shows (bug 3): xwd and python come
// as overlays, the way a person adds them from the launcher.
await new Promise((r) => setTimeout(r, 3000));
for (const id of ['python', 'xeyes']) await ev(`globalThis.gexDesktop.install(${JSON.stringify(id)})`);
await truth({ rounds: 3 });
}
function dump() {
ev('globalThis.gexTerminalText?.()').then((t) => {
console.log('--- console (pid 1) ---');
console.log((t || '').trim().split('\n').slice(-40).join('\n'));
}).catch(() => {});
}
let failed = false;
try {
log(`open ${url}`);
await b.send('Page.navigate', { url }, s);
await until('globalThis.gexDesktop?.ready', 'the desktop');
const marks = await ev('globalThis.gexDesktop.marks');
const rootfs = await ev('globalThis.gexDesktop.rootfs');
const desc = await ev('globalThis.__gexCompositor?.describe?.()');
const launcherShown = await ev("!document.getElementById('launcher').classList.contains('hidden')");
log(`ok desktop: ${JSON.stringify(marks)}; served ${(served / 1048576).toFixed(2)} MiB; ` +
`rootfs ${JSON.stringify(rootfs)}; launcher ${launcherShown ? 'open' : 'MISSING'}`);
if (desc) log(`compositor: ${JSON.stringify(desc).slice(0, 400)}`);
if (!launcherShown) throw new Error('the launcher window is not open');
if (process.env.DEMO_SHOT) await shot(process.env.DEMO_SHOT, 1500);
// The legs every run has (DEMO_LEGS=0 skips them): the xterm's cursor is
// the page's CSS cursor and none is composited; what the xterm prints with
// no input reaches the page's canvas; what it draws as it is typed into is
// what the compositor shows (the X server's copy against the output).
if (process.env.DEMO_LEGS !== '0') await legs();
let n = 0;
let lastHash = null;
for (const step of STEPS) {
n++;
if (step.windows) {
try {
await until(`(globalThis.__gexCompositor?.counters?.().windows ?? 0) >= ${step.windows}`, `${step.windows} windows`,
(step.wall ?? WALL / 1000) * 1000);
log(`ok ${step.windows} windows`);
} catch (e) {
failed = true;
log(`FAIL ${e.message}`);
if (!step.soft) throw e;
}
} else if (step.hashes) {
// Counted with the last readout of the step before, so a change that
// lands before this step's first readout still counts. "screen": the
// page's own screenshot (what is presented), not the compositor's readout.
const seen = lastHash === null ? [] : [lastHash];
for (let i = 0; i < step.hashes; i++) {
await new Promise((r) => setTimeout(r, step.every ?? 1000));
if (step.screen) {
const { data } = await b.send('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 40, width: 600, height: 400, scale: 1 } }, s);
let h = 2166136261;
for (let k = 0; k < data.length; k++) h = ((h ^ data.charCodeAt(k)) * 16777619) >>> 0;
seen.push(h);
} else {
const d = await ev('globalThis.__gexCompositor?.describe?.()');
seen.push(d?.hash ?? 0);
}
}
lastHash = seen[seen.length - 1];
const distinct = new Set(seen).size;
const ok = step.expect !== 'change' || distinct > 1;
if (!ok) failed = true;
log(`${ok ? 'ok' : 'FAIL'} ${step.label ?? 'hashes'}: ${distinct} distinct of ${seen.length} readouts ${step.every ?? 1000} ms apart`);
} else if (step.click) {
const [x, y] = step.click;
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, s);
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, s);
await new Promise((r) => setTimeout(r, 300));
log(`clicked ${step.click}`);
} else if (step.move) {
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: step.move[0], y: step.move[1] }, s);
await new Promise((r) => setTimeout(r, 300));
log(`moved to ${step.move}`);
} else if (step.cursor) {
await new Promise((r) => setTimeout(r, step.after ?? 1500));
const css = (await ev("getComputedStyle(document.getElementById('out')).cursor")) || '';
const kind = css.startsWith('url(') || css.includes('image-set') ? 'image' : css;
const ok = kind === step.cursor;
if (!ok) failed = true;
log(`${ok ? 'ok' : 'FAIL'} cursor ${JSON.stringify(css.slice(0, 80))} (want ${step.cursor})`);
} else if (step.truth) {
await truth(step);
} else if (step.keys) {
await ev("document.getElementById('out').focus()");
for (const code of step.keys) {
const key = code === 'Escape' ? 'Escape' : code === 'Enter' ? 'Enter' : code;
const vk = { Escape: 27, Enter: 13 }[code] ?? 0;
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: vk, ...(code === 'Enter' ? { text: '\r' } : {}) }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: vk }, s);
}
log(`keys ${step.keys.join(' ')}`);
} else if (step.shot) {
await shot(step.shot, step.after ?? 1500);
} else if (step.type) {
// Keys as a person types them, into whatever the compositor focuses.
await ev("document.getElementById('out').focus()");
for (const ch of step.type) {
if (ch === '\n') {
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
} else {
const [code, shift] = keyCode(ch);
const sh = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
if (shift) await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...sh, modifiers: 8 }, s);
const m = shift ? 8 : 0;
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
if (shift) await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...sh }, s);
}
}
log(`typed ${JSON.stringify(step.type)}`);
} else if (step.install || step.launch) {
const id = step.install || step.launch;
const a = Date.now();
if (step.install) await ev(`globalThis.gexDesktop.install(${JSON.stringify(id)})`);
else await ev(`globalThis.gexDesktop.launch(${JSON.stringify(id)})`);
const r = await ev(`globalThis.gexDesktop.installs?.[${JSON.stringify(id)}]`);
log(`ok ${step.install ? 'install' : 'launch'} ${id}: ${Date.now() - a} ms; overlay ${JSON.stringify(r)}`);
} else if (step.run) {
const tag = `S${n}x${Math.floor(Math.random() * 1e6)}`;
const cmd = `s=$EPOCHREALTIME; { ${step.run} ; } >/tmp/${tag}.out 2>&1; r=$?; e=$EPOCHREALTIME; ` +
`printf "\\r\\n"; echo "${tag.toLowerCase()} rc=$r $(( (\${e/./}-\${s/./})/1000 ))ms"; tail -${process.env.DEMO_TAIL || 5} /tmp/${tag}.out`;
await ev(`globalThis.gexDesktop.run(${JSON.stringify(cmd)})`);
const re = new RegExp(`${tag.toLowerCase()} rc=(\\d+) (\\d+)ms`, 'm');
const got = await until(`(() => { const m = /${re.source}/m.exec(globalThis.gexTerminalText()); return m && [m[1], m[2]]; })()`,
`"${step.label ?? step.run}"`, (step.wall ?? 600) * 1000);
log(`${got[0] === '0' ? 'ok' : 'FAIL'} run ${step.label ?? step.run}: rc=${got[0]} ${got[1]} ms (guest clock)`);
if (got[0] !== '0' || step.show) {
if (got[0] !== '0') failed = true;
// The command's last lines, which the session prints after the result.
await new Promise((r) => setTimeout(r, 500));
const t = (await ev('globalThis.gexTerminalText()')) || '';
const at = t.search(re);
for (const l of t.slice(at).split('\n').slice(1, Number(process.env.DEMO_TAIL || 6))) if (l.trim()) log(` | ${l}`);
}
}
}
} catch (e) {
failed = true;
log(`FAIL ${e.message}`);
await new Promise((r) => { dump(); setTimeout(r, 500); });
}
clearTimeout(wall);
// DEMO_CONSOLE=PATH: every console line the page logged, for a trace.
if (process.env.DEMO_CONSOLE) (await import('node:fs')).writeFileSync(process.env.DEMO_CONSOLE, consoleLines.join('\n'));
const errs = stderr.split('\n').filter((l) => /Uncaught|RuntimeError|\[gex-console-error\]/.test(l));
for (const l of errs.slice(0, 10)) log(`stderr: ${l.slice(0, 300)}`);
b.close();
server.close();
cleanup();
process.exit(failed ? 1 : 0);
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# The guest-side legs of the demo (docs/gexcc.md §9), in the page, through
# tests/demo/desktop.mjs: /proc lists every process (ps, htop's source), a
# pty in raw mode reads keys, nvim takes typed keys, an epoll set survives
# fork, ttyname and ps's TTY column. Run on edger:
#
# scratch/bench/bench.sh -s $PWD/web/dist-demo -s $PWD/tests -- \
# bash tests/demo/guest.sh [DIST]
set -eu
cd "$(dirname "$0")/../.."
DIST=${1:-web/dist-demo}
b64() { base64 -w0 "tests/demo/guest/$1"; }
STEPS=$(cat <<JSON
[
{"install":"python"},{"install":"nvim"},{"install":"htop"},
{"run":"n=\$(ls /proc | grep -c '^[0-9][0-9]*\$'); echo \$n pids; ps ax | head -5; test \$n -ge 3 && ps ax | grep -q '[x]term'","label":"procfs lists every process","show":true,"wall":60},
{"run":"echo $(b64 tty.py) | base64 -d > /tmp/guest-tty.py; python3.13 /tmp/guest-tty.py","label":"terminals and fork","show":true,"wall":120},
{"run":"echo $(b64 nvim.py) | base64 -d > /tmp/guest-nvim.py; python3.13 /tmp/guest-nvim.py","label":"nvim on a pty","show":true,"wall":120}
]
JSON
)
DEMO_LEGS=${DEMO_LEGS:-0} DEMO_WALL=${DEMO_WALL:-600} DEMO_TAIL=${DEMO_TAIL:-20} DEMO_STEPS="$STEPS" \
taskset -c "${BENCH_CORES:-2,4,6,8}" node tests/demo/desktop.mjs "$DIST"
+40
View File
@@ -0,0 +1,40 @@
# Keys typed into nvim on a pty reach it (tests/demo/guest.sh): libuv opens
# the terminal non-blocking (FIONBIO), puts it in raw mode and reads it.
import os, pty, select, sys, time
path = '/tmp/guest-nvim.txt'
try:
os.unlink(path)
except FileNotFoundError:
pass
pid, m = pty.fork()
if pid == 0:
os.environ['TERM'] = 'xterm-256color'
os.execvp('nvim', ['nvim', '--clean', path])
out = b''
def drain(sec):
global out
end = time.time() + sec
while time.time() < end:
r, _, _ = select.select([m], [], [], 0.2)
if r:
try:
out += os.read(m, 65536)
except OSError:
return
drain(15)
os.write(m, b'ihello')
drain(3)
os.write(m, b'\x1b')
drain(2)
os.write(m, b':wq\r')
drain(8)
try:
got = open(path).read()
except OSError as e:
got = repr(e)
ok = got == 'hello\n'
print(f"{'ok' if ok else 'FAIL'} nvim took typed keys: {got!r} (drew {len(out)} bytes)", flush=True)
sys.exit(0 if ok else 1)
+72
View File
@@ -0,0 +1,72 @@
# Terminals and fork, in the page (tests/demo/guest.sh). Exits non-zero on
# the first thing that is not as Linux has it.
import os, pty, select, time, sys
def check(what, ok, got):
print(f"{'ok' if ok else 'FAIL'} {what}: {got}", flush=True)
if not ok:
sys.exit(1)
# An epoll set made before fork, waited on by the child.
r, w = os.pipe()
e = select.epoll()
e.register(r, select.EPOLLIN)
pid = os.fork()
if pid == 0:
try:
ev = e.poll(10)
res = f'{ev} {os.read(r, 10)!r}'
e.close()
except BaseException as ex:
res = f'error {ex!r}'
open('/tmp/tty-epoll.txt', 'w').write(res)
os._exit(0)
time.sleep(1)
os.write(w, b'ping')
os.waitpid(pid, 0)
got = open('/tmp/tty-epoll.txt').read()
check('epoll set inherited across fork', got == f"[({r}, 1)] b'ping'", got)
# A raw slave read, the way a full-screen program reads keys.
import tty
m, s = pty.openpty()
tty.setraw(s)
os.write(m, b'k')
check('raw pty read', os.read(s, 4) == b'k', 'k')
# ttyname, /dev/pts and /proc/self/fd agree.
name = os.ttyname(s)
check('ttyname', name.startswith('/dev/pts/'), name)
check('/dev/pts lists it', name.rsplit('/', 1)[1] in os.listdir('/dev/pts'), sorted(os.listdir('/dev/pts')))
check('/proc/self/fd names it', os.readlink(f'/proc/self/fd/{s}') == name, os.readlink(f'/proc/self/fd/{s}'))
# A shell on a pty: tty(1), and ps's TTY column from /proc/<pid>/stat.
pid, mfd = pty.fork()
if pid == 0:
os.execvp('sh', ['sh', '-c', 'tty; ps -o tty=,comm='])
out = b''
end = time.time() + 20
while time.time() < end and out.count(b'\n') < 3:
rr, _, _ = select.select([mfd], [], [], 0.3)
if rr:
try:
out += os.read(mfd, 65536)
except OSError:
break
# Then the shell exits, and the master reads EIO (not end-of-file, not
# waiting): before waitpid, while the shell is a zombie, as on Linux.
t0 = time.time()
ended = None
while time.time() - t0 < 15 and ended is None:
rr, _, _ = select.select([mfd], [], [], 0.3)
if rr:
try:
if not os.read(mfd, 65536):
ended = 'end-of-file'
except OSError as ex:
ended = f'errno {ex.errno}'
os.waitpid(pid, 0)
check('the master reads EIO once the shell is gone', ended == 'errno 5', f'{ended} after {time.time() - t0:.1f} s')
lines = out.decode(errors='replace').split()
check('tty(1) on a pty', bool(lines) and lines[0].startswith('/dev/pts/'), lines[:1])
check("ps's TTY column", 'pts/' in out.decode(errors='replace').split('\n', 1)[1], out.decode(errors='replace').strip().replace('\r', ''))
+37
View File
@@ -0,0 +1,37 @@
# What an X window holds, from the X server: `xwd` of the window named argv[1],
# reduced to a signature tests/demo/desktop.mjs computes the same way from what
# the compositor composed -- per cell of a COLSxROWS grid, how many pixels are
# dark. Prints "xsize W H HASH" (the hash of the whole dark bitmap), then each row of cells as "xsR n,n,...".
import struct, subprocess, sys
COLS, ROWS = 16, 8
title = sys.argv[1]
d = subprocess.run(['xwd', '-name', title, '-silent', '-nobdrs'], capture_output=True, check=True).stdout
h = struct.unpack('>25I', d[:100])
(size, _ver, _fmt, _depth, w, ht, _xoff, order, _unit, _bo, _pad, bpp, bpl,
_cls, rm, gm, bm, _bits, _ents, ncolors) = h[:20]
if bpp != 32:
sys.exit(f'xsig: {bpp} bits per pixel')
px = d[size + ncolors * 12:]
end = '<' if order == 0 else '>'
shift = lambda m: (m & -m).bit_length() - 1
rs, gs, bs = shift(rm), shift(gm), shift(bm)
# The outermost EDGE pixels are left out on both sides: where the window's
# border meets its contents is the compositor's business, not the client's.
EDGE = 2
iw, ih = w - 2 * EDGE, ht - 2 * EDGE
cells = [0] * (COLS * ROWS)
exact = 2166136261 # FNV-1a over the dark bitmap, row by row
for y in range(ih):
row = struct.unpack_from(f'{end}{w}I', px, (y + EDGE) * bpl)
cy = y * ROWS // ih * COLS
for x in range(iw):
v = row[x + EDGE]
dark = max((v & rm) >> rs, (v & gm) >> gs, (v & bm) >> bs) < 128
if dark:
cells[cy + x * COLS // iw] += 1
exact = ((exact ^ dark) * 16777619) & 0xffffffff
# A row of cells a line: the terminal that carries this back wraps long ones.
print('xsize', w, ht, exact)
for r in range(ROWS):
print(f'xs{r}', ','.join(map(str, cells[r * COLS:(r + 1) * COLS])))
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""A launcher package for the demo desktop (docs/gexcc.md §9): what TREE has
that BASE does not, as a tar.
Both trees are mkrootfs.py images, TREE built with BASE's packages and more.
The tar holds every file, symlink and directory of TREE that BASE lacks or
has different, except the dpkg status (one file every image has); instead
the stanzas of the packages BASE lacks go in var/lib/gex-overlay/NAME.status,
and the page appends them to the running system's status
(web/src/overlay-worker.js).
mkoverlay.py --base BASE --tree TREE --name NAME --tar OUT.tar
"""
import argparse
import hashlib
import io
import os
import tarfile
SKIP = {'var/lib/dpkg/status', 'var/lib/dpkg/status-old', 'var/lib/dpkg/available'}
def stanzas(path):
text = open(path, encoding='utf-8', errors='replace').read() if os.path.exists(path) else ''
out = {}
for st in text.split('\n\n'):
st = st.strip('\n')
if not st:
continue
f = dict(l.split(': ', 1) for l in st.split('\n') if ': ' in l and not l.startswith(' '))
out[(f.get('Package'), f.get('Architecture'))] = st
return out
def digest(p):
h = hashlib.sha256()
with open(p, 'rb') as f:
for b in iter(lambda: f.read(1 << 20), b''):
h.update(b)
return h.digest()
def same(a, b):
if os.path.islink(a) or os.path.islink(b):
return os.path.islink(a) and os.path.islink(b) and os.readlink(a) == os.readlink(b)
if os.path.isdir(a) or os.path.isdir(b):
return os.path.isdir(a) and os.path.isdir(b)
if not os.path.lexists(b):
return False
sa, sb = os.lstat(a), os.lstat(b)
return sa.st_size == sb.st_size and (sa.st_mode & 0o111) == (sb.st_mode & 0o111) and digest(a) == digest(b)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--base', required=True)
ap.add_argument('--tree', required=True)
ap.add_argument('--name', required=True)
ap.add_argument('--tar', required=True)
a = ap.parse_args()
rels = []
for d, dirs, files in os.walk(a.tree):
dirs.sort()
for n in sorted(dirs) + sorted(files):
p = os.path.join(d, n)
rel = os.path.relpath(p, a.tree)
if rel in SKIP:
continue
if os.path.isdir(p) and not os.path.islink(p):
if not os.path.isdir(os.path.join(a.base, rel)):
rels.append(rel)
continue
if not same(p, os.path.join(a.base, rel)):
rels.append(rel)
have = stanzas(os.path.join(a.base, 'var/lib/dpkg/status'))
new = [st for k, st in stanzas(os.path.join(a.tree, 'var/lib/dpkg/status')).items() if k not in have]
with tarfile.open(a.tar, 'w', format=tarfile.GNU_FORMAT) as tf:
for rel in rels:
ti = tf.gettarinfo(os.path.join(a.tree, rel), arcname=rel)
ti.uid = ti.gid = 0
ti.uname = ti.gname = 'root'
if ti.isreg():
with open(os.path.join(a.tree, rel), 'rb') as f:
tf.addfile(ti, f)
else:
tf.addfile(ti)
body = ('\n\n'.join(new) + '\n\n').encode() if new else b''
ti = tarfile.TarInfo(f'var/lib/gex-overlay/{a.name}.status')
ti.size = len(body)
ti.mode = 0o644
tf.addfile(ti, io.BytesIO(body))
names = sorted(st.split('\n', 1)[0].split(': ', 1)[1] for st in new)
print(f'{a.name}: {len(rels)} paths, {len(new)} packages: {" ".join(names)}')
if __name__ == '__main__':
main()
+4
View File
@@ -325,6 +325,7 @@ def main():
ap.add_argument('--roots', default=','.join(ROOTS))
ap.add_argument('--extra', default='', help='more arm64 packages (and their closure)')
ap.add_argument('--exclude', default='', help='our packages to leave out of the image (still in the repo)')
ap.add_argument('--ours', default='', help='only these of our packages in the image (still all in the repo)')
ap.add_argument('--full', action='store_true', help='keep documentation and translations')
a = ap.parse_args()
global a_minimal
@@ -336,6 +337,9 @@ def main():
ours.append((deb, control(deb)))
excluded = set(filter(None, a.exclude.split(',')))
image_ours = [(d, c) for d, c in ours if c['Package'] not in excluded]
if a.ours:
only = set(a.ours.split(','))
image_ours = [(d, c) for d, c in image_ours if c['Package'] in only]
our_names = {c['Package'] for _, c in ours}
for _, c in ours:
for item in c.get('Provides', '').split(','):
+8 -3
View File
@@ -3,7 +3,7 @@
# single-thread build (SP, docs/single-thread.md), or both, from one tree.
#
# web/build-all.sh [--mp | --sp | --both] [--debug]
# [--no-assets] [--no-gl] [--no-x] [--dist-only]
# [--no-assets] [--no-gl] [--no-x] [--dist-only] [--x-only] [--gl-only]
#
# Each flavour gets its own dist:
#
@@ -56,7 +56,7 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
CACHE="${GEX_WEB_BUILD:-$ROOT/target/web-build}"
DEBUGDIR="$ROOT/target/web-debug"
MP=1 SP=0 ASSETS=1 GL=1 X=1 DEBUG=0
MP=1 SP=0 ASSETS=1 GL=1 X=1 DEBUG=0 DISTS=1
for a in "$@"; do
case $a in
--mp) MP=1 SP=0 ;;
@@ -67,7 +67,11 @@ for a in "$@"; do
--no-gl) GL=0 ;;
--no-x) X=0 ;;
--dist-only) ASSETS=0 GL=0 X=0 ;;
*) echo "usage: web/build-all.sh [--mp | --sp | --both] [--debug] [--no-assets] [--no-gl] [--no-x] [--dist-only]" >&2; exit 2 ;;
# Only Xwayland into web/public (web/build-demo.sh's step).
--x-only) ASSETS=0 GL=0 X=1 DISTS=0 ;;
# Only Mesa into web/public (web/build-demo.sh --gl).
--gl-only) ASSETS=0 GL=1 X=0 DISTS=0 ;;
*) echo "usage: web/build-all.sh [--mp | --sp | --both] [--debug] [--no-assets] [--no-gl] [--no-x] [--dist-only] [--x-only] [--gl-only]" >&2; exit 2 ;;
esac
done
mkdir -p "$CACHE"
@@ -123,6 +127,7 @@ xwl() { # xwl <mt|st>
[ "$SP" = 1 ] && [ "$GL" = 1 ] && mesa st
[ "$MP" = 1 ] && [ "$X" = 1 ] && xwl mt
[ "$SP" = 1 ] && [ "$X" = 1 ] && xwl st
[ "$DISTS" = 1 ] || exit 0
# -- 3. one dist per flavour ---------------------------------------------------
dist() { # dist <mp|sp>
+286
View File
@@ -0,0 +1,286 @@
#!/bin/bash
# The demo build: the desktop page (docs/gexcc.md §9) into web/dist-demo,
# always stripped, runtime modules shipped as .wasm.zst. No test runs on this
# build; tests/demo/smoke.sh checks the desktop comes up with both windows.
#
# web/build-demo.sh [--no-runtime] [--no-x] [--no-rootfs] [--no-pkgs]
#
# 1. The runtime the page loads (unless --no-runtime): the shared shim, the
# kernel and the seed (shims/browser/build.sh), and the export providers
# (gexc cgen-image --providers), into web/public/gex as build-assets.sh does.
# 2. Xwayland, threaded (web/build-all.sh --x-only; skipped while its stamp
# matches), into web/public/gex/xwayland.
# 3. The base rootfs: bash and xterm and what they need, nothing else
# (tools/gexcc/debian/mkrootfs.py over the gexcc pool, then pruned), as
# rootfs/base.tar.zst. Needs the pool web/build-native-debian.sh builds
# ($GEXCC_DEB/pool).
# 4. The launcher's packages (unless --no-pkgs): web/demo-items.json, one
# pkgs/NAME.tar.zst per item plus pkgs/index.json.
# 5. A staged public tree with only what the desktop loads; every .wasm
# stripped (`wasm-tools strip`: DWARF and other custom sections out, the
# name section kept; the seed and providers keep their gex.* sections) and
# compressed (`zstd --ultra -22`) to NAME.wasm.zst, the plain file
# removed. Providers stay plain: a Worker reads them synchronously.
# 6. vite with GEX_DEMO=1 (desktop.html is the page) and VITE_GEX_ZWASM=1
# (zfetch.js decodes the .wasm.zst files).
#
# Run inside a resource scope:
# systemd-run --user --scope -p TasksMax=4096 -p MemoryMax=32G nice -n 19 web/build-demo.sh
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
CACHE="${GEX_WEB_BUILD:-$ROOT/target/web-build}"
export GEXCC_DEB=${GEXCC_DEB:-$HOME/gex-sandbox/scratch/CC/debian}
WORK=${GEX_DEMO_WORK:-$GEXCC_DEB/demo}
OUT=$HERE/dist-demo
PUB=$HERE/public
RUNTIME=1 X=1 ROOTFS=1 PKGS=1
for a in "$@"; do
case $a in
--no-runtime) RUNTIME=0 ;;
--no-x) X=0 ;;
--no-rootfs) ROOTFS=0 ;;
--no-pkgs) PKGS=0 ;;
*) echo "usage: web/build-demo.sh [--no-runtime] [--no-x] [--no-rootfs] [--no-pkgs]" >&2; exit 2 ;;
esac
done
say() { printf '\n== %s\n' "$*"; }
size() { stat -c %s "$1"; }
mib() { awk -v n="$1" 'BEGIN { printf "%.1f MiB", n / 1048576 }'; }
command -v wasm-tools >/dev/null || { echo "need wasm-tools (cargo install wasm-tools)" >&2; exit 1; }
command -v zstd >/dev/null || { echo "need zstd" >&2; exit 1; }
mkdir -p "$CACHE" "$WORK" "$PUB/gex" "$PUB/rootfs"
# -- 1. runtime ----------------------------------------------------------------
if [ "$RUNTIME" = 1 ]; then
say "runtime: shim, kernel, seed"
(cd "$ROOT" && shims/browser/build.sh >"$CACHE/demo-shim.log" 2>&1) ||
{ tail -20 "$CACHE/demo-shim.log"; exit 1; }
say "export providers"
cargo build --release --manifest-path "$ROOT/Cargo.toml" -p gexc >/dev/null 2>&1
rm -rf "$CACHE/demo-provcache"
GEX_CGEN_CACHE="$CACHE/demo-provcache" "$ROOT/target/release/gexc" cgen-image --providers >/dev/null 2>&1
mkdir -p "$PUB/gex/cgen/providers"
rm -f "$PUB/gex/cgen/providers/"*.wasm
cp "$CACHE/demo-provcache/providers/"*.wasm "$PUB/gex/cgen/providers/"
fi
# -- 2. Xwayland -----------------------------------------------------------------
if [ "$X" = 1 ]; then
"$HERE/build-all.sh" --mp --x-only
fi
# -- 2b. the guest's GL marshallers (glibc, arm64) ------------------------------
# For the gears item: libGL/libEGL/libGLESv2 that marshal to the page's Mesa
# (guest/gl). A cross gcc when there is one, else zig cc for aarch64 glibc.
# The tracked ones in web/public/fs/glibc are used as they are; they are built
# only when missing (rebuild them with guest/gl/build.sh when the protocol moves).
if [ "$PKGS" = 1 ] && ! [ -f "$PUB/fs/glibc/libGL.so.1" -a -f "$PUB/fs/glibc/libEGL.so.1" -a -f "$PUB/fs/glibc/libGLESv2.so.2" ]; then
GL_CC=${GEX_GL_CC:-$(command -v aarch64-linux-gnu-gcc || true)}
if [ -z "$GL_CC" ] && [ -x "$HOME/toolchains/zig/zig" ]; then
GL_CC=$CACHE/aarch64-gnu-cc
printf '#!/bin/sh\nexec %s cc -target aarch64-linux-gnu.2.36 "$@"\n' "$HOME/toolchains/zig/zig" > "$GL_CC"
chmod +x "$GL_CC"
fi
if [ -n "$GL_CC" ]; then
say "GL marshallers (glibc)"
GEX_CC=$GL_CC "$ROOT/guest/gl/build.sh" "$CACHE/gldriver-glibc" >"$CACHE/demo-gl.log" 2>&1 ||
{ tail -20 "$CACHE/demo-gl.log"; exit 1; }
mkdir -p "$PUB/fs/glibc"
cp "$CACHE"/gldriver-glibc/{libGLESv2.so.2,libEGL.so.1,libGL.so.1} "$PUB/fs/glibc/"
else
echo "no aarch64 glibc compiler (aarch64-linux-gnu-gcc or ~/toolchains/zig): no GL marshallers" >&2
fi
fi
# -- 3. the base rootfs --------------------------------------------------------------
# What bash and xterm need, and the tools a shell user expects at the first
# prompt (user, 2026-10-11: "the base first-load should have coreutils at
# least"). All ours (wasm32, from Debian's sources). What each adds over
# bash + xterm, zstd -22 of its overlay (2026-10-11): coreutils 0.77 MiB,
# util-linux 0.57, procps 0.46, tar 0.41, nano 0.33, findutils 0.30,
# ncurses-bin 0.21, less 0.20, grep 0.17, sed 0.09, gzip 0.07, hostname
# 0.04, debianutils (run-parts, which: /etc/profile runs run-parts). Left to the launcher's "More tools": file 0.45, gawk 0.35,
# diffutils 0.32, zstd 0.21, xz-utils 0.14, bsdextrautils 0.13, tree 0.05.
# mawk and sysvinit-utils (0.2 MB) are there because base-files wants an awk
# and x11-common an lsb-base; dash is /bin/sh (dpkg, scripts).
BASE_ROOTS=base-files,base-passwd,ncurses-base
BASE_EXTRA=xterm
BASE_OURS=bash,dash,mawk,sysvinit-utils,coreutils,grep,sed,findutils,procps,less,ncurses-bin,gzip,tar,util-linux,hostname,nano,debianutils
# Pruned after: fonts xterm does not use (its default font is the X server's
# own `fixed`; DejaVu Sans Mono stays for `xterm -fa`), Xlib's locales but C
# and en_US.UTF-8, icons and menus.
base_prune() { # base_prune TREE
local t=$1
rm -f "$t"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf "$t"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \
"$t"/usr/share/fonts/truetype/dejavu/DejaVuSerif*.ttf
for l in "$t"/usr/share/X11/locale/*; do
case ${l##*/} in C|en_US.UTF-8|iso8859-1|locale.alias|locale.dir|compose.dir) ;; *) rm -rf "$l" ;; esac
done
# locale.dir and compose.dir name every locale; keep their C and en_US lines
for f in locale.dir compose.dir; do
[ -f "$t/usr/share/X11/locale/$f" ] || continue
grep -E '^(#|$)|(^| )(C|en_US\.UTF-8|iso8859-1)/' "$t/usr/share/X11/locale/$f" > "$t/usr/share/X11/locale/$f.n" || true
mv "$t/usr/share/X11/locale/$f.n" "$t/usr/share/X11/locale/$f"
done
# (debconf and its perl stay: maintainer scripts source
# /usr/share/debconf/confmodule, and a dpkg -i or apt install runs them)
rm -rf "$t"/usr/share/icons "$t"/usr/share/pixmaps "$t"/usr/share/applications \
"$t"/usr/share/man "$t"/usr/share/doc-base "$t"/usr/share/lintian "$t"/usr/share/bash-completion \
2>/dev/null || true
}
if [ "$ROOTFS" = 1 ]; then
say "base rootfs"
python3 -I "$ROOT/tools/gexcc/debian/mkrootfs.py" --pool "$GEXCC_DEB/pool" --out "$WORK/base" \
--roots "$BASE_ROOTS" --extra "$BASE_EXTRA" --ours "$BASE_OURS"
base_prune "$WORK/base"
tar -C "$WORK/base" --owner=0 --group=0 --numeric-owner --sort=name -cf "$WORK/base.tar" .
zstd --ultra -22 -T0 -q -f "$WORK/base.tar" -o "$PUB/rootfs/base.tar.zst"
echo " base: $(mib "$(du -sb --apparent-size "$WORK/base" | cut -f1)") tree," \
"$(mib "$(size "$WORK/base.tar")") tar, $(mib "$(size "$PUB/rootfs/base.tar.zst")") zstd"
fi
# -- 4. the launcher's packages --------------------------------------------------------------
# web/demo-items.json: one overlay per distinct `pkg` (default the item's id),
# made by mkrootfs.py as the base plus the item and diffed against the base
# (tools/gexcc/debian/mkoverlay.py), pruned as the base is. pkgs/index.json is
# what the launcher lists. DEMO_DEBS=ID,... also writes pkgs/ID-debs.tar.zst:
# the .debs of what that overlay adds, under /var/cache/apt/archives (the
# dpkg -i measurement, docs/gexcc.md §9).
if [ "$PKGS" = 1 ]; then
say "launcher packages"
mkdir -p "$PUB/pkgs" "$WORK/pkgs"
python3 -I - "$HERE/demo-items.json" > "$WORK/pkgs/list" <<'PY'
import json, sys
seen = set()
for it in json.load(open(sys.argv[1]))['items']:
pkg = it.get('pkg', it['id'])
if pkg in seen: continue
seen.add(pkg)
print(pkg, it.get('roots', '-'), it.get('extra', '-') or '-', it.get('ours', '-') or '-')
PY
while read -r pkg roots extra ours; do
[ "$roots" = - ] && roots=$BASE_ROOTS || roots=$BASE_ROOTS,$roots
[ "$extra" = - ] && extra=$BASE_EXTRA || extra=$BASE_EXTRA,$extra
[ "$ours" = - ] && ours=$BASE_OURS || ours=$BASE_OURS,$ours
t=$WORK/pkgs/$pkg
python3 -I "$ROOT/tools/gexcc/debian/mkrootfs.py" --pool "$GEXCC_DEB/pool" --out "$t" \
--roots "$roots" --extra "$extra" --ours "$ours" 2>"$WORK/pkgs/$pkg.log" ||
{ cat "$WORK/pkgs/$pkg.log"; exit 1; }
base_prune "$t"
# The item's own `prune` (globs under the tree) and `files` (guest
# path -> a file under web/public), e.g. gears: Debian's Mesa out, the
# gex GL marshallers in.
GEXCC_DEB=$GEXCC_DEB python3 -I - "$HERE/demo-items.json" "$pkg" "$t" "$PUB" <<'PY'
import glob, json, os, shutil, sys
items, pkg, tree, pub = json.load(open(sys.argv[1]))['items'], sys.argv[2], sys.argv[3], sys.argv[4]
it = next(x for x in items if x.get('pkg', x['id']) == pkg)
for g in it.get('prune', []):
for p in glob.glob(os.path.join(tree, g)):
shutil.rmtree(p) if os.path.isdir(p) and not os.path.islink(p) else os.unlink(p)
# `lazypool`: our own .debs (the gex apt repository, /var/lib/gex-apt) as
# files the guest fetches from the page when apt reads them, so apt can
# install a dependency only we build (bsdextrautils for man-db...) without
# the overlay carrying the whole pool. They are published under gex-apt/.
if it.get('lazypool'):
pool = os.path.join(os.environ['GEXCC_DEB'], 'pool')
lazy = []
for f in sorted(os.listdir(pool)):
if not f.endswith('.deb'):
continue
name = f.split('_')[0]
rel = f'pool/main/{name[0]}/{name}/{f}'
out = os.path.join(pub, 'gex-apt', rel)
os.makedirs(os.path.dirname(out), exist_ok=True)
shutil.copy(os.path.join(pool, f), out)
lazy.append([f'/var/lib/gex-apt/{rel}', os.path.getsize(out), f'gex-apt/{rel}'])
d = os.path.join(tree, 'var/lib/gex-overlay')
os.makedirs(d, exist_ok=True)
json.dump(lazy, open(os.path.join(d, f'{pkg}.lazy'), 'w'))
for dst, src in it.get('files', {}).items():
d = os.path.join(tree, dst.lstrip('/'))
os.makedirs(os.path.dirname(d), exist_ok=True)
shutil.copy(os.path.join(pub, src), d)
os.chmod(d, 0o755)
PY
python3 -I "$ROOT/tools/gexcc/debian/mkoverlay.py" --base "$WORK/base" --tree "$t" --name "$pkg" \
--tar "$WORK/pkgs/$pkg.tar" | tee "$WORK/pkgs/$pkg.added"
zstd --ultra -22 -T0 -q -f "$WORK/pkgs/$pkg.tar" -o "$PUB/pkgs/$pkg.tar.zst"
echo " $pkg: $(mib "$(size "$WORK/pkgs/$pkg.tar")") tar, $(mib "$(size "$PUB/pkgs/$pkg.tar.zst")") zstd"
case ,${DEMO_DEBS:-}, in *,$pkg,*)
d=$WORK/pkgs/$pkg-debs; rm -rf "$d"; mkdir -p "$d/var/cache/apt/archives"
for n in $(sed 's/^[^:]*: [0-9]* paths, [0-9]* packages: //' "$WORK/pkgs/$pkg.added"); do
f=$(ls "$GEXCC_DEB/pool/${n}_"*.deb 2>/dev/null | head -1)
[ -n "$f" ] || f=$(find "$HOME/gex-sandbox/scratch/CC/deb/pool" -name "${n}_*_*.deb" | head -1)
[ -n "$f" ] || { echo " no .deb for $n" >&2; continue; }
cp "$f" "$d/var/cache/apt/archives/"
done
tar -C "$d" --owner=0 --group=0 --numeric-owner -cf "$WORK/pkgs/$pkg-debs.tar" .
zstd --ultra -22 -T0 -q -f "$WORK/pkgs/$pkg-debs.tar" -o "$PUB/pkgs/$pkg-debs.tar.zst"
echo " $pkg-debs: $(ls "$d/var/cache/apt/archives" | wc -l) debs, $(mib "$(size "$PUB/pkgs/$pkg-debs.tar.zst")") zstd"
esac
done < "$WORK/pkgs/list"
python3 -I - "$HERE/demo-items.json" "$PUB/pkgs" > "$PUB/pkgs/index.json" <<'PY'
import json, os, sys
items = json.load(open(sys.argv[1]))['items']
out = []
for it in items:
pkg = it.get('pkg', it['id'])
o = {k: it[k] for k in ('id', 'label', 'desc', 'glyph', 'color', 'cmd', 'auto', 'gl') if k in it}
o['pkg'] = pkg
o['size'] = os.path.getsize(os.path.join(sys.argv[2], pkg + '.tar.zst'))
out.append(o)
# The .deb bundles (DEMO_DEBS), for a measurement rather than the launcher.
hidden = [{'id': f[:-len('.tar.zst')], 'pkg': f[:-len('.tar.zst')], 'cmd': 'true',
'size': os.path.getsize(os.path.join(sys.argv[2], f))}
for f in sorted(os.listdir(sys.argv[2])) if f.endswith('-debs.tar.zst')]
print(json.dumps({'items': out, 'hidden': hidden}, indent=1))
PY
fi
# -- 5. stage --------------------------------------------------------------------------------
say "staging"
STAGE=$CACHE/public-demo
rm -rf "$STAGE"
mkdir -p "$STAGE/gex/cgen" "$STAGE/rootfs"
for f in gex_browser.shared.wasm gex_kernel.wasm seed.shared.wasm; do
[ -f "$PUB/gex/$f" ] || { echo "no web/public/gex/$f: run without --no-runtime" >&2; exit 1; }
cp "$PUB/gex/$f" "$STAGE/gex/"
done
cp -r "$PUB/gex/cgen/providers" "$STAGE/gex/cgen/"
[ -f "$PUB/gex/xwayland/Xwayland.wasm" ] || { echo "no web/public/gex/xwayland: run without --no-x" >&2; exit 1; }
cp -r "$PUB/gex/xwayland" "$STAGE/gex/"
cp "$PUB/rootfs/base.tar.zst" "$STAGE/rootfs/"
# Mesa (the host's GL, gex_wgpu), fetched only when a GL program starts.
if [ -f "$PUB/gex/gex-wgpu-replay.wasm" ]; then
cp "$PUB/gex/gex-wgpu-replay.js" "$PUB/gex/gex-wgpu-replay.wasm" "$STAGE/gex/"
else
echo " no web/public/gex/gex-wgpu-replay.wasm (web/build-all.sh --mp --gl-only): GL items will not draw" >&2
fi
[ -d "$PUB/pkgs" ] && cp -r "$PUB/pkgs" "$STAGE/"
[ -d "$PUB/gex-apt" ] && cp -r "$PUB/gex-apt" "$STAGE/"
before=0 stripped=0 packed=0
for w in $(find "$STAGE/gex" -name '*.wasm' -not -path '*/cgen/*'); do
b=$(size "$w"); before=$((before + b))
case ${w##*/} in
seed*.wasm) wasm-tools strip -d '^\.debug' "$w" -o "$w.s" ;; # keeps gex.* and name
*) wasm-tools strip "$w" -o "$w.s" ;; # keeps name
esac
mv -f "$w.s" "$w"
s=$(size "$w"); stripped=$((stripped + s))
zstd --ultra -22 -T0 -q -f --rm "$w" -o "$w.zst"
z=$(size "$w.zst"); packed=$((packed + z))
printf ' %-34s %10s -> %10s stripped -> %10s zstd\n' "${w#"$STAGE"/}" "$(mib $b)" "$(mib $s)" "$(mib $z)"
done
echo " runtime modules: $(mib $before) -> $(mib $stripped) stripped -> $(mib $packed) zstd"
# -- 6. the page ---------------------------------------------------------------------------
say "web/dist-demo"
rm -rf "$OUT"
(cd "$HERE" && GEX_DEMO=1 VITE_GEX_ZWASM=1 GEX_PUBLIC_DIR="$STAGE" GEX_OUT_DIR="$OUT" \
npm run --silent build >"$CACHE/vite-demo.log" 2>&1) || { tail -20 "$CACHE/vite-demo.log"; exit 1; }
mv "$OUT/desktop.html" "$OUT/index.html"
rm -rf "$STAGE"
echo " web/dist-demo: $(du -sh --apparent-size "$OUT" | cut -f1)"
echo " first load (runtime + page + base rootfs):" \
"$(mib $(( $(find "$OUT" -type f -not -path '*/pkgs/*' -not -path '*/gex-apt/*' -not -name 'gex-wgpu-replay.*' -printf '%s\n' | paste -sd+ | bc) )))"
+140
View File
@@ -0,0 +1,140 @@
{
"comment": "The demo desktop's launcher (docs/gexcc.md §9). Each item is an overlay built by web/demo-pkgs.sh: mkrootfs.py with the base's packages plus `ours` (gexcc-built, from the pool) and `extra` (Debian arm64, translated), minus the base. `auto` items are fetched in the background once the desktop is up. `pkg` shares another item's overlay.",
"items": [
{
"id": "moretools",
"label": "More tools",
"desc": "diff, file, gawk, xz, zstd, tree, column... (wasm32, built from Debian source)",
"glyph": "$",
"color": "#6ee7a8",
"ours": "diffutils,gawk,xz-utils,zstd,file,tree,bsdextrautils",
"cmd": "true"
},
{
"id": "gears",
"label": "glxgears",
"desc": "OpenGL: Debian's glxgears (arm64), drawn by Mesa on WebGPU in this page",
"glyph": "⚙",
"color": "#ff9e64",
"gl": true,
"extra": "mesa-utils",
"ours": "bsdextrautils",
"prune": [
"usr/lib/aarch64-linux-gnu/libgallium*",
"usr/lib/aarch64-linux-gnu/libLLVM*",
"usr/lib/aarch64-linux-gnu/dri",
"usr/lib/aarch64-linux-gnu/libvulkan*",
"usr/share/vulkan",
"usr/lib/aarch64-linux-gnu/libz3*"
],
"files": {
"/usr/lib/gex-gl/libEGL.so.1": "fs/glibc/libEGL.so.1",
"/usr/lib/gex-gl/libGLESv2.so.2": "fs/glibc/libGLESv2.so.2",
"/usr/lib/gex-glx/libGL.so.1": "fs/glibc/libGL.so.1",
"/usr/lib/aarch64-linux-gnu/libGL.so.1": "fs/glibc/libGL.so.1",
"/usr/lib/aarch64-linux-gnu/libEGL.so.1": "fs/glibc/libEGL.so.1",
"/usr/lib/aarch64-linux-gnu/libGLESv2.so.2": "fs/glibc/libGLESv2.so.2"
},
"cmd": "glxgears"
},
{
"id": "xeyes",
"label": "xeyes",
"desc": "X11, Debian arm64 (translated)",
"glyph": "👀",
"color": "#f0b866",
"pkg": "x11-apps",
"extra": "x11-apps",
"cmd": "xeyes",
"ours": "bsdextrautils"
},
{
"id": "xclock",
"label": "xclock",
"desc": "X11, Debian arm64 (translated)",
"glyph": "◷",
"color": "#f0b866",
"pkg": "x11-apps",
"extra": "x11-apps",
"cmd": "xclock",
"ours": "bsdextrautils"
},
{
"id": "foot",
"label": "foot",
"desc": "A Wayland terminal (wasm32)",
"glyph": "▮",
"color": "#7dcfff",
"ours": "foot,fontconfig",
"cmd": "foot"
},
{
"id": "gtk3-demo",
"label": "GTK 3 demo",
"desc": "GTK 3's widget demo (wasm32)",
"glyph": "G",
"color": "#bb9af7",
"ours": "gtk-3-examples,libgtk-3-common,gtk-update-icon-cache,fontconfig",
"cmd": "GDK_BACKEND=wayland gtk3-demo"
},
{
"id": "nvim",
"label": "Neovim",
"desc": "Debian arm64 (translated), in an xterm",
"glyph": "N",
"color": "#8bd450",
"extra": "neovim",
"ours": "bsdextrautils",
"cmd": "xterm -T nvim -e nvim"
},
{
"id": "node",
"label": "Node.js",
"desc": "Debian arm64 node 20 (translated, V8's JIT included), in an xterm",
"glyph": "JS",
"color": "#9ccc65",
"extra": "nodejs",
"cmd": "xterm -T node -e node",
"ours": "bsdextrautils"
},
{
"id": "python",
"label": "Python 3.13",
"desc": "wasm32, built from Debian source, in an xterm",
"glyph": "Py",
"color": "#ffd43b",
"ours": "python3.13",
"cmd": "xterm -T python -e python3.13"
},
{
"id": "htop",
"label": "htop",
"desc": "wasm32, in an xterm",
"glyph": "▤",
"color": "#f07a7a",
"ours": "htop",
"cmd": "xterm -T htop -e htop"
},
{
"id": "doom",
"label": "Chocolate Doom",
"desc": "SDL2 on X11 (wasm32) playing Freedoom",
"glyph": "D",
"color": "#e05d44",
"ours": "chocolate-doom,bsdextrautils",
"extra": "freedoom",
"cmd": "chocolate-doom -iwad /usr/share/games/doom/freedoom2.wad -window"
},
{
"id": "apt",
"label": "apt",
"desc": "Debian's package manager: apt install anything from trixie (needs the network)",
"glyph": "⬇",
"color": "#d6dbe4",
"ours": "apt,dpkg,perl-base,gpgv,openssl,debianutils,coreutils,tar,gzip,xz-utils,zstd,sed,grep,findutils,diffutils,mawk,util-linux",
"roots": "apt,dpkg,base-files,base-passwd,debian-archive-keyring,ncurses-base,libc-bin,perl-base,debconf,ca-certificates,usr-is-merged,sensible-utils,netbase,init-system-helpers,login.defs",
"cmd": "xterm -T apt -e bash -l -c 'echo apt is installed: try apt update then apt install cowsay; exec bash -l'",
"lazypool": true
}
]
}
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gex desktop</title>
<meta name="description" content="Debian programs, built to WebAssembly or translated from arm64, on a desktop in your browser." />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>▚</text></svg>" />
</head>
<body>
<div id="panel">
<button id="apps" class="pbtn" title="Applications">▚ Applications</button>
<div id="tasks"></div>
<span id="pstatus"></span>
<button id="console-btn" class="pbtn" title="The system console: pid 1's terminal">console</button>
<span id="clock"></span>
</div>
<div id="screen">
<canvas id="out" tabindex="0"></canvas>
<div id="splash">
<div class="splash-card">
<div class="logo">▚ gex</div>
<div id="splash-text">starting</div>
<div class="bar"><div id="splash-bar"></div></div>
</div>
</div>
</div>
<div id="launcher" class="dwin hidden">
<div class="dwin-title"><span>Applications</span><button class="dwin-close" title="Close">×</button></div>
<div class="dwin-body">
<p class="lhint">Each program downloads once, the first time you open it, and is added to the running system.</p>
<ul id="items"></ul>
</div>
</div>
<div id="console" class="dwin hidden">
<div class="dwin-title"><span>Console (pid 1)</span><button class="dwin-close" title="Close">×</button></div>
<div class="dwin-body"><div id="term"></div></div>
</div>
<script type="module" src="/src/desktop.js"></script>
</body>
</html>
+160 -107
View File
@@ -1,3 +1,5 @@
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
import './zfetch.js';
import { requestGexDevice, textureCanvas } from './gpu-device.js';
// gex-wayland's output: a real compositor, in a worker, on WebGPU (the device Mesa uses).
//
@@ -42,10 +44,22 @@ import {
// A frame the compositor draws, not the client: this is what "server-side
// decorations" means, and a Qt or KDE app that sees the xdg-decoration
// protocol stops drawing its own.
const TITLE_H = 26;
const BORDER = 1;
const BUTTON = 18;
const BUTTON_GAP = 6;
//
// In device pixels: the output is the canvas's backing store, which is the
// CSS size times devicePixelRatio, so every metric is scaled by that ratio
// (`UI`) and the frame is as large on the screen at any density.
let UI = 1;
let TITLE_H = 26;
let BORDER = 1;
let BUTTON = 18;
let BUTTON_GAP = 6;
function setUiScale(u) {
UI = u > 0 && Number.isFinite(u) ? u : 1;
TITLE_H = Math.round(26 * UI);
BORDER = Math.max(1, Math.round(UI));
BUTTON = Math.round(18 * UI);
BUTTON_GAP = Math.round(6 * UI);
}
const COL = {
desktop: [0.07, 0.09, 0.12, 1],
@@ -146,9 +160,13 @@ class Painter {
fragment: { module, entryPoint: 'fs', targets: [{ format, blend: { color: blend, alpha: blend } }] },
primitive: { topology: 'triangle-strip' },
});
// Linear and clamped, whatever the texture: the guest's GL frames are
// textures Mesa made, and sampling must not depend on their state.
// Clamped, whatever the texture: the guest's GL frames are textures Mesa
// made, and sampling must not depend on their state. Nearest where a
// texture is drawn at its own size (a window, 1:1 with the device's
// pixels, at whole-pixel positions), linear only where it is scaled.
this.sampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
this.nearest = device.createSampler({ minFilter: 'nearest', magFilter: 'nearest' });
this.groupsNearest = new WeakMap();
this.uniforms = device.createBuffer({ size: SLOT * MAX_DRAWS, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
this.data = new ArrayBuffer(SLOT * MAX_DRAWS);
this.f32 = new Float32Array(this.data);
@@ -159,18 +177,19 @@ class Painter {
this.scene = null;
}
#group(tex) {
let g = this.groups.get(tex);
#group(tex, nearest) {
const groups = nearest ? this.groupsNearest : this.groups;
let g = groups.get(tex);
if (!g) {
g = this.device.createBindGroup({
layout: this.layout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms, size: 80 } },
{ binding: 1, resource: this.sampler },
{ binding: 1, resource: nearest ? this.nearest : this.sampler },
{ binding: 2, resource: tex.createView() },
],
});
this.groups.set(tex, g);
groups.set(tex, g);
}
return g;
}
@@ -207,7 +226,12 @@ class Painter {
f[o + 8] = color[0]; f[o + 9] = color[1]; f[o + 10] = color[2]; f[o + 11] = color[3];
f[o + 12] = this.w; f[o + 13] = this.h;
this.u32[o + 14] = mode; this.u32[o + 15] = swizzle; this.u32[o + 16] = opaque;
this.pass.setBindGroup(0, this.#group(tex), [this.n * SLOT]);
// 1:1 and on whole pixels: nearest, so a terminal's glyphs are the
// client's own pixels and not a blend of two neighbours.
const nearest = mode === 1 || (w === Math.round(w) && h === Math.round(h) && x === Math.round(x)
&& y === Math.round(y) && Math.round(w / Math.max(1e-9, uv[2])) === tex.width
&& Math.round(h / Math.max(1e-9, uv[3])) === tex.height);
this.pass.setBindGroup(0, this.#group(tex, nearest), [this.n * SLOT]);
this.pass.draw(4);
this.n++;
}
@@ -331,19 +355,21 @@ class TextCache {
get(text) {
let e = this.#entries.get(text);
if (e) return e;
const scale = 2; // drawn at 2x and minified, so the glyphs stay crisp
// At device pixels and drawn 1:1, so the glyphs are the browser's own
// rendering at this density, not a resampling of it.
const cv = new OffscreenCanvas(1, 1);
let ctx = cv.getContext('2d');
ctx.font = `${13 * scale}px system-ui, sans-serif`;
const px = Math.round(13 * UI);
ctx.font = `${px}px system-ui, sans-serif`;
const width = Math.max(1, Math.ceil(ctx.measureText(text).width));
cv.width = width;
cv.height = 18 * scale;
cv.height = Math.round(18 * UI);
ctx = cv.getContext('2d');
ctx.font = `${13 * scale}px system-ui, sans-serif`;
ctx.font = `${px}px system-ui, sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillStyle = 'white';
ctx.fillText(text, 0, cv.height / 2);
e = { tex: canvasTexture(this.#device, cv), w: cv.width / scale, h: cv.height / scale };
e = { tex: canvasTexture(this.#device, cv), w: cv.width, h: cv.height };
// A cache with no bound is a leak; titles change rarely and a session has
// a handful of windows, so a small cap is plenty.
if (this.#entries.size > 64) {
@@ -358,29 +384,6 @@ class TextCache {
}
}
/** The pointer image, drawn when no client has set one of its own. */
function defaultCursor(device) {
const s = 2;
const cv = new OffscreenCanvas(12 * s, 19 * s);
const ctx = cv.getContext('2d');
ctx.scale(s, s);
ctx.beginPath();
ctx.moveTo(0.5, 0.5);
ctx.lineTo(0.5, 15.5);
ctx.lineTo(4.5, 11.5);
ctx.lineTo(7, 17.5);
ctx.lineTo(9.5, 16.5);
ctx.lineTo(7, 10.5);
ctx.lineTo(11.5, 10.5);
ctx.closePath();
ctx.fillStyle = 'white';
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 1;
ctx.stroke();
return { tex: canvasTexture(device, cv), w: 12, h: 19 };
}
// -- the compositor ---------------------------------------------------------
const key = (s) => `${s.token}:${s.client}:${s.surface}`;
@@ -389,7 +392,6 @@ class Compositor {
#device;
#painter;
#text;
#cursorFallback;
#channel;
/** Guest memories to read pixels from, by the token of the process. */
#memories = new Map();
@@ -442,7 +444,6 @@ class Compositor {
this.#painter.ctx2d = canvas.getContext('2d', { alpha: false });
}
this.#text = new TextCache(device);
this.#cursorFallback = defaultCursor(device);
this.resize(canvas.width, canvas.height);
}
@@ -539,7 +540,7 @@ class Compositor {
this.#reportPosition(win);
} else if (s.role === ROLE_TOPLEVEL) {
// Cascade, so two windows that appear together are both reachable.
const step = 28;
const step = Math.round(28 * UI);
const n = this.#placed++;
const roomX = Math.max(1, this.#outW - s.width - BORDER);
const roomY = Math.max(1, this.#outH - s.height - TITLE_H - BORDER);
@@ -550,13 +551,25 @@ class Compositor {
this.#reportPosition(win);
}
}
// Device pixels from here on. A surface's buffer is `bs` pixels per
// surface unit and its client was told `cs` device pixels per unit
// (wl_output.scale), so it covers width * cs / bs device pixels: 1:1
// when the client drew at the output's scale. X windows are cs = 1 and
// device pixels throughout (the X server is told the device size).
const bs = (s.flags >> 8) & 0xff || 1;
const cs = (s.flags >> 16) & 0xff || 1;
win.role = s.role;
win.width = s.width;
win.height = s.height;
win.bs = bs;
win.cs = cs;
win.width = Math.round((s.width * cs) / bs);
win.height = Math.round((s.height * cs) / bs);
win.title = s.title;
win.parent = s.parent;
win.relX = s.relX;
win.relY = s.relY;
win.relX = s.relX * cs;
win.relY = s.relY * cs;
// A cursor's hotspot, in its surface's units (CSS pixels for the page).
win.hotX = s.relX;
win.hotY = s.relY;
win.info = s;
// A client that asked to be dragged (a GTK titlebar) starts a drag as if
// the pointer had grabbed our own frame.
@@ -741,7 +754,7 @@ class Compositor {
const f = this.#frameOf(win);
return {
x: f.frameX + f.frameW - BUTTON - BUTTON_GAP,
y: f.frameY + (TITLE_H - BUTTON) / 2,
y: f.frameY + Math.floor((TITLE_H - BUTTON) / 2),
w: BUTTON,
h: BUTTON,
};
@@ -750,15 +763,15 @@ class Compositor {
#drawDecoration(win, active) {
const p = this.#painter;
const f = this.#frameOf(win);
p.solid(f.frameX + 3, f.frameY + 4, f.frameW, f.frameH, COL.shadow);
p.solid(f.frameX + Math.round(3 * UI), f.frameY + Math.round(4 * UI), f.frameW, f.frameH, COL.shadow);
p.solid(f.frameX, f.frameY, f.frameW, f.frameH, COL.border);
p.solid(f.frameX, f.frameY, f.frameW, TITLE_H, active ? COL.titleActive : COL.titleIdle);
const label = win.title || 'window';
const t = this.#text.get(label);
const room = f.frameW - BUTTON - 2 * BUTTON_GAP - 10;
const room = f.frameW - BUTTON - 2 * BUTTON_GAP - Math.round(10 * UI);
const w = Math.min(t.w, Math.max(0, room));
if (w > 4) {
p.textured(f.frameX + 8, f.frameY + (TITLE_H - t.h) / 2, w, t.h, t.tex, {
p.textured(f.frameX + Math.round(8 * UI), f.frameY + Math.floor((TITLE_H - t.h) / 2), w, t.h, t.tex, {
uv: [0, 0, w / t.w, 1],
});
}
@@ -767,8 +780,8 @@ class Compositor {
// The cross, from the same text cache as the title: one texture and one
// draw, rather than a quad per pixel of two diagonals.
const x = this.#text.get('\u2715');
const size = Math.min(b.w, b.h) - 6;
p.textured(b.x + (b.w - size) / 2, b.y + (b.h - size) / 2, size, size, x.tex);
const size = Math.min(b.w, b.h) - Math.round(6 * UI);
p.textured(b.x + Math.floor((b.w - size) / 2), b.y + Math.floor((b.h - size) / 2), size, size, x.tex);
}
#drawSurface(k, win) {
@@ -852,7 +865,8 @@ class Compositor {
/** What the output shows, as a string: equal strings, equal pixels (a GL
* window's frame counted by its presented serial). */
#signature() {
const parts = [this.#outW, this.#outH, this.#focus, this.#pointer.x, this.#pointer.y, this.#order.join(',')];
// Not the pointer: the cursor is the page's (`#updateCursor`), not drawn.
const parts = [this.#outW, this.#outH, this.#focus, this.#order.join(',')];
for (const [k, win] of this.#windows) {
const f = win.role === ROLE_TOPLEVEL ? this.#frameOf(win) : null;
parts.push(k, win.role, win.width, win.height, win.relX, win.relY, win.title, win.parent,
@@ -874,7 +888,6 @@ class Compositor {
#directWanted = false;
#direct = null; // { k, w, h, ox, oy } while presenting directly
#directSerial = -1;
#cursorSerial = -1;
setDirect(on) {
this.#directWanted = !!on;
@@ -903,12 +916,10 @@ class Compositor {
this.#canvas.width = d.w;
this.#canvas.height = d.h;
this.#directSerial = -1;
this.#cursorSerial = -1;
post({ kind: 'direct', w: d.w, h: d.h });
this.#note(`direct present ${d.w}x${d.h} (${d.k})`);
}
this.#direct = { k: d.k, w: d.w, h: d.h, ox: d.ox, oy: d.oy };
this.#directCursor(byKey);
for (const b of blocks) ackBlock(this.#channel, b.block, b.gen);
if (d.gl.serial === this.#directSerial) {
this.#skipped++;
@@ -932,41 +943,93 @@ class Compositor {
this.#note('direct present off: compositing');
}
/* The client's cursor as the page's CSS cursor: none when it hid it (a
* game drawing its own), its image when it set one. Read from the guest's
* buffer when its serial moves. */
#directCursor(byKey) {
let found = null;
for (const [k, win] of this.#windows) if (win.role === ROLE_CURSOR) { found = [k, win]; break; }
if (!found) {
if (this.#cursorSerial !== 0) post({ kind: 'cursor', css: 'none' });
this.#cursorSerial = 0;
return;
/* The pointer is the page's own CSS cursor, never drawn into the frame:
* the image the client under the pointer set (wl_pointer.set_cursor, which
* is also how Xwayland shows an X cursor), `none` when that client set no
* image (a game drawing its own), and the browser's default over the
* desktop and the frames this compositor draws. An image is read from the
* guest's buffer when its serial moves, and encoded once per distinct
* content: a client that re-commits the same arrow on every motion costs a
* hash, not a PNG. */
#cursorCss = '';
#cursorImages = new Map(); // content hash -> css, or a promise of it
#updateCursor(byKey) {
let css = 'default';
const overKey = this.#direct ? this.#direct.k : this.#drag ? null : this.#pointer.over;
const over = overKey ? this.#windows.get(overKey) : null;
if (over) {
let cur = null;
for (const [k, w] of this.#windows) {
if (w.role === ROLE_CURSOR && w.token === over.token && w.client === over.client) {
cur = [k, w];
break;
}
}
if (!cur) css = 'none';
else {
const got = this.#cursorImage(byKey.get(cur[0]), cur[1]);
if (got === null) return; // still encoding: keep what is shown
css = got;
}
}
const [k, win] = found;
const s = byKey.get(k);
if (!s || !s.addr || s.serial === this.#cursorSerial) return;
this.#cursorSerial = s.serial;
if (css === this.#cursorCss) return;
this.#cursorCss = css;
post({ kind: 'cursor', css });
}
/** The CSS for a cursor surface's image, or null while it is encoded. */
#cursorImage(s, win) {
if (!s || !s.addr || s.width <= 0 || s.height <= 0 || s.width > 256 || s.height > 256) return 'none';
if (win.cursorSerial === s.serial && win.cursorAddr === s.addr && win.cursorCss) return win.cursorCss;
const mem = this.#memoryOf(s.token);
if (!mem || s.width <= 0 || s.height <= 0 || s.width > 256 || s.height > 256) return;
if (!mem || s.addr + (s.height - 1) * s.stride + s.width * 4 > mem.length) return 'none';
const px = new Uint8ClampedArray(s.width * s.height * 4);
const opaque = s.format === 1;
let h = 2166136261 ^ s.width ^ (s.height << 12) ^ (win.hotX << 20) ^ (win.hotY << 26) ^ (win.bs << 8);
for (let y = 0; y < s.height; y++) {
for (let x = 0; x < s.width; x++) {
const i = s.addr + y * s.stride + x * 4;
const o = (y * s.width + x) * 4;
px[o] = mem[i + 2]; px[o + 1] = mem[i + 1]; px[o + 2] = mem[i]; px[o + 3] = win.info?.format === 1 ? 255 : mem[i + 3];
px[o] = mem[i + 2]; px[o + 1] = mem[i + 1]; px[o + 2] = mem[i]; px[o + 3] = opaque ? 255 : mem[i + 3];
h = Math.imul(h ^ (px[o] | (px[o + 1] << 8) | (px[o + 2] << 16) | (px[o + 3] << 24)), 16777619);
}
}
const cv = new OffscreenCanvas(s.width, s.height);
cv.getContext('2d').putImageData(new ImageData(px, s.width, s.height), 0, 0);
const hx = Math.max(0, win.relX | 0), hy = Math.max(0, win.relY | 0);
cv.convertToBlob().then((blob) => {
post({ kind: 'cursor', css: `url(${URL.createObjectURL(blob)}) ${hx} ${hy}, default` });
}).catch(() => {});
const key = `${h >>> 0}`;
const known = this.#cursorImages.get(key);
if (typeof known === 'string') {
win.cursorSerial = s.serial;
win.cursorAddr = s.addr;
win.cursorCss = known;
return known;
}
if (!known) {
const cv = new OffscreenCanvas(s.width, s.height);
cv.getContext('2d').putImageData(new ImageData(px, s.width, s.height), 0, 0);
const hx = Math.max(0, win.hotX | 0);
const hy = Math.max(0, win.hotY | 0);
const bs = win.bs || 1;
this.#cursorImages.set(key, cv.convertToBlob().then((blob) => {
const url = URL.createObjectURL(blob);
// A buffer drawn at a scale is that many pixels per CSS pixel.
const img = bs > 1 ? `-webkit-image-set(url(${url}) ${bs}x)` : `url(${url})`;
this.#cursorImages.set(key, `${img} ${hx} ${hy}, default`);
if (this.#cursorImages.size > 64) {
for (const [k, v] of this.#cursorImages) {
if (k === key) continue;
if (typeof v === 'string') URL.revokeObjectURL(v.slice(v.indexOf('url(') + 4, v.indexOf(')')));
this.#cursorImages.delete(k);
if (this.#cursorImages.size <= 32) break;
}
}
}).catch(() => this.#cursorImages.delete(key)));
}
return null;
}
frame() {
const { byKey, blocks } = this.#reconcile();
this.#updateCursor(byKey);
const d = this.#directTarget();
if (d) {
this.#frameDirect(d, byKey, blocks);
@@ -975,6 +1038,7 @@ class Compositor {
if (this.#direct) this.#leaveDirect();
// Upload every surface whose content moved on.
for (const [k, s] of byKey) {
if (s.role === ROLE_CURSOR) continue; // the page's cursor, not a texture
const t = this.#textures.get(k);
if (t && t.serial === s.serial) continue;
this.#upload(k, s);
@@ -1012,27 +1076,8 @@ class Compositor {
const win = this.#windows.get(k);
if (win) this.#drawSurface(k, win);
}
// The cursor the client asked for, at the pointer, offset by its hotspot.
let drew = false;
for (const [k, win] of this.#windows) {
if (win.role !== ROLE_CURSOR) continue;
const t = this.#textures.get(k);
if (!t) continue;
this.#painter.textured(
this.#pointer.x - win.relX,
this.#pointer.y - win.relY,
win.width,
win.height,
t.tex,
{ swizzle: 1 },
);
drew = true;
break;
}
if (!drew) {
const c = this.#cursorFallback;
this.#painter.textured(this.#pointer.x, this.#pointer.y, c.w, c.h, c.tex);
}
// No cursor: the client's is the page's own CSS cursor (`#updateCursor`),
// so there is one pointer on the screen and it moves at the browser's rate.
this.#painter.end();
for (const b of blocks) ackBlock(this.#channel, b.block, b.gen);
this.#frames++;
@@ -1100,8 +1145,9 @@ class Compositor {
if (win.configuredAs === want) return;
win.configuredAs = want;
this.#note(`configure ${win.key} ${want}`);
sendEvent(this.#channel, win.block, EV_CONFIGURE, fit.w, fit.h, 0, activated ? 1 : 0,
win.client, win.surface);
const k = win.cs || 1;
sendEvent(this.#channel, win.block, EV_CONFIGURE, Math.floor(fit.w / k), Math.floor(fit.h / k), 0,
activated ? 1 : 0, win.client, win.surface);
}
#focusWindow(k) {
@@ -1162,8 +1208,9 @@ class Compositor {
}
this.#pointer.over = over;
if (!hit || hit.part !== 'content') return;
sendEvent(this.#channel, hit.win.block, EV_MOTION, hit.local.x, hit.local.y, time, 0,
hit.win.client, hit.win.surface);
const k = hit.win.cs || 1;
sendEvent(this.#channel, hit.win.block, EV_MOTION, Math.floor(hit.local.x / k), Math.floor(hit.local.y / k),
time, 0, hit.win.client, hit.win.surface);
}
pointerButton(button, pressed, time) {
@@ -1215,8 +1262,9 @@ class Compositor {
// The press lands where the pointer is, so the client's last motion has
// to be this position -- a click without a motion in front of it would
// otherwise arrive wherever the pointer was last seen by that client.
sendEvent(this.#channel, hit.win.block, EV_MOTION, hit.local.x, hit.local.y, time, 0,
hit.win.client, hit.win.surface);
const k = hit.win.cs || 1;
sendEvent(this.#channel, hit.win.block, EV_MOTION, Math.floor(hit.local.x / k),
Math.floor(hit.local.y / k), time, 0, hit.win.client, hit.win.surface);
}
sendEvent(this.#channel, hit.win.block, EV_BUTTON, button, pressed ? 1 : 0, time, 0,
hit.win.client, hit.win.surface);
@@ -1301,6 +1349,7 @@ class Compositor {
// The GL device's counters (submits, their time, readbacks): the
// compositor side of a frame's cost.
gl: glDevice?.glState?.() ?? null,
turns,
};
}
}
@@ -1314,9 +1363,12 @@ let post = (m, transfer) => self.postMessage(m, transfer ?? []);
let comp = null;
let canvas = null;
let running = false;
/** Turns of the frame loop, for a test: a loop that stops reads as a still screen. */
let turns = 0;
function loop() {
if (!comp || !running) return;
turns++;
try {
// A guest paces itself against frames *displayed*, not produced: its
// `eglSwapBuffers` waits for this count to move. On the page that was the
@@ -1480,12 +1532,13 @@ async function serveGlRing() {
const onMessage = ({ data: m }) => {
try {
if (m.kind === 'init') {
canvas = m.canvas;
presentOverride = m.present ?? null;
canvas = m.canvas;
setUiScale(m.scale || 1);
// The compositor draws with WebGPU on the device Mesa will use.
if (m.glLimits != null) globalThis.GEX_GL_LIMITS = m.glLimits;
requestGexDevice().then((gpu) => {
comp = new Compositor(m.canvas, m.channel, m.stats ? new Int32Array(m.stats) : null, gpu);
comp = new Compositor(canvas, m.channel, m.stats ? new Int32Array(m.stats) : null, gpu);
comp.setDirect(m.direct);
running = true;
requestAnimationFrame(loop);
+12 -6
View File
@@ -44,10 +44,13 @@ export class Compositor {
#stats;
/** `makeWorker`: what stands in for compositor-worker.js's Worker (the
* single-thread build's system Worker hosts it; see runtime.js). */
static async create(canvas, channel, inputTarget = canvas, { makeWorker = null } = {}) {
* single-thread build's system Worker hosts it; see runtime.js).
* `scale`: the page's devicePixelRatio when the canvas's backing store is
* in device pixels (the frames and titles the compositor draws are scaled
* by it). */
static async create(canvas, channel, inputTarget = canvas, { makeWorker = null, scale = 1 } = {}) {
const c = new Compositor();
await c.#start(canvas, channel, inputTarget, makeWorker);
await c.#start(canvas, channel, inputTarget, makeWorker, { scale });
return c;
}
@@ -84,7 +87,7 @@ export class Compositor {
});
}
async #start(canvas, channel, inputTarget, makeWorker) {
async #start(canvas, channel, inputTarget, makeWorker, { scale = 1 } = {}) {
const sameTarget = inputTarget === canvas;
// A canvas can be handed to a worker once and never again, and a page
// that runs a session twice hands over the same element the second time:
@@ -124,7 +127,9 @@ export class Compositor {
if (m.kind === 'direct') {
this.#setDirect(m.w ? { w: m.w, h: m.h } : null);
} else if (m.kind === 'cursor') {
if (this.#direct) this.#canvas.style.cursor = m.css;
// The client's cursor, as the browser's own: one pointer, never a
// second one drawn into the frame.
this.#canvas.style.cursor = m.css;
this.#cursorCss = m.css;
} else if (m.kind === 'describe' || m.kind === 'wgsl-cache' || m.kind === 'readout') {
this.#pending.get(m.id)?.(m.kind === 'wgsl-cache' ? { blob: m.blob, stats: m.stats } : m.state);
@@ -147,7 +152,8 @@ export class Compositor {
// 4.6 Muck's 3D scene drew black, so 43 is only per run.
const glLimits = pq?.has('gllimits') ? Number(pq.get('gllimits')) || 0 : null;
worker.postMessage(
{ kind: 'init', canvas: offscreen, channel, stats: this.#stats, present, direct, glLimits },
{ kind: 'init', canvas: offscreen, width: canvas.width, height: canvas.height, scale,
channel, stats: this.#stats, present, direct, glLimits },
[offscreen],
);
await ready;
+139
View File
@@ -0,0 +1,139 @@
:root {
--panel-h: 30px;
--bg: #1d2128;
--panel: #12151a;
--panel-fg: #d8dde6;
--muted: #8a93a3;
--accent: #6ee7a8;
--win: #232831;
--win-title: #2c323d;
--border: #3a4250;
--hover: #323a47;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--panel-fg);
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
#panel {
position: fixed;
inset: 0 0 auto 0;
height: var(--panel-h);
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
background: var(--panel);
border-bottom: 1px solid #000;
z-index: 10;
user-select: none;
}
.pbtn {
background: none;
border: 0;
color: var(--panel-fg);
font: inherit;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
}
.pbtn:hover, .pbtn.on { background: var(--hover); }
#apps { font-weight: 600; }
#tasks { flex: 1; display: flex; gap: 4px; overflow: hidden; }
#pstatus { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }
#clock { color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 6px; }
#screen {
position: fixed;
inset: var(--panel-h) 0 0 0;
background: radial-gradient(ellipse at 30% 20%, #2a3140 0%, #1a1e25 60%, #14171c 100%);
}
#out { display: block; width: 100%; height: 100%; outline: none; }
#splash {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(16, 19, 24, 0.85);
z-index: 5;
}
#splash.gone { display: none; }
.splash-card { width: min(360px, 80vw); text-align: center; }
.logo { font-size: 28px; font-weight: 700; letter-spacing: 0.02em; margin-bottom: 10px; }
#splash-text { color: var(--muted); min-height: 1.4em; }
.bar { height: 4px; background: #2a303a; border-radius: 2px; margin-top: 10px; overflow: hidden; }
#splash-bar { height: 100%; width: 0; background: var(--accent); transition: width 0.15s; }
.dwin {
position: fixed;
background: var(--win);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
z-index: 8;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dwin.hidden { display: none; }
.dwin-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 6px 5px 12px;
background: var(--win-title);
cursor: move;
user-select: none;
font-weight: 600;
}
.dwin-close {
background: none;
border: 0;
color: var(--muted);
font-size: 18px;
line-height: 1;
width: 24px;
height: 22px;
border-radius: 4px;
cursor: pointer;
}
.dwin-close:hover { background: #c94f4f; color: #fff; }
.dwin-body { flex: 1; overflow: auto; }
#launcher { width: 340px; max-height: calc(100vh - 80px); }
.lhint { color: var(--muted); margin: 10px 12px 4px; font-size: 12px; }
#items { list-style: none; margin: 0; padding: 4px 6px 8px; }
#items li {
display: grid;
grid-template-columns: 34px 1fr auto;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
}
#items li:hover { background: var(--hover); }
#items li.busy { cursor: progress; }
.icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 15px;
color: #10131a;
}
.iname { font-weight: 600; }
.idesc { color: var(--muted); font-size: 12px; }
.istate { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.istate.ok { color: var(--accent); }
.istate.bad { color: #f07a7a; }
#console { width: min(760px, 90vw); height: 380px; }
#console .dwin-body { background: #0b0d10; padding: 4px; overflow: hidden; }
#term { width: 100%; height: 100%; }
@media (max-width: 600px) {
#pstatus { display: none; }
#launcher { width: calc(100vw - 32px); }
}
+512
View File
@@ -0,0 +1,512 @@
// The demo desktop (docs/gexcc.md §9): one page that is a desktop.
//
// The compositor's output fills the screen; the panel, the launcher and the
// console are the page's own DOM, so the shell chrome costs the guest's
// rootfs nothing. At start two windows are open: an xterm running bash (on the
// X server in this page) and the launcher. pid 1 is bash reading command lines
// from the page's terminal -- the console -- and the launcher starts a program
// by typing its command line there, after adding its files to the running
// system (overlay-worker.js).
import '@xterm/xterm/css/xterm.css';
import './desktop.css';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { loadRuntime, fetchBytes, runProgram } from './runtime.js';
import { untar } from './tar.js';
import { unzstd } from './zfetch.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
// `?strace=1` / `?stracepid=N`: narrate system calls (to the console), as
// the other pages do.
if (q.get('strace')) globalThis.GEX_STRACE = 1;
if (q.has('stracepid')) globalThis.GEX_STRACE_PID = Number(q.get('stracepid'));
const T0 = performance.now();
const marks = {};
const mark = (k) => {
marks[k] = Math.round(performance.now() - T0);
};
globalThis.gexDesktop = { marks, ready: false };
// The page-wide unit cache, as the demo page has it (main.js).
globalThis.GEX_UNIT_CACHE = q.has('unitcache') ? Number(q.get('unitcache')) : 1;
if (q.get('debug')) {
globalThis.GEX_DEBUG_EXIT = 1;
globalThis.GEX_TRAP_STACK = 1;
}
// -- chrome -------------------------------------------------------------------
const kib = (n) => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
function splash(text, frac) {
$('splash-text').textContent = text;
if (frac !== undefined) $('splash-bar').style.width = `${Math.round(Math.min(1, frac) * 100)}%`;
}
function status(text) {
$('pstatus').textContent = text;
}
setInterval(() => {
$('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}, 1000);
$('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
/** A page-side window: draggable by its title, closed by its button. */
let zTop = 20;
function dwin(el, { x, y, onClose } = {}) {
if (x !== undefined) {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}
const raise = () => {
el.style.zIndex = String(++zTop);
};
el.addEventListener('pointerdown', raise);
const title = el.querySelector('.dwin-title');
title.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const r = el.getBoundingClientRect();
const dx = e.clientX - r.left;
const dy = e.clientY - r.top;
title.setPointerCapture(e.pointerId);
const move = (m) => {
el.style.left = `${Math.max(0, Math.min(innerWidth - 60, m.clientX - dx))}px`;
el.style.top = `${Math.max(30, Math.min(innerHeight - 30, m.clientY - dy))}px`;
};
const up = () => {
title.removeEventListener('pointermove', move);
title.removeEventListener('pointerup', up);
};
title.addEventListener('pointermove', move);
title.addEventListener('pointerup', up);
});
el.querySelector('.dwin-close').addEventListener('click', () => {
el.classList.add('hidden');
onClose?.();
});
return {
show() {
el.classList.remove('hidden');
raise();
},
hide() {
el.classList.add('hidden');
},
toggle() {
if (el.classList.contains('hidden')) this.show();
else this.hide();
return !el.classList.contains('hidden');
},
};
}
const launcherWin = dwin($('launcher'), {
x: Math.max(16, innerWidth - 340 - 40),
y: 60,
onClose: () => $('apps').classList.remove('on'),
});
const consoleWin = dwin($('console'), {
x: 60,
y: Math.max(60, innerHeight - 440),
onClose: () => $('console-btn').classList.remove('on'),
});
$('apps').addEventListener('click', () => $('apps').classList.toggle('on', launcherWin.toggle()));
// The console: pid 1's terminal. Hidden unless asked for.
const term = new Terminal({
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
fontSize: 12,
convertEol: false,
scrollback: 5000,
theme: { background: '#0b0d10', foreground: '#d6dbe4' },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open($('term'));
$('console-btn').addEventListener('click', () => {
const on = consoleWin.toggle();
$('console-btn').classList.toggle('on', on);
if (on) fit.fit();
});
window.gexTerminalText = () => {
const b = term.buffer.active;
const out = [];
// A wrapped row continues the line above it, as it was written.
for (let i = 0; i < b.length; i++) {
const l = b.getLine(i);
const t = l?.translateToString(true) ?? '';
if (l?.isWrapped && out.length) out[out.length - 1] += t;
else out.push(t);
}
return out.join('\n');
};
// -- the launcher ---------------------------------------------------------------
/** What the launcher offers, from the build's pkgs/index.json. */
async function catalogue() {
try {
const r = await fetch('/pkgs/index.json', { cache: 'no-cache' });
if (r.ok && (r.headers.get('content-type') || '').includes('json')) return await r.json();
} catch {}
return { items: [] };
}
let system = null;
let sysReady = null;
const sysUp = new Promise((r) => (sysReady = r));
const installed = new Set();
const installing = new Map();
let overlayWorker = null;
let overlayStack;
const overlayWaiters = [];
function overlay() {
if (overlayWorker) return overlayWorker;
overlayWorker = new Worker(new URL('./overlay-worker.js', import.meta.url), { type: 'module' });
overlayWorker.onmessage = (e) => {
const m = e.data;
if (m.progress) {
const w = overlayWaiters.find((x) => x.name === m.progress);
w?.onProgress?.(m.got, m.total);
return;
}
const i = overlayWaiters.findIndex((x) => x.name === (m.done ?? m.name));
if (i < 0) return;
const [w] = overlayWaiters.splice(i, 1);
if (m.error) w.reject(new Error(m.error));
else w.resolve(m);
};
return overlayWorker;
}
/** Add one package's overlay to the running system (once). */
function install(item, cat, onProgress) {
if (installed.has(item.id)) return Promise.resolve(null);
if (installing.has(item.id)) return installing.get(item.id);
const p = (async () => {
for (const d of item.deps ?? []) {
const dep = cat.items.find((x) => x.id === d) ?? cat.hidden?.find((x) => x.id === d);
if (dep) await install(dep, cat, onProgress);
}
await sysUp;
while (overlayStack === undefined) {
overlayStack = system.host.takeKernelStack('overlay');
if (overlayStack === undefined) await new Promise((r) => setTimeout(r, 100));
}
const r = await new Promise((resolve, reject) => {
overlayWaiters.push({ name: item.id, resolve, reject, onProgress });
overlay().postMessage({
spec: system.kernel,
stackTop: overlayStack,
url: `/pkgs/${item.pkg ?? item.id}.tar.zst`,
name: item.id,
});
});
installed.add(item.id);
console.log(`[desktop] ${item.id}: ${r.files} files, ${kib(r.bytes)} from ${kib(r.compressed)}; ` +
`fetch ${r.fetchMs} ms, decode ${r.decodeMs} ms, write ${r.writeMs} ms`);
globalThis.gexDesktop.installs = { ...(globalThis.gexDesktop.installs ?? {}), [item.id]: r };
return r;
})();
installing.set(item.id, p);
p.finally(() => installing.delete(item.id));
return p;
}
/** `s` as one shell word. */
const shq = (s) => `'${s.replaceAll("'", "'\\''")}'`;
/** Type a command line to pid 1, which runs it in the background. */
function run(cmd) {
system.tty?.input(`${cmd}\n`);
}
globalThis.gexDesktop.run = (cmd) => run(cmd);
async function launch(item, li, cat) {
const state = li.querySelector('.istate');
if (li.classList.contains('busy')) return;
li.classList.add('busy');
state.className = 'istate';
try {
if (!installed.has(item.id)) {
state.textContent = 'downloading';
await install(item, cat, (got, total) => {
state.textContent = total ? `${Math.round((100 * got) / total)}%` : kib(got);
});
}
state.textContent = 'starting';
await sysUp;
// A GL program: the host's Mesa first (fetched on first use).
if (item.gl) {
state.textContent = 'starting GL';
await system.startGl?.();
}
// In a session of its own, off pid 1's terminal: a program that takes
// the terminal (gtk3-demo does) would otherwise leave pid 1 unable to
// read the next command line. Its output goes to /tmp/launch-ID.log.
run(`setsid -f sh -c ${shq(item.cmd)} </dev/null >/tmp/launch-${item.id}.log 2>&1`);
state.textContent = 'open';
state.className = 'istate ok';
} catch (e) {
state.textContent = 'failed';
state.className = 'istate bad';
state.title = String(e.message ?? e);
console.error(e);
} finally {
li.classList.remove('busy');
}
}
async function fillLauncher() {
const cat = await catalogue();
const ul = $('items');
ul.replaceChildren();
for (const item of cat.items) {
const li = document.createElement('li');
li.dataset.id = item.id;
const icon = document.createElement('div');
icon.className = 'icon';
icon.style.background = item.color ?? '#7aa2f7';
icon.textContent = item.glyph ?? item.label.slice(0, 1).toUpperCase();
const text = document.createElement('div');
text.innerHTML = '<div class="iname"></div><div class="idesc"></div>';
text.querySelector('.iname').textContent = item.label;
text.querySelector('.idesc').textContent = item.desc ?? '';
const state = document.createElement('div');
state.className = 'istate';
state.textContent = item.size ? kib(item.size) : '';
if (item.base) {
installed.add(item.id);
state.textContent = 'installed';
}
li.append(icon, text, state);
li.addEventListener('click', () => launch(item, li, cat));
ul.append(li);
}
globalThis.gexDesktop.launch = (id) => {
const li = ul.querySelector(`li[data-id="${id}"]`);
const item = cat.items.find((x) => x.id === id);
return item && li ? launch(item, li, cat) : Promise.reject(new Error(`no item ${id}`));
};
globalThis.gexDesktop.install = (id) => {
const item = cat.items.find((x) => x.id === id) ?? cat.hidden?.find((x) => x.id === id);
return item ? install(item, cat) : Promise.reject(new Error(`no item ${id}`));
};
return cat;
}
// -- boot ---------------------------------------------------------------------------
/** The page's density: the output is in device pixels (see `boot`). */
const DPR = Math.max(1, Number(q.get('dpr')) || globalThis.devicePixelRatio || 1);
/** X resources for this density. The X server's screen is in device pixels
* (X clients draw 1:1, never resampled), so at a high density an X client
* is told the DPI to size its fonts by, and xterm draws with an outline
* font instead of the 13-pixel bitmap `fixed`, which would be half size. */
function xdefaults() {
if (DPR < 1.25) return '';
return [
`Xft.dpi: ${Math.round(96 * DPR)}`,
'Xft.antialias: true',
'Xft.hinting: true',
'Xft.hintstyle: hintslight',
'Xft.rgba: none',
'XTerm*faceName: DejaVu Sans Mono',
'XTerm*faceSize: 9',
`Xcursor.size: ${Math.round(16 * DPR)}`,
'',
].join('\n');
}
/** The guest's session: pid 1. */
function session(cat) {
const xterm = q.get('xterm') ?? cat.xterm ?? 'xterm -geometry 92x30+24+24 -e bash -l';
return [
'# The desktop session (web/src/desktop.js): pid 1.',
'export DISPLAY=:0 XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0',
'export HOME=/root SHELL=/bin/bash TERM=xterm-256color LANG=C.UTF-8',
'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
'cd /root',
`${xterm} &`,
'# The launcher types a command line here; each runs in the background.',
'while IFS= read -r line; do',
' [ -n "$line" ] || continue',
' ( eval "$line" ) &',
'done',
'',
].join('\n');
}
async function boot() {
const canvas = $('out');
// The backing store in device pixels, so nothing the compositor shows is
// stretched by the browser: at devicePixelRatio 2 a canvas sized in CSS
// pixels was every window's pixels doubled and smoothed.
const deviceSize = () => {
const r = canvas.getBoundingClientRect();
return [Math.max(320, Math.round(r.width * DPR)), Math.max(240, Math.round(r.height * DPR))];
};
[canvas.width, canvas.height] = deviceSize();
splash('fetching the runtime', 0);
const rt = loadRuntime((got, total) => {
splash(`fetching the runtime — ${kib(got)}${total ? ` of ${kib(total)}` : ''}`, total ? (0.5 * got) / total : undefined);
});
const catP = fillLauncher();
const fsUrl = q.get('rootfs') || '/rootfs/base.tar.zst';
const fsP = fetchBytes(fsUrl).then(async (z) => {
mark('rootfsFetched');
const raw = fsUrl.endsWith('.zst') ? await unzstd(z) : z;
return { tree: untar(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength)), z: z.length, raw: raw.length };
});
// The network (Wisp), for apt and anything else that reaches out: started
// beside the downloads, and the desktop does not wait for a slow proxy
// past the rootfs. `?wisp=` picks the proxy, `?wisp=` empty turns it off.
const wisp = q.has('wisp') ? q.get('wisp') : 'wss://anura.pro/';
const netP = wisp
? import('./net.js').then(async ({ WispNetwork }) => {
const n = new WispNetwork(wisp, (e) => status(`network: ${e.split(' ')[0]}`));
await n.start();
mark('network');
return n;
}).catch((e) => {
console.warn(`[desktop] no network: ${e.message ?? e}`);
return null;
})
: Promise.resolve(null);
await rt;
mark('runtime');
splash('fetching the system', 0.6);
const [{ tree, z, raw }, cat] = await Promise.all([fsP, catP]);
const network = await Promise.race([netP, new Promise((r) => setTimeout(() => r(null), 3000))]);
if (!network) status('network: off');
mark('rootfs');
globalThis.gexDesktop.rootfs = { compressed: z, bytes: raw, files: Object.keys(tree.files).length };
splash('starting X and the shell', 0.8);
const files = { ...tree.files, '/etc/gex-desktop/session': new TextEncoder().encode(session(cat)) };
const xd = xdefaults();
if (xd) files['/root/.Xdefaults'] = new TextEncoder().encode(xd);
const elf = files['/usr/bin/bash'];
if (!elf) throw new Error(`${fsUrl} has no /usr/bin/bash`);
// Both windows are up when the compositor has mapped one (the xterm: the
// launcher is the page's own).
const watch = setInterval(() => {
const c = system?.compositor?.counters?.();
if (c && c.windows >= 1) {
clearInterval(watch);
mark('desktop');
$('splash').classList.add('gone');
launcherWin.show();
$('apps').classList.add('on');
status('');
globalThis.gexDesktop.ready = true;
console.log(`[desktop] up: ${JSON.stringify(marks)}`);
// The `auto` items (the core tools) come in behind the desktop, so the
// xterm's shell has `ls` a moment later without the base carrying it.
if (!q.has('noauto')) {
for (const item of cat.items.filter((x) => x.auto)) {
const li = $('items').querySelector(`li[data-id="${item.id}"]`);
const st = li?.querySelector('.istate');
if (st) st.textContent = 'adding';
install(item, cat)
.then(() => {
if (st) { st.textContent = 'installed'; st.className = 'istate ok'; }
mark(`auto:${item.id}`);
})
.catch((e) => console.error(e));
}
}
}
}, 50);
const { status: st } = await runProgram(term, {
elf,
files,
dirs: tree.dirs,
modes: tree.modes,
hard: tree.hard,
symlinks: tree.links,
env: [
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
'HOME=/root',
'TERM=xterm-256color',
'LANG=C.UTF-8',
'DEBIAN_FRONTEND=noninteractive',
],
args: ['/bin/bash', '/etc/gex-desktop/session'],
wl: canvas,
wlInput: canvas,
wlScale: DPR,
hostX: true,
// GL programs marshal to Mesa in the compositor's Worker, started on the
// first GL item (`startGl`), so the first load does not carry it.
gl: 'compositor',
glLazy: true,
cwd: '/root',
tty: true,
uid: 0,
network,
onSystem: (s) => {
system = s;
mark('system');
// The output follows the window.
let pending = 0;
addEventListener('resize', () => {
clearTimeout(pending);
pending = setTimeout(() => s.compositor?.resize(...deviceSize()), 150);
});
sysReady(s);
},
});
clearInterval(watch);
status(`session ended (${st})`);
}
// `?diag=1`: what the compositor drew and what the page's canvas holds, twice
// a second, in a corner. A screen that does not change while "drawn" counts
// up and "held" changes is a browser not displaying frames the page has; a
// "drawn" that stays put is the compositor not seeing the client's frames.
// The corner box repaints the page twice a second, which may itself bring a
// stalled canvas up to date; `?diag=log` says the same in the console
// instead, every 2 s, and changes nothing on the page.
if (q.get('diag')) {
const quiet = q.get('diag') === 'log';
const box = document.createElement('div');
box.style.cssText = 'position:fixed;right:8px;bottom:8px;z-index:9;font:12px monospace;'
+ 'background:#000c;color:#9f9;padding:4px 6px;pointer-events:none;white-space:pre';
if (!quiet) document.body.append(box);
let lastHeld = '';
let ticks = 0;
let heldChanges = 0;
setInterval(() => {
const comp = globalThis.__gexCompositor;
const c = document.getElementById('out');
let held = '-';
try {
const t = new OffscreenCanvas(c.width, c.height);
const g = t.getContext('2d', { willReadFrequently: true });
g.drawImage(c, 0, 0);
const d = g.getImageData(0, 0, c.width, c.height).data;
let h = 2166136261;
for (let i = 0; i < d.length; i += 16) h = Math.imul(h ^ d[i] ^ (d[i + 1] << 8) ^ (d[i + 2] << 16), 16777619) >>> 0;
held = h.toString(16);
} catch (e) {
held = `! ${e?.message ?? e}`;
}
if (held !== lastHeld) heldChanges++;
lastHeld = held;
const n = comp?.counters?.() ?? {};
box.textContent = `drawn ${n.frames ?? '-'} held ${held} (${heldChanges} changes) ${c.width}x${c.height} dpr ${devicePixelRatio}`;
if (quiet && ticks++ % 4 === 0) console.log(`[diag] ${box.textContent}`);
}, 500);
}
boot().catch((e) => {
console.error(e);
splash(`could not start: ${e.message ?? e}`);
status('failed');
globalThis.gexDesktop.error = String(e?.stack ?? e);
});
+65 -4
View File
@@ -95,6 +95,24 @@ export class HostX {
#fds = new Map();
/** Pools of pixels, by descriptor: where they are in the server's memory. */
#pools = new Map();
/**
* The pools the compositor holds, by its own handle for each: what a
* descriptor sent on the Wayland connection became when it arrived.
*
* A descriptor is the server's name for a pool only until the server closes
* it, and Xwayland closes a pool's descriptor as soon as it has sent it --
* then the next pool it makes gets the same number. Handing the compositor
* the number made every buffer of the old pool resolve, at its next commit,
* to whichever pool had the number now. Typing into the xterm reuses its
* two buffers, and with both pools on one number both showed the second's
* memory: the window was a frame behind what it drew until something made
* a fresh pool (clicking off it and back on did). A cursor pool on the
* number was too small to hold the frame, and the commit was dropped. Over
* a socket `SCM_RIGHTS` gives the receiver its own descriptor for the same
* memory, and this is that: a handle per descriptor sent, never reused.
*/
#held = new Map();
#nextHeld = 1;
/**
* Descriptors owed to the server on its Wayland connection.
*
@@ -117,10 +135,17 @@ export class HostX {
#trace = 0;
/** Publishes so far, for the note in `#tick`. */
#published = 0;
/**
* `?hostxcount=1`: once a second, what moved through the server -- bytes in
* from and out to each X client, bytes to the compositor, scenes published.
* Where a picture stops between a client drawing and the page composing it.
*/
#count = null;
async start(msg) {
this.#channel = msg.wlChannel;
this.#trace = msg.trace | 0;
if (msg.count) this.#count = { at: Date.now(), n: new Map(), polls: 0 };
this.#token = msg.token;
const size = outputSize(msg.wlChannel);
this.#out = { w: size.w, h: size.h };
@@ -333,7 +358,7 @@ export class HostX {
/** Where the pool this descriptor names lives, for the compositor. */
#locate(fd, out) {
const pool = this.#pools.get(fd);
const pool = this.#held.get(fd);
this.#hwView();
const w = this.#hwWords;
if (!pool) {
@@ -342,7 +367,7 @@ export class HostX {
// healthy -- the surface is there, the commits arrive, the scene is
// published with an address of zero.
this.#once(`pool:${fd}`, `[hostx] descriptor ${fd} is not a pool`
+ ` (pools: ${[...this.#pools.keys()].join(',') || 'none'})`);
+ ` (held: ${this.#held.size})`);
w[out >> 2] = 0;
w[(out >> 2) + 1] = 0;
return 0;
@@ -399,6 +424,9 @@ export class HostX {
// reading them. So the allocation stays -- freeing it here is the one
// way to make an X window's contents vanish mid-frame.
self.#fds.delete(fd);
// The server's name for a pool goes with the descriptor; the compositor
// has its own (`#held`), and the memory stays (see above).
self.#pools.delete(fd);
try {
self.#x.FS.close(self.#x.FS.getStream(fd));
} catch {
@@ -442,7 +470,12 @@ export class HostX {
send(fd, buf, len, fds, nfds) {
const ep = self.#fds.get(fd);
if (!ep) return -EBADF;
if (ep.kind === 'kernel') return self.#k.k_sock_write(ep.id, buf, len);
if (ep.kind === 'kernel') {
const r = self.#k.k_sock_write(ep.id, buf, len);
if (self.#count && r > 0) self.#bump(`out${fd}`, r);
return r;
}
if (self.#count) self.#bump(ep.kind, len);
if (ep.kind === 'wl' || ep.kind === 'wm') {
// Into the compositor module's memory, which is a different address
// space: a protocol message, never a frame.
@@ -450,7 +483,17 @@ export class HostX {
const p = self.#hw.hw_scratch(pad + nfds * 4);
self.#hwView().set(self.#x.HEAPU8.subarray(buf, buf + len), p);
for (let i = 0; i < nfds; i++) {
self.#hwWords[((p + pad) >> 2) + i] = self.#x.HEAP32[(fds >> 2) + i];
// The compositor's own reference to the pool (see `#held`), not
// the server's descriptor number, which the server is about to
// close and reuse.
const sent = self.#x.HEAP32[(fds >> 2) + i];
const pool = ep.kind === 'wl' ? self.#pools.get(sent) : null;
let word = sent;
if (pool) {
word = self.#nextHeld++;
self.#held.set(word, pool);
}
self.#hwWords[((p + pad) >> 2) + i] = word;
}
if (nfds && self.#trace) {
const got = [];
@@ -472,6 +515,7 @@ export class HostX {
if (nfds) self.#x.HEAP32[nfds >> 2] = 0;
if (ep.kind === 'kernel') {
const n = self.#k.k_sock_read(ep.id, buf, len);
if (self.#count && n > 0) self.#bump(`in${fd}`, n);
return n;
}
if (ep.kind === 'wl' || ep.kind === 'wm') {
@@ -583,11 +627,28 @@ export class HostX {
this.log(`[hostx] keymap event: ${n} bytes as fd ${fd}`);
}
/** `?hostxcount=1`: `n` more bytes under `key` this second. */
#bump(key, n) {
const m = this.#count.n;
m.set(key, (m.get(key) ?? 0) + n);
}
/**
* Between two turns of the server's loop: run the window manager, take the
* page's input, publish the scene.
*/
#tick() {
if (this.#count) {
const c = this.#count;
c.polls++;
if (Date.now() - c.at >= 1000) {
const parts = [...c.n].map(([k, v]) => `${k}=${v}`).join(' ');
this.log(`[hostx-count] turns=${c.polls} pub=${this.#published} ${parts}`);
c.n.clear();
c.polls = 0;
c.at = Date.now();
}
}
// A hook for a harness: this is the only turn anything else gets once the
// server's `main` has the thread. See web/test/hostx.mjs.
globalThis.GEX_HOSTX_TICK?.();
+202
View File
@@ -0,0 +1,202 @@
// A package overlay, written into the running system's filesystem.
//
// The demo desktop's launcher (web/src/desktop.js) adds a program by fetching
// `pkgs/NAME.tar.zst` -- the program's closure minus the base rootfs, built by
// web/build-demo.sh -- and writing its files straight into the global kernel's
// filesystem while the guest runs. That is this Worker: an instance of the
// global kernel of its own (as `persist-worker.js` is), calling `k_fs_*`
// with the bytes in a scratch "process" memory. A Worker and not the page
// because the kernel's locks may wait, and the page's thread cannot.
//
// The tar's `var/lib/gex-overlay/NAME.status` holds the dpkg status stanzas
// of what it adds; they are appended to /var/lib/dpkg/status, so a later
// `apt` or `dpkg -l` sees the program as installed.
//
// Message in: { spec: { module, memory }, stackTop, url, name }
// Messages out: { progress, got, total }, { done, files, bytes, ms }, { error }
import { lazyFetch } from '../../shims/browser/js/lazy-fetch.mjs';
import { untar } from './tar.js';
import { isZstd, unzstd } from './zfetch.js';
const enc = new TextEncoder();
let k = null;
let mem = null;
let view = null;
async function kernel(spec, stackTop) {
if (k) return;
let inst;
for (const shared of [true, false]) {
mem = new WebAssembly.Memory({ initial: 16, maximum: 65535, shared });
try {
inst = await WebAssembly.instantiate(spec.module, {
env: { memory: spec.memory, process: mem, lazy_fetch: lazyFetch(spec.memory) },
});
break;
} catch (e) {
if (!shared) throw e;
}
}
k = inst.exports;
if (stackTop && k.__stack_pointer) k.__stack_pointer.value = stackTop | 0;
}
function bytes(need) {
const have = mem.buffer.byteLength;
if (need > have) mem.grow(Math.ceil((need - have) / 65536));
if (!view || view.buffer !== mem.buffer || view.length !== mem.buffer.byteLength) {
view = new Uint8Array(mem.buffer);
}
return view;
}
/** Two strings (or a string and bytes) laid out at 0 and after it. */
function put(a, b) {
const pa = enc.encode(a);
const pb = typeof b === 'string' ? enc.encode(b) : b;
const at = (pa.length + 15) & ~15;
const v = bytes(at + (pb?.length ?? 0) + 16);
v.set(pa, 0);
if (pb) v.set(pb, at);
return [0, pa.length, at, pb?.length ?? 0];
}
function readFile(path) {
const [pp, pl] = put(path);
const h = k.k_fs_open(pp, pl, 0n, 0);
if (h < 0) return null;
const parts = [];
let off = 0;
const at = 1 << 16;
for (;;) {
bytes(at + (1 << 20));
const n = k.k_fs_read(h, BigInt(off), at, 1 << 20);
if (n <= 0) break;
parts.push(new Uint8Array(mem.buffer).slice(at, at + n));
off += n;
}
k.k_fs_close(h);
const out = new Uint8Array(off);
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
function addFile(path, data) {
const [pp, pl, dp, dl] = put(path, data);
k.k_fs_unlink(pp, pl); // a symlink in the way would be written through
k.k_fs_add(pp, pl, dp, dl);
}
async function overlay({ url, name }) {
const t0 = performance.now();
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: ${res.status}`);
const total = Number(res.headers.get('content-length')) || 0;
const chunks = [];
let got = 0;
const reader = res.body.getReader();
let last = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
if (performance.now() - last > 100) {
last = performance.now();
self.postMessage({ progress: name, got, total });
}
}
let z = new Uint8Array(got);
let o = 0;
for (const c of chunks) {
z.set(c, o);
o += c.length;
}
const tFetched = performance.now();
if (!isZstd(z)) throw new Error(`${url}: not a zstd file`);
const raw = await unzstd(z);
z = null;
const tree = untar(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
const tDecoded = performance.now();
let n = 0;
let size = 0;
for (const d of tree.dirs) {
const [pp, pl] = put(d);
k.k_fs_mkdir(pp, pl, 0o755);
}
let status = '';
// Files fetched from the page as the guest reads them (`k_fs_add_lazy`):
// [guest path, size, same-origin path]. The apt overlay's own .debs.
const lazy = [];
for (const [p, data] of Object.entries(tree.files)) {
if (p.startsWith('/var/lib/gex-overlay/') && p.endsWith('.status')) {
status += new TextDecoder().decode(data);
}
if (p.startsWith('/var/lib/gex-overlay/') && p.endsWith('.lazy')) {
lazy.push(...JSON.parse(new TextDecoder().decode(data)));
}
addFile(p, data);
if (tree.modes[p] !== undefined) {
const [pp, pl] = put(p);
k.k_fs_chmod(pp, pl, tree.modes[p]);
}
n++;
size += data.length;
}
for (const [p, target] of Object.entries(tree.hard)) {
const [tp, tl, pp, pl] = put(target, p);
k.k_fs_unlink(pp, pl);
k.k_fs_link(tp, tl, pp, pl);
n++;
}
for (const [p, target] of Object.entries(tree.links)) {
const [tp, tl, pp, pl] = put(target, p);
k.k_fs_unlink(pp, pl);
k.k_fs_symlink(tp, tl, pp, pl);
n++;
}
for (const [p, size, url] of lazy) {
const [pp, pl, up, ul] = put(p, url);
k.k_fs_add_lazy(pp, pl, up, ul, BigInt(size));
n++;
}
if (status) {
const cur = readFile('/var/lib/dpkg/status') ?? new Uint8Array();
const old = new TextDecoder().decode(cur);
// Two overlays may carry the same package (a library both need): one
// stanza per package and architecture, or dpkg refuses the database.
const key = (st) => `${/^Package: (.*)$/m.exec(st)?.[1]}:${/^Architecture: (.*)$/m.exec(st)?.[1]}`;
const have = new Set(old.split(/\n\n+/).filter((x) => x.trim()).map(key));
const add = status.split(/\n\n+/).filter((x) => x.trim() && !have.has(key(x)));
const text = old.replace(/\n*$/, '\n\n') + add.map((x) => `${x.replace(/\n+$/, '')}\n\n`).join('');
addFile('/var/lib/dpkg/status', enc.encode(text));
}
k.k_wake?.();
return {
files: n,
bytes: size,
fetchMs: Math.round(tFetched - t0),
decodeMs: Math.round(tDecoded - tFetched),
writeMs: Math.round(performance.now() - tDecoded),
ms: Math.round(performance.now() - t0),
compressed: got,
};
}
let queue = Promise.resolve();
self.onmessage = (e) => {
const m = e.data;
queue = queue
.then(async () => {
await kernel(m.spec, m.stackTop);
const r = await overlay(m);
self.postMessage({ done: m.name, ...r });
})
.catch((err) => self.postMessage({ error: String(err?.stack ?? err), name: m.name }));
};
+52 -6
View File
@@ -5,6 +5,8 @@
// editing is the guest's choice (ICANON in its termios), and the page has to
// follow it or every keystroke appears twice.
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
import './zfetch.js';
import { Env } from '../../shims/browser/js/gex.mjs';
import { ProcessHost } from '../../shims/browser/js/host.mjs';
import { PageTty } from '../../shims/browser/js/tty.mjs';
@@ -556,6 +558,10 @@ async function runProgramHere(
// one wl_shm frames landed on. There is one output now, so this is only a
// fallback for where to attach the listeners.
wlInput = null,
// The page's devicePixelRatio when `wl`'s backing store is in device
// pixels: the compositor scales its frames by it and guest Wayland
// clients are told the rounded value as wl_output.scale.
wlScale = 1,
// Run the X server in the page instead of in a guest process: a Worker
// holding `Xwayland.wasm` and the compositor beside it (see
// `web/src/hostx.js`). Guest X clients reach it through the global
@@ -610,6 +616,16 @@ async function runProgramHere(
// page compiles its units while the bundle downloads), as [name, Module]
// pairs every Worker takes instead of fetching and compiling its own.
preModules = null,
// Called once the system is up and before pid 1 starts, with what an
// embedder needs to act on the running system from outside the guest:
// the process host (kernel stack slices), the global kernel, the shared
// terminal and the compositor. The demo desktop's launcher adds files
// with it (web/src/overlay.js) and types commands to pid 1.
onSystem = null,
// With `gl: 'compositor'`: start the host's Mesa only when `startGl`
// (handed to `onSystem`) is called -- a desktop whose first windows
// draw no GL does not download it.
glLazy = false,
},
) {
const { shimBytes, seedBytes } = await loadRuntime();
@@ -641,6 +657,8 @@ async function runProgramHere(
// gated on `have_gl()`) and gles-gears died at `eglInitialize`/`ring_open`.
let glChannel = null;
let glServe = null;
let startGl = null;
let glStarted = null;
if (gl && single.on) {
// The single-thread build: no ring. Each process's `gl_submit` calls the
// compositor's Mesa in this realm on its own memory (compositor-worker.js
@@ -714,16 +732,38 @@ async function runProgramHere(
}
const canvas = typeof wl?.getContext === 'function' ? wl : null;
if (canvas) {
setOutputSize(wlChannel, canvas.width, canvas.height);
setOutputSize(wlChannel, canvas.width, canvas.height, Math.max(1, Math.round(wlScale)));
const { Compositor } = await import('./compositor.js');
compositor = await Compositor.create(canvas, wlChannel, wlInput || canvas);
compositor = await Compositor.create(canvas, wlChannel, wlInput || canvas, { scale: wlScale });
globalThis.__gexCompositor = compositor;
if (gl === 'compositor' && glChannel) {
await startHostGl(compositor, glChannel, {
const opts = {
api: 'core',
backend: glBackend,
canvasBlit: new URLSearchParams(location.search).get('glcanvas') === '1',
}, term);
};
// `glLazy`: Mesa (about 20 MB before compression) is fetched when
// the embedder first asks (`onSystem`'s `startGl`), not at boot.
startGl = () => (glStarted ??= startHostGl(compositor, glChannel, opts, term));
if (!glLazy) await startGl();
else if (glChannel.control && typeof Atomics.waitAsync === 'function') {
// And by the guest: a GL program started from a shell, not from
// the embedder's launcher, makes its first request into a ring
// nothing serves yet -- and waits thirty seconds for the answer,
// then dies. Its first request starts Mesa (gl-bridge.mjs STATE).
const ctl = new Int32Array(glChannel.control);
(async () => {
while (!glStarted) {
const st = Atomics.load(ctl, 0);
if (st === 1) {
startGl();
break;
}
const w = Atomics.waitAsync(ctl, 0, st, 5000);
if (w.async) await w.value;
}
})();
}
}
}
}
@@ -756,10 +796,14 @@ async function runProgramHere(
// global kernel that only exists once the server has bound it.
let listening = null;
const up = new Promise((resolve) => (listening = resolve));
const countX = !!flag('hostxcount');
xServer.onmessage = (e) => {
const m = e.data;
if (m.kind === 'log') term.writeln(m.text);
else if (m.kind === 'wl-memory') compositor?.addMemory(m.token, m.memory);
if (m.kind === 'log') {
term.writeln(m.text);
// `?hostxcount=1`: to the console too, timed, for a harness.
if (countX) console.log(`${(performance.now() / 1000).toFixed(2)} ${m.text}`);
} else if (m.kind === 'wl-memory') compositor?.addMemory(m.token, m.memory);
else if (m.kind === 'ready') listening(true);
else if (m.kind === 'failed') {
term.writeln(`gex: the X server failed: ${m.why}`);
@@ -784,6 +828,7 @@ async function runProgramHere(
verbose: globalThis.GEX_HOSTX_VERBOSE || Number(new URLSearchParams(location.search).get('xverbose')) || undefined,
trace: globalThis.GEX_HOSTX_TRACE || undefined,
wlTrace: flag('wltrace') ? 1 : undefined,
count: flag('hostxcount') ? 1 : undefined,
});
// Two modules and a kernel to instantiate; a slow machine takes a few
// seconds. A run that goes ahead without the display is worse than one that
@@ -1011,6 +1056,7 @@ async function runProgramHere(
if (sharedTty) sharedTty.resize(rows, cols);
else host.resize(rows, cols);
});
onSystem?.({ host, kernel, tty: sharedTty, compositor, startGl });
try {
const status = await host.start({
elf,
+2
View File
@@ -7,6 +7,8 @@
// large module synchronously and make synchronous requests (the page refuses
// both above 8 MB / at all), and the runtime does both; and because a guest
// that runs long stalls this thread, not the page's input and drawing.
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
import './zfetch.js';
import { enableSingle } from '../../shims/browser/js/single.mjs';
enableSingle();
+2
View File
@@ -7,6 +7,8 @@
// through the shared arena instead, which is exactly how a guest process's
// compositor gets them too.
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
import './zfetch.js';
import { HostX } from './hostx.js';
let host = null;
+69
View File
@@ -0,0 +1,69 @@
// The demo build's runtime .wasm files, fetched as .wasm.zst.
//
// `web/build-demo.sh` ships every runtime module (shim, kernel, seed, Xwayland,
// its compositor bridge, Mesa) only as `NAME.wasm.zst`: about a tenth of the
// stripped size for the shim. Rather than teach each loader about it, the
// demo build wraps `fetch` once per realm -- the page and each Worker that
// fetches a module -- so a same-origin request for `X.wasm` asks for
// `X.wasm.zst` and answers with the decoded bytes, typed application/wasm so
// `instantiateStreaming` (emscripten's Xwayland glue) takes them as well.
//
// Off in every other build (`VITE_GEX_ZWASM` is set by build-demo.sh only), and
// under Node, where there is no `import.meta.env`.
const ON = import.meta.env?.VITE_GEX_ZWASM === '1';
let decoder = null;
async function zstd() {
if (!decoder) {
decoder = (async () => {
const { ZSTDDecoder } = await import('../eject-site/vendor/zstddec-stream.mjs');
const d = new ZSTDDecoder();
await d.init();
return d;
})();
}
return decoder;
}
/** zstd's frame magic, little-endian 0xFD2FB528. */
export function isZstd(b) {
return b.length > 4 && b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd;
}
/** Decode zstd bytes (the shared decoder, initialised once per realm). */
export async function unzstd(bytes) {
return (await zstd()).decode(bytes);
}
export function installZFetch() {
if (!ON || globalThis.__gexZFetch || typeof fetch !== 'function') return;
const plain = globalThis.fetch.bind(globalThis);
globalThis.__gexZFetch = plain;
globalThis.fetch = async (input, init) => {
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url;
let u = null;
try {
u = new URL(href, globalThis.location?.href);
} catch {}
if (u && u.origin === globalThis.location?.origin && u.pathname.endsWith('.wasm')) {
u.pathname += '.zst';
const res = await plain(u.href, init);
if (res.ok) {
const z = new Uint8Array(await res.arrayBuffer());
// A missing file answers index.html with a 200 (the static server's
// fallback): only a zstd frame is taken as the module.
if (isZstd(z)) {
const body = await unzstd(z);
return new Response(body, {
status: 200,
headers: { 'content-type': 'application/wasm', 'content-length': String(body.length) },
});
}
}
}
return plain(input, init);
};
}
installZFetch();
+10 -1
View File
@@ -24,7 +24,16 @@ export default defineConfig({
// web/build-all.sh builds each flavour's dist from its own staged public
// tree: GEX_PUBLIC_DIR (default public/) into GEX_OUT_DIR (default dist/).
publicDir: process.env.GEX_PUBLIC_DIR || 'public',
build: { target: 'es2022', chunkSizeWarningLimit: 2048, outDir: process.env.GEX_OUT_DIR || 'dist' },
build: {
target: 'es2022',
chunkSizeWarningLimit: 2048,
outDir: process.env.GEX_OUT_DIR || 'dist',
// web/build-demo.sh: the desktop (desktop.html) is the demo build's only
// page; every other build is the demo list (index.html).
...(process.env.GEX_DEMO === '1'
? { rollupOptions: { input: fileURLToPath(new URL('desktop.html', import.meta.url)) } }
: {}),
},
// `worker.mjs` is an ES module with top-level await; the default `iife`
// worker output cannot express that.
worker: { format: 'es' },