Skip to content

Release 1.5.1 — the exit chain, and the routes the app was describing wrong - #20

Merged
DarkPoesidon merged 4 commits into
mainfrom
fix/connection-root-cause
Aug 19, 2026
Merged

Release 1.5.1 — the exit chain, and the routes the app was describing wrong#20
DarkPoesidon merged 4 commits into
mainfrom
fix/connection-root-cause

Conversation

@DarkPoesidon

@DarkPoesidon DarkPoesidon commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Everything since 1.5.0. Each of these was the app trusting or describing a route that was not the one carrying traffic.

The tunnel would not connect

A failed attempt left the system proxy pointing at a bridge whose tunnel had already gone. Nothing restored it until the supervisor gave up or the user disconnected — so for the whole backoff and the whole next attempt, every application was aimed at a listener that answered nothing. In Whole machine mode that does not read as "one attempt did not come up"; it reads as the app having taken the network down. Ordinary retries now restore it. The kill switch, whose purpose is to hold it, still does.

The chain's node list was never decoded

mihomo's control API is Go's net/http, which sets Content-Length only for a reply small enough to buffer and frames anything larger in chunks. The client read neither. /version is 35 bytes so it arrived whole and the readiness check passed — the chain reported itself up and carried traffic — while the node list, the one reply that is always large, reached the JSON parser as 8a0c\r\n{"proxies":.... That is exactly the expected value at line 1 column 1 on screen, and it read like a crash rather than a reply we had not finished reading.

The app named the wrong address

The exit card read the tunnel even with a hop running, so it reported Cloudflare's address while everything left through the node. "This app only" named the tunnel's port for the same reason, sending anything pointed at it straight past the hop — and that address was a literal, so it was also wrong for anyone who had changed the SOCKS port. The hop's own port was ephemeral (62704, then 36701, then 7677), so the one address a person types into a browser moved every launch; it is now the tunnel's port plus one.

A degraded gateway stayed pinned for good

The engine caches the gateway that last worked and asks only whether it still answers — never whether it is still worth having — and every successful connect writes it back. Measured on one machine, one minute apart:

edge time to first byte
freshly scanned 162.159.198.128 0.11 s, steady
cached 162.159.198.130 3.2 → 5.6 → 7.0 → 9.7 s → timeout

Nothing noticed, because the latency probe timed a SOCKS5 CONNECT — which stays fast on a tunnel whose data plane is congested. The chart read 33 ms while no page would load. The probe now waits for a byte to come back, and a watcher re-scans after three checks in a row over two seconds. It leaves the transport alone: alternating H2/H3 answers a transport that cannot get through, and here the transport was fine.

Nobody could see any of it

The core's output lived only in a 1000-line ring buffer inside the running process, so every diagnosis was inference — and inference kept landing on the engine, which was never at fault. Each line was also its own IPC message, emitted from the thread that is the sole drainer of the core's stdout pipe. mihomo's output was piped and never read at all, which blocks it on its own logging once the pipe fills, and threw away the only account of what the chain was doing.

Lines are now buffered and one pump delivers them: in batches to the window, and to a rotating core.log that survives a crash. mihomo's output joins it.

And one found by accident

Launching the app while it was already running started a second process that knew nothing about the first — it showed "Not connected" over a live tunnel, and its startup proxy recovery reverted the system proxy the running copy had applied. A second launch now focuses the window already open.

Verified

route address location warp
tunnel only 104.28.214.151 Singapore on
through the chain 64.110.98.173 Japan (KIX) off

Nodes list with their delays, latency reads a true 79 ms, This app only names 127.0.0.1:1820, the chain log captures traffic through the selected node, and nothing is left running after disconnect. Built, installed and exercised on Windows 11.

cargo test 55 · pnpm check clean · pnpm test 33

A failed attempt used to leave the system proxy pointing at a bridge whose
tunnel had already gone. Nothing restored it until the supervisor either gave
up or the user disconnected, so for the whole backoff and the whole next
attempt every application on the machine was aimed at a listener that answered
nothing. In "Whole machine" mode that reads as the app having broken the
network rather than as one attempt not coming up. Ordinary retries now put the
proxy back; the kill switch, whose entire purpose is to hold it, still does.

The reason that took so long to find is the second half of this change. The
core's output lived only in a 1000-line ring buffer inside the running process,
so every diagnosis was inference: the evidence was gone by the time anyone went
looking. Each line was also one IPC message, emitted from the thread that is
the sole drainer of the core's stdout pipe — the interface could throttle the
engine. Lines are now buffered and a single pump delivers them, both to the
window in batches and to a rotating core.log in the app's log directory.

With the log in place the connection itself turned out to be healthy: a cached
gateway that had expired, then a full sweep of 3007 candidates inside its own
120s budget, then MASQUE H2 up in half a second. Verified end to end — connected
on 162.159.198.98:443 at 33ms, exit in Singapore with warp=on, and no orphaned
processes after disconnect.

Also carries the chain work that never landed: the through-the-tunnel switch,
the guard that stopped a working chain being torn down and rebuilt on every log
line, and chain failures surfaced on screen instead of swallowed.
mihomo's control API is Go's net/http, which sets Content-Length only for a
reply small enough to buffer and frames anything larger in chunks. The client
here read neither: it took whatever bytes followed the headers and handed them
to the JSON parser.

/version is 35 bytes, so it arrived whole and the readiness check passed — the
chain reported itself up and started carrying traffic. The node list is the one
reply that is always large, so it always arrived chunked, and always reached the
parser with its chunk header still on the front:

    8a0c\r\n{"proxies":{"COMPATIBLE":...

which is exactly the "expected value at line 1 column 1" on screen. It read like
the chain had crashed rather than like a reply we had not finished reading, so
the dashboard showed no nodes and an error for a second hop that was working.

The reply is now decoded before it is parsed. Verified end to end: the tunnel
alone exits 104.28.214.151 in Singapore with warp=on, and through the chain
64.110.98.173 in Japan with warp=off — the node's address, not Cloudflare's.
Four nodes list with their delays, and nothing is left running after disconnect.
Six things, all of them the app describing or trusting a route that was not the
one carrying traffic.

The exit card read the tunnel even with a second hop running, so it reported
Cloudflare's address while every application left through the node. "This app
only" named the tunnel's port for the same reason, which sent anything pointed
at it straight past the hop — the address on that card was a literal, so it was
also wrong for anyone who had changed the SOCKS port. Both now follow the
address traffic is carried on, and the badge says "Through your node" rather
than treating warp=off as a leak: through a hop it is off by definition. That
card is also read a few times before it gives up, because the first read lands
while a hop that has started still has no nodes loaded.

The hop's own port was ephemeral — 62704, then 36701, then 7677 — so the one
address a person has to type into a browser moved on every launch. It is now the
tunnel's port plus one, falling back only if something holds it.

The latency probe timed a SOCKS5 CONNECT, which stays fast on a tunnel whose
data plane is congested. Measured on one machine, one minute apart: the pinned
edge took 3 to 10 seconds to first byte and then timed out, while a freshly
scanned one took 0.11s — and the chart read 33ms throughout. The probe now waits
for a byte to come back, and a watcher re-scans when three checks in a row come
back over two seconds. The engine only ever asked whether its cached gateway
still answered, never whether it was still worth having, and every successful
connect wrote it back — so a degraded edge stayed pinned for good.

mihomo's output was piped and never read, which blocks it on its own logging
once the pipe fills. It also threw away the account of what the chain was doing:
it had marked every node dead behind the slow tunnel and said so. Drained into
the app log now, at info.

Last, launching the app while it was already running started a second process
that knew nothing about the first: it showed "Not connected" over a live tunnel,
and its startup proxy recovery reverted the system proxy the running copy had
applied. A second launch now focuses the window already open.
@DarkPoesidon
DarkPoesidon force-pushed the fix/connection-root-cause branch from d5b2390 to 4f901c5 Compare August 19, 2026 08:04
@DarkPoesidon DarkPoesidon changed the title Release 1.5.1 — put the machine back between attempts, and keep a log Release 1.5.1 — the exit chain, and the routes the app was describing wrong Aug 19, 2026
Twice in a row the Linux x86_64 build sat in "Install Linux build dependencies"
for over half an hour, while every other architecture — including the arm runner
on the same image — finished in ten minutes. apt retries without a deadline, so
the job neither failed nor finished, and a release that was otherwise ready
waited on it.

Bound the step to ten minutes, cap apt's own retries and socket timeout, and
answer needrestart's service prompt rather than leaving it on a terminal nobody
is watching. A re-run of one job costs minutes; a hang costs the cycle.

The runner image stays 22.04 on purpose: building against the older glibc is
what keeps the .deb and .rpm installable on distributions people actually run.
@DarkPoesidon
DarkPoesidon merged commit bfc75a9 into main Aug 19, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant