Skip to content

Commit 2bed169

Browse files
committed
release: v1.4.6
Fixed: removing a chain / Server link now persists. Clearing a node's "Chain via" relay or its optional Server link never saved — the form sent the cleared field as `undefined`, which JSON.stringify drops, so it never reached the PATCH body and the backend's model_dump(exclude_unset=True) kept the old value. The form now sends an explicit `null`; the backend already distinguished omitted from null, so this is a frontend-only fix (chain_node_id and server_id). Fixed: speed test surfaces the real xray error. A node test that could not start its throwaway xray always returned a generic "Failed to start temp xray", hiding the cause. _start_temp_xray now propagates xray's own error into the result, so the UI shows the actionable reason (e.g. invalid reality shortId, empty publicKey). Frontend tsc + build clean; nothing calls _start_temp_xray outside speedtest_node. No schema migration, no new dependencies, no breaking changes.
1 parent afc1a8c commit 2bed169

6 files changed

Lines changed: 39 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable user-facing changes to PiTun. Full per-release detail lives in the
44
[GitHub Releases](https://github.com/DaveBugg/PiTun/releases); this file is the
55
committed summary.
66

7+
## v1.4.6 — 2026-07-03
8+
9+
Two fixes from field-testing multi-hop chains: removing a node's chain (or its
10+
Server link) now actually saves, and a failed speed test now tells you *why*
11+
instead of a blank "couldn't start".
12+
13+
### Fixed
14+
15+
- **Removing a chain / Server link now persists.** Clearing a node's *"Chain via"*
16+
relay (or its optional Server link) never stuck — you could add a chain but never
17+
remove it. The form sent the cleared field as `undefined`, which `JSON.stringify`
18+
drops, so it never reached the `PATCH` body and the backend's
19+
`model_dump(exclude_unset=True)` kept the old value. The form now sends an explicit
20+
`null`, which the backend nulls out correctly. Affects `chain_node_id` and `server_id`.
21+
- **Speed test surfaces the real xray error.** A node test that couldn't start its
22+
throwaway xray always showed a generic *"Failed to start temp xray"*, hiding the
23+
cause. `_start_temp_xray` now propagates xray's own error into the result, so the UI
24+
shows the actionable reason — e.g. `xray: invalid "shortId"` or `xray: empty publicKey`.
25+
726
## v1.4.5 — 2026-06-18
827

928
Node-circle rotation that no longer drops connections, a fix so switching the

backend/app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# OpenAPI metadata, `/health` response, and `/system/status` so the
66
# frontend can display it next to the xray version. Bump this on each
77
# release — frontend keeps its own version in `frontend/package.json`.
8-
APP_VERSION = "1.4.5"
8+
APP_VERSION = "1.4.6"
99

1010

1111
class Settings(BaseSettings):

backend/app/core/speedtest.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ async def speedtest_node(node: Node) -> Dict:
8787
if not resolved_urls:
8888
return _result(node, error="Failed to resolve any test URL")
8989

90-
socks_port, proc, tmp_path = await _start_temp_xray(node, chain, entry_ip)
90+
socks_port, proc, tmp_path, start_err = await _start_temp_xray(node, chain, entry_ip)
9191
if proc is None:
92-
return _result(node, error="Failed to start temp xray")
92+
return _result(node, error=start_err or "Failed to start temp xray")
9393

9494
return await _run_curl_speedtest(node, socks_port, resolved_urls)
9595

@@ -151,7 +151,7 @@ async def _resolve_test_urls() -> List[Tuple[str, str, str, int]]:
151151

152152
async def _start_temp_xray(
153153
node: Node, chain: List[Node], entry_ip: Optional[str]
154-
) -> Tuple[int, Optional[asyncio.subprocess.Process], Optional[str]]:
154+
) -> Tuple[int, Optional[asyncio.subprocess.Process], Optional[str], Optional[str]]:
155155
"""
156156
Start a minimal xray instance bound to 127.0.0.1:<random>.
157157
@@ -189,7 +189,7 @@ async def _start_temp_xray(
189189
outbounds_chain.append(ob)
190190
except Exception as exc:
191191
logger.warning("Cannot build chain outbounds for node %d: %s", node.id, exc)
192-
return 0, None, None
192+
return 0, None, None, f"chain build error: {exc}"
193193

194194
final_tag = outbounds_chain[-1]["tag"]
195195

@@ -241,9 +241,13 @@ async def _start_temp_xray(
241241
err = (stderr_data or stdout_data or b"").decode(errors="replace")[-300:]
242242
logger.warning("Temp xray for node %d died early: %s", node.id, err)
243243
_safe_unlink(tmp_path)
244-
return 0, None, None
244+
# Surface the real reason (last segment of xray's error chain) to
245+
# the UI instead of a generic "failed to start" — e.g. an invalid
246+
# reality shortId or empty publicKey is otherwise invisible.
247+
reason = err.strip().rsplit(">", 1)[-1].strip() or "xray exited early"
248+
return 0, None, None, f"xray: {reason}"
245249
if await _port_open("127.0.0.1", socks_port):
246-
return socks_port, proc, tmp_path
250+
return socks_port, proc, tmp_path, None
247251
await asyncio.sleep(0.15)
248252

249253
# Timed out waiting for port — still alive but unresponsive

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "pitun-frontend",
33
"private": true,
4-
"version": "1.4.5",
4+
"version": "1.4.6",
55
"license": "BSD-3-Clause",
66
"type": "module",
77
"scripts": {

frontend/src/components/NodeForm.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,12 @@ export function NodeForm({ initial, onSave, onCancel, loading, nodes = [] }: Pro
8181
onSave({
8282
...form,
8383
order: initial?.order ?? 0,
84-
chain_node_id: chainNodeId ? Number(chainNodeId) : undefined,
85-
server_id: serverId ? Number(serverId) : undefined,
84+
// Send an explicit `null` (not `undefined`) when cleared — `undefined`
85+
// is dropped by JSON.stringify, so the field never reaches the PATCH
86+
// body and the backend's `model_dump(exclude_unset=True)` leaves the
87+
// old value in place (you could set a chain but never remove it).
88+
chain_node_id: chainNodeId ? Number(chainNodeId) : null,
89+
server_id: serverId ? Number(serverId) : null,
8690
})
8791
}
8892

frontend/src/types/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ export interface Node {
7070
last_check?: string
7171
is_online: boolean
7272
order: number
73-
chain_node_id?: number
73+
chain_node_id?: number | null
7474
// Optional link to a Server (the VPS hosting this node's upstream).
7575
// Purely informational — does not affect routing/connection.
76-
server_id?: number
76+
server_id?: number | null
7777
// Optional link to a DeploymentClient — set on Nodes that were
7878
// exported from a multi-client server-side deployment (WireGuard).
7979
// Drives the "from server X" source label and the orphan badge.

0 commit comments

Comments
 (0)