fix(desktop): close section workspace cutover races

Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
Other Brother Darryl
2026-08-13 18:27:03 -04:00
parent fd95520400
commit ec2d28cd10
5 changed files with 303 additions and 76 deletions
@@ -24,6 +24,8 @@ const OWNER_BYTES = new Uint8Array(32).fill(1);
const OWNER =
"1111111111111111111111111111111111111111111111111111111111111111";
const RELAY = "wss://Relay.Example/";
const RELAY_SELF =
"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f";
const PROJECTION_EVENT = {
id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
pubkey: "9999999999999999999999999999999999999999999999999999999999999999",
@@ -149,6 +151,7 @@ test("projection is decrypted, cached by normalized relay, and rendered during o
subscribeLive: async () => async () => {},
});
const restoreWindow = installWindow(storage, async (command, args) => {
if (command === "get_relay_self") return RELAY_SELF;
if (command === "nip44_decrypt_from_self") return "workspace-key";
if (command === "decrypt_workspace_metadata") {
if (args.envelope === "ciphertext-alpha") return "Alpha";
@@ -16,7 +16,15 @@ import {
import type { RelayEvent } from "@/shared/api/types";
import type { LegacySectionSource } from "./channelSectionsSync";
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import {
hasImportMarker,
readStoredImportAction,
readStoredImportCommand,
clearImportAction,
writeImportState,
} from "./sectionWorkspaceStorage";
import {
KIND_SECTION_WORKSPACE_IMPORT,
KIND_SECTION_WORKSPACE_PROJECTION,
@@ -36,9 +44,6 @@ const MAX_ASSIGNMENTS = 1_000;
const MAX_ENCRYPTED_METADATA_BYTES = 65_535;
const MAX_KEY_ENVELOPE_BYTES = 4_096;
const CACHE_PREFIX = "buzz-section-workspace.v1";
const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`;
const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`;
type ProjectionSection = {
id: string;
rank: number;
@@ -471,54 +476,20 @@ export function canonicalJson(input: unknown): string {
.map((key) => `${JSON.stringify(key)}:${canonicalJson(input[key])}`)
.join(",")}}`;
}
export function canonicalRelayAuthority(relayUrl: string): string {
const parsed = new URL(relayUrl.trim());
if (
!["ws:", "wss:", "http:", "https:"].includes(parsed.protocol) ||
!parsed.hostname
) {
throw new Error("invalid relay URL");
}
return parsed.host.toLowerCase();
}
function cacheKey(pubkey: string, relayUrl: string): string {
return `${CACHE_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function importActionKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function importCommandKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function readStoredValue(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function readStoredImportCommand(
pubkey: string,
relayUrl: string,
): string | null {
return readStoredValue(importCommandKey(pubkey, relayUrl));
}
function hasImportMarker(pubkey: string, relayUrl: string): boolean {
return (
readStoredValue(importActionKey(pubkey, relayUrl)) !== null ||
readStoredImportCommand(pubkey, relayUrl) !== null
);
}
function markImportStarted(
pubkey: string,
relayUrl: string,
actionId: string,
): void {
try {
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
} catch {}
}
function writeImportState(
pubkey: string,
relayUrl: string,
actionId: string,
command: string,
): void {
try {
window.localStorage.setItem(importCommandKey(pubkey, relayUrl), command);
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
} catch {}
}
function readCache(pubkey: string, relayUrl: string): WorkspaceCache | null {
try {
const raw = window.localStorage.getItem(cacheKey(pubkey, relayUrl));
@@ -600,7 +571,13 @@ async function encryptMetadata(
await encryptWorkspaceMetadata({
keyHex: key,
plaintext: value,
aad: aad(community, owner, section.id, 1, purpose),
aad: aad(
canonicalRelayAuthority(community),
owner,
section.id,
1,
purpose,
),
}),
"encrypted_metadata",
MAX_ENCRYPTED_METADATA_BYTES,
@@ -618,7 +595,13 @@ async function decryptMetadata(
return decryptWorkspaceMetadata({
keyHex: key,
envelope: value,
aad: aad(community, owner, sectionId, epoch, purpose),
aad: aad(
canonicalRelayAuthority(community),
owner,
sectionId,
epoch,
purpose,
),
});
}
async function projectionStore(
@@ -683,10 +666,10 @@ function validateEventRouting(event: RelayEvent, owner: string): void {
throw new Error("projection routing mismatch");
}
function projectionFromEvent(
async function projectionFromEvent(
event: RelayEvent,
owner: string,
): SectionWorkspaceProjection {
): Promise<SectionWorkspaceProjection> {
if (
event.id.length !== 64 ||
!/^[0-9a-f]{64}$/.test(event.id) ||
@@ -703,6 +686,14 @@ function projectionFromEvent(
})
)
throw new Error("invalid projection event signature");
let relaySelf: string | null;
try {
relaySelf = await getRelaySelf();
} catch {
throw new Error("relay signer is untrusted");
}
if (!relaySelf || relaySelf !== event.pubkey)
throw new Error("invalid projection relay signer");
const projection = parseSectionWorkspaceProjection(
parseStrictJson(event.content),
);
@@ -776,23 +767,14 @@ async function submitImport(
sha256(new TextEncoder().encode(canonicalPlaintext)),
);
let actionId: string;
let command: string | null;
try {
command = window.localStorage.getItem(
importCommandKey(pubkey, normalizedRelay),
);
} catch {
command = null;
}
let command: string | null = readStoredImportCommand(pubkey, normalizedRelay);
if (command) {
const parsed = parseStrictJson(command);
const imported = parseWorkspaceImport(parsed);
actionId = imported.action_id;
} else {
actionId =
window.localStorage.getItem(importActionKey(pubkey, normalizedRelay)) ??
crypto.randomUUID();
markImportStarted(pubkey, normalizedRelay, actionId);
readStoredImportAction(pubkey, normalizedRelay) ?? crypto.randomUUID();
const key = await generateWorkspaceKey();
const sections: ImportSection[] = [];
const sectionIds = new Set<string>();
@@ -850,7 +832,9 @@ async function submitImport(
};
command = canonicalJson(imported);
parseWorkspaceImport(parseStrictJson(command));
writeImportState(pubkey, normalizedRelay, actionId, command);
if (!writeImportState(pubkey, normalizedRelay, actionId, command)) {
throw new Error("cannot durably persist workspace import command");
}
}
await publishImportCommand(pubkey, actionId, command);
}
@@ -928,12 +912,17 @@ export class SectionWorkspaceSyncManager {
return this.acceptProjection(projection);
}
if (this.migrationStarted) {
const replayed = await replayStoredImport(this.pubkey, this.relayUrl);
if (replayed) return this.getCachedStore();
if (readStoredValue(importActionKey(this.pubkey, this.relayUrl))) {
const command = readStoredImportCommand(this.pubkey, this.relayUrl);
if (command) {
const replayed = await replayStoredImport(this.pubkey, this.relayUrl);
if (replayed) return this.getCachedStore();
}
const actionOnly = readStoredImportAction(this.pubkey, this.relayUrl);
if (actionOnly && clearImportAction(this.pubkey, this.relayUrl)) {
this.migrationStarted = false;
} else {
return this.getCachedStore();
}
this.migrationStarted = false;
}
if (this.pubkey && !this.destroyed) {
const legacy = await legacyFetch();
@@ -967,7 +956,7 @@ export class SectionWorkspaceSyncManager {
if (this.destroyed) return;
void (async () => {
try {
let remote = projectionFromEvent(event, this.pubkey);
let remote = await projectionFromEvent(event, this.pubkey);
let action = projectionRevisionAction(
this.projection?.revision ?? null,
remote.revision,
@@ -0,0 +1,65 @@
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
const CACHE_PREFIX = "buzz-section-workspace.v1";
const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`;
const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`;
function importActionKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
function importCommandKey(pubkey: string, relayUrl: string): string {
return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function readStoredValue(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
export function readStoredImportCommand(
pubkey: string,
relayUrl: string,
): string | null {
return readStoredValue(importCommandKey(pubkey, relayUrl));
}
export function hasImportMarker(pubkey: string, relayUrl: string): boolean {
return (
readStoredValue(importActionKey(pubkey, relayUrl)) !== null ||
readStoredImportCommand(pubkey, relayUrl) !== null
);
}
export function readStoredImportAction(
pubkey: string,
relayUrl: string,
): string | null {
return readStoredValue(importActionKey(pubkey, relayUrl));
}
export function clearImportAction(pubkey: string, relayUrl: string): boolean {
try {
const key = importActionKey(pubkey, relayUrl);
window.localStorage.removeItem(key);
return window.localStorage.getItem(key) === null;
} catch {
return false;
}
}
export function writeImportState(
pubkey: string,
relayUrl: string,
actionId: string,
command: string,
): boolean {
try {
const commandKey = importCommandKey(pubkey, relayUrl);
const actionKey = importActionKey(pubkey, relayUrl);
window.localStorage.setItem(commandKey, command);
window.localStorage.setItem(actionKey, actionId);
return (
window.localStorage.getItem(commandKey) === command &&
window.localStorage.getItem(actionKey) === actionId
);
} catch {
return false;
}
}
@@ -76,3 +76,145 @@ test("assignChannel refreshes an existing assignment before the next eviction",
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});
test("legacy subscription resolving after workspace cutover is disposed and cannot update canonical state", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { finalizeEvent } = await import("nostr-tools/pure");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelSections } = await import("./useChannelSections.ts");
const originalFetchEvents = relayClient.fetchEvents;
const originalSubscribeLive = relayClient.subscribeLive;
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
const owner =
"1111111111111111111111111111111111111111111111111111111111111111";
const relaySelf =
"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f";
const ownerBytes = new Uint8Array(32).fill(1);
const projection = {
version: 1,
owner_pubkey: owner,
revision: 1,
layout_revision: 1,
key_epoch: 1,
migration: {
source_event_id:
"2222222222222222222222222222222222222222222222222222222222222222",
source_hash:
"3333333333333333333333333333333333333333333333333333333333333333",
},
reader_key_envelope: "workspace-key-envelope",
sections: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
rank: 0,
encrypted_label: "canonical-label",
encrypted_icon: null,
},
],
assignments: [],
};
const projectionTemplate = {
pubkey: owner,
created_at: 1,
kind: 30623,
tags: [
["d", owner],
["p", owner],
],
content: JSON.stringify(projection),
};
const projectionEvent = finalizeEvent(projectionTemplate, ownerBytes);
const legacyStore = {
version: 1,
sections: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
name: "legacy-name",
order: 0,
},
],
assignments: {},
};
const legacyEvent = {
id: "4444444444444444444444444444444444444444444444444444444444444444",
pubkey: owner,
created_at: 2,
kind: 30078,
tags: [["d", "channel-sections"]],
content: "legacy-ciphertext",
sig: "legacy-signature",
};
let resolveLegacySubscription;
let legacyLiveCallback;
let disposerCalls = 0;
relayClient.fetchEvents = async (filter) =>
filter.kinds.includes(30623) ? [projectionEvent] : [];
relayClient.subscribeLive = (filter, onEvent) => {
if (filter.kinds.includes(30078)) {
legacyLiveCallback = onEvent;
return new Promise((resolve) => {
resolveLegacySubscription = resolve;
});
}
return Promise.resolve(async () => {});
};
relayClient.subscribeToReconnects = () => () => {};
const previousInternals = window.__TAURI_INTERNALS__;
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
if (command === "get_relay_self") return relaySelf;
if (command === "nip44_decrypt_from_self") {
return args.ciphertext === "legacy-ciphertext"
? JSON.stringify(legacyStore)
: "workspace-key";
}
if (command === "decrypt_workspace_metadata") return "canonical-name";
throw new Error(`unexpected command ${command}`);
},
};
try {
const { result, unmount } = renderHook(() =>
useChannelSections(owner, "wss://relay.example"),
);
const flush = async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
};
await act(flush);
assert.deepEqual(result.current.sections, [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "canonical-name",
order: 0,
},
]);
assert.equal(typeof resolveLegacySubscription, "function");
assert.equal(typeof legacyLiveCallback, "function");
resolveLegacySubscription(async () => {
disposerCalls += 1;
});
await act(flush);
assert.equal(disposerCalls, 1);
legacyLiveCallback(legacyEvent);
await act(flush);
assert.deepEqual(result.current.sections, [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "canonical-name",
order: 0,
},
]);
unmount();
} finally {
cleanup();
window.__TAURI_INTERNALS__ = previousInternals;
relayClient.fetchEvents = originalFetchEvents;
relayClient.subscribeLive = originalSubscribeLive;
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});
@@ -49,6 +49,9 @@ export function useChannelSections(
);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
const legacySubscriptionRef = React.useRef<(() => Promise<void>) | null>(
null,
);
React.useEffect(() => {
if (!pubkey || !relayUrl) {
@@ -68,6 +71,10 @@ export function useChannelSections(
return () => {
managerRef.current?.destroy();
managerRef.current = null;
if (legacySubscriptionRef.current) {
void legacySubscriptionRef.current();
legacySubscriptionRef.current = null;
}
workspaceManagerRef.current?.destroy();
workspaceManagerRef.current = null;
};
@@ -79,7 +86,7 @@ export function useChannelSections(
}
const key = storageKey(pubkey, relayUrl);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
if (e.key !== key || workspaceManagerRef.current?.isCanonical()) {
return;
}
setStore(readChannelSectionsStore(pubkey, relayUrl));
@@ -124,6 +131,11 @@ export function useChannelSections(
const legacy = await legacyManager.fetchLegacySource();
return legacy;
});
if (workspaceManager.isCanonical() && legacySubscriptionRef.current) {
void legacySubscriptionRef.current();
legacySubscriptionRef.current = null;
legacyManager.cancelPendingPublish();
}
if (cancelled) return;
if (workspaceStore) setStore(workspaceStore);
if (workspaceManager.isCanonical()) {
@@ -134,6 +146,7 @@ export function useChannelSections(
readChannelSectionsStore(pubkey, relayUrl),
);
if (cancelled) return;
if (workspaceManager.isCanonical()) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
@@ -169,23 +182,25 @@ export function useChannelSections(
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
let cancelled = false;
void managerRef.current
?.subscribeToSections((remote) => {
if (cancelled) return;
if (cancelled || workspaceManagerRef.current?.isCanonical()) return;
setStore(applyRemote(remote));
})
.then((dispose) => {
if (cancelled) {
if (cancelled || workspaceManagerRef.current?.isCanonical()) {
void dispose();
} else {
unsub = dispose;
legacySubscriptionRef.current = dispose;
}
});
return () => {
cancelled = true;
if (unsub) void unsub();
if (legacySubscriptionRef.current) {
void legacySubscriptionRef.current();
legacySubscriptionRef.current = null;
}
};
}, [pubkey, applyRemote]);
@@ -193,11 +208,16 @@ export function useChannelSections(
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
if (workspaceManagerRef.current?.isCanonical()) {
managerRef.current?.cancelPendingPublish();
return;
}
void managerRef.current?.fetchRemoteSections().then((result) => {
if (cancelled) return;
if (cancelled || workspaceManagerRef.current?.isCanonical()) return;
if (result.status === "found") {
setStore(applyRemote(result.data));
}
if (workspaceManagerRef.current?.isCanonical()) return;
const pending = managerRef.current?.getPendingStore();
if (pending) {
managerRef.current?.publishSections(pending);
@@ -218,6 +238,7 @@ export function useChannelSections(
const createSection = React.useCallback(
(name: string, icon?: string): ChannelSection | null => {
if (!pubkey) return null;
if (workspaceManagerRef.current?.isCanonical()) return null;
const prev = readChannelSectionsStore(pubkey, relayUrl);
const maxOrder =
prev.sections.length > 0
@@ -252,6 +273,7 @@ export function useChannelSections(
if (!pubkey) {
return;
}
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const next: ChannelSectionStore = {
...prev,
@@ -285,6 +307,7 @@ export function useChannelSections(
if (!pubkey) {
return;
}
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const assignments = { ...prev.assignments };
for (const channelId of Object.keys(assignments)) {
@@ -314,6 +337,7 @@ export function useChannelSections(
const moveSectionUp = React.useCallback(
(sectionId: string) => {
if (!pubkey) return;
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const next = swapSectionOrder(prev, sectionId, "up");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
@@ -332,6 +356,7 @@ export function useChannelSections(
const moveSectionDown = React.useCallback(
(sectionId: string) => {
if (!pubkey) return;
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const next = swapSectionOrder(prev, sectionId, "down");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
@@ -350,6 +375,7 @@ export function useChannelSections(
const reorderSections = React.useCallback(
(orderedIds: string[]) => {
if (!pubkey) return;
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const sections = prev.sections.map((s) => {
const newOrder = orderedIds.indexOf(s.id);
@@ -373,6 +399,7 @@ export function useChannelSections(
if (!pubkey) {
return;
}
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
@@ -400,6 +427,7 @@ export function useChannelSections(
if (!pubkey) {
return;
}
if (workspaceManagerRef.current?.isCanonical()) return;
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];