Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionSpoofing issue in the DOM: Web Serial component
ComponentDOM
Bug ClassLogic Error
Tracker2059230
Fix commit808830dead0a (firefox) +288/-84
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
dom/webserial/SerialPort.cpp
modified
if
dom/webserial/SerialPortParent.cpp
modified
for
dom/webserial/SerialPortParent.cpp
modified
if
dom/webserial/tests/browser/browser_webserial_sharing_icon.js
modified
add_setup
dom/webserial/tests/browser/browser_webserial_sharing_icon.js
modified
add_task
dom/webserial/tests/browser/browser_webserial_sharing_icon.js
modified

Files Changed

  • dom/webserial/PSerialPort.ipdl
  • dom/webserial/SerialPort.cpp
  • dom/webserial/SerialPort.h
  • dom/webserial/SerialPortParent.cpp
  • dom/webserial/SerialPortParent.h
  • dom/webserial/tests/browser/browser_webserial_permission_revoke.js
  • dom/webserial/tests/browser/browser_webserial_sharing_icon.js
diff --git a/dom/webserial/PSerialPort.ipdl b/dom/webserial/PSerialPort.ipdl
index 1494cc4a403..824dc81bb7d 100644
--- a/dom/webserial/PSerialPort.ipdl
+++ b/dom/webserial/PSerialPort.ipdl
@@ -48,9 +48,6 @@ parent:
   // its WritableStream.
   async AttachWritePipe(nullable DataPipeReceiver writePipeReceiver);
 
-  // Notify parent of sharing state change
-  async UpdateSharingState(bool connected);
-
   // Clone the parent-side handle into a new PSerialPort actor, allowing
   // a worker thread to bind its own child endpoint to the same port.
   async Clone(Endpoint<PSerialPortParent> aEndpoint);
diff --git a/dom/webserial/SerialPort.cpp b/dom/webserial/SerialPort.cpp
index d5dcee6705a..442a09d4e96 100644
--- a/dom/webserial/SerialPort.cpp
+++ b/dom/webserial/SerialPort.cpp
@@ -285,7 +285,6 @@ already_AddRefed<Promise> SerialPort::Open(const SerialOptions& aOptions,
             // Step 9.3: Set this.[[state]] to "opened".
             self->mState = State::Opened;
             self->UpdateWorkerRef();
-            self->NotifySharingStateChanged(true);
             // Step 9.4: Set this.[[bufferSize]].
             self->mBufferSize = bufferSize;
             self->mPipeCapacity = std::max(bufferSize, kMinSerialPortPumpSize);
@@ -559,7 +558,6 @@ already_AddRefed<Promise> SerialPort::Close(ErrorResult& aRv) {
           aSelf->mState = State::Closed;
         }
         aSelf->UpdateWorkerRef();
-        aSelf->NotifySharingStateChanged(false);
         if (RefPtr<Promise> closePromise = aSelf->mClosePromise.forget()) {
           closePromise->MaybeReject(aReason);
         }
@@ -602,7 +600,6 @@ already_AddRefed<Promise> SerialPort::Forget(ErrorResult& aRv) {
   }
 
   UpdateWorkerRef();
-  NotifySharingStateChanged(false);
 
   if (mChild) {
     RefPtr<SerialPortChild> child = mChild;
@@ -656,7 +653,6 @@ void SerialPort::MarkForgotten() {
   }
 
   UpdateWorkerRef();
-  NotifySharingStateChanged(false);
 }
 
 void SerialPort::GetInfo(SerialPortInfo& aRetVal, ErrorResult& aRv) {
@@ -707,20 +703,6 @@ WritableStream* SerialPort::GetWritable() {
   return mWritable;
 }
 
-void SerialPort::NotifySharingStateChanged(bool aConnected) {
-  if (!mChild) {
-    return;
-  }
-
-  RefPtr<SerialPortChild> child = mChild;
-  nsISerialEventTarget* actorTarget = child->GetActorEventTarget();
-  if (actorTarget) {
-    actorTarget->Dispatch(NS_NewRunnableFunction(
-        "SerialPort::SendUpdateSharingState",
-        [child, aConnected]() { child->SendUpdateSharingState(aConnected); }));
-  }
-}
-
 void SerialPort::OnActorDestroyed() {
   if (mHasShutdown) {
     return;
@@ -731,8 +713,7 @@ void SerialPort::OnActorDestroyed() {
            NS_ConvertUTF16toUTF8(mInfo.id()).get()));
 
   // Clear the child reference first since the actor is already destroyed.
-  // This prevents MarkForgotten/NotifySharingStateChanged from trying to
-  // use the dead actor.
+  // This prevents MarkForgotten from trying to use the dead actor.
   mChild = nullptr;
 
   // Mark the port as forgotten (closes streams, updates worker ref).
@@ -769,7 +750,6 @@ void SerialPort::NotifyDisconnected() {
   // Don't have to wait for this
   RefPtr<Promise> ignoredPromise = CloseStreams(StreamCloseMode::Graceful);
   UpdateWorkerRef();
-  NotifySharingStateChanged(false);
 
   auto event = MakeRefPtr<Event>(this, nullptr, nullptr);
   event->InitEvent(u"disconnect"_ns, true, false);
@@ -940,7 +920,6 @@ void SerialPort::SettleClosePromise(nsresult aResult) {
     mState = State::Closed;
   }
   UpdateWorkerRef();
-  NotifySharingStateChanged(false);
   if (RefPtr<Promise> closePromise = mClosePromise.forget()) {
     if (NS_SUCCEEDED(aResult)) {
       closePromise->MaybeResolveWithUndefined();
diff --git a/dom/webserial/SerialPort.h b/dom/webserial/SerialPort.h
index 3950affdafe..67188e365e1 100644
--- a/dom/webserial/SerialPort.h
+++ b/dom/webserial/SerialPort.h
@@ -131,7 +131,6 @@ class SerialPort final : public DOMEventTargetHelper {
   void SettleClosePromise(nsresult aResult);
   MOZ_CAN_RUN_SCRIPT already_AddRefed<Promise> CloseStreams(
       StreamCloseMode aMode);
-  void NotifySharingStateChanged(bool aConnected);
   void UpdateWorkerRef();
 
   RefPtr<Serial> mSerial;
diff --git a/dom/webserial/SerialPortParent.cpp b/dom/webserial/SerialPortParent.cpp
index 39792e025d3..4d180ea4a9b 100644
--- a/dom/webserial/SerialPortParent.cpp
+++ b/dom/webserial/SerialPortParent.cpp
@@ -104,6 +104,7 @@ mozilla::ipc::IPCResult SerialPortParent::RecvOpen(
            NS_ConvertUTF16toUTF8(mPortId).get()));
 
   mIsOpen = true;
+  NotifySharingStateChanged();
   mPipeCapacity = std::max(aOptions.bufferSize(), kMinSerialPortPumpSize);
   mDetectParityErrors = aOptions.parity() != ParityType::None;
 
@@ -144,6 +145,7 @@ mozilla::ipc::IPCResult SerialPortParent::RecvClose(CloseResolver&& aResolver) {
          NS_ConvertUTF16toUTF8(mPortId).get(), static_cast<uint32_t>(rv)));
   }
   mIsOpen = false;
+  NotifySharingStateChanged();
 
   aResolver(rv);
   return IPC_OK();
@@ -409,12 +411,10 @@ mozilla::ipc::IPCResult SerialPortParent::RecvFlush(bool aReceive,
   return IPC_OK();
 }
 
-void SerialPortParent::NotifySharingStateChanged(bool aConnected) {
-  mSharingConnected = aConnected;
-
+void SerialPortParent::NotifySharingStateChanged() {
   NS_DispatchToMainThread(NS_NewRunnableFunction(
       "SerialPortParent::NotifySharingStateChanged",
-      [browserId = mBrowserId, aConnected]() {
+      [browserId = mBrowserId, connected = mIsOpen]() {
         nsCOMPtr<nsIObserverService> obs =
             mozilla::services::GetObserverService();
         if (!obs) {
@@ -423,26 +423,13 @@ void SerialPortParent::NotifySharingStateChanged(bool aConnected) {
 
         auto props = MakeRefPtr<nsHashPropertyBag>();
         props->SetPropertyAsUint64(u"browserId"_ns, browserId);
-        props->SetPropertyAsBool(u"connected"_ns, aConnected);
+        props->SetPropertyAsBool(u"connected"_ns, connected);
 
         obs->NotifyObservers(static_cast<nsIPropertyBag2*>(props),
                              "serial-device-state-changed", nullptr);
       }));
 }
 
-mozilla::ipc::IPCResult SerialPortParent::RecvUpdateSharingState(
-    bool aConnected) {
-  if (aConnected != mSharingConnected) {
-    NotifySharingStateChanged(aConnected);
-  } else {
-    MOZ_LOG(gWebSerialLog, LogLevel::Warning,
-            ("SerialPortParent[%p]::RecvUpdateSharingState got same state of "
-             "%d for port '%s'",
-             this, aConnected ? 1 : 0, NS_ConvertUTF16toUTF8(mPortId).get()));
-  }
-  return IPC_OK();
-}
-
 mozilla::ipc::IPCResult SerialPortParent::RecvClone(
     mozilla::ipc::Endpoint<PSerialPortParent>&& aEndpoint) {
   if (!aEndpoint.IsValid()) {
@@ -478,6 +465,7 @@ void SerialPortParent::NotifyDisconnected() {
   if (mIsOpen) {
     StopPumpsBeforeClose();
     mIsOpen = false;
+    NotifySharingStateChanged();
     RefPtr<SerialPlatformService> service =
         SerialPlatformService::GetInstance();
     if (service) {
@@ -526,12 +514,10 @@ void SerialPortParent::ActorDestroy(ActorDestroyReason aWhy) {
     mIsOpen = false;
   }
 
-  // If the child sent connected=true but never sent connected=false (e.g.
-  // content process crash or iframe navigation), send the disconnect
-  // notification so the browser sharing indicator count stays in sync.
-  if (mSharingConnected) {
-    NotifySharingStateChanged(false);
-  }
+  // Covers the case where the port was still open when the actor went away
+  // (e.g. content process crash or iframe navigation) so the browser sharing
+  // indicator count stays in sync.
+  NotifySharingStateChanged();
 
   nsTArray<RefPtr<SerialPortParent>> clones = std::move(mClones);
   for (const auto& clone : clones) {
diff --git a/dom/webserial/SerialPortParent.h b/dom/webserial/SerialPortParent.h
index 6d826ef150e..c396d4c311c 100644
--- a/dom/webserial/SerialPortParent.h
+++ b/dom/webserial/SerialPortParent.h
@@ -40,7 +40,6 @@ class SerialPortParent final : public PSerialPortParent {
       const RefPtr<mozilla::ipc::DataPipeSender>& aReadPipeSender);
   mozilla::ipc::IPCResult RecvAttachWritePipe(
       const RefPtr<mozilla::ipc::DataPipeReceiver>& aWritePipeReceiver);
-  mozilla::ipc::IPCResult RecvUpdateSharingState(bool aConnected);
   mozilla::ipc::IPCResult RecvClone(
       mozilla::ipc::Endpoint<PSerialPortParent>&& aEndpoint);
 
@@ -66,7 +65,9 @@ class SerialPortParent final : public PSerialPortParent {
   void StopReadPump();
   void DestroyPlatformReader();
   void StopWritePump();
-  void NotifySharingStateChanged(bool aConnected);
+  // Should only be called when there's a transition from open->closed or
+  // closed->open, as otherwise the sharing indicator can get out of sync.
+  void NotifySharingStateChanged();
   void StartReadPump(
       already_AddRefed<mozilla::ipc::DataPipeSender> aReadPipeSender);
   void StartWritePump(
@@ -75,10 +76,6 @@ class SerialPortParent final : public PSerialPortParent {
   const nsString mPortId;
   const uint64_t mBrowserId;
   bool mIsOpen = false;
-  // Whether we have forwarded a connected=true sharing state notification
-  // without a corresponding connected=false. Used by ActorDestroy to send the
-  // missing disconnect so the browser sharing indicator count stays in sync.
-  bool mSharingConnected = false;
   uint32_t mPipeCapacity = 0;
   // Whether the port was opened with parity checking enabled (even/odd). When
   // set, the read stream is wrapped to detect parity errors and surface them
diff --git a/dom/webserial/tests/browser/browser_webserial_permission_revoke.js b/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
index d0accbefb62..28d9f7c8ac2 100644
--- a/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
+++ b/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
@@ -1,11 +1,6 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
-ChromeUtils.defineESModuleGetters(this, {
-  SerialDeviceSharingHelper:
-    "moz-src:///browser/modules/SerialDeviceSharingHelper.sys.mjs",
-});
-
 const TEST_URL =
   "https://example.com/document-builder.sjs?html=<h1>Test serial permission revocation</h1>";
 
@@ -54,8 +49,9 @@ add_task(async function testPermissionRevocation() {
     gBrowser.selectedBrowser
   );
 
-  SerialDeviceSharingHelper.resetBrowserCount(gBrowser.selectedBrowser);
-  gBrowser.updateBrowserSharing(gBrowser.selectedBrowser, { serial: false });
+  // Unlike the permission panel's revoke button, don't reset the sharing
+  // count here: the icon must be hidden purely by the parent process
+  // observing the port close that forget() triggers.
 
   // Give time for observer to be registered (it happens async)
   await new Promise(resolve => setTimeout(resolve, 0));
@@ -96,9 +92,11 @@ add_task(async function testPermissionRevocation() {
     is(ports.length, 0, "getPorts() should return empty after revocation");
   });
 
-  let serialIcon = document.getElementById("serial-sharing-icon");
-  ok(
-    !BrowserTestUtils.isVisible(serialIcon),
+  await TestUtils.waitForCondition(
+    () =>
+      !BrowserTestUtils.isVisible(
+        document.getElementById("serial-sharing-icon")
+      ),
     "Serial sharing icon should not be visible after permission revocation"
   );
 
diff --git a/dom/webserial/tests/browser/browser_webserial_sharing_icon.js b/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
index c9ababa37bf..2e219006020 100644
--- a/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
+++ b/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
@@ -3,6 +3,57 @@
 
 const TEST_URL =
   "https://example.com/document-builder.sjs?html=<h1>Test serial worker sharing icon</h1>";
+const BASE = "https://example.com/browser/dom/webserial/tests/browser/";
+const BLANK_URL = BASE + "blank.html";
+const WORKER_URL = BASE + "worker_open_close.js";
+
+function isIconVisible() {
+  return BrowserTestUtils.isVisible(
+    document.getElementById("serial-sharing-icon")
+  );
+}
+
+function waitForIconVisible(msg) {
+  return TestUtils.waitForCondition(isIconVisible, msg);
+}
+
+function waitForIconHidden(msg) {
+  return TestUtils.waitForCondition(() => !isIconVisible(), msg);
+}
+
+// Requests a port in the given browsing context (using autoselect, optionally
+// narrowed by filters) and opens it, keeping it reachable as content.testPort.
+async function requestAndOpenPort(bc, filters = []) {
+  await SpecialPowers.spawn(bc, [filters], async aFilters => {
+    content.navigator.serial.autoselectPorts = true;
+    SpecialPowers.wrap(content.document).notifyUserGestureActivation();
+    content.testPort = await content.navigator.serial.requestPort({
+      filters: aFilters,
+    });
+    await content.testPort.open({ baudRate: 9600 });
+  });
+}
+
+// Starts a worker in the given browser that opens the first granted port and
+// resolves once it reports the port is open. The worker is kept reachable as
+// content.testWorker.
+async function openPortFromWorker(browser) {
+  await SpecialPowers.spawn(browser, [WORKER_URL], async url => {
+    const worker = new content.Worker(url);
+    content.testWorker = worker;
+    await new Promise((resolve, reject) => {
+      worker.onmessage = e => {
+        if (e.data.type === "opened") {
+          resolve();
+        } else if (e.data.type === "error") {
+          reject(new Error(e.data.message));
+        }
+      };
+      worker.onerror = e => reject(new Error(e.message));
+      worker.postMessage("open");
+    });
+  });
+}
 
 add_setup(async function () {
   await SpecialPowers.pushPrefEnv({
@@ -36,24 +87,7 @@ add_task(async function testSharingIconAppearsFromWorker() {
   );
 
   // Spawn a worker and have it open the port
-  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
-    const worker = new content.Worker(
-      "https://example.com/browser/dom/webserial/tests/browser/worker_open_close.js"
-    );
-    content.testWorker = worker;
-
-    await new Promise((resolve, reject) => {
-      worker.onmessage = e => {
-        if (e.data.type === "opened") {
-          resolve();
-        } else if (e.data.type === "error") {
-          reject(new Error(e.data.message));
-        }
-      };
-      worker.onerror = e => reject(new Error(e.message));
-      worker.postMessage("open");
-    });
-  });
+  await openPortFromWorker(gBrowser.selectedBrowser);
 
   info("Waiting for serial sharing icon to become visible");
   await TestUtils.waitForCondition(() => {
@@ -175,3 +209,217 @@ add_task(async function testSharingIconWithMultiplePorts() {
 
   BrowserTestUtils.removeTab(tab);
 });
+
+add_task(async function testSharingIconReopen() {
+  info("Test that the icon reappears when a port is closed and reopened");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await requestAndOpenPort(gBrowser.selectedBrowser);
+  await waitForIconVisible("Icon should be visible after first open");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.close();
+  });
+  await waitForIconHidden("Icon should be hidden after first close");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.open({ baudRate: 9600 });
+  });
+  await waitForIconVisible("Icon should be visible again after reopen");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.close();
+  });
+  await waitForIconHidden("Icon should be hidden after second close");
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingIconHiddenOnDeviceDisconnect() {
+  info("Test that the icon hides when an open port's device is unplugged");
+
+  const DEVICE_ID = "test-sharing-disconnect";
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [DEVICE_ID], async id => {
+    await content.navigator.serial.simulateDeviceConnection(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/webserial/tests/browser/browser_webserial_permission_revoke.js b/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
index d0accbefb62..28d9f7c8ac2 100644
--- a/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
+++ b/dom/webserial/tests/browser/browser_webserial_permission_revoke.js
@@ -1,11 +1,6 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
-ChromeUtils.defineESModuleGetters(this, {
-  SerialDeviceSharingHelper:
-    "moz-src:///browser/modules/SerialDeviceSharingHelper.sys.mjs",
-});
-
 const TEST_URL =
   "https://example.com/document-builder.sjs?html=<h1>Test serial permission revocation</h1>";
 
@@ -54,8 +49,9 @@ add_task(async function testPermissionRevocation() {
     gBrowser.selectedBrowser
   );
 
-  SerialDeviceSharingHelper.resetBrowserCount(gBrowser.selectedBrowser);
-  gBrowser.updateBrowserSharing(gBrowser.selectedBrowser, { serial: false });
+  // Unlike the permission panel's revoke button, don't reset the sharing
+  // count here: the icon must be hidden purely by the parent process
+  // observing the port close that forget() triggers.
 
   // Give time for observer to be registered (it happens async)
   await new Promise(resolve => setTimeout(resolve, 0));
@@ -96,9 +92,11 @@ add_task(async function testPermissionRevocation() {
     is(ports.length, 0, "getPorts() should return empty after revocation");
   });
 
-  let serialIcon = document.getElementById("serial-sharing-icon");
-  ok(
-    !BrowserTestUtils.isVisible(serialIcon),
+  await TestUtils.waitForCondition(
+    () =>
+      !BrowserTestUtils.isVisible(
+        document.getElementById("serial-sharing-icon")
+      ),
     "Serial sharing icon should not be visible after permission revocation"
   );
diff --git a/dom/webserial/tests/browser/browser_webserial_sharing_icon.js b/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
index c9ababa37bf..2e219006020 100644
--- a/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
+++ b/dom/webserial/tests/browser/browser_webserial_sharing_icon.js
@@ -3,6 +3,57 @@
 
 const TEST_URL =
   "https://example.com/document-builder.sjs?html=<h1>Test serial worker sharing icon</h1>";
+const BASE = "https://example.com/browser/dom/webserial/tests/browser/";
+const BLANK_URL = BASE + "blank.html";
+const WORKER_URL = BASE + "worker_open_close.js";
+
+function isIconVisible() {
+  return BrowserTestUtils.isVisible(
+    document.getElementById("serial-sharing-icon")
+  );
+}
+
+function waitForIconVisible(msg) {
+  return TestUtils.waitForCondition(isIconVisible, msg);
+}
+
+function waitForIconHidden(msg) {
+  return TestUtils.waitForCondition(() => !isIconVisible(), msg);
+}
+
+// Requests a port in the given browsing context (using autoselect, optionally
+// narrowed by filters) and opens it, keeping it reachable as content.testPort.
+async function requestAndOpenPort(bc, filters = []) {
+  await SpecialPowers.spawn(bc, [filters], async aFilters => {
+    content.navigator.serial.autoselectPorts = true;
+    SpecialPowers.wrap(content.document).notifyUserGestureActivation();
+    content.testPort = await content.navigator.serial.requestPort({
+      filters: aFilters,
+    });
+    await content.testPort.open({ baudRate: 9600 });
+  });
+}
+
+// Starts a worker in the given browser that opens the first granted port and
+// resolves once it reports the port is open. The worker is kept reachable as
+// content.testWorker.
+async function openPortFromWorker(browser) {
+  await SpecialPowers.spawn(browser, [WORKER_URL], async url => {
+    const worker = new content.Worker(url);
+    content.testWorker = worker;
+    await new Promise((resolve, reject) => {
+      worker.onmessage = e => {
+        if (e.data.type === "opened") {
+          resolve();
+        } else if (e.data.type === "error") {
+          reject(new Error(e.data.message));
+        }
+      };
+      worker.onerror = e => reject(new Error(e.message));
+      worker.postMessage("open");
+    });
+  });
+}
 
 add_setup(async function () {
   await SpecialPowers.pushPrefEnv({
@@ -36,24 +87,7 @@ add_task(async function testSharingIconAppearsFromWorker() {
   );
 
   // Spawn a worker and have it open the port
-  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
-    const worker = new content.Worker(
-      "https://example.com/browser/dom/webserial/tests/browser/worker_open_close.js"
-    );
-    content.testWorker = worker;
-
-    await new Promise((resolve, reject) => {
-      worker.onmessage = e => {
-        if (e.data.type === "opened") {
-          resolve();
-        } else if (e.data.type === "error") {
-          reject(new Error(e.data.message));
-        }
-      };
-      worker.onerror = e => reject(new Error(e.message));
-      worker.postMessage("open");
-    });
-  });
+  await openPortFromWorker(gBrowser.selectedBrowser);
 
   info("Waiting for serial sharing icon to become visible");
   await TestUtils.waitForCondition(() => {
@@ -175,3 +209,217 @@ add_task(async function testSharingIconWithMultiplePorts() {
 
   BrowserTestUtils.removeTab(tab);
 });
+
+add_task(async function testSharingIconReopen() {
+  info("Test that the icon reappears when a port is closed and reopened");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await requestAndOpenPort(gBrowser.selectedBrowser);
+  await waitForIconVisible("Icon should be visible after first open");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.close();
+  });
+  await waitForIconHidden("Icon should be hidden after first close");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.open({ baudRate: 9600 });
+  });
+  await waitForIconVisible("Icon should be visible again after reopen");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    await content.testPort.close();
+  });
+  await waitForIconHidden("Icon should be hidden after second close");
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingIconHiddenOnDeviceDisconnect() {
+  info("Test that the icon hides when an open port's device is unplugged");
+
+  const DEVICE_ID = "test-sharing-disconnect";
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [DEVICE_ID], async id => {
+    await content.navigator.serial.simulateDeviceConnection(
+      id,
+      "/dev/ttyUSB90",
+      0x1111,
+      0x2222
+    );
+    content.navigator.serial.autoselectPorts = true;
+    SpecialPowers.wrap(content.document).notifyUserGestureActivation();
+    content.testPort = await content.navigator.serial.requestPort({
+      filters: [{ usbVendorId: 0x1111, usbProductId: 0x2222 }],
+    });
+    await content.testPort.open({ baudRate: 9600 });
+  });
+  await waitForIconVisible("Icon should be visible while the port is open");
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [DEVICE_ID], async id => {
+    const disconnected = new Promise(resolve => {
+      content.testPort.addEventListener("disconnect", resolve, { once: true });
+    });
+    await content.navigator.serial.simulateDeviceDisconnection(id);
+    await disconnected;
+  });
+  await waitForIconHidden("Icon should be hidden after the device disconnects");
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingIconHiddenOnNavigation() {
+  info("Test that the icon hides when a page with an open port navigates away");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await requestAndOpenPort(gBrowser.selectedBrowser);
+  await waitForIconVisible("Icon should be visible while the port is open");
+
+  BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, BLANK_URL);
+  await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+  await waitForIconHidden("Icon should be hidden after navigating away");
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingIconHiddenOnWorkerTerminate() {
+  info("Test that the icon hides when a worker holding an open port is killed");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+    content.navigator.serial.autoselectPorts = true;
+    SpecialPowers.wrap(content.document).notifyUserGestureActivation();
+    await content.navigator.serial.requestPort();
+  });
+
+  await openPortFromWorker(gBrowser.selectedBrowser);
+  await waitForIconVisible(
+    "Icon should be visible while the worker's port is open"
+  );
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+    content.testWorker.terminate();
+  });
+  await waitForIconHidden(
+    "Icon should be hidden after terminating the worker without closing"
+  );
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingIconHiddenOnIframeRemoval() {
+  info(
+    "Test that a port opened in an iframe shows the icon and hides on removal"
+  );
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  let browser = gBrowser.selectedBrowser;
+
+  await SpecialPowers.spawn(browser, [BLANK_URL], async url => {
+    const iframe = content.document.createElement("iframe");
+    iframe.src = url;
+    const loaded = new Promise(resolve => {
+      iframe.addEventListener("load", resolve, { once: true });
+    });
+    content.document.body.appendChild(iframe);
+    await loaded;
+  });
+
+  // Granted ports are tracked per window, so the same-origin iframe requests
+  // and opens its own port. The sharing state must be attributed to the
+  // top-level browser.
+  let iframeBC = browser.browsingContext.children[0];
+  ok(iframeBC, "Iframe browsing context should exist");
+  await requestAndOpenPort(iframeBC);
+  await waitForIconVisible(
+    "Icon should be visible while the iframe's port is open"
+  );
+
+  await SpecialPowers.spawn(browser, [], () => {
+    content.document.querySelector("iframe").remove();
+  });
+  await waitForIconHidden("Icon should be hidden after the iframe is removed");
+
+  BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSharingStateIsPerTab() {
+  info("Test that closing a port in one tab does not affect another tab");
+
+  // Each tab must open a different device; a port can only be open once.
+  let tabA = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await requestAndOpenPort(tabA.linkedBrowser, [
+    { usbVendorId: 0x2341, usbProductId: 0x0043 },
+  ]);
+  await waitForIconVisible("Icon should be visible for tab A");
+
+  let tabB = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+  await requestAndOpenPort(tabB.linkedBrowser, [
+    { usbVendorId: 0x0403, usbProductId: 0x6002 },
+  ]);
+  await waitForIconVisible("Icon should be visible for tab B");
+
+  await SpecialPowers.spawn(tabA.linkedBrowser, [], async () => {
+    await content.testPort.close();
+  });
+  await TestUtils.waitForCondition(
+    () => !tabA.linkedBrowser._sharingState?.serial,
+    "Tab A sharing state should be cleared"
+  );
+  is(
+    tabB.linkedBrowser._sharingState?.serial,
+    "serial",
+    "Tab B sharing state should be unaffected"
+  );
+  ok(isIconVisible(), "Icon should remain visible for the selected tab B");
+
+  await BrowserTestUtils.switchTab(gBrowser, tabA);
... (truncated)
Loading diff…