Skip to content

Fix WebView process termination retry loop#451

Merged
yusuftor merged 8 commits intodevelopfrom
fix/webview-process-termination-retry
Mar 10, 2026
Merged

Fix WebView process termination retry loop#451
yusuftor merged 8 commits intodevelopfrom
fix/webview-process-termination-retry

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented Mar 10, 2026

Summary

  • Fixes infinite WebView kill-reload loops on memory-constrained devices. The previous counter-based retry (processTerminationRetryCount) reset to 0 on every successful didFinish, defeating the max-retry cap entirely. Data showed 44% of affected users (2,856) exceeded the supposed 1-retry limit on SDK 4.12.8.
  • Replaces counter logic with presentation-state check via delegate?.isActive: actively-presenting paywalls always reload immediately, while background/preloaded paywalls mark didFailToLoad for deferred reload on next viewWillAppear.
  • Adds isActive to SWWebViewDelegate protocol, already implemented by PaywallViewController.

Changes

  • SWWebView.swift: Removed processTerminationRetryCount / maxProcessTerminationRetries properties and the counter reset in didFinish. Rewrote webViewWebContentProcessDidTerminate to branch on delegate?.isActive.
  • SWWebViewDelegate protocol: Added var isActive: Bool { get }.
  • CHANGELOG.md: Added 4.14.2 entry.

Test plan

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Testing note: The webViewWebContentProcessDidTerminate handler is a WKNavigationDelegate callback triggered by iOS when the OS kills the WebView process. The branching logic itself is simple (check isActive → reload vs set didFailToLoad). SWWebView instantiates WKWebView and depends on Superwall.shared for tracking, making isolated unit tests impractical without significant mocking infrastructure. The existing SWWebViewLoadingHandler tests cover the downstream didFailToLoad path.

🤖 Generated with Claude Code

Greptile Summary

This PR replaces the broken counter-based WebView process termination retry logic with a presentation-state–aware approach. The previous processTerminationRetryCount was reset to zero on every successful didFinish, making the max-retry cap of 1 completely ineffective and causing infinite kill-reload loops on memory-constrained devices.

The new logic branches on delegate?.isActive:

  • Active paywalls get up to 3 reload attempts (maxActiveProcessTerminationRetries), after which webViewDidFail() is called to dismiss immediately.
  • Background/preloaded paywalls set didFailToLoad = true and defer reloading to the next viewWillAppear.

The activeProcessTerminationRetryCount is properly reset in both SWWebView.loadURL() (on fresh paywall loads) and WKNavigationDelegate.didFinish (on successful recovery), and PaywallViewController.loadWebView() routes through webView.loadURL(), so re-presented paywalls also get a fresh counter.

Key changes:

  • SWWebView.swift: Removed processTerminationRetryCount/maxProcessTerminationRetries; added activeProcessTerminationRetryCount/maxActiveProcessTerminationRetries = 3; rewrote webViewWebContentProcessDidTerminate to branch on delegate?.isActive; counter resets in loadURL and didFinish.
  • SWWebViewDelegate protocol: Added var isActive: Bool { get }, already implemented by PaywallViewController.
  • CHANGELOG.md: Added 4.14.2 fix entry.

Confidence Score: 4/5

  • This PR is safe to merge — the fix is logically sound and the issues raised in previous review threads have all been addressed in the current implementation.
  • The counter reset in both loadURL and didFinish correctly handles all re-presentation paths. PaywallViewController.loadWebView() routes through webView.loadURL(), so activeProcessTerminationRetryCount is properly reset on every new load attempt. The isActive-based branching cleanly separates active vs background paywall behaviour. Score is 4 rather than 5 only because the else if branch sets didFailToLoad = true alongside webViewDidFail() (which dismisses), creating slightly mixed signals — though this is functionally harmless since loadURL resets the flag on re-presentation.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift Core fix: replaces broken counter logic with isActive-based branching. Counter is correctly reset in loadURL() and didFinish(). One minor note: the else-if branch sets didFailToLoad = true redundantly alongside webViewDidFail() (which dismisses the VC), but this is benign because re-presentation goes through loadWebView() → loadURL() which resets the flag.
CHANGELOG.md Adds a 4.14.2 fix entry accurately describing the WebView process termination behaviour change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[webViewWebContentProcessDidTerminate] --> B{delegate?.isActive == true?}
    B -- Yes --> C{activeProcessTerminationRetryCount < 3?}
    B -- No --> G[Set didFailToLoad = true\nDefer reload to viewWillAppear]
    C -- Yes --> D[Increment counter\nwebView.reload]
    D --> E{didFinish fires?}
    E -- Yes --> F[Reset counter to 0\nContinue presenting]
    E -- No / terminated again --> A
    C -- No --> H[Set didFailToLoad = true\nCall webViewDidFail]
    H --> I[handleWebViewFailure:\nguard isActive else return\ndismiss paywall]
    G --> J[viewWillAppear\nCheck didFailToLoad]
    J --> K[loadWebView → loadURL\nReset counter to 0\nReset didFailToLoad on success]
    I --> L[VC dismissed]
    L -- Re-presented --> J
Loading

Last reviewed commit: 02bc8fa

…retry counter

The previous counter-based approach had a bug: processTerminationRetryCount
reset to 0 on every successful didFinish, defeating the max-retry cap entirely.
This caused infinite kill-reload loops on memory-constrained devices with
preloaded paywalls.

Replace with presentation-state logic:
- Active (presenting) paywalls: always reload immediately on process termination
- Background/preloaded paywalls: mark didFailToLoad for deferred reload on next
  viewWillAppear

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift Outdated
yusuftor and others added 2 commits March 10, 2026 13:55
Caps active-paywall reloads at 3 to prevent indefinite loops under
sustained memory pressure where didFinish never fires. The counter
resets on successful load since that breaks the rapid-termination cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yusuftor and others added 2 commits March 10, 2026 14:03
Only didFinish should reset the counter, since it signals a genuinely
successful load. Resetting in the else branch re-armed the cycle for
active paywalls that hit the cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs fixed:
- didFinish reset defeated the retry cap (terminate -> reload -> success
  -> counter back to 0 -> terminate again, infinitely)
- else branch set didFailToLoad for active paywalls, but viewWillAppear
  never fires while presented, leaving users with a blank screen

Now: no counter reset (lifetime cap per WebView instance), and active
paywalls that exhaust retries are dismissed via webViewDidFail().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reset activeProcessTerminationRetryCount in loadURL(from:) so cached
  SWWebView instances get a fresh retry budget per intentional load.
  This is the correct reset point: didFinish fires after terminate-
  triggered reloads too, but loadURL(from:) only fires for SDK-
  initiated loads.

- Set didFailToLoad = true before calling webViewDidFail() so that
  cached VCs re-presenting trigger a fresh load via viewWillAppear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yusuftor and others added 2 commits March 10, 2026 14:29
The counter should cap consecutive unrecovered terminations (rapid kill
loops where didFinish never fires), not total lifetime terminations.
A didFinish after a terminate-triggered reload() means the termination
was recovered — the counter should reset so devices with chronic but
manageable memory pressure aren't penalised.

This is safe because the counter now only gates the active path.
The old bug was a shared counter where didFinish from an unrelated
initial load reset the cap for background paywalls — that path now
bypasses the counter entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yusuftor yusuftor merged commit 79be182 into develop Mar 10, 2026
@yusuftor yusuftor deleted the fix/webview-process-termination-retry branch March 10, 2026 15:11
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.

1 participant