Skip to content

Commit 9ee744a

Browse files
feat: preserve native-first identity and invalidate superseded configuration rounds
1 parent 1ec02ad commit 9ee744a

8 files changed

Lines changed: 140 additions & 29 deletions

File tree

‎android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ final class NativeCheckOrchestrator {
7676
private static volatile NativeUpdateResult roundResult =
7777
NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
7878
private static volatile long roundGeneration = -1;
79+
private static volatile long roundConfigGeneration = -1;
7980
private static volatile String roundConfigJson;
8081

8182
/** Blocking only on the host API's worker; never call on the UI thread. */
@@ -106,7 +107,8 @@ static NativeUpdateResult checkAndUpdate(UpdateContext context) throws Interrupt
106107
return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed");
107108
}
108109
roundDone.await();
109-
if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
110+
if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()
111+
|| !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {
110112
return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed");
111113
}
112114
if (roundGeneration != UpdateContext.getResetGeneration()) {
@@ -300,6 +302,7 @@ private static void runOnce(
300302
) throws JSONException {
301303
final long resetGeneration = UpdateContext.getResetGeneration();
302304
roundGeneration = resetGeneration;
305+
roundConfigGeneration = UpdateContext.getNativeConfigGeneration();
303306
roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed");
304307
String configJson = context.getKv(KEY_CONFIG);
305308
roundConfigJson = configJson;

‎android/src/main/java/cn/reactnative/modules/update/UpdateContext.java‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -738,18 +738,34 @@ public String getBundleUrl(String defaultAssetsUrl) {
738738

739739
/** Sampled/compared by the native check orchestrator; see resetGeneration. */
740740
/** Shared by JS and native hosts. Config replacement invalidates old native decisions. */
741+
private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration =
742+
new java.util.concurrent.atomic.AtomicLong(0);
743+
744+
static long getNativeConfigGeneration() {
745+
return nativeConfigGeneration.get();
746+
}
747+
741748
void setNativeConfig(String config) {
742749
synchronized (commitLock) {
743-
if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) {
744-
return;
745-
}
746-
// Also invalidate on a failed persistence attempt: never allow an
747-
// older round to commit over uncertain configuration state.
748-
resetGeneration.incrementAndGet();
750+
boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null));
749751
SharedPreferences.Editor editor = sp.edit();
752+
if (changed) {
753+
// Also invalidate on a failed persistence attempt: an older
754+
// round must not commit over uncertain configuration state.
755+
resetGeneration.incrementAndGet();
756+
nativeConfigGeneration.incrementAndGet();
757+
editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE);
758+
NativeCheckOrchestrator.markJsCheckCompleted(null);
759+
}
760+
// A native-only first launch needs a stable gray-release identity
761+
// before JS initializes. Never replace an existing installation ID.
762+
String uuid = sp.getString("uuid", null);
763+
if (uuid == null || uuid.isEmpty()) {
764+
editor.putString("uuid", java.util.UUID.randomUUID().toString());
765+
}
750766
editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config);
751-
editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE);
752-
NativeCheckOrchestrator.markJsCheckCompleted(null);
767+
// Persist even an equal value: a previous commit may have updated
768+
// SharedPreferences memory but failed to write its file.
753769
persistEditorOrThrow(editor, "configure native update");
754770
}
755771
NativeCheckOrchestrator.onConfigured(this);

‎harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const hostRound = new NativeUpdateRound();
113113
let scheduledContext: UpdateContext | undefined;
114114
let scheduledRollback = '';
115115
let roundGeneration = -1;
116+
let roundConfigGeneration = -1;
116117
let roundConfigJson: string | undefined;
117118
let roundResult = nativeUpdateResult('failed', 'check_failed');
118119

