Repository: streamlit/streamlit

Issue:
st.image() blinks sporadically in 0.59
# Summary
When displaying a stream of images in a st.empty(), the display "blinks" in 0.59 while it was running smoothly in 0.58.

# Steps to reproduce
Here is a minimal example (just displaying what your camera sees)
```python
import streamlit as st
import cv2

def main():
    show = st.checkbox('Show image', False)
    placeholder = st.empty()
    if show:
        vc = cv2.VideoCapture(0)
        ret, frame = vc.read()
        while ret:
            placeholder.image(frame, channels='BGR')
            ret, frame = vc.read()
        vc.release()

if __name__ == "__main__":
    main()

```
It looks like the placeholder "empties itself" (:thinking:) between two displayed frames, even though it should keep the last frame until a new one is displayed.




I'm using streamlit installed via pip on Ubuntu 18.04

## Is this a regression?
Yep, it worked perfectly in 0.58




Discussion:
@nthmost another example of https://github.com/streamlit/streamlit/issues/1294 ?
It seems to be the same problem
( `0` displayed in place of image, `MediaFileManager: Missing file XXXX` in logs)

Tested with the nightly build 0.59.1.dev20200517, issue still there.
Yeah this is almost certainly the same bug as #1440 -- working on it.

Resolving patch:
diff --git a/lib/streamlit/MediaFileManager.py b/lib/streamlit/MediaFileManager.py
--- a/lib/streamlit/MediaFileManager.py
+++ b/lib/streamlit/MediaFileManager.py
@@ -14,7 +14,7 @@
 
 """Provides global MediaFileManager object as `media_file_manager`."""
 
-from typing import Dict, DefaultDict
+from typing import Dict, DefaultDict, Set, Any
 from weakref import WeakValueDictionary
 import collections
 import datetime as dt
@@ -28,6 +28,11 @@
 
 STATIC_MEDIA_ENDPOINT = "/media"
 
+# Length of time (seconds) to keep media files so that we don't pull the rug out from
+# under rapid-media-generating elements, e.g. using a slider to produce pyplots.
+# See Issues #1440, #1445, #1294
+KEEP_DELAY_SEC = 5
+
 
 def _get_session_id():
     """Semantic wrapper to retrieve current ReportSession ID."""
@@ -67,6 +72,7 @@ def __init__(self, file_id=None, content=None, mimetype=None):
         self._file_id = file_id
         self._content = content
         self._mimetype = mimetype
+        self.ttd = time.time() + KEEP_DELAY_SEC
 
     @property
     def url(self):
@@ -108,24 +114,34 @@ class MediaFileManager(object):
 
     def __init__(self):
         # Dict of file ID to MediaFile.
-        self._files_by_id = (
-            WeakValueDictionary()
-        )  # type: WeakValueDictionary[str, MediaFile]
+        self._files_by_id = dict()
 
         # Dict[session ID][coordinates] -> MediaFile.
         self._files_by_session_and_coord = collections.defaultdict(
             dict
         )  # type: DefaultDict[str, Dict[str, MediaFile]]
 
-        # Since _files_by_id is a weak dict, when a MediaFile is removed from
-        # _files_by_session_and_coord it automatically gets removed from
-        # _files_by_id.
+        self._ioloop = None  # type: Any
+
+    def _del_expired_files(self):
+        LOGGER.debug("Deleting expired files...")
+
+        # Get a flat set of every file ID in the session ID map.
+        active_file_ids = set()  # type: Set[MediaFile]
+
+        for files_by_coord in self._files_by_session_and_coord.values():
+            active_file_ids = active_file_ids.union(files_by_coord.values())
+
+        now = time.time()
+        for file_id, mf in list(self._files_by_id.items()):
+            file_is_expired = mf.ttd < now
+            if file_id not in active_file_ids and file_is_expired:
+                del self._files_by_id[file_id]
 
     def clear_session_files(self, session_id=None):
-        """Clears all stored files for a given ReportSession id.
+        """Removes ReportSession-coordinate mapping immediately, and id-file mapping later.
 
-        Should be called whenever ScriptRunner completes and when
-        a session ends.
+        Should be called whenever ScriptRunner completes and when a session ends.
         """
         if session_id is None:
             session_id = _get_session_id()
@@ -138,6 +154,7 @@ def clear_session_files(self, session_id=None):
         LOGGER.debug(
             "Sessions still active: %r", self._files_by_session_and_coord.keys()
         )
+        self._ioloop.call_later(KEEP_DELAY_SEC, self._del_expired_files)
 
         LOGGER.debug(
             "Files: %s; Sessions with files: %s",
@@ -196,6 +213,9 @@ def get(self, mediafile_or_id):
         """
         return self._files_by_id[mediafile_or_id]
 
+    def set_ioloop(self, ioloop):
+        self._ioloop = ioloop
+
     def __contains__(self, mediafile_or_id):
         return mediafile_or_id in self._files_by_id
 
diff --git a/lib/streamlit/server/Server.py b/lib/streamlit/server/Server.py
--- a/lib/streamlit/server/Server.py
+++ b/lib/streamlit/server/Server.py
@@ -34,6 +34,7 @@
 from streamlit.ForwardMsgCache import ForwardMsgCache
 from streamlit.ForwardMsgCache import create_reference_msg
 from streamlit.ForwardMsgCache import populate_hash_if_needed
+from streamlit.MediaFileManager import media_file_manager
 from streamlit.ReportSession import ReportSession
 from streamlit.UploadedFileManager import UploadedFileManager
 from streamlit.logger import get_logger
@@ -204,6 +205,8 @@ def __init__(self, ioloop, script_path, command_line):
         self._script_path = script_path
         self._command_line = command_line
 
+        media_file_manager.set_ioloop(ioloop = self._ioloop)
+
         # Mapping of ReportSession.id -> SessionInfo.
         self._session_info_by_id = {}
 


Test patch:
diff --git a/lib/tests/streamlit/media_file_manager_test.py b/lib/tests/streamlit/media_file_manager_test.py
--- a/lib/tests/streamlit/media_file_manager_test.py
+++ b/lib/tests/streamlit/media_file_manager_test.py
@@ -17,12 +17,13 @@
 import unittest
 import mock
 import random
+import time
+
+from tornado.testing import AsyncTestCase
 
 from streamlit.MediaFileManager import MediaFileManager
 from streamlit.MediaFileManager import _calculate_file_id
-
-
-mfm = MediaFileManager()
+from streamlit.MediaFileManager import KEEP_DELAY_SEC
 
 
 def random_coordinates():
@@ -76,13 +77,16 @@ def random_coordinates():
 ALL_FIXTURES.update(IMAGE_FIXTURES)
 
 
-class UploadedFileManagerTest(unittest.TestCase):
+class MediaFileManagerTest(AsyncTestCase):
     def setUp(self):
+        super(MediaFileManagerTest, self).setUp()
+        self.mfm = MediaFileManager()
+        self.mfm.set_ioloop(self.io_loop)
         random.seed(1337)
 
     def tearDown(self):
-        mfm._files_by_id.clear()
-        mfm._files_by_session_and_coord.clear()
+        self.mfm._files_by_id.clear()
+        self.mfm._files_by_session_and_coord.clear()
 
     def test_calculate_file_id(self):
         """Test that file_id generation from data works as expected."""
@@ -95,7 +99,8 @@ def test_calculate_file_id(self):
 
         # Make sure we get different file ids for files with same bytes but diff't mimetypes.
         self.assertNotEqual(
-            _calculate_file_id(fake_bytes, "audio/wav"), _calculate_file_id(fake_bytes, "video/mp4")
+            _calculate_file_id(fake_bytes, "audio/wav"),
+            _calculate_file_id(fake_bytes, "video/mp4"),
         )
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
@@ -107,38 +112,55 @@ def test_add_files(self, _get_session_id):
 
         # Make sure we reject files containing None
         with self.assertRaises(TypeError):
-            mfm.add(None, "media/any", coord)
+            self.mfm.add(None, "media/any", coord)
 
         sample_coords = set()
         while len(sample_coords) < len(ALL_FIXTURES):
             sample_coords.add(random_coordinates())
 
         for sample in ALL_FIXTURES.values():
-            f = mfm.add(sample["content"], sample["mimetype"], sample_coords.pop())
-            self.assertTrue(f.id in mfm)
+            f = self.mfm.add(sample["content"], sample["mimetype"], sample_coords.pop())
+            self.assertTrue(f.id in self.mfm)
 
         # There should be as many files in MFM as we added.
-        self.assertEqual(len(mfm), len(ALL_FIXTURES))
+        self.assertEqual(len(self.mfm), len(ALL_FIXTURES))
 
         # There should only be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
-    def test_add_files_same_coord(self, _get_session_id):
+    @mock.patch("time.time")
+    def test_add_files_same_coord(self, _time, _get_session_id):
         """Test that MediaFileManager.add works as expected."""
         _get_session_id.return_value = "SESSION1"
+        _time.return_value = 0
 
         coord = random_coordinates()
 
         for sample in ALL_FIXTURES.values():
-            f = mfm.add(sample["content"], sample["mimetype"], coord)
-            self.assertTrue(f.id in mfm)
+            f = self.mfm.add(sample["content"], sample["mimetype"], coord)
+            self.assertTrue(f.id in self.mfm)
 
-        # There should be only 1 file in MFM.
-        self.assertEqual(len(mfm), 1)
+        # There should be 6 files in MFM.
+        self.assertEqual(len(self.mfm), len(ALL_FIXTURES))
 
         # There should only be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
+
+        # There should only be 1 coord in that session.
+        self.assertEqual(len(self.mfm._files_by_session_and_coord["SESSION1"]), 1)
+
+        pretend_time_passed = self.get_mock_call_later()
+        self.mfm.clear_session_files()
+
+        _time.return_value = KEEP_DELAY_SEC + 1
+        pretend_time_passed[0]()
+
+        # There should be only 0 file in MFM.
+        self.assertEqual(len(self.mfm), 0)
+
+        # There should only be 0 session with registered files.
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
     def test_add_file_already_exists_same_coord(self, _get_session_id):
@@ -147,19 +169,19 @@ def test_add_file_already_exists_same_coord(self, _get_session_id):
         sample = IMAGE_FIXTURES["png"]
         coord = random_coordinates()
 
-        mfm.add(sample["content"], sample["mimetype"], coord)
+        self.mfm.add(sample["content"], sample["mimetype"], coord)
         file_id = _calculate_file_id(sample["content"], sample["mimetype"])
-        self.assertTrue(file_id in mfm)
+        self.assertTrue(file_id in self.mfm)
 
-        mediafile = mfm.add(sample["content"], sample["mimetype"], coord)
-        self.assertTrue(file_id in mfm)
+        mediafile = self.mfm.add(sample["content"], sample["mimetype"], coord)
+        self.assertTrue(file_id in self.mfm)
         self.assertEqual(mediafile.id, file_id)
 
         # There should only be 1 file in MFM.
-        self.assertEqual(len(mfm), 1)
+        self.assertEqual(len(self.mfm), 1)
 
         # There should only be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
     def test_add_file_already_exists_different_coord(self, _get_session_id):
@@ -168,20 +190,20 @@ def test_add_file_already_exists_different_coord(self, _get_session_id):
         sample = IMAGE_FIXTURES["png"]
 
         coord = random_coordinates()
-        mfm.add(sample["content"], sample["mimetype"], coord)
+        self.mfm.add(sample["content"], sample["mimetype"], coord)
         file_id = _calculate_file_id(sample["content"], sample["mimetype"])
-        self.assertTrue(file_id in mfm)
+        self.assertTrue(file_id in self.mfm)
 
         coord = random_coordinates()
-        mediafile = mfm.add(sample["content"], sample["mimetype"], coord)
-        self.assertTrue(file_id in mfm)
+        mediafile = self.mfm.add(sample["content"], sample["mimetype"], coord)
+        self.assertTrue(file_id in self.mfm)
         self.assertEqual(mediafile.id, file_id)
 
         # There should only be 1 file in MFM.
-        self.assertEqual(len(mfm), 1)
+        self.assertEqual(len(self.mfm), 1)
 
         # There should only be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
     def test_add_file_different_mimetypes(self, _get_session_id):
@@ -190,45 +212,58 @@ def test_add_file_different_mimetypes(self, _get_session_id):
         coord = random_coordinates()
 
         sample = AUDIO_FIXTURES["mp3"]
-        f = mfm.add(sample["content"], "audio/mp3", coord)
-        self.assertTrue(f.id in mfm)
-
-        # Do this so we don't keep a refence to the MediaFile, since the MFM
-        # depends on garbage collection.
-        f1_id = f.id
+        f1 = self.mfm.add(sample["content"], "audio/mp3", coord)
+        self.assertTrue(f1.id in self.mfm)
 
-        f = mfm.add(sample["content"], "video/mp4", coord)
-        self.assertNotEqual(f1_id, f.id)
-        self.assertTrue(f.id in mfm)
+        f2 = self.mfm.add(sample["content"], "video/mp4", coord)
+        self.assertNotEqual(f1.id, f2.id)
+        self.assertTrue(f2.id in self.mfm)
 
-        # There should only 2 files in MFM, one for each mimetye.
-        self.assertEqual(len(mfm), 1)
+        # There should be only 2 files in MFM, one for each mimetye.
+        self.assertEqual(len(self.mfm), 2)
 
-        # There should only be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        # There should be only 1 session with registered files.
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
-    def test_clear_session_files(self, _get_session_id):
-        """Test that MediaFileManager removes all files when requested (even if empty)."""
+    @mock.patch("time.time")
+    def test_clear_session_files(self, _time, _get_session_id):
+        """Test that MediaFileManager removes session maps when requested (even if empty)."""
         _get_session_id.return_value = "SESSION1"
+        _time.return_value = 0
+
+        self.assertEqual(len(self.mfm), 0)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
 
-        self.assertEqual(len(mfm), 0)
-        self.assertEqual(len(mfm._files_by_session_and_coord), 0)
-        mfm.clear_session_files()
+        pretend_time_passed = self.get_mock_call_later()
+        self.mfm.clear_session_files()
 
-        self.assertEqual(len(mfm), 0)
-        self.assertEqual(len(mfm._files_by_session_and_coord), 0)
+        self.assertEqual(len(self.mfm), 0)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
+
+        pretend_time_passed[0]()
+
+        self.assertEqual(len(self.mfm), 0)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
 
         for sample in VIDEO_FIXTURES.values():
             coord = random_coordinates()
-            mfm.add(sample["content"], sample["mimetype"], coord)
+            self.mfm.add(sample["content"], sample["mimetype"], coord)
+
+        self.assertEqual(len(self.mfm), len(VIDEO_FIXTURES))
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
-        self.assertEqual(len(mfm), len(VIDEO_FIXTURES))
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
-        mfm.clear_session_files()
+        pretend_time_passed = self.get_mock_call_later()
+        self.mfm.clear_session_files()
 
-        self.assertEqual(len(mfm), 0)
-        self.assertEqual(len(mfm._files_by_session_and_coord), 0)
+        self.assertEqual(len(self.mfm), len(VIDEO_FIXTURES))  # Clears later
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)  # Clears immediately
+
+        _time.return_value = KEEP_DELAY_SEC + 1
+        pretend_time_passed[0]()
+
+        self.assertEqual(len(self.mfm), 0)  # Now this is cleared too!
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
 
     @mock.patch("streamlit.MediaFileManager._get_session_id")
     def test_add_file_multiple_sessions_then_clear(self, _get_session_id):
