Skip to content

Commit 1133a5f

Browse files
Add ability to collect launch information from bazel run (#2774)
By specifying a path with `BAZEL_APPLE_LAUNCH_INFO_PATH`, `bazel run` of an application will write the following JSON to that file: * `platform`: The string `device` if running on a device, otherwise the lldb platform (e.g. `ios-simulator`). * `udid`: The UDID of the device. * `pid`: The PID of the launched application. This information can be used to attach `lldb` to the running application. Since `devicectl` waits until the process exists before writing to `--json_output`, you’ll need to use `BAZEL_DEVICECTL_LAUNCH_FLAGS` to not use `--console`. --------- Signed-off-by: Brentley Jones <github@brentleyjones.com>
1 parent c53a468 commit 1133a5f

2 files changed

Lines changed: 172 additions & 8 deletions

File tree

apple/internal/templates/apple_device.template.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import sys
4646
import tempfile
4747
from typing import Any, Dict, Optional
48+
from uuid import uuid4
4849
import zipfile
4950

5051

@@ -418,19 +419,99 @@ def run_app(
418419
logger.info(
419420
"Launching app %s on %s", app_bundle_id, device_identifier
420421
)
421-
args = [
422+
launch_args = [
422423
devicectl_path,
423424
"device",
424425
"process",
425426
"launch",
426427
*launch_args,
427428
"--device",
428429
device_identifier,
429-
app_bundle_id,
430430
]
431431
# Append optional launch arguments.
432-
args.extend(sys.argv[1:])
433-
subprocess.run(args, env=devicectl_launch_environ(), check=False)
432+
app_args = [app_bundle_id] + sys.argv[1:]
433+
launch_app(
434+
launch_args=launch_args,
435+
app_args=app_args,
436+
env=devicectl_launch_environ(),
437+
device_identifier=device_identifier,
438+
)
439+
440+
441+
def launch_app(
442+
*,
443+
launch_args: list[str],
444+
app_args: list[str],
445+
env: Dict[str, str],
446+
device_identifier: str,
447+
) -> None:
448+
"""Launches an app in a simulator.
449+
450+
Args:
451+
launch_args: The arguments to pass to simctl to launch the app, excluding
452+
the bundle id and app arguments.
453+
app_args: The bundle id and app arguments to pass to simctl to launch the
454+
app.
455+
env: The environment variables to pass to simctl.
456+
device_identifier: The identifier of the device.
457+
"""
458+
launch_info_path = os.environ.get("BAZEL_APPLE_LAUNCH_INFO_PATH")
459+
if not launch_info_path:
460+
subprocess.run(launch_args + app_args, env=env, check=True)
461+
return
462+
463+
if "--json-output" in launch_args:
464+
idx = launch_args.index("--json-output")
465+
json_output_path = launch_args[idx + 1]
466+
delete_json_output = False
467+
else:
468+
json_output_path = os.path.join(
469+
tempfile.gettempdir(), f"devicectl_launch_{uuid4().hex}.json",
470+
)
471+
launch_args = launch_args + ["--json-output", json_output_path]
472+
delete_json_output = True
473+
args = launch_args + app_args
474+
475+
proc = subprocess.Popen(
476+
args,
477+
env=env,
478+
)
479+
480+
exit_code = proc.wait()
481+
482+
# `devicectl` only writes to `--json-output` after the process has exited. We
483+
# use `subprocess.Popen()` instead of `subprocess.run()` to allow us to write
484+
# the launch info before reporting process exit.
485+
try:
486+
with open(json_output_path, "r", encoding="utf-8") as f:
487+
data = json.load(f)
488+
pid = (
489+
data.get("result", {}).get("process", {}).get("processIdentifier")
490+
)
491+
if pid:
492+
os.makedirs(os.path.dirname(launch_info_path), exist_ok=True)
493+
with open(launch_info_path, "w", encoding="utf-8") as f:
494+
f.write(json.dumps(
495+
{
496+
"platform": "device",
497+
"udid": device_identifier,
498+
"pid": pid,
499+
},
500+
indent=2,
501+
))
502+
else:
503+
logger.error("Failed to find PID in JSON output")
504+
except Exception as e:
505+
logger.error("Failed to write launch info to file: %s", e)
506+
507+
if delete_json_output:
508+
try:
509+
os.remove(json_output_path)
510+
except Exception:
511+
pass
512+
513+
if exit_code != 0:
514+
raise subprocess.CalledProcessError(exit_code, args)
434515

435516

436517
def main(

apple/internal/templates/apple_simulator.template.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@
5252
import pathlib
5353
import platform
5454
import plistlib
55+
import pty
56+
import re
5557
import shlex
5658
import shutil
5759
import subprocess
5860
import sys
5961
import tempfile
60-
from typing import Dict, Optional
62+
from typing import IO, Dict, Optional, Sequence
6163
import zipfile
6264

6365

@@ -78,6 +80,22 @@
7880
)
7981

8082

83+
class BufferFlusher:
84+
"""Flushes a buffer to a file descriptor.
85+
86+
This is used to ensure that the buffer is flushed to the file descriptor
87+
as soon as possible.
88+
"""
89+
90+
def __init__(self, raw: IO[bytes]):
91+
self.raw = raw
92+
93+
def write(self, b: bytes) -> int:
94+
n = self.raw.write(b)
95+
self.raw.flush()
96+
return n
97+
98+
8199
class DeviceType(collections.abc.Mapping):
82100
"""Wraps the `devicetype` dictionary from `simctl list -j`.
83101
@@ -662,9 +680,10 @@ def run_app_in_simulator(
662680
root_dir = os.path.dirname(application_output_path)
663681
register_dsyms(root_dir)
664682
with extracted_app(application_output_path, app_name) as app_path:
665-
logger.debug("Installing app %s to simulator %s", app_path, simulator_udid)
683+
logger.info("Installing app %s to simulator %s", app_path, simulator_udid)
666684
subprocess.run(
667-
[simctl_path, "install", simulator_udid, app_path], check=True
685+
[simctl_path, "install", simulator_udid, app_path],
686+
check=True,
668687
)
669688
app_bundle_id = bundle_id(app_path)
670689
launch_args = shlex.split(
@@ -686,7 +705,71 @@ def run_app_in_simulator(
686705
]
687706
# Append optional launch arguments.
688707
args.extend(sys.argv[1:])
689-
subprocess.run(args, env=simctl_launch_environ(), check=False)
708+
launch_app(args, env=simctl_launch_environ(), simulator_udid=simulator_udid)
709+
710+
711+
def launch_app(
712+
args: Sequence[str],
713+
*,
714+
env: Dict[str, str],
715+
simulator_udid: str,
716+
) -> None:
717+
"""Launches an app in a simulator.
718+
719+
Args:
720+
args: The arguments to pass to simctl.
721+
env: The environment variables to pass to simctl.
722+
simulator_udid: The UDID of the simulator in which to run the app.
723+
"""
724+
launch_info_path = os.environ.get("BAZEL_APPLE_LAUNCH_INFO_PATH")
725+
if not launch_info_path:
726+
subprocess.run(args, env=env, check=True)
727+
return
728+
729+
# Open a PTY to capture the output of simctl. We need a PTY to ensure that
730+
# the PID is written to stdout before the rest of the app output.
731+
primary_fd, secondary_fd = pty.openpty()
732+
733+
proc = subprocess.Popen(
734+
args,
735+
env=env,
736+
stdout=secondary_fd,
737+
close_fds=True,
738+
)
739+
740+
# simctl has the fd dup; close ours.
741+
os.close(secondary_fd)
742+
743+
with os.fdopen(primary_fd, "rb", buffering=0) as r:
744+
# Grab PID from the first line of output.
745+
first_line = r.readline()
746+
pid_match = re.search(rb":\s*(\d+)\s*$", first_line)
747+
if pid_match:
748+
pid = int(pid_match.group(1))
749+
try:
750+
os.makedirs(os.path.dirname(launch_info_path), exist_ok=True)
751+
with open(launch_info_path, "w", encoding="utf-8") as f:
752+
f.write(json.dumps(
753+
{
754+
"platform": "ios-simulator",
755+
"udid": simulator_udid,
756+
"pid": pid,
757+
},
758+
indent=2,
759+
))
760+
except Exception as e:
761+
logger.error("Failed to write launch info to file: %s", e)
762+
else:
763+
logger.error("Failed to parse PID from output")
764+
765+
# Stream the rest until simctl exits.
766+
sys.stdout.buffer.write(first_line)
767+
sys.stdout.flush()
768+
shutil.copyfileobj(r, BufferFlusher(sys.stdout.buffer))
769+
770+
exit_code = proc.wait()
771+
if exit_code != 0:
772+
raise subprocess.CalledProcessError(exit_code, args)
690773

691774

692775
def main(

0 commit comments

Comments
 (0)