Follow-up to #79: the DSN it shipped with cannot work, and the native path it enabled was unscrubbed - #80
Merged
Merged
Conversation
The gate ran after Sentry.init, which installs both top-level process handlers before it ever looks at the DSN — and nothing takes them off again, since Sentry.close() disables the client but leaves the listeners. A client that could never transmit therefore kept owning both fatal paths, with the warn-mode rejection handler printing raw unredacted reasons into the stderr teed to host.log. hasNumericProjectId now decides ahead of init, so a refusal installs no client and no listener; the test pins the listener counts. CI baked in secrets.SENTRY_DSN, the app's slug-project DSN the JS SDK refuses outright, so every desktop bridge shipped inert. The builds now read SENTRY_DSN_BRIDGE, which does not exist yet — until it does, reporting stays off loudly rather than silently. tracesSampleRate, spotlight and debug are pinned because getClientOptions fills each from the ambient environment, and the host inherits its environment from whatever spawned it. SENTRY_SPOTLIGHT would fan every envelope to a second loopback destination and SENTRY_TRACES_SAMPLE_RATE would emit transactions, which beforeSend never sees. Also: Modules excluded and event.modules dropped (it walks up from a cwd the host did not choose); redactNullable no longer throws on a null, which beforeSend would swallow into a dropped event; redactDeep uses fromEntries so a __proto__ key cannot silently delete its sibling; debug_meta code_file redacted; a failed first-project open is captured before its bare process.exit; startControlPlane moved below the handler registration, since host.json on disk lets the app drive project:open during the relay handshake.
…s out of reach beforeSend is not the whole story where there is a native layer. sentry_flutter's C binding never calls sentry_options_set_before_send, so sentry-native writes and posts its own envelope for a native crash — and the nativeDatabasePath fix is precisely what turns that path on for the first time, taking it from broken-and-silent to working-and-leaking. beforeBreadcrumb runs before NativeScopeObserver mirrors the scope down, so it is what keeps a path out of the copy the native layer holds. Frames and contexts of a native crash stay unreachable from Dart; the comment now says so rather than implying coverage. The support-dir catch restored the exact behaviour the function exists to fix and had no symptom by construction: no handler process, no database, no release-health session. It now warns. Consent reads through telemetryEnabledProvider, and frame module/package are redacted alongside absPath — native frames carry an absolute path there. fileName deliberately is not: a Dart frame's is a package:/dart: URI, and _pathLike would eat it. The symbols note claimed native frames can be hand-symbolicated against the build's PDBs. No workflow archives them, and the toolchains are not bit-reproducible, so rebuilding the tag yields build ids that do not match. Recorded as the gap it is.
Deferring to scheduleMicrotask unwound the focus notification but still landed inside the frame showing the TextField, so the field outlived the controller and focus node it is built against — any pointer, key or traversal event in that gap touches a disposed ChangeNotifier. A post-frame callback runs after the setState rebuild that takes the field down. The regression test also closed neither the CachedSessionsStore nor the ProjectSession; both own timers and subscriptions that would outlive the tree and fail some later test with a pending-timer assertion pointing nowhere near this file.
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.
Follow-up review of #79, which merged before its review finished. Thirteen of fifteen findings fixed; the rest is recorded below.
Action required before any of this reports anything
Create the repo secret
SENTRY_DSN_BRIDGE, with a numeric project id.All three desktop bridge builds baked in
secrets.SENTRY_DSN— the app's DSN, whose project id is a slug. The JS SDKs accept only a numeric project id (measured;sentry-dartdiffers, it takes the last path segment as an opaqueString, which is why the app works). So #79 shipped bridge crash reporting permanently inert on macOS, Windows and Linux: every consenting host would initialise, capture, andflushsuccessfully while transmitting nothing, andflushresolves true. Nothing on the machine or in CI would ever indicate a problem — the first time anyone looked for bridge crashes there would be none, and it would read as stability.The builds now read
SENTRY_DSN_BRIDGE. Until that secret exists, reporting stays off loudly, inhost.log. errex must issue a numeric-id project for this; that part is not a code change.The ordering bug this exposed
The DSN gate ran after
Sentry.init.initinstalls both top-level process handlers before it ever looks at the DSN, and nothing takes them off again —Sentry.close()disables the client but leaves the listeners attached.So on the shipping (refused-DSN) config, a client that could never transmit still owned every fatal path in the process. The
warn-mode rejection handlerconsole.errors the raw reason — full stack, unredacted paths — into a stderr teed to~/.antgrid/host.log, and takes the rejection away from Bun's own reporting. A leak and a diagnostic regression, both on the path that believed reporting was off.hasNumericProjectIdnow decides ahead ofinit, so a refusal installs no client and no listener. The test asserts the listener counts are unchanged.Note that the SDK's own
validateDsnis not the backstop it appears to be: it opens withif (!DEBUG_BUILD) return true, andDEBUG_BUILDis onlytypeof __SENTRY_DEBUG__ === "undefined" || __SENTRY_DEBUG__. Defining that false at build time — a routine bundle-size flag — would make the SDK accept a slug id and post envelopes to a URL built from it.Native crashes were never scrubbed
sentry_flutter's C binding sets dsn/sample_rate/debug/environment/release/session/dist/max_breadcrumbs/database_path/handler_path and neversentry_options_set_before_send. sentry-native writes and posts its own envelope, so nothing from a Windows or Linux native crash passes throughbeforeSend— whilebeforeSendand its whole test suite sit inert one layer up.The
nativeDatabasePathchange in #79 is exactly what turns that capture path on for the first time. It would have gone from broken-and-silent to working-and-leaking.beforeBreadcrumbcloses the part that is reachable:NativeScopeObservermirrors the Dart scope down, andbeforeBreadcrumbruns before the observers are notified, so scrubbing there keeps paths out of the copy the native layer holds. Native-originated frames and contexts remain outside Dart's reach by construction — the comment now states that rather than implying coverage.Consent could silently revert
_resolveNativeDatabasePath'scatch (_) { return null; }restored the exact behaviour the function exists to fix, with no symptom by construction: no handler process, no database, and (auto-session-tracking being a native option) no release-health session. The one signal that would show the pipeline was dead is the same one the fallback removed. Now logged.main.dartreads consent throughtelemetryEnabledProviderrather than the settings object directly.Smaller, all in the same family
Moduleswas not excluded andevent.modulesnot scrubbed. It walks up fromprocess.cwd()for apackage.json— and the host inherits the spawning app's cwd, so what it finds is not knowable from the bridge. Disk I/O on the fatal path for a field nothing reads.tracesSampleRate,spotlightanddebugwere undefined, andgetClientOptionsfills each from the ambient environment.SENTRY_SPOTLIGHTin a developer's shell would fan every envelope to a second loopback destination;SENTRY_TRACES_SAMPLE_RATEwould emit transactions, whichbeforeSenddoes not see at all (that isbeforeSendTransaction, never set). Pinned.redactNullableguarded on=== undefined, so a runtimenullreachedString.replaceand threw. A throw insidebeforeSendis swallowed and drops the event — the report explaining the crash would be the one that never arrives.redactDeepassigned into an object literal, so a key named__proto__ranObject.prototype's setter instead of creating a property, silently losing that entry. NowObject.fromEntries.debug_meta.images[].code_fileis an absolute path and was travelling unscrubbed.console.errorthen a bareprocess.exit(1), reaching neither the flush nor either handler. A mint failure against a revoked credential pair lands there and nowhere else — the exact class of failure this instrumentation exists to answer.startControlPlane()moved below the handler registration. The old comment claimed the preceding window was one "where no PTY exists yet"; in factstartControlPlanewriteshost.jsonand logs ready, and only then spends seconds on the relay handshake and OAuth mint. Withhost.jsonon disk the app can driveproject:openover loopback for that whole stretch, and a crash there would find Sentry's handler as the sole listener, take the fatal path, and skip the teardown that sweeps every PTY — survivable on Windows via the job object, orphaning the agent tree on POSIX.credentials.test.ts's headline assertion was vacuous:safeParse(base).data?.telemetryEnabledisundefinedboth when the payload parses without the field and when the schema rejects it. Dropping.optional()would have kept it green while every older app and CLI host died atreadBootstrapPayload.crash-scrubber.test.tsnow importsEXCLUDED_INTEGRATIONSinstead of duplicating it, and itsafterEachclears the client off the scope —close()leaves it there, so a later case read the previous case's DSN.The rename fix from #79 was not quite right
Deferring disposal to
scheduleMicrotaskunwound the focus notification but still landed inside the frame showing theTextField, so the field outlived the controller and focus node it is built against. Any pointer, key or traversal event in that gap touches a disposedChangeNotifier— a second crash in the same family, reachable only by timing, so it would not reproduce on demand. A post-frame callback runs after thesetStaterebuild that takes the field down.The regression test also closed neither the
CachedSessionsStorenor theProjectSession; both own timers and subscriptions that would outlive the tree.Not fixed
Finding 5 — consent is captured by value at first spawn.
bootstrapBuildercloses overtelemetryEnabled, andopenProjectassigns with??=, so the first assignment wins for the app's whole life.HostController._scheduleRestartre-invokes that stale builder. A user opts out, the host later dies and auto-respawns withtelemetryEnabled: true, and reports keep flowing from a machine whose owner revoked consent — nothing short of an app restart clears it. Revocation also never reaches an already-running host.This contradicts
credentials.ts, which documents the toggle as taking effect on the next spawn. It is a real consent bug and should be fixed, but the fix is a signature change acrossopenProject/warmHost/_openInnerplus three test doubles, and choosing where the live read comes from has a Riverpod lifetime trap (a per-projectrefcaptured in a container-lifetime closure throws after disposal). That is a design call, not a review edit.Finding 9 — desktop symbols are not archived.
build-desktop.ymluploads only the .dmg, .msix and Linux bundle; PDBs and dSYMs die with the runner, and the toolchains are not bit-reproducible so rebuilding the tag yields non-matching build ids. Since this PR is what makes native capture work, the first real crash would arrive as module+offset and be permanently unreadable. The doc now says so; the CI change is left for a separate PR.Testing
bun run --filter antgrid-bridge testtsc --noEmit(bridge)flutter analyzeflutter testcheck:font-tokensdart format