Compare commits

..

1 Commits

Author SHA1 Message Date
Jack Kingsman abe8390237 Test self-reexec on windows 2026-04-07 18:53:09 -07:00
11 changed files with 147 additions and 234 deletions
-9
View File
@@ -199,15 +199,6 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
> [!WARNING]
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
>
> ```powershell
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
> ```
>
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
## Where To Go Next
+5 -13
View File
@@ -254,22 +254,14 @@ class BaseMqttPublisher(ABC):
self._last_error = _format_error_detail(e)
# Windows ProactorEventLoop does not implement add_reader /
# add_writer, which paho-mqtt requires. The failure can
# surface as a direct NotImplementedError (add_writer in
# __aenter__) or as a generic timeout (add_reader fails
# inside an event-loop callback, so paho never hears back).
# Either way, if we're on Windows with Proactor the root
# cause is the same and retrying won't help.
_on_proactor = (
sys.platform == "win32"
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
)
if _on_proactor:
# add_writer, which paho-mqtt requires. Give a specific,
# actionable toast instead of the generic connection error.
if isinstance(e, NotImplementedError) and sys.platform == "win32":
broadcast_error(
"MQTT unavailable — Windows event loop incompatible",
"The default Windows event loop (ProactorEventLoop) does "
"not support MQTT. Add --loop none to your uvicorn "
"command and restart. See README.md for details.",
"not support MQTT. Restart with: uv run uvicorn "
"app.main:app --loop none",
)
_broadcast_health()
logger.error(
+100 -19
View File
@@ -1,41 +1,122 @@
import logging
import os
import sys
# ---------------------------------------------------------------------------
# Windows event-loop advisory for MQTT fanout
# Windows event-loop fix for MQTT fanout (aiomqtt / paho-mqtt) compatibility
# ---------------------------------------------------------------------------
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
# We cannot fix this from inside the app — the loop is already created by the
# time this module is imported. Log a prominent warning so Windows operators
# who want MQTT know to add ``--loop none`` to their uvicorn command.
# On Windows, uvicorn's default "auto" loop explicitly creates ProactorEventLoop,
# which does NOT implement add_reader()/add_writer() — calls that paho-mqtt
# requires internally. Setting the event loop *policy* alone is not enough
# because uvicorn's "auto" factory bypasses it.
#
# The fix: re-exec the current process with "--loop none", which tells uvicorn
# to let asyncio.run() create the loop through the standard policy (where we
# have just installed WindowsSelectorEventLoopPolicy).
#
# Guards:
# - "--loop" already in argv → we (or the operator) already handled it
# - MESHCORE_NO_AUTO_LOOP_ON_WIN32=true → operator opt-out for custom
# runners, test harnesses, or other non-uvicorn invocations
# ---------------------------------------------------------------------------
if sys.platform == "win32":
_win32_needs_reexec = (
sys.platform == "win32"
and os.environ.get("MESHCORE_NO_AUTO_LOOP_ON_WIN32", "").lower() not in ("true", "1")
and "--loop" not in sys.argv
)
# Skip re-exec when --reload is active: on Windows os.execv spawns a new
# process and exits, so the reloader's child dies and a fresh uvicorn
# (with its own reloader) starts — creating doubled watchers or a loop.
# Also skip if sys.executable is missing (embedded / frozen Python).
if _win32_needs_reexec and "--reload" in sys.argv:
print(
"\n" + "!" * 78 + "\n"
" WINDOWS + --reload DETECTED\n" + "!" * 78 + "\n"
"\n"
" We can't auto-fix the event loop when --reload is active because\n"
" the re-exec would fight with uvicorn's reloader process.\n"
"\n"
" If you need MQTT fanout, add --loop none to your command:\n"
"\n"
" uv run uvicorn app.main:app --reload \033[1m--loop none\033[0m [... other options ...]\n"
"\n"
" Everything else works fine as-is.\n"
"\n" + "!" * 78 + "\n",
file=sys.stderr,
flush=True,
)
_win32_needs_reexec = False
if _win32_needs_reexec and not sys.executable:
# Embedded or frozen Python — can't re-exec, just warn.
_win32_needs_reexec = False
if _win32_needs_reexec:
import asyncio as _asyncio
_loop = _asyncio.get_event_loop()
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
if _is_proactor:
_asyncio.set_event_loop_policy(
_asyncio.WindowsSelectorEventLoopPolicy() # type: ignore[attr-defined]
)
print(
"\n" + "=" * 78 + "\n"
" HALLO FRIEND WINDOWS USER <3 WE GOTTA ADJUST THINGS BEFORE YOU STARTUP\n"
+ "="
* 78
+ "\n"
"\n"
" uvicorn's default event loop on Windows (ProactorEventLoop) is not\n"
" compatible with aiomqtt/paho-mqtt, which require add_reader() /\n"
" add_writer(). Re-executing with '--loop none' so uvicorn honours\n"
" WindowsSelectorEventLoopPolicy and MQTT fanout can function.\n"
""
" In English: The code we use for MQTT is fussy. We're restarting\n"
" the server with the right settings for MQTT to work.\n"
"\n"
" This may or may not work :) If the app starts up after this without a warning, you're good to go.\n"
"\n" + "=" * 78 + "\n",
file=sys.stderr,
flush=True,
)
# sys.argv[0] on Windows is typically a .exe console-script launcher
# (e.g. .venv\Scripts\uvicorn.exe) which Python can't open as a script.
# use "python -m uvicorn" instead, forwarding the original arguments.
# yes, this is brittle as all hell.
try:
os.execv(
sys.executable,
[sys.executable, "-m", "uvicorn"] + sys.argv[1:] + ["--loop", "none"],
)
except Exception:
# execv failed — fall through and let the app start normally.
# MQTT fanout will not work, but everything else will.
print(
"\n" + "!" * 78 + "\n"
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
" AUTO-RESTART FAILED :<\n" + "!" * 78 + "\n"
"\n"
" The running event loop is ProactorEventLoop, which is not\n"
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
" We tried to restart uvicorn with the necessary settings\n"
" automatically, but there was a problem with the invocation\n"
" (not shocking; this is a fragile system).\n"
"\n"
" If you use MQTT integrations, restart with --loop none:\n"
" Please rerun RemoteTerm with a command like:\n"
"\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
" [... other options ...]\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m [... other options ...]\n"
"\n"
" Everything else works fine as-is.\n"
" Setting '--loop none' on uvicorn startup will put you in a good\n"
" state for MQTT and bypass this self-repair.\n"
"\n"
" The server is starting anyway -- everything except MQTT fanout\n"
" will work normally. If you want to suppress this attempt, \n"
" set the env var MESHCORE_NO_AUTO_LOOP_ON_WIN32=true\n"
"\n" + "!" * 78 + "\n",
file=sys.stderr,
flush=True,
)
del _loop, _is_proactor
# ---------------------------------------------------------------------------
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 5000
# MESHCORE_TCP_PORT: 4000
# BLE
# BLE in Docker usually needs additional manual compose changes such as
+26 -179
View File
@@ -11,14 +11,11 @@ import {
Cell,
} from 'recharts';
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types';
import {
KNOWN_PAYLOAD_TYPES,
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
type NeighborStat,
@@ -27,26 +24,9 @@ import {
type RawPacketStatsSessionState,
type RawPacketStatsWindow,
} from '../utils/rawPacketStats';
import { createDecoderOptions } from '../utils/rawPacketInspector';
import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils';
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
function getPacketTypeName(
packet: RawPacket,
decoderOptions?: ReturnType<typeof createDecoderOptions>
): string {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) return 'Unknown';
const name = Utils.getPayloadTypeName(decoded.payloadType);
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
} catch {
return 'Unknown';
}
}
interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
@@ -448,48 +428,6 @@ export function RawPacketFeedView({
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const packetsWithTypes = useMemo(
() =>
packets.map((packet) => ({
packet,
payloadType: getPacketTypeName(packet, decoderOptions),
})),
[packets, decoderOptions]
);
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
const filteredPackets = useMemo(() => {
if (allTypesEnabled) return packets;
return packetsWithTypes
.filter(({ payloadType }) => enabledTypes.has(payloadType))
.map(({ packet }) => packet);
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
const handleToggleAll = () => {
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
};
const handleToggleType = (type: string) => {
setEnabledTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const handleOnly = (type: string) => {
setEnabledTypes(new Set([type]));
};
useEffect(() => {
const interval = window.setInterval(() => {
@@ -530,129 +468,38 @@ export function RawPacketFeedView({
);
return (
<>
<div className="border-b border-border px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="hidden md:block text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<p className="md:hidden text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
{!mobileFiltersOpen && (
<>
{' · '}
<button
type="button"
className="text-primary hover:text-primary/80 transition-colors"
onClick={() => setMobileFiltersOpen(true)}
>
Show Filters
</button>
</>
)}
</p>
{mobileFiltersOpen && (
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
</div>
)}
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList
packets={filteredPackets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
</div>
<aside
@@ -1666,8 +1666,7 @@ function AppriseConfigEditor({
rows={4}
/>
<p className="text-xs text-muted-foreground">
One URL per line. All URLs receive every matched notification. For Matrix room version 12
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
One URL per line. All URLs receive every matched notification.
</p>
</div>
+1 -1
View File
@@ -15,7 +15,7 @@ const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'ses
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
export const KNOWN_PAYLOAD_TYPES = [
const KNOWN_PAYLOAD_TYPES = [
'Advert',
'GroupText',
'TextMessage',
+4 -1
View File
@@ -14,7 +14,7 @@ dependencies = [
"pynacl>=1.5.0",
"meshcore==2.3.2",
"aiomqtt>=2.0",
"apprise>=1.9.8",
"apprise>=1.9.7",
"boto3>=1.38.0",
]
@@ -53,6 +53,9 @@ ignore = [
"SIM117", # nested with statements - can be clearer in tests
]
[tool.ruff.lint.per-file-ignores]
"app/main.py" = ["E402"] # imports after Windows event-loop re-exec block
[tool.ruff.lint.isort]
known-first-party = ["app"]
+3 -3
View File
@@ -35,7 +35,7 @@ SERIAL_HOST_PATH="/dev/ttyACM0"
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
TCP_HOST=""
TCP_PORT="5000"
TCP_PORT="4000"
BLE_ADDRESS=""
BLE_PIN=""
ENABLE_BOTS="N"
@@ -311,8 +311,8 @@ case "$TRANSPORT_CHOICE" in
echo -e "${RED}TCP host is required.${NC}"
read -r -p "TCP host: " TCP_HOST
done
read -r -p "TCP port (default: 5000): " TCP_PORT
TCP_PORT="${TCP_PORT:-5000}"
read -r -p "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
3)
+2 -2
View File
@@ -114,8 +114,8 @@ case "$TRANSPORT_CHOICE" in
echo -e "${RED}TCP host is required.${NC}"
read -rp "TCP host: " TCP_HOST
done
read -rp "TCP port (default: 5000): " TCP_PORT
TCP_PORT="${TCP_PORT:-5000}"
read -rp "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
4)
Generated
+4 -4
View File
@@ -56,7 +56,7 @@ wheels = [
[[package]]
name = "apprise"
version = "1.9.9"
version = "1.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -67,9 +67,9 @@ dependencies = [
{ name = "requests-oauthlib" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822 }
sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/97dc06b3401bb67abcef6e8bef7155f192b75795c2a2aa4d59eb5aa7fa66/apprise-1.9.7.tar.gz", hash = "sha256:2f73cc1e0264fb119fdb9b7cde82e8fde40a0f531ac885d8c6f0cf0f6e13aec2", size = 1937173 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940 },
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
]
[[package]]
@@ -1022,7 +1022,7 @@ dev = [
requires-dist = [
{ name = "aiomqtt", specifier = ">=2.0" },
{ name = "aiosqlite", specifier = ">=0.19.0" },
{ name = "apprise", specifier = ">=1.9.8" },
{ name = "apprise", specifier = ">=1.9.7" },
{ name = "boto3", specifier = ">=1.38.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },