Skip to content

Bug fixes in Hiddify sing-box v5

Bugs fixed in the Hiddify fork on top of sing-box v1.15. The newest fix is at the top. Each entry names the commit that fixes it.

All Hiddify features


A proxy was pinged several times at once, and lowest-delay moved to a slower one

Commit: 635d533ac

What happened: a tap on a proxy, or a group moving its selection to it, started a test while the cycle's test of it was running, and a staged test started a second and a third attempt beside a slow first one: the proxy was pinged two or three times, and whichever test ended last wrote the result, so a late failure replaced a fresh answer and the groups left the member. After a start or a hot reload, when every result is cached, the first member to answer took the lowest-delay selection at 91 ms over a 17 ms one still being tested, and lost it a moment later; each move tested the new pick again, seconds after it was measured, and under load that second ping read 2549 ms for a 59 ms member. A member chosen while the fast one did not answer was held for the whole cycle, long after the fast one answered again. hiddify-core sent an absolute tolerance of 100 ms, which kept a 59 ms member over a 25 ms one.

Fix: one URL test per outbound at a time: a test asked for while one runs joins it and takes its result. A staged test retries only a failed attempt, at the next mark, and skips the first mark for an outbound that worked in this run. Lowest-delay holds during a cycle only until the cycle has tested the member in use. A selection measured in the last 15 s is not tested again. The MONITORING and LOWEST log lines say why every test runs and what the group decides. hiddify-core sends a tolerance of 20 ms with a ratio of 0.3, and keeps a profile's proxy tags stable across its updates, so a hot reload keeps the unchanged proxies and their pings. Then (a85a71b1a): the first cycle's results arrive one by one and the selection still hopped at each faster one, 439 ms to 170 to 112 to 41 to 18 in forty seconds. A member that took the selection with a real ping stays for 30 s while it answers, then the best known takes it. A ping cached from the previous run decides nothing and is not shown: it only orders the members until the real pings arrive, and the app's list sorts by it (sort_delay) without showing it.

With a large subscription, the pings changed every few seconds

Commit: 635d533ac

What happened: with a free subscription of a hundred or more configs, the ping the app showed changed every few seconds, up and down (29, 44, 33 ms). Three things tested the same proxies again and again. A round robin balancer told monitoring of a new selection whenever the members it rotates through changed, which is almost every result of a cycle, and each telling tested its parents (the Extra Security hop) and its next pick. A member that started working retested everything measured through each group it is in, the hop behind the selector too, whether the group used it or not. And each regular cycle moved the selection result by result. In the first minutes on an unstable network, the home screen said the selected proxy was not responding while others worked: lowest-delay stayed on the member it had cached from the last run, since a staged test's miss was hidden from the groups behind the last confirmed result, which for that member was the cached one, and was even marked as fresh.

Fix: only a balancer that sends every connection through one member (lowest-delay) tells monitoring of a new selection. A member that starts working retests what goes through a group only when the group uses it. During a regular cycle of the schedule, lowest-delay and round robin keep the members they use while these answer, and choose with all the results when it ends (monitoring tells every group); the first cycle and one after a network change take results as they come. A miss of a member with no result of this run but the cached one is seen by the groups, so lowest-delay leaves it for a member that answers. The app shows a round robin group by a member that another replaces only when faster by more than the lowest-delay tolerance. On the Android emulator with a 160-config subscription and Extra Security, the hop's priority tests in three minutes went from 65 to 0, and the ping shown stayed the same after the first seconds.


WARP crashed the packet tunnel when it restarted during a network change

Commit: 00f7b6e63

What happened: after a network change, a WARP endpoint with the scanner checks its endpoint in the background, and scans for another when it does not answer. When WARP restarted meanwhile (a refused device, a change of tunnel), the restart cleared the endpoint's scanner state while the check of the start that ended still ran. The scan read that state again to save its scores and hit a nil pointer, which ended the whole process: on iOS, the packet tunnel. The first scan at connect did the same, its probe reading the state again (h_failover.go:258 in 5.5.1): on Windows, right after connecting on a filtered network, when a refused registration restarted WARP ("register WARP masque device: context canceled").

Fix: a check reads the scanner state of its start once and works on it to the end; the scan, the probes, the noise candidates and the scores take it from the check instead of reading the endpoint again, and the state is read and written under the endpoint's lock. A test clears the state in the middle of a scan, as a restart does; it crashed before the fix.


A DNS rule with bypass_if_failed logged an error for every query it passed on

Commit: b1ec924c5

What happened: when the server of a rule with bypass_if_failed failed, the next rule answered the query, but the failure was still logged at error level. hiddify-core's Fake DNS rule uses bypass_if_failed to pass every query other than A and AAAA (HTTPS, TXT, ...) from the fake IP server to the remote rule, so a browser filled the log with "only IP queries are supported by fakeip" errors.

Fix: a failure that the next rule takes over is logged at debug level. A failure with no bypass is still an error. Two tests check both cases.


A masterdns outbound used one of several resolvers on the same IP

Commit: 0e8ed7f69

What happened: with smart_pool off, the outbound gives its resolvers to the MasterDnsVPN client, whose library entry point kept one resolver per IP. Resolvers on one IP with different ports (127.0.0.1:5301, 127.0.0.1:5302, or a server answering on more than one port) became a single resolver, so the client's balancer and packet duplication had nothing to spread over.

Fix: MasterDnsVPN (the Hiddify fork, 62e94f1) keeps each IP and port as a resolver of its own; the resolver file loader keeps its old behaviour. A test checks one IP with three ports.


The default build left out mKCP, Sudoku, Bond and the call tunnel

Commit: 1c6b0ec6f

What happened: the Makefile's default tags were meant to add with_sudoku, with_call, with_bond and with_kcp, but they were joined with / instead of ,. Go read the four as one unknown tag, so make builds and the test runs that use the Makefile tags had none of them, and a profile with mKCP failed with "mKCP is not included in this build".

Fix: the tags are separated by commas. The full test suite passes with them on.


Commit: 502f16cfa

What happened: patterniha's Xray-core links (and the subscriptions built from them) use three things this fork did not know. A finalmask merge mask made the whole link fail to load, so the link was left out of the profile. dialMode=oob.0 was ignored. Xray's versioned fingerprint names such as fp=hellochrome_120 failed the outbound with "unknown uTLS fingerprint".

Fix: finalmask has the merge mask and an oob mask, Xray's oob. dialMode, which sends the first writes with an extra byte as TCP urgent data (ported from patterniha/Xray-core, MPL 2.0). uTLS takes every fingerprint name of Xray's lists, in any case, after sing-box's own names.


Monitoring: proxies stayed "waiting" after a hot reload, and stopped being tested when idle

Commit: pending

