Skip to main content

shuyi_cli/contract/
attach.rs

1//! `attach`: a build that is already running, reached over the live link
2//! (ADR-0047) instead of a game started headless. It needs no project:
3//! the build is a debug build of a game with its backend's `link` feature,
4//! started with `SHUYI_LINK=<port>`, and its log's line `shuyi link:
5//! listening on <address>, token <token>` gives the address and the token.
6//!
7//! It connects, says hello, plays the steps as a player's input (keys,
8//! controller buttons, an action by name, waits counted in the build's
9//! own frames: `shuyi_platform::script`'s steps, `run`'s), lets `frames`
10//! more frames pass, then reads the build's
11//! status, its frame as text, its log from `log_since`, and, if asked,
12//! the window's picture, written to a file. It changes nothing else in
13//! the build. The wire's types are `shuyi_platform::link`'s, the build's
14//! own.
15//!
16//! With `perf_since` it reads the frames' records too, summed up as `run
17//! --perf` sums them; with `perf_follow` it follows them instead, every
18//! 100 ms for `follow_for` seconds, writing each new record to a file as
19//! a perf trace's line as the build runs, so an agent reads the file as
20//! it grows.
21
22use std::fmt::Write as _;
23use std::fs;
24use std::io::{BufRead, BufReader, ErrorKind, Write};
25use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs};
26use std::path::Path;
27use std::time::Duration;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use shuyi_platform::link::{
32    self, Answer as Line, LogAnswer, PerfAnswer, Phase, Played, Request, Status,
33};
34use shuyi_platform::script::wait_frames;
35use shuyi_ui::log::LOG_LINES;
36use shuyi_ui::perf::{FrameRecord, HeapSnapshot};
37
38use super::codes::{self, Code};
39use super::perf::{self, PerfSummary};
40use super::run::MAX_IDLE_FRAMES;
41use super::{Answer, Op};
42use crate::frame_png::{self, Step};
43
44/// How long a connection may take.
45const CONNECT: Duration = Duration::from_secs(3);
46
47/// How long an answer may take, a play's waits aside.
48const ANSWER: Duration = Duration::from_secs(10);
49
50/// What `attach` reaches and plays.
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields, default)]
53// An input: built with `default()` and its fields assigned, it may grow
54// (ADR-0039).
55#[non_exhaustive]
56pub struct AttachInput {
57    /// The build's address, `host:port` (`127.0.0.1:7357`).
58    pub address: String,
59    /// The token the build logged when it started listening.
60    pub token: String,
61    /// The steps to play, in order: waits in seconds, played as the build's
62    /// frames at 60 a second, rounded up; no crank.
63    pub steps: Vec<Step>,
64    /// Frames to let pass after the steps, with no input.
65    pub frames: u32,
66    /// Where to write the window's picture, a PNG file (from the working
67    /// folder), if wanted.
68    pub screenshot: Option<String>,
69    /// The first log line to answer, by its number: 0 for every line kept,
70    /// the last answer's `log_next` for the lines since.
71    pub log_since: u64,
72    /// The first frame's record to answer, by its number, if wanted: 0
73    /// for every record kept, the last answer's `perf_next` for the
74    /// records since. With `perf_follow`, where the following starts.
75    pub perf_since: Option<u64>,
76    /// A file to follow the build's frames into (from the working folder):
77    /// each new frame's record written as a line as the build runs, for
78    /// `follow_for` seconds or until the build quits.
79    pub perf_follow: Option<String>,
80    /// How many seconds to follow, 1 to 3,600: 10 if not said.
81    #[schemars(range(min = 1, max = 3600))]
82    pub follow_for: Option<u32>,
83    /// Take a heap snapshot with the records: with `perf_since`, one now;
84    /// with `perf_follow`, one as the following starts and one as it ends,
85    /// and the diff between them.
86    pub perf_snapshot: bool,
87}
88
89/// How many seconds `perf_follow` follows when not said.
90pub const FOLLOW_FOR: u32 = 10;
91
92/// The most seconds `perf_follow` follows.
93pub const MAX_FOLLOW_FOR: u32 = 3_600;
94
95/// How often `perf_follow` reads the build's records.
96pub const FOLLOW_EVERY: Duration = Duration::from_millis(100);
97
98/// What `perf_follow` wrote.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(deny_unknown_fields)]
101// An answer's, read field by field: it may grow (ADR-0039).
102#[non_exhaustive]
103pub struct AttachFollow {
104    /// The file, its absolute path.
105    pub file: String,
106    /// The lines written: a frame's record each, and with
107    /// `perf_snapshot` the two snapshots and their diff.
108    pub lines: u32,
109    /// The frames the build ran that no read caught: it ran more than
110    /// 600 between two reads.
111    pub dropped: u64,
112    /// Whether the build closed the link before the time was up: it quit.
113    pub closed: bool,
114}
115
116/// The window's picture, as written.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
118#[serde(deny_unknown_fields)]
119// An answer's, read field by field: it may grow (ADR-0039).
120#[non_exhaustive]
121pub struct AttachScreenshot {
122    /// The file, as given.
123    pub path: String,
124    /// Its width in pixels.
125    pub width: u32,
126    /// Its height in pixels.
127    pub height: u32,
128}
129
130/// One line of the build's log, with its number.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133// An answer's, read field by field: it may grow (ADR-0039).
134#[non_exhaustive]
135pub struct AttachLogEntry {
136    /// Its number in the build's log: lines are numbered from 0 as they
137    /// are logged.
138    pub seq: u64,
139    /// The frame it was given in (frames count from 1).
140    pub frame: u32,
141    /// How serious it is: `error`, `warn`, `info` or `debug`.
142    pub level: String,
143    /// What it is about: `assets`, `audio`, `input`, `screens`, `storage`,
144    /// `platform` or `game`.
145    pub category: String,
146    /// What it says, in English.
147    pub text: String,
148}
149
150/// What `attach` saw.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152#[serde(deny_unknown_fields)]
153// An answer, read field by field: it may grow (ADR-0039).
154#[non_exhaustive]
155pub struct AttachOutput {
156    /// The link's version both ends speak.
157    pub link: u32,
158    /// The game: its window's title.
159    pub game: String,
160    /// The frames the build has run since it started.
161    pub frames: u32,
162    /// Where the game is: `loading` (its card shows), `running` or
163    /// `failed` (its load failed; the errors show).
164    pub phase: String,
165    /// The top screen's name; empty with none open.
166    pub top_screen: String,
167    /// The open screens, bottom first.
168    pub screens: Vec<String>,
169    /// Whether the system has suspended the build.
170    pub suspended: bool,
171    /// Whether its window has the player's attention.
172    pub focused: bool,
173    /// Whether the game has quit.
174    pub quit: bool,
175    /// The frame, in the snapshot text format; empty while the game
176    /// isn't running.
177    pub frame: String,
178    /// The window's picture, if asked for.
179    pub screenshot: Option<AttachScreenshot>,
180    /// The build's log lines numbered `log_since` or more, oldest first.
181    pub log: Vec<AttachLogEntry>,
182    /// The number the build's next log line gets: the next `log_since`.
183    pub log_next: u64,
184    /// The frames' records numbered `perf_since` or more, oldest first, at
185    /// most 600; empty without `perf_since`, and with `perf_follow` (they
186    /// are in its file).
187    pub perf: Vec<FrameRecord>,
188    /// The number to read the records from next time: the next
189    /// `perf_since`. Null without `perf_since` or `perf_follow`.
190    pub perf_next: Option<u64>,
191    /// What the records add up to: those answered, or with `perf_follow`
192    /// every one written. Null with none.
193    pub perf_summary: Option<PerfSummary>,
194    /// The heap snapshot taken with the records, with `perf_snapshot`
195    /// (with `perf_follow`, the one as it ended): null where the build
196    /// doesn't count the heap by tag.
197    pub perf_snapshot: Option<HeapSnapshot>,
198    /// What `perf_follow` wrote.
199    pub follow: Option<AttachFollow>,
200}
201
202impl AttachInput {
203    /// Whether it can be played: an address and a token, steps the link
204    /// can play, at most [`MAX_IDLE_FRAMES`] frames after.
205    pub fn check(&self) -> Result<(), String> {
206        if self.address.is_empty() {
207            return Err("the build's address is missing: host:port".to_owned());
208        }
209        if self.token.is_empty() {
210            return Err("--token is missing: the build's log line gives it".to_owned());
211        }
212        if self.frames > MAX_IDLE_FRAMES {
213            return Err(format!(
214                "frames: at most {MAX_IDLE_FRAMES}, not {}",
215                self.frames
216            ));
217        }
218        link::check_play(&self.steps)?;
219        if let Some(seconds) = self.follow_for {
220            if self.perf_follow.is_none() {
221                return Err("follow_for: only with perf_follow".to_owned());
222            }
223            if !(1..=MAX_FOLLOW_FOR).contains(&seconds) {
224                return Err(format!(
225                    "follow_for: 1 to {MAX_FOLLOW_FOR} seconds, not {seconds}"
226                ));
227            }
228        }
229        if self.perf_follow.as_deref().is_some_and(str::is_empty) {
230            return Err("perf_follow: the file is missing".to_owned());
231        }
232        if self.perf_snapshot && self.perf_since.is_none() && self.perf_follow.is_none() {
233            return Err("perf_snapshot: only with perf_since or perf_follow".to_owned());
234        }
235        Ok(())
236    }
237
238    /// The steps as the link plays them, the frames after as waits
239    /// ([`idle`]).
240    pub fn link_steps(&self) -> Vec<Step> {
241        let mut steps = self.steps.clone();
242        steps.extend(idle(self.frames));
243        steps
244    }
245
246    /// Whether the steps or the frames after wait.
247    fn waits(&self) -> bool {
248        self.frames > 0 || self.steps.iter().any(|s| matches!(s, Step::Wait(_)))
249    }
250}
251
252/// `frames` frames with no input as waits the link plays as exactly that
253/// many: the whole seconds, then the frames left over (none for 0). A
254/// second's 60 frames are exact in seconds, and so are fewer than 60.
255pub fn idle(frames: u32) -> Vec<Step> {
256    let (seconds, left) = (frames / 60, frames % 60);
257    let mut steps = Vec::new();
258    // Whole seconds of at most 100 000 frames, and fewer than 60 frames:
259    // exact in an `f32`.
260    #[allow(clippy::cast_precision_loss)]
261    let (seconds, left) = (seconds as f32, left as f32);
262    if seconds > 0.0 {
263        steps.push(Step::Wait(seconds));
264    }
265    if left > 0.0 {
266        steps.push(Step::Wait(left / 60.0));
267    }
268    steps
269}
270
271/// The usage of `attach`.
272pub fn usage(command: &str) -> String {
273    format!(
274        "usage: {command} attach <host:port> --token <token> [steps] [--frames N] \
275         [--screenshot <file.png>] [--log-since N] [--perf-since N] \
276         [--perf-follow <file.jsonl> [--for S]] [--perf-snapshot] [--json]\n\n\
277         a running debug build of a game with its `link` feature, started with SHUYI_LINK=<port>\n\
278         (0 takes a free one); its log line `shuyi link: listening on <address>, token <token>`\n\
279         gives both.\n\n\
280         steps (each may repeat, played in order, as a player's input):\n  \
281         --keys \"Enter Up\"    press and release each key chord in turn (or --script)\n  \
282         --pad \"South\"        press and release each controller button in turn\n  \
283         --act Confirm        press the game's action by its key, or else its button\n  \
284         --wait 0.5           let that many seconds pass, in the build's frames (60 a second)\n\n\
285         --frames N           then let N frames pass\n  \
286         --screenshot <file>  write the window's picture there, as a PNG\n  \
287         --log-since N        the log's lines from number N (the last answer's log_next)\n  \
288         --perf-since N       the frames' records from number N (the last answer's perf_next)\n  \
289         --perf-follow <file> then write each new frame's record to the file as a JSON line\n                       \
290         as the build runs, printing nothing until it ends\n  \
291         --for S              follow for S seconds, 1 to 3600 (10), or until the build quits\n  \
292         --perf-snapshot      a heap snapshot with the records (following: at its start and end)\n"
293    )
294}
295
296/// Reads `attach`'s arguments (not `--json`).
297pub fn parse_args(args: &[String]) -> Result<AttachInput, String> {
298    let mut input = AttachInput::default();
299    let mut iter = args.iter();
300    while let Some(arg) = iter.next() {
301        let mut value = || {
302            iter.next()
303                .ok_or_else(|| format!("{arg} needs a value"))
304                .cloned()
305        };
306        if matches!(
307            arg.as_str(),
308            "--keys" | "--script" | "--pad" | "--act" | "--wait" | "--crank"
309        ) {
310            let text = value()?;
311            if let Some(step) = frame_png::parse_step(arg, &text) {
312                input.steps.push(step?);
313            }
314            continue;
315        }
316        match arg.as_str() {
317            "--token" => input.token = value()?,
318            "--screenshot" => input.screenshot = Some(value()?),
319            "--frames" => {
320                let text = value()?;
321                input.frames = text
322                    .parse()
323                    .ok()
324                    .filter(|n| *n <= MAX_IDLE_FRAMES)
325                    .ok_or_else(|| {
326                        format!("--frames: a whole number 0 to {MAX_IDLE_FRAMES}, not {text}")
327                    })?;
328            }
329            "--log-since" => {
330                let text = value()?;
331                input.log_since = text
332                    .parse()
333                    .map_err(|_| format!("--log-since: a whole number, not {text}"))?;
334            }
335            "--perf-since" => {
336                let text = value()?;
337                input.perf_since = Some(
338                    text.parse()
339                        .map_err(|_| format!("--perf-since: a whole number, not {text}"))?,
340                );
341            }
342            "--perf-follow" => input.perf_follow = Some(value()?),
343            "--for" => {
344                let text = value()?;
345                input.follow_for = Some(
346                    text.parse()
347                        .ok()
348                        .filter(|n| (1..=MAX_FOLLOW_FOR).contains(n))
349                        .ok_or_else(|| {
350                            format!("--for: whole seconds, 1 to {MAX_FOLLOW_FOR}, not {text}")
351                        })?,
352                );
353            }
354            "--perf-snapshot" => input.perf_snapshot = true,
355            flag if flag.starts_with("--") => return Err(format!("unknown option: {flag}")),
356            address if input.address.is_empty() => address.clone_into(&mut input.address),
357            other => return Err(format!("one address, not also {other}")),
358        }
359    }
360    if input.address.is_empty() {
361        return Err("the build's address is missing: host:port".to_owned());
362    }
363    if input.token.is_empty() {
364        return Err("--token is missing: the build's log line gives it".to_owned());
365    }
366    if input.follow_for.is_some() && input.perf_follow.is_none() {
367        return Err("--for: only with --perf-follow".to_owned());
368    }
369    if input.perf_snapshot && input.perf_since.is_none() && input.perf_follow.is_none() {
370        return Err("--perf-snapshot: only with --perf-since or --perf-follow".to_owned());
371    }
372    link::check_play(&input.steps)?;
373    Ok(input)
374}
375
376/// Why `attach` stopped: its code and what to say.
377pub(crate) type Failed = (Code, String);
378
379/// The link to a build: its lines both ways. `attach` and MCP's session
380/// (`crate::mcp::attached`) speak the link through it.
381pub(crate) struct Connection {
382    reader: BufReader<TcpStream>,
383    writer: TcpStream,
384    address: String,
385}
386
387impl Connection {
388    /// Connects to the build at `address`.
389    pub(crate) fn open(address: &str) -> Result<Self, Failed> {
390        let no_build = |why: String| {
391            let error = format!("no build is listening at {address}: {why}");
392            (codes::NO_BUILD, error)
393        };
394        let addresses: Vec<SocketAddr> = address
395            .to_socket_addrs()
396            .map_err(|e| (codes::WRONG_ARGUMENTS, format!("{address}: {e}")))?
397            .collect();
398        let mut last = format!("{address} names no address");
399        for socket in addresses {
400            match TcpStream::connect_timeout(&socket, CONNECT) {
401                Ok(stream) => {
402                    let _ = stream.set_nodelay(true);
403                    let writer = stream.try_clone().map_err(|e| no_build(e.to_string()))?;
404                    return Ok(Self {
405                        reader: BufReader::new(stream),
406                        writer,
407                        address: address.to_owned(),
408                    });
409                }
410                Err(e) => last = e.to_string(),
411            }
412        }
413        Err(no_build(last))
414    }
415
416    /// Sends `request` and reads its answer, waiting at most `wait`; `None`
417    /// if the build closed the link.
418    fn ask(&mut self, request: &Request, wait: Duration) -> Result<Option<Line>, Failed> {
419        let lost = |why: String| (codes::LINK_LOST, why);
420        let line = format!("{}\n", request.to_line());
421        if let Err(e) = self.writer.write_all(line.as_bytes()) {
422            return if closed(e.kind()) {
423                Ok(None)
424            } else {
425                Err(lost(format!("the link to {} broke: {e}", self.address)))
426            };
427        }
428        let _ = self.reader.get_ref().set_read_timeout(Some(wait));
429        let mut answer = String::new();
430        match self.reader.read_line(&mut answer) {
431            Ok(0) => Ok(None),
432            Ok(_) => Line::from_line(&answer).map(Some).map_err(|e| {
433                lost(format!(
434                    "the build at {} answered what this shuyi can't read: {e}",
435                    self.address
436                ))
437            }),
438            Err(e) if closed(e.kind()) => Ok(None),
439            Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
440                Err(lost(format!(
441                    "the build at {} didn't answer in {} s",
442                    self.address,
443                    wait.as_secs()
444                )))
445            }
446            Err(e) => Err(lost(format!("the link to {} broke: {e}", self.address))),
447        }
448    }
449
450    /// [`ask`](Self::ask), the build closing the link being an error.
451    pub(crate) fn answer(&mut self, request: &Request, wait: Duration) -> Result<Line, Failed> {
452        self.ask(request, wait)?.ok_or_else(|| {
453            let error = format!("the build at {} closed the link", self.address);
454            (codes::LINK_LOST, error)
455        })
456    }
457}
458
459impl Drop for Connection {
460    /// Closes the link at once: a copy of the socket's handle that a
461    /// child process started meanwhile inherited (on Windows) would keep
462    /// it open, and the build would think the client still attached.
463    fn drop(&mut self) {
464        let _ = self.writer.shutdown(Shutdown::Both);
465    }
466}
467
468/// Whether an error of `kind` means the other end closed the connection.
469fn closed(kind: ErrorKind) -> bool {
470    matches!(
471        kind,
472        ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted | ErrorKind::BrokenPipe
473    )
474}
475
476/// An answer of the wrong kind to `asked`.
477fn unexpected(asked: &str, line: &Line) -> Failed {
478    let error = match line {
479        Line::Error { error } => format!("the build couldn't answer {asked}: {error}"),
480        other => format!("the build answered {asked} with {}", other.to_line()),
481    };
482    (codes::LINK_LOST, error)
483}
484
485/// What `attach` read, before it is an output.
486pub(crate) struct Seen {
487    /// The play's answer, if steps were played.
488    pub(crate) played: Option<Played>,
489    /// Where the build is, after them.
490    pub(crate) status: Status,
491    /// Its frame as text.
492    pub(crate) frame: String,
493    /// Its log from the input's `log_since`.
494    pub(crate) log: LogAnswer,
495    /// The picture written, and what it doesn't show.
496    pub(crate) screenshot: Option<(AttachScreenshot, Option<String>)>,
497    /// The frames' records read, or followed into a file.
498    pub(crate) perf: Option<PerfSeen>,
499}
500
501/// The frames' records `attach` read, or followed into a file.
502pub(crate) struct PerfSeen {
503    /// The records answered; none when followed.
504    records: Vec<FrameRecord>,
505    /// The number to read from next time.
506    next: u64,
507    /// What the records read or written add up to.
508    summary: Option<PerfSummary>,
509    /// The heap snapshot taken last, if one was.
510    snapshot: Option<HeapSnapshot>,
511    /// What the following wrote.
512    follow: Option<AttachFollow>,
513    /// What the records don't show.
514    limits: Vec<String>,
515}
516
517/// The most records one read over the link answers.
518const READ_MAX: u32 = 600;
519
520/// The build's frames' records numbered `since` or more over `link`, the
521/// first `max`, with a heap snapshot after this frame if `snapshot`;
522/// `None` if the build closed the link.
523pub(crate) fn perf_read(
524    link: &mut Connection,
525    since: u64,
526    max: u32,
527    snapshot: bool,
528) -> Result<Option<PerfAnswer>, Failed> {
529    let request = Request::Perf {
530        since,
531        max,
532        snapshot,
533    };
534    match link.ask(&request, ANSWER)? {
535        None => Ok(None),
536        Some(Line::Perf(answer)) => Ok(Some(answer)),
537        Some(other) => Err(unexpected("its frames' records", &other)),
538    }
539}
540
541/// The limits of every perf answer of the link: what the times are,
542/// nothing measured after `frames` frames, and the heap's counts.
543pub(crate) fn perf_limits(records: &[FrameRecord], measured: bool, frames: u32) -> Vec<String> {
544    let mut limits = vec![perf::TIMES.to_owned()];
545    if !measured {
546        limits.push(perf::unmeasured_limit(frames));
547    }
548    // The build counts its own heap, and nothing of this program's.
549    limits.extend(perf::heap_limits(records, false, false));
550    limits
551}
552
553/// The records `input` asks for from `since`, read once over `link`.
554fn perf_once(link: &mut Connection, input: &AttachInput, since: u64) -> Result<PerfSeen, Failed> {
555    let Some(answer) = perf_read(link, since, READ_MAX, input.perf_snapshot)? else {
556        let error = format!("the build at {} closed the link", link.address);
557        return Err((codes::LINK_LOST, error));
558    };
559    let mut limits = perf_limits(&answer.records, answer.measured, answer.frames);
560    if since > answer.next {
561        limits.push(perf::RESET.to_owned());
562    }
563    if answer.dropped > 0 {
564        limits.push(perf::dropped_limit(answer.dropped));
565    }
566    if input.perf_snapshot && answer.snapshot.is_none() {
567        limits.push(perf::HEAP_UNTAGGED.to_owned());
568    }
569    let summary = perf::summary(&answer.records).map(|mut summary| {
570        summary.dropped = answer.dropped;
571        summary
572    });
573    Ok(PerfSeen {
574        records: answer.records,
575        next: answer.next,
576        summary,
577        snapshot: answer.snapshot,
578        follow: None,
579        limits,
580    })
581}
582
583/// A clock a follow reads and waits on.
584pub(crate) trait Clock {
585    /// The time since some moment.
586    fn now(&mut self) -> Duration;
587    /// Waits `time`.
588    fn sleep(&mut self, time: Duration);
589}
590
591/// The wall's clock.
592struct Wall(std::time::Instant);
593
594impl Clock for Wall {
595    fn now(&mut self) -> Duration {
596        self.0.elapsed()
597    }
598
599    fn sleep(&mut self, time: Duration) {
600        std::thread::sleep(time);
601    }
602}
603
604/// What a follow read and wrote.
605#[derive(Debug, Default)]
606pub(crate) struct Followed {
607    /// Every record written, oldest first.
608    pub(crate) records: Vec<FrameRecord>,
609    /// The number to read from next.
610    pub(crate) next: u64,
611    /// The lines written.
612    pub(crate) lines: u32,
613    /// The records no read caught.
614    pub(crate) dropped: u64,
615    /// Whether the build closed the link before the time was up.
616    pub(crate) closed: bool,
617    /// Whether any frame was measured.
618    pub(crate) measured: bool,
619    /// The frames the build had run at the last read.
620    pub(crate) frames: u32,
621    /// The heap snapshots as the following started and as it ended.
622    pub(crate) snapshots: (Option<HeapSnapshot>, Option<HeapSnapshot>),
623}
624
625/// Follows a build's frames from `since` for `seconds` on `clock`: reads
626/// them with `read` (the records from a number, with a heap snapshot if
627/// asked; `None` once the build closed the link) every
628/// [`FOLLOW_EVERY`], at once again after a full read, and once more when
629/// the time is up; and writes each record to `out` as a
630/// [`PerfLine::Frame`](shuyi_ui::perf::PerfLine) line, flushed after each
631/// read. With `snapshot`, the first read and the last take a heap snapshot,
632/// and the two and their diff end the lines.
633pub(crate) fn follow(
634    read: &mut impl FnMut(u64, bool) -> Result<Option<PerfAnswer>, Failed>,
635    since: u64,
636    seconds: u32,
637    snapshot: bool,
638    out: &mut impl Write,
639    clock: &mut impl Clock,
640) -> Result<Followed, Failed> {
641    let written = |e: std::io::Error| (codes::FILE, format!("--perf-follow: {e}"));
642    let end = clock.now() + Duration::from_secs(u64::from(seconds));
643    let mut followed = Followed {
644        next: since,
645        ..Followed::default()
646    };
647    let mut first = true;
648    loop {
649        let at = clock.now();
650        let last = at >= end;
651        let snap = snapshot && (first || last);
652        let Some(answer) = read(followed.next, snap)? else {
653            followed.closed = true;
654            break;
655        };
656        if snap && first {
657            followed.snapshots.0.clone_from(&answer.snapshot);
658        } else if snap {
659            followed.snapshots.1.clone_from(&answer.snapshot);
660        }
661        out.write_all(perf::lines(&answer.records).as_bytes())
662            .and_then(|()| out.flush())
663            .map_err(written)?;
664        let full = answer.records.len() >= usize::try_from(READ_MAX).unwrap_or(usize::MAX);
665        followed.lines = followed
666            .lines
667            .saturating_add(u32::try_from(answer.records.len()).unwrap_or(u32::MAX));
668        followed.dropped += answer.dropped;
669        followed.measured |= answer.measured;
670        followed.frames = answer.frames;
671        followed.next = answer.next;
672        followed.records.extend(answer.records);
673        first = false;
674        if last {
675            break;
676        }
677        if !full {
678            let now = clock.now();
679            let due = FOLLOW_EVERY.saturating_sub(now.saturating_sub(at));
680            clock.sleep(due.min(end.saturating_sub(now)));
681        }
682    }
683    if let (Some(first), Some(last)) = &followed.snapshots {
684        out.write_all(perf::heap_lines(first, last).as_bytes())
685            .and_then(|()| out.flush())
686            .map_err(written)?;
687        followed.lines = followed.lines.saturating_add(3);
688    }
689    Ok(followed)
690}
691
692/// Follows the build's frames over `link` into `input`'s file (from
693/// `cwd`), from `since` or else from the build's next record.
694fn perf_follow(
695    link: &mut Connection,
696    input: &AttachInput,
697    path: &str,
698    cwd: &Path,
699) -> Result<PerfSeen, Failed> {
700    let since = match input.perf_since {
701        Some(since) => since,
702        None => perf_read(link, 0, READ_MAX, false)?.map_or(0, |answer| answer.next),
703    };
704    let file = cwd.join(path);
705    let mut out =
706        fs::File::create(&file).map_err(|e| (codes::FILE, format!("--perf-follow {path}: {e}")))?;
707    let seconds = input.follow_for.unwrap_or(FOLLOW_FOR);
708    let mut read = |since, snapshot| perf_read(link, since, READ_MAX, snapshot);
709    let mut clock = Wall(std::time::Instant::now());
710    let followed = follow(
711        &mut read,
712        since,
713        seconds,
714        input.perf_snapshot,
715        &mut out,
716        &mut clock,
717    )?;
718    Ok(followed_seen(followed, input, &file.display().to_string()))
719}
720
721/// What `followed` shows, written to `file`, as `attach` answers it.
722pub(crate) fn followed_seen(followed: Followed, input: &AttachInput, file: &str) -> PerfSeen {
723    let mut limits = perf_limits(&followed.records, followed.measured, followed.frames);
724    if followed.dropped > 0 {
725        limits.push(format!(
726            "{} frame(s) were dropped between reads: the build ran more than {READ_MAX} frames \
727             between two",
728            followed.dropped
729        ));
730    }
731    if followed.closed {
732        limits.push("the build closed the link before the time was up: it quit".to_owned());
733    }
734    let (first, last) = followed.snapshots;
735    if input.perf_snapshot && !followed.closed && (first.is_none() || last.is_none()) {
736        limits.push(perf::HEAP_UNTAGGED.to_owned());
737    }
738    let summary = perf::summary(&followed.records).map(|mut summary| {
739        summary.dropped = followed.dropped;
740        summary.file = Some(file.to_owned());
741        if let (Some(first), Some(last)) = (&first, &last) {
742            perf::add_heap(&mut summary, first, last);
743        }
744        summary
745    });
746    PerfSeen {
747        records: Vec::new(),
748        next: followed.next,
749        summary,
750        snapshot: last,
751        follow: Some(AttachFollow {
752            file: file.to_owned(),
753            lines: followed.lines,
754            dropped: followed.dropped,
755            closed: followed.closed,
756        }),
757        limits,
758    }
759}
760
761/// `attach`: reaches the build `input` names, plays its steps and reads
762/// what it shows; the picture is written from `cwd`.
763pub fn attach(input: &AttachInput, cwd: &Path) -> Answer<AttachOutput> {
764    if let Err(e) = input.check() {
765        return Answer::failed(Op::Attach, codes::WRONG_ARGUMENTS, e);
766    }
767    let seen = |link: &mut Connection| {
768        let game = hello(link, &input.token)?;
769        let mut seen = played(link, input)?;
770        if let Some(path) = &input.screenshot {
771            seen.screenshot = Some(screenshot(link, path, cwd)?);
772        }
773        if let Some(path) = &input.perf_follow {
774            seen.perf = Some(perf_follow(link, input, path, cwd)?);
775        } else if let Some(since) = input.perf_since {
776            seen.perf = Some(perf_once(link, input, since)?);
777        }
778        Ok((game, seen))
779    };
780    match Connection::open(&input.address).and_then(|mut link| seen(&mut link)) {
781        Ok((game, seen)) => {
782            let limits = limits_of(input, &seen);
783            Answer::new(Op::Attach, true, output(game, seen), limits)
784        }
785        Err((code, error)) => Answer::failed(Op::Attach, code, error),
786    }
787}
788
789/// The build's status over `link`.
790pub(crate) fn status(link: &mut Connection) -> Result<Status, Failed> {
791    match link.answer(&Request::Status, ANSWER)? {
792        Line::Status(status) => Ok(status),
793        other => Err(unexpected("its status", &other)),
794    }
795}
796
797/// Plays `input`'s steps over `link`, the hello said, then reads the
798/// build's status, frame and log; no picture.
799pub(crate) fn played(link: &mut Connection, input: &AttachInput) -> Result<Seen, Failed> {
800    let steps = input.link_steps();
801    let mut played = None;
802    if !steps.is_empty() && status(link)?.phase != Phase::Failed {
803        let frames: u32 = steps
804            .iter()
805            .map(|s| match s {
806                Step::Wait(seconds) => wait_frames(*seconds),
807                _ => 2,
808            })
809            .sum();
810        let wait = ANSWER + Duration::from_secs(u64::from(frames / 30));
811        match link.answer(&Request::Play { steps }, wait)? {
812            Line::Played(done) => played = Some(done),
813            Line::Error { error } => return Err((codes::WRONG_ARGUMENTS, error)),
814            other => return Err(unexpected("the steps", &other)),
815        }
816    }
817    let status = status(link)?;
818    let frame = match link.answer(&Request::Frame, ANSWER)? {
819        Line::Frame { frame, .. } => frame,
820        other => return Err(unexpected("its frame", &other)),
821    };
822    let since = input.log_since;
823    let log = match link.answer(&Request::Log { since }, ANSWER)? {
824        Line::Log(log) => log,
825        other => return Err(unexpected("its log", &other)),
826    };
827    Ok(Seen {
828        played,
829        status,
830        frame,
831        log,
832        screenshot: None,
833        perf: None,
834    })
835}
836
837/// Says hello with `token`: the build's game, or why it won't talk.
838pub(crate) fn hello(link: &mut Connection, token: &str) -> Result<String, Failed> {
839    let hello = Request::Hello {
840        token: token.to_owned(),
841        link: link::VERSION,
842    };
843    match link.ask(&hello, ANSWER)? {
844        None => Err((
845            codes::LINK_TOKEN,
846            format!(
847                "the build at {} closed the link at its hello: the token is wrong",
848                link.address
849            ),
850        )),
851        Some(Line::Hello {
852            link: version,
853            game,
854        }) if version == link::VERSION => Ok(game),
855        Some(Line::Hello { link: version, .. }) => Err((
856            codes::LINK_VERSION,
857            format!(
858                "the build at {} speaks link version {version}, and this shuyi {}",
859                link.address,
860                link::VERSION
861            ),
862        )),
863        Some(Line::Busy) => Err((
864            codes::LINK_LOST,
865            format!(
866                "another client is attached to the build at {}",
867                link.address
868            ),
869        )),
870        Some(other) => Err(unexpected("its hello", &other)),
871    }
872}
873
874/// The window's picture over `link`.
875pub(crate) fn picture(link: &mut Connection) -> Result<link::Screenshot, Failed> {
876    match link.answer(&Request::Screenshot, ANSWER)? {
877        Line::Screenshot(shot) => Ok(shot),
878        other => Err(unexpected("its picture", &other)),
879    }
880}
881
882/// Asks for the window's picture and writes it to `path` (from `cwd`):
883/// what was written, and what the picture doesn't show.
884fn screenshot(
885    link: &mut Connection,
886    path: &str,
887    cwd: &Path,
888) -> Result<(AttachScreenshot, Option<String>), Failed> {
889    let shot = picture(link)?;
890    let png = link::base64::decode(&shot.png).map_err(|e| (codes::LINK_LOST, e))?;
891    fs::write(cwd.join(path), png)
892        .map_err(|e| (codes::FILE, format!("--screenshot {path}: {e}")))?;
893    let written = AttachScreenshot {
894        path: path.to_owned(),
895        width: shot.width,
896        height: shot.height,
897    };
898    Ok((written, shot.limit))
899}
900
901/// What `seen` couldn't show, `input` having asked for it.
902pub(crate) fn limits_of(input: &AttachInput, seen: &Seen) -> Vec<String> {
903    let mut limits = Vec::new();
904    let mut add = |limit: String| {
905        if !limits.contains(&limit) {
906            limits.push(limit);
907        }
908    };
909    match (seen.status.phase, &seen.status.errors) {
910        (Phase::Loading, _) => add("the build shows its loading card: no frame".to_owned()),
911        (Phase::Failed, errors) => add(format!(
912            "the build's game didn't load, so the steps weren't played: {}",
913            errors.as_deref().unwrap_or_default()
914        )),
915        _ => {}
916    }
917    if let Some(played) = &seen.played {
918        if played.waited_loading {
919            add("the build was loading: the steps waited for its game".to_owned());
920        }
921        if played.waited_suspended {
922            add("the build was suspended: the steps waited".to_owned());
923        }
924    }
925    if input.waits() {
926        add("waits are counted in the build's own frames, not in its clock's seconds".to_owned());
927    }
928    if let Some((_, Some(limit))) = &seen.screenshot {
929        add(format!("the picture: {limit}"));
930    }
931    if seen.log.dropped > 0 {
932        add(format!(
933            "the log keeps the last {LOG_LINES} lines: {} line(s) numbered {} or more were \
934             dropped before this read",
935            seen.log.dropped, input.log_since
936        ));
937    }
938    if let Some(perf) = &seen.perf {
939        for limit in &perf.limits {
940            add(limit.clone());
941        }
942    }
943    limits
944}
945
946/// What `seen` shows of the build of `game`, as `attach` answers it.
947fn output(game: String, seen: Seen) -> AttachOutput {
948    let status = seen.status;
949    let phase = match status.phase {
950        Phase::Loading => "loading",
951        Phase::Failed => "failed",
952        _ => "running",
953    };
954    let mut output = AttachOutput {
955        link: link::VERSION,
956        game,
957        frames: status.frames,
958        phase: phase.to_owned(),
959        top_screen: status.top_screen,
960        screens: status.screens,
961        suspended: status.suspended,
962        focused: status.focused,
963        quit: status.quit,
964        frame: seen.frame,
965        screenshot: seen.screenshot.map(|(shot, _)| shot),
966        log: seen
967            .log
968            .lines
969            .into_iter()
970            .map(|line| AttachLogEntry {
971                seq: line.seq,
972                frame: line.frame,
973                level: line.level,
974                category: line.category,
975                text: line.text,
976            })
977            .collect(),
978        log_next: seen.log.next,
979        perf_next: seen.perf.as_ref().map(|perf| perf.next),
980        perf: Vec::new(),
981        perf_summary: None,
982        perf_snapshot: None,
983        follow: None,
984    };
985    if let Some(perf) = seen.perf {
986        output.perf = perf.records;
987        output.perf_summary = perf.summary;
988        output.perf_snapshot = perf.snapshot;
989        output.follow = perf.follow;
990    }
991    output
992}
993
994/// What `attach` saw, for people.
995pub fn text(output: &AttachOutput) -> String {
996    let screens = if output.screens.is_empty() {
997        "none".to_owned()
998    } else {
999        output.screens.join(" > ")
1000    };
1001    let mut out = format!(
1002        "attach: {} on frame {}, {}; top screen: {} ({screens})\n",
1003        output.game,
1004        output.frames,
1005        output.phase,
1006        if output.top_screen.is_empty() {
1007            "none"
1008        } else {
1009            &output.top_screen
1010        },
1011    );
1012    if output.suspended || !output.focused || output.quit {
1013        let said = [
1014            (output.suspended, "suspended"),
1015            (!output.focused, "not focused"),
1016            (output.quit, "quit"),
1017        ];
1018        let said: Vec<&str> = said.iter().filter(|(on, _)| *on).map(|(_, s)| *s).collect();
1019        let _ = writeln!(out, "the build is {}", said.join(", "));
1020    }
1021    if let Some(shot) = &output.screenshot {
1022        let _ = writeln!(
1023            out,
1024            "picture: {} ({} x {})",
1025            shot.path, shot.width, shot.height
1026        );
1027    }
1028    let serious = |entry: &&AttachLogEntry| matches!(entry.level.as_str(), "warn" | "error");
1029    if output.log.iter().any(|entry| serious(&entry)) {
1030        out.push_str("log (warnings and errors; every line with --json):\n");
1031        for entry in output.log.iter().filter(serious) {
1032            let _ = writeln!(
1033                out,
1034                "  {} frame {}: {} {}: {}",
1035                entry.seq, entry.frame, entry.level, entry.category, entry.text
1036            );
1037        }
1038    }
1039    let _ = writeln!(out, "log: next line {}", output.log_next);
1040    if let Some(follow) = &output.follow {
1041        let _ = writeln!(
1042            out,
1043            "followed: {} line(s){}",
1044            follow.lines,
1045            if follow.closed {
1046                ", until the build quit"
1047            } else {
1048                ""
1049            }
1050        );
1051    }
1052    if let Some(summary) = &output.perf_summary {
1053        out.push_str(&perf::text(summary));
1054    }
1055    if let Some(next) = output.perf_next {
1056        let _ = writeln!(out, "perf: next record {next}");
1057    }
1058    let glyphs = output
1059        .frame
1060        .split_once("--- ")
1061        .map_or(output.frame.as_str(), |(glyphs, _)| glyphs);
1062    if !glyphs.is_empty() {
1063        out.push_str("frame (its colours with --json):\n");
1064        out.push_str(glyphs);
1065        if !glyphs.ends_with('\n') {
1066            out.push('\n');
1067        }
1068    }
1069    out
1070}
1071
1072#[cfg(test)]
1073mod tests;