Bug 1458119: Part 2: Test session restore across Windows shutdown. r=whimboo, Gijs draft
authorAdam Gashlin <agashlin@mozilla.com>
Thu, 07 Jun 2018 10:33:55 -0700
changeset 805316 b73663c5dfd212bb713f3699bd1163eaaa7f3a36
parent 803170 8886a199416e5a526faf6a0895a71fdc9752d555
push id112631
push userbmo:agashlin@mozilla.com
push dateThu, 07 Jun 2018 17:34:31 +0000
reviewerswhimboo, Gijs
bugs1458119
milestone62.0a1
Bug 1458119: Part 2: Test session restore across Windows shutdown. r=whimboo, Gijs When the Windows OS shuts down, we use a synchronous shutdown mechanism, this exercises session save and restore in a unique way. MozReview-Commit-ID: 6sCa3E2wmLY
testing/firefox-ui/tests/functional/sessionstore/manifest.ini
testing/firefox-ui/tests/functional/sessionstore/session_store_test_case.py
testing/firefox-ui/tests/functional/sessionstore/test_restore_windows_after_restart_and_quit.py
testing/firefox-ui/tests/functional/sessionstore/test_restore_windows_after_windows_shutdown.py
--- a/testing/firefox-ui/tests/functional/sessionstore/manifest.ini
+++ b/testing/firefox-ui/tests/functional/sessionstore/manifest.ini
@@ -1,4 +1,6 @@
 [DEFAULT]
 tags = local
 
 [test_restore_windows_after_restart_and_quit.py]
+[test_restore_windows_after_windows_shutdown.py]
+skip-if = os != "win"
new file mode 100644
--- /dev/null
+++ b/testing/firefox-ui/tests/functional/sessionstore/session_store_test_case.py
@@ -0,0 +1,285 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from firefox_puppeteer import PuppeteerMixin
+from marionette_harness import MarionetteTestCase
+
+
+class SessionStoreTestCase(PuppeteerMixin, MarionetteTestCase):
+
+    def setUp(self, startup_page=1, include_private=True, no_auto_updates=True):
+        super(SessionStoreTestCase, self).setUp()
+        # Each list element represents a window of tabs loaded at
+        # some testing URL
+        self.test_windows = set([
+            # Window 1. Note the comma after the absolute_url call -
+            # this is Python's way of declaring a 1 item tuple.
+            (self.marionette.absolute_url('layout/mozilla.html'), ),
+
+            # Window 2
+            (self.marionette.absolute_url('layout/mozilla_organizations.html'),
+             self.marionette.absolute_url('layout/mozilla_community.html')),
+
+            # Window 3
+            (self.marionette.absolute_url('layout/mozilla_governance.html'),
+             self.marionette.absolute_url('layout/mozilla_grants.html')),
+        ])
+
+        self.private_windows = set([
+            (self.marionette.absolute_url('layout/mozilla_mission.html'),
+             self.marionette.absolute_url('layout/mozilla_organizations.html')),
+
+            (self.marionette.absolute_url('layout/mozilla_projects.html'),
+             self.marionette.absolute_url('layout/mozilla_mission.html')),
+        ])
+
+        self.marionette.enforce_gecko_prefs({
+            # Set browser restore previous session pref,
+            # depending on what the test requires.
+            'browser.startup.page': startup_page,
+            # Make the content load right away instead of waiting for
+            # the user to click on the background tabs
+            'browser.sessionstore.restore_on_demand': False,
+            # Avoid race conditions by having the content process never
+            # send us session updates unless the parent has explicitly asked
+            # for them via the TabStateFlusher.
+            'browser.sessionstore.debug.no_auto_updates': no_auto_updates,
+        })
+
+        self.all_windows = self.test_windows.copy()
+        self.open_windows(self.test_windows)
+
+        if include_private:
+            self.all_windows.update(self.private_windows)
+            self.open_windows(self.private_windows, is_private=True)
+
+    def tearDown(self):
+        try:
+            # Create a fresh profile for subsequent tests.
+            self.restart(clean=True)
+        finally:
+            super(SessionStoreTestCase, self).tearDown()
+
+    def open_windows(self, window_sets, is_private=False):
+        """ Opens a set of windows with tabs pointing at some
+        URLs.
+
+        @param window_sets (list)
+               A set of URL tuples. Each tuple within window_sets
+               represents a window, and each URL in the URL
+               tuples represents what will be loaded in a tab.
+
+               Note that if is_private is False, then the first
+               URL tuple will be opened in the current window, and
+               subequent tuples will be opened in new windows.
+
+               Example:
+
+               set(
+                   (self.marionette.absolute_url('layout/mozilla_1.html'),
+                    self.marionette.absolute_url('layout/mozilla_2.html')),
+
+                   (self.marionette.absolute_url('layout/mozilla_3.html'),
+                    self.marionette.absolute_url('layout/mozilla_4.html')),
+               )
+
+               This would take the currently open window, and load
+               mozilla_1.html and mozilla_2.html in new tabs. It would
+               then open a new, second window, and load tabs at
+               mozilla_3.html and mozilla_4.html.
+        @param is_private (boolean, optional)
+               Whether or not any new windows should be a private browsing
+               windows.
+        """
+
+        if (is_private):
+            win = self.browser.open_browser(is_private=True)
+            win.switch_to()
+        else:
+            win = self.browser
+
+        for index, urls in enumerate(window_sets):
+            if index > 0:
+                win = self.browser.open_browser(is_private=is_private)
+            win.switch_to()
+            self.open_tabs(win, urls)
+
+    def open_tabs(self, win, urls):
+        """ Opens a set of URLs inside a window in new tabs.
+
+        @param win (browser window)
+               The browser window to load the tabs in.
+        @param urls (tuple)
+               A tuple of URLs to load in this window. The
+               first URL will be loaded in the currently selected
+               browser tab. Subsequent URLs will be loaded in
+               new tabs.
+        """
+        # If there are any remaining URLs for this window,
+        # open some new tabs and navigate to them.
+        with self.marionette.using_context('content'):
+            if isinstance(urls, str):
+                self.marionette.navigate(urls)
+            else:
+                for index, url in enumerate(urls):
+                    if index > 0:
+                        with self.marionette.using_context('chrome'):
+                            win.tabbar.open_tab()
+                    self.marionette.navigate(url)
+
+    def convert_open_windows_to_set(self):
+        windows = self.puppeteer.windows.all
+
+        # There's no guarantee that Marionette will return us an
+        # iterator for the opened windows that will match the
+        # order within our window list. Instead, we'll convert
+        # the list of URLs within each open window to a set of
+        # tuples that will allow us to do a direct comparison
+        # while allowing the windows to be in any order.
+
+        opened_windows = set()
+        for win in windows:
+            urls = tuple()
+            for tab in win.tabbar.tabs:
+                urls = urls + tuple([tab.location])
+            opened_windows.add(urls)
+        return opened_windows
+
+    def simulate_os_shutdown(self):
+        """ Simulate an OS shutdown.
+
+        :raises: Exception: if not supported on the current platform
+        :raises: WindowsError: if a Windows API call failed
+        """
+
+        if self.marionette.session_capabilities['platformName'] != 'windows_nt':
+            raise Exception('Unsupported platform for simulate_os_shutdown')
+
+        self._shutdown_with_windows_restart_manager(self.marionette.process_id)
+
+    def _shutdown_with_windows_restart_manager(self, pid):
+        """ Shut down a process using the Windows Restart Manager.
+
+        When Windows shuts down, it uses a protocol including the
+        WM_QUERYENDSESSION and WM_ENDSESSION messages to give
+        applications a chance to shut down safely. The best way to
+        simulate this is via the Restart Manager, which allows a process
+        (such as an installer) to use the same mechanism to shut down
+        any other processes which are using registered resources.
+
+        This function starts a Restart Manager session, registers the
+        process as a resource, and shuts down the process.
+
+        :param pid: The process id (int) of the process to shutdown
+
+        :raises: WindowsError: if a Windows API call fails
+        """
+
+        import ctypes
+        from ctypes import Structure, POINTER, WINFUNCTYPE, windll, pointer, WinError
+        from ctypes.wintypes import HANDLE, DWORD, BOOL, WCHAR, UINT, ULONG, LPCWSTR
+
+        # set up Windows SDK types
+        OpenProcess = windll.kernel32.OpenProcess
+        OpenProcess.restype = HANDLE
+        OpenProcess.argtypes = [DWORD,  # dwDesiredAccess
+                                BOOL,   # bInheritHandle
+                                DWORD]  # dwProcessId
+        PROCESS_QUERY_INFORMATION = 0x0400
+
+        class FILETIME(Structure):
+            _fields_ = [('dwLowDateTime', DWORD),
+                        ('dwHighDateTime', DWORD)]
+        LPFILETIME = POINTER(FILETIME)
+
+        GetProcessTimes = windll.kernel32.GetProcessTimes
+        GetProcessTimes.restype = BOOL
+        GetProcessTimes.argtypes = [HANDLE,      # hProcess
+                                    LPFILETIME,  # lpCreationTime
+                                    LPFILETIME,  # lpExitTime
+                                    LPFILETIME,  # lpKernelTime
+                                    LPFILETIME]  # lpUserTime
+
+        ERROR_SUCCESS = 0
+
+        class RM_UNIQUE_PROCESS(Structure):
+            _fields_ = [('dwProcessId', DWORD),
+                        ('ProcessStartTime', FILETIME)]
+
+        RmStartSession = windll.rstrtmgr.RmStartSession
+        RmStartSession.restype = DWORD
+        RmStartSession.argtypes = [POINTER(DWORD),  # pSessionHandle
+                                   DWORD,           # dwSessionFlags
+                                   POINTER(WCHAR)]  # strSessionKey
+
+        class GUID(ctypes.Structure):
+            _fields_ = [('Data1', ctypes.c_ulong),
+                        ('Data2', ctypes.c_ushort),
+                        ('Data3', ctypes.c_ushort),
+                        ('Data4', ctypes.c_ubyte * 8)]
+        CCH_RM_SESSION_KEY = ctypes.sizeof(GUID) * 2
+
+        RmRegisterResources = windll.rstrtmgr.RmRegisterResources
+        RmRegisterResources.restype = DWORD
+        RmRegisterResources.argtypes = [DWORD,             # dwSessionHandle
+                                        UINT,              # nFiles
+                                        POINTER(LPCWSTR),  # rgsFilenames
+                                        UINT,              # nApplications
+                                        POINTER(RM_UNIQUE_PROCESS),  # rgApplications
+                                        UINT,              # nServices
+                                        POINTER(LPCWSTR)]  # rgsServiceNames
+
+        RM_WRITE_STATUS_CALLBACK = WINFUNCTYPE(None, UINT)
+        RmShutdown = windll.rstrtmgr.RmShutdown
+        RmShutdown.restype = DWORD
+        RmShutdown.argtypes = [DWORD,  # dwSessionHandle
+                               ULONG,  # lActionFlags
+                               RM_WRITE_STATUS_CALLBACK]  # fnStatus
+
+        RmEndSession = windll.rstrtmgr.RmEndSession
+        RmEndSession.restype = DWORD
+        RmEndSession.argtypes = [DWORD]  # dwSessionHandle
+
+        # Get the info needed to uniquely identify the process
+        hProc = OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
+        if not hProc:
+            raise WinError()
+
+        creationTime = FILETIME()
+        exitTime = FILETIME()
+        kernelTime = FILETIME()
+        userTime = FILETIME()
+        if not GetProcessTimes(hProc,
+                               pointer(creationTime),
+                               pointer(exitTime),
+                               pointer(kernelTime),
+                               pointer(userTime)):
+            raise WinError()
+
+        # Start the Restart Manager Session
+        dwSessionHandle = DWORD()
+        sessionKeyType = WCHAR * (CCH_RM_SESSION_KEY + 1)
+        sessionKey = sessionKeyType()
+        if RmStartSession(pointer(dwSessionHandle), 0, sessionKey) != ERROR_SUCCESS:
+            raise WinError()
+
+        try:
+            UProcs_count = 1
+            UProcsArrayType = RM_UNIQUE_PROCESS * UProcs_count
+            UProcs = UProcsArrayType(RM_UNIQUE_PROCESS(pid, creationTime))
+
+            # Register the process as a resource
+            if RmRegisterResources(dwSessionHandle,
+                                   0, None,
+                                   UProcs_count, UProcs,
+                                   0, None) != ERROR_SUCCESS:
+                raise WinError()
+
+            # Shut down all processes using registered resources
+            if RmShutdown(dwSessionHandle, 0,
+                          ctypes.cast(None, RM_WRITE_STATUS_CALLBACK)) != ERROR_SUCCESS:
+                raise WinError()
+
+        finally:
+            RmEndSession(dwSessionHandle)
--- a/testing/firefox-ui/tests/functional/sessionstore/test_restore_windows_after_restart_and_quit.py
+++ b/testing/firefox-ui/tests/functional/sessionstore/test_restore_windows_after_restart_and_quit.py
@@ -1,161 +1,28 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-from firefox_puppeteer import PuppeteerMixin
-from marionette_harness import MarionetteTestCase
+# add this directory to the path
+import sys
+import os
+sys.path.append(os.path.dirname(__file__))
+
+from session_store_test_case import SessionStoreTestCase
 
 
-class TestBaseRestoreWindows(PuppeteerMixin, MarionetteTestCase):
-
-    def setUp(self, startup_page=1):
-        super(TestBaseRestoreWindows, self).setUp()
-        # Each list element represents a window of tabs loaded at
-        # some testing URL
-        self.test_windows = set([
-            # Window 1. Note the comma after the absolute_url call -
-            # this is Python's way of declaring a 1 item tuple.
-            (self.marionette.absolute_url('layout/mozilla.html'), ),
-
-            # Window 2
-            (self.marionette.absolute_url('layout/mozilla_organizations.html'),
-             self.marionette.absolute_url('layout/mozilla_community.html')),
-
-            # Window 3
-            (self.marionette.absolute_url('layout/mozilla_governance.html'),
-             self.marionette.absolute_url('layout/mozilla_grants.html')),
-        ])
-
-        self.private_windows = set([
-            (self.marionette.absolute_url('layout/mozilla_mission.html'),
-             self.marionette.absolute_url('layout/mozilla_organizations.html')),
-
-            (self.marionette.absolute_url('layout/mozilla_projects.html'),
-             self.marionette.absolute_url('layout/mozilla_mission.html')),
-        ])
-
-        self.all_windows = self.test_windows | self.private_windows
-
-        self.marionette.enforce_gecko_prefs({
-            # Set browser restore previous session pref,
-            # depending on what the test requires.
-            'browser.startup.page': startup_page,
-            # Make the content load right away instead of waiting for
-            # the user to click on the background tabs
-            'browser.sessionstore.restore_on_demand': False,
-            # Avoid race conditions by having the content process never
-            # send us session updates unless the parent has explicitly asked
-            # for them via the TabStateFlusher.
-            'browser.sessionstore.debug.no_auto_updates': True,
-        })
-
-        self.open_windows(self.test_windows)
-        self.open_windows(self.private_windows, is_private=True)
-
-    def tearDown(self):
-        try:
-            # Create a fresh profile for subsequent tests.
-            self.restart(clean=True)
-        finally:
-            super(TestBaseRestoreWindows, self).tearDown()
-
-    def open_windows(self, window_sets, is_private=False):
-        """ Opens a set of windows with tabs pointing at some
-        URLs.
-
-        @param window_sets (list)
-               A set of URL tuples. Each tuple within window_sets
-               represents a window, and each URL in the URL
-               tuples represents what will be loaded in a tab.
-
-               Note that if is_private is False, then the first
-               URL tuple will be opened in the current window, and
-               subequent tuples will be opened in new windows.
-
-               Example:
-
-               set(
-                   (self.marionette.absolute_url('layout/mozilla_1.html'),
-                    self.marionette.absolute_url('layout/mozilla_2.html')),
-
-                   (self.marionette.absolute_url('layout/mozilla_3.html'),
-                    self.marionette.absolute_url('layout/mozilla_4.html')),
-               )
-
-               This would take the currently open window, and load
-               mozilla_1.html and mozilla_2.html in new tabs. It would
-               then open a new, second window, and load tabs at
-               mozilla_3.html and mozilla_4.html.
-        @param is_private (boolean, optional)
-               Whether or not any new windows should be a private browsing
-               windows.
-        """
-
-        if (is_private):
-            win = self.browser.open_browser(is_private=True)
-            win.switch_to()
-        else:
-            win = self.browser
-
-        for index, urls in enumerate(window_sets):
-            if index > 0:
-                win = self.browser.open_browser(is_private=is_private)
-            win.switch_to()
-            self.open_tabs(win, urls)
-
-    def open_tabs(self, win, urls):
-        """ Opens a set of URLs inside a window in new tabs.
-
-        @param win (browser window)
-               The browser window to load the tabs in.
-        @param urls (tuple)
-               A tuple of URLs to load in this window. The
-               first URL will be loaded in the currently selected
-               browser tab. Subsequent URLs will be loaded in
-               new tabs.
-        """
-        # If there are any remaining URLs for this window,
-        # open some new tabs and navigate to them.
-        with self.marionette.using_context('content'):
-            if isinstance(urls, str):
-                self.marionette.navigate(urls)
-            else:
-                for index, url in enumerate(urls):
-                    if index > 0:
-                        with self.marionette.using_context('chrome'):
-                            win.tabbar.open_tab()
-                    self.marionette.navigate(url)
-
-    def convert_open_windows_to_set(self):
-        windows = self.puppeteer.windows.all
-
-        # There's no guarantee that Marionette will return us an
-        # iterator for the opened windows that will match the
-        # order within our window list. Instead, we'll convert
-        # the list of URLs within each open window to a set of
-        # tuples that will allow us to do a direct comparison
-        # while allowing the windows to be in any order.
-
-        opened_windows = set()
-        for win in windows:
-            urls = tuple()
-            for tab in win.tabbar.tabs:
-                urls = urls + tuple([tab.location])
-            opened_windows.add(urls)
-        return opened_windows
-
-
-class TestSessionStoreEnabled(TestBaseRestoreWindows):
+class TestSessionStoreEnabled(SessionStoreTestCase):
     def setUp(self):
         super(TestSessionStoreEnabled, self).setUp(startup_page=3)
 
     def test_with_variety(self):
