Skip to content

fix(ios): stop the attestation plugin pinning the Flutter engine - #9395

Merged
realmeylisdev merged 2 commits into
mainfrom
fix/9393-engine-pin
Sep 23, 2026
Merged

realmeylisdev merged 2 commits into
mainfrom
fix/9393-engine-pin

Conversation

@realmeylisdev

@realmeylisdev realmeylisdev commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

NostrBridgeAttestationPlugin held its FlutterPluginRegistry in a private let, and on iOS that registry is the FlutterEngine — this app reaches it through FlutterImplicitEngineBridgeImpl.pluginRegistry, which returns _engine. Because the plugin instance lives in a process-lifetime shared static, that one reference pinned the engine for the life of the process.

-[FlutterEngine dealloc] is the only non-test place Flutter dispatches detachFromEngineForRegistrar:, so the pin silently disabled detachFromEngine(for:) for every plugin in the app — including BackgroundUploaderPlugin, whose hook #9381 had just made reachable by publishing the instance.

The symptom is a crash, not a stale-channel warning

#9393 describes the surviving symptom as a "Communicating on a dead channel" warning per progress tick. That warning cannot occur here. It comes from FlutterBinaryMessengerRelay, and only once its parent — which is weak — has gone nil, which is exactly what the pin prevents. So the relay keeps forwarding into an engine whose shell is already gone:

  1. The implicit engine is built with allowHeadlessExecution: self.engineAllowHeadlessExecution, a readonly property that is declared in no internal header and assigned nowhere, so its ivar zero-initializes to NO.
  2. View-controller dealloc therefore reaches notifyViewControllerDeallocated → destroyContext → _shell.reset().
  3. The engine object survives (pinned), so the relay's weak parent is still non-nil and forwards.
  4. -[FlutterEngine sendOnChannel:message:binaryReply:] asserts _shell && _shell->IsSetup(), raising NSInternalInconsistencyException: Sending a message before the FlutterEngine has been run. The assert is live in shipped release builds, not just debug: Crashlytics recorded that exact exception from release 1.0.13 (fix(crashlytics): stop iOS native player callbacks after engine teardown #4400), and the assert string is present in stock Flutter 3.47.2's ios-release engine artifact.

The fix

Make the reference weak. The engine can then dealloc, the teardown hook fires, and the residual window degrades to the benign dead-channel log instead of a crash. Both call sites already treat a missing WKWebView as a recoverable miss, so a torn-down engine takes the same path as a stale identifier; the lookup moves into one resolveWebView helper rather than duplicating the guard.

Checked that nothing else re-pins the engine: engineBridge is only ever a parameter in AppDelegate, never stored in a property, and no channel-handler closure captures it.

Related Issue: Closes #9393

Out of Scope

  • The residual destroyContext→dealloc window. The plugin's own doc comment already names it. Closing it means choosing a teardown trigger — FlutterViewControllerWillDealloc is exported in the engine binary but declared in no public header — and that is the same open design question currently under review on fix(video-player): stop iOS delivering video frames into a torn-down Flutter engine #9384 for divine_video_player. Shipping a design here would pre-empt that decision; fixing the pin helps both sides regardless of how fix(video-player): stop iOS delivering video frames into a torn-down Flutter engine #9384 resolves.
  • macOS. The macOS FlutterPlugin protocol declares no detach hook at all, so there is no teardown path there to fix.
  • check_ios_extension_signing_contract.sh fails identically on clean origin/main. It is pre-existing, absent from the CI guards manifest, and outside this diff.

Verification

  • CI's iOS Native Tests job (ci(mobile): run the native iOS XCTests on pull requests that touch iOS code #9388) ran RunnerTests on the rebased head: 33 passed, 0 failed, including NostrBridgeAttestationEnginePinTests (job). The same 33/33 held locally on an iPhone 18 Pro simulator before the rebase, which moved only the base (git patch-id --stable is identical before and after).
  • The new NostrBridgeAttestationEnginePinTests discriminates — reverting just the weak keyword to a strong reference turns it red, restoring it turns it green.
  • Native guards run locally and passing: check_native_transport_security.sh, check_ios_shipping_versions.sh, check_privacy_manifest_coverage.sh.
  • Not reproduced on a device. The runtime trigger — a scene disconnect while the process survives a background upload — is unverified. The mechanism is read from the Flutter 3.47.2 engine source at the revision this repo pins. The assert string was checked in stock Flutter's ios-release artifact; the App Store build uses Shorebird's engine, whose iOS artifacts were not available locally, so the shipped-binary claim rests on fix(crashlytics): stop iOS native player callbacks after engine teardown #4400's production crash reports rather than on that check.

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)

