fix(xhttp): stop XMUX maxConcurrency from reverting on save

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
This commit is contained in:
MHSanaei
2026-07-11 20:43:47 +02:00
parent ed9686bf29
commit 201d4731de
9 changed files with 125 additions and 22 deletions
@@ -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<string, Record<string, unknown>>).xhttpSettings;
expect(xhttp.enableXmux).toBe(true);
const xmux = xhttp.xmux as Record<string, unknown>;
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<string, Record<string, unknown>>;
const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('1-2');
});
});
@@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
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<string, unknown>).xhttpSettings as Record<string, unknown>;
const xmux = xhttp.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('1-2');
expect(xmux.maxConnections).toBe(0);
const back = formValuesToWirePayload(form);
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
const wireXmux = wireXhttp.xmux as Record<string, unknown>;
expect(wireXmux.maxConcurrency).toBe('1-2');
});
});
describe('outbound-form-adapter: full optional-block round-trip', () => {
@@ -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<string, unknown>;
expect(xmux.maxConnections).toBe(6);
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConcurrency).toBe('');
});
});