-        """ Opens a set of windows, both standard and private, with
+        """ Test opening and restoring both standard and private windows.
+
+        Opens a set of windows, both standard and private, with
         some number of tabs in them. Once the tabs have loaded, restarts
         the browser, and then ensures that the standard tabs have been
         restored, and that the private ones have not.
         """
         current_windows_set = self.convert_open_windows_to_set()
         self.assertEqual(current_windows_set, self.all_windows,
                          msg='Not all requested windows have been opened. Expected {}, got {}.'
                          .format(self.all_windows, current_windows_set))
@@ -166,17 +33,17 @@ class TestSessionStoreEnabled(TestBaseRe
 
         current_windows_set = self.convert_open_windows_to_set()
         self.assertEqual(current_windows_set, self.test_windows,
                          msg="""Non private browsing windows should have
                          been restored. Expected {}, got {}.
                          """.format(self.test_windows, current_windows_set))
 
 
-class TestSessionStoreDisabled(TestBaseRestoreWindows):
+class TestSessionStoreDisabled(SessionStoreTestCase):
     def test_no_restore_with_quit(self):
         current_windows_set = self.convert_open_windows_to_set()
         self.assertEqual(current_windows_set, self.all_windows,
                          msg='Not all requested windows have been opened. Expected {}, got {}.'
                          .format(self.all_windows, current_windows_set))
 
         self.marionette.quit(in_app=True)
         self.marionette.start_session()
new file mode 100644
--- /dev/null
+++ b/testing/firefox-ui/tests/functional/sessionstore/test_restore_windows_after_windows_shutdown.py
@@ -0,0 +1,44 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# add this directory to the path
+import sys
+import os
+sys.path.append(os.path.dirname(__file__))
+
+from session_store_test_case import SessionStoreTestCase
+
+
+class TestSessionStoreWindowsShutdown(SessionStoreTestCase):
+    def setUp(self):
+        super(TestSessionStoreWindowsShutdown, self).setUp(startup_page=3, no_auto_updates=False)
+
+    def test_with_variety(self):
+        """ Test restoring a set of windows across Windows shutdown.
+
+        Opens a set of windows, both standard and private, with
+        some number of tabs in them. Once the tabs have loaded, shuts down
+        the browser with the Windows Restart Manager, restarts the browser,
+        and then ensures that the standard tabs have been restored,
+        and that the private ones have not.
+
+        This specifically exercises the Windows synchronous shutdown
+        mechanism, which terminates the process in response to the
+        Restart Manager's WM_ENDSESSION message.
+        """
+
+        current_windows_set = self.convert_open_windows_to_set()
+        self.assertEqual(current_windows_set, self.all_windows,
+                         msg='Not all requested windows have been opened. Expected {}, got {}.'
+                         .format(self.all_windows, current_windows_set))
+
+        self.marionette.quit(in_app=True, callback=lambda: self.simulate_os_shutdown())
+        self.marionette.start_session()
+        self.marionette.set_context('chrome')
+
+        current_windows_set = self.convert_open_windows_to_set()
+        self.assertEqual(current_windows_set, self.test_windows,
+                         msg="""Non private browsing windows should have
+                         been restored. Expected {}, got {}.
+                         """.format(self.test_windows, current_windows_set))