What happened: Three things kept rows of the proxies page on the waiting animation for good. A hot reload (a profile update, a changed setting) replaces the outbounds whose options changed; each replaced one started over with no result, and Reload could only start a test cycle when none was running. In a profile of hundreds of proxies a cycle runs for minutes, so the replaced ones waited for the next interval tick. The ticks themselves stop once the core has routed nothing for the idle time (three intervals, 30 minutes with Hiddify's defaults), and the app watching the list did not count as activity, so with little traffic nothing was tested again at all; a tap on a row still tested that one row, which made it look as if only the clicked proxy got a ping. And the Touch that restarts the stopped workers left the old activity time in place, so the next tick stopped them again.

Fix: A replaced outbound keeps its last result as a cached one, due for a test, the way a result loaded at start is: the groups can fall back on it and the app shows it as waiting. A reload then tests the outbounds it created or replaced, and only those, at once and whether or not a cycle is running; the others keep their result until their regular time. Restarting the workers counts as activity. hiddify-core touches the monitoring while the app's proxies stream is open.


IPv6 by outbound: IPv6-first sites still died through a proxy without IPv6

Commit: d992de248

What happened: The IPv6 probe that 5.4.0 added opened a connection to a public resolver's IPv6 address through the outbound and took an open connection as IPv6 carried. A VLESS, Trojan, VMess or Shadowsocks dial returns before the proxy's server has connected anywhere, so the probe said yes for every such proxy, AAAA answers went out, Chrome, Samsung Internet and the YouTube app reached Google over IPv6, the proxy closed the connection and the page showed ERR_CONNECTION_CLOSED, while Telegram and Instagram, on IPv4, worked (hiddify-app 5.3 and 5.4 on Android 16 and 17, #2420). The probe was also traffic of its own to Google and Cloudflare addresses, which a filtered exit breaks, and a reset after the proxy's attempt would not have helped: an app falls back to IPv4 only when the connect itself fails, and through a TUN the connect succeeds locally before the proxy has tried anything.

Fix: The probe is gone (ipv6_probe is retired). An outbound's IPv6 is verified at its URL test, with the user's own test URL fetched once more over the host's IPv6 address (verify_ipv6), once per run and cached per outbound; a share link may declare it (ipv6=1, ipv6=0, ipv6_declared); and a TCP connection to an IPv6 address through an outbound not known yet is watched by the router (route.ipv6_fallback): when the outbound gives nothing, the same outbound is dialed with the sniffed domain, the app's bytes are said again and the connection goes on, the app seeing no failure, and the outcome teaches the outbound's IPv6. ipv6_by_outbound and ipv6_unavailable_via are fail-open again: an outbound not known yet is let through, and one known to have no IPv6 gets no AAAA answers and has its IPv6 connections refused before the handshake. hiddify-core turns IPv6 for the apps off by default and all of this on when the IPv6 mode turns it on. See IPv6 by outbound.

Naive: App Review rejected macOS builds for a non-public API

Commit: pending

What happened: App Review rejected the macOS app because hiddify-core.dylib and the packet tunnel referenced _kCFBundleNumericVersionKey, which Apple counts as a non-public API. The reference comes from Chromium's base::mac::OuterBundleCachedInfoPlistData() (info_plist_data.o) in the macOS libcronet.a that with_naive_outbound links. The iOS and tvOS libraries do not have it.

Fix: protocol/naive/h_cronet_info_plist_darwin.go defines that function itself, returning empty data, so the linker no longer takes info_plist_data.o from the archive. Only Chromium's multi-process code (Mach port rendezvous, child-process code signature checks) calls it, and Cronet runs inside the process, so naive works as before.

Commit: pending

What happened: MahsaNG writes the device's key before the @ and the endpoint, with its port, after it, and leaves the endpoint's key out. ray2sing only read a key parameter, so the device had no key and the config failed with "private_key is not an ECDSA key"; and even with the key, a device without endpoint_public_key was refused, and a port other than 443 was dropped.

Fix: ray2sing reads that shape of link (key, endpoint and port, address, mtu, keepalive, transport, sni, insecure, pin, ech, packets). The device's endpoint_public_key defaults to the key of the fixed certificate Cloudflare's MASQUE edge presents on every endpoint, the port of an endpoint written as ip:port is used, and tls.certificate_public_key_sha256 pins the endpoint by the hash of its key, as the links do. A link to WARP is a WARP config: when Cloudflare refuses the link's device (its key was never enrolled, or the device was deleted), the outbound registers a device as the profile does, saves it as the replacement and runs the tunnel with it.

DNS and routing: IPv6-first sites failed through a proxy without IPv6

Commit: bc0447ad8

What happened: Sites that publish AAAA records (Google, YouTube, most big CDNs) were reached over IPv6 whenever the device had it, through a proxy whose exit has no IPv6. The connection died on the server side, the browser never fell back in time, and the site did not open, while IPv4-only sites through the same proxy worked. Routing IPv6 away was no answer: traffic that is not routed leaks past the proxy.

Fix: Monitoring probes each outbound and endpoint, on its own, for IPv6 reachability and keeps the answer in its URL-test history. A DNS rule with ipv6_by_outbound hands out no AAAA answers while that outbound lacks IPv6, and a route rule with ipv6_unavailable_via matches the IPv6 destinations that would die there so they can be rejected at once. One proxy can carry IPv6 while the next one does not; a proxy that gains IPv6 is used for it on the next run. See IPv6 by outbound.

DNS: a hanging server in a sequential group failed every query

Commit: 900d60c40

What happened: A sequential group walked its servers from the first on every query and gave each one the rest of the query's time. A first server whose port the proxy's exit dropped hung for the whole DNS timeout, the query failed, and the servers behind it were never asked. With hiddify-core's remote group, where the chosen TCP server comes first, configs through such a proxy never resolved a name, while the same configs worked with a plain UDP address.

Fix: A server that fails is asked after the others for error_ttl, still asked when they all fail, and forgotten when the network changes. Each server is waited for at most member_timeout, or, unset, the query's time left shared among the servers still to ask, at least 2 s each.

Monitoring: the selected proxy's ping arrived late

Commit: f7d6695cb

What happened: The first test cycle took the members fastest-first by last run's result, so the proxy the user had selected waited behind every member sorted before it. Moving a selector to another member tested only the outbounds routed through the selector, never the new selection, so its ping waited for the next cycle. A test asked for (a tap, a selection) went through the worker pool, behind a cycle of timeouts that holds every workers for up to url_test_timeout each; its delay was stored only after the IP lookup that follows the URL test, which takes as long again; and a proxy that did not answer showed nothing until the whole timeout had passed.

Fix: Before the first cycle, the outbound each group's selection leads to (a selector's pick, a balancer's member, through nested groups) is tested ahead of everything else, and a selection change tests the new selection with priority. A test asked for runs at once, beside the cycle's tests, in stages (on_demand_timeouts, 1 s, 3 s and 10 s unless set): the proxy shows as unavailable for now at the first mark with no answer, or as soon as the first attempt fails, and the first answer from any attempt replaces it at once, however late. Groups keep the last confirmed result through such a miss. The delay is published as soon as the URL test answers, the IP information when its lookup is done, and a priority result reaches the groups' subscribers past debounce_window.

Monitoring: after a while nothing was tested any more

Commit: f7d6695cb

What happened: A URL test dials the outbound with a deadline in its context. An outbound whose dial ignores that deadline, as a dial into a native library may (cronet's naive dial, by the logs), never returned, so its worker waited for good, and so did the cycle waiting for that result. A few such tests, and every worker was held: no cycle, no priority test, no ping for any outbound, until the core was restarted.

Fix: A worker waits for one test at most twice its deadline plus url_test_timeout and two seconds: the URL test, the IP lookup and one more test after a network reset, each with its own deadline. Past that the test counts as failed, its late result is dropped if it ever comes, and the worker goes on to the next test.

Security: every inbound listened on MPTCP

Commit: 9aca6c8c1

What happened: Go 1.24 and newer listen on Multipath TCP by default on Linux, and the Linux kernel let a remote root listen on IPv4 MPTCP. The TCP listener and the dialer set MPTCP only when tcp_multi_path is true and left the Go default otherwise, so every inbound of a build without the multipathtcp=0 setting was an MPTCP listener. The sing-box binary had the setting in its link flags; hiddify-core's Makefile had none, and its CI set a symbol that does not exist (internal/godebug.defaultGODEBUG), so the app's cores listened on MPTCP.

Fix: The listener and the dialer set MPTCP off unless tcp_multi_path asks for it, and both main modules carry godebug multipathtcp=0 in go.mod, which every build of them gets without link flags, as go version -m of the binaries shows. A Linux-only test checks that an inbound with the defaults accepts a plain TCP connection, not an MPTCP one. Update the Linux kernel as well: the kernel bug is not fixed by this, and an inbound listening on all addresses (0.0.0.0) is reachable from the network in any case.

Balancer: every test round moved the selection and cut every connection

Commit: d23f1250b

What happened: The lowest-delay strategy took the new minimum at every monitoring update, with no tolerance (the option was accepted and ignored), and the balancer then closed every connection through it, inbound ones included with interrupt_exist_connections. Two servers with close delays swapped at every round, and during a ping of all servers the results arrived one by one, so the selection moved and the connections were closed again every half second. A voice call or a video dropped, and an app update being downloaded failed, while the previous server was fine. The first cycle also sorted untested members (delay 0) ahead of the cached ones, so the member that was fastest last run was tested last, and the group started on the first member of the list instead of the cached best.

Fix: lowest-delay keeps its member while it answers, and moves only to a member faster by more than tolerance (50 ms when unset), or when its member fails a test. Connections are tracked per member; a member that becomes faster takes new connections only, a member that dies has only its own connections closed, and 30 seconds after start the connections on the members the group no longer uses are moved once, when a result of this run exists. The balancer starts from the cached results, and the first cycle tests the cached fastest member first. Tested with unit tests of the strategy, the per-member interrupt, the settle, the start from the cache and the cycle order.

WireGuard noise: the reserved bytes were written into the noise packets

Commit: wireguard-go@3b861c636

What happened: A wireguard endpoint with no detour sends through the standard bind, whose SendWithoutModify called Send, and Send writes the peer's reserved bytes (WARP's client id) into bytes 1–3 of every packet. Noise packets are sent with SendWithoutModify to stay as they are, but got the reserved bytes too: a QUIC template or <qi> Initial went out as ce 57 3d 35 01 08 … instead of ce 00 00 00 01 08 …, a broken QUIC version, and every random packet carried the same three bytes as the tunnel's own packets, an easy fingerprint. AmneziaWG (awg) endpoints and endpoints behind a detour were not affected.

Fix: The standard bind writes the reserved bytes in Send only. Tested with a packet capture of WARP configs using wnoise links, Clash amnezia-wg-option and sing-box jc/id/ip/ib: the templates now go out byte for byte and the tunnels still connect.

XHTTP: the server leaked goroutines and connections

Commit: 0f0f6966e

What happened: On an XHTTP inbound, the download (GET) request hands its connection to the inbound, which reads the request header from the upload queue. Only closing the connection ends that read, and it was closed only after the inbound returned; the session cleanup is off once the download has started. A client that went away before uploading anything kept the handler, its connection and its session forever: servers grew to about 11,000 goroutines and 1,500 sockets in a week (hiddify-core#163). A full upload queue that nobody read could also stop the session's cleanup for good.

Fix: The connection is closed as soon as the download request ends, and a pending upload gives up when the queue closes. Tested with a client that leaves before uploading and with a full queue, which both hung without the fix, and against Xray 26.10.29 and 26.3.27.

AmneziaWG: an endpoint behind a detour that was not ready failed to start

Commit: df246df56

What happened: An awg endpoint with a single peer opens its connection to the peer when it starts. When its detour was not ready yet, such as another WireGuard endpoint still starting, that dial failed, and with it the start of the endpoint and of the whole config ("WireGuard is not ready yet"). The warp endpoint's awg mode retried; a plain awg endpoint did not.

Fix: The endpoint starts without the connection and dials it again each second until the detour lets it, then works as before. Tested with a dialer that fails its first dials, which failed the start without the fix, and live with an awg endpoint through a WARP wireguard endpoint.

Monitoring: an outbound behind a detour was tested before its detour

Commit: 79c0e9c00

What happened: An outbound that dials through another was tested once that one was merely connected; a selector detour passed when any member had a ping, even a cached one, or when every member was connected, which a selector over one SOCKS proxy always is. The Extra Security hop (Psiphon, WARP) was tested before the proxy it goes through had a result, so its ping and IP measured nothing. The check also stopped applying after the tenth test cycle.

Fix: It is tested once the outbound its detour leads to (the selected member, for a group) has a result of this run and is not being tested again, waiting at most 10 times 3 seconds. When that outbound works after failing, those tested through it are tested again. Tested with an outbound behind a selector, which was tested too early without the fix.

Monitoring: a cached IP was never looked up again

Commit: 9b7726a00

What happened: At start each outbound shows the last run's exit IP from the cache file. It was looked up again only while the outbound's result came from the cache, and the first test of the run, even a failed one, cleared that. The cached IP then stayed for the whole run, though the exit IP of WARP or Psiphon can change between runs.

Fix: URLTestHistory.IpInfoFromCache marks cached IP information, and each working test looks it up until a lookup of this run succeeds. The app can show a cached IP as not confirmed. Tested with a saved IP, a failed test, then a working one, which kept the old IP without the fix.

Monitoring: URL test did nothing for an outbound that was connecting

Commit: 8239116da

What happened: A URL test asked for while the outbound was still connecting (Psiphon) was skipped and queued again, but the outbound stayed marked as having a test queued, so the retry and every later URL test of it were dropped until the core restarted.

Fix: The retry clears the mark first. Tested with a URL test asked for while the outbound connects, then another, which never ran without the fix.

Psiphon: never connected, "domain not resolved"

Commit: 1a9f0b4f7

What happened: Psiphon (alone, or in the chain modes) never connected, on every platform. Since its own connections go through a SOCKS bridge on the outbound's dialer (c82d7d86b), that dialer did not resolve domains. On its first start Psiphon has no servers and fetches its server list from s3.amazonaws.com; each fetch failed with "domain not resolved", so it never found a server ("no active tunnels").

Fix: The dialer resolves domains, or leaves them to the detour. Psiphon now connects in seconds. Tested that the outbound's dialer resolves domains, which it did not without the fix.

MASQUE: MASQUE in MASQUE never connected

Commit: 11a8c9737

What happened: With a MASQUE outbound behind another one, the inner tunnel dials its HTTP/2 endpoint as an IPv4-mapped address ([::ffff:162.159.198.2]:443). The outer tunnel took it as IPv6 and, having only an IPv4 address, refused it with "missing IPv6 local address".

Fix: The tunnel's stack unmaps the destination before choosing the address family. Tested with an IPv4-only tunnel dialing an IPv4-mapped address, which it refused without the fix.

HTTP transport (HTTP/1.1): Trojan behind Xray's raw HTTP header answered 400

Commit: e29388e76

What happened: A Trojan outbound with the http transport over HTTP/1.1 (Xray's "raw TCP with HTTP header", rawhttp in Hiddify Manager) failed about half the time with "v2ray-http: unexpected status: 400 Bad Request" against an Xray server behind HAProxy. With the PROXY protocol, Xray's first read holds at most 256 bytes with that header, so 200 to 228 bytes of the request. Xray parses the HTTP header from it and fails when the read ends in a header name; when the whole request fits in it, its Trojan inbound reads its request from what is left of that read only and rejects a piece shorter than its 58-byte header. The headers are sent in random order, so a short request from Hiddify fell in either case depending on the connection.

Fix: The request starts with a Cookie header (unless the configuration has one) whose value covers the bytes where that first read can end, so the request is longer than it and the read always ends inside a value. Tested with a short request, which fits in the first read without the fix.

AmneziaWG: the core could not stop behind a stalled detour

Commit: 96652cba6

What happened: Stopping the core hung for minutes with WARP in AmneziaWG mode behind a proxy (VMess over XHTTP here) whose stream had stalled. Each AmneziaWG send holds the device's network lock while it writes to the detour; the write never returned, and closing the device, which takes that lock to close the socket, waited for it. WARP's search for a port that answers hung the same way when it replaced the tunnel.

Fix: The device closes its detour connections before it closes, so the stuck write fails and the lock is released. Tested with a detour whose writes block, which fails without the fix.

WARP behind a URL test or balancer: torn down on every move

Commit: ce1ed43e8

What happened: When WARP's detour was a URL test or a balancer, every move of the group closed the tunnel and opened a new one, just as a selector change does. A group that flips between two close members (the lowest-delay one changes every few tests) cut WARP each time, and in auto mode started a new race each time, though the old tunnel still worked.

Fix: A move the user made in a selector still applies at once. A move the group made by itself applies at most every 30 seconds (at once when the member left had failed, or WARP is not connected), through a new tunnel opened next to the one in use, which replaces it only once it connects. Tested with a URL test whose member fails, for every tunnel, live.

URL test and balancer: their moves reached monitoring late

Commit: 4b866b5d9

What happened: A URL test that moved to another member did not tell monitoring, so whatever followed the group (a tunnel through it, the app's proxy list) only saw the move at the next monitoring test. A balancer told only its own subscribers, and a follower of all groups could read the old member first.

Fix: Both signal monitoring when they move, as a selector does. Tested with a test that fails without the fix.

WARP auto mode: stuck on a tunnel that never connected

Commits: ae3ec6c67, 8db9e7ba2

What happened: Behind Extra Security, WARP in auto mode showed WARP · AWG ⏳ Connecting... for good. The race picked the tunnel whose handshake answered first, here AmneziaWG, and kept it although the tunnel itself never started: the AmneziaWG socket asked the detour for UDP to any address (0.0.0.0), which a proxy whose UDP needs a destination refuses (400 Bad Request), while the probe asked for UDP to the peer and got through. Changing the proxy in the selector only reconnected the same tunnel.

Fix: With a single peer, AmneziaWG opens its UDP to that peer, as WireGuard does. Auto mode gives the chosen tunnel 20 seconds to connect and otherwise races again without it, and races again when the detour moves to another proxy (one without UDP then gets MASQUE over HTTP/2). Tested live through an HTTP proxy for every tunnel, and with a move to SOCKS4, which has no UDP.

Psiphon: dead after its tunnel core stopped, and only one at a time

Commit: 3c5e93945

What happened: When Psiphon's tunnel core gave up (after establish_tunnel_timeout, for example on a network where Psiphon was blocked for a while) or failed, the outbound stayed dead until the app restarted. A second Psiphon outbound could not start next to the first, and two would have written the same server list files.

Fix: A stopped tunnel core is started again, after 5 seconds and up to every 5 minutes. Several Psiphon outbounds run at once: each has its own data folder, and each checks its own tunnel before it shows as connected, since the tunnel core's notices do not say which outbound they are about.

Psiphon: shown as connecting with a working tunnel

Commit: df13d433d

What happened: Psiphon showed "Connecting..." while traffic and URL tests went through it. Its connected state came only from the Tunnels notices, through the single notice writer that psiphon-tunnel-core has for the whole process. Each Psiphon set its own writer and reset it when it closed, so one started while another was closing (an app reload) lost the writer and never saw its notice; a second one could not set a writer at all.

Fix: One notice writer is installed for the process and passes every notice to each running Psiphon; it is reset only when the last one stops. A connection through the tunnel counts too: the Psiphon controller only dials while a tunnel is up, and its "no active tunnels" error marks it as not connected.

WARP scanner: probes did not send what the device sends

Commit: 28ff47e9b

What happened: The scanner judges an endpoint by a handshake made after the same noise the device sends. But in the default noise mode the probe left out the short header the device puts before each fake packet, and with awg it sent the WireGuard noise instead of the AmneziaWG junk and special packets. An endpoint could then answer the probe and not the device, or the other way round.

Fix: The device and the probe build the fake packets with the same code, and with awg the probe sends i1–i5 and the jc junk packets. Tested with every noise preset against Cloudflare.

Shadowsocks: QUIC protocols never connected through it

Commit: 1e2ddd7fd

What happened: Hysteria2, and any other QUIC protocol, through a Shadowsocks detour never connected. QUIC reads each packet into a buffer of about one packet, the Shadowsocks client decrypts in place in that buffer, and a full packet with its salt and tag did not fit. It failed with "cipher: message authentication failed", and the QUIC connection ended.

Fix: The Shadowsocks client reads each packet whole and copies it. Tested with Hysteria2 through Shadowsocks, VLESS and Trojan; only Shadowsocks failed before.

WARP: background work outlived the box, and AmneziaWG through a cold detour never started

Commit: 79d187b08

What happened: The box closes each endpoint's scope, not the endpoint, so WARP's port rotation, failover and their goroutines kept running after the box closed, and tried to start replacements that failed with "context canceled". Separately, AmneziaWG opens its socket when it starts, and fails when the detour does not work yet (a group still on a dead member); WARP then gave up and stayed "endpoint not initialized" for good, even after the detour recovered.

Fix: WARP's background work stops when its scope closes. The first start of the tunnel is retried with a backoff (1 s, doubling, up to 30 s), like the registration. Tested live through a selector that moves from a dead member to a working one, and with a unit test that fails without the fix.

MASQUE: a tunnel set up while the outbound closed stayed open

Commit: ff42383d6

What happened: Closing a masque outbound whose tunnel was still being set up returned "tunnel not initialized", and the tunnel, once set up, was never closed. The profile's endpoint addresses were also cut out of ip:port by fixed offsets, which breaks on any other port.

Fix: Close ends a registration in progress, and a tunnel set up meanwhile is closed when its setup ends. The endpoints are parsed as addresses with a port.

masque-client: shown as connected before it reached the server

Commit: 85cff7f36

What happened: The upstream masque-client endpoint used the default ready state, so it showed as connected while it had no session with the server.

Fix: It is ready only while a session with the server is up. An on_demand endpoint, which connects when it is used, is always ready.

Psiphon: connected state raced, and a notice could crash the process

Commit: 7a85e7be9

What happened: The connected flag was written from a new goroutine for each notice and read without a lock. It was only ever set, never cleared when the tunnel count dropped to zero. A Tunnels notice whose count was not a JSON number panicked on a type assertion.

Fix: Notices are handled in order, the state is atomic and follows the tunnel count, and a count of any other type is ignored.

Monitoring: a selection reached the app late, and chained outbounds were tested too early

Commit: af68dde0a

What happened: A selection change waited for the debounce meant for bursts of test results before the group's subscribers saw it, and a subscriber busy with the last update could miss it. An outbound behind a group detour was tested before any member of that group worked, so a chained WARP was marked dead at start.

Fix: A selection is published at once, to the group, its parents and the catch-all group, with room for 8 pending events. An outbound whose detour is not ready is not tested yet, for the first 10 cycles.

Sudoku: a large transfer to a slow reader reset the stream

Commit: 16096efa2

What happened: The Sudoku mux had no flow control. Once a stream held 4 MiB its reader had not read, the next frame reset the stream with "mux receive queue full". The mux's own test failed in 61 of 900 runs.

Fix: The mux waits for the reader to make room, which holds the session and so the sender. 900 of 900 runs pass.

MASQUE: HTTP/3 never connected through Shadowsocks, VLESS or Trojan

Commit: 1f44631b9

What happened: WARP over MASQUE with HTTP/3 worked through a SOCKS detour only. QUIC reads each packet into a buffer of about one packet, but a proxy that decrypts in place needs room for its own header and tag too. A full packet was cut off, failed to decrypt ("cipher: message authentication failed"), and the read error ended the QUIC connection.

Fix: Each packet is read whole from a proxy's packet connection and then copied, in the tunnel and in the scanner's probes. Tested live through SOCKS, Shadowsocks, VLESS and Trojan.

Psiphon: every binary crashed at start when built with Go 1.27

Commit: bb18926a9

What happened: Go 1.27 added a field to tls.ConnectionState. The psiphon-tls fork checks at start that its own copy has the same layout, and panicked, so any program with Psiphon crashed before doing anything.

Fix: The fork keeps one copy of the struct per Go version. Built and tested with Go 1.27.3 and 1.26.3.

Shadowsocks simple-obfs: obfs_host was not checked

Commit: 357a0ec54

What happened: The Shadowsocks inbound accepted obfs_host but never compared it with the host the client sent, so any host passed the simple-obfs handshake.

Fix: When obfs_host is set (one host or a comma-separated list), a client whose HTTP Host or TLS server name is not listed is treated as not simple-obfs. It goes to obfs_fallback, or is closed. Tested over HTTP and TLS obfs, with a listed and an unlisted host.

DNS: bypass_if_failed ignored an empty answer in legacy mode

Commit: 0e67074d9

What happened: In legacy DNS mode, a rule with bypass_if_failed moved on to the next rule only when the query failed. An A or AAAA query that came back with no address stopped there, although a lookup already treated the same empty answer as a failure.

Fix: An address query with no address in the answer now counts as failed and moves on to the next rule, as it does for lookups.

WARP: WireGuard through a detour often never connected

Commit: ce64e6017

What happened: WARP over WireGuard behind a detour (for example Extra Security) often never connected. WireGuard sent the handshake to the profile's IPv4 and IPv6 addresses through one UDP association, and a detour without IPv6 closed it. Without the scanner, a profile port that did not answer was also used for good. WARP and MASQUE showed as connected as soon as they were set up, even when nothing got through.

Fix: WARP uses the profile's IPv4 peer address and moves to the next port when one completes no handshake within 10 seconds. WARP counts as connected only while a handshake is less than 3 minutes old, kept fresh by a 25-second keepalive; MASQUE only while it has a connection to the server. Tested live through a working and a dead detour, for WireGuard, AmneziaWG, and MASQUE over HTTP/3 and HTTP/2.

WireGuard legacy outbound: network was ignored

Commit: 2ab7b4351

What happened: The wireguard_legacy outbound accepted network, but always offered TCP and UDP.

Fix: The outbound offers the networks in network, as upstream's old outbound did. Ping is always offered.

Sudoku: fallback did nothing

Commit: d53e3c37f

What happened: The Sudoku inbound accepted fallback but did not use it. A connection that was not Sudoku was closed.

Fix: A connection whose handshake fails is passed to the fallback address with the bytes it already sent, so a prober sees the site there. A connection that sends more than 64 KiB before its handshake fails is closed.

Call: an unknown wbstream mode never connected

Commit: 7d6d0ef26, 88d4af4c2

What happened: With "platform": "wbstream", a mode other than dc or video was accepted, but the tunnel never connected.

Fix: The config fails to load with "call: unknown wbstream mode …, use dc or video".

Call: route rules stopped after a reconnect, and a failed connect was final

Commit: 3f48fb169

What happened: On vk, telemost and dion, after the call reconnected, the server side opened connections itself, past the router, so route rules no longer applied. The inbound and outbound connected once, and after a failure did not try again until sing-box restarted. On dion, email and password without cookies failed with "empty cookie string". The bridge and Telemost's active bridge were read and written by several goroutines without a lock.

Fix: After a reconnect, connections go to the router again. Both sides try again after 5 seconds, 10 seconds, 30 seconds, then every minute until sing-box stops. On dion, email and password alone log in. The bridges are read and written safely.

TLS: a tls_fragment in the tls block did nothing

Commit: a29c81702

What happened: Old editor templates put tls_fragment inside the outbound's tls block. sing-box accepted it there and did not apply it.

Fix: An enabled tls_fragment in the tls block is moved to the outbound's dial fields, where it applies. When the dial fields have their own enabled tls_fragment, that one is used.

WARP MASQUE: renaming the outbound registered a new device

Commit: a6ca1672a

What happened: The MASQUE profile and scanner scores were saved under the outbound's tag, so renaming the outbound registered a new device with Cloudflare. With profile.detour set and no outbound manager available, the outbound crashed on a nil pointer.

Fix: The new unique_identifier field names the saved profile and scores. When it is empty, the tag is used, as for warp. A missing outbound manager is reported as an error.

DNSTT: DNS queries could loop back into the VPN

Commit: 8f5156c41

What happened: The DNSTT client opens its resolver sockets itself, and they did not get sing-box's socket settings. bind_interface, routing_mark and the platform's socket protection were not applied, so on a VPN the DNS queries could loop back into the tunnel.

Fix: The resolver sockets get the outbound's socket settings. detour, which the DNSTT client cannot use, logs a warning.

DNSTT: record-type did not change the tunnel

Commit: 474f382e5

What happened: record-type only named the saved resolver list. The tunnel always asked for TXT records.

Fix: record-type selects the record type of the data the server sends back: txt, cname, a, aaaa, mx, ns or srv. The server must use the same type.

DNSCrypt: a stamp of another type failed only at the first query

Commit: 219cd3dcc

What happened: The sdns server takes only DNSCrypt stamps, but a DoH, DoT or plain DNS stamp loaded and failed only at its first query.

Fix: Such a stamp fails when the config loads, and the error says to use the https, tls or udp server type.

SSH inbound: host_key_algorithms, network and deadlines were ignored

Commit: 0ebdf81b7

What happened: The SSH inbound read host_key_algorithms and network but used neither. Forwarded connections ignored read and write deadlines.

Fix: host_key_algorithms limits the host keys. An RSA key signs only with the listed rsa-sha2-512, rsa-sha2-256 or ssh-rsa. Another key is used only when its type is listed. If no host key is left, the config fails to load. A network without udp turns UDP over TCP off. Deadlines work on forwarded connections.

Balancer: an unset delay_acceptable_ratio left no usable member

Commit: 3943213db

What happened: Unset, delay_acceptable_ratio was 0, so no member was within the acceptable delay. round-robin used only the fastest member, and consistent-hashing and sticky-sessions treated every member as dead.

Fix: Unset or 0 means 2, the value the Hiddify app sends.

AmneziaWG: the first packet after setting s4 was dropped

Commit: 40dfde705

What happened: After s4 was set, the first data packet went out with the old padding, and the server dropped it.

Fix: The bundled amneziawg-go is updated with the fix.

Reports: archives held sensitive data

Commit: 37d5006d1

What happened: Report archives are not encrypted, but when the app asked for encryption they were named .age. They held the configuration, the open connections of OOM reports, and logs with addresses, links and secrets. The metadata held the full process path, which can contain the user name.

Fix: Archives are always named .zip. They leave out the configuration and the open connections. Logs and other text files are scrubbed of IP addresses, proxy links and DNS stamps, URL paths and queries, UUIDs, emails and key=value secrets. The metadata keeps only the process name.

Call: a Telemost relay failure stopped the app, and VK logged the join token

Commit: e727940f5

What happened: When the Telemost relay failed to start, the whole process exited. On vk, joining an existing call wrote the call's join token, part of the tunnel key, to the log.

Fix: The Telemost error is logged and the process keeps running. The join token is no longer logged.

WireGuard: an awg block was silently ignored

Commit: 3fa692844

What happened: A wireguard endpoint accepted an awg block but never used it. The endpoint ran plain WireGuard while the config looked obfuscated.

Fix: Such a config fails to start and says to use "type": "awg".

AmneziaWG: s3, s4 and h ranges stopped the endpoint

Commit: ee5e66490

What happened: The bundled amneziawg-go was older than AmneziaWG 2.0. A config with s3, s4 or a range in h1–h4, as AmneziaWG 2.0 servers give out, failed to start with "invalid UAPI device key: s3".

Fix: The endpoint uses amneziawg-go 3.1, which accepts these values and adds the 3.x settings.

DNSCrypt: each query fetched the resolver certificate again

Commit: 3e475bb96

What happened: The sdns server fetched the resolver certificate before every query, so each lookup took two round trips.

Fix: The certificate is kept until it expires, a query fails, or the network changes.

XHTTP: auto mode did not match Xray's client

Commit: a4310256e

What happened: With mode set to auto, the client always used packet-up. Xray's client uses stream-one over REALITY, so a server that allows only stream-one refused a Hiddify client left on auto, while an Xray client got through.

Fix: auto picks the mode as Xray's client does: stream-one over REALITY, stream-up over REALITY with a separate download, and packet-up otherwise. Tested against Xray 26.10.21 and 26.3.27.

URL test: a canceled unified delay test kept running

Commit: 48dd50df5

What happened: With unified delay, a test makes two requests. The second one did not stop when the test was canceled, so it could run on for 15 seconds. A cancel between the two requests was reported as a success.

Fix: The second request stops on cancel, and a canceled test reports an error.

SSH: UDP was sent to an outbound that cannot carry it

Commit: a4afbdedf

What happened: The SSH outbound offered UDP by default, but SSH carries UDP only with udp_over_tcp. Without it, the router sent UDP to the outbound and every UDP connection failed.

Fix: The outbound offers UDP only when udp_over_tcp is on, so UDP goes to another outbound.

SSH: one bad forward request closed the whole connection

Commit: ea5c6b5be

What happened: When the SSH inbound got a port-forward request it could not read, it closed the whole SSH connection, with every other forward on it.

Fix: Only that request is refused; the connection and its other forwards go on.

Balancer: connections closed on every test round

Commit: 0e4c6636e

What happened: With consistent-hashing or sticky-sessions and interrupt_exist_connections, the balancer closed all its connections after every monitoring update, even when nothing changed.

Fix: Connections are closed only when the set of usable members changes.

Rule sets: a failed download was retried only after a day

Commit: d918f8167

What happened: A remote rule set with no cached copy starts empty and is downloaded once the service is up. When that download failed, for example because the proxy was still connecting, the next try came only after update_interval, a day by default. Until then the rules in it did not apply.

Fix: A failed download is retried after 1 s, 10 s, 30 s, 1 min, 5 min and 10 min, then every 30 min until it succeeds. A cached copy stays in use meanwhile.

XHTTP: seq in the path was lost when the session was elsewhere

Commit: a22fe417c

What happened: The XHTTP server read the path only when both session_placement and seq_placement were path. With the session in the query, a header or a cookie and the seq in the path, every packet-up request lost its seq, so the upload did not work.

Fix: As in Xray, the session and the seq each take the next path segment when they are placed in the path, so seq in the path works wherever the session is.

XHTTP: the padding generator could loop forever

Commit: ee39f4889

What happened: The random padding string never finished for a character set whose size divides 256, because every random byte was rejected. The padding uses a 62-character set, so no current config hit this.

Fix: The limit no longer wraps around, so the generator ends for any character set.

Shadowsocks obfs: the HTTP server's Date header looked fake

Commit: 4aef1b3ca

What happened: The HTTP obfs server wrote the Date header in the server's local time zone. A real nginx always sends GMT, so the response was easy to tell from a real web server.

Fix: The Date header is sent in GMT, in the standard HTTP format.

MASQUE: an IPv6 bind address in a port mapping was rejected

Commit: 9e374aaf0

What happened: A port mapping in the documented [::1]:8080:host:port form was always rejected, because the colons inside the IPv6 address were treated as separators. No Hiddify option uses port mappings yet, so no config was affected.

Fix: A bracketed IPv6 bind address is read as one part, and it resolves correctly.

MASQUE: UDP to IPv6 addresses failed

Commit: db20461ff

What happened: UDP to an IPv6 destination through MASQUE was sent from the tunnel's IPv4 address, so it failed.

Fix: UDP to an IPv6 destination uses the tunnel's IPv6 address.

VLESS: the Vision wrapper could skip a layer below it

Commit: f9eca8b90

What happened: The Vision wrapper told the copy loop it could read and write the TLS connection directly, even when an encryption layer or a transport sat between them. That could have sent data past that layer. The Vision connection above it already prevents this, so no traffic was affected.

Fix: The wrapper allows the shortcut only when the connection below it does.

VLESS: padding rules in encryption were read as keys

Commit: dcbc72095

What happened: A padding rule such as 100-111-1111 in a VLESS encryption or decryption string is also valid base64. It was decoded as a key and rejected with an invalid key length, so the node did not start.

Fix: In the padding part of the string, a segment of digits and dashes is read as a padding rule.

SSH: a UDP-over-TCP session never closed when idle

Commit: a1f14b533

What happened: With udp_over_tcp, each UDP connection was counted twice on the SSH session, and one count was never released. The session always looked busy, so it stayed open after it went idle.

Fix: A UDP-over-TCP connection is counted once, and an idle session closes as expected.

SSH: the inbound ignored server_version

Commit: 97539d44c

What happened: The SSH inbound read server_version but never used it, so it always announced the Go library's default version.

Fix: The server announces server_version. A bare name such as OpenSSH_9.6 gets the required SSH-2.0- prefix.

DNSTT: resolver tests left connections open

Commit: f3b9ea0b3

What happened: Each test of a DoH, DoT or TCP resolver opened a connection and never closed it. A resolver that never answered could also hold the test forever.

Fix: Each test closes its connection and gives up after 10 seconds.

DNSTT: a failed resolver connection crashed the test

Commit: 2048c1b84

What happened: When the connection to a DoH, DoT or TCP resolver could not be opened, the resolver test ignored the error and crashed.

Fix: The test returns the connection error.

AmneziaWG: an IPv6 peer address was rejected

Commit: c59c8eb37

What happened: A peer at an IPv6 address was passed to the device as 2001:db8::1:51820, without brackets. The device rejected it, so the endpoint could not reach that peer.

Fix: An IPv6 peer address is written in brackets, as [2001:db8::1]:51820.

Clash API: a balancer's connection showed the wrong chain

Commit: 6bf0372a9

What happened: For a connection through a balancer, the connections list built a chain with the outbound the balancer chose, but then showed the original chain. The chosen outbound did not appear.

Fix: The connections list shows the chain with the outbound the balancer chose.

DNS hosts: sslip.io names did not resolve

Commit: 68a7c5d30

What happened: The hosts DNS server never resolved sslip.io names, because the name in a DNS query ends with a dot. An IPv6 name such as 2001-db8-0-0-0-0-0-1.sslip.io could also be read as the IPv4 address 0.0.0.1.

Fix: The trailing dot is removed before matching. IPv6 is tried first, and each pattern starts at the beginning of a label.

SDNS: the server had no tag or type

Commit: 8affe038b

What happened: An sdns DNS server was created without its tag and type. Its detour outbound was also not marked as something it needs, so it was not sure to start first.

Fix: The sdns server gets its tag and type, and its detour is started before it.

Xray helpers: a range number too large was changed silently

Commit: 164930d71

What happened: A range field such as x_padding_bytes, given as a number too large for it, was silently cut down to a different value. For example, 9999999999 became 1410065407. The same value as a string was rejected.

Fix: A number out of range is rejected, as in the string form.

Xray helpers: a UUID of the wrong length could crash

Commit: b291486b7

What happened: Reading a UUID from 16 raw bytes crashed when the length was wrong, instead of returning an error. Nothing in Hiddify uses this yet, so no config was affected.

Fix: A wrong length returns an error that gives the length.

Xray helpers: the buffered writer stopped when its buffer filled

Commit: 8e2f9b3e5

What happened: The Xray buffered writer stopped with a "buffer full" error when the data was larger than its free space, instead of sending the buffer and writing the rest. Nothing in Hiddify uses this writer yet, so no traffic was affected.

Fix: A full buffer is sent and the writer goes on with the rest of the data.

URL test: a canceled test was reported as 0 ms

Commit: b160c990b

What happened: When a URL test was canceled after its connection opened, it returned no delay and no error. It was recorded as a successful test with a 0 ms delay.

Fix: A canceled test is reported as an error.

WARP: a bad device id crashed license registration

Commit: 9b503de83

What happened: Adding a WARP license to a new device built the request before checking that it was valid. A device id with a control character in it crashed the app instead of returning an error.

Fix: The request error is checked first and returned.

WARP: a device given by id and auth_token could not start

Commit: 0b03e9f80

What happened: A warp profile with id and auth_token fetched the existing device from Cloudflare, but the API does not return the private key, so the endpoint had no key and did not start.

Fix: That path now takes the device's key from profile.private_key, checks it against the device's public key, and refuses to start without it.

Tunnel: override_tunnel_destination did nothing

Commit: 3f3c7eece

What happened: The override_tunnel_destination route option was read but never set on the connection. Nothing else sets a tunnel destination, so the tunnel_server endpoint refused every connection and the reverse tunnel could not carry traffic.

Fix: The option now sets the connection's tunnel destination.

Tunnel: an unknown user froze the server

Commit: 641b3fc9e

What happened: When a connection was for a user the tunnel_server endpoint did not know, the endpoint returned an error but kept its lock held. Every later connection through it then hung. The error also named the sender instead of the unknown user.

Fix: The lock is released on that path, and the error names the unknown user.

MASQUE: use_ipv6 stopped the outbound from starting

Commit: 8fe3e948b

What happened: The profile has no IPv6 HTTP/2 endpoint. Since vhttp auto became the default, a MASQUE outbound with use_ipv6 looked one up, failed and never started.

Fix: auto uses the scanner's IPv6 HTTP/2 endpoint when it finds one, and otherwise stays on HTTP/3.

DNS group: a poisoned CNAME or a server failure could win

Commit: 46df8123e

What happened: A DNS group accepted the first response that still had records after ignore_ranges. A poisoned answer made of a CNAME and a blocked address kept its CNAME, so it won with no address in it. A SERVFAIL or REFUSED response was kept as the fallback answer, so when no server had an address the group could return it instead of another server's NXDOMAIN.

Fix: An A or AAAA query needs an address in the answer, so a CNAME left alone is not enough. SERVFAIL and REFUSED count as failures, and the group goes on to the next server. NXDOMAIN is still returned when no server has an answer.

MASQUE: closing the tunnel did not stop it

Commit: 130720144

What happened: Closing the WARP MASQUE outbound (on a reload or shutdown) did not stop its tunnel. While it was connecting, closing blocked for good. After closing, it kept reading the closed device in a tight loop, filling the log with "failed to read from TUN device" and using CPU until the service stopped.

Fix: Closing the tunnel stops a connect in progress and both of its loops.

REALITY: fragment and record_fragment did nothing

Commit: 59a414925

What happened: tls.fragment and tls.record_fragment were accepted on REALITY nodes but never applied, so the ClientHello always went out whole. With the post-quantum key share it is about 1.7 KB, two TCP segments, which some networks drop.

Fix: Both options now apply to REALITY as to the other TLS clients. Tested against Xray 26.10.21 and 26.3.27.

WARP: network changes were not passed on

Commit: ab47a78e6

What happened: The warp endpoint did not pass network changes to the WireGuard endpoint inside it, so after moving between networks it kept its old socket.

Fix: Network changes reach the inner endpoint, which rebinds its socket.

XHTTP: obfs mode padding outside the Referer was rejected

Commit: 17c771dd4

What happened: The XHTTP server read the padding only from the Referer header or the query. With x_padding_obfs_mode and another placement (a header, a cookie, or a custom header), every request was rejected with 400, so an Xray or sing-box client using those settings could not connect.

Fix: The server reads and checks the padding where the configured placement puts it, as Xray does. Tested against Xray 26.10.21 and 26.3.27.

XHTTP: data races

Commit: 0493e3f8d

What happened: Four pieces of XHTTP state were shared between goroutines without synchronization: the download stream's reader, a buffer's length read after it was handed off, the upload queue's closed flag and reader, and the connection addresses. Under load these could corrupt data or crash the process.

Fix: Each is now guarded or passed atomically.

gVisor: a failed TCP handshake could crash the process

Commit: 31947f874 (gVisor fork d811af3)

What happened: In the gVisor network stack, a TCP segment that arrived just after a handshake failed, while the connection was being closed, used state that had already been cleared. The whole process crashed. The gVisor TUN stack and the MASQUE and AmneziaWG endpoints use this stack.

Fix: Such a segment is ignored, and closing the connection cleans up. gVisor is now forked under replace/gvisor.

TUN: new TCP connections refused after a listener error

Commit: de0aae2c2 (sing-tun fork abfac68)

What happened: The system TUN stack hands each new TCP connection to a local listener. Any error while accepting, even a short-lived one such as running out of file descriptors, stopped the listener's loop for good. Every new TCP connection was then refused until the VPN restarted, while UDP kept working.

Fix: Short-lived errors are waited out, and a listener that died is recreated. sing-tun is now forked under replace/sing-tun.

WireGuard: recovery after the network goes bad

Commits: 386158d33 and 31c0f5732 (wireguard-go fork f191d67, b46c414, a7a59c8, 6f870fd, 5e21bd6, a6fcdc9)

What happened:

  • After the device slept, or when DPI flagged a connection, handshakes kept failing from the same source port, and WireGuard and WARP did not recover until the network changed.
  • A receive routine that met a lasting socket error stopped, and nothing restarted it.
  • WireGuard did not start at all on a host or interface without IPv6.
  • Noise (fake) packets: only the first one was sent, the start of each was cut off, and the top of every configured range (count, size and delay) was never used.

Fix:

  • After failed handshakes, the socket is reopened on a new port. This happens once the handshake cycle gives up, or after 3 unanswered attempts when the session is already dead, at most once every 90 seconds. A configured listen_port is kept.
  • A receive routine that stops reopens the socket.
  • IPv4 is kept when IPv6 cannot be opened.
  • All noise packets are sent whole, and ranges include their upper bound.

Local DNS on Linux: no fallback when systemd-resolved is incompatible

Commit: 53aa823c9

What happened: On Linux, the local DNS server failed outright when the systemd-resolved service lacked a method it needs.

Fix: It falls back to /etc/resolv.conf, and returns to systemd-resolved once it works.

MASQUE: no fallback from HTTP/3 when UDP is blocked

Commit: 787f27a51

What happened: When UDP was blocked, the HTTP/3 attempt used up the whole connection timeout, so there was no time left to fall back to HTTP/2. A session that failed on HTTP/3 also kept retrying HTTP/3.

Fix: The HTTP/3 attempt gets at most 5 seconds (or half the time left), and a failed HTTP/3 session makes the next attempt fall back.

Tailscale: control connection over port 80

Commit: a210bd114

What happened: Tailscale's control connection first tried a plaintext HTTP upgrade on port 80, which filtered networks intercept or recognize, and moved to port 443 only after a delay.

Fix: It goes straight to port 443. Set TS_FORCE_NOISE_443=false to restore the old behavior.

Crash reports: unbounded, and they carried the configuration

Commit: 1017225d5

What happened: Every crash added a report that was never removed. Each report also carried a copy of the configuration, including server addresses, keys and passwords, although reports are made to be shared.

Fix: The newest 32 reports of each kind are kept, up to 64 MiB. Reports no longer include the configuration, and an old copy is removed.

SOCKS: UDP never reached servers that answer with 0.0.0.0

Commit: 70d6601fd

What happened: Many SOCKS5 servers answer a UDP request with the address 0.0.0.0, meaning "the address you reached me on". sing-box sent UDP to that address, which is the local machine.

Fix: It sends UDP to the server's address instead.

WireGuard: packets larger than the path were dropped

Commit: 1ed324a7b

What happened: sing-box marks outgoing UDP as "do not fragment". A WireGuard packet larger than the network path allows, as in a tunnel inside another tunnel, was dropped, and large transfers stalled.

Fix: The system may fragment WireGuard packets, as official WireGuard allows. udp_fragment still overrides it.

WireGuard: a stuck detour froze the tunnel

Commits: 304bde1fd, dd032dfd7

What happened: WireGuard's connection through a detour was dialed while holding the lock that sending and receiving wait on, with no time limit. A detour that hung froze the whole tunnel. The same code also had a data race.

Fix: That dial gives up after 15 seconds and is retried, and the race is fixed.

WireGuard, AmneziaWG, MASQUE: slow failure for unreachable hosts

Commit: b1d063442

What happened: A TCP connection through these tunnels to a host that never answers waited one to two minutes before failing, against 15 seconds for a direct connection.

Fix: It fails after 15 seconds.

Clash API: FakeIP flush did nothing

Commit: 3949d2479

What happened: The Clash API's FakeIP flush cleared only the cache file. With the default settings, FakeIP mappings live in memory, so nothing was cleared.

Fix: The flush clears the FakeIP store in use and restarts address allocation.

HTTP transport: client cancellations logged as errors

Commit: 0ac410ae9

What happened: On the server side of the HTTP transport, a client that cancelled its stream was logged as a failure.

Fix: It is treated as a closed connection.

Shadowsocks 2022: "bad request salt"

Commit: fb981ebba

What happened: With Shadowsocks 2022, a reply could be read before the first upload finished. The connection then failed with "bad request salt", most often when the first upload was large.

Fix: The request header is written while connecting, before the connection is used.

Cache file: entries lost when saving failed

Commit: 6927625a1

What happened: When the cache file could not be written (a full disk, a read-only file), the pending DNS cache and FakeIP entries were dropped from memory as well.

Fix: They are kept and saved at the next attempt.

DNS: duplicate queries after a failure

Commit: 42acc7903

What happened: Identical DNS queries wait for the one already sent. When that query failed, every waiting query went to the server separately.

Fix: The waiting queries share one new query.

DNS outbound: connections never closed

Commit: dfb54ab49

What happened: A DNS session through the dns outbound never reported that it ended. The connection stayed in the connection list and statistics, and some inbounds waited for it until shutdown.

Fix: The end of the session is reported.

Routing: UDP sessions leaked after a routing error

Commit: 7ea28f847

What happened: When a UDP session from Shadowsocks, multiplex or UDP-over-TCP could not be routed, the code handling it waited until shutdown.

Fix: It returns after the error.

Startup held by a slow multiplex connection

Commit: 0cb109659

What happened: Starting the service could wait up to 15 seconds for a multiplexed connection being opened through a slow or dead server.

Fix: That work happens after startup.

XHTTP: deadlines silently ignored

Commits: 6534ce882, 1288cc7c6

What happened: XHTTP connections accepted time limits and ignored them, so a wait on a silent peer never ended. Closing a connection also lost the download side's error.

Fix: XHTTP connections report that they need help with time limits, and the router adds it, as for gRPC and HTTP. The error is returned.

VLESS encryption: hang on a silent server

Commit: 3db8eb988

What happened: With VLESS encryption, a server that accepted the connection and never answered held the connection, and any URL test through it, forever.

Fix: The handshake stops when its time limit ends.

URL test: tests kept running after a reload

Commit: 6360e3afb

What happened: After a configuration reload, the old URL test group's tests kept running for up to 15 seconds and wrote results for outbounds that might no longer exist.

Fix: Closing the group stops its tests.

TUN: DNS stalled the whole tunnel

Commit: d098fded1

What happened: DNS queries caught by the TUN were handled on the tunnel's packet loop. With a DNS server behind a dead proxy, each query held every packet of the tunnel for 10 seconds.

Fix: Each query is handled separately, at most 256 at once.

libbox: Connections was not safe for concurrent use

Commit: 767a337ef

What happened: A platform app applies connection events on its command client's thread, and its UI filters, sorts and lists the same Connections object on the main thread. Nothing guarded the map and slices they share, so the connection list could crash the app or show broken entries.

Fix: Every exported method of Connections now takes a lock.

Balancer: sticky sessions landed on dead members

Commit: 299f7c824

What happened: When the member remembered for a session was dead, sticky-sessions picked again with a seed that was often identical on every retry. After the last retry it used the pick without checking it, so about half of the new sessions went to a dead member.

Fix: Each retry draws with a new seed. If no retry finds a live member, the first live member is used. A dead member is used only when none is alive.

Balancer: untested members counted as the fastest ones

Commit: 26038512b

What happened: consistent-hashing and sticky-sessions read the raw test delay. A member that was never tested has a delay of 0, so it looked like the fastest member and got traffic. A delay restored from the cache was trusted as if it were fresh.

Fix: These strategies now use the same adjusted delay as lowest-delay and round-robin. An untested member counts as timed out, and a cached delay gets a penalty.

Balancer: a large delay ratio overflowed

Commit: 9352492aa

What happened: The largest acceptable delay is the smallest delay × delay_acceptable_ratio. It was stored in a 16-bit number, which wraps past 65,535 ms. A cached delay gets a 20,000 ms penalty, so with a ratio of 3 two cached members gave a limit of about 9,000 ms, and round-robin then used only one of them.

Fix: The limit is computed in one place and capped just below the timeout delay. A timed-out member is still never accepted.

Balancer: round-robin reported no selected member

Commit: c4c581f38

What happened: A round-robin balancer always reported an empty current member, so the app and the Clash API showed nothing selected.

Fix: It reports the member the next TCP connection goes to.

Balancer: closing did not stop the worker

Commit: e3a6b22fa

What happened: Close did nothing, so the worker that follows URL-test results kept running until the whole service stopped. After a configuration reload, the old balancer kept updating itself.

Fix: Close stops the worker.

SSH: no background connection after start

Commit: 69fa3a66e

What happened: The SSH outbound connects in the background after start and reconnects when the connection drops, so it is ready and tested before any traffic. After the move to sing-box v1.15's startup stages, this step was never run. The outbound stayed not ready, and the URL test skipped it, until traffic arrived.

Fix: The background connection starts again once the service has started.

Startup: some outbounds were never started, and the balancer crashed

Commit: ac35f63e4

What happened: sing-box v1.15 starts components in stages. Outbounds written for the older startup (balancer, psiphon, dnstt and gooserelay) were only closed at shutdown and never started. A balancer that was never started has no strategy, and its first connection crashed the whole process.

Fix: These outbounds go through the startup stages again: preparation, start, and the step after the service has started.

WARP: an incomplete profile crashed the process

Commit: 35779caa3

What happened: The WARP endpoint starts in the background, so any crash there takes the whole process down. Three cases crashed it:

  • the profile request failed and there was no outbound to retry through;
  • the profile (cached, written in the config, or from the API) had no peers or no ports;
  • the profile had no interface addresses.

Fix: Each case now reports an error. The peer address and port are picked with checks, in this order: the configured server and server_port, a port listed in the profile, or the port in the host name. A missing address family is skipped.

TUIC: a misspelled UDP relay mode was accepted

Commit: 36861907a

What happened: Any udp_relay_mode other than quic, such as quick or Native, silently meant native.

Fix: An empty value or native means native, and quic means QUIC. Any other value fails when the configuration loads.

XHTTP: lost dial errors and accepted rejected uploads

Commit: fee26e298

What happened:

  • When the download stream could not connect, the error was lost. The caller got a reader that never returned data, so the connection hung until it timed out.
  • On HTTP/2 and HTTP/3, an upload the server rejected (any status other than 200) counted as sent.
  • The closed flag was read and written from different goroutines without synchronization.

Fix: The dial error reaches the caller. On HTTP/2 and HTTP/3, a status other than 200 is an error, as in Xray. The closed flag is atomic.

REALITY: a long short_id crashed the process

Commit: f6f378e81

What happened: A REALITY short_id is at most 8 bytes, which is 16 hex digits. A longer value overflowed the buffer it was decoded into and crashed the process, on both the client and the server.

Fix: A short_id longer than 16 hex digits is a configuration error.

VLESS: a failed handshake leaked the connection

Commit: ea3c70fa5

What happened: When the TLS handshake or the VLESS encryption handshake failed, the underlying TCP connection was never closed. A failed encryption handshake also replaced the connection with an empty value, which could crash later code. This applied to both TCP and UDP.

Fix: The original connection is closed on any handshake failure, and it is replaced only after a successful handshake.

Trojan: a disabled TLS block crashed the process

Commit: 27c97f0d8

What happened: A Trojan outbound with a tls block where enabled is false built a TLS dialer with no TLS settings, and the first connection crashed the process.

Fix: The TLS dialer is built only when TLS is enabled.


Last update: February 26, 2026

Comments