Skip to content

Commit a25916d

Browse files
authored
Merge pull request #2009 from Artificial-Pancreas/fix/webview-fixes-main
[main] [backport] Recover from WebContent-process termination/suspension instead of hanging the loop
2 parents fea1787 + 6cfb556 commit a25916d

4 files changed

Lines changed: 262 additions & 34 deletions

File tree

Config.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
APP_DISPLAY_NAME = iAPS
2-
APP_VERSION = 8.3.3
2+
APP_VERSION = 8.3.4
33
APP_BUILD_NUMBER = 1
44
COPYRIGHT_NOTICE =
55
DEVELOPER_TEAM = ##TEAM_ID##

FreeAPS.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@
302302
38E98A2725F52C9300C0CED0 /* CollectionIssueReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */; };
303303
38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */; };
304304
38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */; };
305+
F2A11C2230200C0100EF9230 /* Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A11C2130200C0100EF9230 /* Timeout.swift */; };
305306
38E98A3025F52FF700C0CED0 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2F25F52FF700C0CED0 /* Config.swift */; };
306307
38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; };
307308
38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */; };
@@ -991,6 +992,7 @@
991992
38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionIssueReporter.swift; sourceTree = "<group>"; };
992993
38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Error+Extensions.swift"; sourceTree = "<group>"; };
993994
38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLocking+Extensions.swift"; sourceTree = "<group>"; };
995+
F2A11C2130200C0100EF9230 /* Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeout.swift; sourceTree = "<group>"; };
994996
38E98A2F25F52FF700C0CED0 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
995997
38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
996998
38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLogReporter.swift; sourceTree = "<group>"; };
@@ -2224,6 +2226,7 @@
22242226
38A00B2225FC2B55006BC0B0 /* LRUCache.swift */,
22252227
38FCF3D525E8FDF40078B0D1 /* MD5.swift */,
22262228
38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */,
2229+
F2A11C2130200C0100EF9230 /* Timeout.swift */,
22272230
38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */,
22282231
3811DE5725C9D4D500A708ED /* ProgressBar.swift */,
22292232
3811DE5525C9D4D500A708ED /* Publisher.swift */,
@@ -3770,6 +3773,7 @@
37703773
041D1E995A6AE92E9289DC49 /* BolusDataFlow.swift in Sources */,
37713774
23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */,
37723775
38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
3776+
F2A11C2230200C0100EF9230 /* Timeout.swift in Sources */,
37733777
19C3C72F2E97AE520000BE4D /* SearchResultsView.swift in Sources */,
37743778
19C3C7302E97AE520000BE4D /* FoodSearchStateModel.swift in Sources */,
37753779
19C3C7312E97AE520000BE4D /* BarcodeScannerService.swift in Sources */,

FreeAPS/Sources/APS/OpenAPS/WebViewScriptExecutor.swift

Lines changed: 187 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
import UIKit
12
import WebKit
23

