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:
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);