Skip to content

Commit af74421

Browse files
ralyodioclaude
andcommitted
feat(tor): live bootstrap progress bar + never kill a slow Tor (v3.2.8)
Root cause of the user's failure: a cold first-run Tor downloads the whole network directory (9455 microdescriptors) and can exceed a minute; our 60s timeout KILLED it at ~56% → "Tor couldn't start." Fixes: - Helper never kills Tor for being slow. A background reader watches bootstrap for Tor's whole lifetime, tracks the % and caches the directory, so the next attempt is instant. /start is now non-blocking; /status reports live {progress, ready, error}. - Extension polls /status and pushes live progress to a NEW progress bar in the sidebar (0→100%), then enables the proxy when ready. "Still connecting" is a calm message, not an error. Verified end-to-end: /start returns instantly, the bar climbs 0→100 over a cold bootstrap, and curl --socks5-hostname 127.0.0.1:9071 → IsTor:true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6f2555e commit af74421

32 files changed

Lines changed: 178 additions & 106 deletions

File tree

apps/desktop/extensions/ai-sidebar/background.js

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,46 @@ const TOR_CHECK_URL = 'https://check.torproject.org/api/ip';
4444
// to Tor until the user flips it on.
4545
const TOR_HELPER = 'http://127.0.0.1:9061';
4646

