From 22b87bca2aa83fd6b2c67ec10b1b53b4a7394e10 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Wed, 21 Oct 2026 19:00:20 +0800 Subject: [PATCH] Fix encrypted mirror unlock waiting for Timeline presentation --- app/application.ml | 10 ++ ...10-21-existing-mirror-unlock-completion.md | 60 ++++++++ test/application_view_test.ml | 141 +++++++++++++++++- 3 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 docs/agent-guide/implemented/bugfix/2026-10-21-existing-mirror-unlock-completion.md diff --git a/app/application.ml b/app/application.ml index b8a2e35..c15acd0 100644 --- a/app/application.ml +++ b/app/application.ml @@ -779,6 +779,15 @@ let apply_manager_state state (manager_state : Graph_service.state) = | None | Some _ -> false in let is_awaiting_password = snapshot.startup.awaiting_e2ee_password in + (* Attachment precedes Timeline's first appearance. Waiting for Ready here + would keep the password screen mounted and prevent that acknowledgement. *) + let current_graph_attached = + not (owner_changed || graph_context_changed) + && Option.is_some snapshot.selected_graph + && snapshot.startup.local_access = Graph_service.Local_available + && not snapshot.startup.restoring_local + && snapshot.timeline_presentation_pending + in let e2ee_password, next_local_sequence = if is_awaiting_password = was_awaiting_password && not (owner_changed || graph_context_changed) @@ -816,6 +825,7 @@ let apply_manager_state state (manager_state : Graph_service.state) = || is_awaiting_password || Option.is_some snapshot.startup.failure || snapshot.startup.bootstrapping + || current_graph_attached || (not snapshot.startup.authenticated) || (Journal_startup.derive ~snapshot ~graph:state.graph_state).phase = Ready ) diff --git a/docs/agent-guide/implemented/bugfix/2026-10-21-existing-mirror-unlock-completion.md b/docs/agent-guide/implemented/bugfix/2026-10-21-existing-mirror-unlock-completion.md new file mode 100644 index 0000000..eb32e54 --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-21-existing-mirror-unlock-completion.md @@ -0,0 +1,60 @@ +# 2026 10 21 Existing Mirror Unlock Completion + +## Problem + +After sign-out retains an encrypted local mirror but deletes its unlock credentials, +sign-in can require an E2EE password to reopen that mirror. Application keeps the +submission pending until startup is Ready, but Ready requires Timeline's first +appearance acknowledgement. The pending password screen prevents that appearance. +The sync reducer has already attached the graph successfully. + +## Decision + +End the current password submission when the current manager scope reports a +successful attachment with local access available and first presentation pending. +Keep the Timeline acknowledgement and the existing error/cancellation handling. +Do not treat Local_available alone or command queue admission as unlock success. + +The public sync reducer plus startup presentation reproduces the circular +dependency, but cannot execute Application's private pending-state transition. +Application has no public pure E2EE reducer event/state boundary. Exercise its +existing mounted hooks in application_view_test, the narrowest public boundary +owning the defect; do not add duplicate transport or persistence regressions. + +## Alternatives considered + +### Wait for Ready or clear on Local_available alone + +Ready preserves the cycle. Local_available alone can already be advertised while +the password request is in progress. Removing first-presentation acknowledgement +would change startup and rendering ownership unnecessarily. + +## Acceptance criteria + +- A regression fails before the fix and passes after it when an existing mirror + attaches successfully while the first presentation acknowledgement is pending. +- Manager/graph notification order does not prevent the password screen exiting. +- Local availability without attachment, wrong password, failure, and replaced + scopes cannot be mistaken for successful attachment of the current graph. +- Timeline still sends its scoped first-appearance acknowledgement. +- Test the same sign-out/sign-in/unlock path on the existing isolated Simulator + bundle without deleting graph/account data; user enters all real credentials. + +## Consequences + +- The new terminal condition must be scoped to the currently selected graph and + successful attachment; stale completions remain fenced by the sync reducer. +- Host-restamped Simulator builds retain experimental ABI status and do not + establish iPhoneOS ABI equivalence. +- Both mounted App regressions failed on the original pending-state logic and + pass with the attachment completion condition. The full Application suite + passes (144 cases), including existing wrong-password and admission failures; + the pure sync core suite passes (87 cases), including late E2EE completion + fencing and scoped first appearance acknowledgement. Independent source + review found no issue. Real Simulator acceptance is performed separately + with the user's own credential entry; these synthetic tests do not prove it. + +## Questions + +None. The user approved the minimal fix and Simulator test. No new PR, merge, +or release is authorized by this decision. diff --git a/test/application_view_test.ml b/test/application_view_test.ml index d6440df..14c6331 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -5092,7 +5092,11 @@ let test_fixture_exception_cleanup exception_ = (* Application owns password admission and UI. No public pure root event exists for E2EE; this fixture exercises mounted Application through its public hooks. *) -let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = false) ?(cold_start = false) ?on_platform_request ?(on_auth_classified = fun _ -> ()) () = +let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = false) ?(cold_start = false) + ?(attach_mirror = false) ?(graph_first = false) + ?(attachment_stage = Atomic.make 0) ?(finish_attachment = Atomic.make true) + ?(on_timeline_presented = fun _ -> ()) + ?on_platform_request ?(on_auth_classified = fun _ -> ()) () = let module W = Logseq_db_worker_lui.Journal_worker in let graph_id = Logseq_db_types.Graph_types.Uuid.of_string "00000000-0000-0000-0000-000000000901" @@ -5136,6 +5140,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = else manager in let graph_reads = Atomic.make 0 in let current = Atomic.make manager in + let graph_phase = Atomic.make Logseq_db_worker.Graph_closed in let submissions = Atomic.make [] in let release = Atomic.make false in let client = ref None in @@ -5177,7 +5182,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = (Service.Graph_state { generation = 1 ; graph_id = (Atomic.get current).snapshot.selected_graph - ; phase = Graph_closed + ; phase = Atomic.get graph_phase ; error = None }) | Client_command (Submit_e2ee_password _) when worker_failure -> @@ -5204,7 +5209,31 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = while (not (Atomic.get release)) && Unix.gettimeofday () < deadline do Eio.Time.Mono.sleep (W.Request_context.clock context) 0.01 done; - publish + if attach_mirror then ( + let attached = + { old with snapshot = { old.snapshot with + applied_server_t = Some (server_cursor_of_int 51) + ; timeline_presentation_pending = true + ; startup = { old.snapshot.startup with + awaiting_e2ee_password = false; restoring_local = false + ; local_access = Service.Local_available; failure = None } + ; last_error = None } } + in + let manager_changed () = publish attached in + let graph_changed () = + Atomic.set graph_phase Logseq_db_worker.Graph_open; + W.Request_context.emit context ~topic:Service.graph_state_topic + (Service.Graph_state_changed + { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }) + in + (if graph_first then graph_changed else manager_changed) (); + Atomic.set attachment_stage 1; + while not (Atomic.get finish_attachment) do + Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001 + done; + (if graph_first then manager_changed else graph_changed) (); + Atomic.set attachment_stage 2) + else publish { manager with snapshot = { manager.snapshot with @@ -5216,6 +5245,11 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = } }; Ok Service.Client_command_completed + | Client_command (Acknowledge_timeline_presented scope) -> + on_timeline_presented scope; + let value = Atomic.get current in + publish { value with snapshot = { value.snapshot with timeline_presentation_pending = false } }; + Ok Service.Client_command_completed | Client_command Return_to_graph_picker -> publish { manager with @@ -5243,6 +5277,23 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = | Client_command _ | Asset_command _ | Release_asset_file _ -> Ok Service.Client_command_completed | Acquire_asset_file _ | Acquire_imported_file _ -> Ok (Service.Asset_file None) + | Graph_request request when attach_mirror -> + let module P = Logseq_db_worker.Protocol in + let outcome = match request.command with + | P.V2_graph_info -> P.V2_graph_info_outcome + { graph_uuid = graph_id; graph_name = "Synthetic" + ; schema = { major = 65; minor = 33 }; admission_facts = [] + ; journal_title_format = None + ; limits = { response_budget_bytes = P.maximum_response_bytes + ; outbox_max_records = 4096; outbox_max_bytes = 8388608 + ; change_max_items = 4096; change_max_bytes = 1048576 + ; dispatcher_capacity = 256; wire_batch_max_bytes = 262144 } + ; generation = "g"; projection_revision = "p" } + | V2_list_journals _ -> P.V2_journals_outcome { items = []; next_cursor = None } + | _ -> P.V2_failed { code = "UnusedFixtureRead"; message = "Unused unlock fixture read" } + in + Ok (Service.Graph_response (P.V2_response + { api_version = P.api_version; request_id = request.request_id; outcome })) | Import_asset _ | Graph_request _ -> Error "Unused isolated password fixture command") ~shutdown:(fun () -> ()) @@ -5627,6 +5678,86 @@ let test_unlock_application () = [ "synthetic-only"; "Private Journal"; "Check your encryption password" ] ;; +let test_unlock_attached_mirror ~graph_first () = + let attachment_stage = Atomic.make 0 and finish_attachment = Atomic.make false in + let acknowledgements = Atomic.make [] in + let hooks, submissions, release, client = + unlock_fixture ~attach_mirror:true ~graph_first ~attachment_stage ~finish_attachment + ~on_timeline_presented:(fun scope -> + Atomic.set acknowledgements (scope :: Atomic.get acknowledgements)) () + in + let props = Hashtbl.create 64 and parents = Hashtbl.create 64 in + let track = track_json_wire_teardown ~parents (Hashtbl.remove props) in + let consume encoded = + if encoded <> "" then ( + let open Yojson.Safe.Util in + Yojson.Safe.from_string encoded |> member "ops" |> to_list |> List.iter (fun op -> + track op; + match op |> member "op" |> to_string with + | "create-node" -> Hashtbl.replace props (op |> member "id" |> to_int) [] + | "create-extension" -> Hashtbl.replace props (op |> member "id" |> to_int) + ["_extension", member "identifier" op] + | "set-prop" | "set-extension-prop" -> + let id = op |> member "id" |> to_int in + let key = op |> member "property" |> to_string in + let previous = Option.value (Hashtbl.find_opt props id) ~default:[] in + Hashtbl.replace props id ((key, member "value" op) :: List.remove_assoc key previous) + | _ -> ())) + in + let find key value = Hashtbl.fold (fun id values found -> + if List.assoc_opt key values = Some (`String value) then Some id else found) props None in + let editor () = find "accessibility-identifier" "e2ee-password-editor" in + let property id key = List.assoc_opt key (Hashtbl.find props id) in + let dispatch event = consume (hooks.dispatch event) in + let wait label predicate = + let deadline = Unix.gettimeofday () +. 5. in + while not (predicate ()) && Unix.gettimeofday () < deadline do + consume (hooks.pump ()); Unix.sleepf 0.001 + done; + Alcotest.(check bool) label true (predicate ()) + in + let startup = Logseq_db_worker.Config.create + ~application_support_directory:"/tmp/journal-attached-unlock-synthetic" + ~target:(Managed_sync {base_url="https://example.invalid"}) + ~compatibility_profile:Logseq_65_33_or_newer + ~response_budget_bytes:Logseq_db_worker.Protocol.maximum_response_bytes + ~default_page_size:Logseq_db_worker.Protocol.default_page_size + |> Result.get_ok |> Journal_startup.encode |> Result.get_ok |> Bytes.to_string in + Fun.protect ~finally:(fun () -> + Atomic.set release true; Atomic.set finish_attachment true; + ignore (hooks.dispose ()); + Option.iter Logseq_db_worker_lui.Journal_worker_runtime.stop !client) (fun () -> + consume (hooks.init 2 2 startup); + wait "existing mirror asks for password" (fun () -> Option.is_some (editor ())); + dispatch (Lui_protocol.TextChanged (Option.get (editor ()), "synthetic-only")); + dispatch (Lui_protocol.Submit (Option.get (editor ()))); + wait "submission reaches worker" (fun () -> Atomic.get submissions <> []); + wait "local availability alone keeps submission pending" (fun () -> + Option.fold ~none:false ~some:(fun id -> property id "enabled" = Some (`Bool false)) (editor ())); + Atomic.set release true; + wait "first attachment notification" (fun () -> Atomic.get attachment_stage = 1); + if not graph_first then + wait "attached manager releases password before graph notification" (fun () -> Option.is_none (editor ())); + Alcotest.(check int) "attachment cannot acknowledge a hidden Timeline" 0 + (List.length (Atomic.get acknowledgements)); + Atomic.set finish_attachment true; + wait "attached mirror exits password page" (fun () -> Option.is_none (editor ())); + wait "Timeline mounts before Ready acknowledgement" (fun () -> Option.is_some (find "_extension" "journal-list")); + let rec appear id = + let parent = Hashtbl.find_opt parents id in + if property id "appear-enabled" = Some (`Bool true) then dispatch (Lui_protocol.Appear id); + Option.iter appear parent + in + appear (Option.get (find "_extension" "journal-list")); + wait "Timeline retains first appearance acknowledgement" (fun () -> Atomic.get acknowledgements <> []); + let scope : Service.timeline_scope = List.hd (Atomic.get acknowledgements) in + Alcotest.(check int) "acknowledgement uses current account" 1 scope.startup.account_generation; + Alcotest.(check int) "acknowledgement uses current graph generation" 1 scope.graph_generation; + Alcotest.(check string) "acknowledgement uses selected graph" + "00000000-0000-0000-0000-000000000901" + (Logseq_db_types.Graph_types.Uuid.to_string scope.graph_id)) +;; + (* Root_navigation public reducer events do not own platform continuations, Worker.send admission, or token-cache effects. A pure reducer cannot produce Full here. Exercise the narrow production Application adapter with the real @@ -6392,6 +6523,10 @@ let () = test_repeated_native_sign_in_intent_receives_permission ] ) ; ( "E2EE input" , [ Alcotest.test_case "Application admission and recovery" `Quick test_unlock_application + ; Alcotest.test_case "existing mirror attach manager first" `Quick + (test_unlock_attached_mirror ~graph_first:false) + ; Alcotest.test_case "existing mirror attach graph first" `Quick + (test_unlock_attached_mirror ~graph_first:true) ; Alcotest.test_case "Application unlock worker failure releases pending" `Quick (fun () -> run_unlock_application ~worker_failure:true ()) ; Alcotest.test_case "Application unlock Full releases pending" `Quick