v3.9.1: plain/WASAPI streams now play on an ASIO-mode receiver (silent-mic fix)

Receiver: a Mixed (plain) session is rendered on an active lane in BothIndependent mode instead of being skipped, so a WASAPI-only sender is no longer silent to a receiver that has an ASIO driver selected. Also port the per-session buffer depth-drain (stops the receive jitter buffer bloating).

Sender: add sndAudFr meter (audio frames actually sent) to localise capture vs send.

App: startup sound cue (machine-wide, Preferences); stop sending audio when no peer is reachable (issue #8). Version 3.9.1.

Server (relay): fix updater version-compare for multi-dot tags, guard the main loop against crashes, reject spoofed BYE from a mismatched endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-12 19:00:33 +01:00
co-authored by Claude Opus 4.8
parent 04b17ff1ab
commit 84c4a47411
15 changed files with 360 additions and 148 deletions
+1 -1
View File
@@ -1 +1 @@
server-v2.3
server-v2.4
+38 -33
View File
@@ -88,41 +88,46 @@ read_current_version() {
printf '%s' "${TAG_PREFIX}v0"
}
# Parse a tag like "server-v2.10" into a comparable numeric form.
# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable.
tag_to_version() {
local tag="$1"
# strip the prefix
tag="${tag#"$TAG_PREFIX"}"
# strip a leading "v" if present
tag="${tag#v}"
local major minor
major="${tag%%.*}"
minor="${tag#*.}"
# if there's no dot, minor==major. Treat as MAJOR.0.
if [[ "$minor" == "$tag" ]]; then
minor="0"
fi
# keep only digits — survive things like "v2.0-rc1" by ignoring the suffix.
major="${major//[^0-9]/}"
minor="${minor//[^0-9]/}"
: "${major:=0}"
: "${minor:=0}"
printf '%s.%s' "$major" "$minor"
}
# Compare two release tags by their dotted numeric version, parsed the SAME way as the Python
# release selector in get_latest_release() — split on every '.', keep the digits of each
# component, then pad to equal length before comparing. Delegated to python3 (already a hard
# dependency of this script) so the upgrade GATE and the release SELECTOR can never disagree, and
# so multi-component tags (server-v2.3.1), pre-release suffixes (server-v2.3-rc1) and missing
# components are all handled correctly.
#
# This replaces a bash-only parser that collapsed everything after the FIRST dot into the "minor"
# field and then stripped the dot — so server-v2.3.1 read as "2.31" and was wrongly judged NEWER
# than server-v2.3. The moment any patch-style tag existed, the hourly update check would STOP and
# RESTART the live relay (a real multi-second outage for every connected client), and it could even
# "upgrade" to an OLDER build (server-v2.9.1 -> "2.91" > server-v2.10 -> "2.10"). 2026-06-12.
# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise.
# Returns 0 if $1 is a strictly newer version than $2, 1 otherwise (equal counts as NOT newer).
tag_newer_than() {
local left right lv rv
left="$(tag_to_version "$1")"
right="$(tag_to_version "$2")"
# Numeric compare major then minor.
lv="${left%.*}"; rv="${right%.*}"
if (( lv > rv )); then return 0; fi
if (( lv < rv )); then return 1; fi
lv="${left#*.}"; rv="${right#*.}"
if (( lv > rv )); then return 0; fi
return 1
TAG_PREFIX="$TAG_PREFIX" python3 - "$1" "$2" <<'PY'
import os, sys
prefix = os.environ.get("TAG_PREFIX", "server-")
def parse(tag):
if tag.startswith(prefix):
tag = tag[len(prefix):]
if tag.startswith("v"):
tag = tag[1:]
out = []
for part in tag.split("."):
digits = "".join(c for c in part if c.isdigit())
out.append(int(digits) if digits else 0)
return out
left = parse(sys.argv[1])
right = parse(sys.argv[2])
# Pad to equal length so 2.3 and 2.3.0 compare EQUAL — a shorter tuple must not read as older,
# or a re-tagged same-version release would trigger a needless stop/restart of the relay.
n = max(len(left), len(right))
left += [0] * (n - len(left))
right += [0] * (n - len(right))
sys.exit(0 if left > right else 1)
PY
}
# -------- GitHub releases query ---------------------------------------------
+34 -8
View File
@@ -362,6 +362,12 @@ class Relay:
return
now = time.monotonic()
entry = self.v2_clients.get(client_id)
# Capture whether this packet came from the endpoint this client_id is CURRENTLY registered
# at, BEFORE the NAT-rebind update below overwrites entry.addr. Used to reject spoofed
# control packets: the roster broadcast ships every member's client_id to all members, so on
# an internet-facing relay anyone who joins learns the others' ids and could otherwise forge
# a BYE to evict them. A genuine BYE always comes from the client's own registered endpoint.
from_registered_endpoint = entry is not None and entry.addr == addr
if entry is None:
# Admit attempt.
if len(self.v2_clients) >= self.max_clients:
@@ -398,6 +404,14 @@ class Relay:
self.v2_roster_dirty = True
return
if pkt_type == TYPE_LOBBY_BYE:
# Only the endpoint a client is registered at may say goodbye for it — otherwise a
# forged BYE bearing a known client_id (learned from the roster) could evict any peer.
if not from_registered_endpoint:
self.log.warning(
"event=bye_rejected reason=endpoint_mismatch client_id=%s from=%s",
client_id, _fmt_addr(addr),
)
return
self.v2_clients.pop(client_id, None)
self.log.info(
"event=client_left client_id=%s addr=%s reason=bye",
@@ -526,16 +540,28 @@ def main() -> int:
ready, _, _ = select.select([sock], [], [], SOCKET_POLL_TIMEOUT_SECONDS)
except InterruptedError:
continue
except OSError as e:
# select() itself failed (e.g. a transient resource-pressure error on a long-
# running, low-RAM host). Log and pause briefly rather than spin or exit.
log.warning("event=select_failed err=%s", e)
time.sleep(0.1)
continue
now = time.monotonic()
if ready:
try:
# Per-iteration work, fully guarded. A relay that must stay up for DAYS — and that is
# reachable from the open internet — can never let a single packet or a housekeeping
# tick crash the whole process: that would drop EVERY connected client and force a ~5s
# systemd restart. Anything unexpected is logged (with a traceback) and we carry on.
try:
if ready:
data, addr = sock.recvfrom(RECV_BUFFER_BYTES)
except OSError as e:
log.warning("event=recv_failed err=%s", e)
continue
relay.handle_packet(data, addr)
relay.tick(now)
relay.maybe_log_stats(now)
relay.handle_packet(data, addr)
relay.tick(now)
relay.maybe_log_stats(now)
except OSError as e:
# recvfrom, or a sendto that escaped its own guard — transient; keep serving.
log.warning("event=io_error err=%s", e)
except Exception:
log.exception("event=loop_error — recovered, continuing")
finally:
log.info("event=shutdown")
sock.close()