Просмотр исходного кода

Write printer status straight through while the tab is hidden (#2754)

Removing the requestAnimationFrame wrapper fixed the total stall but left the
100ms coalescing timer in the path, and a hidden page's timers are clamped to
once a second at best -- once a minute past five minutes hidden. The reporter
still saw a tab title at 2% beside a page at 40%.

The coalescing guards against a render cascade, which a hidden tab cannot
have, so it is skipped there and kept while visible.

The existing hidden-tab tests advanced fake timers, which simulates the timer
the browser was throttling; the new one never advances the clock.
maziggy 1 месяц назад
Родитель
Сommit
3bbe00784f

+ 1 - 1
CHANGELOG.md

@@ -24,7 +24,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests.
 - **A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3)** — The Virtual Printer binds ports 990 and 322, both below 1024, which a service running as a normal user may not do without the `CAP_NET_BIND_SERVICE` capability. Without it the rest of Bambuddy works perfectly and only the Virtual Printer is dead: its sockets never open, the slicer never finds the printer, and the sole trace is one line in the journal. The reporter lost days to this before someone on Discord spotted the missing line. The install script has carried it since March, but the three other places that define the same service did not — the manual-install template, the combined Bambuddy plus SpoolBuddy installer, and the unit the wiki tells you to paste. All three have it now, and the wiki no longer claims the capability is always included when its own instructions omitted it. Bambuddy also diagnoses this itself: **Diagnose** on the virtual printer card previously reported only that nothing was listening on port 990, which reads identically to an ordinary port conflict. It now checks whether the process actually holds the capability and, when that is what is wrong, says so and gives the line to add. The check stays quiet when the port is answering, since fronting it another way (an iptables redirect is the documented alternative) is a legitimate setup, and it stays quiet when the capability is held, so a port that failed for some other reason is not misattributed. Existing installs are unaffected until reinstalled; the diagnostic tells you whether yours needs the line. Translated in all locales; wiki updated. Covered by backend tests.
 - **A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl)** — Configuring a slot publishes an `ams_filament_setting` command, and the printer answers it with a verdict. That answer was received and then thrown away at debug level, so a printer that refused the write left no trace at the log level support bundles are collected at. The reporter hit exactly that: six manual **Configure Slot** attempts on one X1C, every one returning success, every one read back by the #2582 verification as still holding the previous profile, and nothing anywhere to say what the printer had made of the command. A refusal is now logged with the printer's own `result` and `reason` alongside the AMS and tray it concerned. Only refusals are promoted — unlike the K-profile and drying commands this one is not rare, since every spool assignment and every K-profile re-apply sends one, and logging each acknowledgement would bury the line worth reading. The developer-mode probe is excluded as well: it sends this same command to the external slot specifically to watch it be refused on P1 firmware, so its failure is a measurement rather than a fault. Diagnostics only — nothing about which commands are sent or how they are built has changed. Covered by backend tests.
-- **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched to another tab and jumped straight to the current value the moment you switched back, which defeats the point of putting it in the title. The cause was not in the tab-title feature: every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all. Those callbacks are not slowed down, they are held — so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue that carries every non-status message, which stalled completely and accumulated messages until the tab came back. The animation frames were added alongside the real fix for a browser freeze on print completion — that fix was the batching, which is untouched; the frames only ever deferred each write by about a sixteenth of a second and are gone. One limit is worth knowing about and is the browser's rather than ours: browsers deliberately slow down timers in tabs you are not looking at, to roughly once a second, and to about once a minute once a tab has been hidden for five minutes. So the title keeps moving in the background, but on a tab left alone for a long time it steps rather than ticks. Covered by frontend tests that reproduce a hidden tab.
+- **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched away and jumped straight to the current value the moment you came back, which defeats the point of putting it in the title. There were two causes, and the first fix only got one of them. Every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all — those callbacks are not slowed down, they are held, so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue carrying every non-status message, which stalled completely. Removing the frames fixed that stall but not the report, because the write still went through a 100 ms timer that batches rapid updates — and a timer is exactly what a browser throttles in a tab you are not looking at, to roughly once a second, and to about once a minute once the tab has been hidden for five minutes. The reporter's screenshot showed a tab title reading 2% beside a page at 40%. That batching exists to stop a burst of messages causing a rendering pile-up, and a hidden tab is not rendering, so there is nothing to protect there: while the tab is hidden the value is now written straight through, and the batching still applies while you are looking at it. Worth knowing if you use Windows: a browser window completely covered by another window counts as hidden, not merely unfocused, which is why this could bite without ever switching tabs. Covered by frontend tests that reproduce a hidden tab, including one that never advances the clock — the earlier tests passed by simulating the very timer the browser was throttling.
 - **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests.
 - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
 - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.