@realmeylisdev
realmeylisdev requested a review from a team as a code owner September 21, 2026 14:28
@realmeylisdev
realmeylisdev requested review from dcadenas and mbradley and removed request for a team September 21, 2026 14:28
@github-actions

This comment has been minimized.

@dcadenas dcadenas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The weak reference is the right fix, and the engine-source claims behind it hold.

I read those claims at the Flutter version this repo pins rather than taking them from the description:

  • FlutterImplicitEngineBridgeImpl.pluginRegistry returns the engine, so the registry the app hands this plugin really is the FlutterEngine.
  • -[FlutterEngine dealloc] is the only non-test place Flutter dispatches detachFromEngineForRegistrar:, so a strong reference from a process-lifetime static does disable that hook for every plugin in the app.
  • The implicit engine's allowHeadlessExecution is read-only, read once, and assigned nowhere, so it is NO.

I also went looking for a second strong path to the engine, because the fix only pays off if the engine can now reach dealloc.
AppDelegate never stores the bridge.
The messenger and texture-registry relays hold their parent weakly, so every registrar.messenger() holder in the app is engine-free.
FlutterEngineBaseRegistrar.flutterEngine is already a weak property.
No other Swift file under Runner names the engine, a registry, or a registrar.
The plugin's static was the only strong path, so dropping it is sufficient for the stated goal.

Both lookup sites already treated a missing WKWebView as recoverable, with policy rollback and WEBVIEW_NOT_FOUND on attach and a deliberate silent skip on detach.
A nil registry therefore lands on a path that was already correct rather than a new one.

The new test discriminates: it holds a weak reference across an autoreleasepool and asserts a positive control inside the scope, so it fails if the registry is retained instead of passing vacuously.

What I did not verify

  • I did not build or run the iOS target. xcodebuild test -only-testing:RunnerTests is the only thing that exercises NostrBridgeAttestationEnginePinTests, and no GitHub workflow runs it, so the green checks here say nothing about that test. It rests on the simulator run reported in the description.
  • dart format and flutter analyze pass on the repo-pinned SDK, but the entire diff sits under mobile/ios, which neither command reaches. The declared check set has no runnable local command for that directory.
  • I did not confirm that the sendOnChannel: shell assert survives in the shipped release engine binary. That claim is in the description's causal narrative, not in the changed code.

Accepted

The destroyContext to dealloc window remains open, and this change narrows a process-lifetime pin down to that window rather than introducing it.
The teardown-trigger design question stays open on #9384.

Reviewed at 0452cda.
The other review request on this pull request is still outstanding, and the merge decision stays with you.

@github-actions

This comment has been minimized.

@realmeylisdev

Copy link
Copy Markdown
Contributor Author

@dcadenas — following up on the three things your review listed as not verified.

1. RunnerTests in CI. #9388 landed after your review and added Mobile CI's iOS Native Tests job. I rebased onto main so this PR picks it up, and the job ran the full RunnerTests target on the new head, including NostrBridgeAttestationEnginePinTests: 33 passed, 0 failed, with NostrBridgeAttestationEnginePinTests.testSetupDoesNotRetainThePluginRegistry() among them (job log). The rebase moved only the base: git patch-id --stable gives c06d4df59eea427905d8428fc4ce7eb6935e844e for the diff both before and after, and main touched neither file in the meantime. Your approval therefore covers the same change, but GitHub carried it forward pinned to 0452cda, which is no longer the head, so I have re-requested your review to put a verdict on 1231dda.

2. format and analyze do not reach mobile/ios. Correct, and the same job closes it: it builds the whole Runner target the way flutter build ios does, so the Swift in this diff is now compile-checked in CI rather than only on my machine.

3. The release-binary assert. You were right to leave this unconfirmed, because the description overclaimed it. I checked stock Flutter 3.47.2's ios-release artifact, which does contain the assert string, but the App Store build comes from shorebird release with Shorebird's engine, whose iOS artifacts I never had locally. The stronger evidence is production: #4400 records Crashlytics issues from release 1.0.13 carrying exactly NSInternalInconsistencyException: Sending a message before the FlutterEngine has been run, which is that assert firing in a shipped build. I have corrected the description to say which binary was checked and to cite #4400 for the shipped one. As you noted, this is narrative rather than changed code, so nothing in the diff depends on it.

@dcadenas dcadenas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rebase changed nothing in the diff, and two of the three gaps my earlier review left open are now closed by CI.

That earlier approval was formed against the pre-rebase commit. This one is pinned to 1231dda.

The rewrite is a pure rebase, checked on the two commits directly: git patch-id --stable is identical, the commit message is byte-identical, and both touched files carry the same blob hashes.
Only the parent moved.
The base advance touches nothing under mobile/ios and changes no Podfile, pubspec, or SDK pin, so the surroundings of this diff are unchanged and the engine-source reasoning I checked before still holds at this head.

Now covered

  • RunnerTests runs in CI. Mobile CI run 35662447640 is on this head, and its iOS Native Tests job log reports ** TEST SUCCEEDED ** with 33 passed and 0 failed, including NostrBridgeAttestationEnginePinTests.testSetupDoesNotRetainThePluginRegistry().
  • That job builds the Runner target, so the Swift in this diff is now compile-checked upstream rather than only on your simulator.

Still not covered

  • dart format and flutter analyze pass on the repo-pinned Flutter 3.47.2, but neither command reaches mobile/ios, where the entire diff sits. The declared check set has no local command for that directory, so CI is the only automated coverage of the changed code.
  • I did not build or run the iOS target myself.

Accepted

The destroyContext to dealloc window stays open. This change narrows a process-lifetime pin down to that window rather than introducing it.
The teardown-trigger design question stays open on #9384.

The description now names the artifact the assert string was actually checked in and says plainly that the App Store build uses a different engine, with the shipped-binary claim resting on the production crash reports in #4400. That is the correction the earlier review asked for.

Reviewed at 1231dda.
The other review request on this pull request is still outstanding, and the merge decision stays with you.

@realmeylisdev

realmeylisdev commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@mbradley — friendly nudge on this one when you have a moment. CI is fully green (including the new iOS Native Tests job) and dcadenas has approved at the current head (1231dda).

`NostrBridgeAttestationPlugin` held its `FlutterPluginRegistry` in a
`private let`, and on iOS that registry *is* the `FlutterEngine` —
`FlutterImplicitEngineBridgeImpl.pluginRegistry` returns `_engine`. The
plugin itself lives in a process-lifetime `shared` static, so that one
reference pinned the engine for the life of the process.

`-[FlutterEngine dealloc]` is the only non-test place Flutter dispatches
`detachFromEngineForRegistrar:`, so the pin silently disabled
`detachFromEngine(for:)` for every plugin in the app — including
`BackgroundUploaderPlugin`, whose hook #9381 had just made reachable.

The consequence is worse than a stale-channel warning. The messenger
relay's `parent` is weak and nils only at dealloc, so a pinned engine
keeps forwarding, while the view controller's dealloc has already run
`destroyContext` and dropped the shell (the implicit engine's
`allowHeadlessExecution` is NO). The send then reaches `sendOnChannel:`,
whose shell assert is present in the release engine binary, raising
`NSInternalInconsistencyException: Sending a message before the
FlutterEngine has been run` — the signature #4400 tracked.

Weak lets the engine dealloc, so the hook fires and the residual
destroyContext-to-dealloc window degrades to the benign dead-channel log
instead of a crash. Both call sites already treat a missing WKWebView as
recoverable, so they take that path when the registry is gone.

Closes #9393
@github-actions

This comment has been minimized.

@realmeylisdev

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/main to resolve the merge conflict GitHub flagged in mobile/ios/RunnerTests/RunnerTests.swift.

Cause. DivineVideoPlayerEngineTeardownTests landed on main after this PR's last rebase, appended to the file at the exact same anchor point (right after BackgroundUploaderEngineTeardownTests) that this PR's own commit inserts NostrBridgeAttestationEnginePinTests + FakePluginRegistry. That's a pure positional conflict between two independent, non-overlapping test-class additions — nothing semantic.

Resolution, verified mechanically rather than by eye:

  • diff confirms the first 862 lines of the resolved file are byte-identical to origin/main's copy of the file.
  • diff confirms the appended block is byte-identical to what this PR's original commit added (git show 1231dda76:...).

So NostrBridgeAttestationEnginePinTests now sits after DivineVideoPlayerEngineTeardownTests instead of after BackgroundUploaderEngineTeardownTests — same test, same assertions, same helper class, just relocated past the newly-landed sibling class. The production fix in NostrBridgeAttestationPlugin.swift had no conflict and is untouched.

Verification before push: ran RunnerTests locally against an iPhone 18 Pro simulator — 45/45 passed, ** TEST SUCCEEDED **, including NostrBridgeAttestationEnginePinTests.testSetupDoesNotRetainThePluginRegistry() and all 12 cases of DivineVideoPlayerEngineTeardownTests running in the same suite. CI is now green on the rebased head (5ef70098), including iOS Native Tests.

@dcadenas — your approval is pinned to 1231dda, which is no longer the head after this rebase, so I've re-requested your review on 5ef70098. @mbradley's review is still outstanding from before.

@github-actions

Copy link
Copy Markdown

Mobile PR Preview

Preview refreshed for 3131ec2

Last refresh: 3131ec2 at 2026-09-23 01:06:12 UTC (preview run)

Property Value
Preview URL https://3e9d4399.openvine-app.pages.dev
Pages project openvine-app
Preview branch pr-9395
PR branch fix/9393-engine-pin
Commit 3131ec2

@dcadenas dcadenas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your rebase is clean, and with #9384 now on main the fix does what it set out to do.

I pushed one comment-only commit on top. Please take a look at 3131ec2.

Changes I pushed

  • 3131ec2 removes a stale example from two comments that #9384 added. The video player's engine-detach doc comment and the doc comment on its view-controller dealloc test both named the attestation plugin as what keeps the engine alive. Once the registry reference is weak, that is no longer true.
  • The general point stays in both comments: anything in the app that strongly holds the engine delays its dealloc.

The commit touches the divine_video_player package, which is outside the files the description covers. It changes comments only, no code.

Rebase

  • Only the base moved. The commit message and the plugin change are byte-identical to 1231dda.
  • The test file's added lines are identical and nothing was removed. Its first 862 lines are exactly main's file, with the new test class appended after DivineVideoPlayerEngineTeardownTests.

Together with #9384

#9384 has merged, so the description's note that the teardown question is "currently under review" there is out of date.
Because this change stops the engine being pinned, the engine-detach hook #9384 added can now actually run.
That hook tears down the players without touching the engine, and #9384's own tests cover it.
Both test classes ran in the same CI job below.

Checks on 3131ec2

  • Mobile CI iOS Native Tests (job): ** TEST SUCCEEDED **, 45 passed, 0 failed. That includes NostrBridgeAttestationEnginePinTests.testSetupDoesNotRetainThePluginRegistry() and all 12 DivineVideoPlayerEngineTeardownTests cases.
  • Divine Video Player CI passed, including Apple Diagnostics and the package build.
  • Every other check passed. The daily service-test publish was skipped.
  • divine_video_player's apple_engine_teardown_contract_test.dart passed 10 of 10 locally on Flutter 3.47.2. It reads the Swift source, so it confirms the comment edit left the checked functions intact.

Not verified

  • I did not build or run the iOS target myself. CI is the only build of the Swift here.
  • No on-device reproduction.
  • The window between the shell being destroyed and the engine deallocating is unchanged and still out of scope, as the description says. Plugins that tear down only at engine dealloc still wait for it.

@realmeylisdev please review the commit I pushed. The merge decision stays with you.
@mbradley your review request is still pending, and the head has moved since it was made.

@realmeylisdev

Copy link
Copy Markdown
Contributor Author

Reviewed 3131ec2 — checked it directly against the diff rather than the summary alone.

Confirmed: both hunks sit entirely inside /// doc comments (the testViewControllerDeallocReleasesPlayersWithoutTouchingTheEngine doc in RunnerTests.swift, and the detachFromEngine doc in DivineVideoPlayerPlugin.swift), no function signatures or assertions touched. The edit is correct: once the registry reference is weak, "NostrBridgeAttestationPlugin.shared keeps the engine" is no longer true, and a comment that contradicts the current implementation is worse than no comment. Good catch, thanks for pushing it directly rather than leaving it as a review comment I'd have to reconcile with the rebase.

CI is green on 3131ec2, including the divine_video_player package jobs the comment change triggered (Apple Diagnostics, package build/coverage).

Status: still waiting on @mbradley's review, which is still open from the original request.

@realmeylisdev
realmeylisdev added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 46fd2e1 Sep 23, 2026
31 checks passed
@realmeylisdev
realmeylisdev deleted the fix/9393-engine-pin branch September 23, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(background-uploader): upload events crash into a destroyed shell while a static keeps the engine alive

2 participants