Skip to content

Commit f1fc762

Browse files
AlexCheemaclaude
andcommitted
fix: replace flaky internet checks with explicit offline mode
Remove socket-based internet connectivity probes (1.1.1.1/8.8.8.8 every 10s) that caused false negatives. Replace with an explicit offline mode toggled via --offline CLI flag, EXO_OFFLINE env var, or macOS app settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9a2d2a4 commit f1fc762

7 files changed

Lines changed: 39 additions & 60 deletions

File tree

app/EXO/EXO/ExoProcessController.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Foundation
55
private let customNamespaceKey = "EXOCustomNamespace"
66
private let hfTokenKey = "EXOHFToken"
77
private let enableImageModelsKey = "EXOEnableImageModels"
8+
private let offlineModeKey = "EXOOfflineMode"
89
private let onboardingCompletedKey = "EXOOnboardingCompleted"
910

1011
@MainActor
@@ -60,6 +61,14 @@ final class ExoProcessController: ObservableObject {
6061
UserDefaults.standard.set(enableImageModels, forKey: enableImageModelsKey)
6162
}
6263
}
64+
@Published var offlineMode: Bool = {
65+
return UserDefaults.standard.bool(forKey: offlineModeKey)
66+
}()
67+
{
68+
didSet {
69+
UserDefaults.standard.set(offlineMode, forKey: offlineModeKey)
70+
}
71+
}
6372

