Skip to content

Commit 6434c6c

Browse files
committed
fix(init): serialize registration retries
1 parent af8909b commit 6434c6c

4 files changed

Lines changed: 264 additions & 38 deletions

File tree

packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import {
@@ -75,11 +75,23 @@ describe("iOS native registration retry state", () => {
7575
const target = identity();
7676
const first = await store.getOrCreate(target);
7777

78-
await store.clear(target);
78+
expect(await store.clear(target, first)).toBe(true);
7979

8080
expect(await store.getOrCreate(target)).not.toBe(first);
8181
});
8282

83+
test("does not let a delayed clear remove a newer registration generation", async () => {
84+
const stateDirectory = await temporaryStateDirectory();
85+
const store = createIOSNativeRegistrationRetryStore(() => stateDirectory);
86+
const target = identity();
87+
const first = await store.getOrCreate(target);
88+
expect(await store.clear(target, first)).toBe(true);
89+
const newer = await store.getOrCreate(target);
90+
91+
expect(await store.clear(target, first)).toBe(false);
92+
expect(await store.peek(target)).toBe(newer);
93+
});
94+
8395
test("retains an old pending operation until remote verification clears it", async () => {
8496
const stateDirectory = await temporaryStateDirectory();
8597
const store = createIOSNativeRegistrationRetryStore(() => stateDirectory);
@@ -106,4 +118,25 @@ describe("iOS native registration retry state", () => {
106118

107119
await expect(store.getOrCreate(target)).rejects.toThrow("retry record is malformed");
108120
});
121+
122+
test("fails closed without stealing an abandoned stale filesystem lock", async () => {
123+
const stateDirectory = await temporaryStateDirectory();
124+
const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, {
125+
lockRetryMs: 1,
126+
lockTimeoutMs: 10,
127+
lockStaleMs: 5,
128+
});
129+
const target = identity();
130+
const first = await store.getOrCreate(target);
131+
const directory = join(stateDirectory, "idempotency");
132+
const [filename] = await readdir(directory);
133+
const lock = join(directory, `${filename!}.lock`);
134+
await mkdir(lock);
135+
const stale = new Date(Date.now() - 60_000);
136+
await utimes(lock, stale, stale);
137+
138+
expect(first).toStartWith("clerk-init-ios-registration-");
139+
await expect(store.getOrCreate(target)).rejects.toThrow("lock is stale");
140+
expect(await readdir(lock)).toEqual([]);
141+
});
109142
});

