Repository: mitmproxy/mitmproxy

Issue:
Reloading a script with a custom view fails with "CommandError: Not viewing a flow."
#### Problem Description

Hot-reloading a script with a custom View doesn't work after any flow has been viewed. The reload crashes with an exception and the view is not loaded.

#### Steps to reproduce the behavior:
1. Create a script `test.py` with the following contents:
```python
from mitmproxy import contentviews, ctx, flow, http
from mitmproxy.contentviews.base import format_text
import typing


class TestView(contentviews.View):
    name = "Test"

    def __call__(self, data, **metadata):
        return "Test HTML", format_text(data)

    def render_priority(self, data: bytes, *, content_type: typing.Optional[str] = None, **metadata) -> float:
        return float(data.startswith(b'<!')) * 2

    def load(self, l):
        contentviews.add(self)

    def done(self):
        contentviews.remove(self)


addons = [
    TestView(),
]
```
2. Launch `mitmproxy` as `mitmproxy -s test.py`
3. Browse to `example.com` (e.g. `http_proxy=localhost:8080 curl example.com`).
4. A request should appear in mitmproxy. Open the request view, then return back to the main request list.
5. Make any edit to the `test.py` file and save.
6. An error appears in the error log:
```
error: Addon error: Traceback (most recent call last):
  File "test.py", line 16, in load
    contentviews.add(self)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/contentviews/__init__.py", line 52, in add
    on_add.send(view)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/blinker/base.py", line 266, in send
    return [(receiver, receiver(sender, **kwargs))
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/blinker/base.py", line 266, in <listcomp>
    return [(receiver, receiver(sender, **kwargs))
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 66, in contentview_added
    self.show()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/tabs.py", line 73, in show
    body = self.tabs[self.tab_offset % len(self.tabs)][1](),
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 122, in view_request
    return self.conn_text(flow.request)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 326, in conn_text
    viewmode = self.master.commands.call("console.flowview.mode")
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/command.py", line 250, in call
    return self.commands[command_name].func(*args)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/command.py", line 303, in wrapper
    return function(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/consoleaddons.py", line 551, in flowview_mode
    raise exceptions.CommandError("Not viewing a flow.")
mitmproxy.exceptions.CommandError: Not viewing a flow.
```

#### System Information
Paste the output of "mitmproxy --version" here.

Tested with the following:

```
Mitmproxy: 8.0.0
Python:    3.8.10
OpenSSL:   OpenSSL 1.1.1n  15 Mar 2022
Platform:  macOS-10.16-x86_64-i386-64bit
```

and 

```
Mitmproxy: 9.0.0.dev (+153, commit 5e7f3c8)
Python:    3.10.2
OpenSSL:   OpenSSL 3.0.2 15 Mar 2022
Platform:  macOS-12.3.1-x86_64-i386-64bit
```

Reloading a script with a custom view fails with "CommandError: Not viewing a flow."
#### Problem Description

Hot-reloading a script with a custom View doesn't work after any flow has been viewed. The reload crashes with an exception and the view is not loaded.

#### Steps to reproduce the behavior:
1. Create a script `test.py` with the following contents:
```python
from mitmproxy import contentviews, ctx, flow, http
from mitmproxy.contentviews.base import format_text
import typing


class TestView(contentviews.View):
    name = "Test"

    def __call__(self, data, **metadata):
        return "Test HTML", format_text(data)

    def render_priority(self, data: bytes, *, content_type: typing.Optional[str] = None, **metadata) -> float:
        return float(data.startswith(b'<!')) * 2

    def load(self, l):
        contentviews.add(self)

    def done(self):
        contentviews.remove(self)


addons = [
    TestView(),
]
```
2. Launch `mitmproxy` as `mitmproxy -s test.py`
3. Browse to `example.com` (e.g. `http_proxy=localhost:8080 curl example.com`).
4. A request should appear in mitmproxy. Open the request view, then return back to the main request list.
5. Make any edit to the `test.py` file and save.
6. An error appears in the error log:
```
error: Addon error: Traceback (most recent call last):
  File "test.py", line 16, in load
    contentviews.add(self)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/contentviews/__init__.py", line 52, in add
    on_add.send(view)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/blinker/base.py", line 266, in send
    return [(receiver, receiver(sender, **kwargs))
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/blinker/base.py", line 266, in <listcomp>
    return [(receiver, receiver(sender, **kwargs))
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 66, in contentview_added
    self.show()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/tabs.py", line 73, in show
    body = self.tabs[self.tab_offset % len(self.tabs)][1](),
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 122, in view_request
    return self.conn_text(flow.request)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/flowview.py", line 326, in conn_text
    viewmode = self.master.commands.call("console.flowview.mode")
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/command.py", line 250, in call
    return self.commands[command_name].func(*args)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/command.py", line 303, in wrapper
    return function(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/site-packages/mitmproxy/tools/console/consoleaddons.py", line 551, in flowview_mode
    raise exceptions.CommandError("Not viewing a flow.")
mitmproxy.exceptions.CommandError: Not viewing a flow.
```

