fix: reject oversized checkout diffs (#2488)

* fix: reject oversized checkout diffs

* fix(diff): keep large previews from disconnecting clients

The physical socket limit is an OOM backstop, not a frame-size contract. Bound structured diff construction incrementally and report oversized previews without closing the client's socket.

* fix(app): preserve oversized diff state

* fix(diff): stay within relay frame limit

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-07-30 00:48:05 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 16bbead5a4
commit 9e5accee3c
24 changed files with 321 additions and 81 deletions
+11 -2
View File
@@ -204,25 +204,34 @@ describe("server data push router", () => {
fake.emit({
type: "subscribe_checkout_diff_response",
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
payload: {
subscriptionId,
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
},
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: "diff-1",
});
fake.emit({
type: "checkout_diff_update",
payload: { subscriptionId, cwd, files: [], error: null },
payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true },
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
diffTooLarge: true,
requestId: `subscription:${subscriptionId}`,
});
+6
View File
@@ -420,6 +420,9 @@ function applyCheckoutDiffUpdate(input: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
...(input.message.payload.diffTooLarge !== undefined
? { diffTooLarge: input.message.payload.diffTooLarge }
: {}),
requestId: `subscription:${input.message.payload.subscriptionId}`,
},
});
@@ -440,6 +443,9 @@ function applyCheckoutDiffSubscribeResponse(input: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
...(input.message.payload.diffTooLarge !== undefined
? { diffTooLarge: input.message.payload.diffTooLarge }
: {}),
requestId: input.message.payload.requestId,
},
});
+8
View File
@@ -119,6 +119,7 @@ import {
type InlineReviewActions,
} from "@/review";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -1755,6 +1756,7 @@ interface DiffBodyContentProps {
notGit: boolean;
isDiffLoading: boolean;
diffErrorMessage: string | null;
diffTooLarge: boolean;
hasChanges: boolean;
emptyMessage: string;
children: ReactElement;
@@ -1768,6 +1770,7 @@ function DiffBodyContent({
notGit,
isDiffLoading,
diffErrorMessage,
diffTooLarge,
hasChanges,
emptyMessage,
children,
@@ -1803,6 +1806,9 @@ function DiffBodyContent({
</View>
);
}
if (diffTooLarge) {
return <DiffTooLargeState />;
}
if (diffErrorMessage) {
return (
<View style={styles.errorContainer}>
@@ -2693,6 +2699,7 @@ export function GitDiffPane({
selectBase: handleSelectBase,
files,
diffPayloadError,
diffTooLarge,
isDiffLoading,
reviewActions,
reviewAttachment,
@@ -2855,6 +2862,7 @@ export function GitDiffPane({
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { FileWarning } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedFileWarning = withUnistyles(FileWarning);
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function DiffTooLargeState() {
const { t } = useTranslation();
return (
<View style={styles.container} testID="diff-too-large">
<ThemedFileWarning size={ICON_SIZE.lg} uniProps={foregroundMutedIconColorMapping} />
<View style={styles.copy}>
<Text style={styles.title}>{t("workspace.git.diff.previewTooLargeTitle")}</Text>
<Text style={styles.description}>{t("workspace.git.diff.previewTooLargeDescription")}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
copy: {
alignItems: "center",
gap: theme.spacing[1],
maxWidth: 360,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textAlign: "center",
},
}));
+1
View File
@@ -92,6 +92,7 @@ export function useCheckoutDiffQuery({
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null && enabled && isConnected,
isFetching: false,
isError: Boolean(payloadError),
+2
View File
@@ -79,6 +79,7 @@ export function useWorkingDiff({
const {
files,
payloadError: diffPayloadError,
diffTooLarge,
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
@@ -123,6 +124,7 @@ export function useWorkingDiff({
selectBase,
files,
diffPayloadError,
diffTooLarge,
isDiffLoading,
reviewActions,
reviewAttachment,
+2
View File
@@ -764,6 +764,8 @@ export const ar: TranslationResources = {
closeChangesTab: "إغلاق علامة تبويب التغييرات",
binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
previewTooLargeDescription: "ضيّق نطاق المقارنة لمعاينتها هنا",
unified: "الفرق الموحدة",
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
+2
View File
@@ -774,6 +774,8 @@ export const en = {
closeChangesTab: "Close Changes tab",
binaryFile: "Binary file",
tooLarge: "Diff too large to display",
previewTooLargeTitle: "This diff is too large to preview",
previewTooLargeDescription: "Narrow the comparison to preview it here",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
+2
View File
@@ -795,6 +795,8 @@ export const es: TranslationResources = {
closeChangesTab: "Cerrar la pestaña Cambios",
binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse",
previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo",
previewTooLargeDescription: "Reduce la comparación para previsualizarla aquí",
unified: "Diferencia unificada",
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
+2
View File
@@ -794,6 +794,8 @@ export const fr: TranslationResources = {
closeChangesTab: "Fermer l'onglet Modifications",
binaryFile: "Fichier binaire",
tooLarge: "Diff trop grand pour être affiché",
previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé",
previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici",
unified: "Différentiel unifié",
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
+2
View File
@@ -775,6 +775,8 @@ export const ja: TranslationResources = {
closeChangesTab: "変更タブを閉じる",
binaryFile: "バイナリファイル",
tooLarge: "差分が大きすぎて表示できません",
previewTooLargeTitle: "この差分は大きすぎるためプレビューできません",
previewTooLargeDescription: "比較範囲を狭めると、ここでプレビューできます",
unified: "ユニファイド差分",
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
+2
View File
@@ -786,6 +786,8 @@ export const ptBR: TranslationResources = {
closeChangesTab: "Fechar a aba Alterações",
binaryFile: "Arquivo binário",
tooLarge: "Diff grande demais para exibir",
previewTooLargeTitle: "Este diff é grande demais para visualizar",
previewTooLargeDescription: "Reduza a comparação para visualizá-la aqui",
unified: "Diff unificado",
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
+2
View File
@@ -786,6 +786,8 @@ export const ru: TranslationResources = {
closeChangesTab: "Закрыть вкладку «Изменения»",
binaryFile: "Бинарный файл",
tooLarge: "Разница слишком велика для отображения",
previewTooLargeTitle: "Этот diff слишком велик для предпросмотра",
previewTooLargeDescription: "Сузьте сравнение, чтобы просмотреть его здесь",
unified: "Единый дифференциал",
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
+2
View File
@@ -756,6 +756,8 @@ export const zhCN: TranslationResources = {
closeChangesTab: "关闭“更改”标签页",
binaryFile: "二进制文件",
tooLarge: "Diff 过大,无法显示",
previewTooLargeTitle: "此 diff 过大,无法预览",
previewTooLargeDescription: "缩小比较范围即可在此预览",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
+4
View File
@@ -17,6 +17,7 @@ import {
resolveDiffLayout,
SharedDiffView,
} from "@/git/diff-pane";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -120,6 +121,9 @@ function WorkingDiffBody({
if (workingDiff.notGit) {
return <PanelState message={t("workspace.git.diff.notRepository")} />;
}
if (workingDiff.diffTooLarge) {
return <DiffTooLargeState />;
}
if (workingDiff.diffPayloadError) {
return (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />