SearchSession¶
SearchSession представляет одну поисковую операцию. Она запускает все включённые provider'ы параллельно, собирает их вывод в snapshot-словари по provider'ам, объединяет их на каждом кадре анимации и предоставляет реактивный results ref, за которым следят Vue-компоненты.
Источник: src/search/session.ts
Состояние¶
class SearchSession {
readonly query: string; // запрос для provider'а (канонический или необработанный)
readonly rawQuery: string; // оригинальный ввод пользователя
readonly resolved: ResolveResult | null;
readonly results: ShallowRef<PipelineEntity[]>; // реактивный, обновляется при каждом flush
readonly status: Ref<"streaming" | "done" | "cancelled">;
readonly providerStatus: Ref<Record<string, ProviderStatus>>;
readonly providerError: Ref<Record<string, string | null>>;
readonly completed: Promise<void>; // разрешается, когда все provider'ы завершают работу
}
results — это ShallowRef (не Ref) — Vue отслеживает ссылку, а не глубокие мутации объекта. Это важно, поскольку pipeline при каждом flush создаёт новый массив; глубокая реактивность на потенциально тысячах entity была бы дорогостоящей.
Выполнение provider'ов¶
Каждый provider запускается как отдельный async-метод _runProvider:
private async _runProvider(provider: SearchProvider): Promise<void> {
const ctx: SearchProviderCtx = {
signal: this._abort.signal, // общий AbortController
log: ...,
requestId: this.requestId,
};
try {
for await (const snapshot of provider.search(this.query, ctx)) {
if (this._abort.signal.aborted) break;
this._snapshots.set(provider.kind, snapshot); // замена последнего snapshot
this._setProviderStatus(provider.kind, "streaming");
this._scheduleFlush();
}
this._setProviderStatus(provider.kind, "done");
} catch (err) {
this._setProviderError(provider.kind, msg);
this._setProviderStatus(provider.kind, "error");
}
}
Provider'ы yield'ят snapshot'ы PipelineEntity[] — полное текущее представление, а не дельту. Каждый yield заменяет предыдущий snapshot для данного provider'а. Session хранит только последний snapshot на provider в _snapshots: Map<string, PipelineEntity[]>.
Все запуски provider'ов стартуют в конструкторе, и их промисы собираются в completed:
const runs = providers.map((p) => this._runProvider(p));
this.completed = Promise.allSettled(runs).then(() => {
if (this.status.value !== "cancelled") this.status.value = "done";
this._flushImmediate();
});
Flushing и rAF batching¶
Механизм _scheduleFlush / _flushImmediate предотвращает запуск pipeline при каждом yield provider'а:
private _scheduleFlush(): void {
if (this._rafPending) return; // уже запланировано — объединяем
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._abort.signal.aborted) return;
this._flushImmediate();
});
}
private _flushImmediate(): void {
const flat: PipelineEntity[] = [];
for (const snap of this._snapshots.values()) flat.push(...snap);
this.results.value = runPipeline(flat, this._pipeline);
}
_rafPending — булев флаг. Когда несколько snapshot'ов provider'ов приходят в течение одного кадра (например, SoulSeek доставляет 10 peer batch'ей за 16 мс), все вызовы _scheduleFlush, кроме первого, являются no-op'ами. Реальный pipeline запускается один раз за кадр, а не один раз за batch.
Это обеспечивает отзывчивость главного потока даже для популярных запросов, где SoulSeek генерирует сотни peer-ответов в секунду.
Snapshot map¶
Каждый ключ provider'а отображается на его последний полный snapshot. Когда срабатывает _flushImmediate:
- Все snapshot'ы разворачиваются в единый массив
- Массив проходит через pipeline (
normalize → dedup → score → filter) results.valueобновляется результатом pipeline
Поскольку provider'ы независимо обновляют свой собственный слот snapshot'а, медленная загрузка деталей RuTracker не блокирует появление результатов SoulSeek — каждый из них отправляет обновления в свой слот по мере поступления.
AbortController¶
Все provider'ы используют один AbortController. Вызов session.cancel() прерывает его:
cancel(): void {
if (this._abort.signal.aborted) return;
this._abort.abort();
this.status.value = "cancelled";
}
Provider'ы проверяют ctx.signal.aborted в своих внутренних циклах и досрочно выходят. Для SoulSeek слушатель Tauri-событий также снимается в блоке finally. Для RuTracker выполняющиеся вызовы getTorrentDetails проверяют signal перед каждым запросом.
Жизненный цикл ProviderStatus¶
"pending" → provider ещё не запущен (устанавливается в конструкторе)
"streaming" → получен первый yield
"done" → генератор завершился без ошибки
"error" → генератор выбросил исключение (сообщение сохраняется в providerError)
providerStatus и providerError — обычные объекты Ref, обновляемые через spread объекта для активации реактивности Vue:
requestId¶
Каждая SearchSession инкрементирует счётчик уровня модуля для получения уникального requestId. SoulSeek использует его для фильтрации входящих Tauri-событий soulseek-search-batch — бэкенд помечает каждый batch тем requestId, с которым он был запущен, поэтому устаревшие события из предыдущего поиска не загрязняют snapshot текущей session.