Moonlight Animate: druga tura po audycie — żeby praca nie znikała
Jakiś czas temu przejechaliśmy Moonlight Animate całościowym przeglądem kodu — nie „czy coś się wywala", tylko: gdzie ta aplikacja może po cichu zjeść czyjąś pracę. Pierwsza tura zamknęła dwanaście rzeczy najgorszych. To jest tura druga, i szczerze — jest ciekawsza od pierwszej, bo prawie nic z tego nie objawiało się jako crash. Objawiało się jako „hm, dziwne" albo wcale.
Otwarcie pliku, które kasowało to, nad czym pracowałeś
Wczytywanie projektu .mart działało tak: najpierw wyczyść dokument docelowy, potem czytaj plik.
Kolejność brzmi niewinnie, dopóki plik nie okaże się ucięty — a wtedy parser wychodził z błędem
w jednym z około trzydziestu miejsc, zostawiając po sobie pusty dokument. Jeśli wczytywałeś do
świeżej zakładki, nic się nie działo. Ale ta sama ścieżka obsługuje edycję smart-obiektów i
współpracę — czyli sytuacje, w których po drugiej stronie jest żywy dokument z twoją pracą.
Teraz wczytywanie jest atomowe wobec celu: robimy migawkę przed startem i przy jakimkolwiek niepowodzeniu wracamy do stanu sprzed próby. Migawka jest tania, bo kafle są współdzielone — kosztuje wskaźniki, nie piksele.
Uszkodzony plik, który udawał zdrowy
Druga rzecz z tej samej rodziny, i bardziej podstępna. Kafel, którego nie dało się rozpakować, był po cichu pomijany. Plik otwierał się „poprawnie", z dziurami w obrazie. Ktoś mógł tego nie zauważyć od razu, nacisnąć Ctrl+S — i dziury nadpisywały nieuszkodzony oryginał. Awaria dysku zamieniała się w trwałą stratę przez jeden odruchowy skrót.
Teraz uszkodzenia są liczone i pokazywane, a plik traci powiązanie ze swoją ścieżką: dokument liczy się jako niezapisany, a najbliższy zapis otwiera „Zapisz jako". Odzyskujesz, co się dało, pod nową nazwą — oryginał zostaje nietknięty. Dokładnie ta sama zasada, którą stosuje odzyskiwanie po awarii.
„Scal w dół" nakładało efekty dwa razy
To był mój faworyt, bo wygląda jak drobiazg, a jest cichym niszczeniem obrazu. Scalanie wypala w piksele pięć rzeczy: maskę rastrową, maskę wektorową, styl warstwy, smart-filtry i przypięte wypaczenie punktu zbiegu. Po scaleniu czyściło dwie z nich. Pozostałe trzy zostawały aktywne na warstwie, która już je miała w pikselach — więc były nakładane ponownie. Rozmycie robiło się podwójne, maska podnosiła się do kwadratu i robiła ciemniejsza. I to się zapisywało.
Przy okazji wyszła rzecz gorsza. Scalanie nie sprawdzało w ogóle, jakiego typu są warstwy. Warstwa korekcyjna, referencer, graf węzłów — one nie mają własnych pikseli; render zwraca dla nich pusty bufor. Scalenie takiej warstwy nie „łączyło" niczego, tylko kasowało zawartość tej drugiej. Teraz polecenie po prostu odmawia, tak jak od zawsze odmawiało dla grup.
Historia, która kasowała się sama
Panel Historii ma budżet pamięci. Licznik tego budżetu odejmował inną liczbę niż dodawał — bo komenda potrafi zmienić swój rozmiar w trakcie życia. Przy odpowiedniej sekwencji cofnięć licznik wpadał w niedomiar i stawał się astronomiczny, po czym mechanizm przycinania uznawał, że jest wiecznie ponad budżetem, i zostawiał dokładnie jeden krok cofania. Do końca sesji.
Nie załatałem objawu. Usunąłem asymetrię: rejestr kosztów jest teraz prowadzony równolegle, więc odejmujemy zawsze dokładnie tę wartość, którą wcześniej dodaliśmy.
Drugi problem tego samego panelu: kafle współdzielone (a takie są wszystkie migawki, dopóki nie zaczniesz malować po nich na nowo) liczyły się jako świeża pamięć u każdego posiadacza. Dwie operacje na całym dokumencie potrafiły „przekroczyć" gigabajtowy budżet, choć realne zużycie nie drgnęło ani o bajt — i historia znikała cała. Teraz każdy posiadacz płaci swój udział.
Migawki, które trafiały do cudzego dokumentu
Panel Historii pozwala robić nazwane migawki i wracać do nich. Lista tych migawek nie czyściła się przy przełączeniu zakładki. Czyli: robisz migawkę w dokumencie A, przechodzisz do B, klikasz „Powrót do migawki" — i do B wjeżdżają warstwy, rozmiar płótna i tryb koloru z A.
Migawki są teraz trzymane osobno dla każdego dokumentu, więc przełączanie kart je przełącza zamiast mieszać, a powrót na kartę przywraca je razem z zaznaczeniem.
Rzeczy, które po prostu jadły pamięć
Domyślny tryb Galerii rozmyć budował dziesięć pełnych kopii płótna naraz. Przy 6000×4000 to około 4,6 GB w szczycie — i całość liczona od nowa przy każdym ruchu suwaka. Teraz poziomy są przetwarzane strumieniowo, jeden naraz, a te, których nikt nie użył, w ogóle nie powstają: 1,66 GB, przy wyniku identycznym co do bitu (sprawdzonym na pięciu konfiguracjach pinezek).
Import PSD materializował każdą maskę warstwy na całym płótnie dokumentu, zamiast w jej własnym prostokącie — około 565 MB na maskę przy dużym dokumencie, plus dziesiątki milionów niepotrzebnych operacji. Teraz maska zajmuje tyle, ile maska.
Eksport animacji klatka po klatce wysyłał teksturę na kartę graficzną dla każdej klatki, ale mechanizm zwalniania działał wyłącznie podczas normalnego rysowania w oknie — czyli w ogóle nie podczas eksportu. Przy 240 klatkach 1080p to jakieś dwa gigabajty VRAM w jedną stronę.
I mój ulubiony drobiazg: cache tekstur klatek animacji nigdy nie trafiał. Klucz był budowany między innymi z identyfikatora obrazu, który tworzyliśmy tuż obok — więc z definicji był za każdym razem inny. Cache istniał, kosztował, i był w stu procentach bezużyteczny. Teraz klucz bierze się z rewizji kafli, czyli z tego, co faktycznie decyduje o zawartości.
Bezpieczeństwo
Kilka rzeczy z gatunku „to nie zaboli, dopóki ktoś nie spróbuje":
Adres pliku assetu potrafi przyjechać w danych — na przykład w udostępnionej scenie, czyli od innego użytkownika. Do tego adresu doklejaliśmy token konta. Token nie opuszcza już naszego serwera: ani przez adres podany w danych, ani przez przekierowanie.
Materiał pędzla z przesyłki używał nadanego przez nadawcę identyfikatora wprost jako nazwy pliku. Nazwa mogła zawierać przejście do katalogu wyżej. Teraz nie może — i to samo dotyczy pola, przez które dało się skłonić aplikację do odczytania dowolnego pliku z dysku.
Rozpakowywanie danych przychodzących ze współpracy alokowało pamięć na podstawie rozmiaru zadeklarowanego w tych danych. Kilkadziesiąt bajtów mogło zamówić kilka gigabajtów. Rozmiar jest teraz sprawdzany zanim cokolwiek zaalokujemy.
Wroga scena gry mogła wstrzyknąć nieskończoności do silnika fizyki — a taka wartość nie psuje jednego obiektu, tylko cały świat. Przy okazji wyszła rzecz, która wygląda jak zabezpieczenie, a nim nie jest: standardowe „przytnij do zakresu" przepuszcza NaN bez zmian, bo każde porównanie z NaN jest fałszywe. Kilka miejsc w kodzie wyglądało na zabezpieczone i nie było.
Co to daje
Nic z tego nie jest nową funkcją. Cała ta tura to czterdzieści plików zmian, po których program robi dokładnie to samo co wcześniej — tyle że nie gubi pracy, nie kasuje historii bez powodu i nie zajmuje trzy razy więcej pamięci, niż potrzebuje.
Uważam, że to jest ważniejsze od nowej funkcji. Narzędzie, któremu nie ufasz na tyle, żeby zostawić w nim dzień roboty, nie jest narzędziem — jest zabawką. A zaufanie buduje się właśnie tak: szukając rzeczy, które psują się cicho, zanim znajdzie je ktoś, kto stracił przez nie plik.
Kolejna tura obejmuje zawieszenia i resztę zgłoszeń z przeglądu. Lista jest długa i nie mam zamiaru jej ukrywać.