Repository navigation
XDRIVE: when the client disappears mid-download, the server uploads the rest of the response and keeps the session for minutes #7208
Description
Integrity requirements
- I have read all the comments in the issue template and ensured that this issue meet the requirements.
- I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
- I provided the complete config and logs, rather than just providing the truncated parts based on my own judgment.
- I searched issues and did not find any similar issues.
- The problem can be successfully reproduced in the latest Release
Description
简述:在 v26.10.17(与 main 相同)中,如果 XDRIVE 客户端在下载中途消失(进程被杀或断网),服务端无法察觉:它会继续从目标读取,并把剩余的整个响应写入存储(复现中 20 秒内写入约 235 MiB、607 个对象)。这些对象要等服务端的 mux 子连接自行结束后才会被清理(约 2.7–13 分钟),期间该会话仍被持续 LIST 轮询。原因是 XDRIVE 既没有对端存活检测,也没有背压。
Version: v26.10.17 (92ccc93, the newest release and the same commit as current main), network: "xdrive", service: "local". XDRIVE is only in the pre-releases since v26.9.30; it is not in v26.3.27. The session logic involved does not depend on the backend, so remote backends behave the same way.
When an XDRIVE client disappears in the middle of a download (process killed, network lost, which is common for mobile clients), the server does not notice:
- It keeps reading from the target and writes the whole remaining response into the storage. In the repro the client had received 22 MiB of a 256 MiB body; within 20 s after the client was killed the server wrote 246,869,084 bytes in 607 objects.
- These objects stay until the server-side mux sub-connection ends on its own: about 2.7 min when the target closes the connection after the body, about 13 min with a keep-alive target. Meanwhile the dead session is still polled (about 2 LIST per second).
- The server logs nothing after the client is gone, even at
debug.
With a remote backend every abandoned download can upload hundreds of MiB into the storage account, consume API quota and leave the objects there for minutes.
If only the application connection is closed while the client stays alive (curl killed), the server stops writing within about 5 s and the folder is cleaned up, so the problem is specific to the peer disappearing.
256 MiB body at 16 MiB/s, sessionTtlSeconds: 30 |
storage at +30 s | +90 s | +180 s | server kept writing |
|---|---|---|---|---|
| download completes (control) | 0 B | 0 B | 0 B | no |
client kill -9 after ~2 s |
246,869,084 B / 607 objects | same | same | yes, the whole remaining body |
client SIGTERM after ~2 s |
246,394,446 B / 515 objects | same | same | yes |
| curl killed, client alive | 0 B (one .end) |
0 B | 0 B | no, stopped within ~5 s |
client kill -9, keep-alive target, watched 900 s |
246,453,796 B | same | same | yes; empty only at ~750–780 s |
client kill -9, target sends Connection: close |
246,485,923 B | same | 0 B | yes; empty at ~165 s |
Cause
Listener.claimmarks the session active (transport/internet/xdrive/xdrive.go:225); the mark is removed only in the connection'sonClose(xdrive.go:234).collect()skips active sessions (xdrive.go:267-269), so the folder of a dead session is not collected while the server-side connection is open.- That connection is closed only when the inbound handler returns (
app/proxyman/inbound/worker.go:123-127), i.e. after the server-side mux session ends. XDRIVE has no peer liveness signal: no heartbeat, no idle timeout.Conn.receiveblocks on the reader channel (conn.go:53-61),walReader.pollreturns quietly on an empty listing (wal.go:295-297),holeTimeoutfires only when later segments exist (wal.go:312-321), EOF comes only from a.endobject (wal.go:365). - No backpressure:
walWriter.Writeappends to the buffer andflushLockedstartsgo w.upload(wal.go:73-91,wal.go:127-156); the semaphore inuploadlimits concurrentPuts, not unconsumed data (wal.go:161-166). Segments are deleted only by the peer's reader, so nothing limits what is written for a reader that is gone.
A scratch build with extra logging showed collect() skipping the session on every pass, and the server connection closing only after the mux worker finished.
Possible fix
- Limit unconsumed data per direction: the reader already deletes consumed segments, so the writer can stop flushing while more than N of its own segments are still listed (or the reader publishes an ack object).
- Or a peer liveness signal: a small heartbeat object refreshed by each side while the session is open; close the connection and release
activewhen the peer's heartbeat is older than an idle timeout.
Reproduction Method
- Create an empty folder
/tmp/xdrive-store. The configs below use throwaway keys fromxray vlessenc. python3 target.py 18080 16 &— a local target serving a 256 MiB body at 16 MiB/s (script below).xray run -c server.json &, thenxray run -c client.json &.curl -sS --socks5-hostname 127.0.0.1:21005 -o /dev/null http://127.0.0.1:18080/body &- After about 2 s:
kill -9 <client xray pid>. - Every 10 s:
du -sk /tmp/xdrive-store; find /tmp/xdrive-store -type f | wc -l. The folder grows to about 235 MiB within 20 s and stays there for minutes.
target.py
#!/usr/bin/env python3 # GET /body serves a 256 MiB body at a capped rate. Usage: target.py PORT MIB_PER_S [close] import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServerPORT = int(sys.argv[1])
RATE_MIB = float(sys.argv[2]) if len(sys.argv) > 2 else 16.0
CLOSE = len(sys.argv) > 3 and sys.argv[3] == "close"
MIB = 1024 * 1024
TOTAL = 256 * MIB
CHUNK = b"x" * (256 * 1024)class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"def log_message(self, *args): pass def do_GET(self): self.send_response(200) self.send_header("Content-Length", str(TOTAL)) if CLOSE: self.send_header("Connection", "close") self.close_connection = True self.end_headers() pause = len(CHUNK) / (RATE_MIB * MIB) try: for _ in range(TOTAL // len(CHUNK)): self.wfile.write(CHUNK) time.sleep(pause) except OSError: passclass Server(ThreadingHTTPServer):
daemon_threads = True
Server(("127.0.0.1", PORT), Handler).serve_forever()
Client config
Details
{
"log": {
"loglevel": "debug",
"dnsLog": true
},
"inbounds": [
{
"tag": "socks",
"listen": "127.0.0.1",
"port": 21005,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": false
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "127.0.0.1",
"port": 22005,
"users": [
{
"id": "11111111-2222-4333-8444-555555555555",
"encryption": "mlkem768x25519plus.native.0rtt.rBBIc7U3hDYCJmmA2eEZ61T_iqorCP_JTlHSQdPp8iA"
}
]
}
]
},
"streamSettings": {
"network": "xdrive",
"xdriveSettings": {
"service": "local",
"remoteFolder": "/tmp/xdrive-store",
"sessionTtlSeconds": 30
}
},
"mux": {
"enabled": true
}
}
]
}
Server config
Details
{
"log": {
"loglevel": "debug",
"dnsLog": true
},
"inbounds": [
{
"tag": "in",
"listen": "127.0.0.1",
"port": 22005,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "11111111-2222-4333-8444-555555555555"
}
],
"decryption": "mlkem768x25519plus.native.600s.KCs6m9UVB6YH10FkHzgP-g7Vq758GrhgaDMoOUhP7XY"
},
"streamSettings": {
"network": "xdrive",
"xdriveSettings": {
"service": "local",
"remoteFolder": "/tmp/xdrive-store",
"sessionTtlSeconds": 30
}
}
}
],
"outbounds": [
{
"tag": "direct",
"protocol": "freedom",
"settings": {
"finalRules": [
{
"action": "allow"
}
]
}
}
]
}
Client log
The client was killed with kill -9, so its log simply ends.
Details
Xray 26.10.17 (Xray, Penetrates Everything.) 92ccc93 (go1.27.1 darwin/arm64)
A unified platform for anti-censorship.
2026/10/20 13:20:14.259229 [Info] infra/conf/serial: Reading config: &{Name:client.json Format:json}
2026/10/20 13:20:14.259757 [Debug] app/log: Logger started
2026/10/20 13:20:14.259793 [Debug] app/proxyman/inbound: creating stream worker on 127.0.0.1:21005
2026/10/20 13:20:14.260017 [Info] transport/internet/tcp: listening tcp on 127.0.0.1:21005
2026/10/20 13:20:14.260034 [Warning] core: Xray 26.10.17 started
2026/10/20 13:20:16.261687 [Info] [2678353761] proxy/socks: TCP Connect request to tcp:127.0.0.1:18080
2026/10/20 13:20:16.261732 [Info] [2678353761] app/dispatcher: default route for tcp:127.0.0.1:18080
2026/10/20 13:20:16.261750 [Info] [2678353761] common/mux: dispatching request to tcp:127.0.0.1:18080
2026/10/20 13:20:16.261790 from tcp:127.0.0.1:54577 accepted tcp:127.0.0.1:18080 [socks >> proxy]
2026/10/20 13:20:16.262180 [Info] transport/internet/xdrive: opened session dcdedc90086e2e46b81de8a65fc1a85a
2026/10/20 13:20:16.262235 [Info] proxy/vless/outbound: tunneling request to tcp:v1.mux.cool:9527 via 127.0.0.1:22005
Server log
The client was killed at about 13:20:18.3. The server logged nothing after that; the folder reached 246,869,084 bytes / 607 objects by 13:20:38 and stayed there.
Details
Xray 26.10.17 (Xray, Penetrates Everything.) 92ccc93 (go1.27.1 darwin/arm64)
A unified platform for anti-censorship.
2026/10/20 13:20:13.253297 [Info] infra/conf/serial: Reading config: &{Name:server.json Format:json}
2026/10/20 13:20:13.254581 [Debug] app/log: Logger started
2026/10/20 13:20:13.254629 [Debug] app/proxyman/inbound: creating stream worker on 127.0.0.1:22005
2026/10/20 13:20:13.254977 [Warning] core: Xray 26.10.17 started
2026/10/20 13:20:16.488550 [Info] transport/internet/xdrive: accepted session dcdedc90086e2e46b81de8a65fc1a85a
2026/10/20 13:20:16.639683 [Info] [4248092209] proxy/vless/inbound: firstLen = 117
2026/10/20 13:20:16.639715 [Info] [4248092209] proxy/vless/inbound: received request for tcp:v1.mux.cool:0
2026/10/20 13:20:16.639723 [Info] [4248092209] common/mux: received request for tcp:127.0.0.1:18080
2026/10/20 13:20:16.639728 [Info] [4248092209] app/dispatcher: default route for tcp:127.0.0.1:18080
2026/10/20 13:20:16.639748 [Info] [4248092209] transport/internet/tcp: dialing TCP to tcp:127.0.0.1:18080
2026/10/20 13:20:16.639758 [Debug] [4248092209] transport/internet: dialing to tcp:127.0.0.1:18080
2026/10/20 13:20:16.639748 from tcp:127.0.0.1:0 accepted tcp:127.0.0.1:18080 [in >> direct]
2026/10/20 13:20:16.640021 [Info] [4248092209] proxy/freedom: connection opened to tcp:127.0.0.1:18080, local endpoint 127.0.0.1:54588, remote endpoint 127.0.0.1:18080
2026/10/20 13:20:16.640040 [Debug] [4248092209] proxy: CopyRawConn (maybe) readv
Activity
thatssoheil commentedon Oct 20, 2026
Reproduced at
92ccc939(v26.10.17) with the configs and target from the report: local storage, 256 MiB body at 16 MiB/s,sessionTtlSeconds: 30, clientkill -92 s in.The server logs nothing after the client is gone. Same shape as the report (246.9 MB / 607 objects), on this box with a local backend.
Mechanism at main, confirming the trace:
Listener.claimmarks the session active (transport/internet/xdrive/xdrive.go:225) and only the connection'sonCloseclears it (:234);collect()skips active sessions (:267-269), so nothing is collected while the server-side connection lives. That connection ends only when the inbound handler returns, and nothing tests peer liveness:Conn.receiveblocks on the reader channel (conn.go:53-61),walReader.pollreturns quietly on an empty listing,holeTimeoutneeds a later segment to exist, EOF needs a.endobject. On the write side there is no bound on unconsumed data:walWriter.Writebuffers andflushLockedposts uploads (wal.go:73-91,:127-156); the semaphore inuploadbounds concurrentPuts, not backlog.Plan (narrow, no protocol change - the peer already acks by deleting consumed segments):
walWritertracks which of its own segments the peer has not deleted yet. The check is aListof its own prefix, only while it has unconsumed segments and paced so a healthy reader costs no extra calls; it escalates to the poll interval once a cap is reached.Writeblocks while more than N segments are unconsumed, so the inbound handler stops pulling from the target instead of filling storage (this is the missing backpressure).holeTimeout, the connection closes with an error;onClosereleasesactiveandcollect()drops the session.Tests: writer-level (blocks at the cap, resumes when the fake peer deletes, errors when nothing is deleted), listener-level (active released, folder collected), plus the reproduction above as the acceptance leg (storage stops growing at the cap, connection closes, folder is collected).
If you would rather have an explicit heartbeat object per session, tell me and I will implement that shape instead. Branch:
fix/xdrive-unconsumed-backpressure.thatssoheil commentedon Oct 21, 2026
Prototype measured before pushing anything, and it surfaced a design question I would rather settle with you than guess.
A writer-side cap on unconsumed segments (2 x
concurrency, 16 segments / ~8 MB with defaults) turns the reproduction from 242 MB / 576 objects into 8.4 MB / 17 objects, frozen for as long as I watched; a normal 64 MiB download through the same build is unchanged. Good.But two things make that shape unfinished:
walReader.discarddrops names when its channel is full (wal.go:560-565). One dropped delete leaves an object that never disappears from the writer's listing, which pins a healthy writer at the cap forever.Writeblocked at the cap has no lifetime bound. With a dead peer the inbound handler never returns, so the session stays active and is never collected - the data is bounded, but the session is pinned. Today the handler finishes on its own and the session becomes collectable.So inferring consumption from deletes is not reliable enough to build on. The cleaner shape is an explicit ack: the reader publishes its last consumed sequence (one small object, refreshed per poll), and the writer uses that watermark for both the cap and a bounded stall close. The alternative is the heartbeat object from your option 2.
Which would you prefer? I will implement that shape rather than push the delete-based prototype.
RPRX commentedon Oct 29, 2026
这个一般靠超时来控制,不过“约 2.7–13 分钟”有点离谱了,@4RH1T3CT0R7 你下一个 PR 顺便修一下
RPRX commentedon Oct 29, 2026
另外下个月整 Mux 可以弄带 padding 的双向 keep-alive 包,对端超时就清掉连接至于背压,
减小的没有,但目前计划中会有“segment 自动逐轮增大” #7217 (comment) ,而这个机制就是依赖对端响应行为,可以先据此超时对于 @Ahmad-2213 的“Carry a Google Drive session in a ring of slots”,应该需要对端更新已有文件的 metadata,
我不确定那个 PR 有没有Ahmad-2213 commentedon Oct 29, 2026
I actually Previously Added SegmentGrowthFactor; Then tried to Address your concerns one by one. most of my XDRIVE Folder is untouched & not brought into this repo.
@4RH1T3CT0R7 may want to check them in his next PR.
@RPRX the full capabilities are stored at https://github.com/Ahmad-2213/Xray-core/tree/main/transport/internet/xdrive
I barely remember what I've addressed since then, but most of your concerns were addressed.
(I'll double Check what I've done so far)RPRX commentedon Oct 29, 2026
目前 main 的代码已实现我对 XDRIVE 的很多硬性要求,my concerns 主要就剩 #7217 (comment) 了,以及整 mux 和文档、教程,
或许有文章全面优化延迟与速度后,不打游戏的话甚至可以日常用 XDRIVE,
剩下的主要是 h2/h3 特征以及加些奇奇怪怪的 backend 吧,毕竟“滥用一切”Ahmad-2213 commentedon Oct 29, 2026
@RPRX
my main repo consists ( some of them are not brought in #7217 ):
RPRX commentedon Oct 29, 2026
我看那个 PR 的代码时已经注意到了,@4RH1T3CT0R7 的下一个 PR 对于 XDRIVE 而言会是质变
另外我不确定 main 的 Google Drive 代码是否解决了该 issue 所述的问题,我觉得对端应当更新 metadata 来实现 ACK,不确定那个 PR 有没有
Ahmad-2213 commentedon Oct 29, 2026
In my repo the reader ACKs by updating the description of a per-direction watermark file after each consumed segment. The writer rewrites a slot only once the watermark has reached seq - depth. If the watermark stops moving for 60 s the writer fails, so a dead client leaves at most 8 slot files and the session is released.
RPRX commentedon Oct 29, 2026
@Ahmad-2213 你检查下 Xray-core main 的代码,有要改的话和 @4RH1T3CT0R7 讨论下吧
Ahmad-2213 commentedon Oct 29, 2026
The Google Drive ring (#7217) does the ACK by metadata: the reader updates the description of a per-direction watermark file when its head moves, and the writer reuses a slot only once the watermark has reached seq - depth. With at most 8 slots per direction and a 60 s stall timeout, a dead client no longer leaves hundreds of objects, so #7208 is solved for the Drive ring.
what I found:
Ahmad-2213 commentedon Oct 29, 2026
@RPRX made some optimizations.
I finally managed to get a ping-time of 1200 ms on V2rayN after a hard work!youtube is smooth now and videos are easily loaded without buffering.
@4RH1T3CT0R7 it's suggested to see the latestxdrivefolder if you want to add commits.the config that i used ( note the relationship between mux and xdrive's ring concurrency):
AI Report: