ソースを参照

fix(slice): report a finished slice once, not once per queued poll

setInterval does not await an async callback. Slicing a large project
blocks the backend for seconds, so poll ticks piled up behind one stalled
request, each holding a snapshot taken while the job was still active.
They resolved together, and every one of them ran the completion path —
one toast and two query invalidations each. A 20s stall against the 1.5s
interval produced 13 "Sliced X" toasts from a single slice.

Only one poll round is now in flight at a time, which also stops queueing
requests against a backend that is already saturated. Completion is
recorded once per job id, and a round still awaiting a response when the
effect tears down now returns instead of acting.
maziggy 1 ヶ月 前
コミット
83142c726c

+ 2 - 0
CHANGELOG.md

@@ -24,6 +24,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest.
 
 ### Fixed
+- **A finished slice produced a stream of a dozen "Sliced ..." notifications** — One slice reported itself complete over and over, a notification every second and a half for as long as twenty seconds. **Root cause.** While a slice runs, Bambuddy asks the server how it is getting on every 1.5 seconds — but it never waited for an answer before asking again. Slicing a large project keeps the server busy for seconds at a time, so those questions piled up unanswered, each one still believing the job was running. When the server caught up it answered all of them at once, and every single answer was treated as the moment the slice finished: one notification each, one list refresh each. The bigger the project, the longer the pile and the more notifications. **Fix.** Bambuddy now waits for an answer before asking the next question, so nothing can pile up and a busy server isn't asked to do more work while it is already behind. A job's completion is also recorded once and only once, and a check that was already in flight when the tracker restarts now stops instead of finishing its work — either of which is enough on its own to keep a duplicate off the screen. Covered by tests for a server stalled across many intervals, for two slices finishing where one restarts the tracker, and for the same job being tracked twice in a row still reporting both times.
+- **Slicing a single-plate project failed on filament slots the plate never prints with (#2711, reporter @kpp39, also seen by @phi-schi)** — Sending a MakerWorld project to the slicer was rejected with "filament preset ... (slot 1) is not compatible with printer ...", naming a slot the model doesn't use, and the slice modal deliberately locks the dropdowns for unused slots so there was no way to correct it by hand. **Root cause.** A project can declare more filaments than any one plate paints with — the reported model declares four and uses one — and the slicer validates every filament it is handed, not just the ones the print touches. Bambuddy already rewrote those unused entries to match a slot the plate really uses, but only when the plate number was part of the request. The modal omits it for single-plate projects, since there is no plate to choose, so the rewrite never ran for them — which is every model imported from MakerWorld. The three idle slots therefore arrived carrying whatever the source file had baked in, in this case profiles for an entirely different printer, and the slicer refused the job on the first one. **Fix.** A missing plate number now means the first plate, which is what it means everywhere else in the slicing path, so single-plate projects get the same treatment multi-plate ones already had. **Also fixed:** "Slice all plates" reached the same code, where it isn't a plate number at all. On a project with a dedicated support filament that combination could rewrite every colour to the support material and silently slice a multi-colour model in one filament — it is now excluded, since across all plates no slot is unused. Covered by tests for a single-plate slice with no plate number in the request, for slice-all leaving every slot untouched, and for the support-filament case specifically.
 - **A database hiccup during dispatch could leave a queue item stuck and the next print of that file filed under the wrong archive** — When PostgreSQL briefly refused a connection in the middle of dispatching a queue item, the dispatch failed part-way through and left two things behind. **Root cause.** Bambuddy tells itself to expect a print just *before* it sends the print command, because the printer can report the job before the send even returns — but nothing undid that expectation when the command was never sent. The entry does expire after two hours, which is far longer than it takes to react to a failed job by pressing print again: that reprint was folded into the old archive and inherited its filament mapping and plate instead of getting a fresh one. Separately, releasing the row's dispatch lock is best-effort and needed the same database that had just refused, so it gave up after one attempt and the item stayed invisible to the scheduler until a restart. Nothing was ever sent to the printer — the failure happens before the print command — so this cost a stuck item, not a wrong print. **Fix.** An expectation is now withdrawn whenever the print command doesn't go out, which also covers two cases that were silently leaking before: a job cancelled during dispatch, and a print command the printer rejects. The dispatch lock is retried rather than abandoned after one try, and any lock left behind is released on the next quiet moment instead of surviving until a restart. Covered by tests for the withdrawal being an exact inverse of the registration, for a confirmed print keeping its expectation, for two dispatches not disturbing each other, and for the lock recovering from both a brief and a sustained database outage.
 - **Bambuddy can be configured to want more database connections than PostgreSQL will give it** — The connection pool's own ceiling is 100 per worker process by default, while a stock PostgreSQL allows 100 in total and reserves 3 of those for administrators. Nothing checked the two against each other, so the mismatch only appeared as a failure somewhere unrelated once the connections ran out — which is how the dispatch failure above happened. **Fix.** Bambuddy now compares the two at startup and, when the pool could ask for more than the server allows, logs a warning naming both numbers, how many connections are already open, and the settings to change. Both figures are also included in the support bundle, so it is possible to tell a misconfigured limit apart from connections being held too long. Nothing is adjusted automatically: the pool is sized before any connection exists to ask the server with, and the right ceiling depends on how many worker processes you run and what else shares the server. Unchanged on SQLite, which has no such limit, and a server that declines the question is ignored rather than delaying startup. Covered by tests for the warning content, the reserved-slot arithmetic, the values reported to the bundle, and startup surviving a refused probe.
 - **An unusable layer number from a printer could drop its connection (#2702 follow-up)** — Reading the current layer from a status message assumed it would always be a number. Anything else raised an error out of the routine that reads those messages, and nothing above that point catches it, so the connection's listener stopped and the printer looked silent until the staleness check rebuilt it — losing not just the layer number but the print-start and print-finished detection carried in the same message. An unusable value is now ignored, holding the last known layer rather than substituting zero, which the firmware uses to signal a cancellation. This is the same containment applied to the layer *total* in this release, three lines away in the same routine. Covered by tests.

+ 18 - 2
backend/app/api/routes/library.py

@@ -3761,10 +3761,26 @@ async def _run_slicer_with_fallback(
     # with printer …" (#2628). Replace unused-slot entries with the
     # plate's lowest used slot before the real slice so the loaded set is
     # materially homogeneous and printer-correct.
-    if is_3mf and request.plate is not None:
+    #
+    # ``plate`` is absent for single-plate and STL sources — the SliceModal
+    # skips the picker and omits the field — and absent means plate 1, the
+    # same reading as ``plate_num`` further down and as the schema's own
+    # description. Treating it as "unknown plate" instead is what left every
+    # single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
+    # four filaments but painting only one reached the CLI with the other
+    # three still holding presets baked into the source for a different
+    # printer, and the slice died on the first of them.
+    #
+    # ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
+    # by some plate, so there is nothing to substitute. It has to be excluded
+    # explicitly because the support-filament slots unioned in below are
+    # read from the project config and are not plate-scoped — they would
+    # survive the (empty) geometry lookup for plate 0 and become the anchor,
+    # collapsing every colour of a slice-all onto the support filament.
+    if is_3mf and request.plate != 0:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
-        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
+        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
     # Cross-class slice-all loop (#1493): when the user asks for
     # ``plate=0`` (all plates) AND the source's nozzle class differs from

+ 15 - 2
backend/app/services/slicer_3mf_convert.py

@@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
     doesn't even use.
 
     The substitution is a no-op when:
-    - ``plate_id`` is None (we can't determine which slots are unused),
+    - ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
+      ``0`` (the slice-all sentinel, where every slot is used by *some*
+      plate so there is nothing unused to substitute). Callers that know
+      "absent means plate 1" must resolve that themselves before calling;
+      this function will not guess, because guessing wrong rewrites a
+      filament the plate actually prints with.
     - the source isn't a valid 3MF / zip,
     - the source doesn't carry plate-extruder metadata (parse returns
       empty set — treat as "every slot is used", same fallback the
       SliceModal uses),
     - ``items`` has fewer than 2 entries (nothing to substitute).
+
+    The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
+    1-indexed, so the geometry lookup for plate 0 matches nothing — but
+    the support-filament slots unioned in below come from the project
+    config and carry no plate scope at all. Without the guard a slice-all
+    of a project with a dedicated support slot would see ``used`` as just
+    that one slot, anchor on it, and rewrite every colour in the project
+    to the support material (#2711).
     """
-    if plate_id is None or len(items) < 2:
+    if plate_id is None or plate_id < 1 or len(items) < 2:
         return items
     # Local import keeps the bytes->ZipFile boundary in this module and
     # avoids dragging zipfile into every caller.

+ 220 - 0
backend/tests/integration/test_library_slice_api.py

@@ -1626,3 +1626,223 @@ class TestNozzleClassGuard:
         if resp.status_code == 400:
             detail = resp.json().get("detail", "")
             assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
+
+
+class TestUnusedSlotSubstitutionOnSinglePlateSource:
+    """#2711: a single-plate 3MF must still get its unused slots substituted.
+
+    The SliceModal omits ``plate`` entirely for single-plate and STL sources —
+    it skips the plate picker, so ``selectedPlate`` stays null and the field
+    never reaches the body. The schema documents an absent plate as "plate 1",
+    but the substitution used to read it as "unknown plate" and skip, so every
+    single-plate project reached the CLI with the dropdown values of slots the
+    plate never paints with.
+
+    In the reported case that was a MakerWorld project declaring four filaments
+    while plate 1 paints with one, the other three carrying presets baked into
+    the source for a different printer. The CLI rejected the whole slice with
+    "filament preset ... (slot 1) is not compatible with printer ...", and the
+    modal disables unused rows so there was no way to correct it by hand.
+    """
+
+    @staticmethod
+    def _single_plate_using_only_slot_3() -> bytes:
+        """One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
+        declared by the project but unused. Mirrors the reported file."""
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="3"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/>'
+                '<metadata key="instance_id" value="0"/></model_instance>'
+                "</plate></config>",
+            )
+        return buf.getvalue()
+
+    @staticmethod
+    def _filament_names_sent(body: bytes) -> list[str]:
+        """Pull the ``name`` of each ``filamentProfile`` part, in slot order.
+
+        ``slice_model`` sends one repeated ``filamentProfile`` part per slot as
+        ``filament_N.json``; the parts stay in submission order, so a plain
+        scan preserves the slot mapping.
+        """
+        names: list[str] = []
+        marker = b'name="filamentProfile"; filename="filament_'
+        pos = body.find(marker)
+        while pos != -1:
+            start = body.find(b"{", pos)
+            end = body.find(b"\r\n", start)
+            names.append(json.loads(body[start:end].decode("utf-8"))["name"])
+            pos = body.find(marker, end)
+        return names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unused_slots_are_substituted_when_the_body_omits_plate(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        tmp_path = slice_test_setup["tmp_path"]
+        src = tmp_path / "library" / "files" / "train.3mf"
+        src.write_bytes(self._single_plate_using_only_slot_3())
+        threemf = LibraryFile(
+            filename="train.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        # Four distinguishable filament presets, one per project slot. Only
+        # slot 3's is compatible with the target in the reported scenario.
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                # No "plate" — exactly what the modal sends for a single-plate
+                # source. This is the whole point of the test.
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        # Every slot carries slot 3's profile: the array length stays intact
+        # (the source's per-slot references depend on it) while nothing the
+        # plate doesn't print with can fail the CLI's validators.
+        assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
+        """``plate=0`` is the all-plates sentinel, so nothing is unused.
+
+        It reaches the same call site, and plate ids are 1-indexed — the
+        geometry lookup for plate 0 matches nothing. Without an explicit
+        exclusion the project's support-filament slot would be the only
+        member of the used set and would be copied over every colour.
+        """
+        tmp_path = slice_test_setup["tmp_path"]
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "enable_support": "1",
+                        "support_filament": "4",
+                        "support_interface_filament": "4",
+                        "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+                    }
+                ),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="1"/></object>'
+                '<object id="2"><metadata key="extruder" value="2"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
+                '<plate><metadata key="plater_id" value="2"/>'
+                '<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
+                "</config>",
+            )
+        src = tmp_path / "library" / "files" / "multi.3mf"
+        src.write_bytes(buf.getvalue())
+        threemf = LibraryFile(
+            filename="multi.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                "plate": 0,
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]

+ 50 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -443,3 +443,53 @@ class TestSubstituteUnusedPlateFilaments:
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
 
         assert result == ["pla.json", "pla.json", "pva.json"]
+
+    # ---- #2711: plate 0 is the slice-all sentinel, not a plate ----------
+
+    def test_no_op_for_the_slice_all_sentinel(self):
+        """``plate=0`` means every plate, so every slot is used by something."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
+        items = ["pla_white.json", "pla_red.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items
+
+    def test_slice_all_is_not_collapsed_onto_the_support_filament(self):
+        """The guard that makes the plate-0 no-op load-bearing.
+
+        Geometry lookup for plate 0 matches nothing (plates are 1-indexed),
+        but the support-filament union reads project settings and has no
+        plate scope — so it survives as the *only* member of the used set.
+        Anchored on it, a slice-all would rewrite every colour in the
+        project to the support material and print the whole thing in PVA.
+        """
+        model_settings = self._model_settings_xml([(1, [1]), (2, [2]), (3, [3])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "4",
+                "support_interface_filament": "4",
+                "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["white.json", "red.json", "blue.json", "pva.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items, "slice-all collapsed the project onto the support filament"
+
+    def test_negative_plate_id_is_a_no_op(self):
+        """Not reachable through the schema (``ge=0``), but the function is the
+        thing that must not guess — a caller resolving a plate wrongly should
+        get the user's picks back, not a rewrite anchored on nothing."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
+        items = ["a.json", "b.json"]
+
+        assert substitute_unused_plate_filaments(zip_bytes, plate_id=-1, items=items) == items

+ 185 - 0
frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx

@@ -482,3 +482,188 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
     expect(screen.queryByText(/%/)).toBeNull();
   });
 });
+
+describe('SliceJobTrackerProvider — one completion per job', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  /** A QueryClient whose invalidateQueries is counted. completeJob calls it
+   * twice (library-files + archives), so the counter divided by two is the
+   * number of times the terminal state was handled — a direct count that
+   * doesn't depend on how long a transient toast happens to stay on screen. */
+  function countingWrapper(counter: { n: number }) {
+    return function Counting({ children }: { children: ReactNode }) {
+      const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+      const original = queryClient.invalidateQueries.bind(queryClient);
+      queryClient.invalidateQueries = ((...args: Parameters<typeof original>) => {
+        counter.n += 1;
+        return original(...args);
+      }) as typeof queryClient.invalidateQueries;
+      return (
+        <QueryClientProvider client={queryClient}>
+          <ToastProvider>
+            <SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
+          </ToastProvider>
+        </QueryClientProvider>
+      );
+    };
+  }
+
+  it('handles completion once when the backend stalls for many poll intervals', async () => {
+    // The reported symptom: a single slice produced a stream of "Sliced X"
+    // toasts, more than ten of them, arriving one poll interval apart.
+    //
+    // setInterval does not await an async callback. Slicing a large project
+    // blocks the backend for seconds (zip parsing and output assembly are
+    // synchronous), so poll ticks piled up behind one stalled request —
+    // each holding a snapshot taken while the job was still active. When
+    // the backend recovered they all resolved 'completed' at once and each
+    // one ran the completion path. A 20s stall against the 1.5s interval
+    // stacks ~14 of them, which is the observed magnitude.
+    const STALL_MS = 20_000;
+    let polls = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      polls += 1;
+      await new Promise((resolve) => setTimeout(resolve, STALL_MS));
+      return {
+        job_id: 30,
+        status: 'completed',
+        kind: 'library_file',
+        source_id: 300,
+        source_name: 'Stalled.3mf',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={30} name="Stalled.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-30').click();
+    });
+
+    // Well past the stall, so every tick that could have piled up has had
+    // its chance to resolve.
+    for (let i = 0; i < 200; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    expect(counter.n / 2).toBe(1);
+    // The in-flight guard also has to stop the pile-up itself, not just its
+    // visible consequence: a stalled backend must not be handed a fresh
+    // request every 1.5s. One poll starts, one more can start after it
+    // resolves and the job is already gone.
+    expect(polls).toBeLessThanOrEqual(2);
+  });
+
+  it('handles the slower of two jobs once when the first one restarts the poller', async () => {
+    // The in-flight guard alone does not cover this. Two jobs are tracked;
+    // the first completes while the second's request is still open. That
+    // completion changes the tracked count, so the polling effect tears
+    // down and starts a fresh interval — with its own in-flight flag —
+    // while the previous round is still parked on the second job's await.
+    // Both rounds then see 'completed' for it, and the job reports itself
+    // twice unless the abandoned round notices it was cancelled or the
+    // completion path refuses the repeat. Either guard alone closes this;
+    // both are kept, so removing one still passes and removing both fails.
+    mockApi.getSliceJob.mockImplementation(async (id: number) => {
+      const base = {
+        kind: 'library_file' as const,
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+      if (id === 40) {
+        return { ...base, job_id: 40, status: 'completed', source_id: 400, source_name: 'Fast.3mf' };
+      }
+      await new Promise((resolve) => setTimeout(resolve, 6000));
+      return { ...base, job_id: 41, status: 'completed', source_id: 401, source_name: 'Slow.3mf' };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={40} name="Fast.3mf" />
+        <TrackTrigger id={41} name="Slow.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-40').click();
+      screen.getByText('track-41').click();
+    });
+
+    for (let i = 0; i < 100; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    // Exactly two completions: one per job, neither repeated.
+    expect(counter.n / 2).toBe(2);
+  });
+
+  it('still completes a job tracked again under the same id', async () => {
+    // The finished-id set must not turn into a permanent block list: a
+    // re-tracked id has to reach the completion path again.
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 32,
+      status: 'completed',
+      kind: 'library_file',
+      source_id: 302,
+      source_name: 'Again.3mf',
+      created_at: new Date().toISOString(),
+      started_at: new Date().toISOString(),
+      completed_at: new Date().toISOString(),
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={32} name="Again.3mf" />
+      </Counting>,
+    );
+
+    for (let round = 0; round < 2; round += 1) {
+      act(() => {
+        screen.getByText('track-32').click();
+      });
+      for (let i = 0; i < 3; i += 1) {
+        await act(async () => {
+          vi.advanceTimersByTime(1500);
+        });
+        await act(async () => {
+          await Promise.resolve();
+          await Promise.resolve();
+        });
+      }
+    }
+
+    expect(counter.n / 2).toBe(2);
+  });
+});

+ 47 - 15
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -85,6 +85,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
   const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
 
+  // Job ids whose terminal state has already been handled. `completeJob`
+  // shows a toast and invalidates two query keys, so it has to be exactly
+  // once per job no matter how many callers reach it — see the poll loop
+  // below for how more than one used to.
+  const finishedRef = useRef<Set<number>>(new Set());
+
   const renderProgressToast = useCallback(
     (job: TrackedJob) => {
       const startedAt = startedAtRef.current.get(job.id);
@@ -151,6 +157,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const trackJob = useCallback(
     (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
+      // Re-tracking an id re-arms it. Ids come from a database sequence so
+      // this can't collide in practice; clearing here is what keeps the set
+      // from being a permanent record of every job the session ever saw.
+      finishedRef.current.delete(id);
       startedAtRef.current.set(id, Date.now());
       phaseRef.current.set(id, 'pending');
       progressRef.current.set(id, null);
@@ -163,6 +173,11 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
 
   const completeJob = useCallback(
     (job: TrackedJob, state: SliceJobState) => {
+      // Guard, not an optimisation: everything below is a side effect the
+      // user sees, and a second call would repeat all of it.
+      if (finishedRef.current.has(job.id)) return;
+      finishedRef.current.add(job.id);
+
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
       startedAtRef.current.delete(job.id);
       phaseRef.current.delete(job.id);
@@ -201,24 +216,41 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   useEffect(() => {
     if (activeJobs.length === 0) return;
     let cancelled = false;
+    // setInterval does not await an async callback, so a tick fires whether
+    // or not the previous one came back. Slicing a large project blocks the
+    // backend for seconds at a time (zip parsing and output assembly are
+    // synchronous), and every tick that piled up during the stall had
+    // already captured a snapshot naming the job as active. They all
+    // resolved `completed` together and each called completeJob, which is
+    // how one slice produced a stream of a dozen "Sliced X" toasts. Letting
+    // only one poll round be in flight fixes that at the source, and stops
+    // queueing requests against a backend that is already saturated.
+    let polling = false;
     const interval = setInterval(async () => {
-      if (cancelled) return;
-      const snapshot = [...activeJobsRef.current];
-      for (const job of snapshot) {
-        try {
-          const state = await api.getSliceJob(job.id);
-          phaseRef.current.set(job.id, state.status);
-          // Capture the latest progress snapshot if the sidecar fed
-          // one through. The 1s tick re-renders the toast off this ref.
-          if (state.progress) {
-            progressRef.current.set(job.id, state.progress);
-          }
-          if (state.status === 'completed' || state.status === 'failed') {
-            completeJob(job, state);
+      if (cancelled || polling) return;
+      polling = true;
+      try {
+        const snapshot = [...activeJobsRef.current];
+        for (const job of snapshot) {
+          try {
+            const state = await api.getSliceJob(job.id);
+            // The tracker may have been torn down while this was in flight.
+            if (cancelled) return;
+            phaseRef.current.set(job.id, state.status);
+            // Capture the latest progress snapshot if the sidecar fed
+            // one through. The 1s tick re-renders the toast off this ref.
+            if (state.progress) {
+              progressRef.current.set(job.id, state.progress);
+            }
+            if (state.status === 'completed' || state.status === 'failed') {
+              completeJob(job, state);
+            }
+          } catch {
+            // Transient poll failure — stay tracked, retry next tick.
           }
-        } catch {
-          // Transient poll failure — stay tracked, retry next tick.
         }
+      } finally {
+        polling = false;
       }
     }, POLL_INTERVAL_MS);
     return () => {

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-F0RaV0AF.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-B-Fe9tPc.js"></script>
+    <script type="module" crossorigin src="/assets/index-F0RaV0AF.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません