55# LICENSE file in the root directory of this source tree.
66
77import asyncio
8+ import errno
89import json
910import logging
11+ import os
1012import subprocess
1113from datetime import timedelta
1214from logging import Logger , DEBUG as LOG_LEVEL_DEBUG
13- from typing import AsyncGenerator , Dict , List , Optional , Sequence , Union
15+ from typing import AsyncGenerator , Dict , List , Optional , Sequence , Union , Tuple
1416
17+ from idb .common .constants import IDB_LOCAL_TARGETS_FILE , IDB_LOGS_PATH
18+ from idb .common .file import get_last_n_lines
1519from idb .common .format import (
1620 target_description_from_json ,
1721 target_descriptions_from_json ,
1822)
1923from idb .common .logging import log_call
24+ from idb .common .pid_saver import PidSaver
2025from idb .common .types import (
2126 Companion as CompanionBase ,
2227 ECIDFilter ,
@@ -38,6 +43,10 @@ class IdbJsonException(Exception):
3843 pass
3944
4045
46+ class CompanionSpawnerException (Exception ):
47+ pass
48+
49+
4150async def _terminate_process (
4251 process : asyncio .subprocess .Process , timeout : timedelta , logger : logging .Logger
4352) -> None :
@@ -73,13 +82,84 @@ def parse_json_line(line: bytes) -> Dict[str, Union[int, str]]:
7382 raise IdbJsonException (f"Failed to parse json from: { decoded_line } " )
7483
7584
85+ async def _extract_port_from_spawned_companion (stream : asyncio .StreamReader ) -> int :
86+ # The first line of stdout should contain launch info,
87+ # otherwise something bad has happened
88+ line = await stream .readline ()
89+ logging .debug (f"Read line from companion: { line } " )
90+ update = parse_json_line (line )
91+ logging .debug (f"Got update from companion: { update } " )
92+ return int (update ["grpc_port" ])
93+
94+
95+ async def do_spawn_companion (
96+ path : str ,
97+ udid : str ,
98+ log_file_path : str ,
99+ device_set_path : Optional [str ],
100+ port : Optional [int ],
101+ cwd : Optional [str ],
102+ tmp_path : Optional [str ],
103+ reparent : bool ,
104+ tls_cert_path : Optional [str ] = None ,
105+ ) -> Tuple [asyncio .subprocess .Process , int ]:
106+ arguments : List [str ] = [
107+ path ,
108+ "--udid" ,
109+ udid ,
110+ "--grpc-port" ,
111+ str (port ) if port is not None else "0" ,
112+ ]
113+ if tls_cert_path is not None :
114+ arguments .extend (["--tls-cert-path" , tls_cert_path ])
115+ if device_set_path is not None :
116+ arguments .extend (["--device-set-path" , device_set_path ])
117+
118+ env = dict (os .environ )
119+ if tmp_path :
120+ env ["TMPDIR" ] = tmp_path
121+
122+ with open (log_file_path , "a" ) as log_file :
123+ process = await asyncio .create_subprocess_exec (
124+ * arguments ,
125+ stdout = asyncio .subprocess .PIPE ,
126+ stdin = asyncio .subprocess .PIPE if reparent else None ,
127+ stderr = log_file ,
128+ cwd = cwd ,
129+ env = env ,
130+ preexec_fn = os .setpgrp if reparent else None ,
131+ )
132+ logging .debug (f"started companion at process id { process .pid } " )
133+ stdout = none_throws (process .stdout )
134+ try :
135+ extracted_port = await _extract_port_from_spawned_companion (stdout )
136+ except Exception as e :
137+ raise CompanionSpawnerException (
138+ f"Failed to spawn companion, couldn't read port "
139+ f"stderr: { get_last_n_lines (log_file_path , 30 )} "
140+ ) from e
141+ if extracted_port == 0 :
142+ raise CompanionSpawnerException (
143+ f"Failed to spawn companion, port is zero"
144+ f"stderr: { get_last_n_lines (log_file_path , 30 )} "
145+ )
146+ if port is not None and extracted_port != port :
147+ raise CompanionSpawnerException (
148+ "Failed to spawn companion, port is not correct "
149+ f"(expected { port } got { extracted_port } )"
150+ f"stderr: { get_last_n_lines (log_file_path , 30 )} "
151+ )
152+ return (process , extracted_port )
153+
154+
76155class Companion (CompanionBase ):
77156 def __init__ (
78157 self , companion_path : str , device_set_path : Optional [str ], logger : Logger
79158 ) -> None :
80159 self ._companion_path = companion_path
81160 self ._device_set_path = device_set_path
82161 self ._logger = logger
162+ self ._pid_saver = PidSaver (logger = logger )
83163
84164 @asynccontextmanager
85165 async def _start_companion_command (
@@ -142,6 +222,81 @@ async def _run_udid_command(
142222 arguments = [f"--{ command } " , udid ], timeout = timeout
143223 )
144224
225+ self ._pid_saver = PidSaver (logger = self .logger )
226+
227+ def _log_file_path (self , target_udid : str ) -> str :
228+ os .makedirs (name = IDB_LOGS_PATH , exist_ok = True )
229+ return IDB_LOGS_PATH + "/" + target_udid
230+
231+ def _is_notifier_running (self ) -> bool :
232+ pid = self ._pid_saver .get_notifier_pid ()
233+ # Taken from https://fburl.com/ibk820b6
234+ if pid <= 0 :
235+ return False
236+ try :
237+ # no-op if process exists
238+ os .kill (pid , 0 )
239+ return True
240+ except OSError as err :
241+ # EPERM clearly means there's a process to deny access to
242+ # otherwise proc doesn't exist
243+ return err .errno == errno .EPERM
244+ except Exception :
245+ return False
246+
247+ async def _read_notifier_output (self , stream : asyncio .StreamReader ) -> None :
248+ while True :
249+ line = await stream .readline ()
250+ if line is None :
251+ return
252+ update = parse_json_line (line )
253+ if update ["report_initial_state" ]:
254+ return
255+
256+ def check_okay_to_spawn (self ) -> None :
257+ if os .getuid () == 0 :
258+ logging .warning (
259+ "idb should not be run as root. "
260+ "Listing available targets on this host and spawning "
261+ "companions will not work"
262+ )
263+
264+ async def spawn_companion (self , target_udid : str ) -> int :
265+ self .check_okay_to_spawn ()
266+ (process , port ) = await do_spawn_companion (
267+ path = self ._companion_path ,
268+ udid = target_udid ,
269+ log_file_path = self ._log_file_path (target_udid ),
270+ device_set_path = None ,
271+ port = None ,
272+ cwd = None ,
273+ tmp_path = None ,
274+ reparent = True ,
275+ )
276+ self ._pid_saver .save_companion_pid (pid = process .pid )
277+ return port
278+
279+ async def spawn_notifier (self , targets_file : str = IDB_LOCAL_TARGETS_FILE ) -> None :
280+ if self ._is_notifier_running ():
281+ return
282+
283+ self .check_okay_to_spawn ()
284+ cmd = [self ._companion_path , "--notify" , targets_file ]
285+ log_path = self ._log_file_path ("notifier" )
286+ with open (log_path , "a" ) as log_file :
287+ process = await asyncio .create_subprocess_exec (
288+ * cmd , stdout = asyncio .subprocess .PIPE , stderr = log_file
289+ )
290+ try :
291+ self ._pid_saver .save_notifier_pid (pid = process .pid )
292+ await self ._read_notifier_output (stream = none_throws (process .stdout ))
293+ logging .debug (f"started notifier at process id { process .pid } " )
294+ except Exception as e :
295+ raise CompanionSpawnerException (
296+ "Failed to spawn the idb notifier. "
297+ f"Stderr: { get_last_n_lines (log_path , 30 )} "
298+ ) from e
299+
145300 @log_call ()
146301 async def create (
147302 self , device_type : str , os_version : str , timeout : Optional [timedelta ] = None
0 commit comments