Преглед изворни кода

fix(virtual-printer): cert-renewal restart regression + clipboard leak

  1. `_cancel_restart_task` self-await guard (manager.py:389-413).
     stop_server() / stop_proxy() are called from inside
     _restart_for_cert_renewal, which runs AS _cert_restart_task.
     Cancelling+awaiting self flagged a CancelledError on the next
     `await` in stop_server, tearing down old listeners but never
     letting start_server run — the VP sat on the expired cert
     until the process was manually restarted, silently defeating
     auto-renewal. Skip when `task is asyncio.current_task()` and
     just clear the reference.

  2. Clipboard fallback textarea leak (VirtualPrinterCard.tsx:66-81).
     The HTTP fallback created a hidden textarea, called
     select() + execCommand('copy'), then removed the textarea.
     If select() or execCommand threw, removal never ran and the
     textarea leaked into the DOM. Move the removal into `finally`
     so it happens regardless of the inner block's outcome.

  Regression tests in test_tailscale.py::TestCancelRestartTaskSelfAwait
  cover both the self-cancel path (must NOT cancel self) and the
  outside-cancel path (must still cancel and await).
maziggy пре 1 месец
родитељ
комит
c0b6010269

+ 20 - 5
backend/app/services/virtual_printer/manager.py

@@ -387,16 +387,31 @@ class VirtualPrinterInstance:
             self._cert_renewal_task = None
 
     async def _cancel_restart_task(self) -> None:
-        """Cancel the cert restart task and await its completion."""
-        if self._cert_restart_task and not self._cert_restart_task.done():
-            self._cert_restart_task.cancel()
+        """Cancel the cert restart task and await its completion.
+
+        Skip when the caller IS the restart task itself — stop_server() /
+        stop_proxy() are called from inside _restart_for_cert_renewal,
+        which runs AS _cert_restart_task. Cancelling + awaiting self
+        flags a CancelledError on the next `await` in stop_server,
+        which tears down the old listeners but never lets start_server
+        run — the VP would sit on an expired cert until process restart.
+        """
+        task = self._cert_restart_task
+        if task is asyncio.current_task():
+            # Renewal path cleaning up its own restart task: clear the
+            # reference so future callers don't see a stale task handle,
+            # but do NOT cancel-and-await ourselves.
+            self._cert_restart_task = None
+            return
+        if task and not task.done():
+            task.cancel()
             try:
-                await self._cert_restart_task
+                await task
             except asyncio.CancelledError:
                 pass
             except Exception as e:
                 logger.warning("[VP %s] Unexpected error in cert restart task: %s", self.name, e)
-        self._cert_restart_task = None
+            self._cert_restart_task = None
 
     async def _restart_for_cert_renewal(self) -> None:
         """Restart VP services to load the newly renewed Tailscale cert into TLS listeners."""

+ 67 - 0
backend/tests/unit/services/test_tailscale.py

@@ -760,3 +760,70 @@ class TestCertRenewalLoop:
             await task
 
         assert restart_scheduled[0] is True
+
+
+class TestCancelRestartTaskSelfAwait:
+    """Regression: _cancel_restart_task must not await the current task.
+
+    stop_server() / stop_proxy() are called from inside _restart_for_cert_renewal,
+    which runs AS _cert_restart_task. Cancelling+awaiting self would flag a
+    CancelledError on the next `await`, tearing down the old listeners but
+    never letting start_server run — the VP would stay on the old/expired cert
+    until the process restarts.
+    """
+
+    def _make_instance(self, tmp_path):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        return VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            tailscale_disabled=False,
+            base_dir=tmp_path,
+        )
+
+    @pytest.mark.asyncio
+    async def test_cancel_from_inside_own_task_does_not_cancel_self(self, tmp_path):
+        """When _cancel_restart_task is called from inside the restart task itself,
+        it clears the reference without cancelling — subsequent awaits must succeed."""
+        instance = self._make_instance(tmp_path)
+        completed_to_end = [False]
+
+        async def fake_restart():
+            # Simulate stop_server calling _cancel_restart_task from inside the restart task.
+            await instance._cancel_restart_task()
+            # If _cancel_restart_task had self-awaited, the next `await` would raise
+            # CancelledError and this line would never be reached.
+            await asyncio.sleep(0)
+            completed_to_end[0] = True
+
+        task = asyncio.create_task(fake_restart(), name="cert_restart")
+        instance._cert_restart_task = task
+        await task
+        assert completed_to_end[0] is True
+        assert instance._cert_restart_task is None
+
+    @pytest.mark.asyncio
+    async def test_cancel_from_outside_still_cancels_and_awaits(self, tmp_path):
+        """Non-self callers must retain the original cancel-and-await behaviour."""
+        instance = self._make_instance(tmp_path)
+        started = asyncio.Event()
+
+        async def long_restart():
+            started.set()
+            try:
+                await asyncio.sleep(10)
+            except asyncio.CancelledError:
+                raise
+
+        task = asyncio.create_task(long_restart(), name="cert_restart")
+        instance._cert_restart_task = task
+        await started.wait()
+        # Cancel from an outside coroutine — this should actually cancel the task.
+        await instance._cancel_restart_task()
+        assert task.cancelled()
+        assert instance._cert_restart_task is None

+ 30 - 25
frontend/src/components/VirtualPrinterCard.tsx