@@ -237,28 +272,62 @@ def test_add_file_multiple_sessions_then_clear(self, _get_session_id):
         sample = next(iter(ALL_FIXTURES.values()))
 
         coord = random_coordinates()
-        f = mfm.add(sample["content"], sample["mimetype"], coord)
-        self.assertTrue(f.id in mfm)
+        f = self.mfm.add(sample["content"], sample["mimetype"], coord)
+        self.assertTrue(f.id in self.mfm)
 
         _get_session_id.return_value = "SESSION2"
 
         coord = random_coordinates()
-        f = mfm.add(sample["content"], sample["mimetype"], coord)
-        self.assertTrue(f.id in mfm)
+        f = self.mfm.add(sample["content"], sample["mimetype"], coord)
+        self.assertTrue(f.id in self.mfm)
 
         # There should be only 1 file in MFM.
-        self.assertEqual(len(mfm), 1)
+        self.assertEqual(len(self.mfm), 1)
 
         # There should be 2 sessions with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 2)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 2)
 
-        mfm.clear_session_files()
+        # force every MediaFile to have a TTD of now, so we can see it get deleted w/o waiting.
+        for mf in self.mfm._files_by_id.values():
+            mf.ttd = time.time()
+
+        self.mfm.clear_session_files()
 
         # There should be 1 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 1)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 1)
 
         _get_session_id.return_value = "SESSION1"
-        mfm.clear_session_files()
+        self.mfm.clear_session_files()
 
         # There should be 0 session with registered files.
-        self.assertEqual(len(mfm._files_by_session_and_coord), 0)
+        self.assertEqual(len(self.mfm._files_by_session_and_coord), 0)
+
+    def get_mock_call_later(self):
+        """Poor-man's mock of ioloop.call_later.
+
+        Returns a list that, once ioloop.call_later is called, will contain the
+        call_later's callback.
+
+        This means you should use it this way:
+
+            pretend_time_passed = self.get_mock_call_later()
+            self.mfm.something_that_calls_call_later()
+
+            # Add tests here for the case where the callback was not called yet.
+
+            pretend_time_passed[0]()
+
+            # Add tests here for the case where the callback was called.
+        """
+
+        # Don't need to do this since io_loop gets reset with every test.
+        # orig_call_later = self.io_loop.call_later
+
+        pretend_time_passed = []  # Hack!
+
+        def new_call_later(_, cb):
+            pretend_time_passed.append(cb)
+
+        self.io_loop.call_later = new_call_later
+
+        return pretend_time_passed
diff --git a/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py b/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
--- a/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
+++ b/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
@@ -13,15 +13,17 @@
 # limitations under the License.
 
 """Tests ScriptRunner functionality"""
+from typing import List
+import os
 import sys
 import time
 import unittest
-from typing import List
 
-import os
 from parameterized import parameterized
+from tornado.testing import AsyncTestCase
 
 from streamlit.Report import Report
+from streamlit.MediaFileManager import media_file_manager
 from streamlit.ReportQueue import ReportQueue
 from streamlit.ScriptRequestQueue import RerunData
 from streamlit.ScriptRequestQueue import ScriptRequest
@@ -46,7 +48,15 @@ def _create_widget(id, states):
     return states.widgets[-1]
 
 
-class ScriptRunnerTest(unittest.TestCase):
+class ScriptRunnerTest(AsyncTestCase):
+    def setUp(self):
+        super(ScriptRunnerTest, self).setUp()
+        media_file_manager.set_ioloop(self.io_loop)
+
+    def tearDown(self):
+        super(ScriptRunnerTest, self).tearDown()
+        media_file_manager.set_ioloop(None)
+
     def test_startup_shutdown(self):
         """Test that we can create and shut down a ScriptRunner."""
         scriptrunner = TestScriptRunner("good_script.py")