mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
Restore the skip-objects list after a restart mid-print
The object list lives in PrinterState and is filled by the print-start path, which bambu_mqtt suppresses on the first RUNNING push after startup so a running print is not archived twice (#1304). Everything else that moment restores came back - the archive into _active_prints, the filament attribution session, the timelapse baseline - and the object list did not. So the card saw zero objects and greyed out its Skip button for the rest of the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a restart at 09:17, Skip dead for the remaining hour. Nothing could bring it back either. GET /print/objects rebuilds the list whenever it is empty, but its only caller is the modal that the greyed-out button opens. on_print_running_observed now reloads the objects from the archive of the print that is still running, anchored on subtask_id - the firmware mints one per print, so a leftover status="printing" row from a completion that was never seen cannot lend its objects to another job. Without an id nothing is loaded rather than guessed; the endpoint's own reload covers that on demand. That endpoint now reads the archived 3MF from disk before it asks the printer. The archive of a running print normally holds the very file the printer is executing, so the fan-out was fetching back 15 MB Bambuddy already had, over the printer's single FTP socket, while it was printing - and on a printer that kept the file on internal storage it cannot succeed at all. FTP stays as the fallback. skipped_objects is left alone: a reload is not a new print, and what the user has already skipped only lives there. The plate image had the same fault one layer down. Opening the modal asks for the cover, the top view and the object-ID mask, and the in-memory 3MF cache those share dies with the process - so after a restart all three went back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte read from socket contention, which is the storm #972 was about. The cover flow takes the running print's archived file too, resolved in the caller's short-lived session and passed in so _produce_cover_image still does no DB work, and marked as a shared file so the cleanup cannot delete the archive. Finally the card: a running print always has at least one object, so a count of zero means "not loaded", not "nothing to skip". Exactly one object is the real nothing-to-skip case and still disables the button. --- Stop a test's printer client leaking into the next test POST /api/v1/printers really connects, so a test that creates a printer through the API leaves a live client in the printer_manager singleton. The singleton outlives the per-test in-memory database, so the next test on that xdist worker - whose own first printer is handed the same primary key - reads that leftover client as its own live status. test_scheduled_drying_routes was the visible victim: an "online" printer with no firmware version fails the drying preflight, so scheduling came back 400 instead of 200. It only bites when --dist load happens to put victim and leaker on one worker, which is why it passes on its own and flakes under -n. Registrations made during a test are now undone after it, ids the test did not add are left alone, and disconnect_printer is what also drops the model and printer-info caches and stops the paho thread the leaked client was keeping alive against an unreachable address for the rest of the run.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The Skip button must stay reachable when the object list is merely unknown.
|
||||
*
|
||||
* `printable_objects_count` is derived from an in-memory list that a Bambuddy
|
||||
* restart wipes, so mid-print it can drop to 0 while the print carries on. The
|
||||
* card read 0 as "nothing to skip" and disabled the button — and the button is
|
||||
* what opens the modal whose fetch rebuilds the list, so the print never got it
|
||||
* back. A running print always has at least one object, so 0 means unknown;
|
||||
* exactly 1 is the real nothing-to-skip case.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
|
||||
const printer = {
|
||||
id: 1,
|
||||
name: 'H2C-1',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'H2C',
|
||||
enabled: true,
|
||||
is_active: true,
|
||||
nozzle_diameter: 0.4,
|
||||
location: 'Workshop',
|
||||
auto_archive: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const printing = {
|
||||
connected: true,
|
||||
state: 'RUNNING',
|
||||
// The card treats a print as active only when it can name it, so the fixture
|
||||
// has to carry one for the Skip button to be in its printing state at all.
|
||||
current_print: 'HULA_H2D_air_pad.gcode.3mf',
|
||||
subtask_name: 'HULA_H2D_air_pad',
|
||||
gcode_file: '/data/Metadata/plate_1.gcode',
|
||||
progress: 42,
|
||||
layer_num: 30,
|
||||
total_layers: 184,
|
||||
awaiting_plate_clear: false,
|
||||
temperatures: { nozzle: 220, bed: 60, chamber: 30 },
|
||||
remaining_time: 3600,
|
||||
wifi_signal: -50,
|
||||
vt_tray: [],
|
||||
};
|
||||
|
||||
function serveStatus(status: Record<string, unknown>) {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
|
||||
http.get('/api/v1/printers/:id/print/objects', () =>
|
||||
HttpResponse.json({ objects: [], total: 0, skipped_count: 0, is_printing: true, bbox_all: null }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The whole live-status block waits on the status query, so find rather than get.
|
||||
const skipButton = () => screen.findByRole('button', { name: /skip objects/i });
|
||||
|
||||
describe('skip objects button gate', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('printerCardSize');
|
||||
});
|
||||
|
||||
it('stays enabled while printing when the object list is not loaded', async () => {
|
||||
serveStatus({ ...printing, printable_objects_count: 0 });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
expect(await skipButton()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('is disabled for a single-object print, where there is nothing to skip', async () => {
|
||||
serveStatus({ ...printing, printable_objects_count: 1 });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
const button = await skipButton();
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('title', 'Skip objects (requires 2+ objects)');
|
||||
});
|
||||
|
||||
it('is enabled for the ordinary multi-object print', async () => {
|
||||
serveStatus({ ...printing, printable_objects_count: 8 });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
expect(await skipButton()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('is disabled when the printer is not printing', async () => {
|
||||
serveStatus({ ...printing, state: 'IDLE', printable_objects_count: 0 });
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
const button = await skipButton();
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('title', 'Skip objects (only while printing)');
|
||||
});
|
||||
});
|
||||
@@ -4258,13 +4258,23 @@ function PrinterCard({
|
||||
const coverUrl = isActivePrint ? status.cover_url : showRetainedPrint ? retainedPrintJob.coverUrl : null;
|
||||
const progress = isActivePrint ? (status.progress || 0) : showRetainedPrint ? 100 : 0;
|
||||
|
||||
// A running print always has at least one object, so a count of
|
||||
// zero means "not loaded", not "nothing to skip" — after a
|
||||
// restart mid-print the list is empty until something rebuilds
|
||||
// it. Treating that as nothing-to-skip disabled the only
|
||||
// control that opens the modal, and the modal's own fetch is
|
||||
// what rebuilds the list, so the print could never get it back.
|
||||
// Exactly one object is the real nothing-to-skip case.
|
||||
const objectCount = status.printable_objects_count ?? 0;
|
||||
const canSkipObjects = isActivePrint && objectCount !== 1 && hasPermission('printers:control');
|
||||
|
||||
return (
|
||||
<div className="p-2 bg-bambu-dark rounded-[10px] relative overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowSkipObjectsModal(true)}
|
||||
disabled={!isActivePrint || (status.printable_objects_count ?? 0) < 2 || !hasPermission('printers:control')}
|
||||
disabled={!canSkipObjects}
|
||||
className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
|
||||
isActivePrint && (status.printable_objects_count ?? 0) >= 2 && hasPermission('printers:control')
|
||||
canSkipObjects
|
||||
? 'text-bambu-gray hover:text-white hover:bg-white/10'
|
||||
: 'text-bambu-gray/30 cursor-not-allowed'
|
||||
}`}
|
||||
@@ -4273,9 +4283,9 @@ function PrinterCard({
|
||||
? t('printers.permission.noControl')
|
||||
: !isActivePrint
|
||||
? t('printers.skipObjects.onlyWhilePrinting')
|
||||
: (status.printable_objects_count ?? 0) >= 2
|
||||
? t('printers.skipObjects.tooltip')
|
||||
: t('printers.skipObjects.requiresMultiple')
|
||||
: objectCount === 1
|
||||
? t('printers.skipObjects.requiresMultiple')
|
||||
: t('printers.skipObjects.tooltip')
|
||||
}
|
||||
>
|
||||
<SkipObjectsIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
|
||||
|
||||
Reference in New Issue
Block a user