lingling 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lingling/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Lingling -- official OpenCode, but your requests ride rotating Tor lanes."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ __version__ = "2.0.0"
8
+
9
+
10
+ def data_dir() -> Path:
11
+ """Per-user runtime state (tor, lanes, proof log). Lives outside the
12
+ package -- a pip install must never write into site-packages."""
13
+ override = os.environ.get("LINGLING_DATA_DIR")
14
+ if override:
15
+ return Path(override)
16
+ if sys.platform == "win32":
17
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
18
+ return Path(base) / "lingling"
19
+ if sys.platform == "darwin":
20
+ return Path.home() / "Library" / "Application Support" / "lingling"
21
+ return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "lingling"
lingling/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import entry
2
+
3
+ if __name__ == "__main__":
4
+ entry()
lingling/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """lingling -- official OpenCode, riding rotating Tor lanes.
2
+
3
+ CLI entrypoint: boots Tor lanes, starts the local relay, then execs opencode
4
+ with HTTPS_PROXY pointed at it; all other args pass through untouched.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from . import __version__, data_dir, proof
19
+ from .health import HealthDaemon
20
+ from .lanes import TorManager
21
+ from .relay import Relay
22
+
23
+ DATA_DIR = data_dir()
24
+ PROOF_LOG = DATA_DIR / "proof.log"
25
+
26
+ DEFAULT_COUNTRIES = ["us", "de", "nl", "fr", "ro", "gb", "ca", "se", "pl", "ch"]
27
+
28
+ _KITCHEN_LINES = [
29
+ "cooking the lanes", "baking it", "warming the exits",
30
+ "glazing the tunnel", "seasoning the circuits", "proofing the dough",
31
+ "preheating the relays", "tasting the traffic",
32
+ ]
33
+
34
+ _SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
35
+
36
+ _PHRASE_COLORS = ["38;5;215", "38;5;222", "38;5;180", "38;5;173",
37
+ "38;5;114", "38;5;109", "38;5;139", "38;5;175"]
38
+
39
+
40
+ def _c(text: str, code: str) -> str:
41
+ if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
42
+ return text
43
+ return f"\x1b[{code}m{text}\x1b[0m"
44
+
45
+
46
+ class _Loader:
47
+ """Self-rewriting status line; deliberately vibe-only, no lane counts or progress bar."""
48
+
49
+ def __init__(self) -> None:
50
+ self._stop = threading.Event()
51
+ self._thread: threading.Thread | None = None
52
+
53
+ def set(self, detail: str = "") -> None:
54
+ # Kept for API compatibility; details are intentionally not shown.
55
+ pass
56
+
57
+ def start(self) -> None:
58
+ if not sys.stdout.isatty():
59
+ return
60
+ self._thread = threading.Thread(target=self._run, daemon=True)
61
+ self._thread.start()
62
+
63
+ def _run(self) -> None:
64
+ tick = itertools.count()
65
+ while not self._stop.is_set():
66
+ t = next(tick)
67
+ spin = _c(_SPIN[t % len(_SPIN)], "1;38;5;220")
68
+ idx = (t // 24) % len(_KITCHEN_LINES)
69
+ msg = _c(_KITCHEN_LINES[idx], _PHRASE_COLORS[idx])
70
+ sys.stdout.write(f"\r\x1b[K {spin} {msg}")
71
+ sys.stdout.flush()
72
+ time.sleep(0.09)
73
+
74
+ def stop(self, final: str = "") -> None:
75
+ self._stop.set()
76
+ if self._thread:
77
+ self._thread.join(timeout=2)
78
+ self._thread = None
79
+ if sys.stdout.isatty():
80
+ sys.stdout.write("\r\x1b[K")
81
+ if final:
82
+ sys.stdout.write(final + "\n")
83
+ sys.stdout.flush()
84
+
85
+
86
+ def _parse_args(argv: list[str]) -> dict:
87
+ """Split lingling's own flags from args passed through to opencode."""
88
+ opts = {
89
+ "lanes": int(os.environ.get("LINGLING_TOR_COUNT", "5") or 5),
90
+ "no_tor": False,
91
+ "no_proof": False,
92
+ "proof_tail": None,
93
+ "demo": False,
94
+ "passthrough": [],
95
+ }
96
+ skip = False
97
+ for i, a in enumerate(argv):
98
+ if skip:
99
+ skip = False
100
+ continue
101
+ if a == "--proof":
102
+ opts["proof_tail"] = argv[i + 1] if i + 1 < len(argv) else ""
103
+ skip = True
104
+ elif a == "--demo":
105
+ opts["demo"] = True
106
+ elif a == "--lanes":
107
+ try:
108
+ opts["lanes"] = max(1, int(argv[i + 1]))
109
+ opts["lanes_explicit"] = True
110
+ except (IndexError, ValueError):
111
+ pass
112
+ skip = True
113
+ elif a == "--no-tor":
114
+ opts["no_tor"] = True
115
+ elif a == "--no-proof":
116
+ opts["no_proof"] = True
117
+ else:
118
+ opts["passthrough"].append(a)
119
+ return opts
120
+
121
+
122
+ def main(argv: list[str]) -> int:
123
+ opts = _parse_args(argv)
124
+
125
+ if opts["proof_tail"] is not None:
126
+ return proof.tail(Path(opts["proof_tail"] or str(PROOF_LOG)))
127
+
128
+ if opts["demo"]:
129
+ from .demo import run_demo
130
+ question = " ".join(opts["passthrough"]).strip() or (
131
+ "In one sentence: who are you, and what exit node do you think "
132
+ "this request came from?")
133
+ lanes = opts["lanes"] if opts.get("lanes_explicit") else 2
134
+ return run_demo(question, lanes=lanes)
135
+
136
+ if "--version" in opts["passthrough"] or "-v" in opts["passthrough"]:
137
+ print(f"lingling {__version__} (wraps opencode)")
138
+ return 0
139
+
140
+ opencode = shutil.which("opencode")
141
+ if opencode is None:
142
+ print("lingling: couldn't find `opencode` on your PATH.")
143
+ print("Install it first (https://opencode.ai) and re-run.")
144
+ return 1
145
+
146
+ loader = _Loader()
147
+ loader.start()
148
+ manager: TorManager | None = None
149
+ daemon: HealthDaemon | None = None
150
+ relay: Relay | None = None
151
+ direct = opts["no_tor"]
152
+
153
+ try:
154
+ if not direct:
155
+ manager = TorManager(
156
+ DATA_DIR, count=opts["lanes"],
157
+ exit_countries=DEFAULT_COUNTRIES,
158
+ tor_exe=os.environ.get("LINGLING_TOR_EXE", ""),
159
+ log=lambda *a: None,
160
+ )
161
+ loader.set()
162
+ err = manager.setup_lanes()
163
+ if err:
164
+ loader.stop(_c(f" !! tor unavailable ({err}) -- going direct",
165
+ "33"))
166
+ direct = True
167
+ else:
168
+ emit = proof.make_emitter(PROOF_LOG)
169
+ daemon = HealthDaemon(manager, event=emit,
170
+ log=lambda *a: None)
171
+
172
+ # Boot order: only lane 1 cooks in the foreground; the rest follow in background.
173
+ first = manager.lanes[0]
174
+ manager.start_lanes([first])
175
+
176
+ # Block until lane 1 provably carries traffic, else the user's first prompt dies.
177
+ deadline = time.time() + 150
178
+ while time.time() < deadline:
179
+ verdict = daemon.probe_lane(first)
180
+ if verdict == "healthy":
181
+ first.healthy = True
182
+ first.unhealthy_cycles = 0
183
+ break
184
+ if verdict == "burned":
185
+ first.burned_cycles += 1
186
+ daemon._heal_burn(first)
187
+ time.sleep(2)
188
+ if first.healthy is not True:
189
+ loader.stop(_c(" !! the kitchen stayed cold -- "
190
+ "going direct", "31"))
191
+ direct = True
192
+ manager.stop_all()
193
+ daemon.start()
194
+
195
+ if direct:
196
+ loader.stop()
197
+ print(_c("lingling: no lanes -- opencode rides your own IP.\n",
198
+ "33"))
199
+ return _run_opencode(opencode, opts["passthrough"], None)
200
+
201
+ emit = proof.make_emitter(PROOF_LOG)
202
+ relay = Relay(manager, event=emit)
203
+ port = relay.start()
204
+
205
+ # Per-request proof via local TLS termination; best-effort, falls back to blind tunnels.
206
+ ca_pem = None
207
+ if os.environ.get("LINGLING_NO_MITM", "").lower() not in ("1", "true"):
208
+ try:
209
+ from . import mitm
210
+ relay.cert_shop = mitm.CertShop(DATA_DIR / "mitm")
211
+ ca_pem = relay.cert_shop.ca_pem_path
212
+ except Exception: # noqa: BLE001
213
+ pass
214
+
215
+ loader.stop(_c(" served! lanes are hot -- proof is in the other "
216
+ "window.", "1;32"))
217
+ if not opts["no_proof"]:
218
+ proof.spawn_proof_window(PROOF_LOG)
219
+
220
+ # Remaining lanes bootstrap in background; health probes join them to rotation as they come up.
221
+ rest = manager.lanes[1:]
222
+ if rest:
223
+ def _cook_rest() -> None:
224
+ for lane in rest:
225
+ emit({"type": "lane", "kind": "heal", "t": time.time(),
226
+ "lane": lane.index, "cc": lane.exit_country,
227
+ "ip": "",
228
+ "msg": f"lane {lane.index} {{{lane.exit_country}}} "
229
+ f"registering in the background ..."})
230
+ manager.start_lanes(rest)
231
+
232
+ threading.Thread(target=_cook_rest, name="lane-cook",
233
+ daemon=True).start()
234
+
235
+ env = dict(os.environ)
236
+ for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"):
237
+ env[var] = f"http://127.0.0.1:{port}"
238
+ env["NO_PROXY"] = env["no_proxy"] = "localhost,127.0.0.1"
239
+ if ca_pem:
240
+ # Let opencode trust our local CA so we can log each model call.
241
+ env["NODE_EXTRA_CA_CERTS"] = str(ca_pem)
242
+ return _run_opencode(opencode, opts["passthrough"], env)
243
+ finally:
244
+ if daemon:
245
+ daemon.stop()
246
+ if relay:
247
+ relay.stop()
248
+ if manager and not direct:
249
+ manager.stop_all()
250
+ if not direct:
251
+ try:
252
+ proof.make_emitter(PROOF_LOG)(proof.DONE)
253
+ except Exception: # noqa: BLE001
254
+ pass
255
+
256
+
257
+ def _run_opencode(binary: str, args: list[str],
258
+ env: dict | None) -> int:
259
+ """Exec opencode with stdio inherited so it owns the terminal."""
260
+ try:
261
+ proc = subprocess.Popen([binary, *args], env=env)
262
+ except OSError as exc:
263
+ print(f"lingling: couldn't launch opencode: {exc}")
264
+ return 1
265
+ try:
266
+ return proc.wait()
267
+ except KeyboardInterrupt:
268
+ try:
269
+ proc.terminate()
270
+ proc.wait(timeout=5)
271
+ except Exception: # noqa: BLE001
272
+ pass
273
+ return 130
274
+
275
+
276
+ def entry() -> None:
277
+ sys.exit(main(sys.argv[1:]))
lingling/demo.py ADDED
@@ -0,0 +1,134 @@
1
+ """``lingling --demo`` -- fire one real Muse Spark request through a real
2
+ lane and show the receipts: which lane, which exit IP, and the reply.
3
+
4
+ Muse Spark lives only on the Responses API (``POST /zen/v1/responses``), not chat/completions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+
12
+ from . import data_dir, netutil
13
+ from .health import UPSTREAM_HOST, UPSTREAM_UA, HealthDaemon
14
+ from .lanes import TorManager
15
+
16
+ DEFAULT_COUNTRIES = ["us", "de", "nl", "fr", "ro", "gb", "ca", "se", "pl", "ch"]
17
+
18
+ MODEL = "muse-spark-1.2-contributor-free"
19
+ DATA_DIR = data_dir()
20
+
21
+
22
+ def _say(msg: str) -> None:
23
+ print(msg, flush=True)
24
+
25
+
26
+ def _extract_text(obj: dict) -> str:
27
+ """Pull the assistant text out of a Responses API reply."""
28
+ for item in obj.get("output") or []:
29
+ if not isinstance(item, dict):
30
+ continue
31
+ if item.get("type") == "message":
32
+ parts = []
33
+ for c in item.get("content") or []:
34
+ if isinstance(c, dict) and c.get("type") == "output_text":
35
+ parts.append(c.get("text", ""))
36
+ if parts:
37
+ return "\n".join(parts)
38
+ if isinstance(obj.get("output_text"), str):
39
+ return obj["output_text"]
40
+ return ""
41
+
42
+
43
+ def run_demo(question: str, lanes: int = 2) -> int:
44
+ manager = TorManager(DATA_DIR, count=lanes,
45
+ exit_countries=DEFAULT_COUNTRIES, log=lambda *a: None)
46
+
47
+ _say("== cooking the lanes (first run downloads tor, ~1-2 min) ==")
48
+ err = manager.setup_lanes()
49
+ if err:
50
+ _say(f" !! tor unavailable: {err}")
51
+ return 1
52
+ manager.start_all(on_lane=lambda lane, status: _say(
53
+ f" lane {lane.index} {{{lane.exit_country}}}: {status}"))
54
+
55
+ daemon = HealthDaemon(manager)
56
+ _say("\n== probing each lane against the real upstream ==")
57
+ deadline = time.time() + 150
58
+ ready = []
59
+ while time.time() < deadline:
60
+ for lane in manager.lanes:
61
+ if lane.healthy is not True:
62
+ verdict = daemon.probe_lane(lane)
63
+ lane.healthy = verdict == "healthy"
64
+ if verdict == "healthy":
65
+ _say(f" lane {lane.index} {{{lane.exit_country}}} up, "
66
+ f"exit IP {lane.exit_ip or '?'}")
67
+ elif verdict == "burned":
68
+ lane.burned_cycles += 1
69
+ daemon._heal_burn(lane)
70
+ ready = manager.healthy_lanes()
71
+ if ready:
72
+ break
73
+ time.sleep(2)
74
+ if not ready:
75
+ _say(" !! no lane came up -- cannot run the demo")
76
+ manager.stop_all()
77
+ return 1
78
+
79
+ lane = ready[0]
80
+ _say(f"\n== firing the request through lane {lane.index} "
81
+ f"{{{lane.exit_country}}}, exit IP {lane.exit_ip or '?'} ==")
82
+ _say(f" model: {MODEL}")
83
+ _say(f" you: {question}")
84
+
85
+ payload = {
86
+ "model": MODEL,
87
+ "input": [{
88
+ "role": "user",
89
+ "content": [{"type": "input_text", "text": question}],
90
+ }],
91
+ "stream": False,
92
+ "store": False,
93
+ "max_output_tokens": 4096,
94
+ }
95
+ t0 = time.time()
96
+ try:
97
+ code, body = netutil.https_via_socks(
98
+ lane.socks_port, UPSTREAM_HOST, "POST", "/zen/v1/responses",
99
+ UPSTREAM_UA, body=json.dumps(payload).encode(),
100
+ timeout=180.0)
101
+ except Exception as exc: # noqa: BLE001
102
+ _say(f" !! the lane dropped the request: {exc}")
103
+ manager.stop_all()
104
+ return 1
105
+ dt = time.time() - t0
106
+
107
+ if code == 429:
108
+ _say(" !! 429 from upstream -- the lane would now be re-cooked "
109
+ "(that's the rotation working)")
110
+ manager.stop_all()
111
+ return 1
112
+ if code != 200:
113
+ _say(f" !! upstream answered HTTP {code}: {body[:400]!r}")
114
+ manager.stop_all()
115
+ return 1
116
+
117
+ try:
118
+ obj = json.loads(body)
119
+ except json.JSONDecodeError:
120
+ _say(f" !! non-JSON reply: {body[:400]!r}")
121
+ manager.stop_all()
122
+ return 1
123
+
124
+ text = _extract_text(obj)
125
+ usage = obj.get("usage") or {}
126
+ _say(f"\n== {MODEL} answered through lane {lane.index} in {dt:.1f}s ==")
127
+ _say(f" exit IP seen by upstream: {lane.exit_ip or '?'}")
128
+ if usage:
129
+ _say(f" tokens: {json.dumps(usage)}")
130
+ _say("")
131
+ _say(text or "(empty reply)")
132
+ _say("")
133
+ manager.stop_all()
134
+ return 0
lingling/health.py ADDED
@@ -0,0 +1,222 @@
1
+ """Lane health daemon -- this is what makes rate limits invisible.
2
+
3
+ A TLS tunnel can't be inspected by the relay, so each lane periodically
4
+ probes the real upstream through its own SOCKS port and is healed (restarted
5
+ or regenerated from scratch) before your next request would have used it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ from typing import Callable, Dict, Optional
15
+
16
+ from . import netutil
17
+ from .lanes import Lane, TorManager
18
+
19
+ UPSTREAM_HOST = "opencode.ai"
20
+ UPSTREAM_PROBE_PATH = "/zen/v1/models"
21
+ # The free tier instantly 429s requests without this UA; a probe lacking it would call every lane burned.
22
+ UPSTREAM_UA = os.environ.get("LINGLING_UPSTREAM_USER_AGENT", "opencode/1.0")
23
+
24
+ PROBE_TIMEOUT = 15.0
25
+ # Consecutive failed cycles before a dead lane escalates from restart to regenerate.
26
+ _ESCALATE_AFTER = 2
27
+ # Consecutive 429 probes before cheap heals give way to rotating the exit country + regenerating.
28
+ _BURN_ESCALATE_AFTER = 3
29
+ # Min gap between regenerates; Tor bootstrap is 30-90s and re-rolling faster just keeps the lane booting.
30
+ _REGEN_COOLDOWN_S = 1200.0
31
+ # A sidelined lane gets one re-probe after this long; blocks do lift.
32
+ _SIDELINE_RECHECK_S = 3600.0
33
+ _FAST_FAIL_CYCLES = 4
34
+
35
+
36
+ class HealthDaemon:
37
+ def __init__(
38
+ self,
39
+ tor: TorManager,
40
+ check_interval: float = 45.0,
41
+ event: Optional[Callable[[Dict], None]] = None,
42
+ log: Optional[Callable[..., None]] = None,
43
+ ) -> None:
44
+ self.tor = tor
45
+ self.check_interval = check_interval
46
+ # ``event`` receives proof-log dicts ({"type": "lane", ...}).
47
+ self._emit = event or (lambda e: None)
48
+ self.log = log or (lambda *a, **k: None)
49
+ self._stop = threading.Event()
50
+ self._thread: Optional[threading.Thread] = None
51
+ self._warmup = True
52
+
53
+ def start(self) -> None:
54
+ if self._thread is not None and self._thread.is_alive():
55
+ return
56
+ self._stop.clear()
57
+ self._thread = threading.Thread(
58
+ target=self._loop, name="lane-health", daemon=True)
59
+ self._thread.start()
60
+
61
+ def stop(self) -> None:
62
+ self._stop.set()
63
+ if self._thread:
64
+ self._thread.join(timeout=5)
65
+ self._thread = None
66
+
67
+ def _loop(self) -> None:
68
+ while not self._stop.is_set():
69
+ try:
70
+ self.check_once()
71
+ except Exception as exc: # noqa: BLE001
72
+ self.log("health: cycle flamed out: %s", exc)
73
+ self._stop.wait(self.check_interval)
74
+
75
+ def probe_lane(self, lane: Lane) -> str:
76
+ """One probe round. Returns "healthy" | "burned" | "dead"."""
77
+ if not netutil.port_is_open("127.0.0.1", lane.socks_port,
78
+ timeout=netutil.PORT_CHECK_TIMEOUT):
79
+ return "dead"
80
+ try:
81
+ code, _ = netutil.https_get_via_socks(
82
+ lane.socks_port, UPSTREAM_HOST, UPSTREAM_PROBE_PATH,
83
+ UPSTREAM_UA, timeout=PROBE_TIMEOUT)
84
+ if code == 429:
85
+ return "burned"
86
+ if code == 0:
87
+ return "dead"
88
+ except Exception: # noqa: BLE001
89
+ return "dead"
90
+ # Lane is carrying traffic; fingerprint its exit IP for the proof pane.
91
+ try:
92
+ code, body = netutil.https_get_via_socks(
93
+ lane.socks_port, "check.torproject.org", "/api/ip",
94
+ UPSTREAM_UA, timeout=PROBE_TIMEOUT)
95
+ if code == 200:
96
+ obj = json.loads(body.decode("utf-8", "replace"))
97
+ if obj.get("IsTor") and obj.get("IP"):
98
+ lane.exit_ip = str(obj["IP"])
99
+ except Exception: # noqa: BLE001
100
+ pass
101
+ return "healthy"
102
+
103
+ def check_once(self) -> None:
104
+ for lane in self.tor.lanes:
105
+ if self._stop.is_set():
106
+ return
107
+ if lane.healing:
108
+ continue
109
+ if lane.sidelined:
110
+ self._maybe_revive(lane)
111
+ continue
112
+
113
+ verdict = self.probe_lane(lane)
114
+ if verdict == "healthy":
115
+ was = lane.healthy
116
+ lane.healthy = True
117
+ lane.unhealthy_cycles = 0
118
+ lane.burned_cycles = 0
119
+ if was is not True:
120
+ self._emit_lane(lane, "up",
121
+ f"lane {lane.index} {{{lane.exit_country}}} "
122
+ f"is cooking -- exit {lane.exit_ip or '?'}")
123
+ continue
124
+
125
+ lane.healthy = False
126
+ # Warmup grace: first failed probe on a live port means the first circuit is still building.
127
+ if self._warmup and netutil.port_is_open(
128
+ "127.0.0.1", lane.socks_port, timeout=netutil.PORT_CHECK_TIMEOUT):
129
+ continue
130
+
131
+ if verdict == "burned":
132
+ lane.unhealthy_cycles = 0
133
+ lane.burned_cycles += 1
134
+ self._heal_burn(lane)
135
+ else:
136
+ lane.burned_cycles = 0
137
+ lane.unhealthy_cycles += 1
138
+ self._heal_dead(lane)
139
+ self._warmup = False
140
+
141
+ def _heal_burn(self, lane: Lane) -> None:
142
+ """429 from upstream: lane is already out of rotation (caller set
143
+ healthy=False); re-cook from scratch, rotating exit country on repeat burns."""
144
+ lane.healing = True
145
+ try:
146
+ cooldown_left = (_REGEN_COOLDOWN_S
147
+ - (time.time() - lane.last_regenerate_at))
148
+ if lane.last_regenerate_at and cooldown_left > 0:
149
+ if lane.burned_cycles <= 1:
150
+ self._emit_lane(
151
+ lane, "burn",
152
+ f"lane {lane.index} hit a hidden limit -- parked "
153
+ f"while it cools, other lanes have your traffic")
154
+ return
155
+ self._emit_lane(
156
+ lane, "burn",
157
+ f"lane {lane.index} hit a hidden limit -- your traffic moved "
158
+ f"to a fresh lane; re-cooking this one from scratch")
159
+ if lane.burned_cycles >= _BURN_ESCALATE_AFTER:
160
+ new_cc = self.tor.rotate_exit_country(lane)
161
+ if new_cc:
162
+ self._emit_lane(
163
+ lane, "rotate",
164
+ f"lane {lane.index} keeps burning -- re-cooking on a "
165
+ f"new country {{{new_cc}}}")
166
+ lane.last_regenerate_at = time.time()
167
+ lane.burned_cycles = 0
168
+ self.tor.regenerate_lane(lane)
169
+ finally:
170
+ lane.healing = False
171
+
172
+ def _heal_dead(self, lane: Lane) -> None:
173
+ if lane.unhealthy_cycles >= _FAST_FAIL_CYCLES:
174
+ lane.sidelined = True
175
+ lane.last_sideline_at = time.time()
176
+ self._emit_lane(lane, "sidelined",
177
+ f"lane {lane.index} sat out (blocked exit) -- "
178
+ f"will retry later")
179
+ return
180
+ lane.healing = True
181
+ try:
182
+ if lane.unhealthy_cycles <= _ESCALATE_AFTER:
183
+ self._emit_lane(lane, "heal",
184
+ f"lane {lane.index} dropped -- poking it")
185
+ if not self.tor.restart_lane(lane):
186
+ self._emit_lane(
187
+ lane, "fail",
188
+ f"lane {lane.index} would not restart -- will "
189
+ f"re-cook it from scratch if it stays down")
190
+ return
191
+ cooldown_left = (_REGEN_COOLDOWN_S
192
+ - (time.time() - lane.last_regenerate_at))
193
+ if lane.last_regenerate_at and cooldown_left > 0:
194
+ return
195
+ lane.last_regenerate_at = time.time()
196
+ self._emit_lane(lane, "heal",
197
+ f"lane {lane.index} stayed down -- re-cooking "
198
+ f"from scratch")
199
+ if not self.tor.regenerate_lane(lane):
200
+ self._emit_lane(lane, "fail",
201
+ f"lane {lane.index} refused to re-cook")
202
+ finally:
203
+ lane.healing = False
204
+
205
+ def _maybe_revive(self, lane: Lane) -> None:
206
+ if time.time() - lane.last_sideline_at < _SIDELINE_RECHECK_S:
207
+ return
208
+ if self.probe_lane(lane) == "healthy":
209
+ lane.sidelined = False
210
+ lane.unhealthy_cycles = 0
211
+ lane.healthy = True
212
+ self._emit_lane(lane, "up",
213
+ f"lane {lane.index} revived -- back in the kitchen")
214
+ else:
215
+ lane.last_sideline_at = time.time()
216
+
217
+ def _emit_lane(self, lane: Lane, kind: str, message: str) -> None:
218
+ self._emit({
219
+ "type": "lane", "kind": kind, "t": time.time(),
220
+ "lane": lane.index, "cc": lane.exit_country,
221
+ "ip": lane.exit_ip, "msg": message,
222
+ })