Как наш плеер на 196 МБ за ночь раздулся до 450 МБ при нуле утечек: 168 забытых вебсокетов и налог SwiftUI


В первой части истории про Kinrox мы радовались как дети: написали открытый нативный видео- и Spotify-плеер для macOS на Swift 6 и SwiftUI (kinrox.app, GitHub), закрыли двухгигабайтный Electron-клиент Spotify.app, прикрутили консольный бинарник Spotify Soloist (который ест скромные 27 МиБ), починили жадный VStack в боковой панели и с гордостью показали скриншот Activity Monitor на 196 МБ оперативки. А во второй серии собрали собственный движок поиска редкой музыки Similar & Discovery в обход закрытых эндпоинтов Spotify.
Вечером я подключил MacBook к проектору, включил музыку, вдоволь нащёлкался по новому трёхколоночному интерфейсу, пораскрывал альбомы, покопался в андеграунде через tag:hipster и оставил плеер играть на ночь.
Проходит 17,5 часов. Утром я подхожу к ноутбуку, открываю Activity Monitor и вспоминаю бессмертный диалог про суслика: «Ты видишь экономию памяти? Нет. А она есть».
В мониторинге висят 450,75 МиБ, а пик за сессию (lifetime peak) доходил до 743,1 МиБ.

При этом вся наша база музыки в SQLite на диске весит пару мегабайт. Сам звуковой движок soloist спустя 17 с половиной часов спокойно держится на своих 29,11 МиБ. Сторожевой C-демон KinroxSoloistHost занимает 0,92 МиБ. Откуда тогда в главном процессе интерфейса Kinrox взялись 416,9 МиБ?
Дальше стало ещё интереснее. Мы запустили на живом процессе (PID 45801) штатную утилиту leaks. Она просканировала миллионы указателей и выдала:
Найдено 2 утечки общим весом 112 байт.
Не 112 мегабайт - сто двенадцать байт. По мнению leaks, в приложении образцовый порядок. Но больше 400 МиБ физической памяти сидят внутри процесса и никуда не уходят.
Пришлось отложить все новые фичи, посмотреть, как вообще устроена работа с памятью в классических десктопных плеерах (foobar2000, DeaDBeeF, IINA, Audirvana, mpv), просветить собственный процесс через vmmap и heap и разобраться, стоит ли выкидывать SwiftUI ради старого доброго AppKit.
Как устроены эталонные плееры, которые почти ничего не едят
Прежде чем править свой код наугад, мы решили посмотреть, как условный foobar2000 или DeaDBeeF переваривает библиотеку на 100 000 треков и укладывается в объём одной пустой вкладки браузера. Если сравнить популярные плееры на macOS, Windows и Linux, получаются четыре разных подхода:
mpv(35-80 МБ RAM) написан на чистом C без собственного GUI (только OSD поверх видео). В памяти вообще нет состояния медиатеки - живут только демуксер и буферы декодера текущего файла.foobar2000иDeaDBeeF(25-50 МБ RAM) используют C/C++ и нативные системные таблицы (NSTableViewили Win32). Вся база у них - это плоский бинарный индекс на диске, подключённый черезmmap, плюс жёсткая дедупликация повторяющихся строк (String Interning).IINA(90-160 МБ RAM) работает на Swift/AppKit поверхlibmpv: обычноеNSWindow, видеослойCAOpenGLLayerи классическийNSTableViewс переиспользованием ячеек.AudirvanaиRoon(300-900+ МБ RAM) тащат тяжёлый десктопный UI со своим аудиодвижком, полным графом объектов в куче и предзагрузкой распакованного PCM-аудио в оперативку.
На фоне этого сравнения сразу бросаются в глаза две низкоуровневые детали, о которых редко задумываешься в обычной прикладной разработке на Swift.
1. Почему mmap в macOS экономит реальную память
Фраза «отобразить файл базы в память через mmap» на первый взгляд звучит подозрительно: кажется, что плеер сейчас отъест ещё больше ОЗУ. Но на уровне ядра XNU в macOS всё работает наоборот - правильно настроенный mmap даёт куда меньший физический RAM Footprint, чем загрузка объектов в кучу или стандартный буферный кэш БД:
Когда приложение читает базу и создаёт массив структур или классов (
[MusicTrack], строки, словари) черезmalloc, система выделяет под них Dirty Memory (грязную приватную память). Она закреплена за процессом и целиком идёт в колонку Memory в Activity Monitor. Если память на Маке заканчивается, macOS приходится тратить CPU на её сжатие (Memory Compressor) или выгружать страницы в своп на SSD.Когда SQLite работает в стандартном режиме через системный вызов
read(), возникает двойная оплата: сначала ядро ОС поднимает страницу файла с диска в системный файловый кэш (Unified Buffer Cache), а затем копирует те же байты в приватный буфер SQLite в пространстве пользователя (PRAGMA cache_size). Одни и те же данные лежат в ОЗУ дважды.Если же включить в SQLite
PRAGMA mmap_size = 67108864;(64 МБ) и ужать внутренний приватный кэш доPRAGMA cache_size = -2000;(2 МБ), ядро просто связывает диапазон виртуальных адресов процесса напрямую с файлом на диске. Физически в момент старта в ОЗУ загружается 0 байт. Когда плеер обращается к куску таблицы, процессор ловит быстрый Page Fault, и macOS подтягивает с NVMe только нужные страницы по 16 КБ (размер страницы на Apple Silicon).Самое приятное: поскольку эти страницы один в один совпадают с файлом на диске, macOS считает их Clean / Purgeable (чистой файловой памятью, Page Cache) и не записывает в тяжёлый личный footprint приложения. Если свернуть плеер и запустить рядом сборку в Xcode, ядро бесшумно выбросит неиспользуемые чистые страницы из ОЗУ (их не надо сохранять в своп - они уже на диске), а при клике в поиск мгновенно подтянет обратно.
2. Как 22-байтные ID Spotify не пролезают в SmallString на Apple Silicon
Вторая деталь связана с тем, как хранятся метаданные треков. Если у вас 50 альбомов одного исполнителя, foobar2000 хранит строку с именем артиста и названием альбома один раз в общем пуле строк (String Pool), а треки ссылаются на неё 4-байтным смещением (UInt32).
В современном Swift на 64-битном Apple Silicon (arm64) есть встроенная оптимизация SmallString: короткие строки хранятся прямо внутри 16-байтной структуры String на стеке или внутри структуры трека без обращения к malloc. Но у SmallString на arm64 жёсткий потолок - максимум 15 байт UTF-8.
А теперь посмотрим на длину идентификаторов в Spotify:
стандартный Base62 ID трека, альбома или артиста (
4uLU6hMCjMI75M1A2tKUQC) занимает 22 байта;стандартный Spotify URI (
spotify:track:4uLU6hMCjMI75M1A2tKUQC) занимает 36 байт.
Любая строка с ID или URI в Spotify превышает порог SmallString минимум на 7 байт и гарантированно получает отдельную аллокацию Swift.StringStorage в куче - с заголовком объекта, счётчиком ссылок и округлением до корзины аллокатора.
Когда в альбоме 15 песен и каждая декодируется из JSON независимо, все 15 структур MusicTrack выделяют в куче 15 отдельных буферов под один и тот же albumID, 15 буферов под одно и то же название albumTitle, 15 буферов под artist.id, artist.uri, artist.name и 15 отдельных массивов Array<MusicArtist>.
Мы проверили эту догадку по дампу кучи нашего живого процесса (heap-selected.txt): при том, что в тестовой библиотеке было всего 488 уникальных артистов, в куче лежало 3 467 отдельных буферов Array<MusicArtist> общим весом 492 КБ.
Рентген процесса (vmmap и heap): куда на самом деле ушли 416,9 МиБ
Вернёмся к главному вопросу: почему leaks показал всего 112 байт утечек, если процесс Kinrox (PID 45801) разросся до 416,9 МиБ, а медиана по всем четырём процессам плеера за 120-секундную серию замеров proc_pid_rusage составила 450,75 МиБ?
Дело в том, что leaks ищет только недостижимые блоки памяти - осиротевшие куски в куче, на которые больше не указывает ни один живой поинтер. Но если код сам аккуратно сложил объекты в словарь, оставил в дереве UI или забыл закрыть сетевую сессию, указатели на них остаются валидными. Для leaks это не утечка, а нормальная работа программы.
Снимаем полную раскладку регионов памяти PID 45801 через vmmap -summary и heap:
Графика
IOAcceleratorиIOSurfaceзабрала 150,6 МиБ (119,6M+31,0M) - это текстуры Metal/OpenGL, буферы окна и видеослойCAOpenGLLayerотlibmpv.Shared Memoryзаняла 82,0 МиБ под общие буферы отрисовки с системнымWindowServerи CoreAnimation.Активная куча
MALLOC(живые объекты) весила 75,7 МиБ (231 806блоков) - здесь лежали структуры Swift, граф SwiftUIAttributeGraph, кэши и сетевые сессии.Ещё 68,9 МиБ (
48%зонMALLOC) пришлись на фрагментацию - пустые «дырки» в страницах аллокатора после пикового скачка UI до 743,1 МиБ, которыеmallocне может вернуть операционной системе.Оставшиеся 39,7 МиБ заняли стеки потоков,
.dylibи буферы SQLite.
В сумме по главному процессу набегает 416,9 МиБ, плюс около 33,8 МиБ на дочерние soloist и reaper - итого 450,75 МиБ.
Дальше мы открыли топ аллокаций в heap-top.txt, отсортированный по числу живых экземпляров, и сразу увидели первую аномалию.
Находка №1. 168 забытых вебсокет-сессий в SoloistProcessSupervisor
Посреди heap-top.txt висела подозрительно кратная группа объектов из Network.framework и CFNetwork:
168экземпляров__NSURLSessionWebSocketTask(180 КБ)169экземпляров__NSURLSessionLocal(128 КБ)169экземпляровNWIOConnection(99 КБ)840экземпляровNSURLRequestInternal(168 × 5)1 009экземпляровCFHTTPMessage(168 × 6 + 1)1 194последовательных очередейdispatch_queue_t(168 × 7)
Откуда взялось число 168? За 17,5 часов работы плеер проиграл ровно 168 треков.
Открываем SoloistProcessSupervisor.swift. В первой статье мы рассказывали, что запускаем консольный soloist в режиме --single-track (один дочерний процесс на один трек) и управляем им по локальному WebSocket на 127.0.0.1. Вот что делал метод launch() на старте каждой песни:
// Как было до исправления:
let config = URLSessionConfiguration.ephemeral
let session = URLSession(
configuration: config, delegate: SoloistSocketDelegate(), delegateQueue: nil)
let socket = session.webSocketTask(with: request)
socket.resume()
А вот что происходило в stop(), когда песня заканчивалась или пользователь нажимал Next:
// Как было в stop():
if let activeSocket = socket {
activeSocket.cancel(with: .goingAway, reason: nil)
}
socket = nil
Тут сошлись сразу три ошибки:
На каждый трек создавался новый экземпляр
URLSessionсо своим набором внутренних очередейNetwork.framework.По контракту Apple Foundation любой
URLSessionдержит сильную ссылку на свой делегат и внутренние структуры, пока явно не вызванsession.invalidateAndCancel()илиfinishTasksAndInvalidate(). Мы не вызывали его ни разу.Вызов
activeSocket.cancel(with: .goingAway, reason: nil)не прибивает задачу синхронно - он пытается обменяться закрывающим WebSocket-фреймом с процессомsoloist, который к этому моменту уже завершился. Без принудительного(activeSocket as URLSessionTask).cancel()все 168 сессий, 168 сокет-тасков, 168 соединенийNWIOConnectionи больше тысячи очередейdispatch_queue_tоставались жить в памяти до закрытия программы.
Рядом в том же SoloistProcessSupervisor нашёлся второй источник лишних аллокаций: перед стартом каждого трека метод verifyInstallation() целиком вычитывал с диска многомегабайтный бинарник soloist во временный буфер Data, считал его SHA-256 и запускал /usr/bin/codesign для проверки подписи. Каждые три минуты, хотя файл на диске никто не трогал. Эти чтения постоянно дёргали зону MallocLarge и раскачивали пиковое потребление памяти.
Исправили в два приёма: перевели супервизор на одну общую сессию activeSession(), которая инвалидируется только в close(), добавили жёсткий cancel() задачи при остановке трека (проверено тестом soloistSupervisorReusesSingleURLSessionAcrossLaunchesUntilClosed) и закэшировали stat()-штамп бинарника (SoloistVerifiedStamp: ino, size, mtime, ctime, digest), сократив повторную проверку перед треком до 0 мс:
// Как стало в SoloistProcessSupervisor:
func activeSession() -> URLSession {
if let session { return session }
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10
config.httpCookieStorage = nil
config.urlCache = nil
let created = URLSession(
configuration: config, delegate: SoloistSocketDelegate(), delegateQueue: nil)
session = created
return created
}
// В stop():
if let activeSocket = socket {
activeSocket.cancel(with: .goingAway, reason: nil)
(activeSocket as URLSessionTask).cancel()
}
socket = nil
Находка №2. Скрытый через .opacity(0) видеослой на 150 МиБ
Следующий вопрос - откуда в музыкальном плеере с текстовыми таблицами 150,6 МиБ графической памяти (IOAccelerator + IOSurface)?
Смотрим PlayerRootView.swift и PlayerWindowController.swift. При запуске приложения Kinrox сразу создавал видеодвижок MPVPlayer, инициализировал OpenGL-контекст (MPVRenderContext + CGL) и монтировал в окно VideoLayer (CAOpenGLLayer). А когда пользователь переключал верхний тумблер из Video в Music, дерево PlayerView просто прятали через прозрачность:
// Как было в PlayerRootView:
PlayerView(model: session.player, chrome: video.chrome, ...)
.opacity(session.mode == .video ? 1 : 0)
.allowsHitTesting(session.mode == .video)
Для macOS и CoreAnimation слой CAOpenGLLayer с прозрачностью 0, прикреплённый к живому Retina-окну (contentsScale = 2.0, isAsynchronous = true), остаётся активной полноэкранной OpenGL-поверхностью. Видеодрайвер продолжает держать под неё буферы IOSurface и IOAccelerator.
Мы научили PlayerView размонтировать VideoView и WelcomeView, когда выбран музыкальный режим и ни один видеофайл не открыт (!isVideoMode && !model.hasMedia && !model.isLoading), а при отсоединении VideoHostView от окна (window == nil в viewDidMoveToWindow()) добавили сброс графических буферов (закреплено тестом videoHostViewUnmountsInMusicModeWhenNoVideoIsLoaded):
// В VideoLayer.swift:
@MainActor
func releaseBacking() {
frame = .zero
contents = nil
}
Сам лёгкий контейнер PlayerView при этом остаётся в иерархии, чтобы не терять состояние и мгновенно возвращать видео и системные кнопки окна при переключении обратно.
Находка №3. Скрытый налог SwiftUI на списки и спор «а не переписать ли всё на AppKit?»

