Skip to content

Commit e8ebd37

Browse files
committed
fix(mobile): keep the running background worker when expo-task-manager restores the task at cold start, build expo-background-task from source, and keep the headless app loader out of R8 (#1001)
1 parent 9df891b commit e8ebd37

5 files changed

Lines changed: 283 additions & 2 deletions

File tree

apps/mobile/app.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@
155155
"expo-build-properties",
156156
{
157157
"android": {
158-
"minSdkVersion": 24
158+
"minSdkVersion": 24,
159+
"extraProguardRules": "# expo-modules-core names the headless app loader only in an AndroidManifest meta-data value,\n# so R8 renames the class and the Class.forName in AppLoaderProvider throws\n# ClassNotFoundException. Without this rule expo-background-task tasks never run in a minified\n# release build: the background sync worker fails at startup and then hangs.\n-keep class expo.modules.adapters.react.apploader.RNHeadlessAppLoader { *; }\n"
159160
}
160161
}
161162
],
@@ -182,7 +183,8 @@
182183
"./plugins/android-network-security-config",
183184
"./plugins/android-system-bars",
184185
"./plugins/android-startup-trace",
185-
"./plugins/patch-alarm-notification-gradle"
186+
"./plugins/patch-alarm-notification-gradle",
187+
"./plugins/patch-expo-background-task"
186188
],
187189
"experiments": {
188190
"typedRoutes": true

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"autolinking": {
119119
"android": {
120120
"buildFromSource": [
121+
"expo-background-task",
121122
"expo-calendar"
122123
]
123124
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const { withDangerousMod } = require('@expo/config-plugins');
4+
5+
// expo-background-task 1.0.10 cancels its own running worker at cold start.
6+
//
7+
// When WorkManager wakes a dead process, expo-task-manager restores the persisted task during
8+
// native bootstrap and calls the consumer's didRegister, which calls
9+
// BackgroundTaskScheduler.registerTask -> scheduleWorker(cancelExisting = true) -> stopWorker.
10+
// stopWorker cancels the unique work even when it is in state RUNNING, i.e. the very worker that
11+
// woke the process, and enqueues a replacement a full interval later. The job is then released
12+
// while the JS task is still running, Android freezes the cached process seconds later, and a
13+
// scheduled background sync dispatched onto a dead process never finishes.
14+
//
15+
// The fix keeps a RUNNING worker instead of cancelling it. That worker already enqueues the next
16+
// run itself when runTasks completes (scheduleWorker with cancelExisting = false), and
17+
// registerTask has already stored the new intervalMinutes, so a changed interval still applies to
18+
// that next enqueue. An ENQUEUED (not running) worker keeps the upstream cancel-and-re-enqueue
19+
// behaviour, so changing the interval from the UI still takes effect immediately.
20+
//
21+
// Delivered as a prebuild patch rather than a bun patch because FOSS builds install with
22+
// `npm ci`, which ignores bun patchedDependencies. Note this is inert on its own: expo-* packages
23+
// ship a prebuilt AAR, so `expo.autolinking.android.buildFromSource` in apps/mobile/package.json
24+
// must list expo-background-task for the patched source to be compiled.
25+
26+
const SCHEDULER_RELATIVE_PATH = path.join(
27+
'expo-background-task',
28+
'android',
29+
'src',
30+
'main',
31+
'java',
32+
'expo',
33+
'modules',
34+
'backgroundtask',
35+
'BackgroundTaskScheduler.kt'
36+
);
37+
38+
const getSchedulerCandidates = (projectRoot) => [
39+
path.join(projectRoot, 'node_modules', SCHEDULER_RELATIVE_PATH),
40+
path.join(projectRoot, '..', '..', 'node_modules', SCHEDULER_RELATIVE_PATH),
41+
];
42+
43+
// Also the grep target that proves the patched class shipped in a built APK.
44+
const APPLIED_MARKER = 'is already running - keeping it.';
45+
46+
// Second edit: getWorkerInfo returns workInfos.firstOrNull(), but the unique work is re-enqueued
47+
// with ExistingWorkPolicy.APPEND, so the name can hold several WorkInfos at once. Observed on
48+
// device: the first entry was an ENQUEUED sibling while another was RUNNING, the guard above
49+
// missed it, and stopWorker ran — and cancelUniqueWork cancels every WorkInfo under the name,
50+
// including the running one. A RUNNING entry has to win the lookup.
51+
const WORK_INFO_ANCHOR = ` val workInfos = workManager.getWorkInfosForUniqueWork(WORKER_IDENTIFIER).await()
52+
return workInfos.firstOrNull()`;
53+
54+
const WORK_INFO_MARKER = 'workInfos.firstOrNull { it.state == WorkInfo.State.RUNNING }';
55+
56+
const WORK_INFO_PATCHED = ` val workInfos = workManager.getWorkInfosForUniqueWork(WORKER_IDENTIFIER).await()
57+
// The unique work is re-enqueued with APPEND, so the name can hold several WorkInfos. A
58+
// RUNNING one must win: cancelUniqueWork cancels every entry under the name, so returning
59+
// an ENQUEUED sibling here lets the cancel path kill the worker that woke this process.
60+
return ${WORK_INFO_MARKER} ?: workInfos.firstOrNull()`;
61+
62+
const STOP_ANCHOR = ` // Stop the current worker (if any)
63+
if (cancelExisting) {
64+
stopWorker(context)
65+
}`;
66+
67+
const RUNNING_GUARD = ` // Keep a worker that is already RUNNING - it is the one that woke this process up. Cancelling
68+
// it here (task restore at cold start calls registerTask) kills the run in flight and enqueues
69+
// a replacement a full interval later. The running worker enqueues the next run itself when its
70+
// tasks finish, using the intervalMinutes registerTask just stored.
71+
if (cancelExisting && getWorkerInfo(context)?.state == WorkInfo.State.RUNNING) {
72+
Log.d(TAG, "Worker with identifier $WORKER_IDENTIFIER ${APPLIED_MARKER}")
73+
return true
74+
}
75+
76+
`;
77+
78+
const applyRunningWorkerGuard = (original) => {
79+
let next = original;
80+
if (!next.includes(APPLIED_MARKER) && next.includes(STOP_ANCHOR)) {
81+
next = next.replace(STOP_ANCHOR, `${RUNNING_GUARD}${STOP_ANCHOR}`);
82+
}
83+
if (!next.includes(WORK_INFO_MARKER) && next.includes(WORK_INFO_ANCHOR)) {
84+
next = next.replace(WORK_INFO_ANCHOR, WORK_INFO_PATCHED);
85+
}
86+
return next;
87+
};
88+
89+
const isFullyPatched = (source) =>
90+
source.includes(APPLIED_MARKER) && source.includes(WORK_INFO_MARKER);
91+
92+
const patchSchedulerSource = (projectRoot) => {
93+
let satisfied = false;
94+
for (const candidate of getSchedulerCandidates(projectRoot)) {
95+
if (!fs.existsSync(candidate)) continue;
96+
const original = fs.readFileSync(candidate, 'utf8');
97+
const next = applyRunningWorkerGuard(original);
98+
if (!isFullyPatched(next)) continue;
99+
if (next !== original) {
100+
fs.writeFileSync(candidate, next);
101+
console.log(`[patch-expo-background-task] patched ${candidate}`);
102+
}
103+
satisfied = true;
104+
}
105+
if (!satisfied) {
106+
throw new Error(
107+
'patch-expo-background-task did not apply and its marker was not found. '
108+
+ 'BackgroundTaskScheduler.kt likely changed upstream - recheck the anchor in '
109+
+ 'plugins/patch-expo-background-task.js.'
110+
);
111+
}
112+
return satisfied;
113+
};
114+
115+
const withExpoBackgroundTaskPatch = (config) =>
116+
withDangerousMod(config, [
117+
'android',
118+
async (cfg) => {
119+
patchSchedulerSource(cfg.modRequest.projectRoot);
120+
return cfg;
121+
},
122+
]);
123+
124+
module.exports = withExpoBackgroundTaskPatch;
125+
module.exports.__testables = {
126+
APPLIED_MARKER,
127+
WORK_INFO_MARKER,
128+
applyRunningWorkerGuard,
129+
getSchedulerCandidates,
130+
patchSchedulerSource,
131+
};
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, describe, expect, it } from 'vitest';
5+
6+
const plugin = require('./patch-expo-background-task');
7+
8+
const {
9+
APPLIED_MARKER,
10+
WORK_INFO_MARKER,
11+
applyRunningWorkerGuard,
12+
getSchedulerCandidates,
13+
patchSchedulerSource,
14+
} = plugin.__testables;
15+
16+
// The real vendored file. It may already carry the patch from an earlier prebuild, so the
17+
// anchor-level assertions run against a pristine fixture and this one checks the end state.
18+
const vendoredSchedulerPath = [process.cwd(), path.join(process.cwd(), 'apps', 'mobile')]
19+
.flatMap((root) => getSchedulerCandidates(root))
20+
.find((candidate) => fs.existsSync(candidate));
21+
22+
const readVendoredScheduler = () => {
23+
if (!vendoredSchedulerPath) {
24+
throw new Error('expo-background-task is not installed - cannot verify the patch.');
25+
}
26+
return fs.readFileSync(vendoredSchedulerPath, 'utf8');
27+
};
28+
29+
// The two upstream regions the patch anchors on, verbatim from expo-background-task 1.0.10.
30+
const PRISTINE_SOURCE = `object BackgroundTaskScheduler {
31+
private suspend fun scheduleWorker(context: Context, appScopeKey: String, cancelExisting: Boolean = true, overriddenIntervalMinutes: Long = intervalMinutes): Boolean {
32+
if (numberOfRegisteredTasksOfThisType == 0) {
33+
return false
34+
}
35+
36+
// Stop the current worker (if any)
37+
if (cancelExisting) {
38+
stopWorker(context)
39+
}
40+
41+
return true
42+
}
43+
44+
private suspend fun getWorkerInfo(context: Context): WorkInfo? {
45+
val workManager = WorkManager.getInstance(context)
46+
47+
return try {
48+
val workInfos = workManager.getWorkInfosForUniqueWork(WORKER_IDENTIFIER).await()
49+
return workInfos.firstOrNull()
50+
} catch (e: Exception) {
51+
return null
52+
}
53+
}
54+
}
55+
`;
56+
57+
const tempRoots = [];
58+
59+
const makeProjectRoot = (source) => {
60+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bgtask-patch-'));
61+
tempRoots.push(root);
62+
const target = getSchedulerCandidates(root)[0];
63+
fs.mkdirSync(path.dirname(target), { recursive: true });
64+
fs.writeFileSync(target, source);
65+
return { root, target };
66+
};
67+
68+
afterEach(() => {
69+
while (tempRoots.length) {
70+
fs.rmSync(tempRoots.pop(), { force: true, recursive: true });
71+
}
72+
});
73+
74+
describe('patch-expo-background-task plugin', () => {
75+
it('returns early on a running worker before scheduleWorker cancels it', () => {
76+
const patched = applyRunningWorkerGuard(PRISTINE_SOURCE);
77+
78+
expect(patched).toContain(
79+
'if (cancelExisting && getWorkerInfo(context)?.state == WorkInfo.State.RUNNING)'
80+
);
81+
expect(patched).toContain(APPLIED_MARKER);
82+
// The guard must sit above the cancel, otherwise the running worker still dies.
83+
expect(patched.indexOf(APPLIED_MARKER)).toBeLessThan(
84+
patched.indexOf('// Stop the current worker (if any)')
85+
);
86+
expect(patched).toContain(' return true\n }');
87+
});
88+
89+
it('makes a RUNNING work info win the unique-work lookup', () => {
90+
const patched = applyRunningWorkerGuard(PRISTINE_SOURCE);
91+
92+
// An APPEND chain holds several work infos; firstOrNull() can return an ENQUEUED sibling
93+
// while another is RUNNING, and cancelUniqueWork would then cancel the running one too.
94+
expect(patched).toContain(`return ${WORK_INFO_MARKER} ?: workInfos.firstOrNull()`);
95+
expect(patched).not.toMatch(/return workInfos\.firstOrNull\(\)\n/);
96+
});
97+
98+
it('leaves the cancel-and-re-enqueue path intact for a worker that is not running', () => {
99+
const patched = applyRunningWorkerGuard(PRISTINE_SOURCE);
100+
101+
expect(patched).toContain(' // Stop the current worker (if any)\n'
102+
+ ' if (cancelExisting) {\n'
103+
+ ' stopWorker(context)\n'
104+
+ ' }');
105+
});
106+
107+
it('is idempotent', () => {
108+
const once = applyRunningWorkerGuard(PRISTINE_SOURCE);
109+
110+
expect(applyRunningWorkerGuard(once)).toBe(once);
111+
});
112+
113+
it('still finds both anchors in the vendored expo-background-task source', () => {
114+
// Fails loudly if an upstream bump moves either anchor, instead of shipping an inert patch.
115+
const patched = applyRunningWorkerGuard(readVendoredScheduler());
116+
117+
expect(patched).toContain(APPLIED_MARKER);
118+
expect(patched).toContain(WORK_INFO_MARKER);
119+
});
120+
121+
it('writes both edits through patchSchedulerSource and does not rewrite them', () => {
122+
const { root, target } = makeProjectRoot(PRISTINE_SOURCE);
123+
124+
expect(patchSchedulerSource(root)).toBe(true);
125+
const afterFirst = fs.readFileSync(target, 'utf8');
126+
expect(afterFirst).toContain(APPLIED_MARKER);
127+
expect(afterFirst).toContain(WORK_INFO_MARKER);
128+
129+
expect(patchSchedulerSource(root)).toBe(true);
130+
expect(fs.readFileSync(target, 'utf8')).toBe(afterFirst);
131+
});
132+
133+
it('throws when an anchor is gone so an inert patch cannot ship silently', () => {
134+
const { root } = makeProjectRoot('object BackgroundTaskScheduler {\n}\n');
135+
136+
expect(() => patchSchedulerSource(root)).toThrow(/did not apply/);
137+
});
138+
139+
it('throws when only one of the two anchors survives upstream', () => {
140+
const halfSource = PRISTINE_SOURCE.replace(' return workInfos.firstOrNull()', ' return null');
141+
const { root } = makeProjectRoot(halfSource);
142+
143+
expect(() => patchSchedulerSource(root)).toThrow(/did not apply/);
144+
});
145+
});

docs/release-notes/unreleased.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,5 @@ Changes collected after `v1.2.6` and before the next version tag.
8787
- Self-hosted server: the attachment endpoint now answers HEAD, so a device can ask whether an attachment file is still on the server without downloading it. (#1119)
8888
- Android and iOS: searching Settings for "timeline" no longer offers a result that leads to a Features screen with no such row. The Timeline view exists on desktop only. (#1145)
8989
- Desktop and mobile: a project can now carry its own start date, next to the due date in the project details. On the desktop Timeline, a project with either date is drawn as its own slim bar above its tasks, so a project that runs over weeks reads as one span instead of a pile of task bars. When only one of the two dates is set, the bar reaches to the earliest start or latest due date among the project's tasks, and clicking the project's name on the Timeline opens the project. (email report)
90+
- Android: scheduled background sync from a closed app now runs to completion. When Android woke the app for the job, the task library cancelled the very worker that woke it and re-queued a run 15 minutes later, so the sync never finished and each window was lost. The worker is now kept while it is running. (#1001)
91+
- Android: release builds can run background tasks again. The release build stripped the class that loads the app in the background, so any background job in a shipped build failed at startup before this fix. (#1001)

0 commit comments

Comments
 (0)