Skip to content

SearchSession

SearchSession represents one search operation. It runs all enabled providers in parallel, collects their output in per-provider snapshot maps, merges them on each animation frame, and exposes a reactive results ref that Vue components watch.

Source: src/search/session.ts


State

class SearchSession {
  readonly query: string;          // provider query (canonical or raw)
  readonly rawQuery: string;       // original user input
  readonly resolved: ResolveResult | null;

  readonly results: ShallowRef<PipelineEntity[]>;  // reactive, updated on each flush
  readonly status: Ref<"streaming" | "done" | "cancelled">;
  readonly providerStatus: Ref<Record<string, ProviderStatus>>;
  readonly providerError: Ref<Record<string, string | null>>;
  readonly completed: Promise<void>;  // resolves when all providers settle
}

results is a ShallowRef (not Ref) — Vue tracks the reference, not deep object mutations. This is important because the pipeline produces a new array on every flush; deep reactivity on potentially thousands of entities would be expensive.


Provider execution

Each provider runs as a separate async method _runProvider:

private async _runProvider(provider: SearchProvider): Promise<void> {
  const ctx: SearchProviderCtx = {
    signal: this._abort.signal,  // shared 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);      // replace latest 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");
  }
}

Providers yield PipelineEntity[] snapshots — a complete current view, not a delta. Each yield replaces the previous snapshot for that provider. The session stores only the latest snapshot per provider in _snapshots: Map<string, PipelineEntity[]>.

All provider runs are started in the constructor and their promises are collected into 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 and rAF batching

The _scheduleFlush / _flushImmediate mechanism prevents the pipeline from running on every provider yield:

private _scheduleFlush(): void {
  if (this._rafPending) return;  // already scheduled — coalesce
  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 is a boolean flag. When multiple provider snapshots arrive within one frame (e.g. SoulSeek delivers 10 peer batches in 16 ms), all but the first _scheduleFlush call are no-ops. The actual pipeline runs once per frame, not once per batch.

This keeps the main thread responsive even for popular queries where SoulSeek fires hundreds of peer responses per second.


Snapshot map

_snapshots: Map<"rutracker" | "soulseek", PipelineEntity[]>

Each provider key maps to its latest complete snapshot. When _flushImmediate fires:

  1. All snapshots are flattened into a single array
  2. The array passes through the pipeline (normalize → dedup → score → filter)
  3. results.value is updated with the pipeline output

Because providers update their own snapshot slot independently, a slow RuTracker detail fetch doesn't block SoulSeek results from appearing — they each push updates to their own slot as they arrive.


AbortController

All providers share one AbortController. Calling session.cancel() aborts it:

cancel(): void {
  if (this._abort.signal.aborted) return;
  this._abort.abort();
  this.status.value = "cancelled";
}

Providers check ctx.signal.aborted in their inner loops and break early. For SoulSeek, the Tauri event listener is also unregistered in the finally block. For RuTracker, in-flight getTorrentDetails calls check the signal before each request.


ProviderStatus lifecycle

"pending"   → provider not yet started (set in constructor)
"streaming" → first yield received
"done"      → generator finished without error
"error"     → generator threw (message stored in providerError)

providerStatus and providerError are regular Ref objects updated with object spread to trigger Vue reactivity:

this.providerStatus.value = { ...this.providerStatus.value, [kind]: s };

requestId

Each SearchSession increments a module-level counter to get a unique requestId. SoulSeek uses this to filter incoming soulseek-search-batch Tauri events — the backend tags each batch with the requestId it was started with, so stale events from a previous search don't contaminate the current session's snapshot.

let _sessionCounter = 0;
// in constructor:
this.requestId = ++_sessionCounter;