fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal (#1508)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-06 13:57:05 -04:00
committed by GitHub
parent 2eebeb54bc
commit c2ee4d1627
13 changed files with 268 additions and 19 deletions

View File

@@ -94,8 +94,8 @@ const overrides = new Map([
// #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies
// contract (replies-only, root excluded — the query keys on root_event_id,
// which root rows lack). Documentation accuracy, not code growth.
// linux-updater-manual: isAutoUpdateSupported() binding adds 7 lines.
["src/shared/api/tauri.ts", 1347],
// linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field.
["src/shared/api/tauri.ts", 1349],
// harness-persona-sync feature growth, queued to split in the resolver-unify
// refactor followup. discovery.rs is dominated by the new test module
// (the effective_agent_command / divergent / create-time override matrix);

View File

@@ -319,5 +319,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
about: None,
nip05_handle: None,
owner_pubkey: None,
has_profile_event: false,
}
}

View File

@@ -16,6 +16,11 @@ pub struct ProfileInfo {
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
/// `true` when a real kind:0 event was found on the relay; `false` for the
/// synthesized fallback returned when no metadata event exists. The
/// onboarding gate uses this to distinguish "new user with no profile" from
/// "returning user whose display_name happens to be empty".
pub has_profile_event: bool,
}
#[derive(Serialize, Deserialize)]

View File

@@ -304,6 +304,7 @@ pub fn profile_info_from_event(event: &Event) -> Result<ProfileInfo, String> {
about,
nip05_handle,
owner_pubkey: profile_valid_oa_owner_pubkey(event),
has_profile_event: true,
})
}

View File

@@ -124,7 +124,7 @@ type UseFirstRunOnboardingGateOptions = {
identityIsFetching: boolean;
identityStatus: QueryStatus;
isSharedIdentity: boolean;
profileDisplayName: string | null | undefined;
profileHasEvent: boolean | undefined;
profileIsFetching: boolean;
profileStatus: QueryStatus;
};
@@ -219,7 +219,7 @@ export function useFirstRunOnboardingGate({
identityIsFetching,
identityStatus,
isSharedIdentity,
profileDisplayName,
profileHasEvent,
profileIsFetching,
profileStatus,
}: UseFirstRunOnboardingGateOptions) {
@@ -288,19 +288,32 @@ export function useFirstRunOnboardingGate({
return;
}
// If the relay already has a profile with a display name for this pubkey,
// the user has previously completed onboarding (possibly on another
// machine or app data directory). Skip the onboarding flow and mark as
// complete so they go straight to the app.
// If the relay has a real kind:0 metadata event for this pubkey, the user
// has previously completed onboarding (possibly on another machine or app
// data directory). Skip the onboarding flow and mark as complete so they
// go straight to the app.
//
// We gate on `hasProfileEvent` — a flag set by the Tauri backend when a
// real kind:0 event was found — rather than any field value. This correctly
// handles the case where a returning user's display_name is empty: the event
// still exists, so onboarding is skipped. A missing event (new user, or no
// kind:0 on the relay) always shows onboarding regardless of display_name.
const hasExistingProfile =
profileStatus === "success" &&
typeof profileDisplayName === "string" &&
profileDisplayName.trim().length > 0;
profileStatus === "success" && profileHasEvent === true;
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => {
// Re-read localStorage here to handle the webkit2gtk WAL race: the
// synchronous useState initializer may have run before the WAL was
// merged into the main SQLite file, returning null for a flag that is
// actually present. By the time this effect fires (identity + profile
// settled), the WAL has had time to merge and the read is reliable.
const hasCompletedAfterRecheck =
readOnboardingCompletion(currentPubkey);
const alreadyOnboarded =
activeGateState.hasCompletedCurrentPubkey || hasExistingProfile;
activeGateState.hasCompletedCurrentPubkey ||
hasCompletedAfterRecheck ||
hasExistingProfile;
if (alreadyOnboarded && typeof window !== "undefined") {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
@@ -321,7 +334,7 @@ export function useFirstRunOnboardingGate({
hasSettledCurrentPubkey,
identityStatus,
isSharedIdentity,
profileDisplayName,
profileHasEvent,
profileIsFetching,
profileStatus,
]);
@@ -382,7 +395,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityStatus: identityQuery.status,
isSharedIdentity,
profileDisplayName: profileQuery.data?.displayName,
profileHasEvent: profileQuery.data?.hasProfileEvent,
profileIsFetching: profileQuery.fetchStatus === "fetching",
profileStatus: profileQuery.status,
});

View File

@@ -75,6 +75,10 @@ async function persistSelfProfile(
avatarUrl: profile.avatarUrl,
avatarDataUrl,
updatedAt: Date.now(),
// Only persist the presence bit when true — no-event fallbacks
// (hasProfileEvent: false) must not be cached as real profiles,
// which would cause the onboarding gate to skip on next restart.
...(profile.hasProfileEvent && { hasProfileEvent: true }),
});
}
@@ -105,6 +109,10 @@ export function useProfileQuery(enabled = true) {
about: null,
nip05Handle: null,
ownerPubkey: null,
// Only true when the cache entry was explicitly written with a
// real kind:0-backed profile. Older entries (absent field) and
// no-event fallbacks default to false — conservative is correct.
hasProfileEvent: cached.hasProfileEvent === true,
} satisfies Profile)
: undefined,
[cached, pubkey],
@@ -370,7 +378,15 @@ export function useUsersBatchQuery(
for (const [pubkey, summary] of Object.entries(profiles)) {
queryClient.setQueryData<Profile>(
["user-profile", pubkey],
(existing) => existing ?? { pubkey, about: null, ...summary },
(existing) =>
existing ?? {
pubkey,
about: null,
// Batch endpoint gives UserProfileSummary (no event-presence flag).
// These cached summaries are never used for the onboarding gate.
hasProfileEvent: false,
...summary,
},
);
}
}, [query.data, queryClient]);

View File

@@ -212,6 +212,53 @@ function makeCache(overrides = {}) {
};
}
test("parseSelfProfileCache: hasProfileEvent true is preserved", () => {
const result = parseSelfProfileCache({
version: 1,
displayName: null,
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1700000000000,
hasProfileEvent: true,
});
assert.equal(result?.hasProfileEvent, true);
});
test("parseSelfProfileCache: hasProfileEvent false is omitted (conservative default)", () => {
const result = parseSelfProfileCache({
version: 1,
displayName: null,
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1700000000000,
hasProfileEvent: false,
});
assert.equal(result?.hasProfileEvent, undefined);
});
test("parseSelfProfileCache: absent hasProfileEvent field is omitted (legacy v1 migration)", () => {
const result = parseSelfProfileCache({
version: 1,
displayName: "Alice",
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1700000000000,
});
assert.equal(result?.hasProfileEvent, undefined);
});
test("parseSelfProfileCache: non-boolean hasProfileEvent is coerced to absent", () => {
const result = parseSelfProfileCache({
version: 1,
displayName: null,
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1700000000000,
hasProfileEvent: "yes",
});
assert.equal(result?.hasProfileEvent, undefined);
});
test("shouldFetchAvatar: URL changed → fetch", () => {
const existing = makeCache({
avatarUrl: "https://relay.example.com/old.jpg",

View File

@@ -43,6 +43,15 @@ export type SelfProfileCache = {
avatarDataUrl: string | null;
/** ms timestamp of last successful profile fetch. 0 = never fetched. */
updatedAt: number;
/**
* True only when the cached result was backed by a real kind:0 metadata
* event on the relay. False (or absent in older v1 entries) means the
* backend returned the synthesized no-event fallback.
*
* Conservative default: absent = false. Callers must not promote an absent
* or false entry to hasProfileEvent: true — that reopens the onboarding bug.
*/
hasProfileEvent?: boolean;
};
const DEFAULT_CACHE: SelfProfileCache = Object.freeze({
@@ -87,8 +96,19 @@ export function parseSelfProfileCache(json: unknown): SelfProfileCache | null {
typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt)
? obj.updatedAt
: 0;
// Conservative: absent field in older v1 entries defaults to false.
// Only a stored true value (written from a profile with has_profile_event)
// is promoted — absent/false must never become true.
const hasProfileEvent = obj.hasProfileEvent === true ? true : undefined;
return { version: 1, displayName, avatarUrl, avatarDataUrl, updatedAt };
return {
version: 1,
displayName,
avatarUrl,
avatarDataUrl,
updatedAt,
...(hasProfileEvent !== undefined && { hasProfileEvent }),
};
}
/**

View File

@@ -165,6 +165,8 @@ export function buildPersonaDraftProfile(persona: AgentPersona): Profile {
about: null,
nip05Handle: null,
ownerPubkey: null,
// Draft profile synthesised from persona config — not backed by a kind:0 event.
hasProfileEvent: false,
};
}

View File

@@ -59,6 +59,7 @@ type RawProfile = {
about: string | null;
nip05_handle: string | null;
owner_pubkey: string | null;
has_profile_event?: boolean;
};
type RawUserProfileSummary = Omit<RawProfile, "pubkey" | "about"> & {
@@ -427,6 +428,7 @@ function fromRawProfile(profile: RawProfile): Profile {
about: profile.about,
nip05Handle: profile.nip05_handle,
ownerPubkey: profile.owner_pubkey,
hasProfileEvent: profile.has_profile_event ?? false,
};
}

View File

@@ -115,6 +115,11 @@ export type Profile = {
about: string | null;
nip05Handle: string | null;
ownerPubkey: string | null;
/** True when a real kind:0 metadata event exists on the relay for this pubkey.
* False for the synthesized fallback returned when no event is present.
* Used by the onboarding gate to distinguish new users from returning users
* whose display name happens to be empty. */
hasProfileEvent: boolean;
};
export type UserProfileSummary = {

View File

@@ -197,6 +197,9 @@ type RawProfile = {
nip05_handle: string | null;
owner_pubkey: string | null;
is_agent?: boolean;
/** Mirrors the Rust `has_profile_event` flag: true when a real kind:0 event
* backed this profile, false for the synthesized empty fallback. */
has_profile_event: boolean;
};
type RawUserProfileSummary = {
@@ -1594,6 +1597,7 @@ function resetMockManagedAgents(config?: E2eConfig) {
nip05_handle: null,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
is_agent: true,
has_profile_event: true,
});
for (const channel of mockChannels) {
const isSeedChannel =
@@ -1777,6 +1781,7 @@ function seedMockSearchProfiles(config?: E2eConfig) {
nip05_handle: seed.nip05Handle ?? null,
owner_pubkey: seed.ownerPubkey ?? null,
is_agent: seed.isAgent ?? false,
has_profile_event: true,
};
mockProfiles.set(pubkey, profile);
applyMockDisplayName(pubkey, seed.displayName);
@@ -1805,6 +1810,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null {
nip05_handle: null,
owner_pubkey: null,
is_agent: mockAgentPubkeys.has(normalizedPubkey),
has_profile_event: true,
};
}
@@ -2515,8 +2521,14 @@ const mockProfiles = new Map<string, RawProfile>([
nip05_handle: null,
owner_pubkey: null,
is_agent: false,
has_profile_event: true,
},
],
// alice, bob, and charlie are intentionally NOT seeded here — they are
// covered by mockDisplayNames + mockAgentPubkeys and synthesised on demand
// by getMockProfileByPubkey. Static seeds would cause ensureMockProfile to
// return has_profile_event:true when alice/bob/charlie are used as the
// active first-run identity, incorrectly skipping onboarding page 1.
[
PROFILE_ONLY_AGENT_PUBKEY,
{
@@ -2527,6 +2539,7 @@ const mockProfiles = new Map<string, RawProfile>([
nip05_handle: null,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
is_agent: true,
has_profile_event: true,
},
],
[
@@ -2539,6 +2552,7 @@ const mockProfiles = new Map<string, RawProfile>([
nip05_handle: null,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
is_agent: true,
has_profile_event: true,
},
],
]);
@@ -2666,6 +2680,10 @@ function importMockIdentity(nsec: string) {
about: null,
nip05_handle: null,
owner_pubkey: null,
// A non-empty username means this identity is registered in
// mockDisplayNames — it has a real mock relay profile (kind:0).
// A truly new identity (no username) has no event yet.
has_profile_event: username.length > 0,
});
}
@@ -2706,13 +2724,18 @@ function ensureMockProfile(config: E2eConfig | undefined): RawProfile {
return existing;
}
const displayName = getMockMemberDisplayName(config);
const profile = {
pubkey,
display_name: getMockMemberDisplayName(config),
display_name: displayName,
avatar_url: null,
about: null,
nip05_handle: null,
owner_pubkey: null,
// Synthesised fallback: no kind:0 event exists on the relay for this
// identity. Always false regardless of display name so the onboarding
// gate cannot mistake a blank first-run identity for a returning user.
has_profile_event: false,
};
mockProfiles.set(pubkey, profile);
return profile;
@@ -4557,6 +4580,7 @@ async function handleGetProfile(config: E2eConfig | undefined) {
avatar_url: null,
nip05_handle: null,
owner_pubkey: null,
has_profile_event: false,
};
}
const content = JSON.parse(events[0].content ?? "{}");
@@ -4567,6 +4591,7 @@ async function handleGetProfile(config: E2eConfig | undefined) {
avatar_url: content.picture ?? null,
nip05_handle: content.nip05 ?? null,
owner_pubkey: null,
has_profile_event: true,
};
}
@@ -4651,6 +4676,7 @@ async function handleUpdateProfile(
avatar_url: updated.picture ?? null,
nip05_handle: updated.nip05 ?? null,
owner_pubkey: null,
has_profile_event: true,
};
}
@@ -4683,6 +4709,7 @@ async function handleGetUserProfile(
avatar_url: null,
nip05_handle: null,
owner_pubkey: null,
has_profile_event: false,
};
}
const content = JSON.parse(events[0].content ?? "{}");
@@ -4693,6 +4720,7 @@ async function handleGetUserProfile(
avatar_url: content.picture ?? null,
nip05_handle: content.nip05 ?? null,
owner_pubkey: null,
has_profile_event: true,
};
}
@@ -6589,6 +6617,7 @@ async function handleCreateManagedAgent(
nip05_handle: null,
owner_pubkey: MOCK_IDENTITY_PUBKEY,
is_agent: true,
has_profile_event: true,
});
syncMockRelayAgentsFromManagedAgents();

View File

@@ -694,6 +694,88 @@ test("identity fallback text does not count as a real onboarding name", async ({
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
});
// Regression test for the H2 predicate fix (PR #1508).
// A blank first-run identity (no kind:0 event on the relay) must see
// onboarding even when the mock bridge returns display_name: "" — the gate
// must depend on `hasProfileEvent`, not on `typeof displayName === "string"`.
test("first-run blank identity with no profile event sees onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Do NOT seed searchProfiles for tyler's pubkey, so ensureMockProfile
// constructs a synthesised profile with has_profile_event: false.
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expectIncompleteOnboarding(page);
});
// Regression test for the H2 predicate fix (PR #1508).
// A returning user who has a real kind:0 profile event with an empty
// display_name must skip onboarding — they are already onboarded.
test("returning user with blank display name and real profile event skips onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Seed tyler's pubkey into searchProfiles with an empty displayName.
// seedMockSearchProfiles stores this into mockProfiles with
// has_profile_event: true, simulating a real kind:0 event with no name.
await installMockBridge(
page,
{
searchProfiles: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
displayName: "",
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Profile event exists → onboarding is skipped, app renders.
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
// Regression test for the cache-seed defect (PR #1508 CRITICAL fix).
// Sequence: no-event profile fetched and cached with hasProfileEvent absent →
// reload with cache present → onboarding must still show. Previously the
// initialData seed hardcoded hasProfileEvent: true for any updatedAt > 0
// entry, reopening the original bug on the second app load.
test("no-event profile cached then reloaded still sees onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// Seed a stale v1 cache entry WITHOUT hasProfileEvent (simulating a cache
// written by the old code path or a no-event fallback). updatedAt > 0 so
// the seed is eligible, but hasProfileEvent is absent → conservative false.
const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:ws://localhost:3000:${TEST_IDENTITIES.tyler.pubkey}`;
await page.addInitScript(
({ key, cache }) => {
window.localStorage.setItem(key, JSON.stringify(cache));
},
{
key: SELF_PROFILE_CACHE_KEY,
cache: {
version: 1,
displayName: null,
avatarUrl: null,
avatarDataUrl: null,
updatedAt: 1_700_000_000_000,
// hasProfileEvent deliberately absent — legacy/no-event entry.
},
},
);
// No profile event on the relay either — ensureMockProfile uses false.
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
// Cache seed must NOT promote hasProfileEvent to true. Onboarding shows.
await expectIncompleteOnboarding(page);
});
test("avatar step uses an add-image placeholder before an avatar is chosen", async ({
page,
}) => {
@@ -1037,8 +1119,21 @@ test("existing relay profile with display name auto-skips onboarding without loc
}) => {
// A user whose relay profile already has a display name should skip
// onboarding even without the localStorage completion flag.
// Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes
// has_profile_event: true into mockProfiles — the harness-intended mechanism
// for simulating a returning user with a real kind:0 event. Do NOT use the
// static mockProfiles seed (removed in PR #1508); that path collides with
// FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs.
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await installMockBridge(
page,
{
searchProfiles: [
{ pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" },
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
@@ -1280,8 +1375,21 @@ test("existing relay profile with display name auto-completes onboarding", async
// A user whose relay profile already has a display name should skip
// onboarding entirely — they've already set up their identity previously
// (possibly on another machine or app data directory).
// Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes
// has_profile_event: true into mockProfiles — the harness-intended mechanism
// for simulating a returning user with a real kind:0 event. Do NOT use the
// static mockProfiles seed (removed in PR #1508); that path collides with
// FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs.
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await installMockBridge(
page,
{
searchProfiles: [
{ pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" },
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);