authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-08-22 04:12:04+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 22:12:04-04:00
log421088c27367da227d922ce999c1637416a6d0f4
treefcbee70081971742e0dfc894cd5925321fd5bdb0
parent592c5983c14d248aa3cfddb8e6c7372f12cd1ab6
signature Signed by PGP key B5690EEEBB952194

fix(search): oversized thread queries no longer crash clients (#6633)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

2 files changed, 59 insertions(+), 10 deletions(-)

packages/client-runtime/src/state/threadSearch.test.ts+45
......@@ -49,6 +49,51 @@ it("encodes scoped thread keys without delimiter collisions", () => {
4949 expect(first).not.toBe(second);
5050});
5151
52it("accepts search keys at the maximum decoded query length", () => {
53 const queries: string[] = [];
54 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
55 getSearchAtom: (_environmentId, query) => {
56 queries.push(query);
57 return Atom.make(AsyncResult.success({ matches: [] }));
58 },
59 labelPrefix: "test:thread-search",
60 });
61 const registry = AtomRegistry.make();
62 const query = "a".repeat(200);
63
64 try {
65 registry.get(searchAtom(makeThreadSearchKey([envA], query)));
66 registry.get(searchAtom(makeThreadSearchKey([envA], ` ${query} `)));
67
68 expect(queries).toEqual([query, query]);
69 } finally {
70 registry.dispose();
71 }
72});
73
74it("ignores invalid search keys", () => {
75 let searchCount = 0;
76 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
77 getSearchAtom: () => {
78 searchCount += 1;
79 return Atom.make(AsyncResult.success({ matches: [] }));
80 },
81 labelPrefix: "test:thread-search",
82 });
83 const registry = AtomRegistry.make();
84
85 try {
86 const state = registry.get(searchAtom(makeThreadSearchKey([envA], "a".repeat(201))));
87 const malformedState = registry.get(searchAtom("not json{{"));
88
89 expect(state).toEqual({ matches: [], isLoading: false });
90 expect(malformedState).toEqual({ matches: [], isLoading: false });
91 expect(searchCount).toBe(0);
92 } finally {
93 registry.dispose();
94 }
95});
96
5297it("merges successful environments and silently ignores failures", () => {
5398 const result: OrchestrationSearchThreadsResult = {
5499 matches: [
packages/client-runtime/src/state/threadSearch.ts+14-10
......@@ -17,11 +17,10 @@ export interface ThreadSearchResultsState {
1717 readonly isLoading: boolean;
1818}
1919
20const ThreadSearchKey = Schema.Tuple([
21 Schema.Array(EnvironmentId),
22 OrchestrationSearchThreadsInput.fields.query,
23]);
24const decodeThreadSearchKey = Schema.decodeUnknownSync(ThreadSearchKey);
20const ThreadSearchKey = Schema.fromJsonString(
21 Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]),
22);
23const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey);
2524
2625export function makeThreadSearchKey(
2726 environmentIds: ReadonlyArray<EnvironmentId>,
......@@ -34,7 +33,7 @@ export function makeThreadSearchKey(
3433}
3534
3635function parseThreadSearchKey(key: string) {
37 return decodeThreadSearchKey(JSON.parse(key));
36 return decodeThreadSearchKey(key);
3837}
3938
4039export function threadSearchMatchKey(
......@@ -44,9 +43,9 @@ export function threadSearchMatchKey(
4443}
4544
4645/**
47 * Combines one search query atom per environment. Failed and disconnected
48 * environments contribute no content matches, preserving local title search
49 * as the compatibility fallback.
46 * Combines one search query atom per environment. Invalid search keys, failed
47 * requests, and disconnected environments contribute no content matches,
48 * preserving local title search as the compatibility fallback.
5049 */
5150export function createThreadSearchResultsAtomFamily<E>(options: {
5251 readonly getSearchAtom: (
......@@ -57,7 +56,12 @@ export function createThreadSearchResultsAtomFamily<E>(options: {
5756}) {
5857 return Atom.family((key: string) =>
5958 Atom.make((get): ThreadSearchResultsState => {
60 const [environmentIds, query] = parseThreadSearchKey(key);
59 const parsedKey = parseThreadSearchKey(key);
60 if (Option.isNone(parsedKey)) {
61 return { matches: [], isLoading: false };
62 }
63
64 const [environmentIds, query] = parsedKey.value;
6165 const matches: EnvironmentThreadSearchMatch[] = [];
6266 let isLoading = false;
6367