mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-11-09 19:52:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead185fb84 | ||
|
|
669c01a5d4 | ||
|
|
bedf31970c | ||
|
|
676eaf66f8 | ||
|
|
af8f9673af | ||
|
|
924a5e7814 | ||
|
|
2a773ffb3d | ||
|
|
3a88ccb2d3 | ||
|
|
a5d52d2d95 | ||
|
|
3d3a504159 | ||
|
|
2d287bbdee | ||
|
|
5745dea2b4 | ||
|
|
2e4c13d8c4 | ||
|
|
bb335c6b0c | ||
|
|
043d2e47fc | ||
|
|
36756ad672 | ||
|
|
abecd7d9f1 |
@@ -9,8 +9,8 @@ ARG TARGETARCH
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags "-s -w -buildid=" ./main
|
||||
|
||||
# Download geodat into a staging directory
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geoip/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geosite/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
ADD https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
|
||||
RUN mkdir -p /tmp/empty
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ ARG TARGETARCH
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags "-s -w -buildid=" ./main
|
||||
|
||||
# Download geodat into a staging directory
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geoip/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geosite/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
ADD https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://github.com/XTLS/Xray-rules-dat/releases/latest/download/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
|
||||
RUN mkdir -p /tmp/empty
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.7.0
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
# go build -o build_assets/wxray.exe -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-H windowsgui -X github.com/xtls/xray-core/core.build=${COMMID} -s -w -buildid=" -v ./main
|
||||
elif [[ ${GOOS} == 'android' ]]; then
|
||||
echo 'Building Xray for Android...'
|
||||
go build -o build_assets/xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=${COMMID} -s -w -buildid= -checklinkname=0" -v ./main
|
||||
go build -o build_assets/xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=${COMMID} -s -w -buildid= -checklinkname=0 -extldflags=-Wl,--pack-dyn-relocs=android" -v ./main
|
||||
else
|
||||
echo 'Building Xray...'
|
||||
if [[ ${GOARCH} == 'mips' || ${GOARCH} == 'mipsle' ]]; then
|
||||
|
||||
@@ -234,8 +234,8 @@ CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ld
|
||||
For Android:
|
||||
|
||||
```bash
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=1 CC=/path/to/aarch64-linux-android24-clang go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=REPLACE -s -w -buildid= -checklinkname=0" -v ./main
|
||||
GOOS=android GOARCH=amd64 CGO_ENABLED=1 CC=/path/to/x86_64-linux-android24-clang go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=REPLACE -s -w -buildid= -checklinkname=0" -v ./main
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=1 CC=/path/to/aarch64-linux-android24-clang go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=REPLACE -s -w -buildid= -checklinkname=0 -extldflags=-Wl,--pack-dyn-relocs=android" -v ./main
|
||||
GOOS=android GOARCH=amd64 CGO_ENABLED=1 CC=/path/to/x86_64-linux-android24-clang go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags="-X github.com/xtls/xray-core/core.build=REPLACE -s -w -buildid= -checklinkname=0 -extldflags=-Wl,--pack-dyn-relocs=android" -v ./main
|
||||
```
|
||||
|
||||
If you are compiling a 32-bit MIPS/MIPSLE target, use this command instead:
|
||||
|
||||
@@ -51,7 +51,17 @@ func (m *Manager) AddHandler(ctx context.Context, handler inbound.Handler) error
|
||||
}
|
||||
|
||||
if m.running {
|
||||
return handler.Start()
|
||||
if err := handler.Start(); err != nil {
|
||||
if len(tag) > 0 {
|
||||
delete(m.taggedHandlers, tag)
|
||||
} else {
|
||||
m.untaggedHandlers = m.untaggedHandlers[:len(m.untaggedHandlers)-1]
|
||||
}
|
||||
if err := handler.Close(); err != nil {
|
||||
errors.LogWarningInner(ctx, err, "failed to close handler ", tag)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -122,8 +122,8 @@ func NewHandler(ctx context.Context, config *core.OutboundHandlerConfig) (outbou
|
||||
if config.Concurrency < 0 {
|
||||
h.mux = &mux.ClientManager{Enabled: false}
|
||||
}
|
||||
if config.Concurrency == 0 {
|
||||
config.Concurrency = 8 // same as before
|
||||
if config.Concurrency >= 0 && config.Concurrency <= 128 {
|
||||
config.Concurrency = 128
|
||||
}
|
||||
if config.Concurrency > 0 {
|
||||
h.mux = &mux.ClientManager{
|
||||
@@ -134,7 +134,7 @@ func NewHandler(ctx context.Context, config *core.OutboundHandlerConfig) (outbou
|
||||
Dialer: h,
|
||||
Strategy: mux.ClientStrategy{
|
||||
MaxConcurrency: uint32(config.Concurrency),
|
||||
MaxConnection: 128,
|
||||
MaxConnection: 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -155,7 +155,7 @@ func NewHandler(ctx context.Context, config *core.OutboundHandlerConfig) (outbou
|
||||
Dialer: h,
|
||||
Strategy: mux.ClientStrategy{
|
||||
MaxConcurrency: uint32(config.XudpConcurrency),
|
||||
MaxConnection: 128,
|
||||
MaxConnection: 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -236,6 +236,10 @@ func (w *BufferedWriter) Close() error {
|
||||
return common.Close(w.writer)
|
||||
}
|
||||
|
||||
func (w *BufferedWriter) Interrupt() {
|
||||
common.Interrupt(w.writer)
|
||||
}
|
||||
|
||||
// SequentialWriter is a Writer that writes MultiBuffer sequentially into the underlying io.Writer.
|
||||
type SequentialWriter struct {
|
||||
io.Writer
|
||||
|
||||
@@ -188,6 +188,9 @@ var (
|
||||
|
||||
// NewClientWorker creates a new mux.Client.
|
||||
func NewClientWorker(stream transport.Link, s ClientStrategy) (*ClientWorker, error) {
|
||||
writer := buf.NewBufferedWriter(stream.Writer)
|
||||
writer.SetBuffered(false)
|
||||
stream.Writer = writer
|
||||
c := &ClientWorker{
|
||||
sessionManager: NewSessionManager(),
|
||||
link: stream,
|
||||
|
||||
@@ -93,9 +93,11 @@ type ServerWorker struct {
|
||||
}
|
||||
|
||||
func NewServerWorker(ctx context.Context, d routing.Dispatcher, link *transport.Link) (*ServerWorker, error) {
|
||||
writer := buf.NewBufferedWriter(link.Writer)
|
||||
writer.SetBuffered(false)
|
||||
worker := &ServerWorker{
|
||||
dispatcher: d,
|
||||
link: link,
|
||||
link: &transport.Link{Reader: link.Reader, Writer: writer},
|
||||
sessionManager: NewSessionManager(),
|
||||
done: done.New(),
|
||||
timer: time.NewTicker(60 * time.Second),
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ import (
|
||||
|
||||
var (
|
||||
Version_x byte = 26
|
||||
Version_y byte = 10
|
||||
Version_z byte = 31
|
||||
Version_y byte = 11
|
||||
Version_z byte = 9
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -16,11 +16,11 @@ require (
|
||||
github.com/pelletier/go-toml v1.9.5
|
||||
github.com/pion/stun/v3 v3.1.7
|
||||
github.com/pires/go-proxyproto v0.16.0
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261014003643-b2e5ff8060be
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261108164504-a13c59ffe98b
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8
|
||||
github.com/xtls/reality v0.0.0-20261108101329-4bb46b2afd8b
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||
golang.org/x/crypto v0.58.0
|
||||
|
||||
@@ -67,8 +67,8 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261014003643-b2e5ff8060be h1:gpxlwEH6mzilrkEdDRoW5Gw6nuHFjie0xnf2cbtdW+E=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261014003643-b2e5ff8060be/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261108164504-a13c59ffe98b h1:Mi/DuaTxHeNIQ9g1ylZicAqa6Cb9wkHRiNtQH9uZ7Ts=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20261108164504-a13c59ffe98b/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
@@ -81,8 +81,8 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs=
|
||||
github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY=
|
||||
github.com/xtls/reality v0.0.0-20261108101329-4bb46b2afd8b h1:fAn11r21QiUY2oAGwENWb5ummjhWwMcs2WzkbjDMh+k=
|
||||
github.com/xtls/reality v0.0.0-20261108101329-4bb46b2afd8b/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@ package conf
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/xtls/xray-core/app/geodata"
|
||||
@@ -19,11 +20,20 @@ func (c *GeodataAssetConfig) Build() (*geodata.Asset, error) {
|
||||
return nil, errors.New("invalid geodata asset url: ", c.URL).Base(err)
|
||||
}
|
||||
return &geodata.Asset{
|
||||
Url: c.URL,
|
||||
Url: migrateGeodataURL(c.URL),
|
||||
File: c.File,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func migrateGeodataURL(assetURL string) string {
|
||||
if strings.HasSuffix(strings.ToLower(assetURL), ".dat") && (strings.HasPrefix(strings.ToLower(assetURL), "https://github.com/xtls/xray-rules-dat/") ||
|
||||
strings.HasPrefix(strings.ToLower(assetURL), "https://raw.githubusercontent.com/xtls/xray-rules-dat/")) {
|
||||
errors.PrintDeprecatedFeatureWarning(assetURL, assetURL+".gz")
|
||||
return assetURL + ".gz"
|
||||
}
|
||||
return assetURL
|
||||
}
|
||||
|
||||
func validateHTTPS(s string) error {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
|
||||
+3
-3
@@ -223,16 +223,16 @@ To make it start, wintun.dll specific for your Windows/arch must be present next
|
||||
|
||||
After the start network adapter with the name you chose in the config will be created in the system, and exist while Xray is running. A Wintun adapter that already has that name is reused.
|
||||
|
||||
With IPv6 disabled system-wide (the `DisabledComponents` registry value), the adapter has no IPv6, and the TUN starts with IPv4 only, unless `gateway` or `autoSystemRoutingTable` has IPv6 entries, which then keep it from starting.
|
||||
With IPv6 turned off by the `DisabledComponents` registry value (0x10 set, as in 0xFF), errors setting up IPv6 for `gateway`, `dns` and `autoSystemRoutingTable`, clearing the IPv6 DNS servers when `dns` has none included, are only logged. Otherwise, they keep the TUN from starting.
|
||||
|
||||
When `dns` is set, those servers are applied to the adapter. Windows is kept from registering the TUN's addresses in DNS, and its DNS cache is flushed when the TUN starts and stops. Xray's own lookups through the system's DNS servers, like those of the `localhost` DNS server with `autoOutboundsInterface`, skip them, unless another interface uses them as well, as they would lead back into the TUN.
|
||||
|
||||
Setting `autoSystemWfpBlockLeak` to at least `["dns"]` is recommended, to block DNS leaks. It needs `autoSystemRoutingTable` (the config is rejected otherwise), and adds Windows Filtering Platform filters that let traffic of every program but Xray itself leave only through the TUN, each kind chosen by a value:
|
||||
Setting `autoSystemWfpBlockLeak` to at least `["dns", "misconfigtun"]` is recommended, to block DNS leaks and the traffic of an IP version without routes. It needs `autoSystemRoutingTable` (the config is rejected otherwise), and adds Windows Filtering Platform filters that let traffic of every program but Xray itself leave only through the TUN, each kind chosen by a value:
|
||||
- `"dns"`: stands for `"53"`, `"853"` (DNS over TLS and QUIC) and `"windnsclient"`. Windows also queries the DNS servers of the other interfaces, through those interfaces whatever the routes say, and other programs reach a resolver on the local network (e.g. `192.168.1.1` from DHCP) through its more specific route.
|
||||
- `"windnsclient"`: Windows' DNS Client service, except for name resolution on the local network (LLMNR, mDNS). Since Windows 11 and Server 2022, it may query over HTTPS or TLS, to any port; earlier versions only query port 53, so there it does nothing.
|
||||
- `"tcp"`, `"udp"`: that protocol, to all ports, or with `/<port>` or `/<low>-<high>` (1 to 65535) to some, e.g. `"tcp/853"` or `"udp/10-20"`. The ports alone stand for both, e.g. `"53"` or `"10-20"`.
|
||||
- `"icmp"`: ICMP and ICMPv6, e.g. `ping`.
|
||||
- `"misconfigtun"`: an IP version without routes in `autoSystemRoutingTable`, which would bypass the TUN.
|
||||
- `"misconfigtun"`: an IP version without routes in `autoSystemRoutingTable`, which would bypass the TUN. With IPv6 turned off by `DisabledComponents`, it covers IPv6 whatever the routes, as IPv6 setup errors are then only logged, and IPv6 still works until the reboot that applies the value.
|
||||
- `"all"`: everything, in both directions. Access to the local network then breaks unless it is routed into the TUN as well, and so do incoming connections to other programs, other VPNs, and virtual machines whose NAT runs on the host.
|
||||
- `"allexceptlan"`: like `"all"`, but traffic to and from the local network stays allowed, e.g. for printers or casting to a TV: private, link-local, multicast and broadcast addresses (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `224.0.0.0/4`, `255.255.255.255`, `fc00::/7`, `fe80::/10`, `ff00::/8`), not global IPv6 ones. It includes `"dns"`, and the other values apply there as well. `"all"` overrides it.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"golang.zx2c4.com/wintun"
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
)
|
||||
@@ -98,6 +99,19 @@ func open(name, desc string) (*wintun.Adapter, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ipv6Off tells whether DisabledComponents turns IPv6 off on the non-tunnel
|
||||
// interfaces (0x10, as 0xFF does), the TUN among them. Windows applies it at
|
||||
// boot only.
|
||||
func ipv6Off() bool {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer key.Close()
|
||||
value, _, err := key.GetIntegerValue("DisabledComponents")
|
||||
return err == nil && value&0x10 != 0
|
||||
}
|
||||
|
||||
func (t *WindowsTun) Start() (err error) {
|
||||
var address4, address6 bool
|
||||
addresses := make([]netip.Prefix, 0, len(t.options.Gateway))
|
||||
@@ -139,6 +153,17 @@ func (t *WindowsTun) Start() (err error) {
|
||||
routesData = append(routesData, &r)
|
||||
}
|
||||
|
||||
// With IPv6 off by DisabledComponents, setting it up may fail, clearing
|
||||
// its DNS servers too, which is then only logged.
|
||||
off6 := ipv6Off()
|
||||
ignored := func(family winipcfg.AddressFamily, err error) bool {
|
||||
if family != windows.AF_INET6 || !off6 {
|
||||
return false
|
||||
}
|
||||
errors.LogErrorInner(context.Background(), err, "[tun] ignored, as DisabledComponents turns IPv6 off")
|
||||
return true
|
||||
}
|
||||
|
||||
var retryTimes int
|
||||
var firstErr error
|
||||
startOver:
|
||||
@@ -155,6 +180,9 @@ startOver:
|
||||
err = t.luid.SetRoutesForFamily(family, routesData)
|
||||
if err != nil {
|
||||
firstErr = errors.New("unable to set routes").Base(err)
|
||||
if ignored(family, firstErr) {
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_NOT_FOUND {
|
||||
goto startOver
|
||||
}
|
||||
@@ -165,6 +193,9 @@ startOver:
|
||||
err = t.luid.SetIPAddressesForFamily(family, addresses)
|
||||
if err != nil {
|
||||
firstErr = errors.New("unable to set ips").Base(err)
|
||||
if ignored(family, firstErr) {
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_NOT_FOUND {
|
||||
goto startOver
|
||||
}
|
||||
@@ -173,9 +204,7 @@ startOver:
|
||||
}
|
||||
ipif, err := t.luid.IPInterface(family)
|
||||
if err != nil {
|
||||
// With IPv6 disabled system-wide (DisabledComponents), the adapter has no
|
||||
// IPv6 interface at all. Skip the family unless the config asks for it.
|
||||
if err == windows.ERROR_NOT_FOUND && family == windows.AF_INET6 && !address6 && !route6 {
|
||||
if ignored(family, errors.New("unable to get the interface").Base(err)) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
@@ -194,6 +223,9 @@ startOver:
|
||||
err = ipif.Set()
|
||||
if err != nil {
|
||||
firstErr = errors.New("unable to set metric and MTU").Base(err)
|
||||
if ignored(family, firstErr) {
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_NOT_FOUND {
|
||||
goto startOver
|
||||
}
|
||||
@@ -201,13 +233,10 @@ startOver:
|
||||
}
|
||||
err = t.luid.SetDNS(family, dns, nil)
|
||||
if err != nil {
|
||||
// Skip IPv6 the config does not ask for also when setting its DNS
|
||||
// servers fails, as reported on Windows 11 24H2 with IPv6 disabled
|
||||
// (Access is denied).
|
||||
if family == windows.AF_INET6 && !address6 && !route6 {
|
||||
firstErr = errors.New("unable to set DNS").Base(err)
|
||||
if ignored(family, firstErr) {
|
||||
continue
|
||||
}
|
||||
firstErr = errors.New("unable to set DNS").Base(err)
|
||||
if err == windows.ERROR_NOT_FOUND {
|
||||
goto startOver
|
||||
}
|
||||
@@ -264,7 +293,10 @@ startOver:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block := newLeakFilters(leaks, route4, route6)
|
||||
// With IPv6 off by DisabledComponents, IPv6 setup failures are only logged,
|
||||
// and IPv6 still works until the reboot that applies it. So "misconfigtun"
|
||||
// then blocks IPv6 outside the TUN whatever the routes.
|
||||
block := newLeakFilters(leaks, route4, route6 && !off6)
|
||||
if route4 || route6 {
|
||||
dnsClient := func(l leak) bool { return l.name == "windnsclient" }
|
||||
if slices.ContainsFunc(block, dnsClient) && !dnsClientUsesAnyPort() {
|
||||
|
||||
@@ -275,6 +275,7 @@ var ModernFingerprints = map[string]*utls.ClientHelloID{
|
||||
"hellochrome_120": &utls.HelloChrome_120,
|
||||
"hellochrome_131": &utls.HelloChrome_131,
|
||||
"hellochrome_133": &utls.HelloChrome_133,
|
||||
"hellochrome_155": &utls.HelloChrome_155,
|
||||
"helloios_13": &utls.HelloIOS_13,
|
||||
"helloios_14": &utls.HelloIOS_14,
|
||||
"helloedge_106": &utls.HelloEdge_106,
|
||||
|
||||
@@ -433,14 +433,22 @@ func (b *doneBody) Close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// a connection that has not answered yet is given this long before its
|
||||
// timeouts retire it, so that a dial or handshake is not cut short again
|
||||
var stalledGrace = 12 * time.Second
|
||||
|
||||
// sessionTransport sends the requests of one session over a connection of
|
||||
// XMUX, and over another once that one has no requests or time left
|
||||
type sessionTransport struct {
|
||||
pool *connPool
|
||||
|
||||
mu sync.Mutex
|
||||
client *splithttp.XmuxClient
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
client *splithttp.XmuxClient
|
||||
closed bool
|
||||
slowAt, retiredAt time.Time
|
||||
movedAt time.Time
|
||||
takenAt time.Time
|
||||
served bool
|
||||
}
|
||||
|
||||
func (t *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
@@ -450,7 +458,7 @@ func (t *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return nil, errSessionClosed
|
||||
}
|
||||
client := t.client
|
||||
if client == nil || client.LeftRequests.Add(-1) < 0 || !client.UnreusableAt.IsZero() && time.Now().After(client.UnreusableAt) {
|
||||
if client == nil || client.XmuxConn.IsClosed() || client.LeftRequests.Add(-1) < 0 || !client.UnreusableAt.IsZero() && time.Now().After(client.UnreusableAt) {
|
||||
t.pool.mu.Lock()
|
||||
t.client = t.pool.xmux.GetXmuxClient(r.Context())
|
||||
t.pool.mu.Unlock()
|
||||
@@ -458,8 +466,10 @@ func (t *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
t.client.LeftRequests.Add(-1)
|
||||
if client != nil {
|
||||
client.DoneRunning()
|
||||
t.movedAt = time.Now()
|
||||
}
|
||||
client = t.client
|
||||
t.takenAt, t.served = time.Now(), false
|
||||
}
|
||||
// counted under mu, or another request of the session that moves on could
|
||||
// close the connection before this one is sent
|
||||
@@ -468,7 +478,34 @@ func (t *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
conn.inflight++
|
||||
conn.mu.Unlock()
|
||||
t.mu.Unlock()
|
||||
return conn.roundTrip(r)
|
||||
resp, err := conn.roundTrip(r)
|
||||
if err == nil {
|
||||
t.mu.Lock()
|
||||
if t.client == client {
|
||||
t.served = true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (t *sessionTransport) stalled(began time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if began.Before(t.movedAt) {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if !t.served && now.Sub(t.takenAt) < stalledGrace {
|
||||
return
|
||||
}
|
||||
slow := now.Sub(t.slowAt) < 3*time.Second
|
||||
t.slowAt = now
|
||||
if !slow || t.client == nil || now.Sub(t.retiredAt) < 2*time.Second {
|
||||
return
|
||||
}
|
||||
t.retiredAt = now
|
||||
t.client.XmuxConn.(*poolConn).Close()
|
||||
}
|
||||
|
||||
func (t *sessionTransport) Close() {
|
||||
|
||||
@@ -987,3 +987,157 @@ func TestDriveXmuxOverH3(t *testing.T) {
|
||||
}
|
||||
waitFor(t, "the first connection of XMUX to close", func() bool { return server.closed.Load() == 1 })
|
||||
}
|
||||
|
||||
type stallConn struct{}
|
||||
|
||||
func (stallConn) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
<-r.Context().Done()
|
||||
return nil, r.Context().Err()
|
||||
}
|
||||
|
||||
type okConn struct{}
|
||||
|
||||
func (okConn) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"file-1"}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestSessionRetiresStalledConn(t *testing.T) {
|
||||
oldGrace := stalledGrace
|
||||
stalledGrace = 0
|
||||
t.Cleanup(func() { stalledGrace = oldGrace })
|
||||
made := atomic.Int32{}
|
||||
transport := &sessionTransport{pool: newConnPool(nil, func(connCtx context.Context) *http.Client {
|
||||
if made.Add(1) == 1 {
|
||||
return &http.Client{Transport: stallConn{}}
|
||||
}
|
||||
return &http.Client{Transport: okConn{}}
|
||||
})}
|
||||
storage := &driveStorage{
|
||||
driveShared: &driveShared{
|
||||
token: "token",
|
||||
tokenExpiry: time.Now().Add(time.Hour),
|
||||
backoff: time.Millisecond,
|
||||
},
|
||||
client: &http.Client{Transport: transport},
|
||||
inflight: make(chan struct{}, 1),
|
||||
reserved: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), attemptKey{}, 100*time.Millisecond)
|
||||
code, body, err := storage.do(ctx, http.MethodPost, "http://drive.invalid/files", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the first request over a stalled connection: %v", err)
|
||||
}
|
||||
if code != http.StatusOK || string(body) != `{"id":"file-1"}` {
|
||||
t.Fatalf("the stalled connection gave %d %q", code, body)
|
||||
}
|
||||
if n := made.Load(); n != 2 {
|
||||
t.Fatalf("a stalled connection took %d connections, want it retired and one made after it", n)
|
||||
}
|
||||
if _, _, err := storage.do(ctx, http.MethodPost, "http://drive.invalid/files", "", nil); err != nil {
|
||||
t.Fatalf("the request after the retirement: %v", err)
|
||||
}
|
||||
if n := made.Load(); n != 2 {
|
||||
t.Fatalf("%d connections were made, want the healthy one kept", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIgnoresStallOfRetiredConn(t *testing.T) {
|
||||
var made atomic.Int32
|
||||
transport := &sessionTransport{pool: newConnPool(nil, func(connCtx context.Context) *http.Client {
|
||||
made.Add(1)
|
||||
return &http.Client{Transport: okConn{}}
|
||||
})}
|
||||
send := func() {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://drive.invalid/files", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
send()
|
||||
old := time.Now()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
transport.stalled(old)
|
||||
transport.stalled(old)
|
||||
send()
|
||||
if n := made.Load(); n != 2 {
|
||||
t.Fatalf("a stalled connection took %d connections, want it retired and one made after it", n)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
transport.slowAt, transport.retiredAt = time.Time{}, time.Time{}
|
||||
transport.mu.Unlock()
|
||||
transport.stalled(old)
|
||||
transport.stalled(old)
|
||||
send()
|
||||
if n := made.Load(); n != 2 {
|
||||
t.Fatalf("the stall of a retired connection took the healthy one too, %d connections", n)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
fresh := time.Now()
|
||||
transport.stalled(fresh)
|
||||
transport.stalled(fresh)
|
||||
send()
|
||||
if n := made.Load(); n != 3 {
|
||||
t.Fatalf("a stall of the current connection made %d connections, want it retired", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSparesFreshConn(t *testing.T) {
|
||||
transport := &sessionTransport{pool: newConnPool(nil, func(connCtx context.Context) *http.Client {
|
||||
return &http.Client{Transport: okConn{}}
|
||||
})}
|
||||
send := func() {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://drive.invalid/files", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
current := func() *poolConn {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
return transport.client.XmuxConn.(*poolConn)
|
||||
}
|
||||
send()
|
||||
conn := current()
|
||||
transport.mu.Lock()
|
||||
transport.takenAt, transport.served = time.Now(), false
|
||||
transport.mu.Unlock()
|
||||
transport.stalled(time.Now())
|
||||
transport.stalled(time.Now())
|
||||
if conn.IsClosed() {
|
||||
t.Fatal("a fresh connection was retired before its grace period ended")
|
||||
}
|
||||
transport.mu.Lock()
|
||||
transport.takenAt = time.Now().Add(-stalledGrace - time.Second)
|
||||
transport.mu.Unlock()
|
||||
transport.stalled(time.Now())
|
||||
transport.stalled(time.Now())
|
||||
if !conn.IsClosed() {
|
||||
t.Fatal("a fresh connection older than its grace period was not retired")
|
||||
}
|
||||
send()
|
||||
conn = current()
|
||||
transport.mu.Lock()
|
||||
transport.takenAt, transport.served = time.Now(), true
|
||||
transport.slowAt, transport.retiredAt = time.Time{}, time.Time{}
|
||||
transport.mu.Unlock()
|
||||
transport.stalled(time.Now())
|
||||
transport.stalled(time.Now())
|
||||
if !conn.IsClosed() {
|
||||
t.Fatal("a connection that had answered was not retired")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,12 @@ func newConn(ctx context.Context, storage Storage, writePrefix, readPrefix strin
|
||||
}
|
||||
}
|
||||
|
||||
func newSlotConn(ctx context.Context, store slotStore, write, read slotTable, p params, onClose func()) *Conn {
|
||||
func newSlotConn(ctx context.Context, store slotStore, write, read slotTable, p params, first []byte, onClose func()) *Conn {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &Conn{
|
||||
cancel: cancel,
|
||||
writer: newSlotWriter(ctx, store, write, p),
|
||||
reader: newSlotReader(ctx, store, read, p),
|
||||
reader: newSlotReader(ctx, store, read, p, first),
|
||||
onClose: onClose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/dice"
|
||||
@@ -18,14 +19,18 @@ import (
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
)
|
||||
|
||||
// testDriveIO is a nil-by-default test hook observing every Drive request
|
||||
var testDriveIO func(method, target string, body []byte)
|
||||
|
||||
const (
|
||||
flatSeparator = "~"
|
||||
|
||||
driveBoundary = "xdrive-boundary"
|
||||
drivePageSize = 1000
|
||||
driveMaxAttempts = 8
|
||||
driveMaxInflight = 32
|
||||
driveInlineLimit = 12000
|
||||
driveBoundary = "xdrive-boundary"
|
||||
drivePageSize = 1000
|
||||
driveMaxAttempts = 8
|
||||
driveMaxInflight = 64
|
||||
driveReservedInflight = 8
|
||||
driveInlineLimit = 12000
|
||||
// driveTimeout ends a request that makes no progress for that long
|
||||
driveTimeout = 60 * time.Second
|
||||
)
|
||||
@@ -36,13 +41,37 @@ var (
|
||||
driveFilesURL = "https://www.googleapis.com/drive/v3/files"
|
||||
driveUploadURL = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name"
|
||||
driveInitialBackoff = 200 * time.Millisecond
|
||||
driveCooldown = 2 * time.Second
|
||||
driveSlotRead = 3 * time.Second
|
||||
driveSlotWrite = 5 * time.Second
|
||||
tokenRefreshBackoff = 30 * time.Second
|
||||
maxTokenBackoff = 5 * time.Minute
|
||||
driveMaxResponse = 32 << 20
|
||||
driveMaxAnnounce = 1 << 20
|
||||
)
|
||||
|
||||
// maxBodyKey lets a caller tighten the response-body cap (announcements).
|
||||
type maxBodyKey struct{}
|
||||
|
||||
// tokenFlight is one shared foreground token fetch; waiters read its result
|
||||
// after done closes instead of starting their own fetch.
|
||||
type tokenFlight struct {
|
||||
done chan struct{}
|
||||
token string
|
||||
lifetime time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
type laneKey struct{}
|
||||
|
||||
type attemptKey struct{}
|
||||
|
||||
type driveStorage struct {
|
||||
*driveShared
|
||||
// a session has a client and inflight of its own, see session
|
||||
client *http.Client
|
||||
inflight chan struct{}
|
||||
reserved chan struct{}
|
||||
release func()
|
||||
}
|
||||
|
||||
@@ -59,12 +88,23 @@ type driveShared struct {
|
||||
uploadURL string
|
||||
backoff time.Duration
|
||||
|
||||
tokenMu sync.Mutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.Mutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
tokenLifetime time.Duration
|
||||
refreshing bool
|
||||
authErr error
|
||||
flight *tokenFlight
|
||||
tokenRetryAt time.Time
|
||||
tokenBackoff time.Duration
|
||||
lastTokenErr error
|
||||
|
||||
idMu sync.Mutex
|
||||
ids map[string]string
|
||||
|
||||
coolMu sync.Mutex
|
||||
coolUntil time.Time
|
||||
throttle atomic.Int64
|
||||
}
|
||||
|
||||
func newDriveStorage(streamSettings *internet.MemoryStreamConfig, config *Config) (*driveStorage, error) {
|
||||
@@ -100,6 +140,7 @@ func newDriveStorage(streamSettings *internet.MemoryStreamConfig, config *Config
|
||||
},
|
||||
client: client,
|
||||
inflight: make(chan struct{}, driveMaxInflight),
|
||||
reserved: make(chan struct{}, driveReservedInflight),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -111,6 +152,7 @@ func (s *driveStorage) session() Storage {
|
||||
driveShared: s.driveShared,
|
||||
client: &http.Client{Transport: transport},
|
||||
inflight: make(chan struct{}, driveMaxInflight),
|
||||
reserved: make(chan struct{}, driveReservedInflight),
|
||||
release: transport.Close,
|
||||
}
|
||||
}
|
||||
@@ -123,14 +165,130 @@ func quoteDriveValue(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(value)
|
||||
}
|
||||
|
||||
type authError struct{ code string }
|
||||
|
||||
func (e authError) Error() string {
|
||||
return "Google rejected the OAuth credentials (" + e.code + "); create a new refresh token, see the XDRIVE setup guide"
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
_, ok := err.(authError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *driveStorage) accessToken(ctx context.Context) (string, error) {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
|
||||
if s.token != "" && time.Now().Before(s.tokenExpiry) {
|
||||
return s.token, nil
|
||||
if s.authErr != nil {
|
||||
err := s.authErr
|
||||
s.tokenMu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
if s.token != "" && time.Now().Before(s.tokenExpiry) {
|
||||
if s.tokenLifetime > 0 && time.Until(s.tokenExpiry) < s.tokenLifetime/5 && !s.refreshing && time.Now().After(s.tokenRetryAt) {
|
||||
s.refreshing = true
|
||||
go s.refreshTokenBackground()
|
||||
}
|
||||
token := s.token
|
||||
s.tokenMu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
if f := s.flight; f != nil {
|
||||
s.tokenMu.Unlock()
|
||||
select {
|
||||
case <-f.done:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return f.token, nil
|
||||
}
|
||||
if time.Now().Before(s.tokenRetryAt) && s.lastTokenErr != nil {
|
||||
err := s.lastTokenErr
|
||||
s.tokenMu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
f := &tokenFlight{done: make(chan struct{})}
|
||||
s.flight = f
|
||||
s.tokenMu.Unlock()
|
||||
|
||||
// The shared fetch must outlive any single waiter's cancellation.
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
token, lifetime, err := s.fetchToken(fetchCtx)
|
||||
cancel()
|
||||
|
||||
s.tokenMu.Lock()
|
||||
s.flight = nil
|
||||
f.token, f.lifetime, f.err = token, lifetime, err
|
||||
if err != nil {
|
||||
s.lastTokenErr = err
|
||||
if isAuthError(err) {
|
||||
s.authErr = err
|
||||
errors.LogError(ctx, err)
|
||||
}
|
||||
s.noteTokenFailure()
|
||||
} else {
|
||||
s.storeToken(token, lifetime)
|
||||
s.tokenBackoff = 0
|
||||
s.tokenRetryAt = time.Time{}
|
||||
s.lastTokenErr = nil
|
||||
}
|
||||
close(f.done)
|
||||
s.tokenMu.Unlock()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// noteTokenFailure backs off further token fetches so a failing endpoint is
|
||||
// not hit once per API call; called with tokenMu held.
|
||||
func (s *driveStorage) noteTokenFailure() {
|
||||
if s.tokenBackoff == 0 {
|
||||
s.tokenBackoff = tokenRefreshBackoff
|
||||
} else {
|
||||
s.tokenBackoff = min(2*s.tokenBackoff, maxTokenBackoff)
|
||||
}
|
||||
s.tokenRetryAt = time.Now().Add(s.tokenBackoff)
|
||||
}
|
||||
|
||||
func (s *driveStorage) storeToken(token string, lifetime time.Duration) {
|
||||
s.token = token
|
||||
s.tokenLifetime = lifetime
|
||||
expiry := lifetime
|
||||
if expiry > time.Minute {
|
||||
expiry -= time.Minute
|
||||
}
|
||||
s.tokenExpiry = time.Now().Add(expiry)
|
||||
}
|
||||
|
||||
func (s *driveStorage) refreshTokenBackground() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
token, lifetime, err := s.fetchToken(ctx)
|
||||
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
s.refreshing = false
|
||||
if err != nil {
|
||||
if isAuthError(err) && s.authErr == nil {
|
||||
s.authErr = err
|
||||
errors.LogError(ctx, err)
|
||||
}
|
||||
s.lastTokenErr = err
|
||||
s.noteTokenFailure()
|
||||
return
|
||||
}
|
||||
if s.authErr == nil {
|
||||
s.storeToken(token, lifetime)
|
||||
s.tokenBackoff = 0
|
||||
s.tokenRetryAt = time.Time{}
|
||||
s.lastTokenErr = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *driveStorage) fetchToken(ctx context.Context) (string, time.Duration, error) {
|
||||
form := url.Values{
|
||||
"client_id": {s.clientID},
|
||||
"client_secret": {s.clientSecret},
|
||||
@@ -139,22 +297,31 @@ func (s *driveStorage) accessToken(ctx context.Context) (string, error) {
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", errors.New("failed to build the token request").Base(err)
|
||||
return "", 0, errors.New("failed to build the token request").Base(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.tokenClient.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to refresh the access token").Base(err)
|
||||
return "", 0, errors.New("failed to refresh the access token").Base(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", errors.New("failed to read the token response").Base(err)
|
||||
return "", 0, errors.New("failed to read the token response").Base(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("the token endpoint answered ", resp.StatusCode, ": ", string(body))
|
||||
var rejected struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &rejected) == nil {
|
||||
switch rejected.Error {
|
||||
case "invalid_grant", "invalid_client", "unauthorized_client":
|
||||
return "", 0, authError{rejected.Error}
|
||||
}
|
||||
}
|
||||
return "", 0, errors.New("the token endpoint answered ", resp.StatusCode, ": ", string(body))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
@@ -162,19 +329,12 @@ func (s *driveStorage) accessToken(ctx context.Context) (string, error) {
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", errors.New("failed to parse the token response").Base(err)
|
||||
return "", 0, errors.New("failed to parse the token response").Base(err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", errors.New("the token endpoint returned no access token")
|
||||
return "", 0, errors.New("the token endpoint returned no access token")
|
||||
}
|
||||
|
||||
lifetime := parsed.ExpiresIn
|
||||
if lifetime > 60 {
|
||||
lifetime -= 60
|
||||
}
|
||||
s.token = parsed.AccessToken
|
||||
s.tokenExpiry = time.Now().Add(time.Duration(lifetime) * time.Second)
|
||||
return s.token, nil
|
||||
return parsed.AccessToken, time.Duration(parsed.ExpiresIn) * time.Second, nil
|
||||
}
|
||||
|
||||
func jitter(backoff time.Duration) time.Duration {
|
||||
@@ -231,15 +391,28 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
backoff = driveMaxBackoff
|
||||
}
|
||||
}
|
||||
if err := s.waitCool(ctx); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if testDriveIO != nil {
|
||||
testDriveIO(method, target, body)
|
||||
}
|
||||
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
return 0, nil, err
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
pool := s.inflight
|
||||
if ctx.Value(laneKey{}) != nil {
|
||||
pool = s.reserved
|
||||
}
|
||||
select {
|
||||
case s.inflight <- struct{}{}:
|
||||
case pool <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
@@ -248,8 +421,14 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, reader)
|
||||
attemptCtx, cancel := ctx, context.CancelFunc(func() {})
|
||||
if d, ok := ctx.Value(attemptKey{}).(time.Duration); ok {
|
||||
attemptCtx, cancel = context.WithTimeout(ctx, d)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(attemptCtx, method, target, reader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
<-pool
|
||||
return 0, nil, errors.New("failed to build a Drive request").Base(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -257,9 +436,18 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
began := time.Now()
|
||||
resp, err := s.client.Do(req)
|
||||
<-s.inflight
|
||||
<-pool
|
||||
if err != nil {
|
||||
timedOut := attemptCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil
|
||||
cancel()
|
||||
if timedOut {
|
||||
s.throttle.Add(1)
|
||||
if t, ok := s.client.Transport.(*sessionTransport); ok {
|
||||
t.stalled(began)
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
@@ -268,8 +456,13 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
attempt+1, " of ", driveMaxAttempts)
|
||||
continue
|
||||
}
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
limit := int64(driveMaxResponse)
|
||||
if v, ok := ctx.Value(maxBodyKey{}).(int64); ok && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return 0, nil, ctx.Err()
|
||||
@@ -277,22 +470,31 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
lastErr = errors.New("failed to read the Drive response").Base(err)
|
||||
continue
|
||||
}
|
||||
if int64(len(payload)) > limit {
|
||||
return 0, nil, errors.New("Drive response exceeds the ", limit, " byte cap")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
s.invalidateToken()
|
||||
s.invalidateToken(token)
|
||||
lastErr = errors.New("Drive rejected the access token")
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden && rateLimited(payload) {
|
||||
lastErr = errors.New("Drive is rate limiting: ", string(payload))
|
||||
errors.LogWarning(ctx, "rate limited by Drive, attempt ",
|
||||
attempt+1, " of ", driveMaxAttempts)
|
||||
attempt+1, " of ", driveMaxAttempts, ", ", method, " ", len(body),
|
||||
" bytes, body read ", strings.Contains(target, "alt=media"),
|
||||
", lane ", ctx.Value(laneKey{}) != nil, ": ", string(payload[:min(len(payload), 300)]))
|
||||
s.cool()
|
||||
continue
|
||||
}
|
||||
if retryableStatus(resp.StatusCode) {
|
||||
lastErr = errors.New("Drive answered ", resp.StatusCode, ": ", string(payload))
|
||||
errors.LogWarning(ctx, "retrying after Drive answered ", resp.StatusCode,
|
||||
", attempt ", attempt+1, " of ", driveMaxAttempts)
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
s.cool()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return resp.StatusCode, payload, nil
|
||||
@@ -301,9 +503,36 @@ func (s *driveStorage) do(ctx context.Context, method, target, contentType strin
|
||||
return 0, nil, lastErr
|
||||
}
|
||||
|
||||
func (s *driveStorage) invalidateToken() {
|
||||
func (s *driveStorage) waitCool(ctx context.Context) error {
|
||||
s.coolMu.Lock()
|
||||
until := s.coolUntil
|
||||
s.coolMu.Unlock()
|
||||
if d := time.Until(until); d > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *driveStorage) cool() {
|
||||
s.throttle.Add(1)
|
||||
s.coolMu.Lock()
|
||||
s.coolUntil = time.Now().Add(jitter(driveCooldown))
|
||||
s.coolMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *driveStorage) throttled() int64 {
|
||||
return s.throttle.Load()
|
||||
}
|
||||
|
||||
func (s *driveStorage) invalidateToken(used string) {
|
||||
s.tokenMu.Lock()
|
||||
s.token = ""
|
||||
if s.token == used {
|
||||
s.token = ""
|
||||
}
|
||||
s.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -542,12 +771,27 @@ func (s *driveStorage) Delete(ctx context.Context, name string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sem := make(chan struct{}, 8)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var first error
|
||||
for childName, file := range children {
|
||||
if err := s.deleteID(ctx, childName, file.id); err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(name, id string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if err := s.deleteID(ctx, name, id); err != nil {
|
||||
mu.Lock()
|
||||
if first == nil {
|
||||
first = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}(childName, file.id)
|
||||
}
|
||||
return nil
|
||||
wg.Wait()
|
||||
return first
|
||||
}
|
||||
|
||||
func (s *driveStorage) List(ctx context.Context, prefix string) ([]Entry, error) {
|
||||
@@ -604,6 +848,9 @@ func (s *driveStorage) makeSlot(ctx context.Context, name string) (string, error
|
||||
|
||||
// setSlot implements slotStore, a description and a body go in one request
|
||||
func (s *driveStorage) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
if body == nil {
|
||||
ctx = context.WithValue(ctx, attemptKey{}, driveSlotWrite)
|
||||
}
|
||||
metadata, err := json.Marshal(map[string]string{"description": description})
|
||||
if err != nil {
|
||||
return errors.New("failed to build the slot metadata").Base(err)
|
||||
@@ -631,6 +878,7 @@ func (s *driveStorage) setSlot(ctx context.Context, id, description string, body
|
||||
|
||||
// readDescription implements slotStore
|
||||
func (s *driveStorage) readDescription(ctx context.Context, id string) (string, error) {
|
||||
ctx = context.WithValue(ctx, attemptKey{}, driveSlotRead)
|
||||
status, payload, err := s.do(ctx, http.MethodGet,
|
||||
s.filesURL+"/"+url.PathEscape(id)+"?fields=description&supportsAllDrives=true", "", nil)
|
||||
switch {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecodeRingCaps(t *testing.T) {
|
||||
good := ring{
|
||||
Up: slotTable{Slots: []string{"a", "b"}, Watermark: "w"},
|
||||
Down: slotTable{Slots: []string{"c", "d"}, Watermark: "w"},
|
||||
}
|
||||
b, _ := json.Marshal(good)
|
||||
if _, ok := decodeRing(b); !ok {
|
||||
t.Fatal("a normal ring was rejected")
|
||||
}
|
||||
|
||||
tooMany := good
|
||||
tooMany.Up.Slots = make([]string, maxConcurrency+1)
|
||||
for i := range tooMany.Up.Slots {
|
||||
tooMany.Up.Slots[i] = "s"
|
||||
}
|
||||
b, _ = json.Marshal(tooMany)
|
||||
if _, ok := decodeRing(b); ok {
|
||||
t.Fatalf("a ring with %d slots per table was accepted", maxConcurrency+1)
|
||||
}
|
||||
|
||||
longID := good
|
||||
longID.Up.Slots[0] = strings.Repeat("x", maxSlotIDLen+1)
|
||||
b, _ = json.Marshal(longID)
|
||||
if _, ok := decodeRing(b); ok {
|
||||
t.Fatal("a ring with an over-long slot id was accepted")
|
||||
}
|
||||
|
||||
longWM := good
|
||||
longWM.Down.Watermark = strings.Repeat("w", maxSlotIDLen+1)
|
||||
b, _ = json.Marshal(longWM)
|
||||
if _, ok := decodeRing(b); ok {
|
||||
t.Fatal("a ring with an over-long watermark id was accepted")
|
||||
}
|
||||
|
||||
emptyID := good
|
||||
emptyID.Down.Slots[1] = ""
|
||||
b, _ = json.Marshal(emptyID)
|
||||
if _, ok := decodeRing(b); ok {
|
||||
t.Fatal("a ring with an empty slot id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func capTestStorage(t *testing.T, body []byte) (*driveStorage, string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &driveStorage{
|
||||
driveShared: &driveShared{token: "t", tokenExpiry: time.Now().Add(time.Hour)},
|
||||
client: srv.Client(),
|
||||
inflight: make(chan struct{}, 1),
|
||||
reserved: make(chan struct{}, 1),
|
||||
}, srv.URL
|
||||
}
|
||||
|
||||
func TestDriveResponseBodyCap(t *testing.T) {
|
||||
old := driveMaxResponse
|
||||
driveMaxResponse = 16
|
||||
t.Cleanup(func() { driveMaxResponse = old })
|
||||
s, url := capTestStorage(t, make([]byte, 32))
|
||||
_, _, err := s.do(context.Background(), http.MethodGet, url, "", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("an over-cap body returned %v, want a cap error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAnnouncementBodyCap(t *testing.T) {
|
||||
s, url := capTestStorage(t, make([]byte, 32))
|
||||
ctx := context.WithValue(context.Background(), maxBodyKey{}, int64(8))
|
||||
_, _, err := s.do(ctx, http.MethodGet, url, "", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("an over-cap announcement returned %v, want a cap error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveNormal16MiBSegment(t *testing.T) {
|
||||
body := make([]byte, 16<<20)
|
||||
s, url := capTestStorage(t, body)
|
||||
_, payload, err := s.do(context.Background(), http.MethodGet, url, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a normal 16 MiB segment failed: %v", err)
|
||||
}
|
||||
if len(payload) != len(body) {
|
||||
t.Fatalf("read %d bytes, want %d", len(payload), len(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
)
|
||||
|
||||
func livePercentileReport(t *testing.T, label string, samples []time.Duration) {
|
||||
t.Helper()
|
||||
if len(samples) == 0 {
|
||||
t.Logf("%-36s no samples", label)
|
||||
return
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
q := func(p float64) time.Duration {
|
||||
return samples[min(int(p*float64(len(samples))), len(samples)-1)]
|
||||
}
|
||||
t.Logf("%-36s n=%d p50=%v p95=%v p99=%v max=%v",
|
||||
label, len(samples), q(0.50), q(0.95), q(0.99), samples[len(samples)-1])
|
||||
}
|
||||
|
||||
func TestLiveDrivePercentiles(t *testing.T) {
|
||||
config := liveDriveConfig(t)
|
||||
root, err := newDriveStorage(&internet.MemoryStreamConfig{}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
store := root.session().(*driveStorage)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
const n = 60
|
||||
inlineDesc := strings.Repeat("a", 10<<10)
|
||||
body := make([]byte, 256<<10)
|
||||
if _, err := rand.Read(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inlineID, err := store.makeSlot(ctx, "percentiles/inline")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.deleteID(ctx, flatten("percentiles/inline"), inlineID)
|
||||
mediaID, err := store.makeSlot(ctx, "percentiles/media")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.deleteID(ctx, flatten("percentiles/media"), mediaID)
|
||||
if err := store.setSlot(ctx, mediaID, "media", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
measure := func(label string, parallelism int, op func() error) {
|
||||
var mu sync.Mutex
|
||||
samples := make([]time.Duration, 0, n)
|
||||
var wg sync.WaitGroup
|
||||
per := n / parallelism
|
||||
for w := 0; w < parallelism; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
start := time.Now()
|
||||
err := op()
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", label, err)
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
samples = append(samples, elapsed)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
livePercentileReport(t, fmt.Sprintf("%s (p=%d)", label, parallelism), samples)
|
||||
}
|
||||
|
||||
for _, p := range []int{1, 8} {
|
||||
measure("inline setSlot 10KiB", p, func() error {
|
||||
return store.setSlot(ctx, inlineID, inlineDesc, nil)
|
||||
})
|
||||
measure("media setSlot 256KiB", p, func() error {
|
||||
return store.setSlot(ctx, mediaID, "media", body)
|
||||
})
|
||||
measure("readDescription", p, func() error {
|
||||
_, err := store.readDescription(ctx, mediaID)
|
||||
return err
|
||||
})
|
||||
measure("readBody 256KiB", p, func() error {
|
||||
_, err := store.readBody(ctx, mediaID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveDriveLatencyUnderLoad(t *testing.T) {
|
||||
config := liveDriveConfig(t)
|
||||
config.Concurrency = 64
|
||||
config.FlushIntervalMs = 10
|
||||
config.MaxPollIntervalMs = 200
|
||||
settings := &internet.MemoryStreamConfig{
|
||||
ProtocolName: protocolName,
|
||||
ProtocolSettings: config,
|
||||
}
|
||||
|
||||
run := func(withBulk bool) []time.Duration {
|
||||
client, server, cleanup := pairWith(t, settings)
|
||||
defer cleanup()
|
||||
if err := server.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var writeMu sync.Mutex
|
||||
writeFrame := func(kind byte, payload []byte) error {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
var header [5]byte
|
||||
header[0] = kind
|
||||
binary.BigEndian.PutUint32(header[1:], uint32(len(payload)))
|
||||
if _, err := client.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := client.Write(payload)
|
||||
return err
|
||||
}
|
||||
readFrame := func() (byte, []byte, error) {
|
||||
var header [5]byte
|
||||
if _, err := io.ReadFull(server, header[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
payload := make([]byte, binary.BigEndian.Uint32(header[1:]))
|
||||
if _, err := io.ReadFull(server, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return header[0], payload, nil
|
||||
}
|
||||
|
||||
markers := make(chan time.Duration, 5)
|
||||
bulkTime := make(chan time.Duration, 1)
|
||||
go func() {
|
||||
var bulkRecv int
|
||||
var bulkStart time.Time
|
||||
for {
|
||||
kind, payload, err := readFrame()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if kind == 1 {
|
||||
if bulkRecv == 0 {
|
||||
bulkStart = time.Now()
|
||||
}
|
||||
bulkRecv += len(payload)
|
||||
if bulkRecv == 8<<20 {
|
||||
bulkTime <- time.Since(bulkStart)
|
||||
}
|
||||
}
|
||||
if kind == 2 && len(payload) == 16 {
|
||||
sent := int64(binary.BigEndian.Uint64(payload[:8]))
|
||||
markers <- time.Since(time.Unix(0, sent))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
bulkDone := make(chan struct{})
|
||||
if withBulk {
|
||||
go func() {
|
||||
defer close(bulkDone)
|
||||
chunk := make([]byte, 64<<10)
|
||||
for i := 0; i < 128; i++ {
|
||||
if err := writeFrame(1, chunk); err != nil {
|
||||
t.Errorf("bulk frame: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
var payload [16]byte
|
||||
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().UnixNano()))
|
||||
if err := writeFrame(2, payload[:]); err != nil {
|
||||
t.Fatalf("marker frame: %v", err)
|
||||
}
|
||||
if i < 4 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
if withBulk {
|
||||
<-bulkDone
|
||||
}
|
||||
out := make([]time.Duration, 0, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case d := <-markers:
|
||||
out = append(out, d)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("marker did not arrive")
|
||||
}
|
||||
}
|
||||
if withBulk {
|
||||
select {
|
||||
case d := <-bulkTime:
|
||||
t.Logf("bulk throughput: %.2f MiB/s", 8/d.Seconds())
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Log("bulk throughput: bulk did not complete")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Log("marker one-way latency with no bulk:")
|
||||
for i, d := range run(false) {
|
||||
t.Logf(" marker %d: %v", i+1, d.Round(time.Millisecond))
|
||||
}
|
||||
t.Log("marker one-way latency under 8MiB bulk:")
|
||||
for i, d := range run(true) {
|
||||
t.Logf(" marker %d: %v", i+1, d.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -343,3 +348,130 @@ func TestLiveDriveListLag(t *testing.T) {
|
||||
}
|
||||
t.Logf("worst listing lag: %v", worst.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func TestLiveDriveLatencyFloor(t *testing.T) {
|
||||
config := liveDriveConfig(t)
|
||||
root, err := newDriveStorage(&internet.MemoryStreamConfig{}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
store := root.session().(*driveStorage)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
const probe = "latency-floor/probe"
|
||||
id, err := store.makeSlot(ctx, probe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.deleteID(ctx, flatten(probe), id)
|
||||
|
||||
small := newSlotFrame(1, slotInline, randomPayload(700)).encode()
|
||||
large := newSlotFrame(1, slotInline, randomPayload(slotInlineLimit)).encode()
|
||||
file := store.filesURL + "/" + url.PathEscape(id)
|
||||
|
||||
type sample struct {
|
||||
total, ttfb time.Duration
|
||||
reused bool
|
||||
}
|
||||
measure := func(label string, op func(context.Context) error) []sample {
|
||||
var out []sample
|
||||
for i := 0; i < 28; i++ {
|
||||
var wrote, first time.Time
|
||||
var reused bool
|
||||
trace := &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
|
||||
WroteRequest: func(httptrace.WroteRequestInfo) { wrote = time.Now() },
|
||||
GotFirstResponseByte: func() { first = time.Now() },
|
||||
}
|
||||
start := time.Now()
|
||||
err := op(httptrace.WithClientTrace(ctx, trace))
|
||||
total := time.Since(start)
|
||||
if err != nil {
|
||||
t.Logf("latency-floor: %s sample failed: %v", label, err)
|
||||
} else if i >= 3 {
|
||||
var ttfb time.Duration
|
||||
if !wrote.IsZero() && !first.IsZero() {
|
||||
ttfb = first.Sub(wrote)
|
||||
}
|
||||
out = append(out, sample{total, ttfb, reused})
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
return out
|
||||
}
|
||||
report := func(label string, s []sample) time.Duration {
|
||||
if len(s) == 0 {
|
||||
t.Logf("latency-floor: %-30s no samples", label)
|
||||
return 0
|
||||
}
|
||||
totals := make([]time.Duration, len(s))
|
||||
ttfbs := make([]time.Duration, len(s))
|
||||
reused := 0
|
||||
for i, x := range s {
|
||||
totals[i], ttfbs[i] = x.total, x.ttfb
|
||||
if x.reused {
|
||||
reused++
|
||||
}
|
||||
}
|
||||
sort.Slice(totals, func(a, b int) bool { return totals[a] < totals[b] })
|
||||
sort.Slice(ttfbs, func(a, b int) bool { return ttfbs[a] < ttfbs[b] })
|
||||
q := func(v []time.Duration, p float64) time.Duration { return v[min(int(p*float64(len(v))), len(v)-1)] }
|
||||
t.Logf("latency-floor: %-30s n=%d min=%v p50=%v p95=%v ttfb_p50=%v reused=%d/%d",
|
||||
label, len(s), totals[0], q(totals, 0.5), q(totals, 0.95), q(ttfbs, 0.5), reused, len(s))
|
||||
return q(ttfbs, 0.5)
|
||||
}
|
||||
patch := func(target string, doc map[string]any) func(context.Context) error {
|
||||
body, _ := json.Marshal(doc)
|
||||
return func(ctx context.Context) error {
|
||||
status, reply, err := store.do(ctx, http.MethodPatch, target, "application/json; charset=UTF-8", body)
|
||||
if err == nil && status != http.StatusOK {
|
||||
err = fmt.Errorf("status %d: %s", status, reply)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
get := func(target string) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
status, reply, err := store.do(ctx, http.MethodGet, target, "", nil)
|
||||
if err == nil && status != http.StatusOK {
|
||||
err = fmt.Errorf("status %d: %s", status, reply)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
proto := ""
|
||||
netFloor := report("net floor (401, no auth)", measure("net", func(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/drive/v3/about?fields=user", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := store.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proto = resp.Proto
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return resp.Body.Close()
|
||||
}))
|
||||
t.Logf("latency-floor: negotiated protocol %s", proto)
|
||||
readDesc := report("GET description (prod)", measure("get", func(ctx context.Context) error {
|
||||
_, err := store.readDescription(ctx, id)
|
||||
return err
|
||||
}))
|
||||
report("GET fields=id", measure("getid", get(file+"?fields=id&supportsAllDrives=true")))
|
||||
report("GET description, no allDrives", measure("getnoall", get(file+"?fields=description")))
|
||||
write1k := report("PATCH inline 1 KB (prod)", measure("p1k", func(ctx context.Context) error {
|
||||
return store.setSlot(ctx, id, small, nil)
|
||||
}))
|
||||
write14k := report("PATCH inline 14 KB (prod)", measure("p14k", func(ctx context.Context) error {
|
||||
return store.setSlot(ctx, id, large, nil)
|
||||
}))
|
||||
report("PATCH 1 KB, no allDrives", measure("pnoall", patch(file+"?fields=id", map[string]any{"description": small})))
|
||||
report("PATCH name 200 B", measure("pname", patch(file+"?fields=id&supportsAllDrives=true", map[string]any{"name": strings.Repeat("n", 200)})))
|
||||
report("PATCH appProperties 100 B", measure("pprop", patch(file+"?fields=id&supportsAllDrives=true", map[string]any{"appProperties": map[string]string{"k": strings.Repeat("a", 100)}})))
|
||||
t.Logf("latency-floor: derived: write backend ~ %v, read backend ~ %v, 14 KB payload cost ~ %v",
|
||||
write1k-netFloor, readDesc-netFloor, write14k-write1k)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
)
|
||||
|
||||
func TestLiveDriveWriteSweep(t *testing.T) {
|
||||
config := liveDriveConfig(t)
|
||||
root, err := newDriveStorage(&internet.MemoryStreamConfig{}, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
store := root.session().(*driveStorage)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
inlineID, err := store.makeSlot(ctx, "sweep/inline")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.deleteID(ctx, flatten("sweep/inline"), inlineID)
|
||||
mediaID, err := store.makeSlot(ctx, "sweep/media")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.deleteID(ctx, flatten("sweep/media"), mediaID)
|
||||
|
||||
desc := strings.Repeat("a", 10<<10)
|
||||
body := make([]byte, 256<<10)
|
||||
if _, err := rand.Read(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kinds := []struct {
|
||||
name string
|
||||
op func(context.Context) error
|
||||
}{
|
||||
{"inline 10KiB", func(ctx context.Context) error { return store.setSlot(ctx, inlineID, desc, nil) }},
|
||||
{"media 256KiB", func(ctx context.Context) error { return store.setSlot(ctx, mediaID, "media", body) }},
|
||||
}
|
||||
|
||||
for _, p := range []int{1, 2, 3, 4, 6, 8, 12} {
|
||||
for _, kind := range kinds {
|
||||
levelCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
thr0 := store.throttled()
|
||||
var mu sync.Mutex
|
||||
var samples []time.Duration
|
||||
success := 0
|
||||
var wg sync.WaitGroup
|
||||
per := 40 / p
|
||||
levelStart := time.Now()
|
||||
for w := 0; w < p; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
if levelCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
if err := kind.op(levelCtx); err != nil {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
samples = append(samples, time.Since(start))
|
||||
success++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
wall := time.Since(levelStart)
|
||||
cancel()
|
||||
if len(samples) == 0 {
|
||||
t.Logf("sweep p=%-2d %-14s writes=0 wall=%v no samples", p, kind.name, wall.Round(time.Millisecond))
|
||||
continue
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
q := func(f float64) time.Duration {
|
||||
return samples[min(int(f*float64(len(samples))), len(samples)-1)]
|
||||
}
|
||||
t.Logf("sweep p=%-2d %-14s writes=%d wall=%v writes/s=%.2f p50=%v p95=%v p99=%v max=%v throttled=+%d",
|
||||
p, kind.name, success, wall.Round(time.Millisecond), float64(success)/wall.Seconds(),
|
||||
q(0.50), q(0.95), q(0.99), samples[len(samples)-1], store.throttled()-thr0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveDriveBrowsingExchanges(t *testing.T) {
|
||||
config := liveDriveConfig(t)
|
||||
config.Concurrency = 64
|
||||
config.FlushIntervalMs = 10
|
||||
config.MaxPollIntervalMs = 200
|
||||
settings := &internet.MemoryStreamConfig{
|
||||
ProtocolName: protocolName,
|
||||
ProtocolSettings: config,
|
||||
}
|
||||
|
||||
var slotWrites, ackWrites, announceWrites, reads atomic.Int64
|
||||
testDriveIO = func(method, target string, body []byte) {
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
reads.Add(1)
|
||||
case http.MethodPatch:
|
||||
var doc struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) != nil {
|
||||
return
|
||||
}
|
||||
if _, ok := decodeSlot(doc.Description); ok {
|
||||
slotWrites.Add(1)
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(doc.Description, 10, 64); err == nil && doc.Description != "" {
|
||||
ackWrites.Add(1)
|
||||
return
|
||||
}
|
||||
slotWrites.Add(1)
|
||||
case http.MethodPost, http.MethodPut:
|
||||
if method == http.MethodPut || strings.Contains(target, "/upload") {
|
||||
slotWrites.Add(1)
|
||||
return
|
||||
}
|
||||
var doc struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) == nil && strings.HasPrefix(doc.Name, sessionsDir+flatSeparator) {
|
||||
announceWrites.Add(1)
|
||||
return
|
||||
}
|
||||
slotWrites.Add(1)
|
||||
}
|
||||
}
|
||||
defer func() { testDriveIO = nil }()
|
||||
|
||||
client, server, cleanup := pairWith(t, settings)
|
||||
if err := server.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := randomPayload(30 << 10)
|
||||
latencies := make([]time.Duration, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
start := time.Now()
|
||||
request := randomPayload(500)
|
||||
if _, err := client.Write(request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotRequest := make([]byte, len(request))
|
||||
if _, err := io.ReadFull(server, gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(gotRequest, request) {
|
||||
t.Fatal("the request came back changed")
|
||||
}
|
||||
if _, err := server.Write(answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(answer))
|
||||
if _, err := io.ReadFull(client, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, answer) {
|
||||
t.Fatal("the answer came back changed")
|
||||
}
|
||||
latencies = append(latencies, time.Since(start))
|
||||
if i < 39 {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
q := func(f float64) time.Duration {
|
||||
return latencies[min(int(f*float64(len(latencies))), len(latencies)-1)]
|
||||
}
|
||||
t.Logf("browsing: exchanges=%d p50=%v p95=%v max=%v",
|
||||
len(latencies), q(0.50), q(0.95), latencies[len(latencies)-1])
|
||||
total := slotWrites.Load() + ackWrites.Load() + announceWrites.Load()
|
||||
share := float64(0)
|
||||
if total > 0 {
|
||||
share = 100 * float64(ackWrites.Load()) / float64(total)
|
||||
}
|
||||
t.Logf("browsing writes: slot-data=%d watermark-ack=%d announcement=%d total=%d ack-share=%.1f%% reads=%d",
|
||||
slotWrites.Load(), ackWrites.Load(), announceWrites.Load(), total, share, reads.Load())
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
"github.com/xtls/xray-core/transport/internet/stat"
|
||||
)
|
||||
|
||||
type fakeFile struct {
|
||||
@@ -29,14 +30,17 @@ type fakeFile struct {
|
||||
type fakeDrive struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
files map[string]*fakeFile
|
||||
nextID int
|
||||
failOnce map[string]bool
|
||||
failStatus map[string]int
|
||||
failBody map[string]string
|
||||
tokens int
|
||||
hosts map[string]bool
|
||||
mu sync.Mutex
|
||||
files map[string]*fakeFile
|
||||
nextID int
|
||||
failOnce map[string]bool
|
||||
failStatus map[string]int
|
||||
failBody map[string]string
|
||||
tokens int
|
||||
tokenStatus int
|
||||
tokenBody string
|
||||
uploadAt []time.Time
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func newFakeDrive(t *testing.T) *fakeDrive {
|
||||
@@ -89,7 +93,13 @@ func newFakeDrive(t *testing.T) *fakeDrive {
|
||||
func (d *fakeDrive) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
d.tokens++
|
||||
status, body := d.tokenStatus, d.tokenBody
|
||||
d.mu.Unlock()
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(body))
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "fake-token",
|
||||
"expires_in": 3600,
|
||||
@@ -97,6 +107,9 @@ func (d *fakeDrive) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (d *fakeDrive) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
d.uploadAt = append(d.uploadAt, time.Now())
|
||||
d.mu.Unlock()
|
||||
if d.shouldFail("upload") {
|
||||
if status, body := d.failure("upload"); status != 0 {
|
||||
w.WriteHeader(status)
|
||||
@@ -167,6 +180,9 @@ func (d *fakeDrive) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (d *fakeDrive) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
d.uploadAt = append(d.uploadAt, time.Now())
|
||||
d.mu.Unlock()
|
||||
if d.shouldFail("upload") {
|
||||
if status, body := d.failure("upload"); status != 0 {
|
||||
w.WriteHeader(status)
|
||||
@@ -741,3 +757,365 @@ func TestDriveFronting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveLargeAnnouncement(t *testing.T) {
|
||||
listenerCtx, cancelListener := context.WithCancel(context.Background())
|
||||
defer cancelListener()
|
||||
newFakeDrive(t)
|
||||
storage := newDriveBackend(t)
|
||||
ctx := context.Background()
|
||||
p := paramsFromConfig(driveSettings().ProtocolSettings.(*Config))
|
||||
|
||||
session := "large-announcement"
|
||||
r := ring{
|
||||
Up: slotTable{Watermark: "up-watermark"},
|
||||
Down: slotTable{Watermark: "down-watermark"},
|
||||
}
|
||||
for i := range 200 {
|
||||
id := fmt.Sprintf("%s-%03d", strings.Repeat("s", 40), i)
|
||||
r.Up.Slots = append(r.Up.Slots, id)
|
||||
r.Down.Slots = append(r.Down.Slots, id)
|
||||
}
|
||||
announce, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(announce) <= driveInlineLimit {
|
||||
t.Fatalf("announcement is %d bytes, want more than %d", len(announce), driveInlineLimit)
|
||||
}
|
||||
if err := storage.Put(ctx, announceName(session, time.Now()), announce); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := storage.List(ctx, sessionsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Inline != nil {
|
||||
t.Fatalf("large announcement was inlined: %v", entries)
|
||||
}
|
||||
|
||||
conns := make(chan *Conn, 2)
|
||||
l := &Listener{
|
||||
ctx: listenerCtx,
|
||||
storage: storage,
|
||||
addConn: func(c stat.Connection) { conns <- c.(*Conn) },
|
||||
params: p,
|
||||
active: make(map[string]bool),
|
||||
handled: make(map[string]time.Time),
|
||||
idleSince: make(map[string]time.Time),
|
||||
announceWarned: make(map[string]bool),
|
||||
}
|
||||
accepted, err := l.acceptPending(ctx)
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("acceptPending: %v, accepted %v", err, accepted)
|
||||
}
|
||||
conn := <-conns
|
||||
reader, ok := conn.reader.(*slotReader)
|
||||
if !ok || len(reader.table.Slots) != 200 {
|
||||
t.Fatalf("large announcement did not become a 200-slot connection: %T", conn.reader)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
empty := "empty-announcement"
|
||||
if err := storage.Put(ctx, announceName(empty, time.Now()), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err = l.acceptPending(ctx)
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("acceptPending empty: %v, accepted %v", err, accepted)
|
||||
}
|
||||
conn = <-conns
|
||||
if _, ok := conn.reader.(*walReader); !ok {
|
||||
t.Fatalf("empty announcement did not become a segment connection: %T", conn.reader)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestDriveDeadCredentials(t *testing.T) {
|
||||
drive := newFakeDrive(t)
|
||||
drive.mu.Lock()
|
||||
drive.tokenStatus = http.StatusBadRequest
|
||||
drive.tokenBody = `{"error":"invalid_grant"}`
|
||||
drive.mu.Unlock()
|
||||
storage := newDriveBackend(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
_, _, err := storage.do(ctx, http.MethodGet, storage.filesURL, "", nil)
|
||||
if !isAuthError(err) {
|
||||
t.Fatalf("do returned %v, want the sticky OAuth error", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatalf("do took %v, want it to fail at once", time.Since(start))
|
||||
}
|
||||
for range 3 {
|
||||
if _, err := storage.readDescription(ctx, "id"); !isAuthError(err) {
|
||||
t.Fatalf("readDescription returned %v, want the sticky OAuth error", err)
|
||||
}
|
||||
}
|
||||
drive.mu.Lock()
|
||||
tokens := drive.tokens
|
||||
drive.mu.Unlock()
|
||||
if tokens != 1 {
|
||||
t.Fatalf("the fake saw %d token requests, want exactly one", tokens)
|
||||
}
|
||||
|
||||
reader := newSlotReader(ctx, storage, slotTable{Slots: []string{"id"}, Watermark: "w"}, params{concurrency: 1}, nil)
|
||||
select {
|
||||
case <-reader.chunks():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the slot reader kept polling after the credentials were rejected")
|
||||
}
|
||||
if !isAuthError(reader.Err()) {
|
||||
t.Fatalf("the slot reader ended with %v, want the sticky OAuth error", reader.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRetryWaitsCooldown(t *testing.T) {
|
||||
drive := newFakeDrive(t)
|
||||
oldCooldown := driveCooldown
|
||||
driveCooldown = 200 * time.Millisecond
|
||||
t.Cleanup(func() { driveCooldown = oldCooldown })
|
||||
storage := newDriveBackend(t)
|
||||
|
||||
drive.failOnceWith("upload", http.StatusForbidden,
|
||||
`{"error":{"code":403,"errors":[{"reason":"userRateLimitExceeded"}]}}`)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if err := storage.Put(ctx, "sessions/abc", nil); err != nil {
|
||||
t.Fatalf("Put did not survive a 403: %v", err)
|
||||
}
|
||||
drive.mu.Lock()
|
||||
times := append([]time.Time(nil), drive.uploadAt...)
|
||||
drive.mu.Unlock()
|
||||
if len(times) < 2 {
|
||||
t.Fatalf("the fake saw %d writes, want a retry", len(times))
|
||||
}
|
||||
if gap := times[1].Sub(times[0]); gap < driveCooldown/2 {
|
||||
t.Fatalf("the retry arrived after %v, less than the jitter floor %v", gap, driveCooldown/2)
|
||||
}
|
||||
storage.coolMu.Lock()
|
||||
until := storage.coolUntil
|
||||
storage.coolMu.Unlock()
|
||||
if times[1].Before(until) {
|
||||
t.Fatalf("the retry reached the fake at %v, before the cooldown ended at %v", times[1], until)
|
||||
}
|
||||
if took := time.Since(start); took < until.Sub(times[0]) {
|
||||
t.Fatalf("the request took %v, less than the cooldown %v", took, until.Sub(times[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBackgroundTokenRefresh(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
mode := "ok"
|
||||
delay := 100 * time.Millisecond
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
n := calls
|
||||
current := mode
|
||||
pause := delay
|
||||
mu.Unlock()
|
||||
time.Sleep(pause)
|
||||
switch current {
|
||||
case "transient":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"temporarily_unavailable"}`))
|
||||
case "auth":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
default:
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": fmt.Sprintf("token-%d", n),
|
||||
"expires_in": 100,
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
newStorage := func() *driveStorage {
|
||||
return &driveStorage{driveShared: &driveShared{
|
||||
tokenClient: server.Client(),
|
||||
tokenURL: server.URL,
|
||||
}}
|
||||
}
|
||||
seed := func(s *driveStorage) {
|
||||
s.tokenMu.Lock()
|
||||
s.token = "old"
|
||||
s.tokenLifetime = 100 * time.Second
|
||||
s.tokenExpiry = time.Now().Add(10 * time.Second)
|
||||
s.tokenMu.Unlock()
|
||||
}
|
||||
setMode := func(m string) {
|
||||
mu.Lock()
|
||||
mode = m
|
||||
mu.Unlock()
|
||||
}
|
||||
callCount := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
t.Run("returns at once inside the window", func(t *testing.T) {
|
||||
s := newStorage()
|
||||
seed(s)
|
||||
start := time.Now()
|
||||
token, err := s.accessToken(context.Background())
|
||||
if err != nil || token != "old" {
|
||||
t.Fatalf("accessToken gave %q, %v", token, err)
|
||||
}
|
||||
if time.Since(start) > 50*time.Millisecond {
|
||||
t.Fatalf("accessToken waited %v for the background refresh", time.Since(start))
|
||||
}
|
||||
waitFor(t, "the background refresh", func() bool {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
return s.token != "old" && !s.refreshing
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("concurrent calls start one refresh", func(t *testing.T) {
|
||||
mu.Lock()
|
||||
calls = 0
|
||||
mu.Unlock()
|
||||
s := newStorage()
|
||||
seed(s)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if token, err := s.accessToken(context.Background()); err != nil || token != "old" {
|
||||
t.Errorf("accessToken gave %q, %v", token, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, "one background refresh", func() bool {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
return !s.refreshing && s.token != "old"
|
||||
})
|
||||
if n := callCount(); n != 1 {
|
||||
t.Fatalf("concurrent calls started %d refreshes, want one", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient failure keeps the old token", func(t *testing.T) {
|
||||
mu.Lock()
|
||||
calls = 0
|
||||
mu.Unlock()
|
||||
setMode("transient")
|
||||
defer setMode("ok")
|
||||
s := newStorage()
|
||||
seed(s)
|
||||
if token, err := s.accessToken(context.Background()); err != nil || token != "old" {
|
||||
t.Fatalf("accessToken gave %q, %v", token, err)
|
||||
}
|
||||
waitFor(t, "the failed refresh to clear", func() bool {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
return !s.refreshing
|
||||
})
|
||||
s.tokenMu.Lock()
|
||||
token := s.token
|
||||
s.tokenMu.Unlock()
|
||||
if token != "old" {
|
||||
t.Fatalf("a transient failure replaced the token with %q", token)
|
||||
}
|
||||
// Simulate the backoff having elapsed so the next call may retry.
|
||||
s.tokenMu.Lock()
|
||||
s.tokenRetryAt = time.Time{}
|
||||
s.tokenBackoff = 0
|
||||
s.tokenMu.Unlock()
|
||||
setMode("ok")
|
||||
if _, err := s.accessToken(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the later refresh", func() bool {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
return s.token != "old" && !s.refreshing
|
||||
})
|
||||
if n := callCount(); n != 2 {
|
||||
t.Fatalf("the retry made %d token requests, want two", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth failure turns sticky", func(t *testing.T) {
|
||||
mu.Lock()
|
||||
calls = 0
|
||||
mu.Unlock()
|
||||
setMode("auth")
|
||||
defer setMode("ok")
|
||||
s := newStorage()
|
||||
seed(s)
|
||||
if _, err := s.accessToken(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the sticky auth error", func() bool {
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
return s.authErr != nil && !s.refreshing
|
||||
})
|
||||
before := callCount()
|
||||
if _, err := s.accessToken(context.Background()); !isAuthError(err) {
|
||||
t.Fatalf("accessToken gave %v, want the sticky auth error", err)
|
||||
}
|
||||
if n := callCount(); n != before {
|
||||
t.Fatalf("the sticky error made %d more token requests", n-before)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlotPathDefaults(t *testing.T) {
|
||||
p := paramsForStorage(&Config{}, &driveStorage{})
|
||||
if p.concurrency != 64 || p.flushInterval != 10*time.Millisecond || p.maxPollInterval != 200*time.Millisecond {
|
||||
t.Fatalf("slot defaults gave concurrency %d, flush %v, max poll %v", p.concurrency, p.flushInterval, p.maxPollInterval)
|
||||
}
|
||||
if wal, base := paramsForStorage(&Config{}, &localStorage{}), paramsFromConfig(&Config{}); wal != base {
|
||||
t.Fatalf("wal defaults changed: %+v, want %+v", wal, base)
|
||||
}
|
||||
explicit := paramsForStorage(&Config{Concurrency: 4, FlushIntervalMs: 30, MaxPollIntervalMs: 100}, &driveStorage{})
|
||||
if explicit.concurrency != 4 || explicit.flushInterval != 30*time.Millisecond || explicit.maxPollInterval != 100*time.Millisecond {
|
||||
t.Fatalf("explicit values did not win: %+v", explicit)
|
||||
}
|
||||
if p := paramsForStorage(&Config{PollIntervalMs: 500}, &driveStorage{}); p.maxPollInterval < p.minPollInterval {
|
||||
t.Fatalf("max poll %v is below min poll %v", p.maxPollInterval, p.minPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialDefaultRingDepth(t *testing.T) {
|
||||
newFakeDrive(t)
|
||||
config := &Config{
|
||||
RemoteFolder: "folder-id",
|
||||
Service: "google-drive",
|
||||
Secrets: []string{"client", "secret", "refresh"},
|
||||
}
|
||||
settings := &internet.MemoryStreamConfig{
|
||||
ProtocolName: protocolName,
|
||||
ProtocolSettings: config,
|
||||
}
|
||||
conn, err := Dial(context.Background(), net.Destination{}, settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
writer, ok := conn.(*Conn).writer.(*slotWriter)
|
||||
if !ok {
|
||||
t.Fatalf("Dial with unset concurrency built %T, want a slot writer", conn.(*Conn).writer)
|
||||
}
|
||||
if len(writer.table.Slots) != 64 {
|
||||
t.Fatalf("Dial with unset concurrency built %d slots, want 64", len(writer.table.Slots))
|
||||
}
|
||||
box := spareBoxFor(config)
|
||||
box.stop.Store(true)
|
||||
for box.busy.Load() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenServer struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
mode int
|
||||
delay time.Duration
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newTokenServer(mode int, delay time.Duration) *tokenServer {
|
||||
ts := &tokenServer{mode: mode, delay: delay}
|
||||
ts.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts.mu.Lock()
|
||||
ts.count++
|
||||
mode, delay := ts.mode, ts.delay
|
||||
ts.mu.Unlock()
|
||||
time.Sleep(delay)
|
||||
if mode != http.StatusOK {
|
||||
w.WriteHeader(mode)
|
||||
w.Write([]byte(`{"error":"temporarily_unavailable"}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"access_token":"new-token","expires_in":3600}`))
|
||||
}))
|
||||
return ts
|
||||
}
|
||||
|
||||
func (ts *tokenServer) requests() int {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
return ts.count
|
||||
}
|
||||
|
||||
func (ts *tokenServer) storage() *driveStorage {
|
||||
return &driveStorage{driveShared: &driveShared{
|
||||
tokenClient: ts.srv.Client(),
|
||||
tokenURL: ts.srv.URL + "/token",
|
||||
}}
|
||||
}
|
||||
|
||||
func TestTokenSingleFlightDuringOutage(t *testing.T) {
|
||||
ts := newTokenServer(http.StatusInternalServerError, 200*time.Millisecond)
|
||||
defer ts.srv.Close()
|
||||
s := ts.storage()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 9)
|
||||
start := make(chan struct{})
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); <-start; _, err := s.accessToken(context.Background()); errs <- err }()
|
||||
close(start)
|
||||
// Wait until the single flight is in progress, then pile on waiters.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for ts.requests() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); _, err := s.accessToken(context.Background()); errs <- err }()
|
||||
}
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
_, err := s.accessToken(cancelledCtx)
|
||||
errs <- err
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
var cancelledSeen bool
|
||||
for err := range errs {
|
||||
if err == nil {
|
||||
t.Fatal("an outage caller succeeded")
|
||||
}
|
||||
if err == context.Canceled {
|
||||
cancelledSeen = true
|
||||
}
|
||||
}
|
||||
if !cancelledSeen {
|
||||
t.Fatal("the cancelled waiter did not return ctx.Err")
|
||||
}
|
||||
if n := ts.requests(); n != 1 {
|
||||
t.Fatalf("N concurrent callers caused %d token fetches, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBackgroundFailureBacksOff(t *testing.T) {
|
||||
ts := newTokenServer(http.StatusInternalServerError, 0)
|
||||
defer ts.srv.Close()
|
||||
s := ts.storage()
|
||||
s.token = "cached"
|
||||
s.tokenLifetime = 10 * time.Second
|
||||
s.tokenExpiry = time.Now().Add(time.Second) // inside the refresh window
|
||||
|
||||
if tok, err := s.accessToken(context.Background()); err != nil || tok != "cached" {
|
||||
t.Fatalf("accessToken=%q err=%v", tok, err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for ts.requests() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if n := ts.requests(); n != 1 {
|
||||
t.Fatalf("background refresh made %d requests, want 1", n)
|
||||
}
|
||||
// A second call inside the backoff must not hit the endpoint again.
|
||||
if _, err := s.accessToken(context.Background()); err != nil {
|
||||
t.Fatalf("cached token should still be returned: %v", err)
|
||||
}
|
||||
if n := ts.requests(); n != 1 {
|
||||
t.Fatalf("a failing endpoint was hit %d times, want 1", n)
|
||||
}
|
||||
s.tokenMu.Lock()
|
||||
backoff := s.tokenBackoff
|
||||
retryAt := s.tokenRetryAt
|
||||
s.tokenMu.Unlock()
|
||||
if backoff < tokenRefreshBackoff {
|
||||
t.Fatalf("backoff %v below the default %v", backoff, tokenRefreshBackoff)
|
||||
}
|
||||
if !retryAt.After(time.Now()) {
|
||||
t.Fatal("tokenRetryAt not set into the future after a background failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken401RefetchOnce(t *testing.T) {
|
||||
ts := newTokenServer(http.StatusOK, 0)
|
||||
defer ts.srv.Close()
|
||||
s := ts.storage()
|
||||
s.token = "old"
|
||||
s.tokenLifetime = time.Hour
|
||||
s.tokenExpiry = time.Now().Add(time.Hour)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.invalidateToken("old")
|
||||
if _, err := s.accessToken(context.Background()); err != nil {
|
||||
t.Errorf("accessToken: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if n := ts.requests(); n != 1 {
|
||||
t.Fatalf("many 401 handlers caused %d refetches, want 1", n)
|
||||
}
|
||||
s.tokenMu.Lock()
|
||||
tok := s.token
|
||||
s.tokenMu.Unlock()
|
||||
if tok != "new-token" {
|
||||
t.Fatalf("token=%q, want new-token", tok)
|
||||
}
|
||||
// Invalidating with a stale used token must not clear the fresh one.
|
||||
s.invalidateToken("old")
|
||||
s.tokenMu.Lock()
|
||||
tok = s.token
|
||||
s.tokenMu.Unlock()
|
||||
if tok != "new-token" {
|
||||
t.Fatalf("stale invalidation cleared the fresh token: %q", tok)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
defaultConcurrency = 8
|
||||
|
||||
maxSegmentBytes = 16 * 1024 * 1024
|
||||
maxConcurrency = 64
|
||||
maxConcurrency = 256
|
||||
)
|
||||
|
||||
type params struct {
|
||||
@@ -67,3 +67,23 @@ func paramsFromConfig(c *Config) params {
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// paramsForStorage applies the Drive slot-path defaults only where the storage
|
||||
// is a slotStore and only to fields the config leaves at 0; wal-based backends
|
||||
// keep the defaults above because their concurrency and flush mean other things
|
||||
func paramsForStorage(c *Config, storage Storage) params {
|
||||
if _, ok := storage.(slotStore); ok {
|
||||
cc := *c
|
||||
if cc.Concurrency == 0 {
|
||||
cc.Concurrency = 64
|
||||
}
|
||||
if cc.FlushIntervalMs == 0 {
|
||||
cc.FlushIntervalMs = 10
|
||||
}
|
||||
if cc.MaxPollIntervalMs == 0 {
|
||||
cc.MaxPollIntervalMs = 200
|
||||
}
|
||||
c = &cc
|
||||
}
|
||||
return paramsFromConfig(c)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
@@ -27,7 +28,6 @@ const (
|
||||
// slotInlineLimit is how large a segment is at most to go in the description
|
||||
// of its slot, which takes one request less than a body
|
||||
slotInlineLimit = 14 << 10
|
||||
slotDepthLimit = 8
|
||||
// inlineRampSegments segments of one size in a row double it, from
|
||||
// slotInlineLimit up to segmentBytes
|
||||
inlineRampSegments = 8
|
||||
@@ -36,24 +36,34 @@ const (
|
||||
slotMedia = 2
|
||||
slotEnd = 3
|
||||
slotErr = 4
|
||||
|
||||
// maxSlotIDLen bounds any slot or watermark id accepted from a peer.
|
||||
maxSlotIDLen = 128
|
||||
)
|
||||
|
||||
var (
|
||||
// the head is asked for between slotHuntFloor and slotHuntCeiling, a segment
|
||||
// behind it at about the pace that segments come
|
||||
slotHuntFloor = 50 * time.Millisecond
|
||||
slotHuntCeiling = 200 * time.Millisecond
|
||||
slotHuntFloor = 10 * time.Millisecond
|
||||
slotHuntCeiling = 25 * time.Millisecond
|
||||
slotTailMin = 150 * time.Millisecond
|
||||
slotTailMax = 800 * time.Millisecond
|
||||
slotFetchAhead = 6
|
||||
slotFetchAhead = 12
|
||||
|
||||
slotAckRetry = 100 * time.Millisecond
|
||||
slotWatermarkTTL = 250 * time.Millisecond
|
||||
slotAckRetry = 30 * time.Millisecond
|
||||
slotWatermarkTTL = 100 * time.Millisecond
|
||||
slotStallTimeout = 60 * time.Second
|
||||
slotCloseGrace = 10 * time.Second
|
||||
slotGoneTimeout = 60 * time.Second
|
||||
slotBodyHedge = 2 * time.Second
|
||||
|
||||
slotReadAhead int64 = 3
|
||||
slotReadAheadPoll = 40 * time.Millisecond
|
||||
slotReadAheadBurst = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
var slotWriteInflight = 12
|
||||
|
||||
// slotStore is a storage of objects that are written and read again by id,
|
||||
// each with a description and a body
|
||||
type slotStore interface {
|
||||
@@ -64,6 +74,12 @@ type slotStore interface {
|
||||
readBody(ctx context.Context, id string) ([]byte, error)
|
||||
}
|
||||
|
||||
// throttleStore is the optional part of a slotStore that reports how often
|
||||
// Drive throttled or timed out its requests; only driveStorage implements it
|
||||
type throttleStore interface {
|
||||
throttled() int64
|
||||
}
|
||||
|
||||
// slotFrame is what the description of a slot says: which segment it holds,
|
||||
// and either the segment itself or the size and checksum of the body that does
|
||||
type slotFrame struct {
|
||||
@@ -128,8 +144,9 @@ type slotTable struct {
|
||||
// ring is what a client announces a session with, the tables are named from
|
||||
// its side
|
||||
type ring struct {
|
||||
Up slotTable `json:"u"`
|
||||
Down slotTable `json:"d"`
|
||||
Up slotTable `json:"u"`
|
||||
Down slotTable `json:"d"`
|
||||
First []byte `json:"first,omitempty"`
|
||||
}
|
||||
|
||||
func decodeRing(b []byte) (ring, bool) {
|
||||
@@ -138,12 +155,25 @@ func decodeRing(b []byte) (ring, bool) {
|
||||
r.Up.Watermark == "" || r.Down.Watermark == "" {
|
||||
return ring{}, false
|
||||
}
|
||||
if len(r.Up.Slots) > maxConcurrency || len(r.Down.Slots) > maxConcurrency {
|
||||
return ring{}, false
|
||||
}
|
||||
for _, t := range []slotTable{r.Up, r.Down} {
|
||||
if len(t.Watermark) > maxSlotIDLen {
|
||||
return ring{}, false
|
||||
}
|
||||
for _, id := range t.Slots {
|
||||
if len(id) == 0 || len(id) > maxSlotIDLen {
|
||||
return ring{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// makeRing makes the slots and watermarks of a session, all at once
|
||||
func makeRing(ctx context.Context, store slotStore, session string, p params) (ring, error) {
|
||||
depth := min(max(p.concurrency, 2), slotDepthLimit)
|
||||
depth := max(p.concurrency, 2)
|
||||
r := ring{
|
||||
Up: slotTable{Slots: make([]string, depth)},
|
||||
Down: slotTable{Slots: make([]string, depth)},
|
||||
@@ -177,7 +207,7 @@ func makeRing(ctx context.Context, store slotStore, session string, p params) (r
|
||||
}
|
||||
|
||||
func readWatermark(ctx context.Context, store slotStore, id string) (int64, error) {
|
||||
d, err := store.readDescription(ctx, id)
|
||||
d, err := store.readDescription(context.WithValue(ctx, laneKey{}, true), id)
|
||||
if err != nil || d == "" {
|
||||
return -1, err
|
||||
}
|
||||
@@ -202,6 +232,7 @@ type slotWriter struct {
|
||||
held int
|
||||
closed bool
|
||||
err error
|
||||
first chan []byte
|
||||
|
||||
// ackMu is held while the watermark is read, which other uploads wait for
|
||||
ackMu sync.Mutex
|
||||
@@ -216,7 +247,7 @@ func newSlotWriter(ctx context.Context, store slotStore, table slotTable, p para
|
||||
store: store,
|
||||
table: table,
|
||||
params: p,
|
||||
slots: make(chan struct{}, len(table.Slots)),
|
||||
slots: make(chan struct{}, min(len(table.Slots), slotWriteInflight)),
|
||||
size: min(slotInlineLimit, p.segmentBytes),
|
||||
acked: -1,
|
||||
progress: time.Now(),
|
||||
@@ -271,6 +302,10 @@ func (w *slotWriter) readyToFlush() bool {
|
||||
grew := len(w.buf) > w.lastSize
|
||||
w.lastSize = len(w.buf)
|
||||
|
||||
if len(w.slots) > 0 && w.held < maxCoalescedTicks && len(w.buf) < w.size/2 {
|
||||
w.held++
|
||||
return false
|
||||
}
|
||||
if grew && w.held < maxCoalescedTicks {
|
||||
w.held++
|
||||
return false
|
||||
@@ -296,11 +331,19 @@ func (w *slotWriter) flushLocked() error {
|
||||
return w.err
|
||||
}
|
||||
|
||||
if w.size < w.segmentBytes && len(w.buf) >= w.size && len(w.slots)*4 > cap(w.slots)*3 {
|
||||
w.size = w.segmentBytes
|
||||
w.full = 0
|
||||
}
|
||||
|
||||
n := min(len(w.buf), w.size)
|
||||
if len(w.buf) > 4*slotInlineLimit {
|
||||
// a burst goes at the full size at once
|
||||
n = min(len(w.buf), w.segmentBytes)
|
||||
}
|
||||
if n > slotInlineLimit && len(w.buf) <= 2*cap(w.slots)*slotInlineLimit {
|
||||
n = slotInlineLimit
|
||||
}
|
||||
if n == w.size && w.size < w.segmentBytes {
|
||||
if w.full++; w.full == inlineRampSegments {
|
||||
w.full = 0
|
||||
@@ -312,6 +355,12 @@ func (w *slotWriter) flushLocked() error {
|
||||
w.buf = append(w.buf[:0], w.buf[n:]...)
|
||||
seq := w.seq
|
||||
w.seq++
|
||||
if seq == 0 && w.first != nil {
|
||||
select {
|
||||
case w.first <- chunk:
|
||||
default:
|
||||
}
|
||||
}
|
||||
w.lastSize = len(w.buf)
|
||||
w.held = 0
|
||||
|
||||
@@ -332,15 +381,21 @@ func (w *slotWriter) upload(seq int64, chunk []byte) {
|
||||
if len(chunk) <= slotInlineLimit {
|
||||
mode = slotInline
|
||||
}
|
||||
began := time.Now()
|
||||
if err := w.await(seq); err != nil {
|
||||
w.fail(errors.New("failed to store segment ", seq).Base(err))
|
||||
return
|
||||
}
|
||||
awaited := time.Since(began)
|
||||
putAt := time.Now()
|
||||
if err := w.write(seq, mode, chunk); err != nil {
|
||||
w.fail(errors.New("failed to store segment ", seq).Base(err))
|
||||
// the slot is free, the reader learns there what happened
|
||||
w.write(seq, slotErr, nil)
|
||||
return
|
||||
}
|
||||
errors.LogDebug(w.ctx, "xdrive write ", slotTag(w.table.Watermark), " seq=", seq, " bytes=", len(chunk),
|
||||
" mode=", mode, " await=", awaited, " put=", time.Since(putAt))
|
||||
}
|
||||
|
||||
func (w *slotWriter) write(seq int64, mode byte, data []byte) error {
|
||||
@@ -393,7 +448,7 @@ func (w *slotWriter) await(seq int64) error {
|
||||
w.ackMu.Lock()
|
||||
still := time.Since(w.progress)
|
||||
w.ackMu.Unlock()
|
||||
if time.Since(start) > slotStallTimeout || closed && still > slotCloseGrace {
|
||||
if min(time.Since(start), still) > slotStallTimeout || closed && still > slotCloseGrace {
|
||||
return errors.New("the reader took nothing from slot ", w.slotID(seq), " for ", still).Base(err)
|
||||
}
|
||||
|
||||
@@ -461,6 +516,7 @@ type slotReader struct {
|
||||
ch chan []byte
|
||||
wake chan struct{}
|
||||
acks chan struct{}
|
||||
grow chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
head int64
|
||||
@@ -468,25 +524,51 @@ type slotReader struct {
|
||||
// hitAt is when a segment last came and gap the time between them, activeAt
|
||||
// when a segment came, was seen coming or Wake. Nothing for the eager window
|
||||
// is idle, and active is closed when that ends
|
||||
hitAt time.Time
|
||||
gap time.Duration
|
||||
activeAt time.Time
|
||||
active chan struct{}
|
||||
err error
|
||||
hitAt time.Time
|
||||
gap time.Duration
|
||||
activeAt time.Time
|
||||
active chan struct{}
|
||||
frontier int64
|
||||
err error
|
||||
stop context.CancelFunc
|
||||
reads atomic.Int64
|
||||
tailMin time.Duration
|
||||
tailMax time.Duration
|
||||
stallTimeout time.Duration
|
||||
bodyRate float64
|
||||
}
|
||||
|
||||
func newSlotReader(ctx context.Context, store slotStore, table slotTable, p params) *slotReader {
|
||||
func newSlotReader(ctx context.Context, store slotStore, table slotTable, p params, first []byte) *slotReader {
|
||||
head := int64(0)
|
||||
if len(first) > 0 {
|
||||
head = 1
|
||||
}
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
r := &slotReader{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
table: table,
|
||||
params: p,
|
||||
ch: make(chan []byte, p.concurrency),
|
||||
wake: make(chan struct{}, 1),
|
||||
acks: make(chan struct{}, 1),
|
||||
hitAt: time.Now(),
|
||||
activeAt: time.Now(),
|
||||
active: make(chan struct{}),
|
||||
ctx: runCtx,
|
||||
stop: stop,
|
||||
store: store,
|
||||
table: table,
|
||||
params: p,
|
||||
head: head,
|
||||
started: len(first) > 0,
|
||||
ch: make(chan []byte, max(p.concurrency, 1)),
|
||||
wake: make(chan struct{}, 1),
|
||||
acks: make(chan struct{}, 1),
|
||||
grow: make(chan struct{}, 1),
|
||||
hitAt: time.Now(),
|
||||
activeAt: time.Now(),
|
||||
active: make(chan struct{}),
|
||||
tailMin: slotTailMin,
|
||||
tailMax: slotTailMax,
|
||||
stallTimeout: slotStallTimeout,
|
||||
}
|
||||
if len(first) > 0 {
|
||||
r.ch <- first
|
||||
select {
|
||||
case r.acks <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
go r.run()
|
||||
go r.ackLoop()
|
||||
@@ -516,9 +598,20 @@ type slotFetch struct {
|
||||
ready chan struct{}
|
||||
// promoted is closed when the segment becomes the head
|
||||
promoted chan struct{}
|
||||
near chan struct{}
|
||||
body []byte
|
||||
end bool
|
||||
err error
|
||||
born time.Time
|
||||
readyAt time.Time
|
||||
}
|
||||
|
||||
func (f *slotFetch) enter() {
|
||||
select {
|
||||
case <-f.near:
|
||||
default:
|
||||
close(f.near)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *slotReader) run() {
|
||||
@@ -529,17 +622,20 @@ func (r *slotReader) run() {
|
||||
fetches := make(map[int64]*slotFetch)
|
||||
for {
|
||||
r.mu.Lock()
|
||||
head, started := r.head, r.started
|
||||
head, started, frontier := r.head, r.started, r.frontier
|
||||
r.mu.Unlock()
|
||||
|
||||
window := 1
|
||||
if started {
|
||||
window = min(slotFetchAhead, len(r.table.Slots))
|
||||
window = min(max(int(frontier-head)+slotFetchAhead, slotFetchAhead), len(r.table.Slots))
|
||||
}
|
||||
for i := range window {
|
||||
seq := head + int64(i)
|
||||
if fetches[seq] == nil {
|
||||
fetches[seq] = &slotFetch{ready: make(chan struct{}), promoted: make(chan struct{})}
|
||||
fetches[seq] = &slotFetch{ready: make(chan struct{}), promoted: make(chan struct{}), near: make(chan struct{}), born: time.Now()}
|
||||
if int64(i) <= slotReadAhead {
|
||||
fetches[seq].enter()
|
||||
}
|
||||
go r.fetch(ctx, seq, i, fetches[seq])
|
||||
}
|
||||
}
|
||||
@@ -547,6 +643,8 @@ func (r *slotReader) run() {
|
||||
f := fetches[head]
|
||||
select {
|
||||
case <-f.ready:
|
||||
case <-r.grow:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -564,8 +662,14 @@ func (r *slotReader) run() {
|
||||
r.head++
|
||||
r.started = true
|
||||
r.mu.Unlock()
|
||||
errors.LogDebug(r.ctx, "xdrive read ", slotTag(r.table.Watermark), " seq=", head, " fetch=",
|
||||
f.readyAt.Sub(f.born), " blocked=", time.Since(f.readyAt), " bytes=", len(f.body),
|
||||
" reads=", r.reads.Load())
|
||||
if next := fetches[head+1]; next != nil {
|
||||
close(next.promoted)
|
||||
if nf := fetches[head+1+slotReadAhead]; nf != nil {
|
||||
nf.enter()
|
||||
}
|
||||
}
|
||||
select {
|
||||
case r.acks <- struct{}{}:
|
||||
@@ -586,26 +690,65 @@ func (r *slotReader) isHead(seq int64) bool {
|
||||
return r.head == seq
|
||||
}
|
||||
|
||||
func (r *slotReader) bursting() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return time.Since(r.hitAt) <= slotReadAheadBurst
|
||||
}
|
||||
|
||||
func (r *slotReader) reach(seq int64) {
|
||||
r.mu.Lock()
|
||||
grown := seq > r.frontier
|
||||
if grown {
|
||||
r.frontier = seq
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if grown {
|
||||
select {
|
||||
case r.grow <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetch asks for the slot of seq until it holds seq: the head often, a segment
|
||||
// behind it at the pace that segments come, as it would wait for the head anyway
|
||||
// behind it soon after it was asked for, then at the pace that segments come
|
||||
func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFetch) {
|
||||
defer close(f.ready)
|
||||
if offset > 0 && !r.hold(ctx, time.Duration(offset)*r.pace(), f.promoted, nil) {
|
||||
defer func() {
|
||||
f.readyAt = time.Now()
|
||||
close(f.ready)
|
||||
}()
|
||||
if offset > 0 && !r.hold(ctx, r.tailMin, f.promoted, f.near) {
|
||||
return
|
||||
}
|
||||
|
||||
id := r.slotID(seq)
|
||||
backoff := slotHuntFloor
|
||||
var goneSince, warnedAt time.Time
|
||||
var goneSince, warnedAt, visibleSince time.Time
|
||||
for {
|
||||
description, err := r.store.readDescription(ctx, id)
|
||||
var description string
|
||||
var err error
|
||||
r.mu.Lock()
|
||||
hot := time.Since(r.activeAt) <= r.eagerWindow
|
||||
r.mu.Unlock()
|
||||
if hot && r.isHead(seq) {
|
||||
description, err = r.readHead(ctx, id, seq)
|
||||
} else {
|
||||
r.reads.Add(1)
|
||||
description, err = r.store.readDescription(ctx, id)
|
||||
}
|
||||
frame, ok := decodeSlot(description)
|
||||
if err == errNotFound || (err == nil && description == "") {
|
||||
// An absent or empty slot is idle, not a failure.
|
||||
visibleSince = time.Time{}
|
||||
}
|
||||
switch {
|
||||
case err == nil && ok && frame.seq == seq:
|
||||
// what follows it is asked for while its body comes
|
||||
r.mu.Lock()
|
||||
r.touch()
|
||||
r.mu.Unlock()
|
||||
r.reach(seq)
|
||||
switch frame.mode {
|
||||
case slotEnd:
|
||||
f.end = true
|
||||
@@ -618,11 +761,37 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
|
||||
r.hit()
|
||||
return
|
||||
case slotMedia:
|
||||
if body, err := r.store.readBody(ctx, id); err == nil && frame.holds(body) {
|
||||
body, bodyErr := r.readBodyHedged(ctx, id, frame.size)
|
||||
if bodyErr == nil && frame.holds(body) {
|
||||
f.body = body
|
||||
r.hit()
|
||||
return
|
||||
}
|
||||
if ctx.Err() == nil && time.Since(warnedAt) > 10*time.Second {
|
||||
warnedAt = time.Now()
|
||||
if bodyErr != nil {
|
||||
errors.LogWarningInner(ctx, bodyErr, "failed to read body of slot ", id)
|
||||
} else {
|
||||
errors.LogWarning(ctx, "body of slot ", id, " does not match segment ", seq)
|
||||
}
|
||||
}
|
||||
if visibleSince.IsZero() {
|
||||
visibleSince = time.Now()
|
||||
}
|
||||
if time.Since(visibleSince) > r.stallTimeout {
|
||||
f.err = errors.New("segment ", seq, " has no usable body after ", r.stallTimeout).Base(bodyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
case err == nil && !ok && description != "":
|
||||
// A present but undecodable description for this slot is not idle;
|
||||
// fail once it has persisted beyond the stall timeout.
|
||||
if visibleSince.IsZero() {
|
||||
visibleSince = time.Now()
|
||||
}
|
||||
if time.Since(visibleSince) > r.stallTimeout {
|
||||
f.err = errors.New("slot ", id, " of segment ", seq, " holds an invalid frame")
|
||||
return
|
||||
}
|
||||
case err == errNotFound:
|
||||
if goneSince.IsZero() {
|
||||
@@ -632,6 +801,10 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
|
||||
return
|
||||
}
|
||||
case err != nil:
|
||||
if isAuthError(err) {
|
||||
f.err = err
|
||||
return
|
||||
}
|
||||
goneSince = time.Time{}
|
||||
if ctx.Err() == nil && time.Since(warnedAt) > 10*time.Second {
|
||||
warnedAt = time.Now()
|
||||
@@ -654,7 +827,15 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
|
||||
// only the head is asked for until something comes
|
||||
wait, a, b, backoff = 0, f.promoted, active, slotHuntFloor
|
||||
default:
|
||||
wait, a, backoff = min(max(r.pace(), slotTailMin), slotTailMax), f.promoted, slotHuntFloor
|
||||
wait, a, b, backoff = min(max(r.pace(), r.tailMin), r.tailMax), f.promoted, f.near, slotHuntFloor
|
||||
select {
|
||||
case <-f.near:
|
||||
b = nil
|
||||
if r.bursting() {
|
||||
wait = slotReadAheadPoll
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
if !r.hold(ctx, wait, a, b) {
|
||||
return
|
||||
@@ -662,6 +843,126 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
|
||||
}
|
||||
}
|
||||
|
||||
func (r *slotReader) readHead(ctx context.Context, id string, seq int64) (string, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
ctx = context.WithValue(ctx, laneKey{}, true)
|
||||
|
||||
type result struct {
|
||||
description string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 4)
|
||||
outstanding := 0
|
||||
launch := func() {
|
||||
outstanding++
|
||||
go func() {
|
||||
r.reads.Add(1)
|
||||
description, err := r.store.readDescription(ctx, id)
|
||||
results <- result{description, err}
|
||||
}()
|
||||
}
|
||||
launch()
|
||||
ticker := time.NewTicker(slotHuntCeiling)
|
||||
defer ticker.Stop()
|
||||
var last result
|
||||
seen := false
|
||||
for {
|
||||
select {
|
||||
case res := <-results:
|
||||
outstanding--
|
||||
if res.err != nil {
|
||||
return res.description, res.err
|
||||
}
|
||||
if frame, ok := decodeSlot(res.description); ok && frame.seq == seq {
|
||||
return res.description, nil
|
||||
}
|
||||
last, seen = res, true
|
||||
case <-ticker.C:
|
||||
r.mu.Lock()
|
||||
idle := time.Since(r.activeAt) > r.eagerWindow
|
||||
r.mu.Unlock()
|
||||
switch {
|
||||
case idle && seen:
|
||||
return last.description, nil
|
||||
case !idle && outstanding < 3:
|
||||
launch()
|
||||
}
|
||||
case <-r.wake:
|
||||
if outstanding < 3 {
|
||||
launch()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *slotReader) readBodyHedged(ctx context.Context, id string, size int) ([]byte, error) {
|
||||
if slotBodyHedge <= 0 {
|
||||
return r.store.readBody(ctx, id)
|
||||
}
|
||||
delay := slotBodyHedge
|
||||
r.mu.Lock()
|
||||
rate := r.bodyRate
|
||||
r.mu.Unlock()
|
||||
if rate > 0 && size > 0 {
|
||||
if scaled := time.Duration(float64(2*size) / rate * float64(time.Second)); scaled > delay {
|
||||
delay = scaled
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
|
||||
type result struct {
|
||||
body []byte
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
launch := func() {
|
||||
go func() {
|
||||
body, err := r.store.readBody(ctx, id)
|
||||
results <- result{body, err}
|
||||
}()
|
||||
}
|
||||
launch()
|
||||
outstanding := 1
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
var last result
|
||||
for {
|
||||
select {
|
||||
case res := <-results:
|
||||
outstanding--
|
||||
if res.err == nil {
|
||||
if elapsed := time.Since(start); elapsed > 0 && len(res.body) > 0 {
|
||||
instant := float64(len(res.body)) / elapsed.Seconds()
|
||||
r.mu.Lock()
|
||||
if r.bodyRate == 0 {
|
||||
r.bodyRate = instant
|
||||
} else {
|
||||
r.bodyRate = (r.bodyRate + instant) / 2
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
return res.body, nil
|
||||
}
|
||||
last = res
|
||||
if outstanding == 0 {
|
||||
return nil, last.err
|
||||
}
|
||||
case <-timer.C:
|
||||
if outstanding == 1 {
|
||||
launch()
|
||||
outstanding++
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pace is about how long segments take to come one after the other
|
||||
func (r *slotReader) pace() time.Duration {
|
||||
r.mu.Lock()
|
||||
@@ -726,7 +1027,7 @@ func (r *slotReader) ackLoop() {
|
||||
if want <= acked {
|
||||
break
|
||||
}
|
||||
if err := r.store.setSlot(r.ctx, r.table.Watermark, strconv.FormatInt(want, 10), nil); err == nil {
|
||||
if err := r.store.setSlot(context.WithValue(r.ctx, laneKey{}, true), r.table.Watermark, strconv.FormatInt(want, 10), nil); err == nil {
|
||||
acked, wait = want, slotAckRetry
|
||||
continue
|
||||
}
|
||||
@@ -742,10 +1043,15 @@ func (r *slotReader) ackLoop() {
|
||||
|
||||
func (r *slotReader) setErr(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err == nil {
|
||||
r.err = err
|
||||
}
|
||||
r.mu.Unlock()
|
||||
// A stored error means the connection is dead: stop the run loop so the
|
||||
// chunk channel closes and Read returns the error instead of blocking.
|
||||
if r.stop != nil {
|
||||
r.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *slotReader) Err() error {
|
||||
@@ -756,3 +1062,10 @@ func (r *slotReader) Err() error {
|
||||
}
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
func slotTag(id string) string {
|
||||
if len(id) > 6 {
|
||||
return id[len(id)-6:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// badBodySlots serves a media frame whose body never matches its checksum.
|
||||
type badBodySlots struct{}
|
||||
|
||||
func (badBodySlots) makeSlot(ctx context.Context, name string) (string, error) { return name, nil }
|
||||
func (badBodySlots) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (badBodySlots) readDescription(ctx context.Context, id string) (string, error) {
|
||||
return newSlotFrame(0, slotMedia, make([]byte, 64)).encode(), nil
|
||||
}
|
||||
|
||||
func (badBodySlots) readBody(ctx context.Context, id string) ([]byte, error) {
|
||||
return bytes.Repeat([]byte{1}, 64), nil
|
||||
}
|
||||
|
||||
// emptySlots serves an empty description: an idle slot, not a failure.
|
||||
type emptySlots struct{}
|
||||
|
||||
func (emptySlots) makeSlot(ctx context.Context, name string) (string, error) { return name, nil }
|
||||
func (emptySlots) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (emptySlots) readDescription(ctx context.Context, id string) (string, error) { return "", nil }
|
||||
func (emptySlots) readBody(ctx context.Context, id string) ([]byte, error) { return nil, nil }
|
||||
|
||||
func shrinkStallClocks(t *testing.T) {
|
||||
t.Helper()
|
||||
// Only the stall timeout is shrunk; readers snapshot it at construction,
|
||||
// so lingering goroutines from other tests never read a mutated global.
|
||||
oldStall := slotStallTimeout
|
||||
slotStallTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { slotStallTimeout = oldStall })
|
||||
}
|
||||
|
||||
func TestHeadBodyChecksumMismatchFails(t *testing.T) {
|
||||
shrinkStallClocks(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
r := newSlotReader(ctx, badBodySlots{}, slotTable{Slots: []string{"id"}, Watermark: "w"},
|
||||
params{concurrency: 1, eagerWindow: 50 * time.Millisecond, maxPollInterval: 5 * time.Millisecond}, nil)
|
||||
select {
|
||||
case _, ok := <-r.chunks():
|
||||
if ok {
|
||||
t.Fatal("a mismatched body was delivered")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("a persistent checksum mismatch retried forever")
|
||||
}
|
||||
if err := r.Err(); err == nil || !strings.Contains(err.Error(), "no usable body") {
|
||||
t.Fatalf("reader ended with %v, want a clear body error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySlotStaysIdle(t *testing.T) {
|
||||
shrinkStallClocks(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
r := newSlotReader(ctx, emptySlots{}, slotTable{Slots: []string{"id"}, Watermark: "w"},
|
||||
params{concurrency: 1, eagerWindow: 50 * time.Millisecond, maxPollInterval: 5 * time.Millisecond}, nil)
|
||||
select {
|
||||
case <-r.chunks():
|
||||
t.Fatal("an empty slot failed the reader instead of staying idle")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -86,8 +88,8 @@ func slotPair(t *testing.T, store slotStore, concurrency, segmentBytes uint32) (
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := newSlotConn(context.Background(), store, r.Up, r.Down, p, nil)
|
||||
server := newSlotConn(context.Background(), store, r.Down, r.Up, p, nil)
|
||||
client := newSlotConn(context.Background(), store, r.Up, r.Down, p, nil, nil)
|
||||
server := newSlotConn(context.Background(), store, r.Down, r.Up, p, nil, nil)
|
||||
t.Cleanup(func() {
|
||||
client.Close()
|
||||
server.Close()
|
||||
@@ -304,6 +306,621 @@ func TestSlotStaleBody(t *testing.T) {
|
||||
client, server := slotPair(t, &staleSlots{memSlots: newMemSlots(), served: make(map[string]bool)}, 2, 64<<10)
|
||||
payload := randomPayload(200 << 10)
|
||||
errs := make(chan error, 1)
|
||||
writeAll(client, payload, true, errs)
|
||||
go func() {
|
||||
_, err := client.Write(payload)
|
||||
if err == nil {
|
||||
err = client.Close()
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
readAll(t, server, payload)
|
||||
}
|
||||
|
||||
func TestSlotBurstSplitsInline(t *testing.T) {
|
||||
store := newMemSlots()
|
||||
client, server := slotPair(t, store, 8, 512<<10)
|
||||
payload := randomPayload(90 << 10)
|
||||
errs := make(chan error, 1)
|
||||
writeAll(client, payload, true, errs)
|
||||
readAll(t, server, payload)
|
||||
store.mu.Lock()
|
||||
bodies := len(store.bodies)
|
||||
store.mu.Unlock()
|
||||
if bodies != 0 {
|
||||
t.Fatalf("a burst of 90 KiB went in %d bodies, want none", bodies)
|
||||
}
|
||||
}
|
||||
|
||||
type stuckBodySlots struct {
|
||||
*memSlots
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *stuckBodySlots) readBody(ctx context.Context, id string) ([]byte, error) {
|
||||
if s.calls.Add(1) == 1 {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return s.memSlots.readBody(ctx, id)
|
||||
}
|
||||
|
||||
func TestSlotBodyHedge(t *testing.T) {
|
||||
old := slotBodyHedge
|
||||
slotBodyHedge = 50 * time.Millisecond
|
||||
defer func() { slotBodyHedge = old }()
|
||||
|
||||
store := &stuckBodySlots{memSlots: newMemSlots()}
|
||||
client, server := slotPair(t, store, 2, 64<<10)
|
||||
payload := randomPayload(200 << 10)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.Write(payload)
|
||||
if err == nil {
|
||||
err = client.Close()
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
readAll(t, server, payload)
|
||||
if store.calls.Load() < 2 {
|
||||
t.Fatalf("the stuck body read was not hedged, %d reads", store.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
type timedBodySlots struct {
|
||||
*memSlots
|
||||
calls atomic.Int32
|
||||
delay time.Duration
|
||||
stuck atomic.Bool
|
||||
}
|
||||
|
||||
func (s *timedBodySlots) readBody(ctx context.Context, id string) ([]byte, error) {
|
||||
n := s.calls.Add(1)
|
||||
if s.stuck.Load() && n == 1 {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(s.delay):
|
||||
}
|
||||
return make([]byte, 1000), nil
|
||||
}
|
||||
|
||||
func TestSlotBodyHedgeRate(t *testing.T) {
|
||||
old := slotBodyHedge
|
||||
slotBodyHedge = 20 * time.Millisecond
|
||||
t.Cleanup(func() { slotBodyHedge = old })
|
||||
|
||||
store := &timedBodySlots{memSlots: newMemSlots(), delay: 60 * time.Millisecond}
|
||||
r := &slotReader{store: store}
|
||||
ctx := context.Background()
|
||||
if _, err := r.readBodyHedged(ctx, "id", 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.calls.Store(0)
|
||||
start := time.Now()
|
||||
if _, err := r.readBodyHedged(ctx, "id", 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 60*time.Millisecond {
|
||||
t.Fatalf("the progressing read returned in %v, before its body was ready", elapsed)
|
||||
}
|
||||
if n := store.calls.Load(); n != 1 {
|
||||
t.Fatalf("a progressing read after a learned slow rate hedged, %d reads", n)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
rate := r.bodyRate
|
||||
r.mu.Unlock()
|
||||
if rate <= 0 {
|
||||
t.Fatal("no body read rate was learned")
|
||||
}
|
||||
expected := max(slotBodyHedge, time.Duration(float64(2*1000)/rate*float64(time.Second)))
|
||||
store.stuck.Store(true)
|
||||
store.calls.Store(0)
|
||||
start = time.Now()
|
||||
if _, err := r.readBodyHedged(ctx, "id", 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
if n := store.calls.Load(); n < 2 {
|
||||
t.Fatalf("a stuck read after a learned rate was not hedged, %d reads", n)
|
||||
}
|
||||
if elapsed < expected/2 {
|
||||
t.Fatalf("the stuck read hedged after %v, want about %v", elapsed, expected)
|
||||
}
|
||||
|
||||
freshStore := &timedBodySlots{memSlots: newMemSlots()}
|
||||
freshStore.stuck.Store(true)
|
||||
fresh := &slotReader{store: freshStore}
|
||||
store.calls.Store(0)
|
||||
start = time.Now()
|
||||
if _, err := fresh.readBodyHedged(ctx, "id", 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < slotBodyHedge {
|
||||
t.Fatalf("with no learned rate the hedge came after %v, want at least %v", elapsed, slotBodyHedge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeSpareSkipsStale(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
p := params{sessionTTL: 5 * time.Minute}
|
||||
st1, err := newLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st2, err := newLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box := spareBoxFor(cfg)
|
||||
box.ch <- spareRing{storage: st1, session: "old", at: time.Now().Add(-time.Hour)}
|
||||
box.ch <- spareRing{storage: st2, session: "new", at: time.Now()}
|
||||
got, ok := takeSpare(cfg, p)
|
||||
if !ok || got.session != "new" {
|
||||
t.Fatalf("takeSpare gave %q, %v, want the fresh one", got.session, ok)
|
||||
}
|
||||
if _, ok := takeSpare(cfg, p); ok {
|
||||
t.Fatal("takeSpare returned a second spare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropSpareDeletesSession(t *testing.T) {
|
||||
st, err := newLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := sessionPrefix("gone") + "/" + uplinkDir + "/s0"
|
||||
if err := st.Put(context.Background(), name, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropSpare(spareRing{storage: st, session: "gone"})
|
||||
if got, _ := st.List(context.Background(), sessionPrefix("gone")); len(got) != 0 {
|
||||
t.Fatalf("%d entries left of a dropped spare", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
type stuckHeadSlots struct {
|
||||
*memSlots
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *stuckHeadSlots) readDescription(ctx context.Context, id string) (string, error) {
|
||||
if s.calls.Add(1) == 1 {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return s.memSlots.readDescription(ctx, id)
|
||||
}
|
||||
|
||||
func TestSlotHeadReadOverlaps(t *testing.T) {
|
||||
client, server := slotPair(t, &stuckHeadSlots{memSlots: newMemSlots()}, 4, 64<<10)
|
||||
for _, c := range [][2]*Conn{{client, server}, {server, client}} {
|
||||
c[0].Write([]byte("ping"))
|
||||
if _, err := io.ReadFull(c[1], make([]byte, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotBurstReadsTogether(t *testing.T) {
|
||||
client, server := slotPair(t, newMemSlots(), 16, 512<<10)
|
||||
payload := randomPayload(8 * slotInlineLimit)
|
||||
go client.Write(payload)
|
||||
start := time.Now()
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(server, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("the burst came back changed")
|
||||
}
|
||||
if took := time.Since(start); took > time.Second {
|
||||
t.Fatalf("a burst of 8 segments took %v to read", took)
|
||||
}
|
||||
}
|
||||
|
||||
type lagSlots struct{ *memSlots }
|
||||
|
||||
func (s lagSlots) readDescription(ctx context.Context, id string) (string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
return s.memSlots.readDescription(ctx, id)
|
||||
}
|
||||
|
||||
func (s lagSlots) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
if _, ok := decodeSlot(description); ok {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(600 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return s.memSlots.setSlot(ctx, id, description, body)
|
||||
}
|
||||
|
||||
func TestSlotReadAheadBurst(t *testing.T) {
|
||||
oldMin, oldMax := slotTailMin, slotTailMax
|
||||
slotTailMin, slotTailMax = 150*time.Millisecond, 800*time.Millisecond
|
||||
t.Cleanup(func() { slotTailMin, slotTailMax = oldMin, oldMax })
|
||||
|
||||
client, server := slotPair(t, lagSlots{newMemSlots()}, 16, 512<<10)
|
||||
payload := randomPayload(8 * slotInlineLimit)
|
||||
go client.Write(payload)
|
||||
start := time.Now()
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(server, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("the burst came back changed")
|
||||
}
|
||||
if took := time.Since(start); took > 1700*time.Millisecond {
|
||||
t.Fatalf("a burst of 8 segments took %v to read", took)
|
||||
}
|
||||
client.Close()
|
||||
server.Close()
|
||||
}
|
||||
|
||||
func TestSlotLargeBurstInline(t *testing.T) {
|
||||
store := newMemSlots()
|
||||
client, server := slotPair(t, store, 16, 512<<10)
|
||||
payload := randomPayload(10 * slotInlineLimit)
|
||||
go client.Write(payload)
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(server, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("the burst came back changed")
|
||||
}
|
||||
store.mu.Lock()
|
||||
bodies := len(store.bodies)
|
||||
store.mu.Unlock()
|
||||
if bodies != 0 {
|
||||
t.Fatalf("a burst of 140 KiB went in %d bodies, want none", bodies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotDepthFollowsConcurrency(t *testing.T) {
|
||||
for _, c := range []struct{ set, want int }{{100, 100}, {maxConcurrency, maxConcurrency}, {1 << 20, maxConcurrency}} {
|
||||
p := paramsFromConfig(&Config{Concurrency: uint32(c.set)})
|
||||
r, err := makeRing(context.Background(), newMemSlots(), "session", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Up.Slots) != c.want || len(r.Down.Slots) != c.want {
|
||||
t.Fatalf("concurrency %d made %d and %d slots, want %d", c.set, len(r.Up.Slots), len(r.Down.Slots), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotDeepRingRoundTrip(t *testing.T) {
|
||||
client, server := slotPair(t, newMemSlots(), 64, 64<<10)
|
||||
payload := randomPayload(2 << 20)
|
||||
errs := make(chan error, 1)
|
||||
writeAll(client, payload, true, errs)
|
||||
readAll(t, server, payload)
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type pollSlots struct {
|
||||
*memSlots
|
||||
pmu sync.Mutex
|
||||
polled map[string]bool
|
||||
}
|
||||
|
||||
func (s *pollSlots) readDescription(ctx context.Context, id string) (string, error) {
|
||||
s.pmu.Lock()
|
||||
s.polled[id] = true
|
||||
s.pmu.Unlock()
|
||||
return s.memSlots.readDescription(ctx, id)
|
||||
}
|
||||
|
||||
func TestSlotReaderReachesPastWindow(t *testing.T) {
|
||||
store := &pollSlots{memSlots: newMemSlots(), polled: make(map[string]bool)}
|
||||
p := paramsFromConfig(&Config{FlushIntervalMs: 1, PollIntervalMs: 1, MaxPollIntervalMs: 20, EagerWindowMs: 50, Concurrency: 64})
|
||||
ring, err := makeRing(context.Background(), store, "session", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
put := func(seq int64) {
|
||||
slot := ring.Down.Slots[seq%int64(len(ring.Down.Slots))]
|
||||
if err := store.setSlot(ctx, slot, newSlotFrame(seq, slotInline, []byte{byte(seq)}).encode(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
reader := newSlotReader(ctx, store, ring.Down, p, nil)
|
||||
next := func() []byte {
|
||||
select {
|
||||
case chunk := <-reader.chunks():
|
||||
return chunk
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("no segment came")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
put(0)
|
||||
if got := next(); !bytes.Equal(got, []byte{0}) {
|
||||
t.Fatalf("segment 0 came as %v", got)
|
||||
}
|
||||
for seq := int64(2); seq <= 40; seq++ {
|
||||
put(seq)
|
||||
}
|
||||
last := ring.Down.Slots[40]
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
reader.Wake()
|
||||
store.pmu.Lock()
|
||||
polled := store.polled[last]
|
||||
store.pmu.Unlock()
|
||||
if polled {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("segment 40 was never asked for while segment 1 was missing")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
put(1)
|
||||
for seq := int64(1); seq <= 40; seq++ {
|
||||
if got := next(); !bytes.Equal(got, []byte{byte(seq)}) {
|
||||
t.Fatalf("segment %d came as %v", seq, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type countSlots struct {
|
||||
*memSlots
|
||||
cur atomic.Int32
|
||||
peak atomic.Int32
|
||||
}
|
||||
|
||||
func (s *countSlots) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
if _, ok := decodeSlot(description); !ok {
|
||||
return s.memSlots.setSlot(ctx, id, description, body)
|
||||
}
|
||||
n := s.cur.Add(1)
|
||||
for {
|
||||
p := s.peak.Load()
|
||||
if n <= p || s.peak.CompareAndSwap(p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
s.cur.Add(-1)
|
||||
return s.memSlots.setSlot(ctx, id, description, body)
|
||||
}
|
||||
|
||||
func TestSlotWriteInflightCap(t *testing.T) {
|
||||
store := &countSlots{memSlots: newMemSlots()}
|
||||
client, server := slotPair(t, store, 48, 512<<10)
|
||||
payload := randomPayload(60 * slotInlineLimit)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
for off := 0; off < len(payload); off += slotInlineLimit {
|
||||
if _, err := client.Write(payload[off : off+slotInlineLimit]); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
errs <- nil
|
||||
}()
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(server, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("the payload came back changed")
|
||||
}
|
||||
if p := int(store.peak.Load()); p > slotWriteInflight {
|
||||
t.Fatalf("%d segments were written at once, want at most %d", p, slotWriteInflight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotBurstInlineWaves(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pieces int
|
||||
media bool
|
||||
}{{20, false}, {30, true}} {
|
||||
store := newMemSlots()
|
||||
client, server := slotPair(t, store, 16, 512<<10)
|
||||
payload := randomPayload(c.pieces * slotInlineLimit)
|
||||
go client.Write(payload)
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(server, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("the burst came back changed")
|
||||
}
|
||||
store.mu.Lock()
|
||||
bodies := len(store.bodies)
|
||||
store.mu.Unlock()
|
||||
if (bodies > 0) != c.media {
|
||||
t.Fatalf("a burst of %d pieces went in %d bodies, media wanted %v", c.pieces, bodies, c.media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type holdSlots struct {
|
||||
*memSlots
|
||||
target string
|
||||
hold chan struct{}
|
||||
}
|
||||
|
||||
func (s *holdSlots) setSlot(ctx context.Context, id, description string, body []byte) error {
|
||||
if id == s.target {
|
||||
select {
|
||||
case <-s.hold:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return s.memSlots.setSlot(ctx, id, description, body)
|
||||
}
|
||||
|
||||
func TestSlotFirstAnnouncement(t *testing.T) {
|
||||
p := params{
|
||||
concurrency: 2,
|
||||
segmentBytes: 4096,
|
||||
flushInterval: time.Millisecond,
|
||||
minPollInterval: time.Millisecond,
|
||||
maxPollInterval: 5 * time.Millisecond,
|
||||
sessionTTL: time.Minute,
|
||||
}
|
||||
store := &holdSlots{memSlots: newMemSlots(), hold: make(chan struct{})}
|
||||
ctx := context.Background()
|
||||
r, err := makeRing(ctx, store, "first", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.target = r.Up.Slots[0]
|
||||
client := newSlotConn(ctx, store, r.Up, r.Down, p, nil, nil)
|
||||
defer client.Close()
|
||||
writer := client.writer.(*slotWriter)
|
||||
writer.first = make(chan []byte, 1)
|
||||
|
||||
payload := randomPayload(10000)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.Write(payload)
|
||||
if err == nil {
|
||||
err = client.Close()
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
var first []byte
|
||||
select {
|
||||
case first = <-writer.first:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the writer never flushed seq 0")
|
||||
}
|
||||
announced := r
|
||||
announced.First = first
|
||||
encoded, err := json.Marshal(announced)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(encoded) > driveInlineLimit {
|
||||
t.Fatalf("the test announcement is %d bytes, want the inline path", len(encoded))
|
||||
}
|
||||
decoded, ok := decodeRing(encoded)
|
||||
if !ok {
|
||||
t.Fatal("the announcement with First did not decode")
|
||||
}
|
||||
server := newSlotConn(ctx, store, decoded.Down, decoded.Up, p, decoded.First, nil)
|
||||
defer server.Close()
|
||||
if err := server.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotFirst := make([]byte, len(first))
|
||||
if _, err := io.ReadFull(server, gotFirst); err != nil {
|
||||
t.Fatalf("the first bytes did not arrive before the ring write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotFirst, first) {
|
||||
t.Fatal("the primed first bytes changed")
|
||||
}
|
||||
close(store.hold)
|
||||
rest := make([]byte, len(payload)-len(first))
|
||||
if _, err := io.ReadFull(server, rest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(append(gotFirst, rest...), payload) {
|
||||
t.Fatal("the stream was duplicated or changed")
|
||||
}
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotFirstAnnouncementTooLarge(t *testing.T) {
|
||||
p := params{
|
||||
concurrency: 2,
|
||||
segmentBytes: 256 << 10,
|
||||
flushInterval: time.Millisecond,
|
||||
sessionTTL: time.Minute,
|
||||
}
|
||||
store := newMemSlots()
|
||||
ctx := context.Background()
|
||||
r, err := makeRing(ctx, store, "large-first", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := newSlotConn(ctx, store, r.Up, r.Down, p, nil, nil)
|
||||
defer client.Close()
|
||||
writer := client.writer.(*slotWriter)
|
||||
writer.first = make(chan []byte, 1)
|
||||
go client.Write(randomPayload(300 << 10))
|
||||
var first []byte
|
||||
select {
|
||||
case first = <-writer.first:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the writer never flushed seq 0")
|
||||
}
|
||||
if len(first) <= slotInlineLimit {
|
||||
t.Fatalf("the first segment is %d bytes, want a media segment", len(first))
|
||||
}
|
||||
withFirst := r
|
||||
withFirst.First = first
|
||||
encoded, err := json.Marshal(withFirst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(encoded) <= driveInlineLimit {
|
||||
t.Fatalf("a %d byte first segment fit the inline announcement", len(first))
|
||||
}
|
||||
plain, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, ok := decodeRing(plain)
|
||||
if !ok || decoded.First != nil {
|
||||
t.Fatal("the announcement without First did not decode cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotRingFirstCompatibility(t *testing.T) {
|
||||
p := params{concurrency: 2, sessionTTL: time.Minute}
|
||||
store := newMemSlots()
|
||||
r, err := makeRing(context.Background(), store, "compat", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.First = []byte("hello")
|
||||
encoded, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var oldServer struct {
|
||||
Up slotTable `json:"u"`
|
||||
Down slotTable `json:"d"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &oldServer); err != nil {
|
||||
t.Fatalf("an old server could not ignore First: %v", err)
|
||||
}
|
||||
if len(oldServer.Up.Slots) != len(r.Up.Slots) || len(oldServer.Down.Slots) != len(r.Down.Slots) {
|
||||
t.Fatal("an old server lost the ring tables")
|
||||
}
|
||||
r.First = nil
|
||||
plain, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, ok := decodeRing(plain)
|
||||
if !ok || decoded.First != nil {
|
||||
t.Fatal("an old client announcement did not decode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
@@ -26,11 +27,18 @@ type Storage interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// testStorageFactory lets tests substitute a backend for newStorage; nil in
|
||||
// production so behaviour is unchanged.
|
||||
var testStorageFactory atomic.Pointer[func(config *Config) (Storage, error)]
|
||||
|
||||
func newStorage(streamSettings *internet.MemoryStreamConfig) (Storage, error) {
|
||||
config, err := streamConfig(streamSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f := testStorageFactory.Load(); f != nil {
|
||||
return (*f)(config)
|
||||
}
|
||||
|
||||
switch config.Service {
|
||||
case "local":
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common"
|
||||
@@ -26,6 +27,11 @@ const (
|
||||
downlinkDir = "s2c"
|
||||
)
|
||||
|
||||
const (
|
||||
spareCount = 2
|
||||
spareIdleStop = 10 * time.Minute
|
||||
)
|
||||
|
||||
func init() {
|
||||
common.Must(internet.RegisterProtocolConfigCreator(protocolName, func() interface{} {
|
||||
return new(Config)
|
||||
@@ -78,47 +84,191 @@ func streamConfig(streamSettings *internet.MemoryStreamConfig) (*Config, error)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type spareRing struct {
|
||||
storage Storage
|
||||
session string
|
||||
ring ring
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type spareBox struct {
|
||||
ch chan spareRing
|
||||
busy atomic.Bool
|
||||
last atomic.Int64
|
||||
stop atomic.Bool
|
||||
}
|
||||
|
||||
var spareBoxes sync.Map
|
||||
|
||||
func spareBoxFor(config *Config) *spareBox {
|
||||
if b, ok := spareBoxes.Load(config); ok {
|
||||
return b.(*spareBox)
|
||||
}
|
||||
b, _ := spareBoxes.LoadOrStore(config, &spareBox{ch: make(chan spareRing, spareCount)})
|
||||
return b.(*spareBox)
|
||||
}
|
||||
|
||||
func dropSpare(s spareRing) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.storage.Delete(ctx, sessionPrefix(s.session))
|
||||
s.storage.Close()
|
||||
}
|
||||
|
||||
func takeSpare(config *Config, p params) (spareRing, bool) {
|
||||
box := spareBoxFor(config)
|
||||
for {
|
||||
select {
|
||||
case s := <-box.ch:
|
||||
if time.Since(s.at) < p.sessionTTL*2/3 {
|
||||
return s, true
|
||||
}
|
||||
go dropSpare(s)
|
||||
default:
|
||||
return spareRing{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warmSpares(streamSettings *internet.MemoryStreamConfig, config *Config, p params) {
|
||||
spareBoxFor(config).last.Store(time.Now().UnixNano())
|
||||
fillSpares(streamSettings, config, p)
|
||||
}
|
||||
|
||||
func fillSpares(streamSettings *internet.MemoryStreamConfig, config *Config, p params) {
|
||||
box := spareBoxFor(config)
|
||||
if !box.busy.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer box.busy.Store(false)
|
||||
for n := len(box.ch); n > 0; n-- {
|
||||
select {
|
||||
case s := <-box.ch:
|
||||
if time.Since(s.at) < p.sessionTTL/3 {
|
||||
box.ch <- s
|
||||
} else {
|
||||
dropSpare(s)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
for len(box.ch) < cap(box.ch) {
|
||||
for range 40 {
|
||||
if box.stop.Load() {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if box.stop.Load() {
|
||||
return
|
||||
}
|
||||
storage, err := newStorage(streamSettings)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
storage = sessionStorage(storage)
|
||||
slots, ok := storage.(slotStore)
|
||||
if !ok {
|
||||
storage.Close()
|
||||
return
|
||||
}
|
||||
session, err := newSessionID()
|
||||
if err != nil {
|
||||
storage.Close()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
r, err := makeRing(ctx, slots, session, p)
|
||||
cancel()
|
||||
if err != nil {
|
||||
dropSpare(spareRing{storage: storage, session: session})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case box.ch <- spareRing{storage: storage, session: session, ring: r, at: time.Now()}:
|
||||
default:
|
||||
storage.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
time.AfterFunc(p.sessionTTL/4, func() {
|
||||
if !box.stop.Load() && time.Since(time.Unix(0, box.last.Load())) < spareIdleStop {
|
||||
fillSpares(streamSettings, config, p)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) {
|
||||
config, err := streamConfig(streamSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage, err := newStorage(streamSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage = sessionStorage(storage)
|
||||
|
||||
session, err := newSessionID()
|
||||
if err != nil {
|
||||
storage.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := paramsFromConfig(config)
|
||||
var announce []byte
|
||||
var r ring
|
||||
|
||||
spare, warm := takeSpare(config, p)
|
||||
storage, session, r := spare.storage, spare.session, spare.ring
|
||||
if !warm {
|
||||
storage, err = newStorage(streamSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage = sessionStorage(storage)
|
||||
|
||||
session, err = newSessionID()
|
||||
if err != nil {
|
||||
storage.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
p = paramsForStorage(config, storage)
|
||||
|
||||
slots, ok := storage.(slotStore)
|
||||
if ok {
|
||||
if r, err = makeRing(ctx, slots, session, p); err == nil {
|
||||
announce, _ = json.Marshal(r)
|
||||
} else {
|
||||
if ok && !warm {
|
||||
if r, err = makeRing(ctx, slots, session, p); err != nil {
|
||||
ok = false
|
||||
errors.LogWarningInner(ctx, err, "failed to make the slots of session ", session, ", it goes in segments")
|
||||
}
|
||||
}
|
||||
|
||||
if err := storage.Put(ctx, announceName(session, time.Now()), announce); err != nil {
|
||||
storage.Close()
|
||||
return nil, errors.New("failed to announce session ", session).Base(err)
|
||||
if ok {
|
||||
warmSpares(streamSettings, config, p)
|
||||
errors.LogInfo(ctx, "opened session ", session, " in ", len(r.Up.Slots), " slots")
|
||||
conn := newSlotConn(context.Background(), slots, r.Up, r.Down, p, nil, func() {
|
||||
storage.Close()
|
||||
})
|
||||
writer := conn.writer.(*slotWriter)
|
||||
writer.first = make(chan []byte, 1)
|
||||
go func() {
|
||||
var first []byte
|
||||
select {
|
||||
case first = <-writer.first:
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
announce, _ := json.Marshal(r)
|
||||
if len(first) > 0 && len(first) <= slotInlineLimit {
|
||||
withFirst := r
|
||||
withFirst.First = first
|
||||
if encoded, err := json.Marshal(withFirst); err == nil && len(encoded) <= driveInlineLimit {
|
||||
announce = encoded
|
||||
}
|
||||
}
|
||||
// An announce failure surfaces as a connection error on the next Write/Read.
|
||||
if err := storage.Put(context.Background(), announceName(session, time.Now()), announce); err != nil {
|
||||
err = errors.New("failed to announce session ", session).Base(err)
|
||||
writer.fail(err)
|
||||
conn.reader.(*slotReader).setErr(err)
|
||||
}
|
||||
}()
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if ok {
|
||||
errors.LogInfo(ctx, "opened session ", session, " in ", len(r.Up.Slots), " slots")
|
||||
return newSlotConn(context.Background(), slots, r.Up, r.Down, p, func() {
|
||||
storage.Close()
|
||||
}), nil
|
||||
if err := storage.Put(ctx, announceName(session, time.Now()), nil); err != nil {
|
||||
storage.Close()
|
||||
return nil, errors.New("failed to announce session ", session).Base(err)
|
||||
}
|
||||
errors.LogInfo(ctx, "opened session ", session)
|
||||
return newConn(context.Background(), storage,
|
||||
@@ -134,10 +284,12 @@ type Listener struct {
|
||||
addConn internet.ConnHandler
|
||||
params
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]bool
|
||||
handled map[string]time.Time
|
||||
idleSince map[string]time.Time
|
||||
mu sync.Mutex
|
||||
active map[string]bool
|
||||
handled map[string]time.Time
|
||||
idleSince map[string]time.Time
|
||||
announceWarned map[string]bool
|
||||
firstSeen map[string]time.Time
|
||||
}
|
||||
|
||||
func Serve(ctx context.Context, address net.Address, port net.Port, streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
|
||||
@@ -153,14 +305,16 @@ func Serve(ctx context.Context, address net.Address, port net.Port, streamSettin
|
||||
|
||||
listenerCtx, cancel := context.WithCancel(context.Background())
|
||||
listener := &Listener{
|
||||
ctx: listenerCtx,
|
||||
cancel: cancel,
|
||||
storage: storage,
|
||||
addConn: addConn,
|
||||
params: paramsFromConfig(config),
|
||||
active: make(map[string]bool),
|
||||
handled: make(map[string]time.Time),
|
||||
idleSince: make(map[string]time.Time),
|
||||
ctx: listenerCtx,
|
||||
cancel: cancel,
|
||||
storage: storage,
|
||||
addConn: addConn,
|
||||
params: paramsForStorage(config, storage),
|
||||
active: make(map[string]bool),
|
||||
handled: make(map[string]time.Time),
|
||||
idleSince: make(map[string]time.Time),
|
||||
announceWarned: make(map[string]bool),
|
||||
firstSeen: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
go listener.acceptLoop(ctx)
|
||||
@@ -174,7 +328,7 @@ func (l *Listener) acceptLoop(logCtx context.Context) {
|
||||
active := time.Now()
|
||||
for {
|
||||
accepted, err := l.acceptPending(logCtx)
|
||||
if err != nil {
|
||||
if err != nil && !isAuthError(err) {
|
||||
errors.LogWarningInner(logCtx, err, "failed to list sessions")
|
||||
}
|
||||
|
||||
@@ -206,27 +360,72 @@ func (l *Listener) acceptPending(logCtx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
accepted := false
|
||||
l.mu.Lock()
|
||||
if l.firstSeen == nil {
|
||||
l.firstSeen = make(map[string]time.Time)
|
||||
}
|
||||
now := time.Now()
|
||||
listedEntries := make(map[string]bool, len(sessions))
|
||||
for _, listed := range sessions {
|
||||
listedEntries[listed.Name] = true
|
||||
if _, seen := l.firstSeen[listed.Name]; !seen {
|
||||
l.firstSeen[listed.Name] = now
|
||||
}
|
||||
}
|
||||
for entry := range l.firstSeen {
|
||||
if !listedEntries[entry] {
|
||||
delete(l.firstSeen, entry)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
for _, listed := range sessions {
|
||||
entry := listed.Name
|
||||
full := sessionsDir + "/" + entry
|
||||
|
||||
session, at, ok := parseAnnounce(entry)
|
||||
session, _, ok := parseAnnounce(entry)
|
||||
if !ok {
|
||||
go l.drop(full)
|
||||
continue
|
||||
}
|
||||
if time.Since(at) > l.sessionTTL {
|
||||
l.mu.Lock()
|
||||
age := now.Sub(l.firstSeen[entry])
|
||||
l.mu.Unlock()
|
||||
if age > l.sessionTTL {
|
||||
errors.LogInfo(logCtx, "dropping the stale announcement of session ", session)
|
||||
go l.drop(full)
|
||||
go l.drop(sessionPrefix(session))
|
||||
l.mu.Lock()
|
||||
protected := l.active[session] || !l.handled[session].IsZero()
|
||||
l.mu.Unlock()
|
||||
if !protected {
|
||||
go l.drop(sessionPrefix(session))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !l.claim(session) {
|
||||
continue
|
||||
}
|
||||
announce := listed.Inline
|
||||
if len(announce) == 0 {
|
||||
if _, ok := sessionStorage(l.storage).(slotStore); ok {
|
||||
announce, err = l.storage.Get(context.WithValue(l.ctx, maxBodyKey{}, int64(driveMaxAnnounce)), full)
|
||||
if err != nil {
|
||||
l.mu.Lock()
|
||||
delete(l.active, session)
|
||||
delete(l.handled, session)
|
||||
warn := !l.announceWarned[session]
|
||||
l.announceWarned[session] = true
|
||||
l.mu.Unlock()
|
||||
if warn {
|
||||
errors.LogWarningInner(logCtx, err, "failed to read the announcement of session ", session)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
go l.drop(full)
|
||||
accepted = true
|
||||
l.addConn(l.newSessionConn(logCtx, session, listed.Inline))
|
||||
l.addConn(l.newSessionConn(logCtx, session, announce))
|
||||
}
|
||||
return accepted, nil
|
||||
}
|
||||
@@ -259,7 +458,7 @@ func (l *Listener) newSessionConn(logCtx context.Context, session string, announ
|
||||
if slots, ok := storage.(slotStore); ok {
|
||||
if r, ok := decodeRing(announce); ok {
|
||||
errors.LogInfo(logCtx, "accepted session ", session, " in ", len(r.Down.Slots), " slots")
|
||||
return newSlotConn(l.ctx, slots, r.Down, r.Up, l.params, onClose)
|
||||
return newSlotConn(l.ctx, slots, r.Down, r.Up, l.params, r.First, onClose)
|
||||
}
|
||||
}
|
||||
errors.LogInfo(logCtx, "accepted session ", session)
|
||||
@@ -320,12 +519,13 @@ func (l *Listener) collect() error {
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
var first error
|
||||
for _, session := range expired {
|
||||
if err := l.storage.Delete(l.ctx, sessionPrefix(session)); err != nil {
|
||||
return err
|
||||
if err := l.storage.Delete(l.ctx, sessionPrefix(session)); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return first
|
||||
}
|
||||
|
||||
func (l *Listener) Addr() net.Addr {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
)
|
||||
|
||||
// failPutStore is a slotStore whose Put always fails, to exercise the announce
|
||||
// failure path without any network.
|
||||
type failPutStore struct {
|
||||
*memSlots
|
||||
}
|
||||
|
||||
func (s *failPutStore) Put(ctx context.Context, name string, data []byte) error {
|
||||
return errors.New("put refused")
|
||||
}
|
||||
|
||||
func (s *failPutStore) Close() error { return nil }
|
||||
|
||||
func (s *failPutStore) Get(ctx context.Context, name string) ([]byte, error) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
|
||||
func (s *failPutStore) List(ctx context.Context, prefix string) ([]Entry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *failPutStore) Delete(ctx context.Context, name string) error { return nil }
|
||||
|
||||
func TestDialAnnounceFailureFailsRead(t *testing.T) {
|
||||
store := &failPutStore{memSlots: newMemSlots()}
|
||||
factory := func(*Config) (Storage, error) { return store, nil }
|
||||
testStorageFactory.Store(&factory)
|
||||
t.Cleanup(func() { testStorageFactory.Store(nil) })
|
||||
|
||||
settings := &internet.MemoryStreamConfig{
|
||||
ProtocolName: protocolName,
|
||||
ProtocolSettings: &Config{
|
||||
Service: "sim",
|
||||
RemoteFolder: "x",
|
||||
Secrets: []string{"a", "b", "c"},
|
||||
Concurrency: 4,
|
||||
FlushIntervalMs: 1,
|
||||
PollIntervalMs: 1,
|
||||
MaxPollIntervalMs: 5,
|
||||
SessionTtlSeconds: 60,
|
||||
},
|
||||
}
|
||||
conn, err := Dial(context.Background(), net.Destination{}, settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
go conn.Write([]byte("hello"))
|
||||
|
||||
type result struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
n, err := conn.Read(make([]byte, 16))
|
||||
done <- result{n, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err == nil || !strings.Contains(r.err.Error(), "failed to announce") {
|
||||
t.Fatalf("Read returned %v, want the announce error", r.err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Read blocked after the announcement failed")
|
||||
}
|
||||
|
||||
// The reader loop must have exited: it no longer polls the store.
|
||||
store.mu.Lock()
|
||||
before := store.reads
|
||||
store.mu.Unlock()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
store.mu.Lock()
|
||||
after := store.reads
|
||||
store.mu.Unlock()
|
||||
if after != before {
|
||||
t.Fatalf("reader kept polling after the announce failure: %d -> %d", before, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package xdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/transport/internet/stat"
|
||||
)
|
||||
|
||||
func announceEntry(name string) string {
|
||||
return strings.TrimPrefix(name, sessionsDir+"/")
|
||||
}
|
||||
|
||||
func TestAcceptPendingClientClockBehind(t *testing.T) {
|
||||
l := newTestListener(t, t.TempDir())
|
||||
var conns int
|
||||
l.addConn = func(c stat.Connection) { conns++; c.Close() }
|
||||
ctx := context.Background()
|
||||
|
||||
session := "clockbehind"
|
||||
ring := sessionPrefix(session) + "/" + uplinkDir + "/s0"
|
||||
if err := l.storage.Put(ctx, ring, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The client's clock is ten minutes behind the server's.
|
||||
name := announceName(session, time.Now().Add(-10*time.Minute))
|
||||
if err := l.storage.Put(ctx, name, []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := l.acceptPending(ctx)
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("a slow client clock must not drop the session: accepted=%v err=%v", accepted, err)
|
||||
}
|
||||
if conns != 1 {
|
||||
t.Fatalf("addConn called %d times, want 1", conns)
|
||||
}
|
||||
if _, err := l.storage.Get(ctx, ring); err != nil {
|
||||
t.Fatalf("the ring of an accepted session was dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptPendingStaleLeftoverProtectsActiveRing(t *testing.T) {
|
||||
l := newTestListener(t, t.TempDir())
|
||||
l.params.sessionTTL = 50 * time.Millisecond
|
||||
var conns int
|
||||
l.addConn = func(c stat.Connection) { conns++; c.Close() }
|
||||
ctx := context.Background()
|
||||
|
||||
session := "active"
|
||||
l.active[session] = true
|
||||
l.handled[session] = time.Now()
|
||||
ring := sessionPrefix(session) + "/" + uplinkDir + "/s0"
|
||||
if err := l.storage.Put(ctx, ring, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := announceName(session, time.Now())
|
||||
if err := l.storage.Put(ctx, name, []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The listener first saw this leftover entry long ago on its own clock.
|
||||
l.firstSeen = map[string]time.Time{announceEntry(name): time.Now().Add(-time.Hour)}
|
||||
|
||||
accepted, err := l.acceptPending(ctx)
|
||||
if err != nil || accepted {
|
||||
t.Fatalf("a stale leftover must not be accepted: accepted=%v err=%v", accepted, err)
|
||||
}
|
||||
if conns != 0 {
|
||||
t.Fatalf("addConn called %d times, want 0", conns)
|
||||
}
|
||||
waitFor(t, "the leftover announcement file to drop", func() bool {
|
||||
_, err := l.storage.Get(ctx, name)
|
||||
return err == errNotFound
|
||||
})
|
||||
if _, err := l.storage.Get(ctx, ring); err != nil {
|
||||
t.Fatalf("the ring of an active session was dropped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptPendingTrulyLeftoverDropped(t *testing.T) {
|
||||
l := newTestListener(t, t.TempDir())
|
||||
l.params.sessionTTL = 50 * time.Millisecond
|
||||
var conns int
|
||||
l.addConn = func(c stat.Connection) { conns++; c.Close() }
|
||||
ctx := context.Background()
|
||||
|
||||
session := "leftover"
|
||||
ring := sessionPrefix(session) + "/" + uplinkDir + "/s0"
|
||||
if err := l.storage.Put(ctx, ring, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := announceName(session, time.Now())
|
||||
if err := l.storage.Put(ctx, name, []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.firstSeen = map[string]time.Time{announceEntry(name): time.Now().Add(-time.Hour)}
|
||||
|
||||
accepted, err := l.acceptPending(ctx)
|
||||
if err != nil || accepted {
|
||||
t.Fatalf("accepted=%v err=%v", accepted, err)
|
||||
}
|
||||
waitFor(t, "the leftover announcement and ring to drop", func() bool {
|
||||
_, e1 := l.storage.Get(ctx, name)
|
||||
_, e2 := l.storage.Get(ctx, ring)
|
||||
return e1 == errNotFound && e2 == errNotFound
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptPendingPrunesFirstSeen(t *testing.T) {
|
||||
l := newTestListener(t, t.TempDir())
|
||||
l.addConn = func(c stat.Connection) { c.Close() }
|
||||
ctx := context.Background()
|
||||
l.firstSeen = map[string]time.Time{"gone-entry": time.Now()}
|
||||
if _, err := l.acceptPending(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := l.firstSeen["gone-entry"]; still {
|
||||
t.Fatal("firstSeen kept an entry that is no longer listed")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,13 @@ func pairWith(t *testing.T, streamSettings *internet.MemoryStreamConfig) (client
|
||||
}
|
||||
|
||||
return client, server, func() {
|
||||
if config, err := streamConfig(streamSettings); err == nil {
|
||||
box := spareBoxFor(config)
|
||||
box.stop.Store(true)
|
||||
for box.busy.Load() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
client.Close()
|
||||
server.Close()
|
||||
listener.Close()
|
||||
@@ -368,6 +375,10 @@ func TestStaleAnnounce(t *testing.T) {
|
||||
if err := listener.storage.Put(ctx, uplinkPrefix("ghost")+"/000000000.seg", []byte("x")); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
// The listener first saw this entry an hour ago on its own clock.
|
||||
listener.firstSeen = map[string]time.Time{
|
||||
strings.TrimPrefix(stale, sessionsDir+"/"): time.Now().Add(-time.Hour),
|
||||
}
|
||||
|
||||
accepted, err := listener.acceptPending(ctx)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user