@@ -65,17 +65,18 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     }
     // Legacy fallback for HTTP (common when Bambuddy is reached over LAN / tailnet IP).
     if (!ok) {
+      const ta = document.createElement('textarea');
+      ta.value = fqdn;
+      ta.style.position = 'fixed';
+      ta.style.opacity = '0';
+      document.body.appendChild(ta);
       try {
-        const ta = document.createElement('textarea');
-        ta.value = fqdn;
-        ta.style.position = 'fixed';
-        ta.style.opacity = '0';
-        document.body.appendChild(ta);
         ta.select();
         ok = document.execCommand('copy');
-        document.body.removeChild(ta);
       } catch {
         ok = false;
+      } finally {
+        if (ta.parentNode) ta.parentNode.removeChild(ta);
       }
     }
     if (ok) {
@@ -257,23 +258,6 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
           {localRemoteInterfaceIp && (
             <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
           )}
-          {printer.status?.tailscale_fqdn && (
-            <span className="flex items-center gap-1 text-xs text-green-400/70 flex-shrink-0">
-              <ShieldCheck className="w-3 h-3" />
-              <span className="font-mono text-[10px]">{printer.status.tailscale_fqdn}</span>
-              <button
-                onClick={handleCopyFqdn}
-                className="p-0.5 rounded hover:bg-bambu-dark-tertiary transition-colors"
-                title={fqdnCopied ? t('printers.copied') : t('printers.copyToClipboard')}
-              >
-                {fqdnCopied ? (
-                  <Check className="w-3 h-3 text-bambu-green" />
-                ) : (
-                  <Copy className="w-3 h-3" />
-                )}
-              </button>
-            </span>
-          )}
           <div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
             <button
               onClick={handleToggleEnabled}
@@ -306,16 +290,37 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
                 onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
                 className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
               />
-              <span className="text-xs text-bambu-gray font-mono">{printer.serial}</span>
               <button
                 onClick={() => setShowDeleteConfirm(true)}
-                className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors"
+                className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
                 title={t('common.delete')}
               >
                 <Trash2 className="w-4 h-4" />
               </button>
             </div>
 
+            {/* Tailscale FQDN (when active) + serial — compact info row */}
+            <div className="flex items-center gap-2 -mt-2">
+              {printer.status?.tailscale_fqdn && (
+                <span className="flex items-center gap-1 text-green-400/70 min-w-0">
+                  <ShieldCheck className="w-3.5 h-3.5 flex-shrink-0" />
+                  <span className="font-mono text-xs truncate">{printer.status.tailscale_fqdn}</span>
+                  <button
+                    onClick={handleCopyFqdn}
+                    className="p-0.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
+                    title={fqdnCopied ? t('printers.copied') : t('printers.copyToClipboard')}
+                  >
+                    {fqdnCopied ? (
+                      <Check className="w-3.5 h-3.5 text-bambu-green" />
+                    ) : (
+                      <Copy className="w-3.5 h-3.5" />
+                    )}
+                  </button>
+                </span>
+              )}
+              <span className="text-xs text-bambu-gray font-mono ml-auto flex-shrink-0">{printer.serial}</span>
+            </div>
+
             {/* Mode */}
             <div>
               <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -3871,6 +3871,10 @@ export default {
       title: 'Lancement automatique',
       description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',
     },
+    tailscaleDisabled: {
+      title: 'Intégration Tailscale',
+      description: 'Lorsqu\'activé, utilise Tailscale pour des certificats TLS de confiance. Désactiver pour n\'utiliser que des certificats auto-signés.',
+    },
     setupRequired: {
       title: 'Configuration requise',
       description: 'Nécessite des réglages système (ports, pare-feu).',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -3870,6 +3870,10 @@ export default {
       title: 'Avvio automatico',
       description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
     },
+    tailscaleDisabled: {
+      title: 'Integrazione Tailscale',
+      description: 'Quando abilitato, utilizza Tailscale per certificati TLS affidabili. Disabilita per utilizzare solo certificati auto-firmati.',
+    },
     setupRequired: {
       title: 'Configurazione necessaria',
       description: 'La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -3909,6 +3909,10 @@ export default {
       title: '自動ディスパッチ',
       description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
     },
+    tailscaleDisabled: {
+      title: 'Tailscale統合',
+      description: '有効にすると、Tailscaleを使用して信頼できるTLS証明書を使用します。自己署名証明書のみを使用する場合は無効にします。',
+    },
     setupRequired: {
       title: 'セットアップが必要です',
       description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3884,6 +3884,10 @@ export default {
       title: 'Envio automático',
       description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',
     },
+    tailscaleDisabled: {
+      title: 'Integração Tailscale',
+      description: 'Quando ativado, usa Tailscale para certificados TLS confiáveis. Desative para usar apenas certificado autoassinado.',
+    },
     setupRequired: {
       title: 'Configuração Necessária',
       description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3936,6 +3936,10 @@ export default {
       title: '自动派发',
       description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
     },
+    tailscaleDisabled: {
+      title: 'Tailscale 集成',
+      description: '启用后,使用 Tailscale 获取受信任的 TLS 证书。禁用则仅使用自签名证书。',
+    },
     setupRequired: {
       title: '需要设置',
       description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3936,6 +3936,10 @@ export default {
       title: '自動派發',
       description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
     },
+    tailscaleDisabled: {
+      title: 'Tailscale 整合',
+      description: '啟用後,使用 Tailscale 取得受信任的 TLS 憑證。停用則僅使用自簽憑證。',
+    },
     setupRequired: {
       title: '需要設定',
       description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-C_WG2aox.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BcG_mHJy.js"></script>
+    <script type="module" crossorigin src="/assets/index-C_WG2aox.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DGaysySO.css">
   </head>
   <body>

Неке датотеке нису приказане због велике количине промена