Connect to a `chrome.runtime` port to keep the service worker alive  (#1789)

diff --git a/dwds/debug_extension_mv3/pubspec.yaml b/dwds/debug_extension_mv3/pubspec.yaml
index e16dbca..2e13fcb 100644
--- a/dwds/debug_extension_mv3/pubspec.yaml
+++ b/dwds/debug_extension_mv3/pubspec.yaml
@@ -14,9 +14,9 @@
 
 dev_dependencies:
   build: ^2.0.0
-  build_web_compilers: ^3.0.0
   build_runner: ^2.0.6
   built_value_generator: ^8.3.0
+  build_web_compilers: ^3.0.0
   dwds: ^16.0.0
 
 dependency_overrides:
diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart
index 4bd45d9..60d2477 100644
--- a/dwds/debug_extension_mv3/web/background.dart
+++ b/dwds/debug_extension_mv3/web/background.dart
@@ -5,6 +5,7 @@
 @JS()
 library background;
 
+import 'dart:async';
 import 'dart:html';
 
 import 'package:dwds/data/debug_info.dart';
@@ -12,6 +13,7 @@
 
 import 'chrome_api.dart';
 import 'data_types.dart';
+import 'lifeline_ports.dart';
 import 'messaging.dart';
 import 'storage.dart';
 import 'web_api.dart';
@@ -22,13 +24,16 @@
 
 void _registerListeners() {
   chrome.runtime.onMessage.addListener(allowInterop(_handleRuntimeMessages));
+  chrome.tabs.onRemoved
+      .addListener(allowInterop((tabId, _) => maybeRemoveLifelinePort(tabId)));
 
   // Detect clicks on the Dart Debug Extension icon.
   chrome.action.onClicked.addListener(allowInterop(_startDebugSession));
 }
 
-Future<void> _startDebugSession(Tab _) async {
-  // TODO(elliette): Start a debug session instead.
+// TODO(elliette): Start a debug session instead.
+Future<void> _startDebugSession(Tab currentTab) async {
+  maybeCreateLifelinePort(currentTab.id);
   final devToolsOpener = await fetchStorageObject<DevToolsOpener>(
       type: StorageObject.devToolsOpener);
   await _createTab('https://dart.dev/',
diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart
index 49a1bac..de02504 100644
--- a/dwds/debug_extension_mv3/web/chrome_api.dart
+++ b/dwds/debug_extension_mv3/web/chrome_api.dart
@@ -12,6 +12,7 @@
 class Chrome {
   external Action get action;
   external Runtime get runtime;
+  external Scripting get scripting;
   external Storage get storage;
   external Tabs get tabs;
   external Windows get windows;
@@ -47,14 +48,39 @@
 @JS()
 @anonymous
 class Runtime {
+  external void connect(String? extensionId, ConnectInfo info);
+
   external void sendMessage(
       String? id, Object? message, Object? options, Function? callback);
 
+  external ConnectionHandler get onConnect;
+
   external OnMessageHandler get onMessage;
 }
 
 @JS()
 @anonymous
+class ConnectInfo {
+  external String? get name;
+  external factory ConnectInfo({String? name});
+}
+
+@JS()
+@anonymous
+class Port {
+  external String? get name;
+  external void disconnect();
+  external ConnectionHandler get onDisconnect;
+}
+
+@JS()
+@anonymous
+class ConnectionHandler {
+  external void addListener(void Function(Port) callback);
+}
+
+@JS()
+@anonymous
 class OnMessageHandler {
   external void addListener(
       void Function(dynamic, MessageSender, Function) callback);
@@ -69,6 +95,37 @@
   external factory MessageSender({String? id, String? url, Tab? tab});
 }
 
+/// chrome.scripting APIs
+/// https://developer.chrome.com/docs/extensions/reference/scripting
+
+@JS()
+@anonymous
+class Scripting {
+  external executeScript(InjectDetails details, Function? callback);
+}
+
+@JS()
+@anonymous
+class InjectDetails<T, U> {
+  external Target get target;
+  external T? get func;
+  external List<U?>? get args;
+  external List<String>? get files;
+  external factory InjectDetails({
+    Target target,
+    T? func,
+    List<U>? args,
+    List<String>? files,
+  });
+}
+
+@JS()
+@anonymous
+class Target {
+  external int get tabId;
+  external factory Target({int tabId});
+}
+
 /// chrome.storage APIs
 /// https://developer.chrome.com/docs/extensions/reference/storage
 
@@ -76,6 +133,8 @@
 @anonymous
 class Storage {
   external StorageArea get local;
+
+  external StorageArea get session;
 }
 
 @JS()
@@ -95,6 +154,14 @@
   external Object query(QueryInfo queryInfo);
 
   external Object create(TabInfo tabInfo);
+
+  external OnRemovedHandler get onRemoved;
+}
+
+@JS()
+@anonymous
+class OnRemovedHandler {
+  external void addListener(void Function(int tabId, dynamic info) callback);
 }
 
 @JS()
diff --git a/dwds/debug_extension_mv3/web/lifeline_connection.dart b/dwds/debug_extension_mv3/web/lifeline_connection.dart
new file mode 100644
index 0000000..ec32786
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/lifeline_connection.dart
@@ -0,0 +1,24 @@
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'chrome_api.dart';
+import 'web_api.dart';
+
+void main() async {
+  _connectToLifelinePort();
+}
+
+void _connectToLifelinePort() {
+  console.log(
+      '[Dart Debug Extension] Connecting to lifeline port at ${_currentTime()}.');
+  chrome.runtime.connect(
+    /*extensionId=*/ null,
+    ConnectInfo(name: 'keepAlive'),
+  );
+}
+
+String _currentTime() {
+  final date = DateTime.now();
+  return '${date.hour}:${date.minute}::${date.second}';
+}
diff --git a/dwds/debug_extension_mv3/web/lifeline_ports.dart b/dwds/debug_extension_mv3/web/lifeline_ports.dart
new file mode 100644
index 0000000..a465ceb
--- /dev/null
+++ b/dwds/debug_extension_mv3/web/lifeline_ports.dart
@@ -0,0 +1,119 @@
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// Keeps the background service worker alive for the duration of a Dart debug
+// session by using the workaround described in:
+// https://bugs.chromium.org/p/chromium/issues/detail?id=1152255#c21
+@JS()
+library lifeline_ports;
+
+import 'dart:async';
+import 'package:js/js.dart';
+
+import 'chrome_api.dart';
+import 'web_api.dart';
+
+// Switch to true to enable debug logs.
+// TODO(elliette): Enable / disable with flag while building the extension.
+final enableDebugLogging = true;
+
+Port? lifelinePort;
+int? lifelineTab;
+final dartTabs = <int>{};
+
+void maybeCreateLifelinePort(int tabId) {
+  // Keep track of current Dart tabs that are being debugged. This way if one of
+  // them is closed, we can reconnect the lifeline port to another one:
+  dartTabs.add(tabId);
+  _debugLog('Dart tabs are: $dartTabs');
+  // Don't create a lifeline port if we already have one (meaning another Dart
+  // app is currently being debugged):
+  if (lifelinePort != null) {
+    _debugWarn('Port already exists.');
+    return;
+  }
+  // Start the keep-alive logic when the port connects:
+  chrome.runtime.onConnect.addListener(allowInterop(_keepLifelinePortAlive));
+  // Inject the connection script into the current Dart tab, that way the tab
+  // will connect to the port:
+  _debugLog('Creating lifeline port.');
+  lifelineTab = tabId;
+  chrome.scripting.executeScript(
+    InjectDetails(
+      target: Target(tabId: tabId),
+      files: ['lifeline_connection.dart.js'],
+    ),
+    /*callback*/ null,
+  );
+}
+
+void maybeRemoveLifelinePort(int removedTabId) {
+  final removedDartTab = dartTabs.remove(removedTabId);
+  // If the removed tab was not a Dart tab, return early.
+  if (!removedDartTab) return;
+  _debugLog('Removed tab $removedTabId, Dart tabs are now $dartTabs.');
+  // If the removed Dart tab hosted the lifeline port connection, see if there
+  // are any other Dart tabs to connect to. Otherwise disconnect the port.
+  if (lifelineTab == removedTabId) {
+    if (dartTabs.isEmpty) {
+      lifelineTab = null;
+      _debugLog('No more Dart tabs, disconnecting from lifeline port.');
+      _disconnectFromLifelinePort();
+    } else {
+      lifelineTab = dartTabs.last;
+      _debugLog('Reconnecting lifeline port to a new Dart tab: $lifelineTab.');
+      _reconnectToLifelinePort();
+    }
+  }
+}
+
+void _keepLifelinePortAlive(Port port) {
+  final portName = port.name ?? '';
+  if (portName != 'keepAlive') return;
+  lifelinePort = port;
+  // Reconnect to the lifeline port every 5 minutes, as per:
+  // https://bugs.chromium.org/p/chromium/issues/detail?id=1146434#c6
+  Timer(Duration(minutes: 5), () {
+    _debugLog('5 minutes have elapsed, therefore reconnecting.');
+    _reconnectToLifelinePort();
+  });
+}
+
+void _reconnectToLifelinePort() {
+  _debugLog('Reconnecting...');
+  if (lifelinePort == null) {
+    _debugWarn('Could not find a lifeline port.');
+    return;
+  }
+  if (lifelineTab == null) {
+    _debugWarn('Could not find a lifeline tab.');
+    return;
+  }
+  // Disconnect from the port, and then recreate the connection with the current
+  // Dart tab:
+  _disconnectFromLifelinePort();
+  maybeCreateLifelinePort(lifelineTab!);
+  _debugLog('Reconnection complete.');
+}
+
+void _disconnectFromLifelinePort() {
+  _debugLog('Disconnecting...');
+  if (lifelinePort != null) {
+    lifelinePort!.disconnect();
+    lifelinePort = null;
+    _debugLog('Disconnection complete.');
+  }
+}
+
+void _debugLog(String msg) {
+  if (enableDebugLogging) {
+    console.log(msg);
+  }
+}
+
+void _debugWarn(String msg) {
+  if (enableDebugLogging) {
+    console.warn(msg);
+  }
+}
diff --git a/dwds/debug_extension_mv3/web/manifest.json b/dwds/debug_extension_mv3/web/manifest.json
index 91698a3..92e5396 100644
--- a/dwds/debug_extension_mv3/web/manifest.json
+++ b/dwds/debug_extension_mv3/web/manifest.json
@@ -14,17 +14,6 @@
     "host_permissions": [
         "<all_urls>"
     ],
-    "web_accessible_resources": [
-        {
-            "matches": [
-                "<all_urls>"
-            ],
-            "resources": [
-                "iframe.html",
-                "iframe_injector.dart.js"
-            ]
-        }
-    ],
     "background": {
         "service_worker": "background.dart.js"
     },
diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart
index e97fcc5..016f8dd 100644
--- a/dwds/test/puppeteer/extension_test.dart
+++ b/dwds/test/puppeteer/extension_test.dart
@@ -10,13 +10,12 @@
 })
 @Timeout(Duration(seconds: 60))
 import 'dart:async';
-import 'dart:io';
 
 import 'package:puppeteer/puppeteer.dart';
-import 'package:path/path.dart' as p;
 import 'package:test/test.dart';
 
 import '../fixtures/context.dart';
+import 'test_utils.dart';
 
 final context = TestContext();
 
@@ -27,7 +26,7 @@
 
   group('MV3 Debug Extension', () {
     setUpAll(() async {
-      extensionPath = await _buildDebugExtension();
+      extensionPath = await buildDebugExtension();
     });
 
     for (var useSse in [true, false]) {
@@ -63,13 +62,13 @@
           final windowIdForAppJs = _windowIdForTabJs(appUrl);
           final windowIdForDevToolsJs = _windowIdForTabJs(devToolsUrl);
           // Navigate to the Dart app:
-          await _navigateToPage(browser, url: appUrl, isNew: true);
+          await navigateToPage(browser, url: appUrl, isNew: true);
           // Click on the Dart Debug Extension icon:
           final worker = (await serviceWorkerTarget.worker)!;
           // Note: The following delay is required to reduce flakiness (it makes
           // sure the execution context is ready):
           await Future.delayed(Duration(seconds: 1));
-          await worker.evaluate(_clickIconJs);
+          await worker.evaluate(clickIconJs);
           // Verify the extension opened the Dart docs in the same window:
           var devToolsTabTarget = await browser
               .waitForTarget((target) => target.url.contains(devToolsUrl));
@@ -81,8 +80,8 @@
           var devToolsTab = await devToolsTabTarget.page;
           await devToolsTab.close();
           // Navigate to the extension settings page:
-          final extensionOrigin = _getExtensionOrigin(browser);
-          final settingsTab = await _navigateToPage(
+          final extensionOrigin = getExtensionOrigin(browser);
+          final settingsTab = await navigateToPage(
             browser,
             url: '$extensionOrigin/settings.html',
             isNew: true,
@@ -95,9 +94,9 @@
           // Close the settings tab:
           await settingsTab.close();
           // Navigate to the Dart app:
-          await _navigateToPage(browser, url: appUrl);
+          await navigateToPage(browser, url: appUrl);
           // Click on the Dart Debug Extension icon:
-          await worker.evaluate(_clickIconJs);
+          await worker.evaluate(clickIconJs);
           // Verify the extension opened DevTools in a different window:
           devToolsTabTarget = await browser
               .waitForTarget((target) => target.url.contains(devToolsUrl));
@@ -114,42 +113,6 @@
   });
 }
 
-Iterable<String> _getUrlsInBrowser(Browser browser) {
-  return browser.targets.map((target) => target.url);
-}
-
-Future<Page> _getPageForUrl(Browser browser, {required String url}) {
-  final pageTarget = browser.targets.firstWhere((target) => target.url == url);
-  return pageTarget.page;
-}
-
-String _getExtensionOrigin(Browser browser) {
-  final chromeExtension = 'chrome-extension:';
-  final extensionUrl = _getUrlsInBrowser(browser)
-      .firstWhere((url) => url.contains(chromeExtension));
-  final urlSegments = p.split(extensionUrl);
-  final extensionId = urlSegments[urlSegments.indexOf(chromeExtension) + 1];
-  return '$chromeExtension//$extensionId';
-}
-
-Future<Page> _navigateToPage(
-  Browser browser, {
-  required String url,
-  bool isNew = false,
-}) async {
-  final page = isNew
-      ? await browser.newPage()
-      : await _getPageForUrl(
-          browser,
-          url: url,
-        );
-  if (isNew) {
-    await page.goto(url, wait: Until.domContentLoaded);
-  }
-  await page.bringToFront();
-  return page;
-}
-
 String _windowIdForTabJs(String tabUrl) {
   return '''
     async () => {
@@ -159,27 +122,3 @@
     }
 ''';
 }
-
-final _clickIconJs = '''
-  async () => {
-    const activeTabs = await chrome.tabs.query({ active: true });
-    const tab = activeTabs[0];
-    chrome.action.onClicked.dispatch(tab);
-  }
-''';
-
-Future<String> _buildDebugExtension() async {
-  final currentDir = Directory.current.path;
-  if (!currentDir.endsWith('dwds')) {
-    throw StateError(
-        'Expected to be in /dwds directory, instead path was $currentDir.');
-  }
-  final extensionDir = '$currentDir/debug_extension_mv3';
-  // TODO(elliette): This doesn't work on Windows, see https://github.com/dart-lang/webdev/issues/1724.
-  await Process.run(
-    'tool/build_extension.sh',
-    [],
-    workingDirectory: extensionDir,
-  );
-  return '$extensionDir/compiled';
-}
diff --git a/dwds/test/puppeteer/lifeline_test.dart b/dwds/test/puppeteer/lifeline_test.dart
new file mode 100644
index 0000000..8a9cf81
--- /dev/null
+++ b/dwds/test/puppeteer/lifeline_test.dart
@@ -0,0 +1,91 @@
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@Timeout(Duration(minutes: 10))
+@Skip('https://github.com/dart-lang/webdev/issues/1788')
+import 'dart:async';
+
+import 'package:puppeteer/puppeteer.dart';
+import 'package:test/test.dart';
+
+import '../fixtures/context.dart';
+import 'test_utils.dart';
+
+final context = TestContext();
+
+void main() async {
+  late Target serviceWorkerTarget;
+  late Browser browser;
+  late String extensionPath;
+
+  int connectionCount = 0;
+
+  group('MV3 Debug Extension Lifeline Connection', () {
+    setUpAll(() async {
+      extensionPath = await buildDebugExtension();
+      await context.setUp(launchChrome: false);
+      browser = await puppeteer.launch(
+        headless: false,
+        timeout: Duration(seconds: 60),
+        args: [
+          '--load-extension=$extensionPath',
+          '--disable-extensions-except=$extensionPath',
+          '--disable-features=DialMediaRouteProvider',
+        ],
+      );
+
+      serviceWorkerTarget = await browser
+          .waitForTarget((target) => target.type == 'service_worker');
+    });
+
+    tearDownAll(() async {
+      await browser.close();
+    });
+
+    test('connects to a lifeline port', () async {
+      // Navigate to the Dart app:
+      final appTab =
+          await navigateToPage(browser, url: context.appUrl, isNew: true);
+      // Click on the Dart Debug Extension icon:
+      final worker = (await serviceWorkerTarget.worker)!;
+      // Note: The following delay is required to reduce flakiness (it makes
+      // sure the execution context is ready):
+      await Future.delayed(Duration(seconds: 1));
+      // Initiate listeners for the port connection event and the subsequent
+      // reconnection logs:
+      final portConnectionPromise = worker.evaluate<bool>(_portConnectionJs);
+      appTab.onConsole.listen((ConsoleMessage message) {
+        final messageText = message.text ?? '';
+        if (messageText
+            .contains('[Dart Debug Extension] Connecting to lifeline port')) {
+          connectionCount++;
+        }
+      });
+      // Click on the Dart Debug Extension icon to intiate a debug session:
+      await worker.evaluate(clickIconJs);
+      final connectedToPort = await portConnectionPromise;
+      // Verify that we have connected to the port:
+      expect(connectedToPort, isTrue);
+      expect(connectionCount, equals(1));
+      // Wait for a little over 5 minutes, and verify that we have reconnected
+      // to the port again:
+      await Future.delayed(Duration(minutes: 5) + Duration(seconds: 15));
+      expect(connectionCount, equals(2));
+    });
+  });
+}
+
+final _portConnectionJs = '''
+  async () => {
+    return new Promise((resolve, reject) => {
+      chrome.runtime.onConnect.addListener((port) => {
+        if (port.name == 'keepAlive') {
+          resolve(true);
+        } else {
+          reject(false);
+        }
+      });
+    });
+  }
+''';
diff --git a/dwds/test/puppeteer/test_utils.dart b/dwds/test/puppeteer/test_utils.dart
new file mode 100644
index 0000000..567aee1
--- /dev/null
+++ b/dwds/test/puppeteer/test_utils.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:puppeteer/puppeteer.dart';
+
+Future<String> buildDebugExtension() async {
+  final currentDir = Directory.current.path;
+  if (!currentDir.endsWith('dwds')) {
+    throw StateError(
+        'Expected to be in /dwds directory, instead path was $currentDir.');
+  }
+  final extensionDir = p.join(currentDir, 'debug_extension_mv3');
+  // TODO(elliette): This doesn't work on Windows, see https://github.com/dart-lang/webdev/issues/1724.
+  await Process.run(
+    p.join('tool', 'build_extension.sh'),
+    [],
+    workingDirectory: extensionDir,
+  );
+  return '$extensionDir/compiled';
+}
+
+Future<Page> navigateToPage(
+  Browser browser, {
+  required String url,
+  bool isNew = false,
+}) async {
+  final page = isNew
+      ? await browser.newPage()
+      : await _getPageForUrl(
+          browser,
+          url: url,
+        );
+  if (isNew) {
+    await page.goto(url, wait: Until.domContentLoaded);
+  }
+  await page.bringToFront();
+  return page;
+}
+
+String getExtensionOrigin(Browser browser) {
+  final chromeExtension = 'chrome-extension:';
+  final extensionUrl = _getUrlsInBrowser(browser)
+      .firstWhere((url) => url.contains(chromeExtension));
+  final urlSegments = p.split(extensionUrl);
+  final extensionId = urlSegments[urlSegments.indexOf(chromeExtension) + 1];
+  return '$chromeExtension//$extensionId';
+}
+
+Iterable<String> _getUrlsInBrowser(Browser browser) {
+  return browser.targets.map((target) => target.url);
+}
+
+Future<Page> _getPageForUrl(Browser browser, {required String url}) {
+  final pageTarget = browser.targets.firstWhere((target) => target.url == url);
+  return pageTarget.page;
+}
+
+final clickIconJs = '''
+  async () => {
+    const activeTabs = await chrome.tabs.query({ active: true });
+    const tab = activeTabs[0];
+    chrome.action.onClicked.dispatch(tab);
+  }
+''';