Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed memory leak attributed to CodeMirror allocating objects on heap that were never freed. [#1580](https://github.com/sourcebot-dev/sourcebot/pull/1580)
- Kept Git provider credentials out of subprocess arguments and on-disk configuration by using isolated in-memory credential caches. [#1584](https://github.com/sourcebot-dev/sourcebot/pull/1584)
- Fixed unary Zoekt searches retaining a gRPC channel after every request by closing each client on completion. [#1591](https://github.com/sourcebot-dev/sourcebot/pull/1591)

## [5.1.7] - 2026-08-13

Expand Down
107 changes: 107 additions & 0 deletions packages/web/src/features/search/zoektSearcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { PrismaClient } from '@sourcebot/db';
import type { SearchRequest as ZoektGrpcSearchRequest } from '@/proto/zoekt/webserver/v1/SearchRequest';
import { beforeEach, describe, expect, test, vi } from 'vitest';

const mocks = vi.hoisted(() => {
const close = vi.fn();
const search = vi.fn();

class WebserverService {
Search = search;
close = close;
}

return {
close,
loadSync: vi.fn(() => ({})),
search,
WebserverService,
};
});

vi.mock('@grpc/proto-loader', () => ({
loadSync: mocks.loadSync,
}));

vi.mock('@grpc/grpc-js', async (importOriginal) => {
const actual = await importOriginal<typeof import('@grpc/grpc-js')>();
return {
...actual,
loadPackageDefinition: vi.fn(() => ({
zoekt: {
webserver: {
v1: {
WebserverService: mocks.WebserverService,
},
},
},
})),
};
});

vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));

vi.mock('@sourcebot/shared', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
env: {
AUTH_URL: 'http://sourcebot.test',
ZOEKT_WEBSERVER_URL: 'http://zoekt:6070',
},
}));

vi.mock('@/lib/posthog', () => ({
captureEvent: vi.fn(),
}));

import { zoektSearch } from './zoektSearcher';

const searchRequest = {} as ZoektGrpcSearchRequest;

describe('zoektSearch', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('closes its gRPC client after a successful unary search', async () => {
mocks.search.mockImplementation((_request, _metadata, callback) => {
callback(null, { files: [] });
});

const response = await zoektSearch(searchRequest, {} as PrismaClient);

expect(response.files).toEqual([]);
expect(mocks.close).toHaveBeenCalledOnce();
});

test('closes its gRPC client when the unary search fails', async () => {
mocks.search.mockImplementation((_request, _metadata, callback) => {
callback({ details: 'zoekt unavailable' });
});

await expect(zoektSearch(searchRequest, {} as PrismaClient)).rejects.toThrow();
expect(mocks.close).toHaveBeenCalledOnce();
});

test('closes its gRPC client when response transformation fails', async () => {
mocks.search.mockImplementation((_request, _metadata, callback) => {
callback(null, {
files: [{ repository_id: 1 }],
});
});
const prisma = {
repo: {
findUnique: vi.fn().mockRejectedValue(new Error('database unavailable')),
},
} as unknown as PrismaClient;

await expect(zoektSearch(searchRequest, prisma)).rejects.toThrow('database unavailable');
expect(mocks.close).toHaveBeenCalledOnce();
});
});
46 changes: 24 additions & 22 deletions packages/web/src/features/search/zoektSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,30 +124,32 @@ export const zoektSearch = async (searchRequest: ZoektGrpcSearchRequest, prisma:
const client = createGrpcClient();
const metadata = new grpc.Metadata();

return new Promise((resolve, reject) => {
client.Search(searchRequest, metadata, (error, response) => {
if (error || !response) {
reject(new ServiceErrorException(unexpectedError(error?.details || 'No response received')))
return;
}

(async () => {
try {
const reposMapCache = await createReposMapForChunk(response, new Map<string | number, Repo>(), prisma);
const { stats, files, repositoryInfo } = await transformZoektSearchResponse(response, reposMapCache);

resolve({
stats,
files,
repositoryInfo,
isSearchExhaustive: stats.totalMatchCount <= stats.actualMatchCount,
} satisfies SearchResponse);
} catch (err) {
reject(err);
try {
const response = await new Promise<ZoektGrpcSearchResponse>((resolve, reject) => {
client.Search(searchRequest, metadata, (error, response) => {
if (error || !response) {
reject(new ServiceErrorException(unexpectedError(error?.details || 'No response received')))
return;
}
})();

resolve(response);
});
});
});

const reposMapCache = await createReposMapForChunk(response, new Map<string | number, Repo>(), prisma);
const { stats, files, repositoryInfo } = await transformZoektSearchResponse(response, reposMapCache);

return {
stats,
files,
repositoryInfo,
isSearchExhaustive: stats.totalMatchCount <= stats.actualMatchCount,
} satisfies SearchResponse;
} finally {
// grpc-js keeps each channel in its process-wide channelz registry until
// the owning client is explicitly closed.
client.close();
}
}

export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, prisma: PrismaClient): Promise<ReadableStream> => {
Expand Down
Loading