Skip to content

fix Vision response truncation on half-close - #16

Open
cfal wants to merge 2 commits into
SagerNet:devfrom
cfal:vision-half-close-fix
Open

cfal wants to merge 2 commits into
SagerNet:devfrom
cfal:vision-half-close-fix

Conversation

@cfal

@cfal cfal commented Oct 17, 2026 •

Copy link
Copy Markdown

VLESS Vision connections still close both directions when the client finishes uploading data, so a destination that waits for EOF before replying loses its response.

a0c58542 fixes Close after DIRECT, but VisionConn still doesn't expose CloseWrite, so sing-box falls back to Close on upload EOF. this keeps that fix and adds half-close through TLS before DIRECT and through the raw carrier afterward. pending VLESS headers are sent first, and carriers without half-close support still fall back to a full raw close. sing-box's pre-DIRECT REALITY full-close workaround is unchanged.

the inline repro sends 65,536 bytes through sing-box, calls CloseWrite, then waits for the response. the destination only replies after receiving EOF. it runs ordinary VLESS as a control, then Vision with the same setup. no other proxy or external test dependencies are needed.

reproduced on sing-box dfb80349 with sing-vmess a0c58542, including the separate DIRECT close fix.

SING_BOX_BINARY=/path/to/sing-box go test -v -count=1 -run TestVisionHalfClose repro_test.go

before:

=== RUN   TestVisionHalfClose
=== RUN   TestVisionHalfClose/VLESS
    repro_test.go:97: sent 65536 bytes; received 65536 bytes after CloseWrite
=== RUN   TestVisionHalfClose/Vision
    repro_test.go:97: sent 65536 bytes; received 0 bytes after CloseWrite
    repro_test.go:99: response truncated or corrupted
--- FAIL: TestVisionHalfClose (0.13s)
    --- PASS: TestVisionHalfClose/VLESS (0.07s)
    --- FAIL: TestVisionHalfClose/Vision (0.06s)
FAIL

after:

=== RUN   TestVisionHalfClose
=== RUN   TestVisionHalfClose/VLESS
    repro_test.go:97: sent 65536 bytes; received 65536 bytes after CloseWrite
=== RUN   TestVisionHalfClose/Vision
    repro_test.go:97: sent 65536 bytes; received 65536 bytes after CloseWrite
--- PASS: TestVisionHalfClose (0.14s)
    --- PASS: TestVisionHalfClose/VLESS (0.08s)
    --- PASS: TestVisionHalfClose/Vision (0.06s)
PASS
package repro

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"io"
	"math/big"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"time"
)

func TestVisionHalfClose(t *testing.T) {
	t.Run("VLESS", func(t *testing.T) { testHalfClose(t, "") })
	t.Run("Vision", func(t *testing.T) { testHalfClose(t, "xtls-rprx-vision") })
}

