From 201d4731de2c3477d74f1554c7adf787e89ace5d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 11 Jul 2026 20:43:47 +0200 Subject: [PATCH] fix(xhttp): stop XMUX maxConcurrency from reverting on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XHttpXmuxSchema defaulted maxConnections to 6 (added to mirror xray-core v26.6.27's anti-RKN client default), so load-time hydration backfilled a non-zero maxConnections onto every config whose saved xmux lacked the key. Since maxConnections and maxConcurrency are mutually exclusive on the wire, the save-time exclusivity rule then saw both fields set and silently deleted the user's maxConcurrency; the missing key came back as the '16-32' schema default on the next load, so edits appeared to never save. Revert the bare schema default to 0 and seed the anti-RKN maxConnections=6 only when XMUX is freshly toggled on (XMUX_FRESH_DEFAULTS, with maxConcurrency left blank — xray-core parses an empty range string as 0), so the two strategies never start out conflicting. The inbound and outbound XMUX forms now also clear the opposing field live as soon as the user sets one, so whichever strategy was actually typed is the one persisted. Closes #5864 --- .../src/lib/xray/outbound-form-adapter.ts | 2 +- .../src/lib/xray/stream-wire-normalize.ts | 12 +++---- .../pages/inbounds/form/transport/xhttp.tsx | 23 ++++++++++--- .../xray/outbounds/OutboundFormModal.tsx | 4 +-- .../pages/xray/outbounds/transport/xhttp.tsx | 19 ++++++++++- .../src/schemas/protocols/stream/xhttp.ts | 16 +++++++++- .../src/test/inbound-form-adapter.test.ts | 32 +++++++++++++++++++ .../src/test/outbound-form-adapter.test.ts | 23 +++++++++++++ .../src/test/stream-wire-normalize.test.ts | 16 +++++++--- 9 files changed, 125 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 550a3a738..194404475 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -387,7 +387,7 @@ export interface RawOutboundRow { mux?: unknown; } -export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); +const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); function hydrateStreamForm(stream: Raw): OutboundStreamFormValues { const next = { ...stream }; diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index 4dee44196..34c5fa1c0 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/frontend/src/lib/xray/stream-wire-normalize.ts @@ -46,7 +46,7 @@ function hasMeaningfulHeaders(headers: unknown): boolean { // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4, // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its // mutual-exclusivity check on the `.To` (upper) side. -function int32RangeUpper(v: unknown): number { +export function int32RangeUpper(v: unknown): number { if (typeof v === 'number') return Number.isFinite(v) ? v : 0; if (typeof v !== 'string') return 0; const trimmed = v.trim(); @@ -57,13 +57,9 @@ function int32RangeUpper(v: unknown): number { } // xray-core's XmuxConfig rejects a config that sets BOTH maxConnections -// and maxConcurrency ("maxConnections cannot be specified together with -// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever -// XMUX is enabled, so any explicit maxConnections would otherwise always -// collide and make xray refuse the config. maxConnections defaults to 0 -// (off), so a positive value is an explicit opt-in to connection-pool -// mode — honor it and drop the leftover default maxConcurrency, matching -// core's "one strategy at a time" semantics. +// and maxConcurrency. A positive maxConnections is an explicit opt-in to +// connection-pool mode — honor it and drop the leftover maxConcurrency +// default that load-time hydration backfills onto older saved configs. function resolveXmuxExclusivity(xmux: Record): Record { if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) { const out = { ...xmux }; diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx index 297158308..24be0d930 100644 --- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx +++ b/frontend/src/pages/inbounds/form/transport/xhttp.tsx @@ -4,10 +4,9 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { HeaderMapEditor } from '@/components/form'; import { FormField } from '@/components/form/rhf'; -import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; +import { XHTTP_SESSION_ID_TABLES, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp'; import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id'; - -const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); +import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize'; function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise) { return async (value: unknown): Promise => { @@ -36,7 +35,21 @@ export default function XhttpForm() { const existing = getValues('streamSettings.xhttpSettings.xmux'); const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0; if (hasValues) return; - setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS }); + setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS }); + } + + function onXmuxMaxConcurrencyChange(value: unknown) { + if (int32RangeUpper(value) <= 0) return; + if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConnections')) > 0) { + setValue('streamSettings.xhttpSettings.xmux.maxConnections', 0); + } + } + + function onXmuxMaxConnectionsChange(value: unknown) { + if (int32RangeUpper(value) <= 0) return; + if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConcurrency')) > 0) { + setValue('streamSettings.xhttpSettings.xmux.maxConcurrency', ''); + } } return ( @@ -295,12 +308,14 @@ export default function XhttpForm() { diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index 8b9d68dd5..99b0e5d33 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -17,11 +17,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { JsonEditor } from '@/components/form'; import { Wireguard } from '@/utils'; import { - XMUX_DEFAULTS, formValuesToWirePayload, rawOutboundToFormValues, } from '@/lib/xray/outbound-form-adapter'; import { parseOutboundLink } from '@/lib/xray/outbound-link-parser'; +import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp'; import { OutboundFormBaseSchema, type OutboundFormValues, @@ -255,7 +255,7 @@ export default function OutboundFormModal({ const existing = methods.getValues('streamSettings.xhttpSettings.xmux'); const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0; if (hasValues) return; - methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS }); + methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS }); } const duplicateTag = useMemo(() => { diff --git a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx b/frontend/src/pages/xray/outbounds/transport/xhttp.tsx index 90f4f17de..a62655265 100644 --- a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx +++ b/frontend/src/pages/xray/outbounds/transport/xhttp.tsx @@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { HeaderMapEditor } from '@/components/form'; import { FormField } from '@/components/form/rhf'; import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id'; +import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize'; import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp'; import { MODE_OPTIONS } from '../outbound-form-constants'; @@ -28,7 +29,7 @@ const XH = 'streamSettings.xhttpSettings'; export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) { const { t } = useTranslation(); - const { control } = useFormContext(); + const { control, getValues, setValue } = useFormContext(); const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined; const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` }); const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined; @@ -37,6 +38,20 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) { const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined; const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` }); + function onXmuxMaxConcurrencyChange(value: unknown) { + if (int32RangeUpper(value) <= 0) return; + if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) { + setValue(`${XH}.xmux.maxConnections`, 0); + } + } + + function onXmuxMaxConnectionsChange(value: unknown) { + if (int32RangeUpper(value) <= 0) return; + if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) { + setValue(`${XH}.xmux.maxConcurrency`, ''); + } + } + return ( <> @@ -283,12 +298,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) { diff --git a/frontend/src/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts index 8c8f38602..7d61e4544 100644 --- a/frontend/src/schemas/protocols/stream/xhttp.ts +++ b/frontend/src/schemas/protocols/stream/xhttp.ts @@ -15,9 +15,14 @@ export type XHttpMode = z.infer; // XMUX is the connection-multiplexing layer xHTTP uses to fan out // parallel requests over a small pool of upstream connections. Fields // are strings because they accept dash-range values like '16-32'. +// maxConcurrency and maxConnections are mutually exclusive strategies +// (xray-core rejects a config that sets both), so the bare schema +// default keeps only one of them non-zero — a non-zero maxConnections +// default resurrected on load made every re-save silently delete the +// user's maxConcurrency. export const XHttpXmuxSchema = z.object({ maxConcurrency: z.string().default('16-32'), - maxConnections: z.union([z.string(), z.number()]).default(6), + maxConnections: z.union([z.string(), z.number()]).default(0), cMaxReuseTimes: z.union([z.string(), z.number()]).default(0), hMaxRequestTimes: z.string().default('600-900'), hMaxReusableSecs: z.string().default('1800-3000'), @@ -25,6 +30,15 @@ export const XHttpXmuxSchema = z.object({ }); export type XHttpXmux = z.infer; +// Seed for freshly enabling XMUX on a config that had no xmux block: +// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback +// rather than the concurrency strategy. +export const XMUX_FRESH_DEFAULTS: XHttpXmux = { + ...XHttpXmuxSchema.parse({}), + maxConcurrency: '', + maxConnections: 6, +}; + // Predefined sessionIDTable names xray-core accepts as a shorthand for a // charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII // charset string is also accepted. diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 99750d8d6..360e82431 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -353,3 +353,35 @@ describe('legacy xhttp session keys on edit (#5621)', () => { expect(out.sessionKey).toBeUndefined(); }); }); + +describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => { + const xmuxRow: RawInboundRow = { + ...vlessRow, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { + path: '/xh', + mode: 'auto', + xmux: { maxConcurrency: '1-2' }, + }, + }, + }; + + it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => { + const values = rawInboundToFormValues(xmuxRow); + const xhttp = (values.streamSettings as unknown as Record>).xhttpSettings; + expect(xhttp.enableXmux).toBe(true); + const xmux = xhttp.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + expect(xmux.maxConnections).toBe(0); + }); + + it('formValuesToWirePayload keeps maxConcurrency on an unedited re-save', () => { + const values = rawInboundToFormValues(xmuxRow); + const payload = formValuesToWirePayload(values); + const stream = JSON.parse(payload.streamSettings) as Record>; + const xmux = stream.xhttpSettings.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + }); +}); diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index 09aae41c4..29c5d27ec 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => { const wireXhttp = (back.streamSettings as Record).xhttpSettings as Record; expect(wireXhttp).not.toHaveProperty('xmux'); }); + + it('keeps a saved maxConcurrency through a load/re-save round-trip', () => { + const wire = { + ...xmuxWire, + streamSettings: { + ...xmuxWire.streamSettings, + xhttpSettings: { + ...xmuxWire.streamSettings.xhttpSettings, + xmux: { maxConcurrency: '1-2' }, + }, + }, + }; + const form = rawOutboundToFormValues(wire); + const xhttp = (form.streamSettings as Record).xhttpSettings as Record; + const xmux = xhttp.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + expect(xmux.maxConnections).toBe(0); + + const back = formValuesToWirePayload(form); + const wireXhttp = (back.streamSettings as Record).xhttpSettings as Record; + const wireXmux = wireXhttp.xmux as Record; + expect(wireXmux.maxConcurrency).toBe('1-2'); + }); }); describe('outbound-form-adapter: full optional-block round-trip', () => { diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index 16e9f88bb..9c70f1bea 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/frontend/src/test/stream-wire-normalize.test.ts @@ -12,7 +12,7 @@ import { import { InboundFormSchema } from '@/schemas/forms/inbound-form'; import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt'; import type { InboundFormValues } from '@/schemas/forms/inbound-form'; -import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; +import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp'; describe('validateRealityTarget', () => { it('accepts host:port and bare port', () => { @@ -153,19 +153,25 @@ describe('normalizeXhttpForWire stream-one', () => { expect(xmux.maxConnections).toBe('8'); }); - it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => { - expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6); + it('bare schema defaults keep only one exclusive xmux strategy non-zero', () => { + expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0); + expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32'); + }); + + it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => { + expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6); + expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe(''); const out = normalizeXhttpForWire({ path: '/app', mode: 'stream-one', enableXmux: true, - xmux: XHttpXmuxSchema.parse({}), + xmux: XMUX_FRESH_DEFAULTS, }, 'outbound'); const xmux = out.xmux as Record; expect(xmux.maxConnections).toBe(6); - expect(xmux).not.toHaveProperty('maxConcurrency'); + expect(xmux.maxConcurrency).toBe(''); }); });