Skip to content

Commit 2a76f87

Browse files
committed
fix(auth): serialize session-state commits
With async encryption, two overlapping project selections could interleave across the await in commitSessionState: an earlier selection completing last would overwrite the newer stored session and published state (and persistSession's own read of the prior selection could race), leaving the committed selection stale — not the one the user picked last. Serialize commits onto a promise chain so each commit's persist-then-publish runs to completion before the next begins; the latest selection now wins consistently across the in-memory session, storage, and subscribers. The stored chain swallows rejections so a failed commit can't wedge later ones, while the returned promise still rejects for the caller. Add a regression test that overlaps two selections, drains encryption newest-first, and asserts the latest selection wins everywhere. Generated-By: PostHog Code Task-Id: 77d13a30-444d-4045-9d80-5e2a9f2e68ae
1 parent 7023c2c commit 2a76f87

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

packages/core/src/auth/auth.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,77 @@ describe("AuthService", () => {
610610
);
611611
});
612612

613+
it("keeps overlapping selections consistent so the latest one wins", async () => {
614+
const orgs = {
615+
"org-1": {
616+
name: "Org 1",
617+
projects: [
618+
{ id: 42, name: "Project 42" },
619+
{ id: 84, name: "Project 84" },
620+
{ id: 99, name: "Project 99" },
621+
],
622+
},
623+
};
624+
oauthFlow.startFlow.mockResolvedValue(
625+
mockTokenResponse({
626+
accessToken: "initial-access-token",
627+
refreshToken: "initial-refresh-token",
628+
}),
629+
);
630+
stubAuthFetch({ orgs });
631+
632+
// Encryption is gated so overlapping selection commits stay in flight and
633+
// can be resolved out of order below.
634+
let deferEncrypt = false;
635+
const pending: Array<() => void> = [];
636+
const gatedCipher: IAuthTokenCipher = {
637+
encrypt: (plaintext) =>
638+
deferEncrypt
639+
? new Promise<string>((resolve) => {
640+
pending.push(() => resolve(plaintext));
641+
})
642+
: Promise.resolve(plaintext),
643+
decrypt: (encrypted) => Promise.resolve(encrypted),
644+
};
645+
service = new AuthService(
646+
preferencePort,
647+
sessionPort,
648+
oauthFlow as unknown as IAuthOAuthFlowService,
649+
connectivity,
650+
gatedCipher,
651+
mockPowerManager as unknown as IPowerManager,
652+
mockLogger,
653+
null,
654+
);
655+
service.init();
656+
await service.initialize();
657+
await service.login("us");
658+
expect(service.getState().currentProjectId).toBe(42);
659+
660+
deferEncrypt = true;
661+
// Two overlapping selections: 84 first, then 99 (the latest intent).
662+
const first = service.selectProject(84);
663+
const second = service.selectProject(99);
664+
665+
// Drain pending encryptions newest-first each round to surface any
666+
// out-of-order completion, until both commits settle. Serialized commits
667+
// only expose one pending encryption at a time; unserialized ones would
668+
// let the stale 84 commit land last.
669+
const flush = () => new Promise((r) => setTimeout(r, 0));
670+
await flush();
671+
while (pending.length > 0) {
672+
for (const resolve of pending.splice(0).reverse()) resolve();
673+
await flush();
674+
}
675+
await Promise.all([first, second]);
676+
677+
// The latest selection wins everywhere — no stale overwrite and no split
678+
// between the in-memory session (getState), stored session, and preference.
679+
expect(service.getState().currentProjectId).toBe(99);
680+
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(99);
681+
expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(99);
682+
});
683+
613684
it("restores the selected project after app restart while logged out", async () => {
614685
const orgs = {
615686
"org-1": {

packages/core/src/auth/auth.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
8686
private session: InMemorySession | null = null;
8787
private initializePromise: Promise<void> | null = null;
8888
private refreshPromise: Promise<InMemorySession> | null = null;
89+
// Serializes session-state commits so overlapping selections can't interleave
90+
// across async encryption (see commitSessionState).
91+
private commitChain: Promise<void> = Promise.resolve();
8992
constructor(
9093
@inject(AUTH_PREFERENCE_STORE)
9194
private readonly authPreference: IAuthPreferenceStore,
@@ -326,7 +329,30 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
326329
}
327330
return orgProjects[0]?.id ?? null;
328331
}
329-
private async commitSessionState(
332+
private commitSessionState(
333+
prevSession: InMemorySession,
334+
next: {
335+
orgProjectsMap: OrgProjectsMap;
336+
currentOrgId: string | null;
337+
currentProjectId: number | null;
338+
},
339+
): Promise<void> {
340+
// Serialize commits onto a chain. Encryption is async, so two overlapping
341+
// selections would otherwise interleave across the await — an earlier one
342+
// completing last would clobber the newer stored session and published
343+
// state (and persistSession's own read of the prior selection would race).
344+
// Chaining runs each commit's persist-then-publish to completion before the
345+
// next starts, so the latest selection wins consistently across the
346+
// in-memory session, storage, and subscribers. The stored chain swallows
347+
// rejections so one failed commit doesn't wedge later ones; the returned
348+
// promise still rejects for the caller.
349+
const run = this.commitChain.then(() =>
350+
this.applyCommittedSession(prevSession, next),
351+
);
352+
this.commitChain = run.catch(() => {});
353+
return run;
354+
}
355+
private async applyCommittedSession(
330356
prevSession: InMemorySession,
331357
next: {
332358
orgProjectsMap: OrgProjectsMap;

0 commit comments

Comments
 (0)