func testHalfClose(t *testing.T, flow string) {
	binary := os.Getenv("SING_BOX_BINARY")
	if binary == "" {
		t.Fatal("set SING_BOX_BINARY to the sing-box executable")
	}
	dir := t.TempDir()
	certificate(t, dir)
	target, err := net.Listen("tcp4", "0.0.0.0:0")
	check(t, err)
	payload := bytes.Repeat([]byte("half-close-test!"), 4096)
	done := make(chan error, 1)
	t.Cleanup(func() { target.Close(); <-done })
	go func() { done <- echoAfterEOF(target, payload); close(done) }()

	var ports [2]int
	var reservations []net.Listener
	for i := range ports {
		listener, err := net.Listen("tcp4", "0.0.0.0:0")
		check(t, err)
		t.Cleanup(func() { listener.Close() })
		reservations = append(reservations, listener)
		ports[i] = listener.Addr().(*net.TCPAddr).Port
	}
	const uuid = "00112233-4455-6677-8899-aabbccddeeff"
	config, err := json.Marshal(map[string]any{
		"log": map[string]any{"disabled": true},
		"inbounds": []any{
			map[string]any{
				"type": "vless", "tag": "server", "listen": "0.0.0.0", "listen_port": ports[0],
				"users": []any{map[string]any{"uuid": uuid, "flow": flow}},
				"tls":   map[string]any{"enabled": true, "certificate_path": filepath.Join(dir, "cert.pem"), "key_path": filepath.Join(dir, "key.pem")},
			},
			map[string]any{
				"type": "direct", "tag": "client", "listen": "0.0.0.0", "listen_port": ports[1], "network": "tcp",
				"override_address": "127.0.0.1", "override_port": target.Addr().(*net.TCPAddr).Port,
			},
		},
		"outbounds": []any{
			map[string]any{
				"type": "vless", "tag": "proxy", "server": "127.0.0.1", "server_port": ports[0], "uuid": uuid, "flow": flow,
				"tls": map[string]any{"enabled": true, "insecure": true, "server_name": "localhost"},
			},
			map[string]any{"type": "direct", "tag": "direct"},
		},
		"route": map[string]any{
			"rules": []any{map[string]any{"inbound": []string{"client"}, "action": "route", "outbound": "proxy"}},
			"final": "direct",
		},
	})
	check(t, err)
	configPath := filepath.Join(dir, "config.json")
	check(t, os.WriteFile(configPath, config, 0o600))
	for _, listener := range reservations {
		check(t, listener.Close())
	}
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	t.Cleanup(cancel)
	cmd := exec.CommandContext(ctx, binary, "run", "-c", configPath)
	cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
	check(t, cmd.Start())
	t.Cleanup(func() { cancel(); cmd.Wait() })
	dial(t, ports[0]).Close()
	conn := dial(t, ports[1])
	t.Cleanup(func() { conn.Close() })
	check(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
	_, err = io.Copy(conn, bytes.NewReader(payload))
	check(t, err)
	check(t, conn.CloseWrite())
	reply, err := io.ReadAll(conn)
	check(t, err)
	t.Logf("sent %d bytes; received %d bytes after CloseWrite", len(payload), len(reply))
	if !bytes.Equal(reply, payload) {
		t.Fatal("response truncated or corrupted")
	}
	check(t, <-done)
}

func echoAfterEOF(listener net.Listener, payload []byte) error {
	conn, err := listener.Accept()
	if err != nil {
		return err
	}
	defer conn.Close()
	if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
		return err
	}
	request, err := io.ReadAll(conn) // Do not reply until upload EOF.
	if err != nil {
		return err
	}
	if !bytes.Equal(request, payload) {
		return fmt.Errorf("destination received %d bytes; want %d identical bytes", len(request), len(payload))
	}
	_, err = io.Copy(conn, bytes.NewReader(request))
	return err
}

func dial(t *testing.T, port int) *net.TCPConn {
	t.Helper()
	deadline := time.Now().Add(5 * time.Second)
	for {
		conn, err := net.DialTimeout("tcp4", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
		if err == nil {
			return conn.(*net.TCPConn)
		}
		if time.Now().After(deadline) {
			t.Fatal(err)
		}
		time.Sleep(10 * time.Millisecond)
	}
}

func certificate(t *testing.T, dir string) {
	t.Helper()
	pub, key, err := ed25519.GenerateKey(rand.Reader)
	check(t, err)
	template := &x509.Certificate{
		SerialNumber: big.NewInt(1), DNSNames: []string{"localhost"},
		NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
		KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, pub, key)
	check(t, err)
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	check(t, err)
	check(t, os.WriteFile(filepath.Join(dir, "cert.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600))
	check(t, os.WriteFile(filepath.Join(dir, "key.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
}

func check(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

cfal added a commit to cfal/shoes that referenced this pull request Oct 18, 2026
Expand legacy and encrypted VLESS coverage across peer transports, Vision transitions, session lifecycle, replay isolation, and chained connections, with Xray updated to 26.10.17. Preserve complete XHTTP HTTP/2 responses when peers gracefully stop an unfinished upload while continuing to reject truncated responses and incomplete packet uploads. Isolate the pinned sing-box server TLS 1.2 shutdown limitation in a test-only workaround, retaining an ignored reproducer and a reference to SagerNet/sing-vmess#16 for removal when the peer pin includes the fix.
@cfal cfal closed this Oct 27, 2026
@cfal cfal reopened this Oct 27, 2026
@cfal
cfal force-pushed the vision-half-close-fix branch from 336bc5f to fff99f3 Compare October 27, 2026 01:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant