Skip to main content

android/agent/
deploy.rs

1//! Deploying the agent (android-driver requirements "Agent deployment
2//! without installation", "Single ownership of UI automation" and "Agent
3//! lifecycle"; design D2 and D3).
4//!
5//! [`Deployer::attach`] lists the agent processes on the device, then:
6//!
7//! 1. reuses the agent this host recorded for the device when it still runs,
8//!    its hello carries the recorded token, it speaks the protocol version
9//!    and names the agent version this driver deploys, and the record says
10//!    it was started from the same build (a digest of the jar, since a
11//!    change to the agent's payload need not change its version);
12//! 2. replaces a recorded agent that is not: one whose protocol the host
13//!    refuses exits by itself (the host's welcome says `version_mismatch`),
14//!    another version or another build is asked to `shutdown`, and one that
15//!    does not answer as recorded is killed by pid;
16//! 3. reports any other agent as a [`Conflict`], since it serves another
17//!    host, unless the client passes force;
18//! 4. otherwise pushes the jar read-only to a per-launch path, starts the
19//!    agent detached as the shell user with a per-launch socket name and
20//!    token, waits for its ready line (or its conflict report, which names
21//!    the likely holder of UI automation), deletes the jar, connects and
22//!    checks the versions its hello reports.
23
24use std::fmt;
25use std::io;
26use std::path::PathBuf;
27use std::time::{Duration, Instant};
28
29use protocol::message::Call;
30use protocol::session::{DisconnectReason, HandshakeError, HostConfig, Negotiated};
31use protocol::{Capabilities, PROTOCOL_VERSION};
32
33use super::connection::Connection;
34use super::jar::AgentJar;
35use super::launch::{
36    ConflictLine, Holder, JAR_MODE, PROCESS_NAME, PS_COMMAND, ReadyLine, RunningAgent, SocketName,
37    launch_command, parse_agents, parse_conflict, parse_ready, random_hex, remote_jar,
38};
39use super::records::{Record, Records};
40use crate::adb::{self, Device, ShellPacket};
41
42/// How long `shutdown` may take to be answered.
43const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(2);
44
45/// How long the stream of a refused launch is read for the agent's exit.
46const CONFLICT_EXIT_WAIT: Duration = Duration::from_secs(3);
47
48/// How to attach.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct DeployOptions {
51    /// Take UI automation over: from a known automation client (the agent's
52    /// `--force`), or from an agent this host did not start.
53    pub force: bool,
54    /// How long the agent outlives a host that went away without stopping it
55    /// (`--idle-exit-ms`).
56    pub idle_exit: Duration,
57    /// Longest wait for the ready line. The agent retries a refused role for
58    /// 2 s and waits up to 2 s for an active window.
59    pub ready_timeout: Duration,
60    /// Longest wait for the agent's hello.
61    pub handshake_timeout: Duration,
62    /// How long a replaced or stopped agent may take to exit before it is
63    /// killed.
64    pub exit_timeout: Duration,
65    /// More arguments for `serve`, such as `--trace`.
66    pub agent_args: Vec<String>,
67}
68
69impl Default for DeployOptions {
70    fn default() -> DeployOptions {
71        DeployOptions {
72            force: false,
73            idle_exit: Duration::from_secs(30),
74            ready_timeout: Duration::from_secs(20),
75            handshake_timeout: Duration::from_secs(5),
76            exit_timeout: Duration::from_secs(3),
77            agent_args: Vec::new(),
78        }
79    }
80}
81
82/// The agent a connection reaches.
83#[derive(Clone, PartialEq, Eq)]
84pub struct AgentInfo {
85    pub serial: String,
86    pub socket: String,
87    pub pid: u32,
88    /// The agent name and version its hello carries.
89    pub agent: String,
90    /// The negotiated protocol version.
91    pub protocol: u16,
92    pub capabilities: Capabilities,
93    token: String,
94}
95
96impl fmt::Debug for AgentInfo {
97    /// Leaves the token out.
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.debug_struct("AgentInfo")
100            .field("serial", &self.serial)
101            .field("socket", &self.socket)
102            .field("pid", &self.pid)
103            .field("agent", &self.agent)
104            .field("protocol", &self.protocol)
105            .field("capabilities", &self.capabilities)
106            .finish()
107    }
108}
109
110impl AgentInfo {
111    /// The agent reached on `socket`, with what its handshake settled.
112    #[cfg(test)]
113    pub(crate) fn new(
114        serial: &str,
115        socket: &str,
116        pid: u32,
117        negotiated: &Negotiated,
118        token: &str,
119    ) -> AgentInfo {
120        AgentInfo {
121            serial: serial.to_owned(),
122            socket: socket.to_owned(),
123            pid,
124            agent: negotiated.agent.clone(),
125            protocol: negotiated.version,
126            capabilities: negotiated.capabilities,
127            token: token.to_owned(),
128        }
129    }
130
131    /// The per-launch token its hello carries.
132    pub fn token(&self) -> &str {
133        &self.token
134    }
135}
136
137/// How an attach went.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum Outcome {
140    /// A new agent was started.
141    Launched(Box<Launched>),
142    /// The running agent this host started was reused: no process started.
143    Reused,
144}
145
146/// A launch.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct Launched {
149    pub ready: ReadyLine,
150    /// The incompatible agent the launch replaced.
151    pub replaced: Option<Replaced>,
152    /// The holder of UI automation that force stopped.
153    pub took_over: Option<Holder>,
154}
155
156/// An incompatible agent that was stopped.
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct Replaced {
159    pub pid: u32,
160    pub socket: String,
161    pub reason: Incompatibility,
162    pub stopped: Stopped,
163}
164
165/// How an agent ended.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum Stopped {
168    /// It exited: on `shutdown`, or by itself after a refused hello.
169    Exited,
170    /// It was still there after the exit timeout and was killed by pid.
171    Killed,
172    /// It was gone already.
173    Gone,
174}
175
176/// Why a running agent cannot be reused.
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub enum Incompatibility {
179    /// No common protocol version: the agent speaks `agent`, this driver
180    /// `host`.
181    Protocol { agent: Vec<u16>, host: Vec<u16> },
182    /// Another build of the agent.
183    Version { reported: String, expected: String },
184    /// It does not answer as recorded: no connection, another token, or no
185    /// hello in time.
186    Unreachable(String),
187    /// Another build reporting the same version, as the agent before and
188    /// after a change to its payload can.
189    Build { running: String, deploying: String },
190}
191
192impl fmt::Display for Incompatibility {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Incompatibility::Protocol { agent, host } => write!(
196                f,
197                "it speaks protocol versions {agent:?} and this driver {host:?}"
198            ),
199            Incompatibility::Version { reported, expected } => {
200                write!(f, "it is {reported} and this driver deploys {expected}")
201            }
202            Incompatibility::Unreachable(why) => {
203                write!(f, "it does not answer as this host recorded it: {why}")
204            }
205            Incompatibility::Build { running, deploying } if running.is_empty() => write!(
206                f,
207                "it was recorded before builds were, and this driver deploys build {deploying}"
208            ),
209            Incompatibility::Build { running, deploying } => write!(
210                f,
211                "it is build {running} of the same version, and this driver deploys build \
212                 {deploying}"
213            ),
214        }
215    }
216}
217
218/// Whether an agent can be reused, from its handshake.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub enum Compatibility {
221    Compatible,
222    Incompatible(Incompatibility),
223}
224
225/// Judges a running agent by its handshake: compatible when the host
226/// accepted its hello and it is the build `expected_agent` names
227/// (`mobile-agent-android <version>`).
228pub fn compatibility(
229    handshake: &Result<Negotiated, DisconnectReason>,
230    expected_agent: &str,
231) -> Compatibility {
232    match handshake {
233        Ok(negotiated) if negotiated.agent == expected_agent => Compatibility::Compatible,
234        Ok(negotiated) => Compatibility::Incompatible(Incompatibility::Version {
235            reported: negotiated.agent.clone(),
236            expected: expected_agent.to_owned(),
237        }),
238        Err(DisconnectReason::Handshake(HandshakeError::NoCommonVersion { host, agent })) => {
239            Compatibility::Incompatible(Incompatibility::Protocol {
240                agent: agent.clone(),
241                host: host.clone(),
242            })
243        }
244        Err(reason) => {
245            Compatibility::Incompatible(Incompatibility::Unreachable(reason.to_string()))
246        }
247    }
248}
249
250/// Judges a recorded agent's build against the one being deployed: an
251/// empty recorded build, from a record written before builds were
252/// recorded, is another build.
253pub fn build_compatibility(recorded: &str, deploying: &str) -> Compatibility {
254    if !recorded.is_empty() && recorded == deploying {
255        Compatibility::Compatible
256    } else {
257        Compatibility::Incompatible(Incompatibility::Build {
258            running: recorded.to_owned(),
259            deploying: deploying.to_owned(),
260        })
261    }
262}
263
264/// Where an attach spent its time.
265#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
266pub struct Timings {
267    pub total: Duration,
268    /// Listing the agent processes.
269    pub listing: Duration,
270    /// Checking and stopping an incompatible agent.
271    pub replace: Option<Duration>,
272    pub push: Option<Duration>,
273    /// From opening the launch stream to the ready line.
274    pub spawn_to_ready: Option<Duration>,
275    /// Connecting and completing the handshake.
276    pub handshake: Duration,
277}
278
279/// Whether an agent runs on a target, from the process list alone: an
280/// attach would take the agent's connection away from whoever holds it, so
281/// the state is read from the socket name, which says the version.
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub enum AgentState {
284    Absent,
285    /// An agent of the version and protocol this driver deploys.
286    Running(RunningAgent),
287    /// An agent of another version or protocol, or whose name does not say.
288    Incompatible(RunningAgent),
289}
290
291/// The state `agents` add up to for a driver deploying `build` of
292/// `version` over `protocol`: running when one of them is that build, else
293/// incompatible when any runs.
294pub fn agent_state(
295    agents: &[RunningAgent],
296    version: &str,
297    protocol: u16,
298    build: &str,
299) -> AgentState {
300    let current = agents.iter().find(|agent| {
301        agent
302            .name
303            .as_ref()
304            .is_some_and(|n| n.is(version, protocol, build))
305    });
306    match (current, agents.first()) {
307        (Some(agent), _) => AgentState::Running(agent.clone()),
308        (None, Some(agent)) => AgentState::Incompatible(agent.clone()),
309        (None, None) => AgentState::Absent,
310    }
311}
312
313/// Another process holds UI automation.
314#[derive(Clone, Debug, PartialEq, Eq)]
315pub struct Conflict {
316    /// The likely holder, when the device's process and instrumentation
317    /// lists name one.
318    pub holder: Option<Holder>,
319    /// The agent's own explanation, or the driver's for an agent it did not
320    /// start.
321    pub detail: String,
322}
323
324impl Conflict {
325    /// A conflict with an agent this host did not start.
326    pub fn foreign_agent(agent: &RunningAgent) -> Conflict {
327        let build = match &agent.name {
328            Some(name) => format!("version {} over protocol {}", name.version, name.protocol),
329            None => "a version its socket name does not say".to_owned(),
330        };
331        Conflict {
332            holder: Some(Holder {
333                name: PROCESS_NAME.to_owned(),
334                pid: Some(agent.pid),
335            }),
336            detail: format!(
337                "an agent this host did not start runs on the device (pid {}, socket {}, {build}); \
338                 another daemon or an earlier run of this one may be using it, and it exits \
339                 by itself once no host has been connected for its idle bound",
340                agent.pid, agent.socket
341            ),
342        }
343    }
344
345    /// What the user reads: the holder, the detail and how to take over.
346    pub fn message(&self) -> String {
347        let who = match &self.holder {
348            Some(holder) => format!("likely {holder}"),
349            None => "by a process the device does not name".to_owned(),
350        };
351        format!(
352            "another process holds UI automation, {who}: {}. Attach again with force to take \
353             it over, which stops that holder.",
354            self.detail
355        )
356    }
357
358    fn from_line(line: ConflictLine) -> Conflict {
359        Conflict {
360            holder: line.holder,
361            detail: line.message,
362        }
363    }
364}
365
366impl fmt::Display for Conflict {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        f.write_str(&self.message())
369    }
370}
371
372/// Why an attach or a stop failed.
373#[derive(Debug)]
374pub enum DeployError {
375    Adb(adb::Error),
376    /// Another process holds UI automation; nothing was taken over.
377    Conflict(Conflict),
378    /// The agent ended before its ready line.
379    Exited {
380        output: String,
381        exit: Option<u8>,
382    },
383    /// No ready line within the timeout; the agent was killed.
384    NoReadyLine {
385        waited: Duration,
386        output: String,
387    },
388    /// The launched agent's handshake failed.
389    Handshake {
390        socket: String,
391        reason: DisconnectReason,
392    },
393    /// The launched jar reports another build than this driver deploys; it
394    /// was stopped.
395    WrongVersion {
396        reported: String,
397        expected: String,
398        jar: Option<PathBuf>,
399    },
400    /// The record of the agent could not be written.
401    Records(io::Error),
402}
403
404impl fmt::Display for DeployError {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        match self {
407            DeployError::Adb(error) => write!(f, "adb: {error}"),
408            DeployError::Conflict(conflict) => f.write_str(&conflict.message()),
409            DeployError::Exited { output, exit } => {
410                let status = exit.map_or_else(|| "unknown".to_owned(), |code| code.to_string());
411                write!(
412                    f,
413                    "the agent ended before it was ready (exit status {status}); its output: {output}"
414                )
415            }
416            DeployError::NoReadyLine { waited, output } => write!(
417                f,
418                "the agent printed no ready line within {:.1} s and was stopped; its output: {output}",
419                waited.as_secs_f64()
420            ),
421            DeployError::Handshake { socket, reason } => write!(
422                f,
423                "the agent listening on {socket} did not complete its handshake: {reason}"
424            ),
425            DeployError::WrongVersion {
426                reported,
427                expected,
428                jar,
429            } => {
430                let jar = jar
431                    .as_ref()
432                    .map_or_else(|| "in memory".to_owned(), |p| p.display().to_string());
433                write!(
434                    f,
435                    "the agent jar ({jar}) reports {reported}, but this driver deploys {expected}; \
436                     rebuild it with agent-android/build.sh"
437                )
438            }
439            DeployError::Records(error) => write!(f, "cannot record the agent: {error}"),
440        }
441    }
442}
443
444impl std::error::Error for DeployError {}
445
446impl From<adb::Error> for DeployError {
447    fn from(error: adb::Error) -> DeployError {
448        DeployError::Adb(error)
449    }
450}
451
452/// Pushes `jar` to `remote` read-only ([`super::JAR_MODE`]).
453pub fn push_jar(device: &Device, jar: &AgentJar, remote: &str) -> Result<(), adb::Error> {
454    device.push_bytes(jar.bytes(), remote, JAR_MODE)
455}
456
457/// What the launch stream showed.
458enum Spawned {
459    Ready(ReadyLine),
460    Conflict(ConflictLine),
461    Exited { output: String, exit: Option<u8> },
462    Timeout { output: String },
463}
464
465/// Stdout lines as they complete, and everything else the stream carried.
466#[derive(Default)]
467struct Transcript {
468    partial: Vec<u8>,
469    lines: Vec<String>,
470    stderr: String,
471}
472
473impl Transcript {
474    /// Adds stdout bytes; returns the lines they completed.
475    fn stdout(&mut self, data: &[u8]) -> Vec<String> {
476        self.partial.extend_from_slice(data);
477        let mut done = Vec::new();
478        while let Some(end) = self.partial.iter().position(|&b| b == b'\n') {
479            let line: Vec<u8> = self.partial.drain(..=end).collect();
480            let line = String::from_utf8_lossy(&line).trim_end().to_owned();
481            self.lines.push(line.clone());
482            done.push(line);
483        }
484        done
485    }
486
487    fn text(&self) -> String {
488        let mut text: Vec<String> = self.lines.iter().map(|l| format!("out: {l}")).collect();
489        if !self.partial.is_empty() {
490            text.push(format!("out: {}", String::from_utf8_lossy(&self.partial)));
491        }
492        text.extend(self.stderr.lines().map(|l| format!("err: {l}")));
493        text.join("\n")
494    }
495}
496
497/// Deploys one agent build on one device. Clones share the records.
498#[derive(Clone, Debug)]
499pub struct Deployer {
500    device: Device,
501    jar: AgentJar,
502    records: Records,
503}
504
505/// A connection to an agent, with how it was obtained.
506pub struct Attached {
507    pub connection: Connection,
508    pub agent: AgentInfo,
509    pub outcome: Outcome,
510    pub timings: Timings,
511    deployer: Deployer,
512}
513
514impl fmt::Debug for Attached {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        f.debug_struct("Attached")
517            .field("agent", &self.agent)
518            .field("outcome", &self.outcome)
519            .field("timings", &self.timings)
520            .finish_non_exhaustive()
521    }
522}
523
524impl Attached {
525    /// The deployer that attached, to stop the agent later.
526    pub fn deployer(&self) -> &Deployer {
527        &self.deployer
528    }
529
530    /// Stops the agent: `shutdown`, then a kill by pid if it has not exited
531    /// within `timeout`; forgets its record.
532    pub fn stop(self, timeout: Duration) -> Result<Stopped, DeployError> {
533        let Attached {
534            connection,
535            agent,
536            deployer,
537            ..
538        } = self;
539        deployer.stop_agent(&connection, agent.pid, &agent.socket, timeout)
540    }
541
542    /// The connection, the agent, the outcome and the deployer.
543    pub(crate) fn into_parts(self) -> (Connection, AgentInfo, Outcome, Deployer) {
544        (self.connection, self.agent, self.outcome, self.deployer)
545    }
546}
547
548impl Deployer {
549    pub fn new(device: Device, jar: AgentJar, records: Records) -> Deployer {
550        Deployer {
551            device,
552            jar,
553            records,
554        }
555    }
556
557    pub fn device(&self) -> &Device {
558        &self.device
559    }
560
561    pub fn jar(&self) -> &AgentJar {
562        &self.jar
563    }
564
565    pub fn records(&self) -> &Records {
566        &self.records
567    }
568
569    /// The agent processes on the device.
570    pub fn running(&self) -> Result<Vec<RunningAgent>, adb::Error> {
571        let listing = self.device.shell(PS_COMMAND)?;
572        Ok(parse_agents(&listing.stdout_text()))
573    }
574
575    /// The target's agent state, from the process list.
576    pub fn state(&self) -> Result<AgentState, adb::Error> {
577        Ok(agent_state(
578            &self.running()?,
579            self.jar.version(),
580            PROTOCOL_VERSION,
581            &self.jar.build(),
582        ))
583    }
584
585    /// Connects to an agent: the running one this host started when it is
586    /// compatible, else a new one. Any connection the reused agent had is
587    /// replaced by this one.
588    pub fn attach(&self, options: &DeployOptions) -> Result<Attached, DeployError> {
589        let started = Instant::now();
590        let agents = self.running()?;
591        let mut timings = Timings {
592            listing: started.elapsed(),
593            ..Timings::default()
594        };
595        let serial = self.device.serial();
596        let mut replaced = None;
597        let mut recorded_pid = None;
598        if let Some(record) = self.records.get(serial) {
599            let running = agents
600                .iter()
601                .any(|a| a.pid == record.pid && a.socket == record.socket);
602            if running {
603                recorded_pid = Some(record.pid);
604                let checking = Instant::now();
605                match self.check(&record, options) {
606                    Ok((connection, negotiated)) => {
607                        timings.handshake = checking.elapsed();
608                        timings.total = started.elapsed();
609                        return Ok(Attached {
610                            agent: AgentInfo {
611                                serial: serial.to_owned(),
612                                socket: record.socket.clone(),
613                                pid: record.pid,
614                                agent: negotiated.agent,
615                                protocol: negotiated.version,
616                                capabilities: negotiated.capabilities,
617                                token: record.token.clone(),
618                            },
619                            connection,
620                            outcome: Outcome::Reused,
621                            timings,
622                            deployer: self.clone(),
623                        });
624                    }
625                    Err((reason, connection)) => {
626                        let stopped = self.replace(&record, &reason, connection, options)?;
627                        timings.replace = Some(checking.elapsed());
628                        replaced = Some(Replaced {
629                            pid: record.pid,
630                            socket: record.socket.clone(),
631                            reason,
632                            stopped,
633                        });
634                    }
635                }
636            }
637            self.records.remove(serial).map_err(DeployError::Records)?;
638        }
639        let foreign = agents.iter().find(|a| Some(a.pid) != recorded_pid);
640        if let Some(other) = foreign
641            && !options.force
642        {
643            return Err(DeployError::Conflict(Conflict::foreign_agent(other)));
644        }
645        self.launch(options, replaced, timings, started)
646    }
647
648    /// Connects to the recorded agent with its token and judges it.
649    fn check(
650        &self,
651        record: &Record,
652        options: &DeployOptions,
653    ) -> Result<(Connection, Negotiated), (Incompatibility, Option<Connection>)> {
654        let unreachable = |why: String| (Incompatibility::Unreachable(why), None);
655        let stream = self
656            .device
657            .open_abstract(&record.socket)
658            .map_err(|e| unreachable(e.to_string()))?;
659        let connection = Connection::open(stream, self.host_config(&record.token))
660            .map_err(|e| unreachable(e.to_string()))?;
661        let handshake = connection.handshake(options.handshake_timeout);
662        match (compatibility(&handshake, &self.jar.agent_name()), handshake) {
663            (Compatibility::Compatible, Ok(negotiated)) => {
664                match build_compatibility(&record.build, &self.jar.build()) {
665                    Compatibility::Compatible => Ok((connection, negotiated)),
666                    Compatibility::Incompatible(reason) => Err((reason, Some(connection))),
667                }
668            }
669            (Compatibility::Incompatible(reason), _) => Err((reason, Some(connection))),
670            (Compatibility::Compatible, Err(reason)) => Err(unreachable(reason.to_string())),
671        }
672    }
673
674    /// Stops the agent `pid` on `socket` that `connection` reaches:
675    /// `shutdown`, answered before it exits, then a kill past `timeout`; and
676    /// forgets its record.
677    pub(crate) fn stop_agent(
678        &self,
679        connection: &Connection,
680        pid: u32,
681        socket: &str,
682        timeout: Duration,
683    ) -> Result<Stopped, DeployError> {
684        let _ = connection.call(Call::Shutdown, SHUTDOWN_DEADLINE);
685        connection.close();
686        let stopped = self.finish(pid, socket, timeout)?;
687        self.forget(pid)?;
688        Ok(stopped)
689    }
690
691    /// Stops an agent that cannot be reused: one whose protocol was refused
692    /// exits by itself, another build is asked to `shutdown`, and anything
693    /// still there after the exit timeout is killed.
694    fn replace(
695        &self,
696        record: &Record,
697        reason: &Incompatibility,
698        connection: Option<Connection>,
699        options: &DeployOptions,
700    ) -> Result<Stopped, DeployError> {
701        let answers = matches!(
702            reason,
703            Incompatibility::Version { .. } | Incompatibility::Build { .. }
704        );
705        if let (true, Some(connection)) = (answers, &connection) {
706            let _ = connection.call(Call::Shutdown, SHUTDOWN_DEADLINE);
707        }
708        drop(connection);
709        self.finish(record.pid, &record.socket, options.exit_timeout)
710    }
711
712    /// Waits up to `timeout` for the agent `pid` on `socket` to exit, then
713    /// kills it.
714    fn finish(&self, pid: u32, socket: &str, timeout: Duration) -> Result<Stopped, DeployError> {
715        if self.wait_gone(pid, socket, timeout)? {
716            return Ok(Stopped::Exited);
717        }
718        self.kill(pid, socket)?;
719        if self.wait_gone(pid, socket, Duration::from_secs(2))? {
720            Ok(Stopped::Killed)
721        } else {
722            Err(DeployError::Exited {
723                output: format!("agent {pid} on {socket} survived SIGKILL"),
724                exit: None,
725            })
726        }
727    }
728
729    /// Whether the agent `pid` on `socket` is gone within `timeout`, polled
730    /// on the device every 20 ms.
731    fn wait_gone(&self, pid: u32, socket: &str, timeout: Duration) -> Result<bool, adb::Error> {
732        let polls = (timeout.as_millis() / 20).max(1);
733        let script = format!(
734            "i=0; while grep -qF {socket} /proc/{pid}/cmdline 2>/dev/null; do \
735             i=$((i+1)); [ $i -gt {polls} ] && exit 1; sleep 0.02; done; exit 0"
736        );
737        Ok(self.device.shell(&script)?.success())
738    }
739
740    /// Kills `pid` if its command line still names `socket`.
741    fn kill(&self, pid: u32, socket: &str) -> Result<(), adb::Error> {
742        self.device.shell(&format!(
743            "grep -qF {socket} /proc/{pid}/cmdline 2>/dev/null && kill -9 {pid}"
744        ))?;
745        Ok(())
746    }
747
748    /// Kills every agent process listening on `socket`.
749    fn kill_socket(&self, socket: &str) -> Result<(), adb::Error> {
750        for agent in self.running()? {
751            if agent.socket == socket {
752                self.kill(agent.pid, socket)?;
753            }
754        }
755        Ok(())
756    }
757
758    /// Forgets the record of the agent `pid`, and only that one.
759    fn forget(&self, pid: u32) -> Result<(), DeployError> {
760        let serial = self.device.serial();
761        if self.records.get(serial).is_some_and(|r| r.pid == pid) {
762            self.records.remove(serial).map_err(DeployError::Records)?;
763        }
764        Ok(())
765    }
766
767    fn host_config(&self, token: &str) -> HostConfig {
768        HostConfig {
769            token: Some(token.to_owned()),
770            ..HostConfig::default()
771        }
772    }
773
774    /// Pushes the jar, starts the agent detached, waits for its ready line,
775    /// deletes the jar, connects and checks the build its hello names.
776    fn launch(
777        &self,
778        options: &DeployOptions,
779        replaced: Option<Replaced>,
780        mut timings: Timings,
781        started: Instant,
782    ) -> Result<Attached, DeployError> {
783        let socket = SocketName::generate(self.jar.version(), PROTOCOL_VERSION, &self.jar.build())
784            .to_string();
785        let token = random_hex(16);
786        let remote = remote_jar(&socket);
787        let pushing = Instant::now();
788        push_jar(&self.device, &self.jar, &remote)?;
789        timings.push = Some(pushing.elapsed());
790        let command = launch_command(
791            &remote,
792            &socket,
793            options.idle_exit,
794            options.force,
795            &options.agent_args,
796        );
797        let spawning = Instant::now();
798        let spawned = self.spawn(&command, &token, options.ready_timeout);
799        timings.spawn_to_ready = Some(spawning.elapsed());
800        // The running agent keeps the dex it loaded; nothing needs the file.
801        let _ = self.device.shell(&format!("rm -f {remote}"));
802        let ready = match spawned? {
803            Spawned::Ready(ready) => ready,
804            Spawned::Conflict(line) => {
805                return Err(DeployError::Conflict(Conflict::from_line(line)));
806            }
807            Spawned::Exited { output, exit } => {
808                return Err(DeployError::Exited { output, exit });
809            }
810            Spawned::Timeout { output } => {
811                let _ = self.kill_socket(&socket);
812                return Err(DeployError::NoReadyLine {
813                    waited: options.ready_timeout,
814                    output,
815                });
816            }
817        };
818        let connecting = Instant::now();
819        let connection = self.device.open_abstract(&socket).and_then(|stream| {
820            Connection::open(stream, self.host_config(&token)).map_err(|source| adb::Error::Io {
821                request: format!("connect to the agent on {socket}"),
822                source,
823            })
824        });
825        let connection = match connection {
826            Ok(connection) => connection,
827            Err(error) => {
828                let _ = self.kill(ready.pid, &socket);
829                return Err(error.into());
830            }
831        };
832        let negotiated = match connection.handshake(options.handshake_timeout) {
833            Ok(negotiated) => negotiated,
834            Err(reason) => {
835                drop(connection);
836                let _ = self.kill(ready.pid, &socket);
837                return Err(DeployError::Handshake { socket, reason });
838            }
839        };
840        timings.handshake = connecting.elapsed();
841        let expected = self.jar.agent_name();
842        if negotiated.agent != expected {
843            let _ = connection.call(Call::Shutdown, SHUTDOWN_DEADLINE);
844            drop(connection);
845            let _ = self.finish(ready.pid, &socket, options.exit_timeout);
846            return Err(DeployError::WrongVersion {
847                reported: negotiated.agent,
848                expected,
849                jar: self.jar.path().map(PathBuf::from),
850            });
851        }
852        let agent = AgentInfo {
853            serial: self.device.serial().to_owned(),
854            socket: socket.clone(),
855            pid: ready.pid,
856            agent: negotiated.agent,
857            protocol: negotiated.version,
858            capabilities: negotiated.capabilities,
859            token,
860        };
861        self.records
862            .put(Record {
863                serial: agent.serial.clone(),
864                socket,
865                pid: agent.pid,
866                token: agent.token.clone(),
867                agent: agent.agent.clone(),
868                protocol: agent.protocol,
869                build: self.jar.build(),
870            })
871            .map_err(DeployError::Records)?;
872        timings.total = started.elapsed();
873        let took_over = ready.forced.clone();
874        Ok(Attached {
875            connection,
876            agent,
877            outcome: Outcome::Launched(Box::new(Launched {
878                ready,
879                replaced,
880                took_over,
881            })),
882            timings,
883            deployer: self.clone(),
884        })
885    }
886
887    /// Runs the launch command, hands it the token, and reads the stream
888    /// until the ready or conflict line. The stream is dropped on return,
889    /// which leaves a ready agent running (see [`launch_command`]).
890    fn spawn(&self, command: &str, token: &str, timeout: Duration) -> Result<Spawned, DeployError> {
891        let mut stream = self.device.shell_stream(command)?;
892        stream.write_stdin(format!("{token}\n").as_bytes())?;
893        stream.close_stdin()?;
894        let deadline = Instant::now() + timeout;
895        let mut transcript = Transcript::default();
896        let mut exit = None;
897        loop {
898            let left = deadline.saturating_duration_since(Instant::now());
899            if left.is_zero() {
900                return Ok(Spawned::Timeout {
901                    output: transcript.text(),
902                });
903            }
904            stream.set_read_timeout(Some(left))?;
905            let packet = match stream.read_packet() {
906                Ok(packet) => packet,
907                Err(error) if error.is_timeout() => continue,
908                Err(error) => return Err(error.into()),
909            };
910            match packet {
911                Some(ShellPacket::Stdout(data)) => {
912                    for line in transcript.stdout(&data) {
913                        if let Some(ready) = parse_ready(&line) {
914                            return Ok(Spawned::Ready(ready));
915                        }
916                        if let Some(conflict) = parse_conflict(&line) {
917                            // The agent exits after its report; its end
918                            // closes the stream.
919                            let _ = stream.set_read_timeout(Some(CONFLICT_EXIT_WAIT));
920                            while let Ok(Some(_)) = stream.read_packet() {}
921                            return Ok(Spawned::Conflict(conflict));
922                        }
923                    }
924                }
925                Some(ShellPacket::Stderr(data)) => {
926                    transcript.stderr.push_str(&String::from_utf8_lossy(&data));
927                }
928                Some(ShellPacket::Exit(code)) => exit = Some(code),
929                None => {
930                    return Ok(Spawned::Exited {
931                        output: transcript.text(),
932                        exit,
933                    });
934                }
935            }
936        }
937    }
938
939    /// Stops the agent this host recorded for the device, when it still
940    /// runs, without a connection to it: `shutdown` over a new connection,
941    /// else a kill by pid. `None` when there was nothing to stop.
942    pub fn stop_recorded(&self, timeout: Duration) -> Result<Option<Stopped>, DeployError> {
943        let serial = self.device.serial();
944        let Some(record) = self.records.get(serial) else {
945            return Ok(None);
946        };
947        let running = self
948            .running()?
949            .into_iter()
950            .any(|a| a.pid == record.pid && a.socket == record.socket);
951        let stopped = if running {
952            if let Ok(stream) = self.device.open_abstract(&record.socket)
953                && let Ok(connection) = Connection::open(stream, self.host_config(&record.token))
954                && connection.handshake(timeout).is_ok()
955            {
956                let _ = connection.call(Call::Shutdown, SHUTDOWN_DEADLINE);
957            }
958            self.finish(record.pid, &record.socket, timeout)?
959        } else {
960            Stopped::Gone
961        };
962        self.records.remove(serial).map_err(DeployError::Records)?;
963        Ok(Some(stopped))
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::agent::launch::SocketName;
971    use protocol::session::HandshakeError;
972
973    const EXPECTED: &str = "mobile-agent-android 0.1.0";
974
975    fn negotiated(agent: &str) -> Negotiated {
976        Negotiated {
977            version: 1,
978            max_frame: 1 << 24,
979            capabilities: Capabilities::TREE,
980            agent: agent.into(),
981            host_versions: vec![1],
982            agent_versions: vec![1],
983        }
984    }
985
986    fn running(pid: u32, socket: &str) -> RunningAgent {
987        RunningAgent {
988            pid,
989            socket: socket.into(),
990            name: SocketName::parse(socket),
991        }
992    }
993
994    #[test]
995    fn the_same_build_on_the_same_protocol_is_compatible() {
996        assert_eq!(
997            compatibility(&Ok(negotiated(EXPECTED)), EXPECTED),
998            Compatibility::Compatible
999        );
1000    }
1001
1002    /// Spec scenario "Incompatible agent replaced": the running agent reports
1003    /// an older protocol version than the daemon requires.
1004    #[test]
1005    fn an_agent_on_an_older_protocol_is_incompatible() {
1006        let refused = Err(DisconnectReason::Handshake(
1007            HandshakeError::NoCommonVersion {
1008                host: vec![1],
1009                agent: vec![0],
1010            },
1011        ));
1012        assert_eq!(
1013            compatibility(&refused, EXPECTED),
1014            Compatibility::Incompatible(Incompatibility::Protocol {
1015                agent: vec![0],
1016                host: vec![1],
1017            })
1018        );
1019    }
1020
1021    #[test]
1022    fn another_build_is_incompatible() {
1023        assert_eq!(
1024            compatibility(&Ok(negotiated("mobile-agent-android 0.0.9")), EXPECTED),
1025            Compatibility::Incompatible(Incompatibility::Version {
1026                reported: "mobile-agent-android 0.0.9".into(),
1027                expected: EXPECTED.into(),
1028            })
1029        );
1030    }
1031
1032    #[test]
1033    fn an_agent_that_does_not_answer_as_recorded_is_unreachable() {
1034        for reason in [
1035            DisconnectReason::Handshake(HandshakeError::TokenMismatch),
1036            DisconnectReason::Eof,
1037            DisconnectReason::Silent(Duration::from_secs(3)),
1038        ] {
1039            let text = reason.to_string();
1040            assert_eq!(
1041                compatibility(&Err(reason), EXPECTED),
1042                Compatibility::Incompatible(Incompatibility::Unreachable(text))
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn incompatibilities_say_what_differs() {
1049        let protocol = Incompatibility::Protocol {
1050            agent: vec![0],
1051            host: vec![1],
1052        }
1053        .to_string();
1054        assert!(
1055            protocol.contains("[0]") && protocol.contains("[1]"),
1056            "{protocol}"
1057        );
1058        let version = Incompatibility::Version {
1059            reported: "mobile-agent-android 0.0.9".into(),
1060            expected: EXPECTED.into(),
1061        }
1062        .to_string();
1063        assert!(
1064            version.contains("0.0.9") && version.contains("0.1.0"),
1065            "{version}"
1066        );
1067    }
1068
1069    const BUILD: &str = "0123456789ab";
1070
1071    #[test]
1072    fn no_agent_process_is_absent() {
1073        assert_eq!(agent_state(&[], "0.1.0", 1, BUILD), AgentState::Absent);
1074    }
1075
1076    #[test]
1077    fn an_agent_named_for_this_build_is_running() {
1078        let agent = running(812, "mobile-agent-0.1.0-p1-0123456789ab-00ff");
1079        assert_eq!(
1080            agent_state(std::slice::from_ref(&agent), "0.1.0", 1, BUILD),
1081            AgentState::Running(agent)
1082        );
1083    }
1084
1085    #[test]
1086    fn another_version_protocol_build_or_an_unknown_name_is_incompatible() {
1087        for socket in [
1088            "mobile-agent-0.0.9-p1-0123456789ab-00ff",
1089            "mobile-agent-0.1.0-p2-0123456789ab-00ff",
1090            "mobile-agent-0.1.0-p1-ba9876543210-00ff",
1091            "mobile-agent-0.1.0-p1-00ff",
1092            "mobile-agent-3f9a1c2b7d4e",
1093        ] {
1094            let agent = running(812, socket);
1095            assert_eq!(
1096                agent_state(std::slice::from_ref(&agent), "0.1.0", 1, BUILD),
1097                AgentState::Incompatible(agent),
1098                "{socket}"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn a_compatible_agent_wins_over_an_incompatible_one() {
1105        let old = running(700, "mobile-agent-0.1.0-p1-ba9876543210-aa");
1106        let current = running(812, "mobile-agent-0.1.0-p1-0123456789ab-bb");
1107        assert_eq!(
1108            agent_state(&[old, current.clone()], "0.1.0", 1, BUILD),
1109            AgentState::Running(current)
1110        );
1111    }
1112
1113    /// The agent's payload gained window metadata without a version bump:
1114    /// a recorded agent of another build reporting the same version is
1115    /// replaced, as is one recorded before builds were.
1116    #[test]
1117    fn a_recorded_agent_of_another_build_is_incompatible() {
1118        assert_eq!(build_compatibility(BUILD, BUILD), Compatibility::Compatible);
1119        assert_eq!(
1120            build_compatibility("ba9876543210", BUILD),
1121            Compatibility::Incompatible(Incompatibility::Build {
1122                running: "ba9876543210".into(),
1123                deploying: BUILD.into(),
1124            })
1125        );
1126        assert_eq!(
1127            build_compatibility("", BUILD),
1128            Compatibility::Incompatible(Incompatibility::Build {
1129                running: String::new(),
1130                deploying: BUILD.into(),
1131            })
1132        );
1133        let text = Incompatibility::Build {
1134            running: "ba9876543210".into(),
1135            deploying: BUILD.into(),
1136        }
1137        .to_string();
1138        assert!(
1139            text.contains("ba9876543210") && text.contains(BUILD),
1140            "{text}"
1141        );
1142    }
1143
1144    /// Spec scenario "Another tool holds automation": the error names the
1145    /// likely holder and how to take over.
1146    #[test]
1147    fn a_conflict_names_the_holder_and_the_force_option() {
1148        let conflict = Conflict {
1149            holder: Some(Holder {
1150                name: "uiautomator".into(),
1151                pid: Some(4702),
1152            }),
1153            detail: "UI automation is held by another process, likely uiautomator (pid 4702: \
1154                     uiautomator events); stop it, or run the agent with --force to take the \
1155                     role over"
1156                .into(),
1157        };
1158        let message = conflict.message();
1159        assert!(message.contains("uiautomator (pid 4702)"), "{message}");
1160        assert!(message.contains("force"), "{message}");
1161        assert!(
1162            DeployError::Conflict(conflict)
1163                .to_string()
1164                .contains("uiautomator")
1165        );
1166    }
1167
1168    #[test]
1169    fn an_agent_this_host_did_not_start_is_a_conflict_naming_it() {
1170        let conflict = Conflict::foreign_agent(&running(900, "mobile-agent-0.1.0-p1-cc"));
1171        assert_eq!(
1172            conflict.holder,
1173            Some(Holder {
1174                name: "mobile-agent".into(),
1175                pid: Some(900),
1176            })
1177        );
1178        let message = conflict.message();
1179        assert!(message.contains("mobile-agent-0.1.0-p1-cc"), "{message}");
1180        assert!(message.contains("force"), "{message}");
1181    }
1182
1183    #[test]
1184    fn deploy_errors_explain_themselves() {
1185        let exited = DeployError::Exited {
1186            output: "err: cannot listen on @s: Address already in use".into(),
1187            exit: Some(1),
1188        }
1189        .to_string();
1190        assert!(exited.contains("cannot listen"), "{exited}");
1191        let wrong = DeployError::WrongVersion {
1192            reported: "mobile-agent-android 0.0.9".into(),
1193            expected: EXPECTED.into(),
1194            jar: Some(PathBuf::from("/tmp/a.jar")),
1195        }
1196        .to_string();
1197        assert!(wrong.contains("agent-android/build.sh"), "{wrong}");
1198        assert!(wrong.contains("/tmp/a.jar"), "{wrong}");
1199    }
1200
1201    #[test]
1202    fn the_defaults_match_the_agents_own() {
1203        let options = DeployOptions::default();
1204        assert!(!options.force);
1205        assert_eq!(options.idle_exit, Duration::from_secs(30));
1206        assert!(options.ready_timeout >= Duration::from_secs(5));
1207    }
1208}