Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 43a9646e8a9df09de6ea1b7522ee5f5fc502f09f
parent bc372c7476e835581659ba226386c3d74efe9122
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 17:44:17 -0400

common: an empty scope settles — the streaming drivers finalize when no worker starts, and runLeaf answers an empty scope at once (review M1)

None of the three drivers finalized given zero slugs (or a cap already
met): ensureWorkers started no worker, so nothing called finalize, the
leaf's done never resolved and the query read "searching" for ever. On main
only a leaf asked for by name could get an empty scope; the Search in
rewrite makes one from a plain query (a posts copy under a tag filter or a
channel selection with no posts, a chat copy where no video in scope has
chat, a transcripts copy when Type keeps no video). Each driver now settles
when idle after its first ensureWorkers and after a raised cap, and
searchEval.runLeaf records an empty result for an empty scope without a
cache lookup. Tests through the real drivers (transcripts, posts, chat,
description) and through runQueryTree with posts scope null, an empty posts
set and an empty chat set; all seven fail without the fixes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/search/leafPipeline.ts | 28++++++++++++++++++++++++++++
Mcommon/lib/search/searchIn.test.ts | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/searchEval.ts | 14++++++++++++++
3 files changed, 166 insertions(+), 1 deletion(-)

diff --git a/common/lib/search/leafPipeline.ts b/common/lib/search/leafPipeline.ts @@ -194,9 +194,20 @@ export function createSearchPipeline( for (let i = 0; i < needed; i++) worker(); }; + // `worker()` counts itself in synchronously, before its first await, so + // right after ensureWorkers a zero here means none is running or will. + const settleIfIdle = () => { + if (activeWorkers === 0 && !done) finalize(); + }; + // Emit initial snapshot synchronously so the UI clears previous results. pushUpdate(); ensureWorkers(); + // No worker started (nothing to scan, or the cap is already met): nobody is + // left to call finalize, so settle here. Without it an empty scope — which + // "Search in" makes from a plain query, e.g. a posts copy under a tag filter + // — never reported done and the search read "searching" for ever. + settleIfIdle(); return { cancel() { @@ -216,6 +227,7 @@ export function createSearchPipeline( pushUpdate(); } ensureWorkers(); + settleIfIdle(); }, }; } @@ -325,8 +337,15 @@ export function createPostsSearchPipeline( for (let i = 0; i < needed; i++) worker(); }; + // `worker()` counts itself in synchronously, before its first await, so + // right after ensureWorkers a zero here means none is running or will. + const settleIfIdle = () => { + if (activeWorkers === 0 && !done) finalize(); + }; + pushUpdate(); ensureWorkers(); + settleIfIdle(); // see createSearchPipeline return { cancel() { @@ -345,6 +364,7 @@ export function createPostsSearchPipeline( pushUpdate(); } ensureWorkers(); + settleIfIdle(); }, }; } @@ -503,8 +523,15 @@ export function createSubsSearchPipeline( for (let i = 0; i < needed; i++) worker(); }; + // `worker()` counts itself in synchronously, before its first await, so + // right after ensureWorkers a zero here means none is running or will. + const settleIfIdle = () => { + if (activeWorkers === 0 && !done) finalize(); + }; + pushUpdate(); ensureWorkers(); + settleIfIdle(); // see createSearchPipeline return { cancel() { @@ -523,6 +550,7 @@ export function createSubsSearchPipeline( pushUpdate(); } ensureWorkers(); + settleIfIdle(); }, }; } diff --git a/common/lib/search/searchIn.test.ts b/common/lib/search/searchIn.test.ts @@ -12,7 +12,17 @@ import { type LayerCache, type TreeProgress, } from "../searchEval"; -import type { LayerHit, LeafController, LeafRunner } from "./leafPipeline"; +import type { TranscriptDetail } from "../transcripts"; +import type { SubsDetail } from "../subs"; +import type { Post } from "../posts"; +import { + runLeafPipeline, + type LayerHit, + type LeafController, + type LeafFetchers, + type LeafProgress, + type LeafRunner, +} from "./leafPipeline"; import { foldSearchIn } from "./searchIn"; // The "Search in" fold, and the rewrite run end to end through the real tree @@ -199,3 +209,116 @@ test("nothing rewritten: the fold hands back the same progress", () => { }; assert.equal(foldSearchIn(p, { origin: new Map(), unionOf: new Map() }), p); }); + +// ─── An empty scope settles (review M1) ─── +// The rewrite makes copies whose scope can be empty from a plain query: a posts +// copy under a tag filter or on a channel selection with no posts, a chat copy +// where no video in scope has chat, a transcripts copy when the Type row keeps +// no video. The drivers used to start no worker for zero slugs and so never +// finalize — the query read "searching" for ever. These run the REAL drivers. + +const realFetchers: LeafFetchers = { + transcript: async (slug) => + ({ + slug, + cues: [{ start: 1, end: 2, text: TEXT.transcripts[slug] ?? "" }], + }) as unknown as TranscriptDetail, + subs: async (slug) => + ({ + slug, + tracks: { live_chat: [{ start: 1, end: 2, text: TEXT.chat[slug] ?? "" }] }, + }) as unknown as SubsDetail, + post: async (slug) => ({ slug, text: TEXT.posts[slug] ?? "" }) as unknown as Post, +}; +const realRunLeaf: LeafRunner = (opts) => + runLeafPipeline({ ...opts, fetchers: realFetchers }); + +function within<T>(p: Promise<T>, ms = 2_000): Promise<T> { + return Promise.race([ + p, + new Promise<T>((_, reject) => + setTimeout(() => reject(new Error(`not settled within ${ms} ms`)), ms), + ), + ]); +} + +for (const scope of ["transcripts", "posts", "chat", "description"] as const) { + test(`the real ${scope} driver settles an empty scope, and again after a raised cap`, async () => { + const seen: LeafProgress[] = []; + const ctrl = runLeafPipeline({ + leaf: newLeaf({ id: "x", query: "alpha", scope }), + scopeSlugs: [], + initialHitLimit: 10, + concurrency: 2, + flushIntervalMs: 5, + emit: (p) => seen.push(p), + fetchers: realFetchers, + }); + const result = await within(ctrl.done); + assert.equal(result.slugs.size, 0); + assert.equal(seen.at(-1)?.done, true); + // "Show more" on it resumes nothing, and must settle again. + ctrl.setHitLimit(1_000); + assert.equal(seen.at(-1)?.done, true); + }); +} + +function runReal( + root: GroupNode, + s: SearchIn, + scopes: { chat: ReadonlySet<string> | null; posts: ReadonlySet<string> | null }, +): Promise<TreeProgress> { + const rewritten = applySearchIn(root, s); + return within( + new Promise((resolve) => { + runQueryTree({ + root: rewritten.root, + runtime: { runLeaf: realRunLeaf, cache: noCache }, + globalScope: ["v1", "v2", "v3"], + summaries: [], + chatScopeSlugs: scopes.chat, + postScopeSlugs: scopes.posts, + initialHitLimit: 500, + concurrency: 2, + flushIntervalMs: 5, + emit: (p) => { + if (p.done) resolve(foldSearchIn(p, rewritten)); + }, + }); + }), + ); +} + +test("a plain query whose posts copy has nothing to read finishes (posts scope null)", async () => { + // What the session passes under a tag filter: no posts in scope at all. + const p = await runReal(ALPHA(), { transcripts: true, posts: true, chat: false }, { + chat: null, + posts: null, + }); + assert.deepEqual([...p.slugs], ["v1"]); + assert.equal(p.leafStates.get("a")?.active, false); +}); + +test("… and with an empty posts set (a channel selection with no posts channel)", async () => { + const p = await runReal(ALPHA(), { transcripts: true, posts: true, chat: false }, { + chat: null, + posts: new Set(), + }); + assert.deepEqual([...p.slugs], ["v1"]); + assert.equal(p.leafStates.get("a")?.active, false); +}); + +test("… and with Live chat ticked where no video in scope has chat", async () => { + const p = await runReal(ALPHA(), { transcripts: true, posts: false, chat: true }, { + chat: new Set(), + posts: null, + }); + assert.deepEqual([...p.slugs], ["v1"]); + // Posts only, with no posts: nothing, and done. + const none = await runReal(ALPHA(), { transcripts: false, posts: true, chat: false }, { + chat: null, + posts: null, + }); + assert.equal(none.slugs.size, 0); + assert.equal(none.leafStates.get("a")?.active, false); +}); diff --git a/common/lib/searchEval.ts b/common/lib/searchEval.ts @@ -345,6 +345,20 @@ async function runLeaf( // Fully-network leaves (transcripts / chat). Try the layer cache first. const scopeArr = Array.from(effectiveScope); + // Nothing to read — a posts leaf on a site or selection with no posts, a + // chat leaf where no video in scope has chat, a video leaf over posts only. + // "Search in" makes such leaves from a plain query, so settle it here, at + // once and without a cache lookup: matched nothing, done. (The drivers + // settle an empty scope too; this skips the cache round trip.) + if (scopeArr.length === 0) { + return applyCached( + leaf, + parentScope, + { slugs: new Set(), hits: new Map() }, + ctx, + /*cached*/ false, + ); + } const scopeHash = hashSlugs(scopeArr); const key = ctx.runtime.cache.key(canonicalHash(leaf), scopeHash); const cachedSync = ctx.runtime.cache.getSync(key);