34
@MainActor class WebViewScriptExecutor: NSObject, WKScriptMessageHandler {
5+
static let defaultTimeout: Duration = .seconds(30)
6+
private static let maxAttachAttempts = 10
7+
private static let readyMessageID = "__iaps_ready__"
8+
49
private var webView: WKWebView!
510
private var continuationStreams = [String: AsyncThrowingStream<RawJSON, Error>.Continuation]()
11+
12+
/// True once the current WebView has loaded its document and every user script has run.
13+
private var isReady = false
14+
private var readyWaiters = [CheckedContinuation<Void, Error>]()
15+
private var webViewGeneration = 0
616
private var scripts = [
717
FunctionScript(name: OpenAPS.Bundle.autosens, function: "freeaps_autosens"),
818
FunctionScript(name: OpenAPS.Bundle.autotuneCore, function: "freeaps_autotuneCore"),
@@ -30,7 +40,67 @@ import WebKit
3040
init(frame _: CGRect = .zero) {
3141
super.init()
3242

43+
replaceWebView()
44+
}
45+
46+
/// Tear down the current WebView and stand up a fresh one. Anything waiting on the
47+
/// outgoing view is failed rather than left parked, so a replacement can never
48+
/// strand a continuation.
49+
private func replaceWebView() {
50+
webView?.removeFromSuperview()
51+
failReadyWaiters(
52+
NSError(
53+
domain: "WebViewScriptExecutor", code: 3,
54+
userInfo: [NSLocalizedDescriptionKey: "WebView replaced before it became ready"]
55+
)
56+
)
57+
isReady = false
58+
webViewGeneration &+= 1
3359
webView = createWebView()
60+
ensureAttachedToWindow()
61+
}
62+
63+
private func failReadyWaiters(_ error: Error) {
64+
let waiters = readyWaiters
65+
readyWaiters.removeAll()
66+
for waiter in waiters {
67+
waiter.resume(throwing: error)
68+
}
69+
}
70+
71+
private func awaitReady() async throws {
72+
if isReady { return }
73+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
74+
readyWaiters.append(continuation)
75+
}
76+
}
77+
78+
private func ensureAttachedToWindow(attempt: Int = 0) {
79+
guard let webView, webView.superview == nil else { return }
80+
81+
guard let window = Self.hostWindow() else {
82+
guard attempt < Self.maxAttachAttempts else {
83+
debug(.openAPS, "WebView has no window to attach to; retrying on next JS call")
84+
return
85+
}
86+
Task { @MainActor in
87+
try? await Task.sleep(for: .seconds(1))
88+
self.ensureAttachedToWindow(attempt: attempt + 1)
89+
}
90+
return
91+
}
92+
93+
webView.isUserInteractionEnabled = false
94+
window.addSubview(webView)
95+
debug(.openAPS, "WebView attached to window for background protection")
96+
}
97+
98+
private static func hostWindow() -> UIWindow? {
99+
let scenes = UIApplication.shared.connectedScenes
100+
let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
101+
?? scenes.compactMap { $0 as? UIWindowScene }.first
102+
guard let windowScene else { return nil }
103+
return windowScene.windows.first(where: \.isKeyWindow) ?? windowScene.windows.first
34104
}
35105

36106
private func createWebView() -> WKWebView {
@@ -39,18 +109,37 @@ import WebKit
39109
contentController.add(self, name: "jsBridge")
40110
contentController.add(self, name: "scriptError")
41111

112+
// Register the oref bundles as user scripts.
113+
// WebKit re-applies user scripts on every document load.
114+
for source in userScriptSources() {
115+
contentController.addUserScript(WKUserScript(
116+
source: source,
117+
injectionTime: .atDocumentStart,
118+
forMainFrameOnly: true
119+
))
120+
}
121+
contentController.addUserScript(WKUserScript(
122+
source: """
123+
window.webkit.messageHandlers.jsBridge.postMessage({ id: "\(Self.readyMessageID)", value: "" });
124+
""",
125+
injectionTime: .atDocumentEnd,
126+
forMainFrameOnly: true
127+
))
128+
42129
let config = WKWebViewConfiguration()
43130
config.userContentController = contentController
44131

45132
let webView = WKWebView(frame: .zero, configuration: config)
133+
webView.navigationDelegate = self
46134

47-
injectConsoleLogHandler(webView: webView)
48-
loadScripts(webView: webView)
135+
// User scripts only run as part of a document load.
136+
// This is also what makes webViewWebContentProcessDidTerminate reliable.
137+
webView.loadHTMLString("<html><body></body></html>", baseURL: nil)
49138

50139
return webView
51140
}
52141

53-
private func injectConsoleLogHandler(webView: WKWebView) {
142+
private func userScriptSources() -> [String] {
54143
let consoleScript = """
55144
var _consoleLog = function (message) {
56145
window.webkit.messageHandlers.consoleLog.postMessage(message.join(" "));
@@ -60,30 +149,18 @@ import WebKit
60149
});
61150
62151
"""
63-
webView.evaluateJavaScript(consoleScript, completionHandler: nil)
152+
return [consoleScript] + scripts.map(\.body) + [Script(name: OpenAPS.Prepare.log).body]
64153
}
65154

66155
private func script(for name: String) -> FunctionScript? {
67156
scripts.filter { $0.name == name }.first
68157
}
69158

70-
private func loadScripts(webView: WKWebView) {
71-
for script in scripts {
72-
includeScript(webView: webView, script: script)
73-
}
74-
75-
includeScript(webView: webView, script: Script(name: OpenAPS.Prepare.log))
76-
}
77-
78-
private func includeScript(webView: WKWebView, script: FunctionScript) {
79-
includeScript(webView: webView, script: Script(name: "Script", body: script.body))
80-
}
81-
82-
private func includeScript(webView: WKWebView, script: Script) {
83-
webView.evaluateJavaScript(script.body)
84-
}
85-
86-
func call(name: String, with arguments: [JSON], withBody body: String = "") async -> RawJSON {
159+
func call(
160+
name: String,
161+
with arguments: [JSON],
162+
withBody body: String = ""
163+
) async -> RawJSON {
87164
if let script = script(for: name) {
88165
return await callFunctionAsync(function: script, with: arguments, withBody: body)
89166
} else {
@@ -100,7 +177,11 @@ import WebKit
100177
await callFunctionAsync(function: function.variable, with: arguments, withBody: body)
101178
}
102179

103-
private func callFunctionAsync(function: String, with arguments: [JSON], withBody body: String = "") async -> RawJSON {
180+
private func callFunctionAsync(
181+
function: String,
182+
with arguments: [JSON],
183+
withBody body: String = ""
184+
) async -> RawJSON {
104185
let joined = arguments.map(\.rawJSON).joined(separator: ",")
105186

106187
let script = """
@@ -110,17 +191,24 @@ import WebKit
110191
"""
111192

112193
do {
113-
let result = try await evaluateFunction(body: script)
194+
let result = try await evaluateFunction(name: function, body: script)
114195
return result
115196
} catch {
116-
print(error)
197+
warning(.openAPS, "Javascript function (\(function)) failed: \(error.localizedDescription)")
117198
return ""
118199
}
119200
}
120201

121-
private func evaluateFunction(body: String, attempts: Int = 0) async throws -> RawJSON {
202+
private func evaluateFunction(
203+
name: String,
204+
body: String,
205+
attempts: Int = 0
206+
) async throws -> RawJSON {
207+
ensureAttachedToWindow()
208+
122209
let maxAttempts = 2
123210
let requestId = UUID().uuidString
211+
let generation = webViewGeneration
124212

125213
let script = """
126214
(function () {
@@ -143,18 +231,35 @@ import WebKit
143231
}
144232

145233
do {
146-
try await webView.evaluateJavaScript(script)
234+
return try await withTimeout("js.\(name)", Self.defaultTimeout) { [self] in
235+
try await Task { @MainActor in
236+
try await awaitReady()
237+
238+
try await webView.evaluateJavaScript(script)
147239

148-
for try await value in stream {
149-
return value
240+
for try await value in stream {
241+
return value
242+
}
243+
throw NSError(
244+
domain: "WebViewScriptExecutor", code: 2,
245+
userInfo: [NSLocalizedDescriptionKey: "No result emitted"]
246+
)
247+
}.value
150248
}
151-
throw NSError(domain: "WebViewScriptExecutor", code: 2, userInfo: [NSLocalizedDescriptionKey: "No result emitted"])
152249
} catch {
153-
print("Javascript function (\(requestId)) attempt \(attempts + 1) failed with error: \(error)")
154-
continuationStreams.removeValue(forKey: requestId)
250+
warning(
251+
.openAPS,
252+
"Javascript function (\(name), \(requestId)) attempt \(attempts + 1) failed with error: \(error)"
253+
)
254+
continuationStreams.removeValue(forKey: requestId)?.finish(throwing: error)
255+
// Rebuild even when giving up: otherwise the next JS call in this cycle
256+
// inherits the wedged WebView and burns its own timeout rediscovering it.
257+
// Skipped if the terminate delegate (or another failing call) already did it.
258+
if webViewGeneration == generation {
259+
replaceWebView()
260+
}
155261
if attempts < maxAttempts {
156-
webView = createWebView()
157-
return try await evaluateFunction(body: body, attempts: attempts + 1)
262+
return try await evaluateFunction(name: name, body: body, attempts: attempts + 1)
158263
} else {
159264
throw error
160265
}
@@ -171,6 +276,23 @@ import WebKit
171276
if message.name == "scriptError", let logMessage = message.body as? String {
172277
warning(.openAPS, "JavaScript Error: \(logMessage)")
173278
}
279+
// The documentEnd user script fired: the document (re)loaded and every oref
280+
// bundle has been injected, so queued calls may proceed.
281+
if message.name == "jsBridge",
282+
let body = message.body as? [String: Any],
283+
body["id"] as? String == Self.readyMessageID,
284+
// A replaced WebView's in-flight ready message must not mark its successor
285+
// ready before that one has injected anything.
286+
message.webView === webView
287+
{
288+
isReady = true
289+
let waiters = readyWaiters
290+
readyWaiters.removeAll()
291+
for waiter in waiters {
292+
waiter.resume()
293+
}
294+
return
295+
}
174296
// Handle responses from evaluateFunction via jsBridge
175297
if message.name == "jsBridge",
176298
let body = message.body as? [String: Any],
@@ -196,3 +318,35 @@ import WebKit
196318
}
197319
}
198320
}
321+
322+
extension WebViewScriptExecutor: WKNavigationDelegate {
323+
/// The WebContent (JS) process died — jetsam under memory pressure, a crash, or iOS
324+
/// reclaiming it in the background. Every in-flight call would otherwise wait for a
325+
/// reply that can no longer arrive, so fail them all now; their retry path picks up
326+
/// the rebuilt WebView. Unlike a suspended process this one WebKit tells us about,
327+
/// which is why it is worth handling separately from the watchdog.
328+
func webViewWebContentProcessDidTerminate(_ terminatedWebView: WKWebView) {
329+
guard terminatedWebView === webView else { return }
330+
331+
let pending = continuationStreams
332+
continuationStreams.removeAll()
333+
warning(
334+
.openAPS,
335+
"WebContent process terminated by iOS - failing \(pending.count) in-flight JS call(s) and rebuilding the WebView"
336+
)
337+
let error = NSError(
338+
domain: "WebViewScriptExecutor", code: 510,
339+
userInfo: [NSLocalizedDescriptionKey: "WebContent process terminated"]
340+
)
341+
for (_, continuation) in pending {
342+
continuation.finish(throwing: error)
343+
}
344+
replaceWebView()
345+
}
346+
347+
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
348+
// The blank document that carries the user scripts failed to load, so nothing
349+
// was injected and awaitReady() will time out. Log it or it is invisible.
350+
warning(.openAPS, "WebView document load failed: \(error.localizedDescription)")
351+
}
352+
}

0 commit comments

Comments
 (0)