47-
// Ask the helper to start Tor and block until it's bootstrapped. Returns
48-
// { ok, error? }; { ok:false, error:'unreachable' } when the helper isn't running.
49-
async function startTorViaHelper() {
47+
async function helperJson(path, method) {
48+
const ctrl = new AbortController();
49+
const t = setTimeout(() => ctrl.abort(), 8000);
5050
try {
51-
const ctrl = new AbortController();
52-
const t = setTimeout(() => ctrl.abort(), 75000); // tor bootstrap can take ~60s
53-
const res = await fetch(`${TOR_HELPER}/start`, { method: 'POST', signal: ctrl.signal });
51+
const res = await fetch(`${TOR_HELPER}${path}`, { method, signal: ctrl.signal });
52+
return await res.json().catch(() => ({}));
53+
} finally {
5454
clearTimeout(t);
55-
const data = await res.json().catch(() => ({}));
56-
return { ok: res.ok && data.ready !== false, error: data.error };
55+
}
56+
}
57+
58+
// Kick Tor off (non-blocking). Returns the helper's initial status, or
59+
// { error:'unreachable' } when the helper isn't running.
60+
async function startTorViaHelper() {
61+
try {
62+
return await helperJson('/start', 'POST');
5763
} catch (_) {
58-
return { ok: false, error: 'unreachable' };
64+
return { error: 'unreachable' };
5965
}
6066
}
6167

68+
// Poll the helper's bootstrap until ready / error / timeout, reporting live
69+
// progress (0..100) via onProgress. Returns { ready } or { error }.
70+
async function waitForTor(onProgress, timeoutMs = 180000) {
71+
const deadline = Date.now() + timeoutMs;
72+
while (Date.now() < deadline) {
73+
let st;
74+
try {
75+
st = await helperJson('/status', 'GET');
76+
} catch (_) {
77+
return { error: 'unreachable' };
78+
}
79+
if (typeof st.progress === 'number') onProgress(st.progress);
80+
if (st.ready) return { ready: true };
81+
if (st.error) return { error: st.error }; // tor exited with a reason
82+
await new Promise((r) => setTimeout(r, 1000));
83+
}
84+
return { error: 'tor-starting' };
85+
}
86+
6287
async function stopTorViaHelper() {
6388
try {
6489
const ctrl = new AbortController();
@@ -127,27 +152,24 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
127152
(async () => {
128153
if (msg.on) {
129154
const started = await startTorViaHelper();
130-
if (started.ok) {
131-
// The daemon bootstrapped → route through it and TRUST it. The
132-
// check.torproject.org probe is only a confirmation; a slow/blocked
133-
// probe must NOT tear down a working Tor session.
134-
await enableTor();
135-
const check = await checkTor();
136-
sendResponse({ enabled: true, started, check });
137-
} else if (started.error === 'unreachable') {
138-
// No control helper — maybe the user runs their own Tor. Try, but
139-
// here we DO require the probe to confirm before committing.
155+
if (started.error === 'unreachable') {
156+
sendResponse({ enabled: false, started: { error: 'unreachable' } });
157+
return;
158+
}
159+
if (started.error === 'tor-not-installed') {
160+
sendResponse({ enabled: false, started: { error: 'tor-not-installed' } });
161+
return;
162+
}
163+
// Poll bootstrap, pushing live progress to the sidebar's progress bar.
164+
const result = await waitForTor((pct) => {
165+
chrome.runtime.sendMessage({ type: 'tor-progress', pct }).catch(() => {});
166+
});
167+
if (result.ready) {
140168
await enableTor();
141169
const check = await checkTor();
142-
if (check.ok && check.isTor) {
143-
sendResponse({ enabled: true, started, check });
144-
} else {
145-
await disableTor();
146-
sendResponse({ enabled: false, started, check });
147-
}
170+
sendResponse({ enabled: true, check });
148171
} else {
149-
// tor-not-installed / tor-exited / spawn error → can't route.
150-
sendResponse({ enabled: false, started });
172+
sendResponse({ enabled: false, started: { error: result.error } });
151173
}
152174
} else {
153175
await disableTor();

apps/desktop/extensions/ai-sidebar/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "TronBrowser",
4-
"version": "3.2.7",
4+
"version": "3.2.8",
55
"description": "TronBrowser — privacy-first, AI-native. Branded new tab, private search, CoinPay login, and a bring-your-own-keys AI sidebar.",
66
"icons": {
77
"16": "icons/icon-16.png",

apps/desktop/extensions/ai-sidebar/sidepanel.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ header button { background: transparent; border: 1px solid var(--line); color: v
2828
.tor-status.ok { color: #6ee7a8; }
2929
.tor-status.warn { color: #ffcf6e; }
3030
.tor-status.hidden { display: none; }
31+
.tor-progress { height: 5px; background: #1b2540; overflow: hidden; }
32+
.tor-progress.hidden { display: none; }
33+
.tor-progress .bar { height: 100%; width: 0%; background: #7d4698;
34+
transition: width .35s ease; box-shadow: 0 0 8px rgba(125,70,152,.7); }
3135
.tor-status code { color: var(--cyan); }
3236
.messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
3337
.msg { padding: 8px 10px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; }

apps/desktop/extensions/ai-sidebar/sidepanel.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<button id="settings" title="Settings"></button>
1616
</header>
1717

18+
<div id="tor-progress" class="tor-progress hidden"><div id="tor-progress-bar" class="bar"></div></div>
1819
<div id="tor-status" class="tor-status hidden"></div>
1920

2021
<div id="messages" class="messages"></div>

apps/desktop/extensions/ai-sidebar/sidepanel.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ async function consumePendingQuery() {
147147
// routing, not Tor-Browser-grade — see docs/tor-onion-mode.md.
148148
const torBtn = el('tor');
149149
const torStatusEl = el('tor-status');
150+
const torProgressEl = el('tor-progress');
151+
const torProgressBar = el('tor-progress-bar');
150152

151153
function showTorStatus(kind, html) {
152154
torStatusEl.className = 'tor-status ' + kind;
@@ -156,6 +158,22 @@ function hideTorStatus() {
156158
torStatusEl.className = 'tor-status hidden';
157159
torStatusEl.textContent = '';
158160
}
161+
function showTorProgress(pct) {
162+
torProgressEl.classList.remove('hidden');
163+
torProgressBar.style.width = Math.max(0, Math.min(100, pct)) + '%';
164+
}
165+
function hideTorProgress() {
166+
torProgressEl.classList.add('hidden');
167+
torProgressBar.style.width = '0%';
168+
}
169+
170+
// Live bootstrap progress pushed from the background while connecting.
171+
chrome.runtime.onMessage.addListener((m) => {
172+
if (m && m.type === 'tor-progress') {
173+
showTorProgress(m.pct);
174+
showTorStatus('', `Connecting through Tor… ${Math.round(m.pct)}%`);
175+
}
176+
});
159177
function setTorButton(on) {
160178
torBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
161179
torBtn.textContent = on ? '🧅 Tor ON' : '🧅 Tor';
@@ -174,7 +192,10 @@ async function toggleTor() {
174192
const turningOn = torBtn.getAttribute('aria-pressed') !== 'true';
175193
torBtn.classList.add('busy');
176194
torBtn.disabled = true;
177-
if (turningOn) showTorStatus('', 'Connecting through Tor…');
195+
if (turningOn) {
196+
showTorStatus('', 'Connecting through Tor… (the first run can take up to a minute)');
197+
showTorProgress(0);
198+
}
178199
try {
179200
const res = await chrome.runtime.sendMessage({ type: 'tor-set', on: turningOn });
180201
const torBrowserNote =
@@ -195,7 +216,9 @@ async function toggleTor() {
195216
// Background couldn't route. Explain why, in plain language.
196217
setTorButton(false);
197218
const err = res && res.started && res.started.error;
198-
if (err === 'tor-not-installed') {
219+
if (err === 'tor-starting') {
220+
showTorStatus('', 'Tor is still connecting — the first run downloads the Tor network and can take a minute or two. Click 🧅 again in a few seconds; it’ll finish in the background.');
221+
} else if (err === 'tor-not-installed') {
199222
showTorStatus('warn', 'Tor isn’t installed yet. Run <code>tron tor</code> once (it installs Tor automatically), then try again.');
200223
} else if (err === 'unreachable') {
201224
showTorStatus('warn', 'Couldn’t reach the Tor helper. Restart TronBrowser and try again, or run <code>tron tor</code>.');
@@ -207,6 +230,7 @@ async function toggleTor() {
207230
setTorButton(false);
208231
showTorStatus('warn', 'Could not toggle Tor: ' + ((e && e.message) || e));
209232
} finally {
233+
hideTorProgress();
210234
torBtn.classList.remove('busy');
211235
torBtn.disabled = false;
212236
}
915 Bytes
Binary file not shown.

apps/desktop/launcher/tron-tor-helper

Lines changed: 72 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ the port is taken, so the launcher can fire-and-forget it every launch.
2121
import glob
2222
import json
2323
import os
24+
import re
2425
import shutil
2526
import signal
2627
import subprocess
@@ -33,15 +34,17 @@ PORT = int(os.environ.get("TRON_TOR_HELPER_PORT", "9061"))
3334
SOCKS_PORT = int(os.environ.get("TRON_TOR_SOCKS_PORT", "9071"))
3435
DATA_DIR = os.environ.get("TRON_TOR_DATA", os.path.expanduser("~/.tronbrowser/tor"))
3536
BUNDLED_DIR = os.environ.get("TRON_TOR_BIN_DIR", "")
36-
BOOTSTRAP_TIMEOUT = float(os.environ.get("TRON_TOR_TIMEOUT", "60"))
3737
PIDFILE = os.environ.get("TRON_TOR_PIDFILE", "")
3838
# Bumped whenever the helper protocol/behaviour changes; the launcher kills a
3939
# stale helper so the current version always runs.
40-
HELPER_VERSION = "3.2.7"
41-
40+
HELPER_VERSION = "3.2.8"
4241
_lock = threading.Lock()
43-
_proc = None # the running tor subprocess (or None)
44-
_ready = False # True once tor reported Bootstrapped 100%
42+
_proc = None # the running tor subprocess (or None)
43+
_ready = False # True once tor reported Bootstrapped 100%
44+
_ready_event = threading.Event()
45+
_tor_tail = [] # recent tor output, for error reporting
46+
_progress = 0 # latest bootstrap percentage (0..100)
47+
_error = None # set if tor exited before bootstrapping
4548

4649

4750
def log(msg):
@@ -79,17 +82,25 @@ def socks_port_open():
7982

8083

8184
def start_tor():
82-
"""Start tor and block until bootstrapped. Returns (ok, error)."""
83-
global _proc, _ready
85+
"""Ensure Tor is starting — NON-BLOCKING. Returns (ok, error). The caller
86+
polls /status for live `progress` and `ready`; Tor keeps bootstrapping in a
87+
background thread (a cold first run downloads the whole directory and can take
88+
a minute+), and is never killed for being slow."""
89+
global _proc, _error
90+
8491
with _lock:
85-
if _proc is not None and _proc.poll() is None and _ready:
86-
return True, None # already up (we started it)
87-
# Something already serving SOCKS on the port (tron tor / system tor)? Reuse
88-
# it — don't spawn a second tor that would fail to bind the port.
92+
alive = _proc is not None and _proc.poll() is None
93+
if alive:
94+
return True, None # already running (ready or still bootstrapping)
95+
96+
# Something else already serving SOCKS on the port (tron tor / our prior run)?
8997
if socks_port_open():
98+
_mark_progress(100)
9099
_set_ready(True)
100+
_ready_event.set()
91101
log("tor already running on %s:%d — reusing it" % (HOST, SOCKS_PORT))
92102
return True, None
103+
93104
with _lock:
94105
binary = tor_binary()
95106
if not binary:
@@ -102,12 +113,10 @@ def start_tor():
102113
except OSError as exc:
103114
return False, "datadir: %s" % exc
104115

105-
_ready = False
106116
env = os.environ.copy()
107-
# The Tor Expert Bundle ships its own libs (libevent/libssl/…) next to
108-
# the binary with no $ORIGIN rpath, so it needs its dir on the library
109-
# path. Detect a bundle by a sibling libevent — works wherever tor was
110-
# found; a system tor (no sibling libs) is left untouched.
117+
# The Tor Expert Bundle ships its own libs (libevent/libssl/…) next to the
118+
# binary with no $ORIGIN rpath, so it needs its dir on the library path.
119+
# Detect a bundle by a sibling libevent — works wherever tor was found.
111120
bindir = os.path.dirname(binary)
112121
if glob.glob(os.path.join(bindir, "libevent*")):
113122
for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"):
@@ -124,40 +133,42 @@ def start_tor():
124133
)
125134
except OSError as exc:
126135
return False, "spawn: %s" % exc
127-
128-
# Read tor's log (outside the lock) until it bootstraps or dies/timeouts.
129-
# Capture recent output so a failure reports WHY (perms, port in use, libs…).
130-
proc = _proc
131-
timer = threading.Timer(BOOTSTRAP_TIMEOUT, lambda: proc.poll() is None and proc.kill())
132-
timer.start()
133-
recent = []
134-
try:
135-
for line in proc.stdout:
136-
line = line.rstrip()
137-
if not line:
138-
continue
139-
recent.append(line)
140-
if len(recent) > 20:
141-
recent.pop(0)
142-
# Surface progress + anything that looks like a problem to the log.
143-
if "Bootstrapped " in line or "[warn]" in line or "[err]" in line:
144-
log(line)
145-
if "Bootstrapped 100%" in line:
146-
_set_ready(True)
147-
log("tor ready")
148-
return True, None
149-
# stdout closed before 100% → tor exited. If something is now serving the
150-
# SOCKS port (a system Tor / stale tor that beat us to it), reuse it
151-
# rather than reporting failure.
152-
if socks_port_open():
136+
_ready_event.clear()
137+
_error = None
138+
_mark_progress(0)
139+
del _tor_tail[:]
140+
threading.Thread(target=_read_tor, args=(_proc,), daemon=True).start()
141+
return True, None
142+
143+
144+
def _mark_progress(pct):
145+
global _progress
146+
_progress = pct
147+
148+
149+
def _read_tor(proc):
150+
"""Background: read tor's output for its whole lifetime; track bootstrap %."""
151+
global _error
152+
for line in proc.stdout:
153+
line = line.rstrip()
154+
if not line:
155+
continue
156+
_tor_tail.append(line)
157+
if len(_tor_tail) > 20:
158+
_tor_tail.pop(0)
159+
if "Bootstrapped " in line or "[warn]" in line or "[err]" in line:
160+
log(line)
161+
m = re.search(r"Bootstrapped (\d{1,3})%", line)
162+
if m:
163+
_mark_progress(min(100, int(m.group(1))))
164+
if "Bootstrapped 100%" in line:
153165
_set_ready(True)
154-
log("SOCKS port %d already serving — reusing the existing Tor" % SOCKS_PORT)
155-
return True, None
156-
tail = " ¦ ".join(recent[-6:]) or "(no output)"
157-
log("tor exited before bootstrapping: " + tail)
158-
return False, "tor-exited: " + tail
159-
finally:
160-
timer.cancel()
166+
_ready_event.set()
167+
log("tor ready")
168+
if not _ready_event.is_set():
169+
_error = "tor-exited: " + (" ¦ ".join(_tor_tail[-6:]) or "(no output)")
170+
log(_error)
171+
log("tor exited before bootstrapping: " + (" ¦ ".join(_tor_tail[-6:]) or "(no output)"))
161172

162173

163174
def _set_ready(value):
@@ -170,6 +181,7 @@ def stop_tor():
170181
global _proc, _ready
171182
with _lock:
172183
proc, _proc, _ready = _proc, None, False
184+
_ready_event.clear()
173185
if proc is not None and proc.poll() is None:
174186
proc.terminate()
175187
try:
@@ -181,7 +193,10 @@ def stop_tor():
181193
def status():
182194
with _lock:
183195
running = _proc is not None and _proc.poll() is None
184-
return {"running": running, "ready": running and _ready,
196+
return {"running": running or socks_port_open(),
197+
"ready": _ready_event.is_set(),
198+
"progress": _progress,
199+
"error": _error,
185200
"torInstalled": tor_binary() is not None,
186201
"version": HELPER_VERSION}
187202

@@ -200,8 +215,14 @@ class Handler(BaseHTTPRequestHandler):
200215
def _route(self):
201216
path = self.path.split("?", 1)[0].rstrip("/") or "/"
202217
if path == "/start":
218+
# Non-blocking: kick Tor off, then report live state. The caller polls
219+
# /status for `progress` and `ready`.
203220
ok, err = start_tor()
204-
self._send(200 if ok else 503, {"ready": ok, "error": err})
221+
st = status()
222+
st["started"] = ok
223+
if err:
224+
st["error"] = err
225+
self._send(200 if ok else 503, st)
205226
elif path == "/stop":
206227
stop_tor()
207228
self._send(200, {"stopped": True})

apps/desktop/launcher/tronbrowser

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ if [ "$TOR" != "1" ]; then
136136
# running helper isn't this version — otherwise leave a healthy current
137137
# helper alone (don't drop an active Tor session). All backgrounded so the
138138
# kill+settle never holds up the browser launch.
139-
HELPER_VERSION=3.2.7
139+
HELPER_VERSION=3.2.8
140140
(
141141
_pf="$DATA/tor-helper.pid"
142142
_rv="$(curl -fsS --max-time 1 http://127.0.0.1:9061/status 2>/dev/null | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')"

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tronbrowser/desktop",
3-
"version": "3.2.7",
3+
"version": "3.2.8",
44
"private": true,
55
"description": "Desktop shell for the TronBrowser Chromium fork",
66
"type": "module",

0 commit comments

Comments
 (0)