Commit Graph

495 Commits

Author SHA1 Message Date
ospab 271a39c664 fix(icons): forcefully overwrite all android legacy and adaptive icons with logo_new.png 2026-07-11 20:59:45 +03:00
ospab 44f9067222 feat(icons): apply logo_new.png to all apps and watermarks 2026-07-11 00:58:44 +03:00
ospab dee0288f2a fix(release): rename -Switch param to -NewVersion (silently broke channel resolution)
A script parameter named exactly $Switch collides with PowerShell's `switch`
statement keyword - confirmed by bisection - and made every `$X = switch (...)
{...}` in the script silently evaluate to empty instead of erroring. This is
what produced the malformed "v0.4.1-.0" tag on the last release attempt
(Channel resolved to "" instead of "stable", Iteration to 0). Renaming the
parameter is the only fix; nothing else about the switch statement itself
was wrong.
2026-07-10 03:25:26 +03:00
ospab 10ec253fa0 chore: release v0.4.1-.0 on master 2026-07-10 03:17:41 +03:00
ospab cb59a5343f merge master: reconcile 3 commits pushed directly to master
master had UAC/SmartScreen fix, RTT/speed-display toggle, and a run-name CI
tweak that never made it back into alpha. Alpha already independently
contains equivalent (UAC fix is byte-identical) or superior (run-name
handles the newer alpha/beta/nightly channel scheme master's version
doesn't know about) versions of all three, so this merge is a pure
reconciliation - alpha's side wins on every conflicting hunk.
2026-07-10 03:14:59 +03:00
ospab b7bd8c20a5 docs: update CLI arguments to subcommands 2026-07-10 03:05:30 +03:00
ospab d725a4440b chore: gitignore netstack-smoltcp (vendored, already tracked separately) 2026-07-10 01:47:20 +03:00
ospab caab8698ba chore: remove dead ostp-license/frontend (143MB committed node_modules+dist)
No source ever existed in this tree for it - only a built dist/ and a
full node_modules/ dump (6200+ files), and nothing in the codebase
references "ostp-license" anywhere. Leftover from the old commercial-
license-gated era before the AGPLv3 switch; pure bloat since.

Also added **/node_modules/ to .gitignore - its absence is exactly how
this got committed in the first place.
2026-07-10 01:46:32 +03:00
ospab 3e9e8845f1 docs: remove nonexistent 'prober' entry from CLI reference
'ostp prober' was never a real subcommand - ostp-prober is a separate,
gitignored standalone tool, not part of the ostp binary's CLI surface
(no Prober variant in the Commands enum, no handler in main.rs). Also
fixed misaligned columns on the proxy-env/proxy-env-clear lines.
2026-07-10 01:44:01 +03:00
ospab e52087ee8e fix: restore LICENSE file to actual AGPL-3.0 text (was stuck on old BSL 1.1)
The repo switched to AGPLv3 back on 2026-06-18 (commit 9ce9e6d), and
Cargo.toml/README have said AGPL-3.0 ever since — but that license-change
commit was never carried forward into the 0.4.x rebuild branch, so the
actual LICENSE file silently reverted to the pre-rebuild BSL 1.1 text
(with a "converts to MIT in 2030" clause that hasn't applied for months).
Restored the real AGPLv3 text from 9ce9e6d.

Also added the missing `license` field to a few crate manifests that
didn't declare one (ostp-gui/src-tauri, ostp-jni, ostp-tun-helper), and
dropped the Tauri template placeholder authors/description.
2026-07-10 01:36:41 +03:00
ospab 2092f6c716 fix(flutter/android): surface getMetrics failures into the in-app log
Traced the whole traffic-counter pipeline (Dart -> MethodChannel ->
Kotlin -> JNI -> Bridge) end to end; it's architecturally identical to
the working desktop implementation, so no code-level bug was found.
Previously a getMetrics exception was only reported as a PlatformException
that Dart swallows with a bare debugPrint, invisible in the in-app log
viewer users actually have access to. Now it's also written to the
native log buffer via OstpClientSdk.addLog, so if the counter breaks
again the actual cause (exception vs. genuinely-zero atomics) shows up
in View Logs instead of requiring adb.
2026-07-10 01:23:28 +03:00
ospab 223f02287a feat(flutter): add optional live speed/RTT display, matching desktop GUI
Mirrors ostp-gui's "Show Speed" / "Show RTT" client settings toggles
(both default on): the home screen now shows live download/upload
throughput (computed from byte deltas between 1s polls, same as
desktop's poll()) as a subtitle under the existing cumulative
Download/Upload totals, and the RTT box is now hideable. Also fixed
"Test Ping" to actually query getMetrics instead of just faking a
500ms spinner with no real measurement.
2026-07-10 01:18:07 +03:00
ospab 1d1a1ea5af refactor: remove dead stealth_sni config field across the whole stack
stealth_sni was never actually consumed to construct any wire bytes —
verified dead in bridge.rs (only stored, never read). It implied
TLS/HTTP SNI mimicry that this project deliberately does not do
(zapret-like: packet-level DPI obfuscation only, no protocol
mimicry). Removed from the runtime schema (config.rs, bridge.rs),
both CLI/GUI local config shapes and their JSON templates, the
Flutter profile model/UI/share-link logic, and README feature docs.
migrate.rs now drops the field from legacy configs with a note
instead of carrying it forward.
2026-07-10 01:04:56 +03:00
ospab 1b3390a3cf fix(flutter): declutter profile editor, fix contrast bugs, unblock app list
- Profile edit dialog: moved junk packets + TCP fragmentation into their own
  modals (tap-to-configure), replacing 5 inline field rows with a compact
  2-button row. These are occasional/advanced settings, not something every
  profile edit needs to see up front.
- Profile card: subtitle repeated the server address verbatim whenever a
  profile had no custom name (name falls back to serverAddr) — showing
  "1.2.3.4:50000" as both title AND subtitle, with transport mode tacked on
  the end of the second copy. Now only shown once; added maxLines/ellipsis
  so long addresses truncate instead of wrapping awkwardly.
- Mobile: removed the "Bypass Processes" field entirely (editor UI, prefs
  key, config JSON). Android per-app selection (Configure Split Tunneling)
  is the real, correct control here — a process-name text field doesn't map
  to anything meaningful on Android the way it does on desktop.
- Share icon changed from a QR icon (redundant — the modal already shows a
  QR code) to the standard Material share glyph. Share modal title no
  longer interpolates the profile's name, which — same root cause as
  above — can silently BE the raw server address; title is now generic
  ("Share Profile") so a screenshot/recording can't leak host:port through it.
- Contrast: the monochrome theme's colorScheme.primary is pure white
  (0xFFFFFFFF); several buttons hardcoded white text/icons on top of it
  (Bypass/Proxy mode toggles, Copy Link), making them invisible when active.
  Added an _onColor() helper (luminance-based black/white pick) and applied
  it everywhere a button's foreground sits on a theme color.
- "Configure Split Tunneling" appeared to hang for 10-15s before doing
  anything: MainActivity.kt's getInstalledApps handler enumerated every
  installed package AND decoded+re-encoded each one's icon synchronously
  inside the MethodChannel callback, which runs on the main/UI thread by
  default — blocking it for the whole duration meant Flutter couldn't
  render ANY frame, not even the loading spinner, until it finished. Moved
  the work onto a background Thread; only the final result.success() hops
  back via runOnUiThread(). Navigation + spinner now show immediately.
2026-07-10 00:45:30 +03:00
ospab 7d9e5faeec fix(flutter): eagle watermark rendered as a flat gray square
assets/logo.png had NO real alpha transparency — both the background and
the eagle shape were fully opaque (A=255 everywhere), just baked in as
near-black (3,3,3) vs near-white (253,253,253) RGB. Applying `color:
Colors.white` to tint it painted the WHOLE bounding square white (alpha
being 255 across the entire image gives BlendMode nothing to mask against),
which at low Opacity looked like a flat gray square instead of a silhouette.

Converted the asset in place: since it was already grayscale (R=G=B), each
pixel's luminance became its new alpha channel, RGB set to pure white. The
background (near-black, low luminance) is now near-transparent; the eagle
(near-white, high luminance) is now near-opaque. This is the same effect the
desktop GUI gets for free from its logo.svg (a vector eagle path with no
background element at all — inherently transparent), just reproduced for a
raster asset without adding flutter_svg as a new dependency.

The `color: Colors.white` tint in both watermark call sites is now
redundant (the asset is already a pure-white silhouette) and removed.
2026-07-10 00:24:28 +03:00
ospab eda2a0eba7 fix(flutter): increase watermark opacity and apply to settings 2026-07-10 00:09:55 +03:00
ospab 22c2d5edd8 feat(flutter): update UI theme to monochrome with eagle watermark 2026-07-09 23:57:58 +03:00
ospab fa7ec2cd9a feat(flutter): update launcher icons to new eagle design with round support 2026-07-09 23:52:37 +03:00
ospab 794ea5251b refactor(ci): target_version + per-channel iteration in gha.ps1
Previous scheme conflated "which release is this" with "how many times has
it been rebuilt": every run bumped the patch version, so by the time a build
was ready to promote to master the version number had already crept forward
by however many alpha/beta iterations it took to get there.

Now a release cycle has one fixed target version (e.g. 0.4.1) that stays in
every manifest unchanged through all alpha/beta iterations; only a
per-channel counter increments, and that counter lives ONLY in the git tag,
never in Cargo.toml:

  v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.N
  v0.4.1-beta.1  -> v0.4.1-beta.2  -> ... -> v0.4.1-beta.N
  v0.4.1                                          <- master, iteration dropped

Deliberately "0.4.1-alpha.N" (dot AFTER the hyphen — a semver pre-release
identifier), not "0.4.1.N-alpha" (a 4th dot component before the hyphen):
the latter isn't valid semver and Cargo's version parser rejects it outright,
so it can never appear in Cargo.toml. That's also why the target version
itself never needs to change on a plain iteration — bumping every manifest +
refreshing both Cargo.locks is now skipped entirely unless -Switch actually
changes the target, making a routine alpha/beta push fast (just the state
file's counter + a tag).

Also fixes a real bug found while touching this: release.yml's push trigger
is tags-only ("v*") with no branch trigger, so the old `git push origin
$branch`-only path for alpha/pre-release never actually started a CI run —
only the master path (which already pushed a tag) worked. Every channel now
always pushes a real tag, which is what actually triggers the build.

release.yml's resolve-channel needed no changes: its tag-channel detection
already does substring matching (*-alpha*/*-beta*), so it classifies
"v0.4.1-alpha.37" correctly without modification.

-Prefix is gone — channel was always 1:1 with -Branch (alpha/pre-release/
master), so it was a redundant, independently-settable axis that could
silently drift from the branch (e.g. -Branch alpha -Prefix beta).
2026-07-09 23:42:27 +03:00
ospab c6d506e6c0 feat(flutter): profiles + per-profile junk/frag, drop WSS
Merges the best of both lineages instead of a blind revert to v0.3.21:
kept from v0.3.21: multi-profile management (add via QR scan/link/manual,
single-select active profile, auto-mode transport/MTU probing). Kept from
current: Share Config (QR generation), Check for Updates, curated stealth-SNI
domain list, and actually-rendered exclusions fields (v0.3.21 loaded/saved
them but never showed them in the UI — dead code).

New: junk packets (pc/ps min/max) and TCP fragmentation (chunk/sleep) are now
per-profile fields in the profile editor, mirroring the desktop GUI's profile
object shape 1:1 (ostp-gui/src/main.js) so behavior matches across platforms.

WSS is gone — removed from the model, the UI, and the config builder. The
core dropped TLS-mimicry transports entirely (see §A of the rebuild), so
there was nothing left for it to configure.

Config building now targets the flat single-server schema
(ostp_client::config::ClientConfig) built from ONE active profile, not the
old modular inbounds/outbounds/urltest-failover config — the core no longer
supports connecting to multiple servers at once, matching how the desktop
GUI already works (single activeId). Also dropped a dead nested "tun": {...}
object that neither version's JSON producer nor the real ClientConfig struct
ever actually used — serde silently ignored it.
2026-07-09 23:42:10 +03:00
ospab 61091b6d56 chore(release): 0.4.7 + fix resolve-channel prerelease labelling
resolve-channel treated EVERY v* tag as stable, so v0.4.6-beta got published
as a non-prerelease "Latest" release, sitting on top of the release line. Now
a pushed tag is used as-is and its suffix decides the channel: v*-alpha / v*-beta
are prereleases, only a bare vX.Y.Z is stable. (A tag is never recomputed from
Cargo.toml, so the release can't upload to a different tag than the one pushed.)

Version bumped 0.4.5 -> 0.4.7 (0.4.6 is already taken by the mislabelled beta).
This commit's tip is what gets tagged v0.4.7-beta to cut the beta build.
2026-07-09 14:58:40 +03:00
ospab aa1c4ccd52 chore: ignore AI agent instructions directory 2026-07-09 14:53:11 +03:00
ospab 5ab6833eab feat(core): time-rotating junk marker — kill the static per-user fingerprint
The junk marker was a per-key CONSTANT sent in plaintext at a fixed offset in
junk frames. Junk is meant to look like random noise (zapret-style), but a
constant prefix is a recognizable per-user structure: an on-path observer
watching one user sees the same 4 bytes on every junk packet, i.e. an OSTP
fingerprint. (The earlier fix only removed the GLOBAL constant.)

Now the marker rotates every 60s window: junk_marker = HKDF(key, ver, 0x04 ||
window). To an observer the prefix changes each window (no fixed signature),
and a captured marker is only valid for ~1 window — the "bit of protection"
against a leaked marker. Only a key holder can compute it, so an outsider still
can't forge a silently-dropped junk packet (and silent-drop is cheaper than
normal processing anyway, so junk spam was never a DoS lever to begin with).

- core: derive_junk_marker(key, window) + current_junk_window() (60s window),
  same version-gated HKDF scheme; junk_marker dropped from DerivedSecrets.
- client: stamps junk with the current window's marker.
- server: checks current AND previous window per key (absorbs ~1 window of
  clock skew) before falling through to unauthorized-probe handling.
- Not a wire break: only junk framing changes; real handshake/data untouched.
  During mixed rollout, unmatched junk merely logs as a probe (cosmetic).
2026-07-09 14:43:40 +03:00
ospab 5dc3a60017 refactor(logging): consolidate all logs into one ostp.log, Windows clears on start
Every process (CLI daemon, GUI, TUN helper) and every subsystem (tracing, the
core event logger, the helper IPC, panic hook) wrote its own file: ostp-cli.log
+ ostp-core.log + ostp-helper.log + ostp-crash.log — a pile per run. Now they
all funnel into a single ostp.log next to the exe.

- logging: LOG_FILE_NAME/log_file_path() as the one source of truth; init_tracing
  gains a `truncate` arg. Truncation is gated twice: Windows-only (cfg!(windows))
  AND daemon-only. One-shot commands (gk/check/init/-V/...) and the elevated TUN
  helper pass truncate=false so they can never wipe a running daemon's log;
  invocation_is_daemon() detects the daemon from argv. On Linux the server always
  appends (history kept, OS-rotated) as requested.
- runner/helper manual writers + panic hook now target log_file_path(), so their
  output lands in the same ostp.log instead of separate files.
2026-07-09 14:32:08 +03:00
ospab a33e5d3874 ci: fix invalid rust toolchain name 2026-07-09 14:30:23 +03:00
ospab e21acc2ee1 docs: update references from nightly to alpha 2026-07-09 02:49:33 +03:00
ospab 1568db3323 ci: rename nightly to alpha 2026-07-09 02:46:47 +03:00
ospab edb2d8e229 ci: fix powershell encoding error 2026-07-09 02:45:00 +03:00
ospab d609a3e883 ci: fix powershell parse error caused by utf-8 em-dash 2026-07-09 02:44:11 +03:00
ospab 43914055b3 ci: fix branch name in gha.ps1 2026-07-09 02:43:46 +03:00
ospab 3df5d5fccf ci: remove push branches to save GHA minutes, update script to beta 2026-07-09 02:43:07 +03:00
ospab 3d531ee0d9 fix(gui): fix light theme invisible text/borders 2026-07-09 02:39:19 +03:00
ospab 2819e2b3c2 feat(gui): add junk and tcp fragmentation global settings 2026-07-09 02:35:21 +03:00
ospab 244d3ad374 ci: update GHA run-name format for releases 2026-07-09 01:25:46 +03:00
ospab b89c6b0950 ci: update GHA run-name format for releases 2026-07-09 01:24:43 +03:00
ospab 992c212c76 feat(gui): make RTT and speed display optional 2026-07-08 23:59:35 +03:00
ospab 6361a87072 feat(gui): make RTT and speed display optional 2026-07-08 23:12:21 +03:00
ospab fbc37e9d39 fix(gui): fix UAC SmartScreen and scrolling UI layout 2026-07-08 22:50:21 +03:00
ospab db581ca391 fix(gui): fix UAC SmartScreen and scrolling UI layout 2026-07-08 22:45:03 +03:00
ospab a547ebff17 fix(ci): quote run-name — unquoted colon broke YAML parsing on every push
The run-name expression contains the GHA string literal 'Release build: {0}'
— an unquoted YAML plain scalar treats ": " as starting a nested mapping,
which invalidated the entire workflow file at parse time (before any job
runs). Every push since that line was introduced failed instantly with
"Invalid workflow file ... line 11", silently burning an Actions-minutes
run each time for nothing. Wrapping the whole expression in double quotes
fixes it — verified with `npx js-yaml` that the file now parses and the
run-name value round-trips intact.
2026-07-08 22:23:01 +03:00
ospab d065f6ceca chore: release 0.4.4-nightly on nightly 2026-07-08 19:13:55 +03:00
ospab d822f48891 refactor(config): one canonical config.json schema for client/server/relay
All three on-disk config.json shapes (client, server, relay) used to be
declared locally inside ostp/src/main.rs, invisible to any other consumer —
which is exactly how ostp_client::migrate ended up matching against loosely
typed serde_json::Value instead of a real schema, with no guarantee its
hand-built output actually matched what the CLI parser expected.

Moved every one of those definitions (AppMode, UnifiedConfig, ServerConfig,
RelayServerConfig, ClientFileConfig, TunConfig, ExcludeConfig, MuxConfig,
TransportConfigRaw, ApiConfig, FallbackCfg, ListenConfig, UserConfig) into
ostp_client::config — the same file that already held the runtime
ClientConfig/OstpConfig/etc. main.rs now imports them instead of
re-declaring them (`ClientFileConfig as ClientConfig` to avoid colliding
with the runtime ClientConfig, which stays separate on purpose: it's the
engine's internal shape — handshake/io timeouts and the like a user never
sets in config.json — built FROM one of these via the mapping in
run_client_directly, not the same thing).

ServerConfig.dns is now Option<serde_json::Value> rather than
Option<ostp_server::dns::DnsConfig> — ostp-client doesn't (and shouldn't)
depend on ostp-server just to name that type. main.rs, which already depends
on both crates, deserializes it right before handing it to run_server().

cmd_migrate now proves its output against this schema before ever writing
to disk (serde_json::from_value::<UnifiedConfig>(migrated)) — a migrator/
schema drift is now a hard error at migrate time, not a corrupted
config.json discovered later. Added a matching unit test
(every_migrated_output_matches_the_canonical_schema) that exercises this
same check on all three migration paths (modular, legacy-flat, server).
2026-07-08 18:58:34 +03:00
ospab 26665a826f feat(client): one authoritative config migrator, manual-only
- fix(cli): stop printing the startup banner ("ostp-cli vX.Y.Z | OS: ...")
  to stderr on every single command invocation. init_tracing() ran
  unconditionally before command dispatch, so `ostp -V`, `ostp gk`, etc. all
  showed it. It's still written to the log file (useful there), just no
  longer echoed via the stderr tracing layer for one-shot commands.

- feat(client): add ostp-client::migrate, the ONE place config migration
  runs. Previously there were three uncoordinated migration paths: a Python
  snippet embedded in scripts/install.sh (only touched server api.* fields,
  ran on every update), the old 0.3.x line's auto-migration on every hot
  reload (silent besides a log warning), and nothing at all for the current
  rebuild. Consolidated into one module covering every config shape that's
  actually existed:
    - v0.3.1-v0.3.21 modular (inbounds/outbounds/routing) -> current flat
      schema, including correctly resolving routing.default_outbound through
      a urltest/selector group to the real server, and reporting (not
      silently dropping) every additional server a multi-server config had.
    - pre-0.3.1 flat configs carrying now-dead fields (tun.wintun_path,
      tun.ipv4_address, transport.wss) -> dropped with an explicit reason,
      everything else passes through untouched.
    - server configs -> backfills api.* defaults and drops legacy api.token
      (ported straight from the install.sh Python, same behavior, correct
      place).
  6 unit tests cover all of the above against realistic fixtures. Wired up
  as `ostp migrate` (was missing from Commands entirely) — no other code
  path calls into this module, so a config's shape only ever changes when
  explicitly asked.

- feat(cli): `ostp import <url>` now asks the same TUN/mux/debug questions
  `ostp connect <url>` always did. Previously import just wrote flat
  defaults to disk with no way to turn any of that on short of hand-editing
  the resulting config.json afterward. Extracted the shared prompt into
  prompt_client_options() so both paths stay in sync.

- chore(install): remove the embedded Python config-migration snippet from
  install.sh; schema migration must never happen implicitly during an
  install/update. Points users at `ostp migrate` instead.
2026-07-08 18:45:04 +03:00
ospab 7b43e1dcf7 ci: prefix rolling-channel release tags with "v" for consistency
Stable releases were already "vX.Y.Z" (from the actual git tag pushed for
that channel), but nightly/beta tags were bare "X.Y.Z-nightly"/"X.Y.Z-beta"
— inconsistent with the v-prefixed convention used everywhere else. Every
channel's tag now starts with v: "vX.Y.Z-nightly", "vX.Y.Z-beta", "vX.Y.Z".
2026-07-08 18:20:45 +03:00
ospab b17e5499eb ci: fix run-name to say "channel" instead of a bare branch name
run-name showed "release version nightly" / "release version pre-release"
for every branch-push run — read exactly like the release TAG was bare
"nightly"/"pre-release" (the bug fixed earlier), when the actual GH Release
tag has been correctly versioned (e.g. "0.4.3-nightly") all along via
resolve-channel. run-name can't reference job outputs (it's evaluated before
any job runs), so it can't show the real computed tag directly — spell out
"channel" instead so the label can't be mistaken for the release tag again.
2026-07-08 18:18:11 +03:00
ospab ec947ec9d1 chore: release 0.4.3-nightly on nightly 2026-07-08 18:13:37 +03:00
ospab 0ec09d1311 ci: add scripts/gha.ps1 — versioned release cutter with channel memory
Replaces ad-hoc manual tag pushes (which is how the confusing v0.4.1-beta /
v0.4.2-beta / bare "nightly" / "pre-release" release mess happened) with one
script that always goes through the same path: bump every version manifest,
commit, and push in the way release.yml's resolve-channel job actually
expects (branch push for nightly/pre-release, a real "vX.Y.Z" tag for
master — never a hand-pushed "vX.Y.Z-beta"-style tag).

Remembers the last {version, branch, prefix} used in .release-state.json, so
a bare run repeats last time's channel with the patch version bumped, and
-Switch starts a new version line (e.g. 0.3.x -> 0.4.0) without disturbing
which channel is currently being released to.
2026-07-08 18:05:01 +03:00
ospab f81610f939 chore: bump version to 0.4.2 2026-07-08 17:28:14 +03:00
ospab 114011df5a docs: add commit conventions and branch strategy to CONTRIBUTING
- New "Commit Message Conventions" section (type(scope): summary + a body
  only when the why isn't obvious from the diff) — formalizes the style
  already used across this rebuild's history.
- New "Branch Strategy" section documenting the nightly -> pre-release ->
  master promotion model (pre-release/master are fast-forward-only,
  never committed to directly).
- Fixed PR/branch-creation instructions that still said "target master" /
  "branch from master" — contributor work targets nightly now.
- Clarified the ostp-control build step is optional for day-to-day
  core/client/server work (the server embeds a dummy dist/ otherwise).
2026-07-08 17:28:10 +03:00
ospab f96daaf57d feat(client): auto-reconnect on network change or any subsystem drop
run_client_core previously ran once: if the OSTP protocol connection, the
TUN device, or the local proxy listener ended for any reason (network
change stranding the socket/adapter on a dead interface, a transient
crash, a drop the inner Bridge-level "TunnelStopped" retry couldn't
recover from), the whole client returned/errored and just stayed down.

Wrapped the existing body (now run_client_once) in an outer supervising
loop: any non-shutdown-requested exit triggers a full clean restart —
fresh DNS resolution, fresh Bridge, fresh TUN/proxy — with backoff
(1/2/5/10/20/30s, resetting once a run has been stable for 60s). Only an
explicit shutdown request stops the loop. connection_state reports
"connecting" during the retry wait so the UI shows reconnecting, not
disconnected.
2026-07-08 17:28:05 +03:00