1#!/usr/bin/env vpython3
2#
3# Copyright 2020 The Chromium Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6"""Helps launch lacros-chrome with mojo connection established on Linux
7  or Chrome OS. Use on Chrome OS is for dev purposes.
8
9  The main use case is to be able to launch lacros-chrome in a debugger.
10
11  Please first launch an ash-chrome in the background as usual except without
12  the '--lacros-chrome-path' argument and with an additional
13  '--lacros-mojo-socket-for-testing' argument pointing to a socket path:
14
15  XDG_RUNTIME_DIR=/tmp/ash_chrome_xdg_runtime ./out/ash/chrome \\
16      --user-data-dir=/tmp/ash-chrome --enable-wayland-server \\
17      --no-startup-window --enable-features=LacrosSupport \\
18      --lacros-mojo-socket-for-testing=/tmp/lacros.sock
19
20  Then, run this script with '-s' pointing to the same socket path used to
21  launch ash-chrome, followed by a command one would use to launch lacros-chrome
22  inside a debugger:
23
24  EGL_PLATFORM=surfaceless XDG_RUNTIME_DIR=/tmp/ash_chrome_xdg_runtime \\
25  ./build/lacros/mojo_connection_lacros_launcher.py -s /tmp/lacros.sock
26  gdb --args ./out/lacros-release/chrome --user-data-dir=/tmp/lacros-chrome
27"""
28
29import argparse
30import array
31import contextlib
32import os
33import pathlib
34import socket
35import sys
36import subprocess
37
38
39_NUM_FDS_MAX = 3
40
41
42# contextlib.nullcontext is introduced in 3.7, while Python version on
43# CrOS is still 3.6. This is for backward compatibility.
44class NullContext:
45  def __init__(self, enter_ret=None):
46    self.enter_ret = enter_ret
47
48  def __enter__(self):
49    return self.enter_ret
50
51  def __exit__(self, exc_type, exc_value, trace):
52    pass
53
54
55def _ReceiveFDs(sock):
56  """Receives FDs from ash-chrome that will be used to launch lacros-chrome.
57
58  Args:
59    sock: A connected unix domain socket.
60
61  Returns:
62    File objects for the mojo connection and maybe startup data file.
63  """
64  # This function is borrowed from with modifications:
65  # https://docs.python.org/3/library/socket.html#socket.socket.recvmsg
66  fds = array.array("i")  # Array of ints
67  # Along with the file descriptor, ash-chrome also sends the version in the
68  # regular data.
69  version, ancdata, _, _ = sock.recvmsg(
70      1, socket.CMSG_LEN(fds.itemsize * _NUM_FDS_MAX))
71  for cmsg_level, cmsg_type, cmsg_data in ancdata:
72    if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
73      # There are three versions currently this script supports.
74      # The oldest one: ash-chrome returns one FD, the mojo connection of
75      # old bootstrap procedure (i.e., it will be BrowserService).
76      # The middle one: ash-chrome returns two FDs, the mojo connection of
77      # old bootstrap procedure, and the second for the start up data FD.
78      # The newest one: ash-chrome returns three FDs, the mojo connection of
79      # old bootstrap procedure, the second for the start up data FD, and
80      # the third for another mojo connection of new bootstrap procedure.
81      # TODO(crbug.com/1156033): Clean up the code to drop the support of
82      # oldest one after M91.
83      # TODO(crbug.com/1180712): Clean up the mojo procedure support of the
84      # the middle one after M92.
85      cmsg_len_candidates = [(i + 1) * fds.itemsize
86                             for i in range(_NUM_FDS_MAX)]
87      assert len(cmsg_data) in cmsg_len_candidates, (
88          'CMSG_LEN is unexpected: %d' % (len(cmsg_data), ))
89      fds.frombytes(cmsg_data[:])
90
91  if version == b'\x00':
92    assert len(fds) in (1, 2, 3), 'Expecting exactly 1, 2, or 3 FDs'
93    legacy_mojo_fd = os.fdopen(fds[0])
94    startup_fd = None if len(fds) < 2 else os.fdopen(fds[1])
95    mojo_fd = None if len(fds) < 3 else os.fdopen(fds[2])
96  elif version == b'\x01':
97    assert len(fds) == 2, 'Expecting exactly 2 FDs'
98    legacy_mojo_fd = None
99    startup_fd = os.fdopen(fds[0])
100    mojo_fd = os.fdopen(fds[1])
101  else:
102    raise AssertionError('Unknown version: \\x%s' % version.encode('hex'))
103  return legacy_mojo_fd, startup_fd, mojo_fd
104
105
106def _MaybeClosing(fileobj):
107  """Returns closing context manager, if given fileobj is not None.
108
109  If the given fileobj is none, return nullcontext.
110  """
111  return (contextlib.closing if fileobj else NullContext)(fileobj)
112
113
114def Main():
115  arg_parser = argparse.ArgumentParser()
116  arg_parser.usage = __doc__
117  arg_parser.add_argument(
118      '-s',
119      '--socket-path',
120      type=pathlib.Path,
121      required=True,
122      help='Absolute path to the socket that were used to start ash-chrome, '
123      'for example: "/tmp/lacros.socket"')
124  flags, args = arg_parser.parse_known_args()
125
126  assert 'XDG_RUNTIME_DIR' in os.environ
127  assert os.environ.get('EGL_PLATFORM') == 'surfaceless'
128
129  with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
130    sock.connect(flags.socket_path.as_posix())
131    legacy_mojo_connection, startup_connection, mojo_connection = (
132        _ReceiveFDs(sock))
133
134  with _MaybeClosing(legacy_mojo_connection), \
135       _MaybeClosing(startup_connection), \
136       _MaybeClosing(mojo_connection):
137    cmd = args[:]
138    pass_fds = []
139    if legacy_mojo_connection:
140      cmd.append('--mojo-platform-channel-handle=%d' %
141                 legacy_mojo_connection.fileno())
142      pass_fds.append(legacy_mojo_connection.fileno())
143    else:
144      # TODO(crbug.com/1188020): This is for backward compatibility.
145      # We should remove this after M93 lacros is spread enough.
146      cmd.append('--mojo-platform-channel-handle=-1')
147    if startup_connection:
148      cmd.append('--cros-startup-data-fd=%d' % startup_connection.fileno())
149      pass_fds.append(startup_connection.fileno())
150    if mojo_connection:
151      cmd.append('--crosapi-mojo-platform-channel-handle=%d' %
152                 mojo_connection.fileno())
153      pass_fds.append(mojo_connection.fileno())
154    proc = subprocess.Popen(cmd, pass_fds=pass_fds)
155
156  return proc.wait()
157
158
159if __name__ == '__main__':
160  sys.exit(Main())
161