packages/cli-core/src/commands/init/ios/native-registration-retry.ts

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash, randomUUID } from "node:crypto";
2-
import { mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
2+
import { lstat, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
33
import { setTimeout as sleep } from "node:timers/promises";
44
import { dirname, join } from "node:path";
55
import { getConfigFile } from "../../../lib/config.ts";
@@ -12,6 +12,15 @@ const IDEMPOTENCY_KEY_PATTERN =
1212
/^clerk-init-ios-registration-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1313
const CONCURRENT_WRITE_ATTEMPTS = 20;
1414
const CONCURRENT_WRITE_RETRY_MS = 5;
15+
const LOCK_RETRY_MS = 10;
16+
const LOCK_TIMEOUT_MS = 5_000;
17+
const LOCK_STALE_MS = 30_000;
18+
19+
interface IOSNativeRegistrationRetryStoreOptions {
20+
lockRetryMs?: number;
21+
lockTimeoutMs?: number;
22+
lockStaleMs?: number;
23+
}
1524

1625
export interface IOSNativeRegistrationRetryIdentity {
1726
applicationId: string;
@@ -22,7 +31,8 @@ export interface IOSNativeRegistrationRetryIdentity {
2231

2332
export interface IOSNativeRegistrationRetryStore {
2433
getOrCreate(identity: IOSNativeRegistrationRetryIdentity): Promise<string>;
25-
clear(identity: IOSNativeRegistrationRetryIdentity): Promise<void>;
34+
peek(identity: IOSNativeRegistrationRetryIdentity): Promise<string | undefined>;
35+
clear(identity: IOSNativeRegistrationRetryIdentity, expectedKey: string): Promise<boolean>;
2636
}
2737

2838
interface IOSNativeRegistrationRetryRecord {
@@ -69,6 +79,74 @@ function isExistingFile(error: unknown): boolean {
6979
return (error as NodeJS.ErrnoException).code === "EEXIST";
7080
}
7181

82+
function lockPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string {
83+
return `${retryPath(baseDirectory, identity)}.lock`;
84+
}
85+
86+
async function acquireLock(
87+
baseDirectory: string,
88+
identity: IOSNativeRegistrationRetryIdentity,
89+
options: Required<IOSNativeRegistrationRetryStoreOptions>,
90+
): Promise<string> {
91+
await mkdir(retryDirectory(baseDirectory), { recursive: true, mode: 0o700 });
92+
const path = lockPath(baseDirectory, identity);
93+
const deadline = Date.now() + options.lockTimeoutMs;
94+
while (true) {
95+
try {
96+
await mkdir(path, { mode: 0o700 });
97+
return path;
98+
} catch (error) {
99+
if (!isExistingFile(error)) throw error;
100+
if (Date.now() >= deadline) {
101+
let stale = false;
102+
try {
103+
stale = Date.now() - (await lstat(path)).mtimeMs >= options.lockStaleMs;
104+
} catch (statError) {
105+
if (isMissingFile(statError)) continue;
106+
throw statError;
107+
}
108+
throw new Error(
109+
stale
110+
? `The Clerk iOS registration retry-state lock is stale and was left in place for safety: ${path}`
111+
: "Timed out waiting for the Clerk iOS registration retry-state lock.",
112+
);
113+
}
114+
await sleep(options.lockRetryMs);
115+
}
116+
}
117+
}
118+
119+
async function withIdentityLock<T>(
120+
baseDirectory: string,
121+
identity: IOSNativeRegistrationRetryIdentity,
122+
options: Required<IOSNativeRegistrationRetryStoreOptions>,
123+
operation: () => Promise<T>,
124+
): Promise<T> {
125+
const path = await acquireLock(baseDirectory, identity, options);
126+
const release = async () => {
127+
try {
128+
await rmdir(path);
129+
} catch (error) {
130+
if (!isMissingFile(error)) throw error;
131+
}
132+
};
133+
try {
134+
const result = await operation();
135+
await release();
136+
return result;
137+
} catch (operationError) {
138+
try {
139+
await release();
140+
} catch (releaseError) {
141+
throw new AggregateError(
142+
[operationError, releaseError],
143+
"The Clerk iOS registration retry operation and lock release both failed.",
144+
);
145+
}
146+
throw operationError;
147+
}
148+
}
149+
72150
function isRetryRecord(
73151
value: unknown,
74152
identity: IOSNativeRegistrationRetryIdentity,
@@ -173,40 +251,64 @@ async function getOrCreateRetryKey(
173251
async function clearRetryKey(
174252
baseDirectory: string,
175253
identity: IOSNativeRegistrationRetryIdentity,
176-
): Promise<void> {
254+
expectedKey: string,
255+
): Promise<boolean> {
256+
const existing = await readRetryRecord(baseDirectory, identity);
257+
if (!existing) return true;
258+
if (existing.idempotencyKey !== expectedKey) return false;
177259
try {
178260
await unlink(retryPath(baseDirectory, identity));
179261
} catch (error) {
180262
if (!isMissingFile(error)) throw error;
181-
return;
182-
}
183-
184-
// Remove only the empty operational directory. Never remove other CLI state.
185-
try {
186-
await rmdir(retryDirectory(baseDirectory));
187-
} catch {
188-
// Another retry record is present, or another process started a retry.
263+
return true;
189264
}
265+
return true;
190266
}
191267

192268
export function createIOSNativeRegistrationRetryStore(
193269
resolveBaseDirectory: () => string = () => dirname(getConfigFile()),
270+
options: IOSNativeRegistrationRetryStoreOptions = {},
194271
): IOSNativeRegistrationRetryStore {
272+
const lockOptions: Required<IOSNativeRegistrationRetryStoreOptions> = {
273+
lockRetryMs: options.lockRetryMs ?? LOCK_RETRY_MS,
274+
lockTimeoutMs: options.lockTimeoutMs ?? LOCK_TIMEOUT_MS,
275+
lockStaleMs: options.lockStaleMs ?? LOCK_STALE_MS,
276+
};
195277
return {
196278
async getOrCreate(identity) {
197279
const baseDirectory = resolveBaseDirectory();
198280
const path = retryPath(baseDirectory, identity);
199281
return withHomeFsAccess(
200282
{ operation: "write", target: path, label: "CLI idempotency state directory" },
201-
async () => getOrCreateRetryKey(baseDirectory, identity),
283+
async () =>
284+
withIdentityLock(baseDirectory, identity, lockOptions, async () =>
285+
getOrCreateRetryKey(baseDirectory, identity),
286+
),
202287
);
203288
},
204-
async clear(identity) {
289+
async peek(identity) {
205290
const baseDirectory = resolveBaseDirectory();
206291
const path = retryPath(baseDirectory, identity);
207-
await withHomeFsAccess(
292+
return withHomeFsAccess(
293+
{ operation: "read", target: path, label: "CLI idempotency state directory" },
294+
async () =>
295+
withIdentityLock(
296+
baseDirectory,
297+
identity,
298+
lockOptions,
299+
async () => (await readRetryRecord(baseDirectory, identity))?.idempotencyKey,
300+
),
301+
);
302+
},
303+
async clear(identity, expectedKey) {
304+
const baseDirectory = resolveBaseDirectory();
305+
const path = retryPath(baseDirectory, identity);
306+
return withHomeFsAccess(
208307
{ operation: "delete", target: path, label: "CLI idempotency state directory" },
209-
async () => clearRetryKey(baseDirectory, identity),
308+
async () =>
309+
withIdentityLock(baseDirectory, identity, lockOptions, async () =>
310+
clearRetryKey(baseDirectory, identity, expectedKey),
311+
),
210312
);
211313
},
212314
};

packages/cli-core/src/commands/init/ios/native-remote.test.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,15 @@ function memoryRegistrationRetryStore(): {
117117
entries.set(key, created);
118118
return created;
119119
},
120-
async clear(identity) {
121-
entries.delete(scope(identity));
120+
async peek(identity) {
121+
return entries.get(scope(identity));
122+
},
123+
async clear(identity, expectedKey) {
124+
const key = scope(identity);
125+
const existing = entries.get(key);
126+
if (existing && existing !== expectedKey) return false;
127+
entries.delete(key);
128+
return true;
122129
},
123130
},
124131
pending(identity) {
@@ -761,7 +768,7 @@ describe("Clerk Native Application remote setup", () => {
761768
message: expect.stringContaining("Xcode target identity changed"),
762769
});
763770

764-
expect(calls).toEqual(["GET native settings", "GET iOS registrations"]);
771+
expect(calls).toEqual([]);
765772
expect(calls).not.toContain("POST iOS registration");
766773
expect(calls).not.toContain("PATCH native settings");
767774
});
@@ -785,7 +792,7 @@ describe("Clerk Native Application remote setup", () => {
785792
message: expect.stringContaining("Xcode target identity could not be rechecked"),
786793
});
787794

788-
expect(calls).toEqual(["GET native settings", "GET iOS registrations"]);
795+
expect(calls).toEqual([]);
789796
expect(calls).not.toContain("POST iOS registration");
790797
expect(calls).not.toContain("PATCH native settings");
791798
});
@@ -802,7 +809,7 @@ describe("Clerk Native Application remote setup", () => {
802809
),
803810
).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE });
804811

805-
expect(calls).toEqual(["GET native settings", "GET iOS registrations"]);
812+
expect(calls).toEqual([]);
806813
expect(calls).not.toContain("POST iOS registration");
807814
expect(calls).not.toContain("PATCH native settings");
808815
});
@@ -943,6 +950,65 @@ describe("Clerk Native Application remote setup", () => {
943950
expect(retry.pending(registrationRetryIdentity())).toBeUndefined();
944951
});
945952

953+
test("rechecks remote state after a paused invocation acquires a newer retry generation", async () => {
954+
const retry = memoryRegistrationRetryStore();
955+
let releaseGet!: () => void;
956+
const getGate = new Promise<void>((resolve) => {
957+
releaseGet = resolve;
958+
});
959+
let reportPaused!: () => void;
960+
const paused = new Promise<void>((resolve) => {
961+
reportPaused = resolve;
962+
});
963+
const pausedStore: IOSNativeRegistrationRetryStore = {
964+
async getOrCreate(identity) {
965+
reportPaused();
966+
await getGate;
967+
return retry.store.getOrCreate(identity);
968+
},
969+
async peek(identity) {
970+
return retry.store.peek(identity);
971+
},
972+
async clear(identity, expectedKey) {
973+
return retry.store.clear(identity, expectedKey);
974+
},
975+
};
976+
const exactRegistration = registration();
977+
const resumed = scriptedAPI({
978+
nativeReads: [nativeSettings(true), nativeSettings(true)],
979+
registrationReads: [[exactRegistration], [exactRegistration]],
980+
});
981+
982+
const resumedApply = applyRemoteSetup(
983+
plan({ nativeApi: "satisfied", registration: "required" }),
984+
resumed.api,
985+
approvedTargetReader,
986+
pausedStore,
987+
);
988+
await paused;
989+
990+
const first = scriptedAPI({
991+
nativeReads: [nativeSettings(true), nativeSettings(true)],
992+
registrationReads: [[], [exactRegistration]],
993+
});
994+
await applyRemoteSetup(
995+
plan({ nativeApi: "satisfied", registration: "required" }),
996+
first.api,
997+
approvedTargetReader,
998+
retry.store,
999+
);
1000+
const completedKey = first.registrationIdempotencyKeys[0]!;
1001+
expect(retry.pending(registrationRetryIdentity())).toBeUndefined();
1002+
1003+
releaseGet();
1004+
await resumedApply;
1005+
1006+
expect(resumed.registrationIdempotencyKeys).toEqual([]);
1007+
expect(resumed.calls).not.toContain("POST iOS registration");
1008+
expect(retry.pending(registrationRetryIdentity())).toBeUndefined();
1009+
expect(completedKey).toStartWith("clerk-init-ios-registration-");
1010+
});
1011+
9461012
test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => {
9471013
const exactRegistration = registration();
9481014
const ambiguousError = new Error("connection reset after enable");

0 commit comments

Comments
 (0)