6473
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
6574
@Published private(set) var isFirstLaunchReady = false
@@ -267,6 +276,9 @@ final class ExoProcessController: ObservableObject {
267276
if enableImageModels {
268277
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
269278
}
279+
if offlineMode {
280+
environment["EXO_OFFLINE"] = "true"
281+
}
270282

271283
var paths: [String] = []
272284
if let existing = environment["PATH"], !existing.isEmpty {

app/EXO/EXO/Views/SettingsView.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct SettingsView: View {
1313
@State private var pendingNamespace: String = ""
1414
@State private var pendingHFToken: String = ""
1515
@State private var pendingEnableImageModels = false
16+
@State private var pendingOfflineMode = false
1617
@State private var needsRestart = false
1718
@State private var bugReportInFlight = false
1819
@State private var bugReportMessage: String?
@@ -42,6 +43,7 @@ struct SettingsView: View {
4243
pendingNamespace = controller.customNamespace
4344
pendingHFToken = controller.hfToken
4445
pendingEnableImageModels = controller.enableImageModels
46+
pendingOfflineMode = controller.offlineMode
4547
needsRestart = false
4648
}
4749
}
@@ -72,6 +74,13 @@ struct SettingsView: View {
7274
.foregroundColor(.secondary)
7375
}
7476

77+
Section {
78+
Toggle("Offline Mode", isOn: $pendingOfflineMode)
79+
Text("Skip internet checks and use only locally available models.")
80+
.font(.caption)
81+
.foregroundColor(.secondary)
82+
}
83+
7584
Section {
7685
HStack {
7786
Spacer()
@@ -445,6 +454,7 @@ struct SettingsView: View {
445454

446455
private var hasGeneralChanges: Bool {
447456
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
457+
|| pendingOfflineMode != controller.offlineMode
448458
}
449459

450460
private var hasModelChanges: Bool {
@@ -454,6 +464,7 @@ struct SettingsView: View {
454464
private func applyGeneralSettings() {
455465
controller.customNamespace = pendingNamespace
456466
controller.hfToken = pendingHFToken
467+
controller.offlineMode = pendingOfflineMode
457468
restartIfRunning()
458469
}
459470

src/exo/download/coordinator.py

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
import socket
32
from dataclasses import dataclass, field
43
from random import random
54

@@ -73,8 +72,6 @@ class DownloadCoordinator:
7372

7473
def __post_init__(self) -> None:
7574
self.event_sender, self.event_receiver = channel[Event]()
76-
if self.offline:
77-
self.shard_downloader.set_internet_connection(False)
7875
self.shard_downloader.on_progress(self._download_progress_callback)
7976

8077
def _model_dir(self, model_id: ModelId) -> str:
@@ -123,49 +120,17 @@ async def run(self) -> None:
123120
logger.info(
124121
f"Starting DownloadCoordinator{' (offline mode)' if self.offline else ''}"
125122
)
126-
if not self.offline:
127-
self._test_internet_connection()
128123
try:
129124
async with self._tg as tg:
130125
tg.start_soon(self._command_processor)
131126
tg.start_soon(self._forward_events)
132127
tg.start_soon(self._emit_existing_download_progress)
133128
tg.start_soon(self._resend_out_for_delivery)
134129
tg.start_soon(self._clear_ofd)
135-
if not self.offline:
136-
tg.start_soon(self._check_internet_connection)
137130
finally:
138131
for task in self.active_downloads.values():
139132
task.cancel()
140133

141-
def _test_internet_connection(self) -> None:
142-
# Try multiple endpoints since some ISPs/networks block specific IPs
143-
for host in ("1.1.1.1", "8.8.8.8", "1.0.0.1"):
144-
try:
145-
socket.create_connection((host, 443), timeout=3).close()
146-
self.shard_downloader.set_internet_connection(True)
147-
logger.debug(f"Internet connectivity: True (via {host})")
148-
return
149-
except OSError:
150-
continue
151-
self.shard_downloader.set_internet_connection(False)
152-
logger.debug("Internet connectivity: False")
153-
154-
async def _check_internet_connection(self) -> None:
155-
first_connection = True
156-
while True:
157-
await asyncio.sleep(10)
158-
159-
# Assume that internet connection is set to False on 443 errors.
160-
if self.shard_downloader.internet_connection:
161-
continue
162-
163-
self._test_internet_connection()
164-
165-
if first_connection and self.shard_downloader.internet_connection:
166-
first_connection = False
167-
self._tg.start_soon(self._emit_existing_download_progress)
168-
169134
def shutdown(self) -> None:
170135
self._tg.cancel_tasks()
171136

src/exo/download/impl_shard_downloader.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515
)
1616

1717

18-
def exo_shard_downloader(max_parallel_downloads: int = 8) -> ShardDownloader:
18+
def exo_shard_downloader(
19+
max_parallel_downloads: int = 8, offline: bool = False
20+
) -> ShardDownloader:
1921
return SingletonShardDownloader(
20-
CachedShardDownloader(ResumableShardDownloader(max_parallel_downloads))
22+
CachedShardDownloader(
23+
ResumableShardDownloader(max_parallel_downloads, offline=offline)
24+
)
2125
)
2226

2327

@@ -50,10 +54,6 @@ def __init__(self, shard_downloader: ShardDownloader):
5054
self.shard_downloader = shard_downloader
5155
self.active_downloads: dict[ShardMetadata, asyncio.Task[Path]] = {}
5256

53-
def set_internet_connection(self, value: bool) -> None:
54-
self.internet_connection = value
55-
self.shard_downloader.set_internet_connection(value)
56-
5757
def on_progress(
5858
self,
5959
callback: Callable[[ShardMetadata, RepoDownloadProgress], Awaitable[None]],
@@ -90,10 +90,6 @@ def __init__(self, shard_downloader: ShardDownloader):
9090
self.shard_downloader = shard_downloader
9191
self.cache: dict[tuple[str, ShardMetadata], Path] = {}
9292

93-
def set_internet_connection(self, value: bool) -> None:
94-
self.internet_connection = value
95-
self.shard_downloader.set_internet_connection(value)
96-
9793
def on_progress(
9894
self,
9995
callback: Callable[[ShardMetadata, RepoDownloadProgress], Awaitable[None]],
@@ -123,8 +119,9 @@ async def get_shard_download_status_for_shard(
123119

124120

125121
class ResumableShardDownloader(ShardDownloader):
126-
def __init__(self, max_parallel_downloads: int = 8):
122+
def __init__(self, max_parallel_downloads: int = 8, offline: bool = False):
127123
self.max_parallel_downloads = max_parallel_downloads
124+
self.offline = offline
128125
self.on_progress_callbacks: list[
129126
Callable[[ShardMetadata, RepoDownloadProgress], Awaitable[None]]
130127
] = []
@@ -151,8 +148,7 @@ async def ensure_shard(
151148
self.on_progress_wrapper,
152149
max_parallel_downloads=self.max_parallel_downloads,
153150
allow_patterns=allow_patterns,
154-
skip_internet=not self.internet_connection,
155-
on_connection_lost=lambda: self.set_internet_connection(False),
151+
skip_internet=self.offline,
156152
)
157153
return target_dir
158154

@@ -168,8 +164,7 @@ async def _status_for_model(
168164
shard,
169165
self.on_progress_wrapper,
170166
skip_download=True,
171-
skip_internet=not self.internet_connection,
172-
on_connection_lost=lambda: self.set_internet_connection(False),
167+
skip_internet=self.offline,
173168
)
174169

175170
semaphore = asyncio.Semaphore(self.max_parallel_downloads)
@@ -198,7 +193,6 @@ async def get_shard_download_status_for_shard(
198193
shard,
199194
self.on_progress_wrapper,
200195
skip_download=True,
201-
skip_internet=not self.internet_connection,
202-
on_connection_lost=lambda: self.set_internet_connection(False),
196+
skip_internet=self.offline,
203197
)
204198
return progress

src/exo/download/shard_downloader.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@
1616

1717
# TODO: the PipelineShardMetadata getting reinstantiated is a bit messy. Should this be a classmethod?
1818
class ShardDownloader(ABC):
19-
internet_connection: bool = False
20-
21-
def set_internet_connection(self, value: bool) -> None:
22-
self.internet_connection = value
23-
2419
@abstractmethod
2520
async def ensure_shard(
2621
self, shard: ShardMetadata, config_only: bool = False

src/exo/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ async def create(cls, args: "Args") -> Self:
6060
download_coordinator = DownloadCoordinator(
6161
node_id,
6262
session_id,
63-
exo_shard_downloader(),
63+
exo_shard_downloader(offline=args.offline),
6464
download_command_receiver=router.receiver(topics.DOWNLOAD_COMMANDS),
6565
local_event_sender=router.sender(topics.LOCAL_EVENTS),
6666
offline=args.offline,
@@ -211,7 +211,7 @@ async def _elect_loop(self):
211211
self.download_coordinator = DownloadCoordinator(
212212
self.node_id,
213213
result.session_id,
214-
exo_shard_downloader(),
214+
exo_shard_downloader(offline=self.offline),
215215
download_command_receiver=self.router.receiver(
216216
topics.DOWNLOAD_COMMANDS
217217
),
@@ -283,7 +283,7 @@ class Args(CamelCaseModel):
283283
tb_only: bool = False
284284
no_worker: bool = False
285285
no_downloads: bool = False
286-
offline: bool = False
286+
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
287287
fast_synch: bool | None = None # None = auto, True = force on, False = force off
288288

289289
@classmethod

src/exo/shared/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,6 @@ def _get_xdg_dir(env_var: str, fallback: str) -> Path:
7878
os.getenv("EXO_ENABLE_IMAGE_MODELS", "false").lower() == "true"
7979
)
8080

81+
EXO_OFFLINE = os.getenv("EXO_OFFLINE", "false").lower() == "true"
82+
8183
EXO_TRACING_ENABLED = os.getenv("EXO_TRACING_ENABLED", "false").lower() == "true"

0 commit comments

Comments
 (0)