Peer connection events + Unbounded integration + manual port forward#471
Draft
myleshorton wants to merge 48 commits into
Draft
Peer connection events + Unbounded integration + manual port forward#471myleshorton wants to merge 48 commits into
myleshorton wants to merge 48 commits into
Conversation
Picks up getlantern/lantern-box#253 which bumped broflake to land getlantern/unbounded#360 (clientcore: classify QUIC connection errors). After this ships in a Lantern build, lantern.log will carry err_class structured-log fields on every "QUIC connection ended" record, distinguishing idle_timeout / handshake_timeout / application_close_remote / application_close_local / transport_error / stateless_reset / context_canceled / etc. The transitive broflake bump also picked up getlantern/unbounded#359 (integration test for connection migration), which is test-only. `go mod tidy` clean; pre-existing `cmd/lantern/lantern.go:78` ipc.NewClient build error reproduces on main and is unrelated. Co-authored-by: Adam Fisk <afisk@mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: preserve caller-supplied data dir; migrate v9.1.x stragglers `setupDirectories` (added in #370) unconditionally appended `/data` and `/logs` suffixes to the caller-supplied paths, even when the caller had already passed a fully-qualified directory. On Android, where `MainActivity.kt` calls `Mobile.initLogging(initConfigDir(), …)` and `initConfigDir()` returns `<application.dataDir>/.lantern`, this rewrote the radiance data dir from `.lantern` (used by v9.0.x) to `.lantern/data` (used by v9.1.x). The settings file path therefore moved silently between minor versions: v9.0.x → <app.dataDir>/.lantern/settings.json v9.1.x → <app.dataDir>/.lantern/data/settings.json On every existing install, the v9.1.x client failed to find the prior file, fell through to defaults (`UserLevelKey="free"`, no `UserIDKey`, no `DeviceIDKey`, no `JwtTokenKey`), generated a fresh device id, and called `UserRecoverByDevice` cold against the auth server. The user's local Pro state was effectively wiped on upgrade, surfacing as "Pro shown as expired" — which is exactly what the v9.1.5 China-Android tickets (#174455 / #174496 / #174515) reported. Auto-diagnosis on those tickets initially blamed cancelled Stripe checkouts; the actual cause was this path regression and the prior payment channel (Shepherd / AliPay / WeChat — not Stripe — for the vast majority of CN users). Two fixes: 1. setupDirectories now honors the caller's path as-is (the pre-#370 behavior). When the caller passes an empty string we still fall back to internal.DefaultDataPath() / DefaultLogPath(); the `maybeAddSuffix` helper is removed. 2. Settings init has a one-shot v9.1.x migration: on the first launch of the fixed client, if `<dataDir>/settings.json` is missing but `<dataDir>/data/settings.json` exists, the latter is copied up. This means v9.1.x users who'd already lost their v9.0.x state recover their freshly-minted v9.1.x identifiers on upgrade rather than getting wiped again. Quick stat check, no-op for the vast majority of installs that never had the bad nested file. Three sub-tests cover the recover, no-op, and canonical-already-present paths. Affects Android (confirmed via auto-diagnosis) and any other platform that passes a fully-qualified path through to `common.Init` — iOS via `lantern-core/ffi/ffi.go:114` and desktop via `cmd/lanternd/lanternd.go:328` use the same code. Refs Freshdesk #174455, #174515, #174496. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ask-migration: prefer the file whose user_level == 'pro' Prior version of the migration just preferred the canonical path when it existed. That was correct for the typical upgrade path (v9.0.x canonical=pro vs v9.1.x nested=expired) but wrong if the inverse ever happened — e.g., a Shepherd payment landed during the v9.1.x window and the nested file legitimately holds the pro state. Now the migration reads both files, compares their user_level, and prefers whichever actually says 'pro'. Falls back to canonical when both or neither have pro. Three additional sub-tests cover: - canonical-pro vs nested-expired (the broken-upgrade case) - canonical-expired vs nested-pro (rare inverse) - both-pro and neither-pro (canonical wins by tiebreaker) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ask-migration: also recover v9.0.x local.json (Derek's failing case) Derek's macOS test on the fix branch went 9.1.5 → fix (Pro restored ✓) but 9.0.25 → fix (Pro lost ✗). Investigation of #174542 logs shows 'Created new user' fired in (*fetcher).ensureUser at fetcher.go:135 because UserIDKey was 0 — the canonical settings.json didn't exist on his disk. Reason: v9.0.x's radiance (commit 2d39607) called the settings file `local.json`. The LocalBackend refactor in #370 renamed it to `settings.json`. So 9.0.25 wrote `<dataDir>/local.json`; the fix was looking only at `<dataDir>/settings.json` and the v9.1.x nested `<dataDir>/data/settings.json`. v9.0.x's good state at local.json was orphaned. The keys (UserIDKey, TokenKey, JwtTokenKey, UserLevelKey, etc.) are identical between v9.0.x and current — only the filename changed — so the file format is forward-compatible. The migration just needs to add `<dataDir>/local.json` as a third candidate path. Generalized the migration to consider all three candidates and pick whichever has user_level == "pro" first; if none has pro, prefer canonical → legacy → nested in that order so user identifiers survive even when Pro state is non-pro (losing identifiers creates server-side device-registration orphans, which matters more than losing the Pro string). Renamed migrateV91xSettingsIfNeeded → migrateLegacySettingsIfNeeded since it's no longer about a single version. Eight sub-tests cover: v9.0.x-only (Derek's case), v9.1.x-only, v9.0.x-pro-vs-v9.1.x-expired (chained upgrade), canonical-pro vs nested-expired, nested-pro-only, all-pro tiebreak, no-pro identifier-survival, and the empty-disk no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: distinguish ENOENT, atomic write the migrated file Address Copilot's two review comments on radiance#463: 1. Migration was treating any os.Stat error as "file not present," which masked permission/I/O errors on the canonical path. Now distinguishes errors.Is(err, fs.ErrNotExist) (expected, proceed) from other read errors (logged; if the canonical path itself is unreadable for non-ENOENT reasons, skip migration entirely so we don't try to write over a file the OS won't let us read). 2. writeMigrated used os.WriteFile, which is non-atomic and could leave a partially-written settings.json if the process crashed mid-write. Switched to atomicfile.WriteFile, the same mechanism the normal save path uses (writes to a temp file then renames). New sub-test exercises the unreadable-canonical case (skipped on windows where chmod 000 doesn't reproduce the same semantics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * settings: also migrate from pre-9.x flashlight/lantern-client YAML The fix in #463 covered the v9.0.x → v9.1.x → fixed upgrade path (rename of local.json → settings.json plus the bogus /data suffix). But Lantern users on the older flashlight + lantern-client stack (pre-9.0.x) wrote their state to a different file entirely: macOS ~/.lantern/settings.yaml Windows %APPDATA%\Lantern\settings.yaml Linux ~/.config/lantern/settings.yaml iOS <fileDir>/userconfig.yaml Anyone upgrading directly from v8.x (or anything with that schema) would skip both fixed paths and start fresh — same Pro-lost symptom Derek reproduced for the v9.0.x case. Adds a fourth candidate to migrateLegacySettingsIfNeeded that reads the platform-specific YAML, translates field names and types into the canonical settings.json schema, and feeds the translated JSON into the same priority-pick logic the existing JSON candidates use. Order: canonical > v9.0.x local.json > pre-9.x YAML > v9.1.x nested. The pre-9.x YAML beats the v9.1.x nested file because the nested file is known-bugged (fresh device id, possibly wrong user_id) while the YAML is real legitimate state. Field translation (desktop schema): userID → user_id deviceID → device_id userPro bool → user_level "pro" / "free" (only "free" if user_id is set; an anonymous yaml leaves it unset so the next /account/login decides) userToken → token emailAddress → email Field translation (iOS schema): UserID/DeviceID/Token only — iOS didn't persist user_level locally. Android is intentionally not handled here — its pre-9.x state lived in an encrypted SQLite (/data/data/.../files/masterDBv2/db) whose password lives in EncryptedSharedPreferences. Reading that requires Kotlin-side code in the lantern repo, not a pure-Go migration in radiance. Will be a separate change. Adds 6 new sub-tests covering both desktop and iOS layouts (pro, free-but-identified, anonymous, malformed, unknown layout, and the end-to-end iOS migration on darwin/ios). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix macOS path: ~/Library/Application Support/Lantern (not ~/.lantern) The pre-9.x desktop client used getlantern/appdir.General('Lantern') which on macOS routes to os.UserConfigDir() — i.e. ~/Library/Application Support/Lantern, NOT ~/.lantern. (Verified against lantern-client/desktop/app/settings.go:132 and appdir/appdir_darwin.go.) Linux is os.UserConfigDir() + lowercase 'lantern' (linux build tag in appdir.General does the lowercasing). Windows is os.UserConfigDir() + 'Lantern' which resolves to %APPDATA%\Lantern. Switched to os.UserConfigDir() across all three desktop platforms to match exactly. Also factored the path resolver into a package- level var legacyYAMLPathFn so tests can redirect the lookup at a tempDir without picking up the host machine's actual Lantern install (which is what was breaking the 'nothing on disk is a no-op' test on my macOS). Three new sub-tests cover the YAML migration path end-to-end: - pre-9.x desktop YAML recovered (Pro state translates correctly) - v9.0.x local.json beats pre-9.x YAML when both present - pre-9.x YAML beats v9.1.x bugged nested file Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * trim verbose comments Drop multi-paragraph philosophical asides and per-platform tables that duplicate what the code shows. Keeping only the non-obvious WHYs: - linux's lowercase appdir quirk - iOS sandbox path provenance - "free" fallback when userID is set - empty user_level on iOS ~90 fewer lines, no behavior change. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Adam Fisk <afisk@mini.local>
* deps: bump kindling for method-aware retry across transports raceTransport in kindling was retrying every request on 4xx/5xx and on post-RoundTrip transport errors, which meant non-idempotent endpoints (notably POST /peer/verify) were being replayed across transports — each replay observable on the server, with the first succeeding, deprecating its row, and subsequent replays 404'ing. Bump pulls in getlantern/kindling#32 which makes the retry behavior method-aware: - Non-idempotent (POST/PUT/DELETE/PATCH/OPTIONS): exactly one request fires once any transport connects; whatever happens is returned. - Idempotent (GET/HEAD): retain retry-on-5xx and retry-on-transport- error across transports for the legitimate "fronting CDN is being blocked" case. 4xx still short-circuits — the request itself is the problem and replay won't help. - Connection-establishment failures fall back regardless of method. Behavioral effect on radiance: /config-new (GET) — unchanged retry behavior. /peer/register, /peer/verify, /peer/heartbeat, /peer/deregister (POST) — single-shot, removing the multi-pod-deprecation race seen during peer-share testing on 2026-05-07. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * config: opt /config-new POST into kindling's idempotent retry Address Copilot review on PR #468: /config-new is a POST (config/fetcher.go:150), not a GET, so kindling's new method-aware retry made it single-shot — losing the cross-transport fallback that historically kept config fetches resilient when one fronting CDN returns 5xx. /config-new is semantically a read-only fetch despite the POST shape (body carries the client's last-known etag/version and metadata), so it's safe to retry. Set kindling.IdempotentHeader on the request to opt it back into raceTransport's GET/HEAD-style retry behavior. Pulls in the merge SHA of getlantern/kindling#33 which adds IdempotentHeader. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Adam Fisk <afisk@mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): add monitor TUI, throughput, and session history Adds a `lantern monitor` subcommand showing live VPN status, throughput, recent sessions, and errors, with q/Ctrl-C to quit. Supporting changes: - vpn: throughput tracker (global + per-outbound) and session history recorded across server changes; expose Bytes() and Throughput() on VPNClient - ipc: /vpn/throughput and /vpn/sessions endpoints; map net errors to ErrIPCNotRunning so clients can drive reconnect - cli: --json on status, ip, and servers list; promote servers/private flags to subcommands with positional args; `throughput` subcommand; --level and --grep filters plus reconnect on `logs` - backend: wire SessionHistory into LocalBackend; disconnect VPN on shutdown - AGENTS.md: expand code comment guidance and reinstate Go doc rules * test(vpn): cover session history and throughput tracker; preserve ipc dial error Wrap ErrIPCNotRunning with the underlying connection error so callers can errors.Is the sentinel while still seeing the network failure in the chain.
issue-report: add multipart attachment support
Plumb lantern-box's peerconn listener registry through to the radiance event bus so consumers (Flutter globe view, future abuse aggregation) can subscribe to a per-connection accept/close stream. Listener is registered after libbox.Start so the box's accept loop is already serving when notifications start flowing; cleared on Stop and in the Start rollback path so post-teardown callbacks land on a no-op rather than emitting events to a torn-down consumer. Source field carries the remote "ip:port" string verbatim from M.Socksaddr.String(); consumers extract the IP for geo-lookup or rate-limit attribution. Pinned to local lantern-box via a replace directive while the peerconn package is in flight; remove once lantern-box tags a release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a localhost HTTP endpoint exposing the active samizdat connection set as JSON, fed by the lantern-box peerconn listener registered when peer.Client.Start succeeds. Replaces the planned full Go→FFI→Dart event channel for the prototype with poll-driven Dart consumption — much smaller surface, same data shape, swap with a streaming FFI events path later without changing the Dart side. Loopback-only: net.Listen 127.0.0.1 enforces it at the kernel level, plus a defense-in-depth host check on each request in case someone later misconfigures RADIANCE_PEER_STATS_ADDR to a non-loopback bind. The endpoint reveals connected client IPs which we don't want surfaced beyond the local machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTTP endpoint at 127.0.0.1:17099/peer/connections was added to bridge
peer connection lifecycle to Flutter without writing FFI plumbing, but
two problems with that approach:
1. Detectability — a fixed loopback port is a Lantern-specific
fingerprint any local process (incl. malware) can probe. Sandboxed
adversary on the user's machine could detect Lantern is running.
2. Local server adds attack surface for free.
Reverting to ConnectionEvent emission only; Flutter consumption rides
on the existing FlutterEventEmitter / Dart api_dl bridge in lantern-core
(separate commit) which has no port footprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_PORT Many users can't or don't use UPnP — routers ship with UPnP off for security, ISP gateways without IGD, networks behind double-NAT — but can manually configure a port forward on their router. Today peer.Client fails to start in those environments because NewForwarder only knows how to talk UPnP IGDv2/v1. This adds a ManualForwarder that satisfies the peer.portForwarder interface without router interaction: - MapPort returns the configured port unchanged (1:1 NAT — every consumer router exposes port forwarding as a single port number, and splitting external/internal isn't a real-world use case) - UnmapPort and StartRenewal are no-ops (user owns the router rule) - ExternalIP probes a public-IP discovery service since no UPnP gateway is available to ask peer.Client.Start now reads RADIANCE_PEER_EXTERNAL_PORT at the NewForwarder factory; if set, ManualForwarder is used instead of UPnP discovery. Surfacing this as a proper user-facing setting (so non-engineers can configure it without env vars) is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_PORT
Adds settings.PeerManualPortKey so the user-facing Advanced UI can
persist the manual port forward without an env var. Resolution order
in peer.Client.Start's NewForwarder:
1. settings.PeerManualPortKey (Advanced UI in lantern Flutter)
2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / power-user)
3. UPnP discovery (default)
The setting is wired through lantern-core's
PatchSettings(PeerShareEnabledKey...) path on a separate branch — the
new `setPeerManualPort` FFI export over there calls
PatchSettings({PeerManualPortKey: <int>}) which lands in radiance's
settings store and gets picked up on the next peer.Client.Start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…467) * ci: open a PR for fronted refresh instead of pushing direct to main The scheduled refresh of kindling/fronted/fronted.yaml.gz was direct- pushing to main, which the repo's pull_request rule (ruleset "copilot-review") now rejects with GH013. Switch to peter-evans/ create-pull-request so the daily refresh produces a PR on a stable branch (chore/refresh-fronted-config) — successive runs supersede the prior unmerged refresh rather than piling up. Add pull-requests: write to the workflow's permissions block. Failing run for reference: https://github.com/getlantern/radiance/actions/runs/25478096992 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Adam Fisk <afisk@mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* vpn: tune base box options and route RU locale to Yandex DNS - drop explicit MTU=1500 to let sing-box pick the platform default - enable EndpointIndependentNat for QUIC migration and UDP hole-punching - enable StoreFakeIP and StoreRDRC so fake-IP and reject-cache survive restarts - drop RU from AliDNS locales; resolve via Yandex (77.88.8.8) instead * use Yandex DNS for RURU - normalized ru_RU * fix test
…tavism/payment-redirect
Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI)
WebRTC donor-mode integration. Self-contained package under
radiance/unbounded; drops cleanly onto the current branch without
needing the larger vpn/ refactor that adam/unbounded-widget-proxy
ships with.
unbounded.SetEnabled(bool) toggles the local opt-in
(settings.UnboundedKey). InitSubscription wires the manager to
config.NewConfigEvent — the broflake widget actually runs only when:
1. settings.UnboundedKey is true (local opt-in)
2. server cfg.Features[UNBOUNDED] is on
3. server provides cfg.Unbounded discovery + egress URLs
Each consumer connection change emits unbounded.ConnectionEvent on
the radiance event bus, mirroring the shape of peer.ConnectionEvent
so lantern-core subscribers can feed both into one Flutter event
stream.
Wired into LocalBackend.Start so the manager is live for the process
lifetime; sync.Once-guarded against double-subscribe.
Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy
(#336), with the package moved out of vpn/ since
that branch's vpn/ is undergoing a separate refactor and we want the
unbounded code to land independently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 tasks
account: forward payment redirect idempotency keys
The original manualPortForwarder (introduced in b81b6b0) returned "", nil from ExternalIP, leaning on lantern-cloud peer_handler's "external_ip empty → use observed" fall-through to fill the IP from the /v1/peer/register call's RemoteAddr. The refactor into portforward.ManualForwarder regressed this path by actively probing publicip.Detect, which fails on the only deployment where it matters: machines with Lantern's own VPN tunnel up. When the tunnel is up, outbound traffic routes through it. The publicip discovery endpoints (api.iantem.io etc) either time out or return the tunnel exit's IP rather than the user's WAN IP — neither is what we want. Worse, the failure surfaces as a hard error so peer.Client.Start aborts before /v1/peer/register is even called, breaking peer-share for every user with a manual port + an active tunnel. Empty is also more correct: peer_handler uses the IP that will actually receive inbound traffic on the manually-forwarded port (the register-call's RemoteAddr), which is by definition the right answer. Add a regression test pinning the empty-string contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes that pair with the lantern-cloud /peer/verify split:
1. peer/api.go: drop the leading /v1 from peer endpoint paths.
baseURL already ends with /api/v1 (from common.GetBaseURL), so
/v1/peer/register was hitting /api/v1/v1/peer/register on prod
and 404'ing. Every other radiance API caller appends without
/v1 (config/fetcher.go, issue/issue.go); peer/api.go was the
odd one out. Updated NewAPI's docstring to spell out the
convention.
2. peer/peer.go: after box.Start succeeds, call API.Verify(routeID).
The server's verifier dials back through the peer's external
port using the just-built creds, so the inbound has to be
listening before verify runs. Splitting verify out of register
resolves the chicken-and-egg where register-time verify could
never see a peer that didn't yet have its cert. Verify failure
here is fatal — the server has already deprecated the row, so
the deferred cleanup tears the rest of the session down.
3. peer/api.go: new API.Verify(ctx, routeID) wrapping POST
/peer/verify.
Tests: stubServer's mux handles the new /peer/verify route plus
verifyCount / verifyDeviceID / verifyStatus knobs. Existing tests
exercise the new step transparently because they use the default
verifyStatus=200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…every peer endpoint
peer/api.go was building requests with bare http.NewRequestWithContext,
skipping the X-Lantern-Config-Client-IP / X-Lantern-User-Id / version
header set that /config-new sends via common.NewRequestWithHeaders.
That mattered for /peer/register specifically: the server's
util.ClientIPWithAddr (lantern-cloud cmd/api/util/header.go:155-184)
prefers X-Lantern-Config-Client-IP over X-Forwarded-For and RemoteAddr
when resolving clientIP. With the header missing, the server fell back
to whatever its X-Forwarded-For chain produced — potentially a
different IP than the radiance-detected publicIP, leading the verifier
to dial back to an address the peer's listener wasn't bound to.
Switching to common.NewRequestWithHeaders makes peer endpoints
consistent with /config-new's header set:
- X-Lantern-Config-Client-IP (the key one for verify-dial targeting)
- X-Lantern-App-Version, X-Lantern-Version, X-Lantern-Platform,
X-Lantern-App, X-Lantern-User-Id, X-Lantern-Time-Zone, X-Lantern-Rand
DeviceIDHeader is set by NewRequestWithHeaders from settings; we
explicitly re-set it to a.deviceID afterward for parity with the
prior behavior in case the two ever diverge.
Adds TestAPI_ForwardsCommonHeaders which hits all four peer endpoints
against a stub server and asserts each carries the expected headers
(uses common.SetPublicIP / Cleanup to avoid leaking into other tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
defaultBuildBoxService used to call libbox.NewServiceWithContext with
the caller's bare ctx, which has no lantern-box protocol registries
plumbed in. The samizdat inbound type ServerConfig sends back from
/peer/register isn't a built-in sing-box protocol, so libbox's JSON
decoder couldn't resolve inbounds[0].type="samizdat" and returned
"missing inbound fields registry in context". The integration tests
stub BuildBoxService entirely, so this layer was never exercised in
CI — only surfaced live during the eero end-to-end test.
Two pieces:
1. Use box.BaseContext() (from getlantern/lantern-box) when calling
libbox.NewServiceWithContext. That ctx has the InboundOptionsRegistry
populated with samizdat / reflex / etc. so the decode succeeds.
Coexists with the user's VPN tunnel (vpn/tunnel.go) — libbox.Setup
is process-global, the ctx registries are per-box.
2. TestDefaultBuildBoxService_DecodesSamizdatInbound walks the actual
decode path with a minimal samizdat-inbound JSON. Verified to fail
with the exact production error message under the pre-fix code,
pass under the fix. Cuts the diagnostic loop from a 5-minute
rebuild+redeploy+toggle cycle to a 0.5s test failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* set max compressed size for issue report to 19 * change to 19.5MB
When the user toggles SmC off while real client traffic is flowing, box.Close fires per-connection disconnect callbacks for every in-flight inbound. peerconn.Notify reads its registered listener under an RLock and releases the lock before invoking — SetListener(nil) alone races against goroutines that have already snapshotted the listener (one per live connection). Each surviving callback hits events.Emit, which spawns yet another goroutine per subscriber. The Flutter-side subscriber posts main-thread tasks per event, and a hundred-task flood against an engine that's simultaneously handling the SmC-off state change reproduced as a Flutter mutex abort on the main thread. Add a sync/atomic flag the listener wrapper checks inline. Flip it before box.Close in both Stop and the Start-rollback defer; re-arm it at the top of Start so a Stop→Start cycle doesn't leave the wrapper muted. SetListener(nil) still runs for cleanliness, but the flag is what actually halts the cascade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The UI today sees a single active/inactive flip — toggling SmC on looks
"hung" through the multi-second sequence of port-forwarding, registering,
starting the local box, and verifying. This adds a Phase field to Status
and emits one StatusEvent per stage:
Start: mapping_port → detecting_ip → registering → starting_proxy →
verifying → serving
Stop: stopping → idle
on err: error (Status.Error populated with the wrapped fmt.Errorf
message, e.g. "map port 33445: upnp gateway refused mapping")
Phase is a stable string so Flutter / web consumers can switch on it
without depending on Go enum ordering. Active stays as a derived bool
(true only on PhaseServing) for subscribers that just want the binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "no globe arcs despite 200+ samizdat connections" pattern is
unobservable from current logs: peerconn.SetListener and events.Emit
don't log, so when the chain breaks between samizdat-in's Notify and
the Flutter bridge, there's no trace. This adds three breadcrumbs to
make the failure mode diagnosable on the next rebuild:
- "peer listener: registered with peerconn" — one line per Start that
confirms the listener actually got installed
- "peer listener: forwarding connection event" — one line per accept
AND per close; pairs with the lantern-core subscriber breadcrumb
so we can see if events bus delivers what the listener emits
- "peer listener: dropping post-Stop Notify" — DEBUG-level for the
race window the listenerDraining flag silences; makes that bucket
countable instead of silently discarding events
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The radiance peer listener fires (42 ConnectionEvents observed) but lantern-core's subscriber breadcrumb never fires, suggesting either Subscribe never ran or Emit is looking at a different subscriptions map. Logs the type key + subscriber count at every Emit so we can distinguish "no subscribers registered" (init bug) from "subscribers registered but callback panics" (rare, but possible). Uses stdlib log to avoid pulling slog into the events package (and a possible import cycle with slog-forwarding handlers that subscribe to events). Temporary diagnostic — should be downgraded to Debug or removed once the chain works end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The events package's globals are process-scoped — events.Emit in
lanternd (where radiance/peer runs) doesn't reach events.Subscribe in
Liblantern. Diagnostic at events.go showed subscribers=0 for every
peer.ConnectionEvent emit despite Subscribe being called.
Adds the cross-process bridge:
- New /peer/connection/events SSE endpoint (mirrors /peer/status/events).
peerConnectionEventsHandler buffers 64 events to absorb slow consumers
without backpressuring events.Emit; drops on overflow rather than
growing unbounded.
- Client.PeerStatusEvents(ctx, handler) and Client.PeerConnectionEvents(
ctx, handler) in both mobile and nonmobile client variants. Mobile
keeps the events.SubscribeContext path so in-process delivery still
works for builds that bundle radiance with the consumer; otherwise
falls through to SSE.
The peer-status SSE endpoint and handler were already there; this PR
just adds the matching client method so lantern-core can actually
consume it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lantern-box bumps samizdat to plumb the underlying TLS conn's RemoteAddr through serverStreamConn. With this, peer.ConnectionEvent emitted from the peerconn listener carries a real peer ip:port instead of the "client:0" placeholder, so the Dart Share My Connection UI can key globe arcs per actual peer (and arcs persist through real connection lifetimes instead of flickering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…events # Conflicts: # backend/radiance.go # go.mod # go.sum
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three related additions on top of the in-flight peer-share work (#460):
peer.ConnectionEventfires on every samizdat accept/close, fed by lantern-box's newtracker/peerconnlistener (lantern-box#255). Lantern-core subscribes and forwards via the existing FlutterEvent bridge so the UI globe can render arcs as remote clients connect.portforward.ManualForwarderfor users on networks where UPnP doesn't work (router has UPnP off, ISP gateway without IGD, double-NAT). Configured via either the new `settings.PeerManualPortKey` (UI-driven) or `RADIANCE_PEER_EXTERNAL_PORT` env var (developer override). Resolution order in `peer.Client.Start`: setting > env var > UPnP discovery.peer.ConnectionEventso consumers can feed both protocols into a single Flutter event stream.What's new vs. base branch
```mermaid
sequenceDiagram
autonumber
participant Box as lantern-box samizdat
participant Peer as radiance/peer
participant Bus as radiance/events
participant Unbnd as radiance/unbounded
participant Backend as LocalBackend
```
Test plan
Caveats
Related
🤖 Generated with Claude Code