diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a650..cddb1c301ef 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { CommunityThemeController } from "@/shared/theme/CommunityThemeController"; import { useReloadShortcut } from "@/app/useReloadShortcut"; +import { IdleAutoReloadController } from "@/app/useIdleAutoReload"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; @@ -283,6 +284,7 @@ function AppReady({ } > + {huddleWindowChannelId() === null ? : null} diff --git a/desktop/src/app/useIdleAutoReload.ts b/desktop/src/app/useIdleAutoReload.ts new file mode 100644 index 00000000000..e6a99ceab49 --- /dev/null +++ b/desktop/src/app/useIdleAutoReload.ts @@ -0,0 +1,107 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { getOsIdleSeconds } from "@/shared/api/osIdle"; +import { invokeTauri } from "@/shared/api/tauri"; +import { isAppFocused } from "@/shared/lib/useDocumentVisible"; +import { + createSerializedIdleReloadCheck, + IDLE_RELOAD_SESSION_AGE_MS, + normalizeOsIdleMs, + type IdleAutoReloadReaders, +} from "@/shared/lib/idleAutoReloadPolicy"; +import { requestRendererReload } from "@/shared/lib/reloadRenderer"; +import { isAnyVolatileWorkPending } from "@/shared/lib/volatileWorkRegistry"; + +/** Wall-clock ms when this renderer module first loaded — the session-age + * anchor. `location.reload()` reloads the module, so a fired reload resets this + * to ~now and re-arms the backstop automatically on the fresh page. */ +const SESSION_LOADED_AT_MS = Date.now(); + +/** How often the backstop re-evaluates once the session is old enough to arm. */ +const CHECK_INTERVAL_MS = 60_000; + +/** True while a huddle is anything but fully idle. Unknown/errored → true so a + * live call is never dropped by a reload. */ +async function readHuddleActive(): Promise { + try { + const state = await invokeTauri<{ phase?: string }>("get_huddle_state"); + return (state?.phase ?? "idle") !== "idle"; + } catch { + return true; + } +} + +/** + * Idle auto-reload backstop. Once the renderer session is ~12h old, checks + * once a minute whether the user is provably away (OS idle + window unfocused) + * with no volatile work outstanding, and if so performs the same clean-teardown + * reload as Cmd+R — discarding the accumulated renderer heap and timers while + * the native engine and its child agents keep running. + * + * The volatile-work blocker withholds the reload during a huddle, a foreground + * or background media upload (including its completion settling window), queued + * local attachments a draft cannot serialize, or any in-flight + * send/mutation/native operation (`queryClient.isMutating()` — sends, media + * pickers, install/import/save all run as mutations). Persisted draft text + * survives reload via `localStorage`, so it is deliberately not a blocker. + * + * All fire/withhold logic lives in the pure, exhaustively tested policy module. + * This hook only wires live signals, arms after the session-age threshold, and + * serializes overlapping checks. Every signal is read imperatively at check + * time (once a minute), so the hook holds no reactive subscription and never + * re-renders. + * + * Mounted via {@link IdleAutoReloadController} — a null-rendering leaf inside + * the main ready shell only. Huddle companion windows and every onboarding / + * blocking / reset / keyring / relaunch screen are ineligible by construction. + */ +export function useIdleAutoReload(): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + let disposed = false; + let intervalId: number | undefined; + let armTimerId: number | undefined; + + const readers: IdleAutoReloadReaders = { + now: Date.now, + sessionLoadedAtMs: SESSION_LOADED_AT_MS, + isAppFocused, + getOsIdleMs: () => normalizeOsIdleMs(getOsIdleSeconds), + getHuddleActive: readHuddleActive, + isVolatileWorkPending: () => + isAnyVolatileWorkPending() || queryClient.isMutating() > 0, + reload: () => requestRendererReload(), + }; + + const check = createSerializedIdleReloadCheck(readers); + + const startInterval = () => { + if (disposed) return; + check(); + intervalId = window.setInterval(check, CHECK_INTERVAL_MS); + }; + + const msUntilArm = + SESSION_LOADED_AT_MS + IDLE_RELOAD_SESSION_AGE_MS - Date.now(); + if (msUntilArm <= 0) { + startInterval(); + } else { + armTimerId = window.setTimeout(startInterval, msUntilArm); + } + + return () => { + disposed = true; + if (intervalId !== undefined) window.clearInterval(intervalId); + if (armTimerId !== undefined) window.clearTimeout(armTimerId); + }; + }, [queryClient]); +} + +/** Null-rendering mount point for {@link useIdleAutoReload}. Kept as its own + * leaf so nothing in the app tree re-renders on its account. */ +export function IdleAutoReloadController(): null { + useIdleAutoReload(); + return null; +} diff --git a/desktop/src/app/useReloadShortcut.ts b/desktop/src/app/useReloadShortcut.ts index fb93f77f6f6..0e8b5cb9095 100644 --- a/desktop/src/app/useReloadShortcut.ts +++ b/desktop/src/app/useReloadShortcut.ts @@ -1,9 +1,7 @@ import * as React from "react"; -import { closeAllWebSockets } from "@/shared/api/relayWebSocketClose"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; - -const RELOAD_TEARDOWN_TIMEOUT_MS = 500; +import { requestRendererReload } from "@/shared/lib/reloadRenderer"; /** Reloads the webview after bounded native WebSocket teardown. */ export function useReloadShortcut() { @@ -19,13 +17,7 @@ export function useReloadShortcut() { } event.preventDefault(); - await Promise.race([ - closeAllWebSockets(), - new Promise((resolve) => - window.setTimeout(resolve, RELOAD_TEARDOWN_TIMEOUT_MS), - ), - ]); - window.location.reload(); + await requestRendererReload(); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 6204186abe9..e46f6564b50 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -21,6 +21,7 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; +import { useRegisterVolatileWork } from "@/shared/lib/volatileWorkRegistry"; import { DropdownMenu, DropdownMenuContent, @@ -55,6 +56,13 @@ export function ForumComposer({ const contentRef = React.useRef(content); contentRef.current = content; + // The forum composer has no draft persistence, so unsent text lives only in + // renderer memory and a reload discards it. Register it with the app-global + // volatile-work registry so the idle backstop withholds the reload while a + // draft is unsent; the hold releases when the text clears or the forum view + // (and this composer) unmounts, so a closed view never leaks its key. + useRegisterVolatileWork(content.trim().length > 0); + const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index ace711985ad..fb8539504ff 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -2,6 +2,7 @@ import * as React from "react"; import type { BlobDescriptor } from "@/shared/api/tauri"; import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { registerVolatileWorkPredicate } from "@/shared/lib/volatileWorkRegistry"; import { type BackgroundMediaUploadPhase, isNativeMediaUploadPhase, @@ -336,6 +337,39 @@ export function takeQueuedAttachmentsForDraft( return attachments; } +/** + * True while any local attachment is queued for a deferred upload. These are + * `File` objects retained only in memory (they cannot be serialized into a + * persisted draft), so a renderer reload destroys them — the idle backstop + * withholds the reload until the queue drains via the registered predicate + * below. + */ +function hasQueuedDraftAttachments(): boolean { + for (const attachments of queuedAttachmentsByDraftKey.values()) { + if (attachments.length > 0) return true; + } + return false; +} + +/** + * True while any background media upload is in flight, including the + * `isCompleting` settling window between the last byte and `onComplete` + * resolving. Mirrors the `isUploading` snapshot without a React subscription so + * the idle backstop can read it at check time. + */ +function isBackgroundMediaUploadInFlight(): boolean { + return snapshot.isUploading; +} + +// Register this store's reload-destroyed state with the app-global volatile- +// work registry: an in-flight background upload (its settling window included) +// or a local file retained off-channel for a deferred upload (the retention +// map, in-memory only). The idle backstop reads this at check time and +// withholds the reload while either holds. +registerVolatileWorkPredicate( + () => isBackgroundMediaUploadInFlight() || hasQueuedDraftAttachments(), +); + function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 4b9d2c6cce7..b4f2f4e1991 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -6,6 +6,7 @@ import { uploadMediaBytes, } from "@/shared/api/tauri"; import { uploadMediaFile } from "@/shared/api/tauriMedia"; +import { useRegisterVolatileWork } from "@/shared/lib/volatileWorkRegistry"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; import { useFilePicker } from "./useFilePicker"; @@ -622,6 +623,19 @@ export function useMediaUpload({ const handlePaperclip = React.useCallback(async () => { if (queueUntilSend) { + // Deliberately NOT registered with the volatile-work registry. Unlike the + // native picker (`pickAndUploadMedia`), this path opens `useFilePicker`'s + // hidden ``: `input.click()` returns synchronously and + // the dialog is owned by the webview. There is no reliable dismissal + // signal — `useFilePicker`'s own comment notes "Cancel emits no `change`" + // — so acquiring on open would risk a hold that never releases when the + // user cancels, and a leaked key blocks the idle backstop forever (worse + // than the window it would protect). The window is safe to leave + // unregistered: no reload-destroyed state exists until `change` fires + // (which routes into `queueFiles`/`uploadFiles`, both already covered by + // the registered `queuedAttachments`/`uploadingCount` hold), and a user + // interacting with an open dialog is not OS-idle, so the backstop's + // 30-minute idle gate cannot fire mid-dialog anyway. openFilePicker({ multiple: true }, (files) => { queueFiles(files.filter(shouldQueueFile)); uploadFiles(files.filter((file) => !shouldQueueFile(file))); @@ -894,6 +908,12 @@ export function useMediaUpload({ * already-cleared composer. */ const isUploading = uploadingCount > 0; + // Foreground uploads in flight and local files queued for a deferred send + // both live only in this hook's state — a renderer reload discards them. Hold + // the app-global volatile-work registry while either is outstanding so the + // idle backstop withholds the reload; the hold releases when the composer + // drains or unmounts. + useRegisterVolatileWork(isUploading || queuedAttachments.length > 0); const queuedPreviews = React.useMemo( () => queuedAttachments.map((attachment) => ({ diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b2..82571799a1d 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -179,6 +179,13 @@ function installDOMShim() { configurable: true, }); } + // The draft-persist hook registers a window-level `pagehide` listener; back + // window.{add,remove}EventListener/dispatchEvent with an event target so the + // listener binds cleanly and the pagehide flush can be dispatched in tests. + const windowET = new MinimalEventTarget(); + globalThis.addEventListener = windowET.addEventListener.bind(windowET); + globalThis.removeEventListener = windowET.removeEventListener.bind(windowET); + globalThis.dispatchEvent = windowET.dispatchEvent.bind(windowET); if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { Object.defineProperty(globalThis, "navigator", { value: { userAgent: "node" }, @@ -291,6 +298,24 @@ async function mountStrictMode(Comp) { }; } +// Plain (non-StrictMode) mount: no simulate-unmount, so the draft-key-change +// cleanup never fires while mounted. Used to isolate the pagehide flush as the +// sole persist path. +async function mountPlain(Comp) { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Comp)); + }); + return { + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + // ── Tests ───────────────────────────────────────────────────────────────────── /** @@ -662,3 +687,79 @@ test("discarding_a_draft_drops_its_retained_local_files", () => { assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); }); + +/** + * Regression test: typed-but-unpersisted composer text survives a + * pagehide-then-reload cycle (the idle auto-reload backstop and Cmd+R). + * + * ── Background ──────────────────────────────────────────────────────────────── + * `location.reload()` navigates the webview without running React effect + * cleanup, and the draft-key-change cleanup is otherwise the ONLY path that + * persists live editor text. So text typed since the last key change — the + * common case: the user is mid-message when the backstop or Cmd+R fires — + * would be lost. `pagehide` fires before the reload, so the hook flushes the + * CURRENT draft there. + * + * This mounts the REAL `useDraftPersistLifecycle` on a key with NO saved draft, + * types content into the live editor (never triggering a key change), dispatches + * `pagehide`, and asserts the store now holds that text — proving the flush + * captures work the cleanup never saw. + */ +test("pagehide_flush_persists_typed_but_unsaved_composer_text_before_reload", async () => { + const DRAFT_KEY = "chan-pagehide-flush"; + setupStore("pubkey-pagehide-flush"); + // No draft seeded: the live editor text has never been persisted. + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "precondition: empty store", + ); + + // Live editor content the user is typing; the cleanup has not run for it. + const editorContent = "half-written message"; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: () => {}, + clearContent: () => {}, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + return null; + } + + const handle = await mountPlain(HarnessComposer); + + // Still unsaved after mount — mount does not persist an unchanged key, and a + // plain mount runs no cleanup, so only the pagehide flush can persist here. + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "mount alone must not persist the live editor text", + ); + + // The reload path fires pagehide before location.reload(). + await act(async () => { + globalThis.dispatchEvent({ type: "pagehide" }); + }); + + const persisted = loadDraftEntry(DRAFT_KEY); + assert.ok(persisted, "pagehide flush must persist the current draft"); + assert.equal( + persisted.content, + "half-written message", + "typed-but-unsaved composer text must survive the reload", + ); + + await handle.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adbc..4977808e3ac 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -116,6 +116,46 @@ export function useDraftPersistLifecycle({ // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; + // Persist a draft's volatile composer state — live editor text, queued local + // files, imeta, spoilers, and mention routing. The key/channelId are passed + // explicitly so the draft-key-change cleanup persists the OUTGOING draft + // while the pagehide flush persists the CURRENT one; both read the same live + // editor content and imeta ref, so the write is identical either way. + const persistDraftSnapshot = ( + draftKey: string, + draftChannelId: string | null | undefined, + ) => { + const queuedAttachments = getQueuedAttachments?.() ?? []; + if (queuedAttachments.length > 0) { + saveQueuedAttachmentsForDraft?.(draftKey, queuedAttachments); + } + const content = syncComposerContentFromEditor(); + persistDraft( + draftKey, + content, + draftChannelId ?? draftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + getMentionRefs(content), + ); + }; + + // location.reload() (the idle backstop and Cmd+R) and window close do not run + // React effect cleanup, so the live editor text — otherwise persisted only by + // the draft-key-change cleanup below — would be lost. pagehide fires before + // all three, so flush the current draft synchronously there. A ref keeps the + // mount-once listener bound to the live key/channelId without re-subscribing. + // Mirrors the pagehide flush in useObservedUnreadPersistence. + const flushCurrentDraftRef = React.useRef<() => void>(() => {}); + flushCurrentDraftRef.current = () => { + if (effectiveDraftKey) persistDraftSnapshot(effectiveDraftKey, channelId); + }; + React.useEffect(() => { + const onPageHide = () => flushCurrentDraftRef.current(); + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before @@ -156,19 +196,7 @@ export function useDraftPersistLifecycle({ return () => { if (effectiveDraftKey) { - const queuedAttachments = getQueuedAttachments?.() ?? []; - if (queuedAttachments.length > 0) { - saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); - } - const content = syncComposerContentFromEditor(); - persistDraft( - effectiveDraftKey, - content, - channelId ?? effectiveDraftKey, - [...pendingImetaForPersistRef.current], - [...spoileredAttachmentUrlsRef.current], - getMentionRefs(content), - ); + persistDraftSnapshot(effectiveDraftKey, channelId); } }; }, [effectiveDraftKey]); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81dd..819c1fad19b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -593,11 +593,6 @@ export async function uploadMedia( isTemp, }); } -export async function pickAndUploadMedia( - progressId?: string, -): Promise { - return invokeTauri("pick_and_upload_media", { progressId }); -} export async function uploadMediaBytes( data: number[], filename?: string, @@ -612,6 +607,7 @@ export async function uploadMediaBytes( } export { editMessage } from "@/shared/api/editMessage"; +export { pickAndUploadMedia } from "@/shared/api/tauriMedia"; export async function deleteMessage( channelId: string, diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index daedebde5cf..82a011b43ce 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,5 +1,6 @@ import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; +import { withVolatileWork } from "@/shared/lib/volatileWorkRegistry"; function encodeRawIpcHeader(value: string): string { const bytes = new TextEncoder().encode(value); @@ -46,7 +47,30 @@ export async function cancelMediaUpload(progressId: string): Promise { * reach the relay. Resolves to `null` when the user cancels the dialog. */ export async function pickAndUploadImage(): Promise { - return invokeTauri("pick_and_upload_image", {}); + // Same reload-destroyed window as `pickAndUploadMedia`: an open OS dialog + // then a Rust-side upload. Hold the volatile-work registry across it (release + // on resolve or cancel) so the idle backstop withholds the reload. + return withVolatileWork("pick-and-upload-image", () => + invokeTauri("pick_and_upload_image", {}), + ); +} + +/** + * Open the native multi-file picker and upload the chosen files, reporting + * per-file progress under `progressId`. Rust performs the upload; this resolves + * once every selected file has landed. + */ +export async function pickAndUploadMedia( + progressId?: string, +): Promise { + // The native picker returns immediately from JS but holds an open OS dialog + // and then a Rust-side upload — reload-destroyed work that no other signal + // (`uploadingCount`, mutations) covers for every caller. Hold the app-global + // volatile-work registry across the whole operation (release on resolve or + // reject) so the idle backstop withholds the reload while the picker is open. + return withVolatileWork("pick-and-upload-media", () => + invokeTauri("pick_and_upload_media", { progressId }), + ); } /** diff --git a/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs b/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs new file mode 100644 index 00000000000..4f8ac5b94cb --- /dev/null +++ b/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createSerializedIdleReloadCheck, + IDLE_RELOAD_OS_IDLE_MS, + IDLE_RELOAD_SESSION_AGE_MS, + normalizeOsIdleMs, + runIdleReloadCheck, + shouldIdleReload, +} from "./idleAutoReloadPolicy.ts"; + +// ── shouldIdleReload gate matrix ───────────────────────────────────────────── +// +// SAFETY-CRITICAL: a wrong "true" navigates the webview out from under the +// user. The all-green baseline is the ONLY fire case; each never-fire row +// flips exactly one input away from green to prove that gate alone holds. + +/** All gates open, session old, user away — the only true case. */ +function greenInputs(overrides = {}) { + return { + osIdleMs: IDLE_RELOAD_OS_IDLE_MS, + sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS, + appFocused: false, + volatileWorkPending: false, + ...overrides, + }; +} + +test("shouldIdleReload_all_gates_green_returns_true", () => { + assert.equal(shouldIdleReload(greenInputs()), true); +}); + +const NEVER_FIRE_ROWS = [ + ["volatile work pending blocks", { volatileWorkPending: true }], + ["focused app blocks", { appFocused: true }], + ["young session blocks", { sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS - 1 }], + ["null idle (unsupported platform) blocks", { osIdleMs: null }], + ["below idle threshold blocks", { osIdleMs: IDLE_RELOAD_OS_IDLE_MS - 1 }], +]; + +for (const [name, override] of NEVER_FIRE_ROWS) { + test(`shouldIdleReload_${name.replace(/\s+/g, "_")}_returns_false`, () => { + assert.equal(shouldIdleReload(greenInputs(override)), false); + }); +} + +test("shouldIdleReload_exact_thresholds_are_inclusive", () => { + assert.equal( + shouldIdleReload( + greenInputs({ + osIdleMs: IDLE_RELOAD_OS_IDLE_MS, + sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS, + }), + ), + true, + ); +}); + +test("shouldIdleReload_volatile_work_beats_all_other_green_gates", () => { + // Every precondition green but volatile work outstanding → still false. + assert.equal( + shouldIdleReload(greenInputs({ volatileWorkPending: true })), + false, + ); +}); + +// ── runIdleReloadCheck orchestration ───────────────────────────────────────── + +const HOUR = 60 * 60 * 1000; + +/** Readers wired to fire: old session, away, all clear. */ +function greenReaders(overrides = {}) { + const calls = { reload: 0 }; + const base = { + now: () => 100 * HOUR, + sessionLoadedAtMs: 100 * HOUR - IDLE_RELOAD_SESSION_AGE_MS, + isAppFocused: () => false, + getOsIdleMs: async () => IDLE_RELOAD_OS_IDLE_MS, + getHuddleActive: async () => false, + isVolatileWorkPending: () => false, + reload: async () => { + calls.reload += 1; + }, + }; + return { readers: { ...base, ...overrides }, calls }; +} + +test("runIdleReloadCheck_fires_reload_when_all_conditions_hold", async () => { + const { readers, calls } = greenReaders(); + assert.equal(await runIdleReloadCheck(readers), true); + assert.equal(calls.reload, 1); +}); + +test("runIdleReloadCheck_young_session_skips_before_any_ipc", async () => { + let osIdleReads = 0; + let huddleReads = 0; + const { readers, calls } = greenReaders({ + sessionLoadedAtMs: 100 * HOUR - (IDLE_RELOAD_SESSION_AGE_MS - 1), + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + getHuddleActive: async () => { + huddleReads += 1; + return false; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); + // A present-enough session must not incur the per-minute IPC reads. + assert.equal(osIdleReads, 0); + assert.equal(huddleReads, 0); +}); + +test("runIdleReloadCheck_focused_user_skips_before_any_ipc", async () => { + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + isAppFocused: () => true, + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); + assert.equal(osIdleReads, 0); +}); + +test("runIdleReloadCheck_huddle_active_blocks_reload", async () => { + const { readers, calls } = greenReaders({ + getHuddleActive: async () => true, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_volatile_work_blocks_reload", async () => { + // Stands in for a foreground/background upload, queued local attachment, or + // in-flight send/mutation/native operation — the hook folds all into one read. + const { readers, calls } = greenReaders({ + isVolatileWorkPending: () => true, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_null_os_idle_blocks_reload", async () => { + const { readers, calls } = greenReaders({ + getOsIdleMs: async () => null, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +// ── normalizeOsIdleMs — the extracted reader normalizer ────────────────────── +// +// The MINOR finding: prove a rejecting getOsIdleSeconds withholds the reload +// this tick, then a later successful read lets it fire. The normalizer is the +// seam that collapses all three "cannot confirm away" outcomes to null; it was +// extracted to the pure module precisely so this path tests without the hook's +// React/timer/IPC weight. + +test("normalizeOsIdleMs_seconds_convert_to_milliseconds", async () => { + assert.equal(await normalizeOsIdleMs(async () => 42), 42_000); +}); + +test("normalizeOsIdleMs_null_reader_stays_null", async () => { + assert.equal(await normalizeOsIdleMs(async () => null), null); +}); + +test("normalizeOsIdleMs_rejecting_reader_collapses_to_null", async () => { + // A failed idle IPC must never surface as a numeric idle the policy could + // read as "away" — it collapses to null, which shouldIdleReload withholds on. + assert.equal( + await normalizeOsIdleMs(async () => { + throw new Error("idle IPC failed"); + }), + null, + ); +}); + +test("idle_read_rejection_withholds_this_tick_then_retry_fires", async () => { + // The full MINOR contract through the real normalizer + orchestrator: the + // first tick's idle read rejects → normalizeOsIdleMs → null → withheld; the + // next tick's read succeeds → the reload fires. No stuck state between ticks. + let attempt = 0; + const getOsIdleSeconds = async () => { + attempt += 1; + if (attempt === 1) throw new Error("transient idle IPC failure"); + return IDLE_RELOAD_OS_IDLE_MS / 1000; + }; + const { readers, calls } = greenReaders({ + getOsIdleMs: () => normalizeOsIdleMs(getOsIdleSeconds), + }); + + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0, "a rejected idle read must withhold this tick"); + + assert.equal(await runIdleReloadCheck(readers), true); + assert.equal(calls.reload, 1, "the next tick's successful read must fire"); +}); + +test("runIdleReloadCheck_user_returns_during_ipc_await_blocks_reload", async () => { + // Focus flips to true while the async idle/huddle reads are in flight; the + // post-await re-read of the synchronous focus signal must catch it. + let focused = false; + const { readers, calls } = greenReaders({ + isAppFocused: () => focused, + getOsIdleMs: async () => { + focused = true; // user touched the machine mid-check + return IDLE_RELOAD_OS_IDLE_MS; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_volatile_work_appears_during_ipc_await_blocks_reload", async () => { + let busy = false; + const { readers, calls } = greenReaders({ + isVolatileWorkPending: () => busy, + getHuddleActive: async () => { + busy = true; // a send/upload began mid-check + return false; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +// ── createSerializedIdleReloadCheck ────────────────────────────────────────── + +test("serialized_check_drops_ticks_while_a_check_is_pending", async () => { + let releaseIdle; + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + getOsIdleMs: () => { + osIdleReads += 1; + return new Promise((resolve) => { + releaseIdle = () => resolve(IDLE_RELOAD_OS_IDLE_MS); + }); + }, + }); + const check = createSerializedIdleReloadCheck(readers); + check(); // starts a check that parks on the idle read + check(); // second tick lands while the first is pending — dropped + check(); + assert.equal(osIdleReads, 1); + releaseIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.reload, 1); +}); + +test("serialized_check_runs_again_after_the_previous_check_settles", async () => { + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + reload: async () => { + calls.reload += 1; + }, + }); + const check = createSerializedIdleReloadCheck(readers); + check(); + await new Promise((resolve) => setTimeout(resolve, 0)); + check(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Both ticks ran to completion because each settled before the next. + assert.equal(osIdleReads, 2); +}); diff --git a/desktop/src/shared/lib/idleAutoReloadPolicy.ts b/desktop/src/shared/lib/idleAutoReloadPolicy.ts new file mode 100644 index 00000000000..2e350653341 --- /dev/null +++ b/desktop/src/shared/lib/idleAutoReloadPolicy.ts @@ -0,0 +1,155 @@ +/** + * Idle auto-reload backstop — the pure decision core. + * + * A long-lived renderer session accumulates heap and timer churn until GC + * pauses queue behind input (the multi-GB, multi-hour sessions that motivate + * this feature). The Cmd+R clean-teardown reload discards that state without + * touching the native engine or its child agents. This backstop performs the + * same reload automatically, but ONLY when the user is provably away and the + * session is old enough to have accumulated the churn — never during use. + * + * SAFETY: the reload navigates the webview (`location.reload()`), so a wrong + * "fire" interrupts the user or drops in-flight work. Every gate below is + * load-bearing; the test matrix enumerates them exhaustively. When any signal + * is unknown (e.g. `osIdleMs === null` on platforms without an idle API), the + * policy withholds the reload rather than guessing. + */ +export type IdleAutoReloadInputs = { + /** + * Milliseconds since the last OS-wide user input, or null where the platform + * exposes no idle API. Null is treated as "cannot confirm away" — never fire. + */ + osIdleMs: number | null; + /** Milliseconds since this renderer session loaded. */ + sessionAgeMs: number; + /** True while the window is visible AND focused; the reload waits for away. */ + appFocused: boolean; + /** + * True while any volatile, reload-destroyed work is outstanding: a huddle + * starting/active, a foreground or background media upload (including its + * completion settling window), queued local attachments a draft cannot + * serialize, or an in-flight send/mutation/native operation. One blocker so + * a new volatile-work class is guarded by extending its single reader, not by + * threading another gate through this predicate. + */ + volatileWorkPending: boolean; +}; + +/** OS idle the reload waits for: long enough that the user has clearly stepped + * away rather than paused to read. */ +export const IDLE_RELOAD_OS_IDLE_MS = 30 * 60 * 1000; + +/** Minimum renderer session age before the backstop arms: below this the heap + * and timer churn have not grown enough to be worth a reload. */ +export const IDLE_RELOAD_SESSION_AGE_MS = 12 * 60 * 60 * 1000; + +/** + * Decide whether the idle backstop should reload the renderer now. + * + * Returns true only when the session is old, the user is provably away, and no + * volatile work is outstanding. Any red gate returns false. + */ +export function shouldIdleReload(inputs: IdleAutoReloadInputs): boolean { + const { osIdleMs, sessionAgeMs, appFocused, volatileWorkPending } = inputs; + + // The single volatile-work blocker: anything a reload would destroy. + if (volatileWorkPending) return false; + + // Preconditions: only reload a stale session whose user has stepped away. + if (appFocused) return false; + if (sessionAgeMs < IDLE_RELOAD_SESSION_AGE_MS) return false; + if (osIdleMs === null) return false; + return osIdleMs >= IDLE_RELOAD_OS_IDLE_MS; +} + +/** + * Normalize a raw OS-idle read into the milliseconds the policy consumes. + * + * The platform reader ({@link getOsIdleSeconds}) reports seconds, null where no + * idle API exists, and rejects when the IPC call fails. All three "cannot + * confirm away" outcomes collapse to null so the policy withholds the reload + * rather than guessing. Pure and reader-injected so the rejection path is + * testable without importing the hook (React, timers, IPC). + */ +export async function normalizeOsIdleMs( + getOsIdleSeconds: () => Promise, +): Promise { + try { + const seconds = await getOsIdleSeconds(); + return seconds === null ? null : seconds * 1000; + } catch { + return null; + } +} + +/** + * Injectable signal sources for {@link runIdleReloadCheck}. The hook wires + * these to live app state; tests wire them to fixtures — no React, timers, or + * IPC required to exercise the orchestration. + */ +export type IdleAutoReloadReaders = { + now: () => number; + /** Wall-clock ms when this renderer session loaded. */ + sessionLoadedAtMs: number; + isAppFocused: () => boolean; + /** Async OS idle read; resolves null where unknown/unsupported. */ + getOsIdleMs: () => Promise; + /** Async huddle read; resolves true when a call is in progress OR unknown. */ + getHuddleActive: () => Promise; + /** Synchronous read of non-huddle volatile work (uploads, queued files, + * in-flight sends/mutations/native operations). */ + isVolatileWorkPending: () => boolean; + reload: () => Promise; +}; + +/** + * Gather the current signals and reload the renderer iff {@link shouldIdleReload} + * says so. Returns whether the reload fired. + * + * The session-age and focus gates run before the async IPC reads so a present + * user never triggers per-minute huddle/idle queries. The synchronous signals + * are re-read when the decision is made so state that changed during the IPC + * awaits (the user returning or starting work mid-check) still blocks the + * reload. + */ +export async function runIdleReloadCheck( + readers: IdleAutoReloadReaders, +): Promise { + const sessionAgeMs = readers.now() - readers.sessionLoadedAtMs; + if (sessionAgeMs < IDLE_RELOAD_SESSION_AGE_MS) return false; + if (readers.isAppFocused()) return false; + + const osIdleMs = await readers.getOsIdleMs(); + const huddleActive = await readers.getHuddleActive(); + + const fire = shouldIdleReload({ + osIdleMs, + sessionAgeMs: readers.now() - readers.sessionLoadedAtMs, + appFocused: readers.isAppFocused(), + volatileWorkPending: huddleActive || readers.isVolatileWorkPending(), + }); + if (!fire) return false; + + await readers.reload(); + return true; +} + +/** + * Wrap {@link runIdleReloadCheck} so overlapping interval ticks collapse to one + * in-flight check: a tick that lands while the previous check is still awaiting + * its IPC reads (or reload) is dropped. Keeps the per-minute poll from stacking + * concurrent huddle/idle queries. Pure — no React or timers — so the collapse + * is exercised directly in tests. + */ +export function createSerializedIdleReloadCheck( + readers: IdleAutoReloadReaders, +): () => void { + let running = false; + return () => { + if (running) return; + running = true; + void runIdleReloadCheck(readers).finally(() => { + running = false; + }); + }; +} diff --git a/desktop/src/shared/lib/reloadRenderer.test.mjs b/desktop/src/shared/lib/reloadRenderer.test.mjs new file mode 100644 index 00000000000..5db0d7e674b --- /dev/null +++ b/desktop/src/shared/lib/reloadRenderer.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; + +import { + RELOAD_TEARDOWN_TIMEOUT_MS, + requestRendererReload, + resetRendererReloadForTests, +} from "./reloadRenderer.ts"; + +// requestRendererReload takes injectable `reload`/`closeSockets`, so the +// teardown/timeout race and idempotence run headless without navigating the +// runner. Only `window.setTimeout` needs a stub for the timeout arm. +let originalWindow; + +beforeEach(() => { + originalWindow = globalThis.window; + globalThis.window = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id), + }; +}); + +afterEach(() => { + globalThis.window = originalWindow; + resetRendererReloadForTests(); +}); + +test("requestRendererReload_tears_down_sockets_then_reloads", async () => { + let reloadCount = 0; + let closeCalls = 0; + let resolveClose; + const done = requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => { + closeCalls += 1; + return new Promise((resolve) => { + resolveClose = resolve; + }); + }, + }); + // Teardown started; reload waits for the race to settle. + assert.equal(closeCalls, 1); + assert.equal(reloadCount, 0); + resolveClose(); + await done; + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_reloads_even_when_teardown_hangs_past_timeout", async () => { + let reloadCount = 0; + // Never resolve the teardown; only the timeout can win the race. + await requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => new Promise(() => {}), + }); + assert.equal(reloadCount, 1); + assert.ok(RELOAD_TEARDOWN_TIMEOUT_MS > 0); +}); + +test("requestRendererReload_reloads_even_when_teardown_rejects", async () => { + let reloadCount = 0; + // A rejecting teardown must not win the race and strand the stale renderer. + await requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => Promise.reject(new Error("socket wedged")), + }); + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_collapses_concurrent_triggers_to_one", async () => { + let reloadCount = 0; + let closeCalls = 0; + let resolveClose; + const overrides = { + reload: () => { + reloadCount += 1; + }, + closeSockets: () => { + closeCalls += 1; + return new Promise((resolve) => { + resolveClose = resolve; + }); + }, + }; + // Cmd+R and the idle backstop fire while the first teardown is mid-flight. + const first = requestRendererReload(overrides); + const second = requestRendererReload(overrides); + assert.equal(first, second); + assert.equal(closeCalls, 1); + resolveClose(); + await Promise.all([first, second]); + // Teardown ran once; the webview navigated once. + assert.equal(closeCalls, 1); + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_stays_collapsed_after_settling", async () => { + let reloadCount = 0; + const overrides = { + reload: () => { + reloadCount += 1; + }, + closeSockets: () => Promise.resolve(), + }; + await requestRendererReload(overrides); + assert.equal(reloadCount, 1); + // In production location.reload() discards the module; the guard is never + // reset, so a settled reload cannot start a second on the dying page. + await requestRendererReload(overrides); + assert.equal(reloadCount, 1); +}); diff --git a/desktop/src/shared/lib/reloadRenderer.ts b/desktop/src/shared/lib/reloadRenderer.ts new file mode 100644 index 00000000000..849dffb919b --- /dev/null +++ b/desktop/src/shared/lib/reloadRenderer.ts @@ -0,0 +1,64 @@ +import { closeAllWebSockets } from "@/shared/api/relayWebSocketClose"; + +/** Upper bound on the graceful WebSocket teardown before the webview reloads. */ +export const RELOAD_TEARDOWN_TIMEOUT_MS = 500; + +/** + * The single reload in progress. Overlapping triggers (a second Cmd+R, or the + * idle backstop firing while Cmd+R is mid-teardown) collapse onto it so the + * native WebSocket teardown never runs twice. Never cleared in production — + * `location.reload()` discards the module — so a settled reload cannot start a + * second one on the dying page. + */ +let inFlightReload: Promise | null = null; + +type RequestRendererReloadOverrides = { + /** Navigate the webview. Injected in tests so the runner never reloads. */ + reload?: () => void; + /** Bounded native teardown. Injected to exercise the rejection/timeout path. */ + closeSockets?: () => Promise; +}; + +/** + * Reload the webview after a bounded native WebSocket teardown, exactly once + * per navigation. + * + * Shared by the Cmd+R shortcut (`useReloadShortcut`) and the idle backstop + * (`useIdleAutoReload`): both need the same clean-teardown path so the native + * engine and its child agents survive while the renderer's heap and timers are + * discarded. Only the backstop applies eligibility guards; this primitive is + * pure execution. + * + * The reload is unconditional. Teardown is attempted for at most + * `RELOAD_TEARDOWN_TIMEOUT_MS`; a hung socket loses to the timeout and a + * rejecting teardown is logged and swallowed, so the reload always runs in the + * `finally`. A rejection must never win the race and strand the renderer on a + * stale heap. + */ +export function requestRendererReload({ + reload = () => window.location.reload(), + closeSockets = closeAllWebSockets, +}: RequestRendererReloadOverrides = {}): Promise { + if (inFlightReload) return inFlightReload; + inFlightReload = (async () => { + try { + await Promise.race([ + closeSockets().catch((err) => + console.debug("requestRendererReload teardown rejected:", err), + ), + new Promise((resolve) => + window.setTimeout(resolve, RELOAD_TEARDOWN_TIMEOUT_MS), + ), + ]); + } finally { + reload(); + } + })(); + return inFlightReload; +} + +/** Clear the module-level in-flight reload between tests (production reloads + * the page, so the guard is never reset there). */ +export function resetRendererReloadForTests(): void { + inFlightReload = null; +} diff --git a/desktop/src/shared/lib/volatileWorkRegistry.test.mjs b/desktop/src/shared/lib/volatileWorkRegistry.test.mjs new file mode 100644 index 00000000000..31de72db493 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkRegistry.test.mjs @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The registry hook uses React.useId + React.useEffect, so the mounted-lifecycle +// tests need a DOM. The imperative acquire/release/predicate surface is pure. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +async function loadRegistry() { + const mod = await import("./volatileWorkRegistry.ts"); + mod._resetVolatileWorkForTests(); + return mod; +} + +afterEach(async () => { + const { _resetVolatileWorkForTests } = await import( + "./volatileWorkRegistry.ts" + ); + _resetVolatileWorkForTests(); +}); + +// ── acquire / release ──────────────────────────────────────────────────────── + +test("isAnyVolatileWorkPending_no_registrations_returns_false", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_holds_until_released", async () => { + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const release = acquireVolatileWork("op-1"); + assert.equal(isAnyVolatileWorkPending(), true); + release(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_same_key_twice_is_idempotent", async () => { + // Two acquisitions of the same key collapse to one hold; the key is either + // held or not, so a single release clears it (no refcount drift). + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const releaseA = acquireVolatileWork("op-dup"); + const releaseB = acquireVolatileWork("op-dup"); + assert.equal(isAnyVolatileWorkPending(), true); + releaseA(); + assert.equal(isAnyVolatileWorkPending(), false); + // The second release is a harmless no-op on an already-clear key. + releaseB(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_distinct_keys_each_hold_independently", async () => { + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const releaseA = acquireVolatileWork("op-a"); + const releaseB = acquireVolatileWork("op-b"); + releaseA(); + // b still outstanding — a concurrent picker/upload must keep blocking. + assert.equal(isAnyVolatileWorkPending(), true); + releaseB(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── withVolatileWork (bounded async seam) ───────────────────────────────────── + +test("withVolatileWork_holds_while_running_releases_on_resolve", async () => { + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + let releaseInner; + const pending = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseInner = () => resolve("done"))), + ); + // The hold is live while the wrapped operation is in flight. + assert.equal(isAnyVolatileWorkPending(), true); + releaseInner(); + assert.equal(await pending, "done", "the wrapped result passes through"); + assert.equal(isAnyVolatileWorkPending(), false, "resolve releases the hold"); +}); + +test("withVolatileWork_releases_on_reject", async () => { + // A failed picker/upload must not leak its hold — the reload would be blocked + // forever with nothing to surface it. + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + await assert.rejects( + withVolatileWork("op", async () => { + throw new Error("picker failed"); + }), + ); + assert.equal(isAnyVolatileWorkPending(), false, "reject releases the hold"); +}); + +test("withVolatileWork_concurrent_calls_hold_independently", async () => { + // Two overlapping operations (composer paperclip + custom-emoji upload) must + // get distinct keys so one settling does not release the other's hold. + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + let releaseA; + let releaseB; + const a = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseA = resolve)), + ); + const b = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseB = resolve)), + ); + assert.equal(isAnyVolatileWorkPending(), true); + releaseA(); + await a; + assert.equal( + isAnyVolatileWorkPending(), + true, + "the second operation's hold must survive the first settling", + ); + releaseB(); + await b; + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── predicates ─────────────────────────────────────────────────────────────── + +test("registerVolatileWorkPredicate_reports_pending_on_demand", async () => { + const { registerVolatileWorkPredicate, isAnyVolatileWorkPending } = + await loadRegistry(); + let busy = false; + registerVolatileWorkPredicate(() => busy); + assert.equal(isAnyVolatileWorkPending(), false); + busy = true; + assert.equal(isAnyVolatileWorkPending(), true); + busy = false; + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("registerVolatileWorkPredicate_unregister_stops_reads", async () => { + const { registerVolatileWorkPredicate, isAnyVolatileWorkPending } = + await loadRegistry(); + const unregister = registerVolatileWorkPredicate(() => true); + assert.equal(isAnyVolatileWorkPending(), true); + unregister(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("isAnyVolatileWorkPending_holder_alone_is_enough", async () => { + // A held key blocks even when every predicate reports idle. + const { + acquireVolatileWork, + registerVolatileWorkPredicate, + isAnyVolatileWorkPending, + } = await loadRegistry(); + registerVolatileWorkPredicate(() => false); + const release = acquireVolatileWork("op-solo"); + assert.equal(isAnyVolatileWorkPending(), true); + release(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── useRegisterVolatileWork (mounted lifecycle) ──────────────────────────────── + +test("useRegisterVolatileWork_holds_while_active_true", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_active_false_does_not_hold", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + renderHook(() => useRegisterVolatileWork(false)); + assert.equal(isAnyVolatileWorkPending(), false); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_releases_when_active_flips_false", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { rerender } = renderHook( + ({ active }) => useRegisterVolatileWork(active), + { initialProps: { active: true } }, + ); + assert.equal(isAnyVolatileWorkPending(), true); + rerender({ active: false }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "clearing work while mounted must stop blocking", + ); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_releases_on_unmount", async () => { + // A component torn down mid-work (the forum view closing with unsent text) + // must not leak its key — a leaked key blocks the reload forever. + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { unmount } = renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmount must release the pending key", + ); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_two_mounts_use_distinct_keys", async () => { + // Two composers active at once must not collide on one key: releasing one + // must leave the other's hold intact. + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const first = renderHook(() => useRegisterVolatileWork(true)); + const second = renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + first.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the second mount's key must survive the first's unmount", + ); + second.unmount(); + assert.equal(isAnyVolatileWorkPending(), false); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_strict_mode_settles_to_single_hold", async () => { + // StrictMode double-invokes the effect (acquire → release → acquire). Because + // the key is stable across the replay and the registry is keyed, the final + // state is a single hold that a single unmount fully releases. + const React = await import("react"); + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { unmount } = renderHook(() => useRegisterVolatileWork(true), { + wrapper: ({ children }) => + React.createElement(React.StrictMode, null, children), + }); + assert.equal(isAnyVolatileWorkPending(), true); + unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "StrictMode replay must not leave a residual hold after unmount", + ); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/shared/lib/volatileWorkRegistry.ts b/desktop/src/shared/lib/volatileWorkRegistry.ts new file mode 100644 index 00000000000..9f77108af69 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkRegistry.ts @@ -0,0 +1,127 @@ +import * as React from "react"; + +/** + * App-global registry of volatile, reload-destroyed work. + * + * The idle auto-reload backstop (`useIdleAutoReload`) navigates the webview + * (`location.reload()`), discarding the renderer heap. Any work that lives only + * in renderer memory — a hook-local queue of local files, unsent composer text a + * draft cannot serialize, an open native picker whose result has not returned — + * would be lost if the reload fired mid-flight. Each such source registers here; + * {@link isAnyVolatileWorkPending} folds them into the single blocker the policy + * reads, so a new volatile-work class is guarded by registering it, not by + * threading another gate through the reload predicate. + * + * Two registration shapes, because volatile work comes in two lifetimes: + * + * - {@link registerVolatileWorkPredicate} — a module-level store (e.g. the + * background upload store) whose pending state is read on demand. Registered + * once at import; never released. + * - {@link acquireVolatileWork} / {@link useRegisterVolatileWork} — a bounded + * operation or a mounted component's live state. Acquiring returns a release + * handle; the work counts as pending until it is released. A leaked handle + * blocks the backstop forever and nothing surfaces it, so release is wired to + * both unmount and the `active` flag flipping false (see the hook). + */ + +/** + * Live acquire handles, keyed so repeated acquisition from one owner (a + * component key, an operation) collapses to a single hold. StrictMode double- + * invokes effects (acquire → release → acquire); a keyed map is idempotent + * under that replay where a bare counter would drift. + */ +const holders = new Set(); + +/** On-demand predicates contributed by module-level stores. */ +const predicates = new Set<() => boolean>(); + +/** + * Register a predicate read at reload-check time. Returns an unregister fn for + * symmetry and tests; module-level stores register once and never unregister. + */ +export function registerVolatileWorkPredicate( + predicate: () => boolean, +): () => void { + predicates.add(predicate); + return () => { + predicates.delete(predicate); + }; +} + +/** + * Mark a bounded volatile operation as in progress under `key`, returning a + * release handle. Repeated acquisition under the same key is idempotent (the + * key is either held or not); the returned release clears that key. Callers own + * exactly one release call per acquire — a native-picker wrapper releases in a + * `finally`, a component effect releases on cleanup. + */ +export function acquireVolatileWork(key: string): () => void { + holders.add(key); + return () => { + holders.delete(key); + }; +} + +/** Monotonic suffix so overlapping {@link withVolatileWork} calls hold distinct + * keys (e.g. the composer paperclip and a custom-emoji upload running at once). */ +let workSeq = 0; + +/** + * Run an async operation while holding a volatile-work key, releasing it once + * the operation settles — resolve OR reject. The single seam for bounded + * reload-destroyed work (native pickers, IPC uploads): the `finally` cannot + * leak the hold, and a per-call key keeps concurrent operations independent. + */ +export async function withVolatileWork( + label: string, + run: () => Promise, +): Promise { + const release = acquireVolatileWork(`${label}-${workSeq++}`); + try { + return await run(); + } finally { + release(); + } +} + +/** + * True while any registered source has volatile work outstanding — a held + * acquire key or a predicate that reports pending. Read imperatively by the + * idle backstop at check time; holds no subscription. + */ +export function isAnyVolatileWorkPending(): boolean { + if (holders.size > 0) return true; + for (const predicate of predicates) { + if (predicate()) return true; + } + return false; +} + +/** + * Register a mounted component's live volatile state with the registry. + * + * While `active` is true the component holds a unique per-mount key (from + * {@link React.useId}, so two composers never collide); when `active` flips + * false the hold releases, and unmount releases it too. Both paths matter: a + * component that clears its work but stays mounted must stop blocking, and a + * component torn down mid-work (the forum view closing with unsent text) must + * not leak its key. + * + * StrictMode double-invokes this effect (acquire → release → acquire); because + * the key is stable across the replay and the registry is keyed, the final + * state is a single hold, matching production's single invoke. + */ +export function useRegisterVolatileWork(active: boolean): void { + const key = React.useId(); + React.useEffect(() => { + if (!active) return; + const release = acquireVolatileWork(key); + return release; + }, [active, key]); +} + +/** Clear all registrations. Tests only — production never resets. */ +export function _resetVolatileWorkForTests(): void { + holders.clear(); + predicates.clear(); +} diff --git a/desktop/src/shared/lib/volatileWorkWiring.test.mjs b/desktop/src/shared/lib/volatileWorkWiring.test.mjs new file mode 100644 index 00000000000..787795e9fe8 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkWiring.test.mjs @@ -0,0 +1,647 @@ +/** + * Mounted production-wiring tests for the volatile-work registry. + * + * ── What this proves ───────────────────────────────────────────────────────── + * The registry itself is unit-tested in volatileWorkRegistry.test.mjs. This + * file proves the OTHER half of the CRITICAL finding: that each real source + * Thufir enumerated actually registers its reload-destroyed work, by mounting + * the REAL production code (not a fixture with the blocker hand-set) and + * asserting `isAnyVolatileWorkPending()` flips as the source's live state does. + * + * Covered sources, each empty → active → drained: + * - ForumComposer's unsent draft: full mount (real tiptap editor, router, + * providers); empty composer never blocks, real editor input blocks, and + * unmount releases (the forum view closing must not leak its key). + * - useMediaUpload, BOTH branches: the production composer takes the + * `deferUploadsUntilSend` branch (queued local videos), the non-deferred + * path is a foreground upload in flight. A mounted test on only one branch + * would "prove" coverage the other path never gets. + * - The native-picker choke points `pickAndUploadMedia` (tauri.ts) and + * `pickAndUploadImage` (tauriMedia.ts): the open-dialog + Rust-upload window + * blocks, and the `finally` releases on both resolve and reject. Testing at + * the choke point (not the caller) is what covers every picker caller — + * CustomEmojiSettingsCard and useSendFeedback have no uploadingCount. + * - backgroundMediaUploadStore's registered predicate: an off-channel + * retained local file (the in-memory `queuedAttachmentsByDraftKey` map) and + * an in-flight background task both block; draining either releases. + * + * ── DOM + process-exit note ────────────────────────────────────────────────── + * The full ForumComposer mount pulls in tiptap/ProseMirror + TanStack Router, + * whose deep tree leaves ref'd timers alive after unmount (visible only in + * `process.getActiveResourcesInfo()`, not `_getActiveHandles()`). Left alone + * they keep the event loop alive and hang `pnpm test`. We track every timer + * created after setup and clear the survivors in `after()`, so this file exits + * cleanly. node:test runs each file in its own process, so the timer shims and + * the module-level store predicate cannot leak into other test files. + */ + +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// Real timer fns captured before the tracking shims replace the globals. +const realSetTimeout = globalThis.setTimeout; +const realClearTimeout = globalThis.clearTimeout; +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; + +// Every timer created after setup, cleared in after() to release the ref'd +// timers the tiptap/router tree leaves behind. +const liveTimeouts = new Set(); +const liveIntervals = new Set(); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Swappable Tauri IPC handler; tests that drive the bridge replace it. +let invokeImpl = async () => undefined; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + DOMParser: dom.window.DOMParser, + Node: dom.window.Node, + Range: dom.window.Range, + File: dom.window.File, + Blob: dom.window.Blob, + FileList: dom.window.FileList, + localStorage: dom.window.localStorage, + self: globalThis, + scrollTo: () => {}, + }); + globalThis.setTimeout = (...args) => { + const id = realSetTimeout(...args); + liveTimeouts.add(id); + return id; + }; + globalThis.clearTimeout = (id) => { + liveTimeouts.delete(id); + return realClearTimeout(id); + }; + globalThis.setInterval = (...args) => { + const id = realSetInterval(...args); + liveIntervals.add(id); + return id; + }; + globalThis.clearInterval = (id) => { + liveIntervals.delete(id); + return realClearInterval(id); + }; + const raf = (fn) => globalThis.setTimeout(() => fn(Date.now()), 0); + const caf = (id) => globalThis.clearTimeout(id); + globalThis.requestAnimationFrame = raf; + globalThis.cancelAnimationFrame = caf; + dom.window.requestAnimationFrame = raf; + dom.window.cancelAnimationFrame = caf; + if (!globalThis.MutationObserver) { + globalThis.MutationObserver = dom.window.MutationObserver; + } + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + // jsdom's createObjectURL rejects a File; the composer only needs an opaque + // handle, so stub it (and revoke) on both realms. + globalThis.URL.createObjectURL = () => "blob:test"; + globalThis.URL.revokeObjectURL = () => {}; + dom.window.URL.createObjectURL = () => "blob:test"; + dom.window.URL.revokeObjectURL = () => {}; + const tauriInternals = { + invoke: (cmd, args, opts) => invokeImpl(cmd, args, opts), + transformCallback: () => 0, + }; + globalThis.__TAURI_INTERNALS__ = tauriInternals; + dom.window.__TAURI_INTERNALS__ = tauriInternals; + // The Tauri event plugin's _unlisten() reads this global on teardown; the + // upload hooks register a byte-progress listener whose cleanup runs on + // unmount, so without the stub every mounted-hook test throws there. + const tauriEventPluginInternals = { + unregisterListener: () => {}, + }; + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = tauriEventPluginInternals; + dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = tauriEventPluginInternals; + // This jsdom build ships File/Blob without arrayBuffer(); uploadMediaFile + // awaits it before the IPC, so polyfill it to reach the mocked invoke. + if (!dom.window.Blob.prototype.arrayBuffer) { + dom.window.Blob.prototype.arrayBuffer = async function arrayBuffer() { + return new ArrayBuffer(this.size ?? 0); + }; + } +}); + +after(() => { + for (const id of liveTimeouts) realClearTimeout(id); + for (const id of liveIntervals) realClearInterval(id); + liveTimeouts.clear(); + liveIntervals.clear(); + dom.window.close(); +}); + +// React core needs no DOM; react-dom is imported dynamically inside the mount +// helper so the timer shims installed in before() are active before its +// scheduler binds. +const React = await import("react"); + +/** Wait `ms` on the real (untracked) timer so the wait itself is never a + * survivor we clear. */ +function realDelay(ms) { + return new Promise((resolve) => realSetTimeout(resolve, ms)); +} + +/** Mount a hook in a thin harness, exposing its latest return value. */ +async function mountHook(useHook) { + const { createRoot } = await import("react-dom/client"); + const { act } = await import("react"); + const ref = { current: null }; + function Harness() { + ref.current = useHook(); + return null; + } + const container = dom.window.document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Harness)); + }); + return { + ref, + act, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +/** Mount the real ForumComposer inside the providers + router it needs. */ +async function mountForumComposer() { + const { createRoot } = await import("react-dom/client"); + const { act } = await import("react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { createRouter, createRootRoute, RouterProvider, createMemoryHistory } = + await import("@tanstack/react-router"); + const { ForumComposer } = await import( + "@/features/forum/ui/ForumComposer.tsx" + ); + const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { TooltipProvider } = await import("@/shared/ui/tooltip.tsx"); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: "c".repeat(64) }); + const rootRoute = createRootRoute({ + component: () => + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + TooltipProvider, + null, + React.createElement(ForumComposer, { + placeholder: "Post to the forum", + onSubmit: () => {}, + }), + ), + ), + ), + }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(RouterProvider, { router })); + }); + await act(async () => { + await realDelay(150); + }); + return { + container, + act, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +async function loadRegistry() { + return import("@/shared/lib/volatileWorkRegistry.ts"); +} + +// ── ForumComposer unsent draft ─────────────────────────────────────────────── + +test("forum_composer_empty_draft_does_not_block_reload", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const view = await mountForumComposer(); + const editor = view.container.querySelector( + '.ProseMirror, [contenteditable="true"]', + ); + assert.ok(editor, "the real tiptap editor must mount"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "an empty forum composer holds no reload-destroyed work", + ); + await view.unmount(); +}); + +test("forum_composer_unsent_text_blocks_then_unmount_releases", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const view = await mountForumComposer(); + const editor = view.container.querySelector( + '.ProseMirror, [contenteditable="true"]', + ); + assert.ok(editor, "the real tiptap editor must mount"); + + // Type into the real editor; the content flows into ForumComposer's state, + // which drives useRegisterVolatileWork. + await view.act(async () => { + editor.textContent = "an unsent forum reply"; + editor.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true })); + }); + await view.act(async () => { + await realDelay(30); + }); + assert.equal( + isAnyVolatileWorkPending(), + true, + "unsent forum text must block the idle reload", + ); + + // The forum view closing with unsent text must not leak its key. + await view.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmounting the forum composer must release its pending key", + ); +}); + +// ── useMediaUpload — deferred (production composer) branch ──────────────────── + +test("media_upload_deferred_queued_video_blocks_then_clear_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: true }), + ); + // A video on the deferred branch is queued locally (never uploaded until + // send) — reload-destroyed state with no in-flight upload behind it. + const video = new dom.window.File([new Uint8Array(8)], "clip.mp4", { + type: "video/mp4", + }); + await handle.act(async () => { + await handle.ref.current.uploadFile(video); + }); + assert.equal( + handle.ref.current.queuedAttachments.length, + 1, + "the video must be queued locally, not uploaded", + ); + assert.equal( + isAnyVolatileWorkPending(), + true, + "a queued local attachment must block the reload", + ); + + await handle.act(async () => { + handle.ref.current.clearQueuedAttachments(); + }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "clearing the queue drains the block", + ); + await handle.unmount(); +}); + +// ── useMediaUpload — non-deferred (foreground upload) branch ────────────────── + +test("media_upload_foreground_in_flight_blocks_then_completion_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolveUpload; + invokeImpl = (cmd) => { + if (cmd === "upload_media_bytes_raw") { + return new Promise((resolve) => { + resolveUpload = () => + resolve({ url: "u", sha256: "s", size: 8, type: "image/png" }); + }); + } + return undefined; + }; + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: false }), + ); + const image = new dom.window.File([new Uint8Array(8)], "pic.png", { + type: "image/png", + }); + await handle.act(async () => { + handle.ref.current.uploadFile(image).catch(() => {}); + await realDelay(20); + }); + assert.equal( + handle.ref.current.isUploading, + true, + "the foreground upload must be in flight", + ); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an in-flight foreground upload must block the reload", + ); + + await handle.act(async () => { + resolveUpload(); + await realDelay(20); + }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "a completed upload drains the block", + ); + await handle.unmount(); + invokeImpl = async () => undefined; +}); + +test("media_upload_unmount_while_queued_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: true }), + ); + const video = new dom.window.File([new Uint8Array(8)], "clip.mp4", { + type: "video/mp4", + }); + await handle.act(async () => { + await handle.ref.current.uploadFile(video); + }); + assert.equal(isAnyVolatileWorkPending(), true, "queued video blocks"); + + // A composer torn down with a queued attachment must not leak its key. + await handle.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmounting a composer with a queued attachment must release its key", + ); +}); + +// ── Native-picker choke points ──────────────────────────────────────────────── + +test("pick_and_upload_media_open_dialog_blocks_then_release_on_resolve", async () => { + const { pickAndUploadMedia } = await import("@/shared/api/tauri.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolvePick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_media") { + return new Promise((resolve) => { + resolvePick = () => resolve([]); + }); + } + return undefined; + }; + + const pending = pickAndUploadMedia("progress-id"); + await realDelay(5); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the open picker + Rust upload window must block the reload", + ); + resolvePick(); + await pending; + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release when the picker resolves", + ); + invokeImpl = async () => undefined; +}); + +test("pick_and_upload_media_release_on_reject", async () => { + const { pickAndUploadMedia } = await import("@/shared/api/tauri.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let rejectPick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_media") { + return new Promise((_resolve, reject) => { + rejectPick = () => reject(new Error("picker failed")); + }); + } + return undefined; + }; + + const pending = pickAndUploadMedia("progress-id"); + await realDelay(5); + assert.equal(isAnyVolatileWorkPending(), true, "open picker blocks"); + rejectPick(); + await assert.rejects(pending); + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release even when the picker rejects", + ); + invokeImpl = async () => undefined; +}); + +test("pick_and_upload_image_open_dialog_blocks_then_release_on_resolve", async () => { + const { pickAndUploadImage } = await import("@/shared/api/tauriMedia.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolvePick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_image") { + return new Promise((resolve) => { + resolvePick = () => resolve(null); + }); + } + return undefined; + }; + + const pending = pickAndUploadImage(); + await realDelay(5); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the image picker choke point (feedback, no uploadingCount) must block", + ); + resolvePick(); + await pending; + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release when the image picker resolves", + ); + invokeImpl = async () => undefined; +}); + +// ── backgroundMediaUploadStore registered predicate ─────────────────────────── + +test("background_store_retained_offchannel_file_blocks_then_taking_releases", async () => { + const store = await import( + "@/features/messages/lib/backgroundMediaUploadStore.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + // A local file retained after the user leaves its channel lives only in the + // in-memory queuedAttachmentsByDraftKey map — the clearest reload-loss case. + const attachment = { + file: new dom.window.File([new Uint8Array(4)], "note.pdf", { + type: "application/pdf", + }), + id: 1, + spoilered: false, + }; + store.saveQueuedAttachmentsForDraft("chan-away", [attachment]); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an off-channel retained local file must block the reload", + ); + + const taken = store.takeQueuedAttachmentsForDraft("chan-away"); + assert.equal(taken.length, 1, "the retained file is returned when taken"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "draining the retention map releases the block", + ); +}); + +test("background_store_in_flight_task_blocks_then_completion_releases", async () => { + const store = await import( + "@/features/messages/lib/backgroundMediaUploadStore.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolveUpload; + invokeImpl = (cmd) => { + if (cmd === "upload_media_bytes_raw") { + return new Promise((resolve) => { + resolveUpload = () => + resolve({ url: "u", sha256: "s", size: 4, type: "image/png" }); + }); + } + return undefined; + }; + + let completed = false; + const attachment = { + file: new dom.window.File([new Uint8Array(4)], "pic.png", { + type: "image/png", + }), + id: 2, + spoilered: false, + }; + store.enqueueBackgroundMediaUpload({ + attachments: [attachment], + onComplete: async () => { + completed = true; + }, + onError: () => {}, + }); + await realDelay(20); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an in-flight background upload must block the reload", + ); + + resolveUpload(); + await realDelay(20); + assert.equal(completed, true, "the background upload completes"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "the settled background task releases the block", + ); + store.resetBackgroundMediaUploads(); + invokeImpl = async () => undefined; +}); diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 202ec968698..1b3eeec3167 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -417,7 +417,16 @@ test("partial list-item selections snap to whole items for block formats", async ], }[format]; expect(structure).toEqual(expected); - await page.reload(); + // Clear the composer before the next iteration navigates. The pagehide + // draft flush persists live editor text on navigation-away, so leaving the + // formatted content in place would restore it into the next iteration's + // composer. `fill("")` does not reliably empty the ProseMirror editor, so + // select-all + Backspace clears it the way a user would; the emptied editor + // then flushes to a cleared draft. (This replaces a bare page.reload(), + // which the flush now defeats — the next iteration's openGeneral already + // provides the fresh page.) + await input.press("ControlOrMeta+a"); + await input.press("Backspace"); } });