mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Allow headless JS tasks to retry (#23231)
Summary:
`setTimeout` inside a headless JS task does not always works; the function does not get invoked until the user starts an `Activity`.
This was attempted to be used in the context of widgets. When the widget update or user interaction causes the process and React context to be created, the headless JS task may run before other app-specific JS initialisation logic has completed. If it's not possible to change the behaviour of the pre-requisites to be synchronous, then the headless JS task blocks such asynchronous JS work that it may depend on. A primitive solution is the use of `setTimeout` in order to wait for the pre-conditions to be met before continuing with the rest of the headless JS task. But as the function passed to `setTimeout` is not always called, the task will not run to completion.
This PR solves this scenario by allowing the task to be retried again with a delay. If the task returns a promise that resolves to a `{'timeout': number}` object, `AppRegistry.js` will not notify that the task has finished as per master, instead it will tell `HeadlessJsContext` to `startTask` again (cleaning up any posted `Runnable`s beforehand) via a `Handler` within the `HeadlessJsContext`.
Documentation also updated here: https://github.com/facebook/react-native-website/pull/771
### AppRegistry.js
If the task provider does not return any data, or if the data it returns does not contain `timeout` as a number, then it behaves as `master`; notifies that the task has finished. If the response does contain `{timeout: number}`, then it will attempt to queue a retry. If that fails, then it will behaves as if the task provider returned no response i.e. behaves as `master` again. If the retry was successfully queued, then there is nothing to do as we do not want the `Service` to stop itself.
### HeadlessJsTaskSupportModule.java
Similar to notify start/finished, we simply check if the context is running, and if so, pass the request onto `HeadlessJsTaskContext`. The only difference here is that we return a `Promise`, so that `AppRegistry`, as above, knows whether the enqueuing failed and thus needs to perform the usual task clean-up.
### HeadlessJsTaskContext.java
Before retrying, we need to clean-up any timeout `Runnable`'s posted for the first attempt. Then we need to copy the task config so that if this retry (second attempt) also fails, then on the third attempt (second retry) we do not run into a consumed exception. This is also why in `startTask` we copy the config before putting it in the `Map`, so that the initial attempt does leave the config's in the map as consumed. Then we post a `Runnable` to call `startTask` on the main thread's `Handler`. We use the same `taskId` because the `Service` is keeping track of active task IDs in order to calculate whether it needs to `stopSelf`. This negates the need to inform the `Service` of a new task id and us having to remove the old one.
## Changelog
[Android][added] - Allow headless JS tasks to return a promise that will cause the task to be retried again with the specified delay
Pull Request resolved: https://github.com/facebook/react-native/pull/23231
Differential Revision: D15646870
fbshipit-source-id: 4440f4b4392f1fa5c69aab7908b51b7007ba2c40
This commit is contained in:
committed by
Facebook Github Bot
parent
2fe3dd2e7d
commit
ac7ec4602f
@@ -5,12 +5,6 @@
|
||||
|
||||
package com.facebook.react.jstasks;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.util.SparseArray;
|
||||
|
||||
@@ -20,6 +14,14 @@ import com.facebook.react.bridge.UiThreadUtil;
|
||||
import com.facebook.react.common.LifecycleState;
|
||||
import com.facebook.react.modules.appregistry.AppRegistry;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Helper class for dealing with JS tasks. Handles per-ReactContext active task tracking, starting /
|
||||
* stopping tasks and notifying listeners.
|
||||
@@ -51,6 +53,7 @@ public class HeadlessJsTaskContext {
|
||||
private final AtomicInteger mLastTaskId = new AtomicInteger(0);
|
||||
private final Handler mHandler = new Handler();
|
||||
private final Set<Integer> mActiveTasks = new CopyOnWriteArraySet<>();
|
||||
private final Map<Integer, HeadlessJsTaskConfig> mActiveTaskConfigs = new ConcurrentHashMap<>();
|
||||
private final SparseArray<Runnable> mTaskTimeouts = new SparseArray<>();
|
||||
|
||||
private HeadlessJsTaskContext(ReactContext reactContext) {
|
||||
@@ -85,6 +88,16 @@ public class HeadlessJsTaskContext {
|
||||
* @return a unique id representing this task instance.
|
||||
*/
|
||||
public synchronized int startTask(final HeadlessJsTaskConfig taskConfig) {
|
||||
final int taskId = mLastTaskId.incrementAndGet();
|
||||
startTask(taskConfig, taskId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a JS task the provided task id. Handles invoking {@link AppRegistry#startHeadlessTask}
|
||||
* and notifying listeners.
|
||||
*/
|
||||
private synchronized void startTask(final HeadlessJsTaskConfig taskConfig, int taskId) {
|
||||
UiThreadUtil.assertOnUiThread();
|
||||
ReactContext reactContext = Assertions.assertNotNull(
|
||||
mReactContext.get(),
|
||||
@@ -95,8 +108,8 @@ public class HeadlessJsTaskContext {
|
||||
"Tried to start task " + taskConfig.getTaskKey() +
|
||||
" while in foreground, but this is not allowed.");
|
||||
}
|
||||
final int taskId = mLastTaskId.incrementAndGet();
|
||||
mActiveTasks.add(taskId);
|
||||
mActiveTaskConfigs.put(taskId, new HeadlessJsTaskConfig(taskConfig));
|
||||
reactContext.getJSModule(AppRegistry.class)
|
||||
.startHeadlessTask(taskId, taskConfig.getTaskKey(), taskConfig.getData());
|
||||
if (taskConfig.getTimeout() > 0) {
|
||||
@@ -105,7 +118,44 @@ public class HeadlessJsTaskContext {
|
||||
for (HeadlessJsTaskEventListener listener : mHeadlessJsTaskEventListeners) {
|
||||
listener.onHeadlessJsTaskStart(taskId);
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a running JS task with a delay. Invokes
|
||||
* {@link HeadlessJsTaskContext#startTask(HeadlessJsTaskConfig, int)} as long as the process does
|
||||
* not get killed.
|
||||
*
|
||||
* @return true if a retry attempt has been posted.
|
||||
*/
|
||||
public synchronized boolean retryTask(final int taskId) {
|
||||
final HeadlessJsTaskConfig sourceTaskConfig = mActiveTaskConfigs.get(taskId);
|
||||
Assertions.assertCondition(
|
||||
sourceTaskConfig != null,
|
||||
"Tried to retrieve non-existent task config with id " + taskId + ".");
|
||||
|
||||
final HeadlessJsTaskRetryPolicy retryPolicy = sourceTaskConfig.getRetryPolicy();
|
||||
if (!retryPolicy.canRetry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
removeTimeout(taskId);
|
||||
final HeadlessJsTaskConfig taskConfig = new HeadlessJsTaskConfig(
|
||||
sourceTaskConfig.getTaskKey(),
|
||||
sourceTaskConfig.getData(),
|
||||
sourceTaskConfig.getTimeout(),
|
||||
sourceTaskConfig.isAllowedInForeground(),
|
||||
retryPolicy.update()
|
||||
);
|
||||
|
||||
final Runnable retryAttempt = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
startTask(taskConfig, taskId);
|
||||
}
|
||||
};
|
||||
|
||||
UiThreadUtil.runOnUiThread(retryAttempt, retryPolicy.getDelay());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,11 +168,10 @@ public class HeadlessJsTaskContext {
|
||||
Assertions.assertCondition(
|
||||
mActiveTasks.remove(taskId),
|
||||
"Tried to finish non-existent task with id " + taskId + ".");
|
||||
Runnable timeout = mTaskTimeouts.get(taskId);
|
||||
if (timeout != null) {
|
||||
mHandler.removeCallbacks(timeout);
|
||||
mTaskTimeouts.remove(taskId);
|
||||
}
|
||||
Assertions.assertCondition(
|
||||
mActiveTaskConfigs.remove(taskId) != null,
|
||||
"Tried to remove non-existent task config with id " + taskId + ".");
|
||||
removeTimeout(taskId);
|
||||
UiThreadUtil.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -133,6 +182,14 @@ public class HeadlessJsTaskContext {
|
||||
});
|
||||
}
|
||||
|
||||
private void removeTimeout(int taskId) {
|
||||
Runnable timeout = mTaskTimeouts.get(taskId);
|
||||
if (timeout != null) {
|
||||
mHandler.removeCallbacks(timeout);
|
||||
mTaskTimeouts.remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given task is currently running. A task is stopped if either {@link #finishTask} is
|
||||
* called or it times out.
|
||||
|
||||
Reference in New Issue
Block a user