@@ -184,7 +185,8 @@ export async function checkAndUpdateNative(
184185
return nativeUpdateResult('failed', 'invalid_config');
185186
}
186187
const result = await startNativeRound(context, scheduledRollback);
187-
if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {
188+
if (roundConfigGeneration !== context.getNativeConfigGeneration()
189+
|| configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {
188190
return nativeUpdateResult('cancelled', 'config_changed');
189191
}
190192
if (roundGeneration !== context.getResetGeneration()) {
@@ -247,6 +249,7 @@ async function runOnce(
247249
// reset 必须赢过本轮的决策。
248250
const resetGeneration = context.getResetGeneration();
249251
roundGeneration = resetGeneration;
252+
roundConfigGeneration = context.getNativeConfigGeneration();
250253
roundResult = nativeUpdateResult('failed', 'check_failed');
251254
const configJson = context.getKv(KEY_CONFIG);
252255
roundConfigJson = configJson;

‎harmony/pushy/src/main/ets/UpdateContext.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,20 @@ export class UpdateContext {
461461

462462
/** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */
463463
/** Shared JS/native configuration writer; all mutations precede the first await. */
464+
private static nativeConfigGeneration: number = 0;
465+
466+
public getNativeConfigGeneration(): number {
467+
return UpdateContext.nativeConfigGeneration;
468+
}
469+
464470
public setNativeConfig(config: string): Promise<void> {
471+
if (!this.getKv('uuid')) {
472+
this.preferences.putSync('uuid', util.generateRandomUUID());
473+
}
465474
if (this.getKv(KEY_CONFIG) !== config) {
466475
// Also guards A -> B -> A replacements and late download commits.
467476
UpdateContext.resetGeneration += 1;
477+
UpdateContext.nativeConfigGeneration += 1;
468478
this.preferences.putSync(KEY_CONFIG, config);
469479
this.preferences.deleteSync(KEY_RESP_CACHE);
470480
markJsCheckCompleted('');

‎ios/RCTPushy/RCTPushy.mm‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation
778778
static NSDictionary *pushyHostRoundResult = nil;
779779
static NSString *pushyHostRoundConfig = nil;
780780
static uint64_t pushyHostRoundGeneration = 0;
781+
static std::atomic<uint64_t> pushyNativeConfigGeneration{0};
782+
static uint64_t pushyHostRoundConfigGeneration = 0;
781783

782784
static const NSTimeInterval kPushyRescueTriggerUptime = 60;
783785
static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10;
@@ -2216,12 +2218,16 @@ @implementation RCTPushyOrchestrator
22162218
+ (void)persistConfiguration:(NSString *)config {
22172219
PushyWithStateLock(^{
22182220
NSUserDefaults *defaults = PushyDefaults();
2221+
if ([defaults stringForKey:keyUuid].length == 0) {
2222+
[defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid];
2223+
}
22192224
if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) {
22202225
return;
22212226
}
22222227
// The same generation protects reset and replacement of the request
22232228
// identity/policy, including a late crash-rescue activation.
22242229
pushyResetGeneration.fetch_add(1);
2230+
pushyNativeConfigGeneration.fetch_add(1);
22252231
[defaults setObject:config forKey:keyNativeConfig];
22262232
[defaults removeObjectForKey:keyNativeCheckCache];
22272233
[self markJsCheckCompleted:nil];
@@ -2309,7 +2315,8 @@ + (NSDictionary *)checkAndUpdate {
23092315
return PushyHostResult(@"skipped", @"config_changed", nil, NO);
23102316
}
23112317
dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER);
2312-
if (![configJson isEqualToString:pushyHostRoundConfig]
2318+
if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()
2319+
|| ![configJson isEqualToString:pushyHostRoundConfig]
23132320
|| ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) {
23142321
return PushyHostResult(@"cancelled", @"config_changed", nil, NO);
23152322
}
@@ -2449,6 +2456,7 @@ + (RCTPushy *)engine {
24492456
+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {
24502457
const uint64_t resetGeneration = pushyResetGeneration.load();
24512458
pushyHostRoundGeneration = resetGeneration;
2459+
pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load();
24522460
pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO);
24532461
NSUserDefaults *defaults = PushyDefaults();
24542462
NSString *configJson = [defaults stringForKey:keyNativeConfig];

‎ios/RCTPushy/RCTPushyNativeConfig.mm‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ static void PushyConfigInvalid(NSString *message) {
7575
PushyConfigInvalid(@"afterDownload must be none or setNeedUpdate");
7676
}
7777
id disabled = options[@"disabled"] ?: @NO;
78-
if (CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {
78+
if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {
7979
PushyConfigInvalid(@"disabled must be a boolean");
8080
}
8181
NSMutableDictionary *result = [@{

‎src/__tests__/nativeConfiguration.test.ts‎

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ function runtimeSource(relativePath: string): string {
1212
return new Bun.Transpiler({ loader: 'ts' }).transformSync(source);
1313
}
1414

15-
const storeSource = runtimeSource('../../harmony/pushy/src/main/ets/UpdateContext.ts');
15+
const storeSource = runtimeSource(
16+
'../../harmony/pushy/src/main/ets/UpdateContext.ts'
17+
);
1618
const clientSource = runtimeSource('../client.ts');
1719

1820
interface ConfigStore {
@@ -45,6 +47,7 @@ function storeHarness() {
4547
`${storeSource}\nconst store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;`,
4648
{
4749
preferences,
50+
util: { generateRandomUUID: () => 'native-installation-id' },
4851
KEY_CONFIG: 'nativeConfig',
4952
KEY_RESP_CACHE: 'nativeCheckResp',
5053
markJsCheckCompleted: (config: string) => clearedSignals.push(config),
@@ -53,8 +56,12 @@ function storeHarness() {
5356
}
5457
) as ConfigStore;
5558
return {
56-
store, values, clearedSignals,
57-
fail: (value: boolean) => { shouldFail = value; },
59+
store,
60+
values,
61+
clearedSignals,
62+
fail: (value: boolean) => {
63+
shouldFail = value;
64+
},
5865
flushes: () => flushes,
5966
};
6067
}
@@ -77,7 +84,9 @@ function clientHarness(native: boolean) {
7784
i18n: { setLocale() {} },
7885
dedupeEndpoints: (urls: string[]) => [...new Set(urls)],
7986
PushyModule: {
80-
syncNativeConfig: async (config: string) => { writes.push(config); },
87+
syncNativeConfig: async (config: string) => {
88+
writes.push(config);
89+
},
8190
},
8291
}
8392
) as { setOptions: (options: Record<string, unknown>) => void };
@@ -86,9 +95,12 @@ function clientHarness(native: boolean) {
8695

8796
describe('native configuration normalization', () => {
8897
test('appKey alone supplies Pushy endpoints without automatically activating', () => {
89-
const config = JSON.parse(normalizeNativeUpdateConfig({ appKey: 'test-app' }));
98+
const config = JSON.parse(
99+
normalizeNativeUpdateConfig({ appKey: 'test-app' })
100+
);
90101
expect(config.endpoints).toEqual([
91-
'https://update.react-native.cn/api', 'https://update.reactnative.cn/api',
102+
'https://update.react-native.cn/api',
103+
'https://update.reactnative.cn/api',
92104
]);
93105
expect(config.queryUrls).toHaveLength(2);
94106
expect(config.afterDownload).toBe('none');
@@ -99,7 +111,10 @@ describe('native configuration normalization', () => {
99111
test('custom endpoints do not inherit public discovery, and are deduplicated', () => {
100112
const options: NativeUpdateConfig = {
101113
appKey: 'test-app',
102-
endpoints: ['https://updates.example/api/', 'https://updates.example/api'],
114+
endpoints: [
115+
'https://updates.example/api/',
116+
'https://updates.example/api',
117+
],
103118
afterDownload: 'setNeedUpdate',
104119
};
105120
const original = JSON.stringify(options);
@@ -111,24 +126,34 @@ describe('native configuration normalization', () => {
111126
});
112127

113128
test('allows explicit discovery URLs and version identity overrides', () => {
114-
const config = JSON.parse(normalizeNativeUpdateConfig({
115-
appKey: 'test-app', endpoints: ['http://localhost:8080/api'],
116-
queryUrls: ['https://updates.example/endpoints.json?v=1'],
117-
packageVersion: '2.0', rn: '0.77.3', rnu: 'test-sdk', disabled: true,
118-
}));
129+
const config = JSON.parse(
130+
normalizeNativeUpdateConfig({
131+
appKey: 'test-app',
132+
endpoints: ['http://localhost:8080/api'],
133+
queryUrls: ['https://updates.example/endpoints.json?v=1'],
134+
packageVersion: '2.0',
135+
rn: '0.77.3',
136+
rnu: 'test-sdk',
137+
disabled: true,
138+
})
139+
);
119140
expect(config.packageVersion).toBe('2.0');
120141
expect(config.disabled).toBe(true);
121142
expect(config.queryUrls[0]).toContain('?v=1');
122143
});
123144

124145
for (const [name, options] of [
125-
['missing key', {}], ['blank key', { appKey: ' ' }],
146+
['missing key', {}],
147+
['blank key', { appKey: ' ' }],
126148
['wrong key type', { appKey: 12 }],
127149
['empty endpoints', { appKey: 'a', endpoints: [] }],
128150
['null endpoints', { appKey: 'a', endpoints: null }],
129151
['non-array endpoints', { appKey: 'a', endpoints: 'https://example.com' }],
130152
['unsafe scheme', { appKey: 'a', endpoints: ['file:///tmp/update'] }],
131-
['credentials', { appKey: 'a', endpoints: ['https://user:pass@example.com'] }],
153+
[
154+
'credentials',
155+
{ appKey: 'a', endpoints: ['https://user:pass@example.com'] },
156+
],
132157
['relative URL', { appKey: 'a', endpoints: ['/api'] }],
133158
['base query', { appKey: 'a', endpoints: ['https://example.com/api?x=1'] }],
134159
['wrong discovery type', { appKey: 'a', queryUrls: [42] }],
@@ -138,7 +163,9 @@ describe('native configuration normalization', () => {
138163
['unknown option', { appKey: 'a', endponts: ['https://example.com'] }],
139164
] as const) {
140165
test(`rejects ${name} before storage is touched`, () => {
141-
expect(() => normalizeNativeUpdateConfig(options as unknown as NativeUpdateConfig)).toThrow();
166+
expect(() =>
167+
normalizeNativeUpdateConfig(options as unknown as NativeUpdateConfig)
168+
).toThrow();
142169
});
143170
}
144171
});
@@ -153,8 +180,17 @@ describe('actual native configuration store', () => {
153180
expect(h.values.has('nativeCheckResp')).toBe(false);
154181
await h.store.setNativeConfig('A');
155182
expect(h.store.getResetGeneration()).toBe(oldGeneration + 2);
156-
expect(await h.store.commitNativeCheckResult(oldGeneration, 'old', '{}', true, 'stale')).toBe(false);
183+
expect(
184+
await h.store.commitNativeCheckResult(
185+
oldGeneration,
186+
'old',
187+
'{}',
188+
true,
189+
'stale'
190+
)
191+
).toBe(false);
157192
expect(h.values.has('hash_old')).toBe(false);
193+
expect(h.values.get('uuid')).toBe('native-installation-id');
158194
expect(h.clearedSignals).toEqual(['', '', '']);
159195
});
160196

@@ -170,7 +206,9 @@ describe('actual native configuration store', () => {
170206
test('storage errors reject and an equal-value retry can recover', async () => {
171207
const h = storeHarness();
172208
h.fail(true);
173-
await expect(h.store.setNativeConfig('A')).rejects.toThrow('storage unavailable');
209+
await expect(h.store.setNativeConfig('A')).rejects.toThrow(
210+
'storage unavailable'
211+
);
174212
h.fail(false);
175213
await h.store.setNativeConfig('A');
176214
expect(h.flushes()).toBe(2);

‎src/__tests__/nativeHostApi.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function harness() {
5353
values.delete(key);
5454
},
5555
getResetGeneration: () => state.generation,
56+
getNativeConfigGeneration: () => 0,
5657
getCurrentVersion: () => '',
5758
getPackageVersion: () => '1.0',
5859
getBuildTime: () => '123',
@@ -205,3 +206,35 @@ describe('native host API orchestration', () => {
205206
expect(h.state.checks).toBe(1);
206207
});
207208
});
209+
210+
test('automatic preflight without configuration leaves a round for later native provisioning', async () => {
211+
const h = harness();
212+
h.values.delete('nativeConfig');
213+
h.initialize();
214+
for (const timer of h.timers) timer();
215+
await Promise.resolve();
216+
expect(h.state.checks).toBe(0);
217+
h.values.set(
218+
'nativeConfig',
219+
JSON.stringify({
220+
appKey: 'native-first-app',
221+
afterDownload: 'setNeedUpdate',
222+
})
223+
);
224+
h.state.decision = { action: 'download', hash: 'v2', activate: true };
225+
expect((await h.check()).activated).toBe(true);
226+
expect(h.state.checks).toBe(1);
227+
});
228+
229+
test('configuration replacement during an update cancels its returned snapshot', async () => {
230+
const h = harness();
231+
h.initialize();
232+
h.state.decision = { action: 'download', hash: 'v2', activate: true };
233+
h.state.beforeResponse = async () => {
234+
h.values.set('nativeConfig', JSON.stringify({ appKey: 'replacement' }));
235+
h.state.generation += 1;
236+
};
237+
expect(await h.check()).toEqual(
238+
nativeUpdateResult('cancelled', 'config_changed')
239+
);
240+
});

0 commit comments

Comments
 (0)