Оставалась самая неприятная часть: 75,7 МиБ активной кучи, 68,9 МиБ фрагментации malloc и пиковый скачок до 743,1 МиБ.
В какой-то момент мы всерьёз обсуждали идею отказаться от SwiftUI в таблицах и переписать их на старый добрый AppKit. Между классическим NSTableView и связкой ScrollView + LazyVStack есть фундаментальная разница:
В чистом AppKit (
NSTableView), если в плейлисте 5 000 треков, а на экран помещается 30 строк, система создаёт в памяти около 35 ячеекNSTableCellViewи при прокрутке переиспользует их по кругу черезmakeView(withIdentifier:), меняя только текст.В SwiftUI (
LazyVStack) пула переиспользования ячеек нет. Когда активно листаешь тысячи треков, раскрываешь карточки альбомов и переключаешь вкладкиSimilar & Discovery, внутренний граф зависимостей SwiftUI (AttributeGraph) создаёт и удерживает узлы атрибутов, кэширует списки отрисовки (один только буферDisplayList.ViewUpdater.ViewCache.mapв нашем дампе занимал 4,87 МиБ) и хранит состояние модификаторов для каждой созданной строки. Когда временные буферы освобождаются вперемешку с долгоживущими узлами графа, страницы аллокатораmallocпревращаются в «швейцарский сыр» на 68,9 МиБ фрагментации.
Но детальный разбор дампа кучи показал, что сам SwiftUI виноват лишь отчасти. Основной объём лишних аллокаций давали четыре ошибки, которые мы сами допустили в вёрстке списков и работе с кэшем.
Ловушка 1. Безусловные .popover и .help на каждой ссылке в таблице
В релизе 0.5.2 мы уже делали условным поповер у кнопки лайка (TrackHeart), но в TrackMetadataPopovers.swift у каждой кликабельной ссылки на артиста (TrackArtistLink) и альбом (TrackAlbumLink) внутри строки таблицы модификаторы .popover(isPresented:) и .help(...) продолжали висеть безусловно.
Даже когда поповер закрыт (isPresented == false), сам факт присутствия .popover в дереве ячейки заставляет SwiftUI выделять структуры PopoverPresentationModifier и HelpStyleConfiguration на каждую ссылку в каждой строке. Вдобавок свойства previewArtistAnchor, previewAlbumAnchor и focusedTrackID жили прямо в корневом @Observable-классе MusicViewModel, из-за чего каждая ссылка в таблице подписывалась на изменения главной модели плеера.
Мы вынесли горячие свойства фокуса и поповеров из MusicViewModel в отдельные изолированные объекты MusicFocusState и MusicPopoverState (с пометкой @ObservationIgnored в корневой модели), а .popover и .hoverHelp стали подключать к дереву только тогда, когда они реально открыты:
// В TrackMetadataPopovers.swift:
if isPresented {
button.popover(isPresented: presented, arrowEdge: .bottom) {
ArtistPreviewPopover(model: model, track: track)
}
} else {
button
}
Ловушка 2. Жадные VStack, спрятанные внутри ленивых экранов
Основная таблица треков TrackTable и колонка очереди CompactQueueColumn использовали LazyVStack. Но когда мы открыли код нового экрана рекомендаций DiscoveryWorkspaceView.swift, выяснилось, что внутри внешнего LazyVStack секция рекомендованных треков tracksSection оборачивала весь ForEach в обычный жадный VStack. То же самое нашлось внутри раскрытых карточек альбомов AlbumWorkspaceCard в AlbumsWorkspaceView.swift:
// Как было в DiscoveryWorkspaceView.tracksSection:
private func tracksSection(_ snapshot: MusicDiscoverySnapshot) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.text("RECOMMENDED TRACKS"))
// ЖАДНЫЙ VStack внутри скролла - создаёт все строки рекомендаций разом
VStack(spacing: 0) {
ForEach(Array(snapshot.items.enumerated()), id: \.element.id) { index, item in
DiscoveryTrackRow(...)
}
}
}
}
Для внешнего LazyVStack вся секция tracksSection выглядела как один неделимый блок. Поэтому при открытии экрана Similar & Discovery SwiftUI разом создавал сотни строк DiscoveryTrackRow со всеми их кнопками, бейджами и поповерами. Замена внутренних контейнеров на LazyVStack сразу срезала пиковый всплеск аллокаций при открытии дискавери и альбомов.
Ловушка 3. Вторая копия всего каталога в памяти SpotifyCacheStore
Чтобы при сохранении изменений в SQLite не перезаписывать всю базу целиком, а обновлять только изменившиеся треки, сериализованный актор SpotifyCacheStore хранил у себя поле:
// Как было в SpotifyCacheStore:
private var previous: SpotifyCachedLibrary?
Иными словами, актор кэша постоянно держал в оперативке вторую полную копию всего MusicCatalog и LibrarySnapshot только ради того, чтобы при следующем вызове save() сравнить previous?.catalog.tracks != value.catalog.tracks. А при чтении из SQLite метод db.rows(...) сначала копировал каждую строку таблицы catalog_entities в промежуточный массив [[Data?]], и лишь затем отдавал в декодер.
Здесь пригодился опыт foobar2000. В SpotifyCacheDatabase.swift мы включили WAL, окно mmap на 64 МБ и урезали приватный кэш страниц SQLite до 2 МБ:
try execute("PRAGMA journal_mode = WAL")
try execute("PRAGMA synchronous = NORMAL")
try execute("PRAGMA mmap_size = 67108864") // 64 МБ в Clean Page Cache ОС
try execute("PRAGMA cache_size = -2000") // Максимум 2 МБ приватного кэша SQLite
try execute("PRAGMA temp_store = MEMORY")
Вместо выгрузки [[Data?]] написали потоковый сборщик catalogBatches, который читает сырые указатели sqlite3_column_blob прямо из mmap-окна SQLite в готовые JSON-пакеты по 512 записей для параллельного декодирования. А хранение полной копии SpotifyCachedLibrary в SpotifyCacheStore заменили на компактную структуру SpotifyCacheSummary с 64-битными FNV-1a дайджестами ([String: UInt64]) без аллокаций при хешировании - она весит около 235 КБ вместо полного дубликата каталога (проверено тестом enablesMmapWalAndStreamsIncrementalDigestWrites).
Ловушка 4. Накопление временных результатов поиска и дубликаты строк
В SpotifyLibraryStore+Loading.swift словарь кэша поиска searchCache был ограничен 20 записями с временем жизни 60 секунд. Но сразу после ответа от API метод search() вызывал catalog.merge(tracks: result.tracks, albums: result.albums, artists: result.artists). Когда старый запрос вытеснялся из searchCache или пользователь запускал новый прогон discover(), слитые в общий catalog треки, альбомы и артисты никогда оттуда не удалялись и за 17,5 часов расползались без ограничений.
Мы уменьшили лимит searchCache с 20 до 10 записей и добавили метод pruneDisposableCatalog: при вытеснении старых запросов и каждом новом запуске discover() он вычищает из catalog временные сущности, которых нет в сохранённой библиотеке пользователя, активном поиске или текущей выдаче дискавери.
Одновременно в MusicCatalog.merge (Packages/KinroxMusic/Sources/MusicCore/MusicCatalog.swift) добавили каноническое интернирование массивов [MusicArtist] (artistGroupCache) и строковых буферов id, uri, title, albumID, albumTitle, которые не помещаются в 15-байтный SmallString. Теперь, когда в каталог сливаются 15 треков одного альбома, все 15 структур MusicTrack ссылаются на один и тот же буфер Swift.StringStorage и один и тот же буфер Array<MusicArtist> в куче за счёт встроенного в Swift Copy-on-Write. В тестах (mergeInternsArtistArrayAndAlbumStringBuffersAcrossTracks) мы проверяем это напрямую через сравнение адресов String.utf8.withUnsafeBufferPointer.
Что в сухом остатке: 158 МБ по новым замерам
Все пять исправлений вошли в мастер без переписывания интерфейса на AppKit и закрыты 362 автоматическими тестами (43 теста в пакете KinroxMusic и 319 в PlayerMacTests).
По новым замерам через macOS footprint (физическое потребление памяти) Kinrox вместе с процессами воспроизведения теперь занимает около 158 МБ памяти - это даже меньше тех 196 МБ, с которых мы начинали в первой статье:
основное приложение
Kinrox- 123 МБ (против прежних 416,9 МиБ), при этом пик основного приложения за текущий запуск составляет 153 МБ (вместо 743,1 МиБ до оптимизации);движок
soloistи вспомогательные процессы - около 35 МБ.
Если коротко свести опыт этого разбора для тех, кто пишет десктопные приложения под macOS на Swift и SwiftUI:
Нулевой отчёт
leaksне означает отсутствие утечек. ЗабытыеURLSession, неотменённые сетевые таски и растущие словари сохраняют валидные ссылки, поэтомуleaksсчитает их легальными объектами. Реальную картину на длинной дистанции показывают толькоheap <PID>(динамика числа экземпляров по классам) иvmmap -summary <PID>(разбивка по грязным страницам, фрагментацииmallocи буферам GPU).В таблицах SwiftUI дороже всего обходятся безусловные модификаторы презентации и широкие
@Observable-подписки. Один.popoverили.help, оставленный внутри строки списка безif isPresented, или один обычныйVStack, вложенный внутрьLazyVStack, умножается на тысячи ячеек и оставляет после себя десятки мегабайт фрагментированных страниц аллокатора.На
arm64любая строка длиннее 15 байт UTF-8 (включая все 22-байтные ID и 36-байтные URI Spotify) выпадает изSmallStringв кучу. Простое интернирование повторяющихся строк и массивов артистов на этапе слияния данных убирает тысячи клонов вmalloc.В локальной базе SQLite связка
PRAGMA mmap_sizeс маленькимPRAGMA cache_sizeпозволяет переложить чтение кэша на системныйClean Page Cacheядра macOS и не платить дважды за одни и те же страницы в приватной памяти процесса.
Весь код, тесты и подробный инженерный отчёт этого расследования (docs/RESEARCHES/2026-11-08_12-17_Kinrox-memory-reduction-research.md вместе с сырыми дампами vmmap и heap в docs/RESEARCHES/2026-11-08_12-17_Kinrox-memory-evidence/) открыты:
Сайт и DMG для Mac (Apple Silicon, macOS 14+): https://kinrox.app/
Репозиторий на GitHub: https://github.com/awaik/Kinrox
А вам приходилось ловить в SwiftUI или URLSession память, которую в упор не видит leaks? Делитесь своими находками из vmmap и heap в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.