This is an automated email from the ASF dual-hosted git repository.
GitToTheHub pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cordova-ios.git
The following commit(s) were added to refs/heads/master by this push:
new 00695c4c fix: `NSInternalInconsistencyException: "No response has been
sent for this task"` - second attempt (#1640)
00695c4c is described below
commit 00695c4c29429dab11d6e0b27265f00481bc27ca
Author: Manuel Beck <[email protected]>
AuthorDate: Fri Apr 24 12:42:12 2026 +0200
fix: `NSInternalInconsistencyException: "No response has been sent for this
task"` - second attempt (#1640)
* fix: don't call `[WKURLSchemeTask
didFinish]` in `webView:stopURLSchemeTask:``
- Calling `[WKURLSchemeTask
didFinish]` in `webView:stopURLSchemeTask:` will raise the exception
`NSInternalInconsistencyException - This task has already been stopped`
- That an exception is raised in this situation is also documented in the
Apple documentation for `WKURLSchemeTask
didFinish`:
https://developer.apple.com/documentation/webkit/wkurlschemetask/didfinish()?language=objc
- Fixes https://github.com/apache/cordova-ios/issues/1638
- Generated-By: GPT-5.3-Codex, GitHub Copilot Chat
* fix(ios): Do less in background thread in `webView:startURLSchemeTask:`
- Resolve file errors and range validation before background execution
- Send the URL scheme response before queuing background reads
- Exit early when task is no longer active and close the file handle
* fix(ios): ensure WKURLSchemeTask callbacks are dispatched on the main
thread to prevent race conditions
- Generated-By: Claude Sonnet 4.6, GitHub Copilot Chat
---
.../Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m | 165 +++++++++++---------
tests/CordovaLibTests/CDVURLSchemeHandlerTest.m | 171 +++++++++++++++++++++
2 files changed, 261 insertions(+), 75 deletions(-)
diff --git
a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m
b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m
index 5b74555d..e031e9c0 100644
--- a/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m
+++ b/CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVURLSchemeHandler.m
@@ -73,102 +73,118 @@ static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 *
4; // 4 MiB
// We do this so that we can (in future) detect if the task is cancelled
before we finished feeding it response data
[self.handlerMap setObject:(id)[NSNull null] forKey:urlSchemeTask];
- [self.viewController.commandDelegate runInBackground:^{
- NSURL *fileURL = [self fileURLForRequestURL:req.URL];
- NSError *error;
-
- NSFileHandle *fileHandle = [NSFileHandle
fileHandleForReadingFromURL:fileURL error:&error];
- if (!fileHandle || error) {
- if ([self taskActive:urlSchemeTask]) {
- [urlSchemeTask didFailWithError:error];
- }
+ NSURL *fileURL = [self fileURLForRequestURL:req.URL];
+ NSError *error;
- @synchronized(self.handlerMap) {
- [self.handlerMap removeObjectForKey:urlSchemeTask];
- }
- return;
+ NSFileHandle *fileHandle = [NSFileHandle
fileHandleForReadingFromURL:fileURL error:&error];
+ if (!fileHandle || error) {
+ if ([self taskActive:urlSchemeTask]) {
+ [urlSchemeTask didFailWithError:error];
}
- NSInteger statusCode = 200; // Default to 200 OK status
- NSString *mimeType = [self getMimeType:fileURL] ?:
@"application/octet-stream";
- NSNumber *fileLength;
- [fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey
error:nil];
-
- NSNumber *responseSize = fileLength;
- NSUInteger responseSent = 0;
+ @synchronized(self.handlerMap) {
+ [self.handlerMap removeObjectForKey:urlSchemeTask];
+ }
+ return;
+ }
- NSMutableDictionary *headers = [NSMutableDictionary
dictionaryWithCapacity:5];
- headers[@"Content-Type"] = mimeType;
- headers[@"Cache-Control"] = @"no-cache";
- headers[@"Content-Length"] = [responseSize stringValue];
-
- // Check for Range header
- NSString *rangeHeader = [urlSchemeTask.request
valueForHTTPHeaderField:@"Range"];
- if (rangeHeader) {
- NSRange range = NSMakeRange(NSNotFound, 0);
-
- if ([rangeHeader hasPrefix:@"bytes="]) {
- NSString *byteRange = [rangeHeader substringFromIndex:6];
- NSArray<NSString *> *rangeParts = [byteRange
componentsSeparatedByString:@"-"];
- NSUInteger start = (NSUInteger)[rangeParts[0] integerValue];
- NSUInteger end = rangeParts.count > 1 && ![rangeParts[1]
isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength
unsignedIntegerValue] - 1;
- range = NSMakeRange(start, end - start + 1);
- }
+ NSInteger statusCode = 200; // Default to 200 OK status
+ NSString *mimeType = [self getMimeType:fileURL] ?:
@"application/octet-stream";
+ NSNumber *fileLength;
+ [fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey error:nil];
+
+ NSNumber *responseSize = fileLength;
+ NSMutableDictionary *headers = [NSMutableDictionary
dictionaryWithCapacity:5];
+ headers[@"Content-Type"] = mimeType;
+ headers[@"Cache-Control"] = @"no-cache";
+ headers[@"Content-Length"] = [responseSize stringValue];
+
+ // Check for Range header
+ NSString *rangeHeader = [urlSchemeTask.request
valueForHTTPHeaderField:@"Range"];
+ if (rangeHeader) {
+ NSRange range = NSMakeRange(NSNotFound, 0);
+
+ if ([rangeHeader hasPrefix:@"bytes="]) {
+ NSString *byteRange = [rangeHeader substringFromIndex:6];
+ NSArray<NSString *> *rangeParts = [byteRange
componentsSeparatedByString:@"-"];
+ NSUInteger start = (NSUInteger)[rangeParts[0] integerValue];
+ NSUInteger end = rangeParts.count > 1 && ![rangeParts[1]
isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength
unsignedIntegerValue] - 1;
+ range = NSMakeRange(start, end - start + 1);
+ }
- if (range.location != NSNotFound) {
- // Ensure range is valid
- if (range.location >= [fileLength unsignedIntegerValue] &&
[self taskActive:urlSchemeTask]) {
+ if (range.location != NSNotFound) {
+ // Ensure range is valid
+ if (range.location >= [fileLength unsignedIntegerValue]) {
+ if ([self taskActive:urlSchemeTask]) {
headers[@"Content-Range"] = [NSString
stringWithFormat:@"bytes */%@", fileLength];
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc]
initWithURL:req.URL statusCode:416 HTTPVersion:@"HTTP/1.1"
headerFields:headers];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didFinish];
-
- @synchronized(self.handlerMap) {
- [self.handlerMap removeObjectForKey:urlSchemeTask];
- }
- return;
}
- [fileHandle seekToFileOffset:range.location];
- responseSize = [NSNumber
numberWithUnsignedInteger:range.length];
- statusCode = 206; // Partial Content
- headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes
%lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location +
range.length - 1), fileLength];
- headers[@"Content-Length"] = [NSString
stringWithFormat:@"%lu", (unsigned long)range.length];
+ [fileHandle closeFile];
+
+ @synchronized(self.handlerMap) {
+ [self.handlerMap removeObjectForKey:urlSchemeTask];
+ }
+ return;
}
- }
- NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc]
initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1"
headerFields:headers];
- if ([self taskActive:urlSchemeTask]) {
- [urlSchemeTask didReceiveResponse:response];
+ [fileHandle seekToFileOffset:range.location];
+ responseSize = [NSNumber numberWithUnsignedInteger:range.length];
+ statusCode = 206; // Partial Content
+ headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes
%lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location +
range.length - 1), fileLength];
+ headers[@"Content-Length"] = [NSString stringWithFormat:@"%lu",
(unsigned long)range.length];
}
+ }
- while ([self taskActive:urlSchemeTask] && responseSent < [responseSize
unsignedIntegerValue]) {
+ if (![self taskActive:urlSchemeTask]) {
+ [fileHandle closeFile];
+ return;
+ }
+
+ NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc]
initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1"
headerFields:headers];
+ [urlSchemeTask didReceiveResponse:response];
+
+ [self.viewController.commandDelegate runInBackground:^{
+ NSError *readError;
+ NSUInteger responseSent = 0;
+
+ // Read file data on the background thread and dispatch each chunk to
the
+ // main thread. Dispatching to the main thread serialises all
+ // WKURLSchemeTask callbacks with webView:stopURLSchemeTask:, which is
+ // also called on the main thread. This eliminates the race condition
+ // between the taskActive check and the actual callback invocation.
+ while (responseSent < [responseSize unsignedIntegerValue]) {
@autoreleasepool {
- NSData *data = [self readFromFileHandle:fileHandle
upTo:FILE_BUFFER_SIZE error:&error];
- if (!data || error) {
- if ([self taskActive:urlSchemeTask]) {
- [urlSchemeTask didFailWithError:error];
- }
+ NSData *data = [self readFromFileHandle:fileHandle
upTo:FILE_BUFFER_SIZE error:&readError];
+ if (!data || readError || data.length == 0) {
break;
}
-
- if ([self taskActive:urlSchemeTask]) {
- [urlSchemeTask didReceiveData:data];
- }
-
responseSent += data.length;
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if ([self taskActive:urlSchemeTask]) {
+ [urlSchemeTask didReceiveData:data];
+ }
+ });
}
}
[fileHandle closeFile];
+ NSError *capturedError = readError;
- if ([self taskActive:urlSchemeTask]) {
- [urlSchemeTask didFinish];
- }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (capturedError && [self taskActive:urlSchemeTask]) {
+ [urlSchemeTask didFailWithError:capturedError];
+ } else if ([self taskActive:urlSchemeTask]) {
+ [urlSchemeTask didFinish];
+ }
- @synchronized(self.handlerMap) {
- [self.handlerMap removeObjectForKey:urlSchemeTask];
- }
+ @synchronized(self.handlerMap) {
+ [self.handlerMap removeObjectForKey:urlSchemeTask];
+ }
+ });
}];
}
@@ -183,10 +199,9 @@ static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 *
4; // 4 MiB
[self.handlerMap removeObjectForKey:urlSchemeTask];
}
- if ([plugin isEqual:[NSNull null]]) {
- // NSNull means we own this task, so we need to mark it as finished
- [urlSchemeTask didFinish];
- } else if ([plugin respondsToSelector:@selector(stopSchemeTask:)]) {
+ // After stopURLSchemeTask is invoked, WKURLSchemeTask must not receive
+ // additional callbacks from this handler.
+ if (![plugin isEqual:[NSNull null]] && [plugin
respondsToSelector:@selector(stopSchemeTask:)]) {
[plugin stopSchemeTask:urlSchemeTask];
}
}
diff --git a/tests/CordovaLibTests/CDVURLSchemeHandlerTest.m
b/tests/CordovaLibTests/CDVURLSchemeHandlerTest.m
index 073b027a..b02eedc4 100644
--- a/tests/CordovaLibTests/CDVURLSchemeHandlerTest.m
+++ b/tests/CordovaLibTests/CDVURLSchemeHandlerTest.m
@@ -18,6 +18,8 @@
*/
#import <XCTest/XCTest.h>
+#import <Cordova/CDVCommandDelegate.h>
+#import <Cordova/CDVSettingsDictionary.h>
#import "CDVURLSchemeHandler.h"
#import "CordovaApp-Swift.h"
@@ -26,6 +28,114 @@
- (NSURL *)fileURLForRequestURL:(NSURL *)url;
@end
+@interface CDVTestCommandDelegate : NSObject <CDVCommandDelegate>
+
+@property (nonatomic, strong) CDVSettingsDictionary* settings;
+@property (nonatomic, copy) void (^pendingBlock)(void);
+
+- (void)runPendingBlock;
+
+@end
+
+@implementation CDVTestCommandDelegate
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self != nil) {
+ _settings = [[CDVSettingsDictionary alloc] init];
+ }
+ return self;
+}
+
+- (NSString *)pathForResource:(NSString *)resourcepath
+{
+ return resourcepath;
+}
+
+- (nullable CDVPlugin *)getCommandInstance:(NSString *)pluginName
+{
+ return nil;
+}
+
+- (void)sendPluginResult:(CDVPluginResult *)result callbackId:(NSString
*)callbackId
+{
+}
+
+- (void)evalJs:(NSString *)js
+{
+}
+
+- (void)evalJs:(NSString *)js scheduledOnRunLoop:(BOOL)scheduledOnRunLoop
+{
+}
+
+- (void)runInBackground:(void (^)(void))block
+{
+ self.pendingBlock = block;
+}
+
+- (void)runPendingBlock
+{
+ void (^block)(void) = self.pendingBlock;
+ self.pendingBlock = nil;
+ if (block != nil) {
+ block();
+ }
+}
+
+@end
+
+@interface CDVTestURLSchemeTask : NSObject <WKURLSchemeTask>
+
+@property (nonatomic, strong) NSURLRequest* request;
+@property (nonatomic, strong) NSMutableArray<NSData *>* receivedData;
+@property (nonatomic, strong, nullable) NSURLResponse* response;
+@property (nonatomic, strong, nullable) NSError* error;
+@property (nonatomic) NSUInteger responseCount;
+@property (nonatomic) NSUInteger finishedCount;
+@property (nonatomic) NSUInteger failedCount;
+
+- (instancetype)initWithURL:(NSURL *)url;
+
+@end
+
+@implementation CDVTestURLSchemeTask
+
+- (instancetype)initWithURL:(NSURL *)url
+{
+ self = [super init];
+ if (self != nil) {
+ _request = [NSURLRequest requestWithURL:url];
+ _receivedData = [NSMutableArray array];
+ }
+ return self;
+}
+
+- (void)didReceiveResponse:(NSURLResponse *)response
+{
+ self.response = response;
+ self.responseCount += 1;
+}
+
+- (void)didReceiveData:(NSData *)data
+{
+ [self.receivedData addObject:data];
+}
+
+- (void)didFinish
+{
+ self.finishedCount += 1;
+}
+
+- (void)didFailWithError:(NSError *)error
+{
+ self.error = error;
+ self.failedCount += 1;
+}
+
+@end
+
@interface CDVURLSchemeHandlerTest : XCTestCase
@property AppDelegate* appDelegate;
@@ -68,4 +178,65 @@
XCTAssertEqualObjects([result absoluteString], expected);
}
+- (void)testStartURLSchemeTaskSendsResponseBeforeBackgroundWork
+{
+ CDVTestCommandDelegate *commandDelegate = [[CDVTestCommandDelegate alloc]
init];
+ [self.viewController setValue:commandDelegate forKey:@"commandDelegate"];
+
+ CDVURLSchemeHandler *handler = [[CDVURLSchemeHandler alloc]
initWithViewController:self.viewController];
+ CDVTestURLSchemeTask *task = [[CDVTestURLSchemeTask alloc]
initWithURL:[NSURL URLWithString:@"app://localhost/index.html"]];
+
+ [handler webView:nil startURLSchemeTask:task];
+
+ XCTAssertEqual(task.responseCount, 1u);
+ XCTAssertNotNil(task.response);
+ XCTAssertEqual(task.failedCount, 0u);
+ XCTAssertEqual(task.finishedCount, 0u);
+ XCTAssertNotNil(commandDelegate.pendingBlock);
+}
+
+- (void)testStopURLSchemeTaskDoesNotFinishStoppedTask
+{
+ CDVTestCommandDelegate *commandDelegate = [[CDVTestCommandDelegate alloc]
init];
+ [self.viewController setValue:commandDelegate forKey:@"commandDelegate"];
+
+ CDVURLSchemeHandler *handler = [[CDVURLSchemeHandler alloc]
initWithViewController:self.viewController];
+ CDVTestURLSchemeTask *task = [[CDVTestURLSchemeTask alloc]
initWithURL:[NSURL URLWithString:@"app://localhost/index.html"]];
+
+ [handler webView:nil startURLSchemeTask:task];
+ [handler webView:nil stopURLSchemeTask:task];
+
+ XCTAssertEqual(task.responseCount, 1u);
+ XCTAssertEqual(task.finishedCount, 0u);
+ XCTAssertEqual(task.failedCount, 0u);
+
+ [commandDelegate runPendingBlock];
+
+ // The background block dispatches its final callbacks onto the main queue.
+ // Drain the main queue so those callbacks have a chance to run before
+ // we assert, making the test verify the stopped task stays silent.
+ XCTestExpectation *drained = [self expectationWithDescription:@"main queue
drained"];
+ dispatch_async(dispatch_get_main_queue(), ^{ [drained fulfill]; });
+ [self waitForExpectationsWithTimeout:1.0 handler:nil];
+
+ XCTAssertEqual(task.finishedCount, 0u);
+ XCTAssertEqual(task.failedCount, 0u);
+}
+
+- (void)testMissingFileFailsBeforeBackgroundWork
+{
+ CDVTestCommandDelegate *commandDelegate = [[CDVTestCommandDelegate alloc]
init];
+ [self.viewController setValue:commandDelegate forKey:@"commandDelegate"];
+
+ CDVURLSchemeHandler *handler = [[CDVURLSchemeHandler alloc]
initWithViewController:self.viewController];
+ CDVTestURLSchemeTask *task = [[CDVTestURLSchemeTask alloc]
initWithURL:[NSURL URLWithString:@"app://localhost/does-not-exist.html"]];
+
+ [handler webView:nil startURLSchemeTask:task];
+
+ XCTAssertEqual(task.responseCount, 0u);
+ XCTAssertEqual(task.failedCount, 1u);
+ XCTAssertNotNil(task.error);
+ XCTAssertNil(commandDelegate.pendingBlock);
+}
+
@end
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]