Skip to main content

rich_web/
isolate.rs

1//! One process per app session ([`Isolation::Processes`], the default).
2//!
3//! The server keeps the browser's connection. For each session it starts
4//! its own program again (the same executable and arguments, or
5//! [`Server::session_command`](crate::Server::session_command)) with
6//! [`SESSION_ENV`] set, and listens on a loopback port for that one
7//! process. The child's call to [`Server::bind`](crate::Server::bind) (or
8//! [`serve`](crate::serve)) sees the variable and, instead of listening,
9//! connects back, proves it is the process the server started with a
10//! secret from the variable (the server answers with a second one, so a
11//! process whose server died serves nobody who took over its port), runs
12//! the session there, and exits. The
13//! server relays the WebSocket's bytes both ways, so the session loop is
14//! the same one a thread runs, and the page cannot tell the difference.
15//!
16//! A crash, a hang or a runaway allocation then ends one process, not the
17//! server: the server tells the page the session ended, kills the process
18//! (on Unix with its process group, on Windows with its job object) when
19//! the page goes or the server stops, and on Unix can cap each process's
20//! memory and CPU time
21//! ([`Server::session_memory`](crate::Server::session_memory),
22//! [`Server::session_cpu`](crate::Server::session_cpu)).
23//!
24//! On Windows the server puts each session's process in a job object of
25//! its own as soon as it has started, and holds the job's only handle. The
26//! job kills its processes when that handle closes: when the session ends,
27//! and when the server dies however it dies, since the system closes a
28//! dead process's handles. What the session's process starts is in the job
29//! too, and cannot leave it (the job allows no breakaway). Only what a
30//! process starts in the moment between its start and the server's
31//! assignment escapes: the session's own code runs after it (the server
32//! answers the process's connection only once it is in the job), so only
33//! a program's startup before `serve` can, and a `session_command` that
34//! starts the program as a child rather than being it. A server that runs
35//! in a job itself (a CI runner, a service) nests the session's job in its
36//! own (Windows 8 and later); one whose job forbids that (UI limits) cannot
37//! start sessions in processes, and the page is told why.
38
39use std::ffi::OsString;
40use std::io::{self, ErrorKind, Read, Write};
41use std::net::{Shutdown, TcpListener, TcpStream};
42use std::path::PathBuf;
43use std::process::{Child, Command, Stdio};
44use std::sync::Arc;
45use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
46use std::thread;
47use std::time::{Duration, Instant};
48
49use intuituive::App;
50use tungstenite::WebSocket;
51use tungstenite::protocol::{Role, WebSocketConfig};
52
53use crate::{Renderer, session};
54
55/// The variable a session's process is started with: what it needs to
56/// connect back and run its session. Not for setting by hand.
57pub const SESSION_ENV: &str = "RS_RICH_WEB_SESSION";
58
59/// Where each app session runs.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
61#[non_exhaustive]
62pub enum Isolation {
63    /// A process of its own: this program started again, which runs the
64    /// session when it reaches the same `serve` or `Server::bind`. A crash,
65    /// a hang or a runaway allocation ends that session alone.
66    #[default]
67    Processes,
68    /// A thread of the server (0.0.18's behaviour): for a program that
69    /// cannot start itself again (tests, an app built inside another
70    /// program, a REPL).
71    Threads,
72}
73
74/// How long a session's process has to connect back before the page is
75/// told it did not start.
76const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
77/// How long it has to send its secret once connected (in all, however
78/// slowly it trickles in).
79const HELLO_TIMEOUT: Duration = Duration::from_secs(2);
80/// The most connections waiting to send a secret at once; more are turned
81/// away.
82const MAX_WAITING: usize = 64;
83/// How long an ended session waits for its process to exit before it is
84/// killed.
85const EXIT_WAIT: Duration = Duration::from_secs(1);
86/// How often the relay looks up from its reads (the server stopping, the
87/// other side gone).
88const TURN: Duration = Duration::from_millis(50);
89
90/// How a session's process is started and limited.
91#[derive(Clone, Debug, Default)]
92pub(crate) struct Spawn {
93    /// The program and its arguments; `None`: this one, with the same.
94    pub command: Option<(PathBuf, Vec<OsString>)>,
95    /// The most memory (address space) a process may take, in bytes.
96    pub memory: Option<u64>,
97    /// The most CPU time a process may use.
98    pub cpu: Option<Duration>,
99}
100
101/// Which `bind` of this program a session belongs to: the parent numbers
102/// its binds, and the child runs the one with the same number.
103static BINDS: AtomicUsize = AtomicUsize::new(0);
104
105/// The number of the next app server this program binds.
106pub(crate) fn next_bind() -> usize {
107    BINDS.fetch_add(1, Ordering::SeqCst)
108}
109
110/// What a session's process was started with.
111#[derive(Debug, PartialEq, Eq)]
112pub(crate) struct Spec {
113    port: u16,
114    /// The server's process id: only its own child is a session.
115    parent: u32,
116    /// What the process sends to prove it is the one started.
117    secret: String,
118    /// What the server answers to prove it is the server, so a process
119    /// whose server died serves nobody who took over its port.
120    reply: String,
121    bind: usize,
122    columns: u16,
123    rows: u16,
124    renderer: Renderer,
125    memory: Option<u64>,
126    cpu: Option<u64>,
127    /// Compress frames from this size.
128    compress: Option<u64>,
129}
130
131impl Spec {
132    fn encode(&self) -> String {
133        let limit = |n: Option<u64>| n.map_or("-".to_string(), |n| n.to_string());
134        format!(
135            "3 {} {} {} {} {} {} {} {} {} {} {}",
136            self.port,
137            self.parent,
138            self.secret,
139            self.reply,
140            self.bind,
141            self.columns,
142            self.rows,
143            self.renderer.name(),
144            limit(self.memory),
145            limit(self.cpu),
146            limit(self.compress)
147        )
148    }
149
150    fn decode(text: &str) -> Option<Spec> {
151        let mut parts = text.split(' ');
152        if parts.next()? != "3" {
153            return None;
154        }
155        let mut next = || parts.next();
156        let limit = |text: &str| match text {
157            "-" => Some(None),
158            n => n.parse().ok().map(Some),
159        };
160        let spec = Spec {
161            port: next()?.parse().ok()?,
162            parent: next()?.parse().ok()?,
163            secret: next()?.to_string(),
164            reply: next()?.to_string(),
165            bind: next()?.parse().ok()?,
166            columns: next()?.parse().ok()?,
167            rows: next()?.parse().ok()?,
168            renderer: Renderer::parse(next()?)?,
169            memory: limit(next()?)?,
170            cpu: limit(next()?)?,
171            compress: limit(next()?)?,
172        };
173        next().is_none().then_some(spec)
174    }
175}
176
177/// The session this process was started for, if it was, and this `bind`
178/// is the one it belongs to.
179pub(crate) fn session_for(bind: usize) -> Option<Spec> {
180    let spec = own_spec()?;
181    (spec.bind == bind).then_some(spec)
182}
183
184/// Whether this process was started for a session (of any `bind`).
185pub(crate) fn is_session_process() -> bool {
186    own_spec().is_some()
187}
188
189/// The spec, when this process is the one the server started. A program
190/// the app runs inherits the variable and is not a session: it serves as
191/// it would anywhere. On Unix the server starts each session's process
192/// leading a process group of its own, which what it runs joins; so a group
193/// leader whose parent is no longer the server is a session whose server
194/// has died, and exits. A `session_command` must therefore be the program,
195/// or `exec` it. (On Windows a session whose server has died is ended by
196/// its job object, and the session's process takes the variable out of
197/// its environment before the app runs, so what the app starts does not
198/// see it.)
199fn own_spec() -> Option<Spec> {
200    let spec = Spec::decode(&std::env::var(SESSION_ENV).ok()?)?;
201    #[cfg(unix)]
202    {
203        use rustix::process::{Pid, getpgrp, getpid, getppid};
204        if Pid::as_raw(getppid()) as u32 != spec.parent {
205            if getpgrp() == getpid() {
206                eprintln!("rich-web: the session's server has gone");
207                std::process::exit(1);
208            }
209            return None;
210        }
211    }
212    Some(spec)
213}
214
215/// In a session's process: limit it, connect back, run the session, and
216/// give the exit code.
217pub(crate) fn run_child(spec: Spec, app: impl FnOnce() -> App) -> i32 {
218    // What this process starts from here on is not the session, and on
219    // Windows no process group tells them apart: it does not inherit the
220    // variable. (Windows guards its environment, so this is safe beside
221    // other threads; on Unix, `own_spec`'s group check does it.)
222    #[cfg(windows)]
223    // SAFETY: Windows guards its environment block, so removing a variable
224    // beside other threads is sound there (std reads it through the same
225    // API); this runs on Windows only.
226    #[allow(unsafe_code)]
227    unsafe {
228        std::env::remove_var(SESSION_ENV)
229    };
230    // Should the server die without ending it, this process ends its whole
231    // group. (Not Linux's parent-death signal: that would kill this process
232    // alone, before it could end the rest.) On Windows the job object the
233    // server holds does that.
234    #[cfg(unix)]
235    watch_server(spec.parent);
236    limit(spec.memory, spec.cpu);
237    let stream = match TcpStream::connect(("127.0.0.1", spec.port)) {
238        Ok(stream) => stream,
239        Err(error) => {
240            eprintln!("rich-web: the session could not reach its server: {error}");
241            return 1;
242        }
243    };
244    let _ = stream.set_nodelay(true);
245    let mut hello = stream.try_clone().expect("a socket clones");
246    if hello
247        .write_all(format!("{}\n", spec.secret).as_bytes())
248        .is_err()
249    {
250        return 1;
251    }
252    // The server answers with the reply; anyone else who took the port
253    // when the server died cannot.
254    let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT));
255    let mut answer = vec![0u8; spec.reply.len() + 1];
256    if hello.read_exact(&mut answer).is_err()
257        || !crate::http::same_token(
258            std::str::from_utf8(&answer[..spec.reply.len()]).unwrap_or(""),
259            &spec.reply,
260        )
261        || answer[spec.reply.len()] != b'\n'
262    {
263        eprintln!("rich-web: the session's server did not answer");
264        return 1;
265    }
266    let _ = stream.set_read_timeout(None);
267    let config = WebSocketConfig::default()
268        .max_message_size(Some(crate::MAX_MESSAGE))
269        .max_frame_size(Some(crate::MAX_MESSAGE));
270    let mut socket = WebSocket::from_raw_socket(stream, Role::Server, Some(config));
271    // The server ends the session by closing the connection, or by killing
272    // this process.
273    let stopping = AtomicBool::new(false);
274    let code = match session::run(
275        &mut socket,
276        app(),
277        spec.columns,
278        spec.rows,
279        &stopping,
280        spec.renderer,
281        session::Compress(spec.compress.map(|n| n as usize)),
282    ) {
283        Ok(()) => 0,
284        Err(_) => 1,
285    };
286    // A server that died closed the connection a moment before it let go
287    // of this process: a session ended by that is the watcher's to end, so
288    // what the app started does not outlive it. A live server ends the
289    // group itself.
290    #[cfg(unix)]
291    thread::sleep(ENDING);
292    code
293}
294
295/// How long a session's process waits, once its session ends, for the
296/// watcher to see a server that died.
297#[cfg(unix)]
298const ENDING: Duration = Duration::from_millis(250);
299
300/// How often a session's process looks for its server.
301#[cfg(unix)]
302const WATCH: Duration = Duration::from_millis(50);
303
304/// Should the server go (killed, so it could not end this process), end
305/// this process's whole group: what the app started goes with it. A
306/// process that left the group (`setsid`, a daemon) is not reached.
307#[cfg(unix)]
308fn watch_server(server: u32) {
309    use rustix::process::{Pid, Signal, getpid, getppid, kill_process_group};
310    thread::spawn(move || {
311        loop {
312            let parent = Pid::as_raw(getppid()) as u32;
313            if parent != server {
314                let _ = kill_process_group(getpid(), Signal::KILL);
315                std::process::exit(1);
316            }
317            thread::sleep(WATCH);
318        }
319    });
320}
321
322/// The limits to set: address space in bytes, CPU time in seconds.
323#[cfg(unix)]
324fn limits(
325    memory: Option<u64>,
326    cpu: Option<u64>,
327) -> impl Iterator<Item = (rustix::process::Resource, rustix::process::Rlimit)> {
328    use rustix::process::{Resource, Rlimit};
329    // macOS refuses an address-space limit (`EINVAL`): there it has none.
330    let memory = memory.filter(|_| !cfg!(target_vendor = "apple"));
331    [(Resource::As, memory), (Resource::Cpu, cpu)]
332        .into_iter()
333        .filter_map(|(resource, value)| {
334            let value = value?;
335            let limit = Rlimit {
336                current: Some(value),
337                maximum: Some(value),
338            };
339            Some((resource, limit))
340        })
341}
342
343/// Cap this process's address space and CPU time (Unix; elsewhere there is
344/// no `setrlimit`, and the limits are not applied). On Linux the server has
345/// set them already, from the moment the process started.
346#[cfg(unix)]
347fn limit(memory: Option<u64>, cpu: Option<u64>) {
348    for (resource, limit) in limits(memory, cpu) {
349        if let Err(error) = rustix::process::setrlimit(resource, limit) {
350            eprintln!("rich-web: the session's limit could not be set: {error}");
351        }
352    }
353}
354
355/// Cap a session's process from the server, as soon as it has started, so
356/// the limits hold for its startup too (before it reaches `serve`).
357#[cfg(target_os = "linux")]
358fn limit_child(pid: u32, memory: Option<u64>, cpu: Option<u64>) -> io::Result<()> {
359    let pid = i32::try_from(pid)
360        .ok()
361        .and_then(rustix::process::Pid::from_raw)
362        .ok_or_else(|| io::Error::other("no such process"))?;
363    for (resource, limit) in limits(memory, cpu) {
364        rustix::process::prlimit(Some(pid), resource, limit)?;
365    }
366    Ok(())
367}
368
369#[cfg(not(unix))]
370fn limit(_memory: Option<u64>, _cpu: Option<u64>) {}
371
372/// Counts a session's process as running until dropped, so that stopping
373/// the server waits for it.
374struct Running<'a>(&'a AtomicUsize);
375
376impl Drop for Running<'_> {
377    fn drop(&mut self) {
378        self.0.fetch_sub(1, Ordering::SeqCst);
379    }
380}
381
382/// What the parent needs to run one session in a process.
383pub(crate) struct Session<'a> {
384    pub spawn: &'a Spawn,
385    pub bind: usize,
386    pub columns: u16,
387    pub rows: u16,
388    pub renderer: Renderer,
389    pub compress: Option<usize>,
390    pub stopping: &'a AtomicBool,
391    pub running: &'a AtomicUsize,
392}
393
394/// Run a session in a process of its own, relaying `browser`'s WebSocket
395/// (upgraded already) to it, until either side ends or the server stops.
396pub(crate) fn run_parent(browser: TcpStream, session: Session<'_>) {
397    session.running.fetch_add(1, Ordering::SeqCst);
398    let _counted = Running(session.running);
399    if session.stopping.load(Ordering::SeqCst) {
400        return goodbye(browser, "The server stopped.");
401    }
402    let started = match start(&session) {
403        Ok(started) => started,
404        Err(error) => {
405            return goodbye(
406                browser,
407                &format!("The session's process could not start: {error}."),
408            );
409        }
410    };
411    let (mut child, tree, listener, secrets) = started;
412    let memory = session.spawn.memory;
413    let child_stream = match connected(&listener, &secrets, &mut child, memory, session.stopping) {
414        Ok(stream) => stream,
415        Err(why) => {
416            end(&mut child, tree);
417            return goodbye(browser, &why);
418        }
419    };
420    drop(listener);
421    let relayed = relay(&browser, &child_stream, session.stopping);
422    // The page went, the process ended, or the server is stopping: the
423    // process's connection closes, and its session ends. It gets a moment
424    // to exit, and is killed if it has not.
425    let _ = child_stream.shutdown(Shutdown::Both);
426    let status = end(&mut child, tree);
427    // The page is told why, when it is still there, between frames, and
428    // was not sent a close by the session itself.
429    let reason = match relayed.ended {
430        _ if !relayed.can_close => None,
431        _ if session.stopping.load(Ordering::SeqCst) => Some("The server stopped.".to_string()),
432        Ended::Page => None,
433        Ended::Child => Some(match status {
434            Some(status) if status.success() => "The session ended.".to_string(),
435            Some(status) => format!("The session's process ended unexpectedly ({status})."),
436            None => "The session's process ended unexpectedly.".to_string(),
437        }),
438    };
439    match reason {
440        Some(reason) => goodbye(browser, &reason),
441        None => {
442            let _ = browser.shutdown(Shutdown::Both);
443        }
444    }
445}
446
447/// Start the process, with what ends it and what it starts, a loopback
448/// listener for it to connect to, and the secret it must send.
449fn start(session: &Session<'_>) -> io::Result<(Child, Tree, TcpListener, Secrets)> {
450    let listener = TcpListener::bind(("127.0.0.1", 0))?;
451    let secrets = Secrets {
452        secret: crate::random_token()?,
453        reply: crate::random_token()?,
454    };
455    let spec = Spec {
456        port: listener.local_addr()?.port(),
457        parent: std::process::id(),
458        secret: secrets.secret.clone(),
459        reply: secrets.reply.clone(),
460        bind: session.bind,
461        columns: session.columns,
462        rows: session.rows,
463        renderer: session.renderer,
464        memory: session.spawn.memory,
465        cpu: session.spawn.cpu.map(|cpu| cpu.as_secs().max(1)),
466        compress: session.compress.map(|n| n as u64),
467    };
468    let (program, args) = match &session.spawn.command {
469        Some((program, args)) => (program.clone(), args.clone()),
470        None => (
471            std::env::current_exe()?,
472            std::env::args_os().skip(1).collect(),
473        ),
474    };
475    let mut command = Command::new(program);
476    command
477        .args(args)
478        .env(SESSION_ENV, spec.encode())
479        .stdin(Stdio::null());
480    // Its own process group, so that what it starts is ended with it.
481    #[cfg(unix)]
482    std::os::unix::process::CommandExt::process_group(&mut command, 0);
483    let mut child = command.spawn()?;
484    #[cfg(target_os = "linux")]
485    if let Err(error) = limit_child(child.id(), spec.memory, spec.cpu)
486        .and_then(|()| within_memory(child.id(), spec.memory))
487    {
488        let _ = child.kill();
489        let _ = child.wait();
490        return Err(io::Error::other(format!(
491            "its limits could not be set: {error}"
492        )));
493    }
494    // Before the process is answered (it runs nothing of the session's
495    // until then), so all the session starts is in the job.
496    let tree = match Tree::new(&child) {
497        Ok(tree) => tree,
498        Err(error) => {
499            let _ = child.kill();
500            let _ = child.wait();
501            return Err(io::Error::other(format!(
502                "it could not be put in a job object: {error}"
503            )));
504        }
505    };
506    listener.set_nonblocking(true)?;
507    Ok((child, tree, listener, secrets))
508}
509
510/// What ends a session's process and everything it started: on Unix the
511/// process group it leads, on Windows a job object whose only handle the
512/// server holds (dropped, or closed by the system when the server dies, it
513/// kills every process in the job).
514struct Tree {
515    #[cfg(unix)]
516    leader: u32,
517    #[cfg(windows)]
518    _job: win32job::Job,
519}
520
521impl Tree {
522    /// The tree of `child`, just started.
523    #[cfg(unix)]
524    fn new(child: &Child) -> io::Result<Tree> {
525        Ok(Tree { leader: child.id() })
526    }
527
528    /// The tree of `child`, just started: a job object that kills its
529    /// processes when its last handle closes, which `child` is put in.
530    /// Without breakaway, what it starts stays in the job.
531    #[cfg(windows)]
532    fn new(child: &Child) -> io::Result<Tree> {
533        use std::os::windows::io::AsRawHandle;
534        let mut limits = win32job::ExtendedLimitInfo::new();
535        limits.limit_kill_on_job_close();
536        let job = win32job::Job::create_with_limit_info(&limits)?;
537        job.assign_process(child.as_raw_handle() as isize)?;
538        Ok(Tree { _job: job })
539    }
540
541    #[cfg(not(any(unix, windows)))]
542    fn new(_child: &Child) -> io::Result<Tree> {
543        Ok(Tree {})
544    }
545
546    /// Kill what is left of it.
547    fn kill(self) {
548        #[cfg(unix)]
549        kill_group(self.leader);
550        // On Windows, dropping the job closes its handle, which kills it.
551    }
552}
553
554/// The secret a session's process sends, and the reply the server answers.
555struct Secrets {
556    secret: String,
557    reply: String,
558}
559
560/// Refuse a process that took more memory than its limit before the limit
561/// was set (it raced ahead of `prlimit`): the limit holds from then on, so
562/// its peak until then is all there is to check.
563#[cfg(target_os = "linux")]
564fn within_memory(pid: u32, memory: Option<u64>) -> io::Result<()> {
565    let Some(memory) = memory else {
566        return Ok(());
567    };
568    let status = std::fs::read_to_string(format!("/proc/{pid}/status"))?;
569    let peak = status
570        .lines()
571        .find_map(|line| line.strip_prefix("VmPeak:"))
572        .and_then(|kb| kb.trim().trim_end_matches("kB").trim().parse::<u64>().ok())
573        .unwrap_or(0)
574        * 1024;
575    if peak > memory {
576        return Err(io::Error::other(format!(
577            "it took {peak} bytes before they were set"
578        )));
579    }
580    Ok(())
581}
582
583/// Wait for the process to connect and send its secret, then answer with
584/// the reply. Anything else that connects meanwhile is turned away; every
585/// connection is read at once, each for at most [`HELLO_TIMEOUT`], so none
586/// holds up the process or the server stopping.
587///
588/// Its peak memory is checked again before the answer: an allocation the
589/// process began before `prlimit` can still be under way when the server
590/// first looks, and is only counted once it completes. By the time the
591/// process connects, its startup is over.
592fn connected(
593    listener: &TcpListener,
594    secrets: &Secrets,
595    child: &mut Child,
596    memory: Option<u64>,
597    stopping: &AtomicBool,
598) -> Result<TcpStream, String> {
599    let until = Instant::now() + CONNECT_TIMEOUT;
600    let mut waiting: Vec<(TcpStream, Vec<u8>, Instant)> = Vec::new();
601    loop {
602        if stopping.load(Ordering::SeqCst) {
603            return Err("The server stopped.".into());
604        }
605        if let Ok(Some(status)) = child.try_wait() {
606            return Err(format!(
607                "The session's process ended before it started the app ({status}): \
608                 it must reach the same serve() call as the server."
609            ));
610        }
611        let now = Instant::now();
612        if now > until {
613            return Err("The session's process did not start the app in time.".into());
614        }
615        while let Ok((stream, _)) = listener.accept() {
616            if waiting.len() < MAX_WAITING && stream.set_nonblocking(true).is_ok() {
617                waiting.push((stream, Vec::new(), now + HELLO_TIMEOUT));
618            }
619        }
620        let mut found = None;
621        waiting.retain_mut(|(stream, line, by)| {
622            if found.is_some() {
623                return true;
624            }
625            match hello(stream, line, &secrets.secret) {
626                Some(true) => {
627                    found = stream.try_clone().ok();
628                    false
629                }
630                Some(false) => false,
631                None => now < *by,
632            }
633        });
634        if let Some(mut stream) = found {
635            #[cfg(not(target_os = "linux"))]
636            let _ = memory;
637            #[cfg(target_os = "linux")]
638            if let Err(error) = within_memory(child.id(), memory) {
639                return Err(format!(
640                    "The session's process could not start: its limits could not be set: {error}."
641                ));
642            }
643            let answered = stream
644                .set_nonblocking(false)
645                .and_then(|()| stream.write_all(format!("{}\n", secrets.reply).as_bytes()));
646            if answered.is_ok() {
647                let _ = stream.set_nodelay(true);
648                return Ok(stream);
649            }
650            continue;
651        }
652        thread::sleep(Duration::from_millis(10));
653    }
654}
655
656/// Read what `stream` (non-blocking) has sent of its secret into `line`:
657/// `Some(true)` once it is the secret and a newline, `Some(false)` once it
658/// cannot be, `None` until there is more. Only the secret's bytes are read,
659/// one at a time: what follows is the session's.
660fn hello(stream: &TcpStream, line: &mut Vec<u8>, secret: &str) -> Option<bool> {
661    let mut byte = [0u8];
662    loop {
663        match (&*stream).read(&mut byte) {
664            Ok(1) if byte[0] == b'\n' => {
665                return Some(
666                    std::str::from_utf8(line)
667                        .is_ok_and(|line| crate::http::same_token(line, secret)),
668                );
669            }
670            Ok(1) if line.len() < secret.len() => line.push(byte[0]),
671            Err(e) if e.kind() == ErrorKind::WouldBlock => return None,
672            Err(e) if e.kind() == ErrorKind::Interrupted => {}
673            _ => return Some(false),
674        }
675    }
676}
677
678/// Which side a relay ended on.
679#[derive(Debug, PartialEq, Eq)]
680enum Ended {
681    /// The page went (or the server stopped).
682    Page,
683    /// The process's side ended.
684    Child,
685}
686
687/// How a relay ended.
688struct Relayed {
689    ended: Ended,
690    /// What reached the page stopped between WebSocket frames, and held no
691    /// close: the server can still send one.
692    can_close: bool,
693}
694
695/// Copy bytes both ways until one side ends or the server stops.
696fn relay(browser: &TcpStream, child: &TcpStream, stopping: &AtomicBool) -> Relayed {
697    let failed = Relayed {
698        ended: Ended::Page,
699        can_close: true,
700    };
701    let done = Arc::new(AtomicBool::new(false));
702    let (Ok(page_in), Ok(child_out)) = (browser.try_clone(), child.try_clone()) else {
703        return failed;
704    };
705    let _ = page_in.set_read_timeout(Some(TURN));
706    let _ = child.set_read_timeout(Some(TURN));
707    // A process that has stopped reading (a hang) must not hold the relay
708    // in a write: writes to it give up each turn, and try again unless the
709    // relay is ending.
710    let _ = child.set_write_timeout(Some(TURN));
711    // The page's input, to the process, on a thread of its own.
712    let upstream = {
713        let done = done.clone();
714        thread::Builder::new()
715            .name("rich-web relay".into())
716            .spawn(move || {
717                pump(page_in, child_out, &done, &mut |_| {}, true);
718                done.store(true, Ordering::SeqCst);
719            })
720    };
721    // What the process draws, to the page, on this one, keeping track of
722    // where its frames begin.
723    let mut frames = Frames::default();
724    let (Ok(child_in), Ok(page_out)) = (child.try_clone(), browser.try_clone()) else {
725        done.store(true, Ordering::SeqCst);
726        return failed;
727    };
728    let child_ended = {
729        let stop = StopOr(&done, stopping);
730        pump_until(
731            child_in,
732            page_out,
733            &stop,
734            &mut |bytes| frames.feed(bytes),
735            false,
736        )
737    };
738    done.store(true, Ordering::SeqCst);
739    if let Ok(upstream) = upstream {
740        let _ = upstream.join();
741    }
742    Relayed {
743        ended: if child_ended {
744            Ended::Child
745        } else {
746            Ended::Page
747        },
748        can_close: frames.at_boundary() && !frames.closed,
749    }
750}
751
752/// Either of two flags.
753struct StopOr<'a>(&'a AtomicBool, &'a AtomicBool);
754
755impl StopOr<'_> {
756    fn is_set(&self) -> bool {
757        self.0.load(Ordering::SeqCst) || self.1.load(Ordering::SeqCst)
758    }
759}
760
761/// Copy from `from` to `to` until either ends or `done` is set.
762fn pump(
763    from: TcpStream,
764    to: TcpStream,
765    done: &AtomicBool,
766    seen: &mut dyn FnMut(&[u8]),
767    patient: bool,
768) {
769    let never = AtomicBool::new(false);
770    pump_until(from, to, &StopOr(done, &never), seen, patient);
771}
772
773/// Copy from `from` to `to` until either ends or `stop` is set; whether
774/// `from` ended (rather than `to`, or `stop`). `patient`: a write that
775/// times out is tried again (until `stop`) rather than ending the copy.
776fn pump_until(
777    mut from: TcpStream,
778    mut to: TcpStream,
779    stop: &StopOr<'_>,
780    seen: &mut dyn FnMut(&[u8]),
781    patient: bool,
782) -> bool {
783    let mut buffer = vec![0u8; 64 * 1024];
784    loop {
785        if stop.is_set() {
786            return false;
787        }
788        match from.read(&mut buffer) {
789            Ok(0) => return true,
790            Ok(n) => {
791                seen(&buffer[..n]);
792                if !write_until(&mut to, &buffer[..n], stop, patient) {
793                    return false;
794                }
795            }
796            Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
797            Err(e) if e.kind() == ErrorKind::Interrupted => {}
798            Err(_) => return true,
799        }
800    }
801}
802
803/// Write all of `bytes`; whether it was. With `patient`, a write that times
804/// out is tried again until `stop` is set; without, it ends the copy.
805fn write_until(to: &mut TcpStream, mut bytes: &[u8], stop: &StopOr<'_>, patient: bool) -> bool {
806    while !bytes.is_empty() {
807        match to.write(bytes) {
808            Ok(0) => return false,
809            Ok(n) => bytes = &bytes[n..],
810            Err(e) if e.kind() == ErrorKind::Interrupted => {}
811            Err(e)
812                if patient && matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
813            {
814                if stop.is_set() {
815                    return false;
816                }
817            }
818            Err(_) => return false,
819        }
820    }
821    true
822}
823
824/// Where a stream of WebSocket frames is: inside a frame, or between two,
825/// and whether a close went by.
826#[derive(Default)]
827struct Frames {
828    /// The header read so far of the next frame.
829    header: Vec<u8>,
830    /// Payload bytes left in the current frame.
831    left: u64,
832    closed: bool,
833}
834
835impl Frames {
836    fn at_boundary(&self) -> bool {
837        self.header.is_empty() && self.left == 0
838    }
839
840    fn feed(&mut self, mut bytes: &[u8]) {
841        while !bytes.is_empty() {
842            if self.left > 0 {
843                let n = (self.left.min(bytes.len() as u64)) as usize;
844                self.left -= n as u64;
845                bytes = &bytes[n..];
846                continue;
847            }
848            self.header.push(bytes[0]);
849            bytes = &bytes[1..];
850            if let Some((length, opcode)) = header_done(&self.header) {
851                if opcode == 0x8 {
852                    self.closed = true;
853                }
854                self.header.clear();
855                self.left = length;
856            }
857        }
858    }
859}
860
861/// When `header` is a whole frame header: the payload's length and the
862/// opcode.
863fn header_done(header: &[u8]) -> Option<(u64, u8)> {
864    if header.len() < 2 {
865        return None;
866    }
867    let opcode = header[0] & 0x0f;
868    let masked = header[1] & 0x80 != 0;
869    let short = u64::from(header[1] & 0x7f);
870    let (extended, length) = match short {
871        126 => (2, None),
872        127 => (8, None),
873        n => (0, Some(n)),
874    };
875    let mask = if masked { 4 } else { 0 };
876    if header.len() < 2 + extended + mask {
877        return None;
878    }
879    let length = length.unwrap_or_else(|| {
880        header[2..2 + extended]
881            .iter()
882            .fold(0u64, |n, b| (n << 8) | u64::from(*b))
883    });
884    Some((length, opcode))
885}
886
887/// End the session's process: a moment to exit, then killed (on Unix with
888/// its process group, on Windows with its job). Its exit status, when it
889/// exited on its own.
890fn end(child: &mut Child, tree: Tree) -> Option<std::process::ExitStatus> {
891    let until = Instant::now() + EXIT_WAIT;
892    while Instant::now() < until {
893        match child.try_wait() {
894            Ok(Some(status)) => {
895                tree.kill();
896                return Some(status);
897            }
898            Ok(None) => thread::sleep(Duration::from_millis(10)),
899            Err(_) => break,
900        }
901    }
902    tree.kill();
903    let _ = child.kill();
904    let _ = child.wait();
905    None
906}
907
908/// Kill what is left of the process group the session's process led.
909#[cfg(unix)]
910fn kill_group(leader: u32) {
911    use rustix::process::{Pid, Signal, kill_process_group};
912    if let Some(pid) = i32::try_from(leader).ok().and_then(Pid::from_raw) {
913        let _ = kill_process_group(pid, Signal::KILL);
914    }
915}
916
917/// Close the page's WebSocket with `reason`, which it shows, then let the
918/// connection go.
919fn goodbye(mut browser: TcpStream, reason: &str) {
920    let mut end = reason.len().min(123);
921    while !reason.is_char_boundary(end) {
922        end -= 1;
923    }
924    let mut payload = 1000u16.to_be_bytes().to_vec();
925    payload.extend_from_slice(&reason.as_bytes()[..end]);
926    let mut frame = vec![0x88, payload.len() as u8];
927    frame.extend_from_slice(&payload);
928    let _ = browser.write_all(&frame);
929    let _ = browser.flush();
930    // A moment for the page to answer, so the close is not cut off by a
931    // reset.
932    let _ = browser.set_read_timeout(Some(Duration::from_millis(200)));
933    let _ = browser.read(&mut [0u8; 256]);
934    let _ = browser.shutdown(Shutdown::Both);
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn specs_round_trip() {
943        let spec = Spec {
944            port: 4321,
945            parent: 99,
946            secret: "abc".into(),
947            reply: "def".into(),
948            bind: 2,
949            columns: 80,
950            rows: 24,
951            renderer: Renderer::Dom,
952            memory: Some(1 << 28),
953            cpu: None,
954            compress: Some(1024),
955        };
956        assert_eq!(Spec::decode(&spec.encode()), Some(spec));
957        // 0.0.19's first form, without the parent and the reply.
958        assert_eq!(Spec::decode("1 1 a 0 80 24 xterm - -"), None);
959        assert_eq!(Spec::decode("3 1 9 a b 0 80 24 xterm - -"), None);
960        assert_eq!(Spec::decode("2 1 9 a b 0 80 24 xterm - -"), None);
961        assert_eq!(Spec::decode("3 1 9 a b 0 80 24 canvas - - -"), None);
962    }
963
964    #[test]
965    fn frames_are_followed_across_reads() {
966        let mut frames = Frames::default();
967        // A text frame of 3 bytes, split across reads.
968        frames.feed(&[0x81, 0x03, b'a']);
969        assert!(!frames.at_boundary());
970        frames.feed(b"bc");
971        assert!(frames.at_boundary());
972        // A 300-byte binary frame: a 16-bit length.
973        let mut big = vec![0x82, 126, 0x01, 0x2c];
974        big.extend(vec![0u8; 300]);
975        frames.feed(&big[..3]);
976        assert!(!frames.at_boundary());
977        frames.feed(&big[3..]);
978        assert!(frames.at_boundary());
979        assert!(!frames.closed);
980        frames.feed(&[0x88, 0x02, 0x03, 0xe8]);
981        assert!(frames.closed && frames.at_boundary());
982    }
983
984    #[test]
985    fn a_write_to_a_process_that_stopped_reading_gives_up_when_told() {
986        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
987        let mut writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
988        // The other end never reads: the socket's buffers fill.
989        let (_reader, _) = listener.accept().unwrap();
990        writer
991            .set_write_timeout(Some(Duration::from_millis(20)))
992            .unwrap();
993        let stopping = Arc::new(AtomicBool::new(false));
994        let never = AtomicBool::new(false);
995        let stopper = {
996            let stopping = stopping.clone();
997            thread::spawn(move || {
998                thread::sleep(Duration::from_millis(300));
999                stopping.store(true, Ordering::SeqCst);
1000            })
1001        };
1002        let started = Instant::now();
1003        let big = vec![0u8; 64 << 20];
1004        let stop = StopOr(&never, &stopping);
1005        assert!(!write_until(&mut writer, &big, &stop, true));
1006        assert!(started.elapsed() < Duration::from_secs(5));
1007        stopper.join().unwrap();
1008    }
1009
1010    #[test]
1011    fn a_masked_header_counts_its_key() {
1012        assert_eq!(header_done(&[0x81, 0x85]), None);
1013        assert_eq!(header_done(&[0x81, 0x85, 1, 2, 3]), None);
1014        assert_eq!(header_done(&[0x81, 0x85, 1, 2, 3, 4]), Some((5, 1)));
1015    }
1016}