Repository: postlund/pyatv
Pull request #1527 (2021-12-13 20:56:29)

facade: Block calls to public methods after close
Use shielding to block calls to all public methods after closing the connection (or disconnecting for any reason).

Relates to #1415

<a href="https://gitpod.io/#https://github.com/postlund/pyatv/pull/1527"><img src="https://gitpod.io/button/open-in-gitpod.svg"/></a>



codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
> Merging [#1527](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (123c6f1) into [master](https://codecov.io/gh/postlund/pyatv/commit/b00dbf4c92510a486e1f7b05638b481843f5458f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) (b00dbf4) will **increase** coverage by `0.06%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/1527/graphs/tree.svg?width=650&height=150&src=pr&token=wb6azJjRSY&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)

```diff
@@            Coverage Diff             @@
##           master    #1527      +/-   ##
==========================================
+ Coverage   92.37%   92.44%   +0.06%     
==========================================
  Files         150      151       +1     
  Lines        9781     9871      +90     
==========================================
+ Hits         9035     9125      +90     
  Misses        746      746              
```


| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) | Coverage Δ | |
|---|---|---|
| [pyatv/core/facade.py](https://codecov.io/gh/postlund/pyatv/pull/1527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvY29yZS9mYWNhZGUucHk=) | `99.16% <100.00%> (+0.18%)` | :arrow_up: |
| [pyatv/exceptions.py](https://codecov.io/gh/postlund/pyatv/pull/1527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvZXhjZXB0aW9ucy5weQ==) | `89.28% <100.00%> (+0.39%)` | :arrow_up: |
| [pyatv/support/shield.py](https://codecov.io/gh/postlund/pyatv/pull/1527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9zaGllbGQucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl). Last update [b00dbf4...123c6f1](https://codecov.io/gh/postlund/pyatv/pull/1527?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).


Patch:
diff --git a/chickn.yaml b/chickn.yaml
index e5758ed0..29201232 100644
--- a/chickn.yaml
+++ b/chickn.yaml
@@ -27,7 +27,7 @@ pipeline:
         - black .
         - isort {code_dirs}
         - ./scripts/api.py generate
-        - ./scripts/protobuf.py generate
+        - ./scripts/protobuf.py --download generate
   rebuild_miniaudio:
     # A lot of builds on GitHub Actions result in "Illegal Instruction"
     # when calling functions in miniaudio. Seemingly random and not
diff --git a/docs/api/pyatv/exceptions.html b/docs/api/pyatv/exceptions.html
index 3cf74edc..fa838ca5 100644
--- a/docs/api/pyatv/exceptions.html
+++ b/docs/api/pyatv/exceptions.html
@@ -24,6 +24,9 @@ link_group: api
 <h4><code><a title="pyatv.exceptions.BackOffError" href="#pyatv.exceptions.BackOffError">BackOffError</a></code></h4>
 </li>
 <li>
+<h4><code><a title="pyatv.exceptions.BlockedStateError" href="#pyatv.exceptions.BlockedStateError">BlockedStateError</a></code></h4>
+</li>
+<li>
 <h4><code><a title="pyatv.exceptions.CommandError" href="#pyatv.exceptions.CommandError">CommandError</a></code></h4>
 </li>
 <li>
@@ -93,7 +96,7 @@ link_group: api
 </header>
 <section id="section-intro">
 <p>Local exceptions used by library.</p>
-<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L106" class="git-link">Browse git</a></div>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L1-L115" class="git-link">Browse git</a></div>
 </section>
 <section>
 </section>
@@ -130,6 +133,22 @@ link_group: api
 <li>builtins.BaseException</li>
 </ul>
 </dd>
+<dt id="pyatv.exceptions.BlockedStateError"><code class="flex name class">
+<span>class <span class="ident">BlockedStateError</span></span>
+<span>(</span><span>*args, **kwargs)</span>
+</code></dt>
+<dd>
+<section class="desc"><p>Thrown when calling a blocked method.</p>
+<p>Typically, public interface methods (e.g. any method available via the object
+returned by <code><a title="pyatv.connect" href="index#pyatv.connect">connect()</a></code>) becomes blocked after either calling close or because
+the connection was closed for some other reason.</p></section>
+<div class="git-link-div"><a href="https://github.com/postlund/pyatv/blob/master/pyatv/exceptions.py#L109-L115" class="git-link">Browse git</a></div>
+<h3>Ancestors</h3>
+<ul class="hlist">
+<li>builtins.Exception</li>
+<li>builtins.BaseException</li>
+</ul>
+</dd>
 <dt id="pyatv.exceptions.CommandError"><code class="flex name class">
 <span>class <span class="ident">CommandError</span></span>
 <span>(</span><span>*args, **kwargs)</span>
diff --git a/pyatv/core/facade.py b/pyatv/core/facade.py
index 77d51b98..124a14f8 100644
--- a/pyatv/core/facade.py
+++ b/pyatv/core/facade.py
@@ -27,7 +27,7 @@ from pyatv.const import (
 )
 from pyatv.core import CoreStateDispatcher, SetupData, StateMessage, UpdatedState
 from pyatv.core.relayer import Relayer
-from pyatv.support import deprecated
+from pyatv.support import deprecated, shield
 from pyatv.support.collections import dict_merge
 from pyatv.support.http import ClientSessionManager
 
@@ -50,86 +50,106 @@ class FacadeRemoteControl(Relayer, interface.RemoteControl):
         super().__init__(interface.RemoteControl, DEFAULT_PRIORITIES)
 
     # pylint: disable=invalid-name
+    @shield.guard
     async def up(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key up."""
         return await self.relay("up")(action=action)
 
+    @shield.guard
     async def down(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key down."""
         return await self.relay("down")(action=action)
 
+    @shield.guard
     async def left(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key left."""
         return await self.relay("left")(action=action)
 
+    @shield.guard
     async def right(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key right."""
         return await self.relay("right")(action=action)
 
+    @shield.guard
     async def play(self) -> None:
         """Press key play."""
         return await self.relay("play")()
 
+    @shield.guard
     async def play_pause(self) -> None:
         """Toggle between play and pause."""
         return await self.relay("play_pause")()
 
+    @shield.guard
     async def pause(self) -> None:
         """Press key play."""
         return await self.relay("pause")()
 
+    @shield.guard
     async def stop(self) -> None:
         """Press key stop."""
         return await self.relay("stop")()
 
+    @shield.guard
     async def next(self) -> None:
         """Press key next."""
         return await self.relay("next")()
 
+    @shield.guard
     async def previous(self) -> None:
         """Press key previous."""
         return await self.relay("previous")()
 
+    @shield.guard
     async def select(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key select."""
         return await self.relay("select")(action=action)
 
+    @shield.guard
     async def menu(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key menu."""
         return await self.relay("menu")(action=action)
 
     @deprecated
+    @shield.guard
     async def volume_up(self) -> None:
         """Press key volume up."""
         return await self.relay("volume_up")()
 
     @deprecated
+    @shield.guard
     async def volume_down(self) -> None:
         """Press key volume down."""
         return await self.relay("volume_down")()
 
+    @shield.guard
     async def home(self, action: InputAction = InputAction.SingleTap) -> None:
         """Press key home."""
         return await self.relay("home")(action=action)
 
+    @shield.guard
     async def home_hold(self) -> None:
         """Hold key home."""
         return await self.relay("home_hold")()
 
+    @shield.guard
     async def top_menu(self) -> None:
         """Go to main menu (long press menu)."""
         return await self.relay("top_menu")()
 
     @deprecated
+    @shield.guard
     async def suspend(self) -> None:
         """Suspend the device."""
         return await self.relay("suspend")()
 
     @deprecated
+    @shield.guard
     async def wakeup(self) -> None:
         """Wake up the device."""
         return await self.relay("wakeup")()
 
+    @shield.guard
     async def skip_forward(self) -> None:
         """Skip forward a time interval.
 
@@ -137,6 +157,7 @@ class FacadeRemoteControl(Relayer, interface.RemoteControl):
         """
         return await self.relay("skip_forward")()
 
+    @shield.guard
     async def skip_backward(self) -> None:
         """Skip backwards a time interval.
 
@@ -144,14 +165,17 @@ class FacadeRemoteControl(Relayer, interface.RemoteControl):
         """
         return await self.relay("skip_backward")()
 
+    @shield.guard
     async def set_position(self, pos: int) -> None:
         """Seek in the current playing media."""
         return await self.relay("set_position")(pos=pos)
 
+    @shield.guard
     async def set_shuffle(self, shuffle_state: const.ShuffleState) -> None:
         """Change shuffle mode to on or off."""
         return await self.relay("set_shuffle")(shuffle_state=shuffle_state)
 
+    @shield.guard
     async def set_repeat(self, repeat_state: const.RepeatState) -> None:
         """Change repeat state."""
         return await self.relay("set_repeat")(repeat_state=repeat_state)
@@ -164,11 +188,13 @@ class FacadeMetadata(Relayer, interface.Metadata):
         """Initialize a new FacadeMetadata instance."""
         super().__init__(interface.Metadata, DEFAULT_PRIORITIES)
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def device_id(self) -> Optional[str]:
         """Return a unique identifier for current device."""
         return self.relay("device_id")
 
+    @shield.guard
     async def artwork(self, width=512, height=None) -> Optional[interface.ArtworkInfo]:
         """Return artwork for what is currently playing (or None).
 
@@ -180,16 +206,19 @@ class FacadeMetadata(Relayer, interface.Metadata):
         """
         return await self.relay("artwork")(width=width, height=height)
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def artwork_id(self) -> str:
         """Return a unique identifier for current artwork."""
         return self.relay("artwork_id")
 
+    @shield.guard
     async def playing(self) -> interface.Playing:
         """Return what is currently playing."""
         return await self.relay("playing")()
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def app(self) -> Optional[interface.App]:
         """Return information about current app playing something.
 
@@ -225,6 +254,7 @@ class FacadeFeatures(Relayer, interface.Features):
                 ):
                     self._feature_map[feature] = (protocol, instance)
 
+    @shield.guard
     def get_feature(self, feature_name: FeatureName) -> interface.FeatureInfo:
         """Return current state of a feature."""
         if feature_name == FeatureName.PushUpdates:
@@ -298,7 +328,8 @@ class FacadePower(Relayer, interface.Power, interface.PowerListener):
         if not self._is_playing:
             self.listener.powerstate_update(old_state, new_state)
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def power_state(self) -> const.PowerState:
         """Return device power state."""
         # Override power state in case something is playing
@@ -306,12 +337,14 @@ class FacadePower(Relayer, interface.Power, interface.PowerListener):
             return const.PowerState.On
         return self.relay("power_state")
 
+    @shield.guard
     async def turn_on(self, await_new_state: bool = False) -> None:
         """Turn device on."""
         await self.relay("turn_on", priority=self.OVERRIDE_PRIORITIES)(
             await_new_state=await_new_state
         )
 
+    @shield.guard
     async def turn_off(self, await_new_state: bool = False) -> None:
         """Turn device off."""
         await self.relay("turn_off", priority=self.OVERRIDE_PRIORITIES)(
@@ -327,10 +360,12 @@ class FacadeStream(Relayer, interface.Stream):  # pylint: disable=too-few-public
         super().__init__(interface.Stream, DEFAULT_PRIORITIES)
         self._features = features
 
+    @shield.guard
     def close(self) -> None:
         """Close connection and release allocated resources."""
         self.relay("close")()
 
+    @shield.guard
     async def play_url(self, url: str, **kwargs) -> None:
         """Play media from an URL on the device."""
         if not self._features.in_state(FeatureState.Available, FeatureName.PlayUrl):
@@ -338,6 +373,7 @@ class FacadeStream(Relayer, interface.Stream):  # pylint: disable=too-few-public
 
         await self.relay("play_url")(url, **kwargs)
 
+    @shield.guard
     async def stream_file(self, file: Union[str, io.BufferedReader], **kwargs) -> None:
         """Stream local file to device.
 
@@ -353,10 +389,12 @@ class FacadeApps(Relayer, interface.Apps):
         """Initialize a new FacadeApps instance."""
         super().__init__(interface.Apps, DEFAULT_PRIORITIES)
 
+    @shield.guard
     async def app_list(self) -> List[interface.App]:
         """Fetch a list of apps that can be launched."""
         return await self.relay("app_list")()
 
+    @shield.guard
     async def launch_app(self, bundle_id: str) -> None:
         """Launch an app based on bundle ID."""
         await self.relay("launch_app")(bundle_id)
@@ -369,15 +407,18 @@ class FacadeAudio(Relayer, interface.Audio):
         """Initialize a new FacadeAudio instance."""
         super().__init__(interface.Audio, DEFAULT_PRIORITIES)
 
+    @shield.guard
     async def volume_up(self) -> None:
         """Press key volume up."""
         return await self.relay("volume_up")()
 
+    @shield.guard
     async def volume_down(self) -> None:
         """Press key volume down."""
         return await self.relay("volume_down")()
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def volume(self) -> float:
         """Return current volume level."""
         volume = self.relay("volume")
@@ -385,6 +426,7 @@ class FacadeAudio(Relayer, interface.Audio):
             return volume
         raise exceptions.ProtocolError(f"volume {volume} is out of range")
 
+    @shield.guard
     async def set_volume(self, level: float) -> None:
         """Change current volume level."""
         if 0.0 <= level <= 100.0:
@@ -409,11 +451,13 @@ class FacadePushUpdater(
         )
         interface.PushUpdater.__init__(self)
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def active(self) -> bool:
         """Return if push updater has been started."""
         return self.relay("active")
 
+    @shield.guard
     def start(self, initial_delay: int = 0) -> None:
         """Begin to listen to updates.
 
@@ -423,6 +467,7 @@ class FacadePushUpdater(
             instance.listener = self
             instance.start(initial_delay)
 
+    @shield.guard
     def stop(self) -> None:
         """No longer forward updates to listener."""
         for instance in self.instances:
@@ -469,6 +514,17 @@ class FacadeAppleTV(interface.AppleTV):
             interface.Apps: FacadeApps(),
             interface.Audio: FacadeAudio(),
         }
+        self._shield_everything()
+
+    def _shield_everything(self):
+        shield.shield(self)
+        for instance in self._interfaces.values():
+            shield.shield(instance)
+
+    def _block_everything(self):
+        shield.block(self)
+        for instance in self._interfaces.values():
+            shield.block(instance)
 
     def add_protocol(self, setup_data: SetupData):
         """Add a new protocol to the relay."""
@@ -482,6 +538,7 @@ class FacadeAppleTV(interface.AppleTV):
         _LOGGER.debug("Adding handler for protocol %s", setup_data.protocol)
         self._protocols_to_setup.put(setup_data)
 
+    @shield.guard
     async def connect(self) -> None:
         """Initiate connection to device."""
         # No protocols to setup + no protocols previously set up => no service
@@ -536,6 +593,10 @@ class FacadeAppleTV(interface.AppleTV):
         asyncio.ensure_future(self._session_manager.close())
         for setup_data in self._protocol_handlers.values():
             self._pending_tasks.update(setup_data.close())
+
+        # Block access to everything in the public interface
+        self._block_everything()
+
         return self._pending_tasks
 
     def takeover(self, protocol: Protocol, *interfaces: Any) -> Callable[[], None]:
@@ -567,12 +628,14 @@ class FacadeAppleTV(interface.AppleTV):
 
         return _release
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def device_info(self) -> interface.DeviceInfo:
         """Return API for device information."""
         return self._device_info
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def service(self) -> interface.BaseService:
         """Return main service used to connect to the Apple TV."""
         for protocol in DEFAULT_PRIORITIES:
@@ -582,42 +645,50 @@ class FacadeAppleTV(interface.AppleTV):
 
         raise RuntimeError("no service (bug)")
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def remote_control(self) -> interface.RemoteControl:
         """Return API for controlling the Apple TV."""
         return cast(interface.RemoteControl, self._interfaces[interface.RemoteControl])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def metadata(self) -> interface.Metadata:
         """Return API for retrieving metadata from the Apple TV."""
         return cast(interface.Metadata, self._interfaces[interface.Metadata])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def push_updater(self) -> interface.PushUpdater:
         """Return API for handling push update from the Apple TV."""
         return cast(interface.PushUpdater, self._interfaces[interface.PushUpdater])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def stream(self) -> interface.Stream:
         """Return API for streaming media."""
         return cast(interface.Stream, self._interfaces[interface.Stream])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def power(self) -> interface.Power:
         """Return API for power management."""
         return cast(interface.Power, self._interfaces[interface.Power])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def features(self) -> interface.Features:
         """Return features interface."""
         return cast(interface.Features, self._interfaces[interface.Features])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def apps(self) -> interface.Apps:
         """Return apps interface."""
         return cast(interface.Apps, self._interfaces[interface.Apps])
 
-    @property
+    @property  # type: ignore
+    @shield.guard
     def audio(self) -> interface.Audio:
         """Return audio interface."""
         return cast(interface.Audio, self._interfaces[interface.Audio])
diff --git a/pyatv/exceptions.py b/pyatv/exceptions.py
index f2914db1..6f2a1696 100644
--- a/pyatv/exceptions.py
+++ b/pyatv/exceptions.py
@@ -104,3 +104,12 @@ class HttpError(ProtocolError):
 
 class InvalidConfigError(Exception):
     """Thrown when something is wrong or missing in the config."""
+
+
+class BlockedStateError(Exception):
+    """Thrown when calling a blocked method.
+
+    Typically, public interface methods (e.g. any method available via the object
+    returned by `pyatv.connect`) becomes blocked after either calling close or because
+    the connection was closed for some other reason.
+    """
diff --git a/pyatv/support/shield.py b/pyatv/support/shield.py
new file mode 100644
index 00000000..c2ce2ff1
--- /dev/null
+++ b/pyatv/support/shield.py
@@ -0,0 +1,74 @@
+"""The shield module helps restricting access to an object.
+
+A shielded object can use the @guard decorator to block calls to particular methods
+when block has been called:
+
+```python
+from pyatv.support.shield import block, guard, shield
+class MyClass:
+    @guard
+    def guarded(self):
+        print("do something")
+
+
+obj = shield(MyClass())
+obj.guarded()
+block(obj)
+obj.guarded()
+```
+
+Running this code would yield something like this:
+
+```raw
+do something
+Traceback (most recent call last):
+  File "<stdin>", line 1, in <module>
+  File ".../pyatv/pyatv/support/shield.py", line 70, in _guard_method
+    raise BlockedStateError(f"{func.__name__} is blocked")
+pyatv.exceptions.BlockedStateError: guarded is blocked
+```
+
+"""
+from functools import wraps
+from typing import TypeVar
+
+from pyatv.exceptions import BlockedStateError, InvalidStateError
+
+_SHIELD_VAR = "__shield_is_blocking"
+
+T = TypeVar("T")
+
+
+def shield(obj: T) -> T:
+    """Add shielding to an object."""
+    setattr(obj, _SHIELD_VAR, False)
+    return obj
+
+
+def is_shielded(obj: T) -> bool:
+    """Return if an object is shielded."""
+    return hasattr(obj, _SHIELD_VAR)
+
+
+def block(obj: T) -> None:
+    """Change a shielded object into blocking state."""
+    if not is_shielded(obj):
+        raise InvalidStateError("object is not shielded")
+    setattr(obj, _SHIELD_VAR, True)
+
+
+def is_blocking(obj: T) -> bool:
+    """Return if a shielded object is in blocking state."""
+    return is_shielded(obj) and getattr(obj, _SHIELD_VAR)
+
+
+def guard(func):
+    """Guard a function in a shielded object from being called."""
+
+    @wraps(func)
+    def _guard_method(self, *args, **kwargs):
+        if is_blocking(self):
+            raise BlockedStateError(f"{func.__name__} is blocked")
+        return func(self, *args, **kwargs)
+
+    return _guard_method


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b3182aa8..0c13295f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -39,7 +39,6 @@ def stub_ifaddr():
         )
     }
     with patch.multiple("pyatv.support.net", **methods):
-        print("patch")
         yield
 
 
diff --git a/tests/core/test_facade.py b/tests/core/test_facade.py
index 6e4bfd6c..af503ca5 100644
--- a/tests/core/test_facade.py
+++ b/tests/core/test_facade.py
@@ -3,6 +3,7 @@ from abc import ABC
 import asyncio
 import inspect
 from ipaddress import IPv4Address
+import logging
 import math
 from typing import Any, Dict, Set
 from unittest.mock import MagicMock
@@ -10,7 +11,7 @@ from unittest.mock import MagicMock
 import pytest
 
 from pyatv import exceptions
-from pyatv.conf import AppleTV
+from pyatv.conf import AppleTV as AppleTVConf
 from pyatv.const import (
     DeviceState,
     FeatureName,
@@ -29,22 +30,28 @@ from pyatv.core import (
 )
 from pyatv.core.facade import FacadeAppleTV, SetupData
 from pyatv.interface import (
+    AppleTV,
+    Apps,
     Audio,
     DeviceInfo,
     DeviceListener,
     FeatureInfo,
     Features,
     FeatureState,
+    Metadata,
     Playing,
     Power,
     PowerListener,
     PushListener,
     PushUpdater,
+    RemoteControl,
     Stream,
 )
 
 from tests.utils import until
 
+_LOGGER = logging.getLogger(__name__)
+
 TEST_URL = "http://test"
 
 pytestmark = pytest.mark.asyncio
@@ -67,7 +74,7 @@ def dmap_state_dispatcher_fixture(core_dispatcher):
 
 @pytest.fixture(name="facade_dummy")
 def facade_dummy_fixture(session_manager, core_dispatcher):
-    conf = AppleTV(IPv4Address("127.0.0.1"), "Test")
+    conf = AppleTVConf(IPv4Address("127.0.0.1"), "Test")
     facade = FacadeAppleTV(conf, session_manager, core_dispatcher)
     yield facade
 
@@ -795,3 +802,75 @@ async def test_power_no_updates_without_power_instance(
 
     await dispatch_device_state(mrp_state_dispatcher, DeviceState.Playing)
     assert listener.last_update is None
+
+
+# GUARD CALLS AFTER CLOSE
+
+# Retrieve method names of all methods (and properties) in an interface but exclude
+# members inherited from super classes.
+def get_interface_methods(iface, base_methods=None):
+    if base_methods is None:
+        base_methods = set(dir(iface))
+
+    for base in iface.__bases__:
+        # Remove objects that comes from current super class
+        base_methods.difference_update(set(dir(base)) & base_methods)
+        get_interface_methods(base, base_methods)
+    return set(method for method in base_methods if not method.startswith("_"))
+
+
+def assert_interface_guarded(obj, iface, exclude=None):
+    # All methods and properties in the base object should be properly guarded
+    for method_name in get_interface_methods(iface) - (exclude or set()):
+        with pytest.raises(exceptions.BlockedStateError):
+            try:
+                method = getattr(obj, method_name)
+                if inspect.ismethod(method):
+                    method()
+            except exceptions.BlockedStateError:
+                _LOGGER.debug(
+                    "Method %s in %s is properly guarded", method_name, iface.__name__
+                )
+                raise
+            else:
+                raise Exception(
+                    f"method {method_name} in {iface.__name__} is not guarded"
+                )
+
+
+async def test_base_methods_guarded_after_close(facade_dummy, register_interface):
+    register_interface(FeatureName.Play, DummyFeatures(FeatureName.Play), Protocol.DMAP)
+    await facade_dummy.connect()
+    facade_dummy.close()
+    assert_interface_guarded(facade_dummy, AppleTV, exclude={"close"})
+
+
+@pytest.mark.parametrize(
+    "iface,member,exclude",
+    [
+        (RemoteControl, "remote_control", {}),
+        (Metadata, "metadata", {}),
+        (PushUpdater, "push_updater", {}),
+        (Stream, "stream", {}),
+        (Power, "power", {}),
+        # in_states is not abstract but uses get_features, will which will raise
+        (Features, "features", {"in_state"}),
+        (Apps, "apps", {}),
+        (Audio, "audio", {}),
+    ],
+)
+async def test_interface_methods_guarded_after_close(
+    facade_dummy, register_interface, iface, member, exclude
+):
+    register_interface(FeatureName.Play, DummyFeatures(FeatureName.Play), Protocol.DMAP)
+    await facade_dummy.connect()
+
+    # Store reference to interface prior to closing to simulate something like this:
+    # atv = await connect(...)
+    # rc = atv.remote_control
+    # atv.close()
+    # await rc.left()
+    instance = getattr(facade_dummy, member)
+    facade_dummy.close()
+
+    assert_interface_guarded(instance, iface, exclude=exclude)
diff --git a/tests/support/test_shield.py b/tests/support/test_shield.py
new file mode 100644
index 00000000..5754f3cb
--- /dev/null
+++ b/tests/support/test_shield.py
@@ -0,0 +1,63 @@
+"""Unit tests for pyatv.support.shield."""
+import pytest
+
+from pyatv.exceptions import BlockedStateError, InvalidStateError
+from pyatv.support import shield
+
+
+class Dummy:
+    pass
+
+
+def test_shield_object():
+    obj = Dummy()
+
+    assert not shield.is_shielded(obj)
+    obj2 = shield.shield(obj)
+    assert obj == obj2
+    assert shield.is_shielded(obj)
+
+
+def test_cannot_block_unshielded_object():
+    obj = Dummy()
+    with pytest.raises(InvalidStateError):
+        shield.block(obj)
+
+
+def test_is_blocking_does_not_raise_on_unshielded_object():
+    obj = Dummy()
+    assert not shield.is_blocking(obj)
+
+
+def test_block_shielded_object():
+    obj = Dummy()
+    shield.shield(obj)
+    assert not shield.is_blocking(obj)
+    shield.block(obj)
+    assert shield.is_blocking(obj)
+
+
+class GuardedClass:
+    @shield.guard
+    def guarded_method(self, a):
+        return a * a
+
+    def unguarded_method(self, b):
+        return b + b
+
+
+def test_guarded_methods():
+    obj = GuardedClass()
+
+    shield.shield(obj)
+
+    # Should work fine since object is not blocked
+    assert obj.guarded_method(2) == 4
+    assert obj.unguarded_method(4) == 8
+
+    shield.block(obj)
+
+    with pytest.raises(BlockedStateError):
+        obj.guarded_method()
+
+    obj.unguarded_method(5) == 10