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", () => {...@@ -49,6 +49,51 @@ it("encodes scoped thread keys without delimiter collisions", () => {
49 expect(first).not.toBe(second);49 expect(first).not.toBe(second);
50});50});
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
52it("merges successful environments and silently ignores failures", () => {97it("merges successful environments and silently ignores failures", () => {
53 const result: OrchestrationSearchThreadsResult = {98 const result: OrchestrationSearchThreadsResult = {
54 matches: [99 matches: [
packages/client-runtime/src/state/threadSearch.ts+14-10
...@@ -17,11 +17,10 @@ export interface ThreadSearchResultsState {...@@ -17,11 +17,10 @@ export interface ThreadSearchResultsState {
17 readonly isLoading: boolean;17 readonly isLoading: boolean;
18}18}
1919
20const ThreadSearchKey = Schema.Tuple([20const ThreadSearchKey = Schema.fromJsonString(
21 Schema.Array(EnvironmentId),21 Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]),
22 OrchestrationSearchThreadsInput.fields.query,22);
23]);23const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey);
24const decodeThreadSearchKey = Schema.decodeUnknownSync(ThreadSearchKey);
2524
26export function makeThreadSearchKey(25export function makeThreadSearchKey(
27 environmentIds: ReadonlyArray<EnvironmentId>,26 environmentIds: ReadonlyArray<EnvironmentId>,
...@@ -34,7 +33,7 @@ export function makeThreadSearchKey(...@@ -34,7 +33,7 @@ export function makeThreadSearchKey(
34}33}
3534
36function parseThreadSearchKey(key: string) {35function parseThreadSearchKey(key: string) {
37 return decodeThreadSearchKey(JSON.parse(key));36 return decodeThreadSearchKey(key);
38}37}
3938
40export function threadSearchMatchKey(39export function threadSearchMatchKey(
...@@ -44,9 +43,9 @@ export function threadSearchMatchKey(...@@ -44,9 +43,9 @@ export function threadSearchMatchKey(
44}43}
4544
46/**45/**
47 * Combines one search query atom per environment. Failed and disconnected46 * Combines one search query atom per environment. Invalid search keys, failed
48 * environments contribute no content matches, preserving local title search47 * requests, and disconnected environments contribute no content matches,
49 * as the compatibility fallback.48 * preserving local title search as the compatibility fallback.
50 */49 */
51export function createThreadSearchResultsAtomFamily<E>(options: {50export function createThreadSearchResultsAtomFamily<E>(options: {
52 readonly getSearchAtom: (51 readonly getSearchAtom: (
...@@ -57,7 +56,12 @@ export function createThreadSearchResultsAtomFamily<E>(options: {...@@ -57,7 +56,12 @@ export function createThreadSearchResultsAtomFamily<E>(options: {
57}) {56}) {
58 return Atom.family((key: string) =>57 return Atom.family((key: string) =>
59 Atom.make((get): ThreadSearchResultsState => {58 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;
61 const matches: EnvironmentThreadSearchMatch[] = [];65 const matches: EnvironmentThreadSearchMatch[] = [];
62 let isLoading = false;66 let isLoading = false;
6367