+ 99 - 9
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -623,16 +623,23 @@ describe('useWebSocket hook', () => {
 
   /**
    * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
-   * front, and caught up all at once on switching back. The cache writes ran
-   * inside requestAnimationFrame, and a hidden tab gets no rendering
-   * opportunities — so the browser holds queued frame callbacks indefinitely
-   * rather than merely throttling them.
+   * front, and caught up all at once on switching back.
    *
-   * The stub below is what makes these tests meaningful: it hands back a
-   * handle and never invokes the callback, which is what a real hidden tab
-   * does. `document.hidden` is set alongside it to name the scenario, but the
-   * production code doesn't branch on visibility — it simply no longer defers
-   * to a frame. Reintroduce a rAF wrapper on either path and these fail.
+   * Two causes, fixed in two rounds. First the cache writes ran inside a
+   * requestAnimationFrame, and a hidden tab gets no rendering opportunities —
+   * the browser holds queued frame callbacks indefinitely rather than merely
+   * throttling them. The rAF stub below is what makes those tests meaningful:
+   * it hands back a handle and never invokes the callback, which is what a
+   * real hidden tab does.
+   *
+   * Removing the frame callback did not close the report, because the 100ms
+   * coalescing timer was still in the path and a hidden page's timers are
+   * clamped to at best once a second — once a minute past five minutes hidden.
+   * So the writes must not depend on a timer either while hidden, which is
+   * what `writes without waiting on a timer` pins down. Note it deliberately
+   * never advances the clock: a test that advances fake timers cannot tell a
+   * throttled timer from a prompt one, which is exactly why the original tests
+   * kept passing while the reporter's tab stayed frozen.
    */
   describe('hidden tab (#2754)', () => {
     let rafSpy: ReturnType<typeof vi.fn>;
@@ -690,6 +697,49 @@ describe('useWebSocket hook', () => {
       expect(rafSpy).not.toHaveBeenCalled();
     });
 
+    it('writes without waiting on a timer', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'printer_status',
+          printer_id: 1,
+          data: { state: 'RUNNING', progress: 42 },
+        });
+      });
+
+      // No advanceTimersByTime: a hidden tab's timers are throttled to once a
+      // second at best, so anything the title depends on has to have landed
+      // already. Reintroduce the coalescing timer on this path and the cache
+      // is still empty here.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
+        state: 'RUNNING',
+        progress: 42,
+      });
+    });
+
+    it('applies the newest value when several arrive before a frame would have run', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 40 } });
+        ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 41 } });
+      });
+
+      // Writing through per message must not resurrect an earlier one: the
+      // pending map is drained on each flush, so a stale entry cannot be
+      // re-applied over the newer value.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 41 });
+    });
+
     it('drains queued messages instead of wedging the queue', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -715,6 +765,46 @@ describe('useWebSocket hook', () => {
     });
   });
 
+  describe('visible tab still coalesces (#2754)', () => {
+    /**
+     * The counterpart to the hidden-tab block: the write-through is scoped to
+     * a hidden tab on purpose. A visible one is painting, and the 100ms window
+     * is what stops a burst of status messages turning into a render cascade —
+     * so "just always write through" is not the simplification it looks like.
+     */
+    it('defers the write while the tab is visible', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const client = new QueryClient({
+        defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+      });
+      vi.useFakeTimers();
+      try {
+        renderHook(() => useWebSocket(), { wrapper: createWrapper(client) });
+        const ws = await waitForWs();
+        act(() => ws.open());
+
+        act(() => {
+          ws.simulateMessage({
+            type: 'printer_status',
+            printer_id: 1,
+            data: { state: 'RUNNING', progress: 42 },
+          });
+        });
+
+        expect(client.getQueryData(['printerStatus', 1])).toBeUndefined();
+
+        await act(async () => {
+          vi.advanceTimersByTime(200);
+        });
+
+        expect(client.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 42 });
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+  });
+
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 54 - 30
frontend/src/hooks/useWebSocket.ts

@@ -194,45 +194,69 @@ export function useWebSocket() {
     wsRef.current = ws;
   }, [processMessageQueue]);
 
-  // Throttled printer status update - coalesces rapid updates per printer.
+  // Write every pending printer status into the query cache.
   //
-  // #2754: these cache writes used to happen inside a requestAnimationFrame.
-  // A hidden tab gets no rendering opportunities, so the browser *holds*
-  // queued frame callbacks rather than throttling them — every status update
-  // parked in a pending frame and nothing reached the query cache until the
-  // tab was shown again, at which point they all ran at once. That froze the
-  // tab-title progress (usePrintProgressTitle reads this key and nothing
-  // else) and stalled every other live view. The 100ms coalescing below is
-  // what prevented the original render cascade; the frame callback only ever
-  // deferred the write by a frame, so it is gone.
+  // Extracted so the hidden-tab path below can run it inline: both paths share
+  // this one body, so the merge semantics cannot drift apart. Cancels any
+  // scheduled coalescing timer, since everything it was going to write has
+  // just been written and re-running it would re-apply stale data over newer.
+  const flushPrinterStatus = useCallback(() => {
+    if (printerStatusTimeoutRef.current) {
+      clearTimeout(printerStatusTimeoutRef.current);
+      printerStatusTimeoutRef.current = null;
+    }
+
+    const updates = new Map(pendingPrinterStatus.current);
+    pendingPrinterStatus.current.clear();
+
+    updates.forEach((statusData, id) => {
+      queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
+        const merged = { ...old, ...statusData };
+        if (merged.wifi_signal == null && old?.wifi_signal != null) {
+          merged.wifi_signal = old.wifi_signal;
+        }
+        return merged;
+      });
+    });
+  }, [queryClient]);
+
+  // Printer status update — coalesced while the tab is visible, written
+  // straight through while it is not.
+  //
+  // #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
+  // requestAnimationFrame: a hidden tab gets no rendering opportunities, so
+  // the browser *holds* queued frame callbacks rather than throttling them,
+  // and nothing reached the cache until the tab was shown again. Removing the
+  // frame callback fixed that total stall but not the report, because a second
+  // timer-shaped dependency was left behind — this 100ms coalescing window.
+  //
+  // Browsers clamp timers in a hidden page to at best once a second, and drop
+  // pages hidden for more than five minutes to roughly one wake-up a minute.
+  // The reporter saw a tab title stuck at 2% beside a page at 40%.
+  //
+  // The coalescing exists to stop rapid messages triggering a render cascade.
+  // A hidden tab is not painting, so there is no cascade to prevent there —
+  // the timer is pure cost, and it is exactly the thing being throttled. So
+  // when hidden, skip it and write immediately.
+  //
+  // Note "hidden", not "unfocused": on Windows a fully-occluded window reports
+  // visibilityState 'hidden' too, which is why the reporter saw this from
+  // merely clicking away rather than only from switching tabs.
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
     pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
 
+    if (document.hidden) {
+      flushPrinterStatus();
+      return;
+    }
+
     // Schedule update if not already scheduled
     if (!printerStatusTimeoutRef.current) {
-      printerStatusTimeoutRef.current = window.setTimeout(() => {
-        const updates = new Map(pendingPrinterStatus.current);
-        pendingPrinterStatus.current.clear();
-        printerStatusTimeoutRef.current = null;
-
-        // Apply all pending updates
-        updates.forEach((statusData, id) => {
-          queryClient.setQueryData(
-            ['printerStatus', id],
-            (old: Record<string, unknown> | undefined) => {
-              const merged = { ...old, ...statusData };
-              if (merged.wifi_signal == null && old?.wifi_signal != null) {
-                merged.wifi_signal = old.wifi_signal;
-              }
-              return merged;
-            }
-          );
-        });
-      }, 100); // Update at most every 100ms
+      printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
     }
-  }, [queryClient]);
+  }, [flushPrinterStatus]);
 
   // Debounced invalidation helper - coalesces multiple rapid invalidations
   const debouncedInvalidate = useCallback((queryKey: string) => {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Cy2mY2rf.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-BAVjF7qG.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cy2mY2rf.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов