fix: bypass Wails RPC entirely for local search via HTTP endpoint

Both SearchLocal RPC and Wails events were blocked by Wails v2's
Go call serialization. When the indexer or other Go calls were
in-flight, even a 1ms Go function couldn't return to JS.

New approach: registered /api/search-local as an HTTP handler on
the Wails asset server. The frontend fetches it directly via
fetch() — this runs on Go's HTTP server goroutine pool, completely
independent of Wails RPC serialization.

The fetch completes in milliseconds regardless of what other Go
calls are queued. The full Search() pipeline still runs via Wails
RPC and replaces the local results when done.
This commit is contained in:
2026-03-29 17:48:01 -04:00
parent c22066e5d5
commit c3e16a9fd2
3 changed files with 52 additions and 27 deletions
+3
View File
@@ -118,6 +118,9 @@ func NewYellowJacketApp(
yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler)
}
// Register local search endpoint — bypasses Wails RPC serialization.
yjApp.assetHandler.RegisterHandler("/api/search-local", yjApp.explore.SearchLocalHandler())
// create playlist service
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
+24
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"math"
"net/http"
"sort"
"strings"
"sync"
@@ -156,6 +157,29 @@ func (e *Service) SearchLocal(query string) *MBSearchResult {
return &result
}
// SearchLocalHandler returns an http.Handler that serves local
// index search results as JSON. This bypasses the Wails RPC
// serialization queue, ensuring sub-millisecond response times.
func (e *Service) SearchLocalHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
result := e.SearchLocal(query)
if result == nil {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("null"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
}
// ---------------------------------------------------------------------------
// MusicBrainz lookup
// ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { Search, GetThumbnails, GetArtistImageURL, CheckLibraryMBIDs } from '@go/explore/Service';
import { EventsOn } from '@runtime/runtime';
import type { ThumbnailRequest } from '@go/explore/Service';
import type {
MBSearchResult,
@@ -70,28 +69,6 @@ export class ExploreView extends LitElement {
@litQuery('input') private inputEl!: HTMLInputElement;
/* ── Lifecycle ── */
override connectedCallback() {
super.connectedCallback();
EventsOn('search:local-results', (local: MBSearchResult) => {
// Only apply if we're actively loading (a search is in flight).
if (!this.loading) return;
if (local.artists?.length || local.releaseGroups?.length || local.recordings?.length) {
this.results = local;
this.loadThumbnails();
this.loadArtistImages();
this.checkLibrary();
console.log(
`[explore] local results via event — ` +
`artists=${local.artists?.length ?? 0}, ` +
`albums=${local.releaseGroups?.length ?? 0}, ` +
`tracks=${local.recordings?.length ?? 0}`,
);
}
});
}
/* ── Styles ── */
static override styles = [
@@ -543,11 +520,32 @@ export class ExploreView extends LitElement {
const startTime = performance.now();
console.log(`[explore] search started: "${query}"`);
// Local index results arrive via the 'search:local-results' event
// emitted by the backend at the start of Search(), before the
// slow MB/LB pipeline runs. No separate RPC call needed.
// Phase 1: fetch local index results via HTTP (bypasses Wails
// RPC serialization — guaranteed instant).
try {
const resp = await fetch(`/api/search-local?q=${encodeURIComponent(query)}`);
if (version !== this.searchVersion) return;
if (resp.ok) {
const local = await resp.json() as MBSearchResult | null;
if (local && (local.artists?.length || local.releaseGroups?.length || local.recordings?.length)) {
this.results = local;
this.loadThumbnails();
this.loadArtistImages();
this.checkLibrary();
const elapsed = (performance.now() - startTime).toFixed(0);
console.log(
`[explore] local results: "${query}" in ${elapsed}ms — ` +
`artists=${local.artists?.length ?? 0}, ` +
`albums=${local.releaseGroups?.length ?? 0}, ` +
`tracks=${local.recordings?.length ?? 0}`,
);
}
}
} catch {
// Local search failed — continue to full search.
}
// Full pipeline (MB + LB + reranking).
// Phase 2: full pipeline (MB + LB + reranking) via Wails RPC.
void this.executeFullSearch(version, query, startTime);
}