#### System Information
Paste the output of "mitmproxy --version" here.

Tested with the following:

```
Mitmproxy: 8.0.0
Python:    3.8.10
OpenSSL:   OpenSSL 1.1.1n  15 Mar 2022
Platform:  macOS-10.16-x86_64-i386-64bit
```

and 

```
Mitmproxy: 9.0.0.dev (+153, commit 5e7f3c8)
Python:    3.10.2
OpenSSL:   OpenSSL 3.0.2 15 Mar 2022
Platform:  macOS-12.3.1-x86_64-i386-64bit
```



Discussion:

Resolving patch:
diff --git a/mitmproxy/contentviews/__init__.py b/mitmproxy/contentviews/__init__.py
--- a/mitmproxy/contentviews/__init__.py
+++ b/mitmproxy/contentviews/__init__.py
@@ -47,6 +47,8 @@
 
 on_add = blinker.Signal()
 """A new contentview has been added."""
+on_remove = blinker.Signal()
+"""A contentview has been removed."""
 
 
 def get(name: str) -> Optional[View]:
@@ -68,6 +70,7 @@ def add(view: View) -> None:
 
 def remove(view: View) -> None:
     views.remove(view)
+    on_remove.send(view)
 
 
 def safe_to_print(lines, encoding="utf8"):
diff --git a/mitmproxy/tools/console/flowview.py b/mitmproxy/tools/console/flowview.py
--- a/mitmproxy/tools/console/flowview.py
+++ b/mitmproxy/tools/console/flowview.py
@@ -50,7 +50,8 @@ def __init__(self, master):
         super().__init__([])
         self.show()
         self.last_displayed_body = None
-        contentviews.on_add.connect(self.contentview_added)
+        contentviews.on_add.connect(self.contentview_changed)
+        contentviews.on_remove.connect(self.contentview_changed)
 
     @property
     def view(self):
@@ -60,11 +61,12 @@ def view(self):
     def flow(self) -> mitmproxy.flow.Flow:
         return self.master.view.focus.flow
 
-    def contentview_added(self, view):
+    def contentview_changed(self, view):
         # this is called when a contentview addon is live-reloaded.
         # we clear our cache and then rerender
         self._get_content_view.cache_clear()
-        self.show()
+        if self.master.window.current_window("flowview"):
+            self.show()
 
     def focus_changed(self):
         f = self.flow


Test patch:
diff --git a/test/mitmproxy/tools/console/conftest.py b/test/mitmproxy/tools/console/conftest.py
--- a/test/mitmproxy/tools/console/conftest.py
+++ b/test/mitmproxy/tools/console/conftest.py
@@ -42,5 +42,6 @@ async def console(monkeypatch) -> ConsoleTestMaster:  # noqa
     opts = options.Options()
     m = ConsoleTestMaster(opts)
     opts.server = False
+    opts.console_mouse = False
     await m.running()
     return m
diff --git a/test/mitmproxy/tools/console/test_contentview.py b/test/mitmproxy/tools/console/test_contentview.py
new file mode 100644
--- /dev/null
+++ b/test/mitmproxy/tools/console/test_contentview.py
@@ -0,0 +1,39 @@
+from mitmproxy.test import tflow
+from mitmproxy import contentviews
+from mitmproxy.contentviews.base import format_text
+
+
+class TContentView(contentviews.View):
+    name = "Test View"
+
+    def __call__(self, data, **metadata):
+        return "TContentView", format_text("test_content")
+
+    def render_priority(self, data, *, content_type=None, **metadata) -> float:
+        return 2
+
+
+async def test_contentview_flowview(console):
+    assert "Flows" in console.screen_contents()
+    flow = tflow.tflow()
+    flow.request.headers["content-type"] = "text/html"
+    await console.load_flow(flow)
+    assert ">>" in console.screen_contents()
+    console.type("<enter>")
+    assert "Flow Details" in console.screen_contents()
+    assert "XML" in console.screen_contents()
+
+    view = TContentView()
+    contentviews.add(view)
+    assert "XML" not in console.screen_contents()
+    assert "TContentView" in console.screen_contents()
+    contentviews.remove(view)
+    assert "XML" in console.screen_contents()
+    assert "TContentView" not in console.screen_contents()
+
+    console.type("q")
+    assert "Flows" in console.screen_contents()
+    contentviews.add(view)
+    console.type("<enter>")
+    assert "Flow Details" in console.screen_contents()
+    assert "TContentView" in console.screen_contents()