blob: 0306550f6294dc04cfac138fa9d29aec6449773f [file]
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import asyncio
import inspect
import logging
from abc import abstractmethod
from collections.abc import Coroutine
from inspect import getframeinfo, stack
from typing import Any, Dict, Set, cast
import fuchsia_controller_py as fc
from fidl_codec import decode_fidl_response, encode_fidl_message
from ._fidl_common import (
FIDL_EPITAPH_ORDINAL,
EpitaphError,
FidlMessage,
FidlMeta,
StopEventHandler,
TXID_Type,
parse_epitaph_value,
parse_ordinal,
parse_txid,
)
from ._ipc import GlobalHandleWaker, HandleWaker
# The active TXID (mutable).
TXID: TXID_Type = 0
# The TXID of a FIDL event.
EVENT_TXID: TXID_Type = 0
# Simple client ID. Monotonically increasing for each client.
_CLIENT_ID = 0
_LOGGER = logging.getLogger("fidl.client")
class FidlClient(metaclass=FidlMeta):
@staticmethod
@abstractmethod
def construct_response_object(
response_ident: str, response_obj: Any
) -> Any:
...
def __init__(
self,
channel: int | fc.Channel,
channel_waker: HandleWaker | None = None,
) -> None:
global _CLIENT_ID
self.id = _CLIENT_ID
_CLIENT_ID += 1
if isinstance(channel, int):
self._channel: fc.Channel | None = fc.Channel(channel)
else:
self._channel = channel
if channel_waker is None:
self._channel_waker: HandleWaker = GlobalHandleWaker()
else:
self._channel_waker = channel_waker
self.pending_txids: Set[TXID_Type] = set({})
self.staged_messages: Dict[TXID_Type, asyncio.Queue[FidlMessage]] = {}
self.epitaph_received: EpitaphError | None = None
self.epitaph_event = asyncio.Event()
caller = getframeinfo(stack()[1][0])
_LOGGER.debug(
f"{self} instantiated from {caller.filename}:{caller.lineno}"
)
def close_cleanly(self) -> None:
"""Closes the underlying channel.
This is so-named to avoid name conflicts with existing FIDL methods.
"""
if self._channel is not None:
self._channel.close()
# self._channel = None is NOT done here, as it may be used in pending tasks.
# The handle being closed will result in errors in those tasks.
def __str__(self) -> str:
return f"client:{type(self).__name__}:{self.id}"
async def _get_staged_message(self, txid: TXID_Type) -> FidlMessage:
res = await self.staged_messages[txid].get()
self.staged_messages[txid].task_done()
return res
def _stage_message(self, txid: TXID_Type, msg: FidlMessage) -> None:
# This should only ever happen if we're a channel reading another channel's response before
# it has ever made a request.
if txid not in self.staged_messages:
self.staged_messages[txid] = asyncio.Queue(1)
self.staged_messages[txid].put_nowait(msg)
def _clean_staging(self, txid: TXID_Type) -> None:
self.staged_messages.pop(txid)
# Events are never added to this set, since they're always pending.
if txid != EVENT_TXID:
self.pending_txids.remove(txid)
def _decode(self, txid: TXID_Type, msg: FidlMessage) -> Dict[str, Any]:
self._clean_staging(txid)
handles = msg[1]
verified_handles: list[int] = [0] * len(handles)
for i in range(len(handles)):
# Asserting is not enough for mypy, we must also cast.
hdl = cast(fc.BaseHandle, handles[i])
assert isinstance(hdl, fc.BaseHandle)
verified_handles[i] = hdl.take()
return decode_fidl_response(bytes=msg[0], handles=verified_handles)
async def next_event(self) -> FidlMessage | None:
"""Attempts to read the next FIDL event from this client.
Returns:
The next FIDL event. If ZX_ERR_PEER_CLOSED is received on the channel, will return None.
Note: this does not check to see if the protocol supports any events, so if not this
function could wait forever.
Raises:
Any exceptions other than ZX_ERR_PEER_CLOSED (fuchsia_controller_py.FcTransportStatus)
"""
# TODO(awdavies): Raise an exception if there are no events supported for this client.
try:
return await self._read_and_decode(0)
except fc.FcTransportStatus as e:
err_code = e.code()
if err_code != fc.FcTransportStatus.FC_ERR_FDOMAIN:
_LOGGER.warning(
f"{self} received error waiting for next event: {e}"
)
raise e
return None
def _epitaph_check(self, msg: FidlMessage) -> None:
# If the epitaph is already set, no need to continue with the remaining
# work.
if self.epitaph_received is not None:
raise self.epitaph_received
ordinal = parse_ordinal(msg)
if ordinal == FIDL_EPITAPH_ORDINAL:
if self.epitaph_received is None:
self.epitaph_received = EpitaphError(parse_epitaph_value(msg))
self.epitaph_event.set()
if self.epitaph_received is not None:
raise self.epitaph_received
async def _epitaph_event_wait(self) -> None:
await self.epitaph_event.wait()
if self.epitaph_received is not None:
raise self.epitaph_received
# TODO(https://fxbug.dev/493309088): This (and many of the other `-> Any` return
# values should be returning a specific type, as we're decoding a nested
# dictionary type that is specific to FIDL.
async def _read_and_decode(self, txid: int) -> Any:
if txid not in self.staged_messages:
self.staged_messages[txid] = asyncio.Queue(1)
if self._channel is None:
raise ValueError("Channel is already closed")
msg: FidlMessage
with self._channel_waker.registration(self._channel, name=str(self)):
while True:
if self.epitaph_received is not None:
raise self.epitaph_received
# 1. Try to read from the channel.
try:
msg = self._channel.read()
self._epitaph_check(msg)
recvd_txid = parse_txid(msg)
if recvd_txid == txid:
if txid != EVENT_TXID:
return self._decode(txid, msg)
else:
self._clean_staging(txid)
return msg
if (
recvd_txid != EVENT_TXID
and recvd_txid not in self.pending_txids
):
_LOGGER.warning(
f"{self} received unexpected TXID: {recvd_txid}"
)
# Unexpected TXID is often a sign of a bad server or a serious bug.
# We don't close the channel here, but we raise.
raise RuntimeError(
f"{self} received unexpected TXID: {recvd_txid}"
)
self._stage_message(recvd_txid, msg)
except fc.FcTransportStatus as e:
if e.code() != fc.FcTransportStatus.FC_ERR_SHOULD_WAIT:
raise e
# 2. Wait for either:
# - The channel being readable again.
# - A staged message for our TXID becoming available.
# - An epitaph being received.
#
# This is handled with a task group in the event that
# there are task cancellations they apply to the other
# tasks in the task group, making cleanup predictable.
try:
async with asyncio.TaskGroup() as tg:
read_ready_task = asyncio.create_task(
self._channel_waker.wait_ready(self._channel)
)
staged_msg_task = asyncio.create_task(
self._get_staged_message(txid)
)
epitaph_task = asyncio.create_task(
self.epitaph_event.wait()
)
done, pending = await asyncio.wait(
[read_ready_task, staged_msg_task, epitaph_task],
return_when=asyncio.FIRST_COMPLETED,
)
for p in pending:
p.cancel()
except ExceptionGroup as eg:
# If there is only a single exception, raise this as the
# main exception. We care particularly if there is an
# epitaph exception raised.
if len(eg.exceptions) == 1:
raise eg.exceptions[0] from None
raise
if staged_msg_task in done:
# If we got a staged message, we're done.
msg = staged_msg_task.result()
# If read_ready_task was also done, we should "re-notify" because we didn't read.
if read_ready_task in done:
if self._channel is None:
raise ValueError("Channel is already closed")
self._channel_waker.post_ready(self._channel)
if txid != EVENT_TXID:
return self._decode(txid, msg)
else:
self._clean_staging(txid)
return msg
if epitaph_task in done:
# This will raise the epitaph error.
if self.epitaph_received is not None:
raise self.epitaph_received
return None
# If only read_ready_task reached here, we just loop again and try to read().
def _send_two_way_fidl_request(
self,
ordinal: int,
library: str,
msg_obj: Any,
response_ident: str,
) -> Coroutine[Any, Any, Any]:
"""Sends a two-way asynchronous FIDL request.
Args:
ordinal: The method ordinal (for encoding).
library: The FIDL library from which this method ordinal exists.
msg_obj: The object being sent.
response_ident: The full FIDL identifier of the response object, e.g. foo.bar/Baz
Returns:
The object from the two-way function, as constructed from the response_ident type.
"""
global TXID
TXID += 1
self.pending_txids.add(TXID)
self._send_one_way_fidl_request(TXID, ordinal, library, msg_obj)
async def result(txid: int) -> Any:
res = await self._read_and_decode(txid)
return self.construct_response_object(response_ident, res)
return result(TXID)
def _send_one_way_fidl_request(
self, txid: int, ordinal: int, library: str, msg_obj: Any
) -> None:
"""Sends a synchronous one-way FIDL request.
Args:
ordinal: The method ordinal (for encoding).
library: The FIDL library from which this method ordinal exists.
msg_obj: The object being sent.
"""
type_name = None
if msg_obj is not None:
type_name = msg_obj.__fidl_raw_type__
encoded_fidl_message = encode_fidl_message(
ordinal=ordinal,
object=msg_obj,
library=library,
txid=txid,
type_name=type_name,
)
if self._channel is None:
raise ValueError("Channel is already closed")
self._channel.write(encoded_fidl_message)
class EventHandlerBase(
metaclass=FidlMeta,
required_class_variables=[
("library", str),
("method_map", dict),
],
):
"""Base object for doing FIDL client event handling."""
client: FidlClient
method_map: Dict[int, Any]
@staticmethod
@abstractmethod
def construct_response_object(
response_ident: str, response_obj: Any
) -> Any:
...
def __init__(self, client: FidlClient) -> None:
self.client = client
def __str__(self) -> str:
return f"event:{type(self.client).__name__}:{self.client.id}"
async def serve(self) -> None:
while True:
msg = await self.client.next_event()
# msg is None if the channel has been closed.
if msg is None:
break
if not await self._handle_request(msg):
break
async def _handle_request(self, msg: FidlMessage) -> bool:
try:
await self._handle_request_helper(msg)
return True
except StopEventHandler:
return False
async def _handle_request_helper(self, msg: FidlMessage) -> None:
ordinal = parse_ordinal(msg)
handles = msg[1]
verified_handles: list[int] = [0] * len(handles)
for i in range(len(handles)):
# Asserting is not enough for mypy, we must also cast.
hdl = cast(fc.BaseHandle, handles[i])
assert isinstance(hdl, fc.BaseHandle)
verified_handles[i] = hdl.take()
decoded_msg = decode_fidl_response(
bytes=msg[0], handles=verified_handles
)
method = self.method_map[ordinal]
request_ident = method.request_ident
request_obj = self.construct_response_object(request_ident, decoded_msg)
method_lambda = getattr(self, method.name)
if request_obj is not None:
res = method_lambda(request_obj)
else:
res = method_lambda()
if inspect.isawaitable(res):
await res