From 3a897fcfc206b9ec2b9d0d7f1d54126e78f9afc0 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 06:10:14 +0000 Subject: [PATCH 01/13] Prepare Trystero 0.26.0 rollout guidance --- docs/p2p.md | 8 ++++++++ updates.md | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/p2p.md b/docs/p2p.md index 75160f48..c9f0da82 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -4,6 +4,14 @@ Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). +## Updating a P2P group + +The unreleased transport update uses Trystero 0.26.0, whose data format is +incompatible with the previous transport. Update every device in the P2P +group, including CLI and WebPeer clients, before resuming P2P synchronisation. +Keeping the same Group ID, passphrase, and relay settings does not make mixed +transport versions compatible. + ## Connection model ```mermaid diff --git a/updates.md b/updates.md index 93c3d030..0743ac10 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,18 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Improved + +#### P2P synchronisation + +- P2P connections include upstream improvements to peer discovery after relay + reconnection, recovery from missed signalling messages, and stale connections + after rejoining or waiting. Connection offers are created on demand to reduce + idle resource use. + - Update every device in the P2P group, including CLI and WebPeer clients, + before resuming P2P synchronisation. The updated transport uses Trystero + 0.26.0 and cannot exchange data with the previous transport. + ## 1.0.45 5th November, 2026 From fc5f02ca3b532edb6d123c9a5ebc99d270d26007 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 08:45:24 +0000 Subject: [PATCH 02/13] feat: show P2P transfer and comparison progress Show per-peer document revision counts and brief directional comparison labels in both P2P panes and the editor status. Use the P2P projection for the main status bar when P2P is the selected remote. Retain labelled sequence progress for peers without statistics, and keep adjunct P2P counters separate from the selected remote. Requires the additive P2P statistics API in vrtmrz/livesync-commonlib@be7bd0988ea978fd7aa0efd29eca44c9fec09d7b. Consumer checks passed against a packed candidate; pin the published package before integration. --- .../P2POpenReplicationPane.svelte | 24 ++- .../P2PReplicator/P2PServerStatusPane.svelte | 20 ++ src/modules/features/ModuleLog.ts | 25 ++- src/modules/features/P2PStatusLabel.ts | 178 ++++++++++++++++++ .../features/P2PStatusLabel.unit.spec.ts | 148 +++++++++++++++ updates.md | 6 + 6 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 src/modules/features/P2PStatusLabel.ts create mode 100644 src/modules/features/P2PStatusLabel.unit.spec.ts diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte index 41e0e11b..9c4749d4 100644 --- a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte @@ -13,6 +13,7 @@ import { delay, fireAndForget } from "octagonal-wheels/promises"; import P2PServerStatusCard from "./P2PServerStatusCard.svelte"; import { $msg as translateMessage } from "@/common/translation"; + import { observeP2PDiagnosticsProgress, type P2PStatusProjection } from "@/modules/features/P2PStatusLabel"; interface Props { p2p: P2PServiceViews; @@ -27,6 +28,7 @@ let serverInfo = $state(undefined); let syncingPeerId = $state(null); + let peerProgress = $state(new Map()); const logLevel = $derived(showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); async function requestServerStatus() { @@ -34,6 +36,9 @@ eventHub.emitEvent(EVENT_REQUEST_STATUS); } onMount(() => { + const unsubscribeProgress = observeP2PDiagnosticsProgress(p2p.diagnostics, (snapshot) => { + peerProgress = snapshot.peers; + }); // ServerStatus const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => { serverInfo = status; @@ -42,7 +47,10 @@ await delay(100); await requestServerStatus(); }); - return unsubscribe; + return () => { + unsubscribe(); + unsubscribeProgress(); + }; }); async function handleSync(peerId: string) { @@ -109,6 +117,14 @@
{peer.name}
+ {#if peerProgress.has(peer.peerId)} +
+ {peerProgress.get(peer.peerId)?.message} +
+ {/if}
{peer.platform} @@ -176,6 +192,12 @@
+
diff --git a/test/p2p-diagnostics/status-card/main.ts b/test/p2p-diagnostics/status-card/main.ts new file mode 100644 index 00000000..08fc854a --- /dev/null +++ b/test/p2p-diagnostics/status-card/main.ts @@ -0,0 +1,58 @@ +import { mount } from "svelte"; +import Card from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte"; +import LogPane from "../../../src/modules/features/Log/LogPane.svelte"; +import { eventHub } from "../../../src/common/events"; +import { EVENT_SERVER_STATUS } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; +import { setGlobalLogFunction } from "octagonal-wheels/common/logger"; +import type { P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p"; + +setGlobalLogFunction(() => undefined); +const timestamp = 1_700_000_000_000; +const listeners = new Set<(event: P2PDiagnosticEvent) => void>(); +const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room" }; +const p2p = { + transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined }, + changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} }, + diagnostics: { + getDiagnosticSnapshot: () => ({ + relay: { category: "relay", code: "room-opened", reason: "relay-details-unobserved", timestamp }, + peer: { category: "peer", code: "peer-left", peerId: "departed-peer", timestamp }, + }), + onDiagnosticEvent: (listener: (event: P2PDiagnosticEvent) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + requestStatus: () => + eventHub.emitEvent(EVENT_SERVER_STATUS, { + isConnected: true, + knownAdvertisements: [], + serverPeerId: "fixture-self", + roomId: "fixture-room", + diag: { + totalNewConnections: 0, + totalSuccessfulConnections: 0, + totalFailedConnections: 0, + totalClosedConnections: 0, + details: {}, + }, + }), + }, +}; +const core = { + services: { + setting: { currentSettings: () => settings }, + API: { + showWindow: async (view: string) => { + document.querySelector("#opened")!.textContent = view; + logMessages.value = ["[P2P Diag] Earlier connection failure"]; + mount(LogPane, { target: document.querySelector("#logapp")!, props: { close() {} } }); + }, + }, + }, +}; +mount(Card, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } }); +document.querySelector("#complete")!.onclick = () => { + for (const listener of listeners) + listener({ category: "sync", code: "pull-completed", timestamp: timestamp + 1_000 }); +}; diff --git a/test/p2p-diagnostics/status-pane.test.ts b/test/p2p-diagnostics/status-pane.test.ts new file mode 100644 index 00000000..a27ca664 --- /dev/null +++ b/test/p2p-diagnostics/status-pane.test.ts @@ -0,0 +1,101 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +for (const width of [320, 390, 768]) { + test(`P2P pane controls fit a ${width}px viewport`, async () => { + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + try { + const page = await browser.newPage({ + viewport: { width, height: 844 }, + colorScheme: width === 320 ? "dark" : "light", + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157"; + await page.goto(`${base}/test/p2p-diagnostics/status-pane/index.html`); + await page.waitForSelector(".peer-item"); + assert.deepEqual(errors, [], "Fixture must load without browser errors"); + const undersized = await page.locator(".peers-section button").evaluateAll((buttons) => + buttons + .filter((button) => { + const rect = button.getBoundingClientRect(); + return rect.width < 43.5 || rect.height < 43.5; + }) + .map((button) => ({ + label: button.textContent?.trim(), + width: button.getBoundingClientRect().width, + height: button.getBoundingClientRect().height, + })) + ); + assert.deepEqual(undersized, [], "Peer buttons must have at least a 44px target at the default font size"); + const overflow = await page.locator(".peer-item").evaluateAll((items) => + items.flatMap((item, index) => { + const rect = item.getBoundingClientRect(); + const style = getComputedStyle(item); + const left = rect.left + parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth); + const right = rect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth); + return Array.from(item.querySelectorAll("button")).flatMap((button) => { + const bounds = button.getBoundingClientRect(); + return bounds.left < left - 1 || bounds.right > right + 1 + ? [ + { + peer: index, + button: button.textContent?.trim(), + left: bounds.left, + right: bounds.right, + container: [left, right], + }, + ] + : []; + }); + }) + ); + assert.deepEqual(overflow, [], "Every peer action must stay inside its card content"); + const clipped = await page + .locator(".peer-actions .action-button") + .evaluateAll((buttons) => + buttons + .filter((button) => button.scrollWidth > button.clientWidth + 1) + .map((button) => button.textContent?.trim()) + ); + assert.deepEqual(clipped, [], "Peer action labels must remain readable in full"); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + "Long names must not cause horizontal page overflow" + ); + assert.equal(await page.locator(".diag-grid").isVisible(), true); + assert.deepEqual(await page.locator(".diag-item > span:last-child").allTextContents(), [ + "4", + "2", + "1", + "1", + ]); + if (process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX) { + await page.screenshot({ + path: `${process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX}-${width}.png`, + fullPage: true, + }); + } + await page.locator(".peer-item").nth(0).getByRole("button", { name: "Replicate now", exact: true }).click(); + assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), { + sync: "permanent-peer", + }); + await page + .locator(".peer-item") + .nth(2) + .getByRole("button", { name: "Allow in session", exact: true }) + .click(); + assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), { + peerId: "new-peer", + name: "New device", + decision: true, + isTemporary: true, + }); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } + }); +} diff --git a/test/p2p-diagnostics/status-pane/index.html b/test/p2p-diagnostics/status-pane/index.html new file mode 100644 index 00000000..c363aacb --- /dev/null +++ b/test/p2p-diagnostics/status-pane/index.html @@ -0,0 +1,11 @@ + +P2P status pane layout fixture + +
diff --git a/test/p2p-diagnostics/status-pane/main.ts b/test/p2p-diagnostics/status-pane/main.ts new file mode 100644 index 00000000..de05e236 --- /dev/null +++ b/test/p2p-diagnostics/status-pane/main.ts @@ -0,0 +1,67 @@ +import { mount } from "svelte"; +import Pane from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte"; +import { eventHub } from "../../../src/common/events"; +import { + EVENT_SERVER_STATUS, + EVENT_P2P_REPLICATOR_STATUS, +} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import { setGlobalLogFunction } from "octagonal-wheels/common/logger"; + +setGlobalLogFunction(() => undefined); +const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room", remoteConfigurations: {} }; +const knownAdvertisements = [ + { peerId: "permanent-peer", name: "Phone", platform: "mobile", isAccepted: true }, + { + peerId: "session-peer", + name: "LongDeviceNameWithoutWordBreaks".repeat(4), + platform: "desktop", + isTemporaryAccepted: true, + }, + { peerId: "new-peer", name: "New device", platform: "mobile" }, + { peerId: "denied-peer", name: "Denied device", platform: "mobile", isTemporaryAccepted: false }, +]; +if (new URLSearchParams(location.search).has("preview")) { + knownAdvertisements.splice(1, 1); + knownAdvertisements.splice(2, 1); + knownAdvertisements[0].name = "Android phone"; + knownAdvertisements[1].name = "MacBook Pro (Living room)"; +} +const record = (action: unknown) => { + document.querySelector("#action")!.textContent = JSON.stringify(action); +}; +const p2p = { + transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined }, + changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} }, + peerAdmission: { + makeDecision: async (decision: unknown) => record(decision), + revokeDecision: async (decision: unknown) => record(decision), + }, + targetedTransfer: { synchroniseWithPeer: async (peerId: string) => record({ sync: peerId }) }, + diagnostics: { + getDiagnosticSnapshot: () => ({ peer: { category: "peer", code: "peer-left", timestamp: 1_700_000_000_000 } }), + onDiagnosticEvent: () => () => undefined, + requestStatus: () => { + eventHub.emitEvent(EVENT_SERVER_STATUS, { + isConnected: true, + knownAdvertisements, + serverPeerId: "fixture-self", + roomId: "fixture-room", + diag: { + totalNewConnections: 4, + totalSuccessfulConnections: 2, + totalFailedConnections: 1, + totalClosedConnections: 1, + details: {}, + }, + } as never); + eventHub.emitEvent(EVENT_P2P_REPLICATOR_STATUS, { + replicatingFrom: [], + replicatingTo: [], + watchingPeers: [], + isBroadcasting: false, + } as never); + }, + }, +}; +const core = { services: { setting: { currentSettings: () => settings }, API: { showWindow: async () => undefined } } }; +mount(Pane, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } }); diff --git a/test/p2p-diagnostics/vite.config.mjs b/test/p2p-diagnostics/vite.config.mjs new file mode 100644 index 00000000..85d1b866 --- /dev/null +++ b/test/p2p-diagnostics/vite.config.mjs @@ -0,0 +1,21 @@ +import { mergeConfig } from "vite"; +import { fileURLToPath } from "node:url"; +import common from "../../vitest.config.common.ts"; + +const config = mergeConfig(common, { + optimizeDeps: { + entries: [ + "test/p2p-diagnostics/log-pane/index.html", + "test/p2p-diagnostics/status-card/index.html", + "test/p2p-diagnostics/status-pane/index.html", + ], + }, + server: { host: "127.0.0.1", port: 5157, strictPort: true }, +}); +config.resolve.alias = { + "@/modules/features/Log/LogPaneView": fileURLToPath(new URL("./log-pane-view.ts", import.meta.url)), + "@/deps": fileURLToPath(new URL("./host-ui.ts", import.meta.url)), + "@/modules/features/SetupManager": fileURLToPath(new URL("./host-ui.ts", import.meta.url)), + ...common.resolve.alias, +}; +export default config; diff --git a/updates.md b/updates.md index f24a9b73..ac668045 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,17 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### P2P synchronisation +- P2P status shows Stats with diagnostic details collapsed by default. Expand + 'Diagnostic log' to see the latest relay, peer, and synchronisation observations. + With Diag enabled, their events also appear in the Log Pane without updating + the editor status or producing notifications. + - Unobserved information is labelled explicitly. Relay rejection messages are + unavailable through the current transport API; an open room does not confirm + that a relay accepted its announcements. + +- Larger peer controls wrap onto additional lines in narrow panes, keeping action labels + readable alongside long device names. + - P2P progress shows sent (↑) and received (↓) document revision counts, including Metadata and Chunks. Directional ⏩ labels show comparison progress even when no transfer is needed, and disappear three seconds after their last count change. From a6b97ddb3b1c8bfdc7cd37b1b9fe9bb92ed23d93 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 12:17:31 +0000 Subject: [PATCH 04/13] refactor: register log cleanup through the host API Delegate display, subscription, and collector cleanup through APIService instead of registering directly on the Obsidian plug-in. The Obsidian adapter forwards the same callbacks to native component ownership. Preserve the separate log resource which remains alive throughout asynchronous shutdown, and cover native delegation and renderer cleanup registration. --- src/modules/features/ModuleLog.ts | 10 +++++----- src/modules/features/ModuleLog.unit.spec.ts | 20 +++++++++++++++++++ src/modules/services/ObsidianAPIService.ts | 5 +++++ .../services/ObsidianAPIService.unit.spec.ts | 13 ++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index c42974d4..45aa95d2 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -168,7 +168,7 @@ export class ModuleLog extends AbstractObsidianModule { observeForLogs() { const registerDisplay = (display: T): T => { - this.plugin.register(() => display.dispose()); + this.services.API.registerCleanup(() => display.dispose()); return display; }; const labelReplication = registerDisplay( @@ -211,12 +211,12 @@ export class ModuleLog extends AbstractObsidianModule { ); const p2pProgress = registerDisplay(createP2PProgressDisplay(this.p2pLogCollector.p2pReplicationStats)); const p2pIsPrimary = reactiveSource(this.settings.remoteType === REMOTE_P2P); - this.plugin.register( + this.services.API.registerCleanup( this.services.context.events.onEvent(EVENT_SETTING_SAVED, (settings) => { p2pIsPrimary.value = settings.remoteType === REMOTE_P2P; }) ); - this.plugin.register(() => this.p2pLogCollector.dispose()); + this.services.API.registerCleanup(() => this.p2pLogCollector.dispose()); const replicationStatLabel = computed(() => projectReplicationStatusLabel( this.services.replicator.replicationStatics.value, @@ -530,12 +530,12 @@ ${stringifyYaml(info)} if (this.statusLine) { const renderer = createStatusBarRenderer(this.statusLine); this.statusLineRenderer = renderer; - this.plugin.register(() => renderer.dispose()); + this.services.API.registerCleanup(() => renderer.dispose()); } if (this.statusBar) { const renderer = createStatusBarRenderer(this.statusBar); this.statusBarRenderer = renderer; - this.plugin.register(() => renderer.dispose()); + this.services.API.registerCleanup(() => renderer.dispose()); } this.adjustStatusDivPosition(); this._log("Log module loaded", LOG_LEVEL_INFO); diff --git a/src/modules/features/ModuleLog.unit.spec.ts b/src/modules/features/ModuleLog.unit.spec.ts index bf2f7d44..9071814a 100644 --- a/src/modules/features/ModuleLog.unit.spec.ts +++ b/src/modules/features/ModuleLog.unit.spec.ts @@ -8,6 +8,9 @@ vi.mock("@/deps.ts", () => ({ stringifyYaml: vi.fn(), })); vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" })); +vi.mock("./StatusBarRenderer.ts", () => ({ + createStatusBarRenderer: vi.fn(() => ({ dispose: vi.fn() })), +})); vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() })); vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true })); vi.mock("@/common/translation", () => ({ $msg: (key: string) => key })); @@ -36,6 +39,7 @@ async function createHarness() { registerWindow: vi.fn(), addRibbonIcon: vi.fn(), registerProtocolHandler: vi.fn(), + registerCleanup: vi.fn<(callback: () => void) => void>(), }, context: { events: createLiveSyncEventHub(), notices: { hide: vi.fn() } }, appLifecycle: { @@ -64,6 +68,22 @@ describe("ModuleLog service connection", () => { vi.unstubAllGlobals(); }); + it("registers status renderer cleanup through the API without native plug-in registration", async () => { + const { services, module } = await createHarness(); + const { createStatusBarRenderer } = await import("./StatusBarRenderer.ts"); + const factory = vi.mocked(createStatusBarRenderer); + factory.mockClear(); + Object.assign(module, { statusLine: {}, statusBar: {} }); + await services.appLifecycle.onSettingLoaded(); + expect(factory).toHaveBeenCalledTimes(2); + expect(services.API.registerCleanup).toHaveBeenCalledTimes(2); + const renderers = factory.mock.results.map((result) => result.value); + for (const renderer of renderers) expect(renderer.dispose).not.toHaveBeenCalled(); + for (const [cleanup] of services.API.registerCleanup.mock.calls) cleanup(); + for (const renderer of renderers) expect(renderer.dispose).toHaveBeenCalledOnce(); + await module.logResource.dispose(); + }); + it("routes accepted P2P diagnostics only through their owning context and disposes the subscription", async () => { const first = await createHarness(); const second = await createHarness(); diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index 5f5a5862..d188d589 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -135,6 +135,11 @@ export class ObsidianAPIService extends InjectableAPIService void): void { + // Keep disposal owned by the native component, preserving its unload timing. + this.context.plugin.register(callback); + } + /** * In Obsidian, we will use the native `requestUrl` function as the default fetch handler, * to address unavoidable CORS issues. diff --git a/src/modules/services/ObsidianAPIService.unit.spec.ts b/src/modules/services/ObsidianAPIService.unit.spec.ts index 9eb4aa53..60d1f1b9 100644 --- a/src/modules/services/ObsidianAPIService.unit.spec.ts +++ b/src/modules/services/ObsidianAPIService.unit.spec.ts @@ -248,3 +248,16 @@ describe("ObsidianAPIService.nativeFetch cache control", () => { expect(sentHeaders().has("pragma")).toBe(false); }); }); + +describe("ObsidianAPIService.registerCleanup", () => { + it("delegates the original callback to native component ownership without invoking it immediately", () => { + const register = vi.fn(); + const service = new ObsidianAPIService({ app: {}, plugin: { register } } as unknown as ObsidianServiceContext); + const cleanup = vi.fn(); + expect(service.registerCleanup(cleanup)).toBeUndefined(); + expect(register).toHaveBeenCalledExactlyOnceWith(cleanup); + expect(cleanup).not.toHaveBeenCalled(); + register.mock.calls[0][0](); + expect(cleanup).toHaveBeenCalledOnce(); + }); +}); From eea78e44db8b3c1feb3bba75c9db5c9ad1aaf523 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 14:45:55 +0000 Subject: [PATCH 05/13] fix: clarify the P2P diagnostic summary label Call the three latest category observations 'Diagnostic summary' and retain 'Open log' for their event history. Update the primary English message, status card, and release note together. Validated with the refactored Commonlib candidate: full checks, 2,063 unit tests with one skipped, production build, iOS 15 compatibility, and five Chromium cases. Dependency declarations remain on the published version until the immutable Commonlib update is available. --- src/common/messages/LiveSyncProvisionalMessages.ts | 2 +- src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte | 2 +- updates.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index e52abf8f..101b1155 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -9,7 +9,7 @@ * the same change. */ export const liveSyncProvisionalEnglishMessages = { - "Diagnostic log": "Diagnostic log", + "Diagnostic summary": "Diagnostic summary", "Open log": "Open log", "Detailed P2P diagnostics and Log Pane output are enabled": "Detailed P2P diagnostics and Log Pane output are enabled", diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte index 111fda26..5e097f43 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -203,7 +203,7 @@
- {translateMessage("Diagnostic log")} + {translateMessage("Diagnostic summary")}
{#each diagnosticRows as row (row.category)}
diff --git a/updates.md b/updates.md index ac668045..b13fbecf 100644 --- a/updates.md +++ b/updates.md @@ -17,7 +17,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### P2P synchronisation - P2P status shows Stats with diagnostic details collapsed by default. Expand - 'Diagnostic log' to see the latest relay, peer, and synchronisation observations. + 'Diagnostic summary' to see the latest relay, peer, and synchronisation observations. With Diag enabled, their events also appear in the Log Pane without updating the editor status or producing notifications. - Unobserved information is labelled explicitly. Relay rejection messages are From 4a245debe7e29c5d34a081f41ac8a3664600d7c7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 15:28:46 +0000 Subject: [PATCH 06/13] fix: inspect and repair incorrect recorded file sizes --- docs/recovery.md | 1 + docs/settings.md | 2 +- docs/specs_conflict_resolution.md | 2 + .../messages/LiveSyncProvisionalMessages.ts | 1 + .../features/SettingDialogue/PaneHatch.ts | 27 +++ src/serviceFeatures/fileRepair.ts | 102 ++++++++-- src/serviceFeatures/fileRepair.unit.spec.ts | 160 +++++++++++++--- src/serviceFeatures/fileRepairPresentation.ts | 7 +- .../fileRepairPresentation.unit.spec.ts | 63 +++++++ test/e2e-obsidian/scripts/revision-repair.ts | 174 +++++++++++++++++- updates.md | 6 + 11 files changed, 488 insertions(+), 57 deletions(-) diff --git a/docs/recovery.md b/docs/recovery.md index 06b09edd..587238fa 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -98,6 +98,7 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun - **Compare with Vault** opens a read-only comparison for readable text. - **Apply this revision to Vault** replaces the Vault file with that readable database revision. - **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision. + - **Correct recorded file size** is available when a readable, conflict-free database winner has the same bytes as the Vault but its recorded size differs. It creates a child Metadata revision with the decoded byte size while preserving the body, Chunks, path, and timestamps. The action rechecks the selected revision; if the database has advanced, inspect the file again. - **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch. - **Retry reading revision** retries configured chunk retrieval without changing the revision tree. - **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed. diff --git a/docs/settings.md b/docs/settings.md index 77493609..d6f8b252 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -963,7 +963,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded. -Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. +Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded size when the readable revision exactly matches the Vault and has no conflict, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices. diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index 6f29fedb..36678cb4 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -89,6 +89,8 @@ Each reported current leaf revision has a compact wrench menu. The available act Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch. +The inspection also offers **Correct recorded file size** when the conflict-free current winner is readable, its decoded bytes match the Vault exactly, and its recorded size differs from the decoded byte length. This creates a child Metadata revision which changes only the recorded size while retaining the content references and file timestamps. The operation rechecks the current revision before writing and records the resulting byte match as Vault provenance. Size and timestamps diagnose this condition; exact bytes establish that the Vault and database contain the same file. + The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win. A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index ae63c87c..e0bc027e 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -280,6 +280,7 @@ export const liveSyncProvisionalEnglishMessages = { "Apply database revision to Vault": "Apply database revision to Vault", "Mark this revision as the Vault version": "Mark this revision as the Vault version", "Store Vault file as a child of this revision": "Store Vault file as a child of this revision", + "Correct recorded file size": "Correct recorded file size", "Apply logical deletion to Vault": "Apply logical deletion to Vault", "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.": "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.", diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 48f6e4de..46425648 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -32,12 +32,14 @@ import { chooseAndCopyFileDatabaseInfo, collectFileDatabaseInfoPaths, copyFileDatabaseInfo, + inspectFileDatabaseInfo, retryReadFileDatabaseRevision, } from "@/serviceFeatures/fileDatabaseInfo.ts"; import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, + repairRevisionSize, type FileRepairInspection, type FileRepairRevision, } from "@/serviceFeatures/fileRepair.ts"; @@ -786,6 +788,31 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, }, }); } + if (metadata.revision && policy.repairSize) { + revisionActions.push({ + title: $msg("Correct recorded file size"), + run: async () => { + await runMutation( + `Correct the recorded size on database revision ${metadata.revision}`, + async () => { + const result = await repairRevisionSize( + this.core, + path, + metadata.revision! + ); + if (result !== "repaired" && result !== "unchanged") { + return false; + } + const current = await inspectFileDatabaseInfo(this.core, path); + const currentRevision = current.database.currentRevision; + return currentRevision !== null + ? storeStorageOnRevision(path, currentRevision, false) + : false; + } + ); + }, + }); + } if (metadata.revision && policy.storeVaultOnBranch) { revisionActions.push({ title: $msg("Store Vault file as a child of this revision"), diff --git a/src/serviceFeatures/fileRepair.ts b/src/serviceFeatures/fileRepair.ts index 64172843..8194ff8c 100644 --- a/src/serviceFeatures/fileRepair.ts +++ b/src/serviceFeatures/fileRepair.ts @@ -1,3 +1,4 @@ +import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import type { LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { createBlob, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; @@ -12,6 +13,8 @@ import { export type FileRepairCore = FileDatabaseInfoCore & { fileHandler: Pick; + localDatabase: FileDatabaseInfoCore["localDatabase"] & + Pick; storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick; }; @@ -29,11 +32,7 @@ export type FileRepairInspection = { requiresAttention: boolean; }; -export type DiscardUnreadableRevisionResult = - | "discarded" - | "failed" - | "no-longer-live" - | "revision-is-readable"; +export type DiscardUnreadableRevisionResult = "discarded" | "failed" | "no-longer-live" | "revision-is-readable"; export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision"; @@ -67,9 +66,7 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted; const databaseAndStorageDiffer = information.storage.exists !== winnerRepresentsStoredFile || - (information.storage.exists && - winnerRepresentsStoredFile && - winner.contentMatchesStorage === false); + (information.storage.exists && winnerRepresentsStoredFile && winner.contentMatchesStorage === false); const unreadableLiveRevision = information.database.unavailableConflictRevisions.length > 0 || revisions.some(({ contentReadable }) => !contentReadable); @@ -77,6 +74,10 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro databaseAndStorageDiffer || information.database.conflictCount > 0 || unreadableLiveRevision || + revisions.some( + ({ loadedEntry, metadata }) => + loadedEntry !== false && readAsBlob(loadedEntry).size !== metadata.recordedSize + ) || (information.database.exists && winner === undefined); return { @@ -92,10 +93,9 @@ export async function discardUnreadableLiveRevision( revision: string ): Promise { const latest = await inspectFileDatabaseInfo(core, path); - const liveRevisions = [ - latest.database.currentRevision, - ...latest.database.conflictRevisions, - ].filter((candidate): candidate is string => candidate !== null); + const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter( + (candidate): candidate is string => candidate !== null + ); if (!liveRevisions.includes(revision)) { return "no-longer-live"; } @@ -116,10 +116,9 @@ export async function discardLiveBranch( revision: string ): Promise { const latest = await inspectFileDatabaseInfo(core, path); - const liveRevisions = [ - latest.database.currentRevision, - ...latest.database.conflictRevisions, - ].filter((candidate): candidate is string => candidate !== null); + const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter( + (candidate): candidate is string => candidate !== null + ); if (!liveRevisions.includes(revision)) { return "no-longer-live"; } @@ -130,3 +129,74 @@ export async function discardLiveBranch( const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); return deleted ? "discarded" : "failed"; } + +export function canRepairRevisionSize(inspection: FileRepairInspection, revision: FileRepairRevision): boolean { + return ( + revision.role === "winner" && + revision.metadata.current && + revision.metadata.revision !== null && + revision.metadata.revision === inspection.information.database.currentRevision && + revision.contentReadable && + !revision.metadata.deleted && + revision.metadata.storageLayout === "chunked" && + inspection.information.database.conflictCount === 0 && + inspection.information.database.unavailableConflictRevisions.length === 0 && + inspection.information.storage.exists && + revision.contentMatchesStorage === true && + revision.loadedEntry !== false && + readAsBlob(revision.loadedEntry).size !== revision.metadata.recordedSize + ); +} + +export async function repairRevisionSize( + core: FileRepairCore, + path: string, + revision: string +): Promise<"repaired" | "unchanged" | "stale" | "unsafe" | "failed"> { + const inspection = await inspectFileRepair(core, path); + if (inspection.information.database.currentRevision !== revision) return "stale"; + const winner = inspection.revisions.find((candidate) => candidate.role === "winner"); + if (!winner || winner.loadedEntry === false) return "unsafe"; + if (!canRepairRevisionSize(inspection, winner)) { + return !winner.metadata.deleted && + winner.contentMatchesStorage === true && + inspection.information.database.conflictCount === 0 && + readAsBlob(winner.loadedEntry).size === winner.metadata.recordedSize + ? "unchanged" + : "unsafe"; + } + const content = readAsBlob(winner.loadedEntry); + const raw = await core.localDatabase.localDatabase + .get(winner.metadata.documentId, { conflicts: true }) + .catch((error: unknown) => { + if (isNotFoundError(error)) return false as const; + throw error; + }); + if (raw === false) return "stale"; + if ( + (raw.type !== "plain" && raw.type !== "newnote") || + raw._rev !== revision || + raw._deleted || + ("deleted" in raw && raw.deleted) || + raw._conflicts?.length + ) + return "stale"; + if ( + !(await core.storageAccess.isExistsIncludeHidden(path)) || + !(await isDocContentSame(content, createBlob(await core.storageAccess.readHiddenFileBinary(path)))) + ) { + return "unsafe"; + } + // Preserve the exact stored document; loaded content must never be embedded in Metadata. + const corrected = { ...raw, size: content.size }; + delete corrected._conflicts; + delete corrected._revs_info; + delete corrected._revisions; + try { + const result = await core.localDatabase.putRaw(corrected); + return result.ok ? "repaired" : "failed"; + } catch (error) { + if ((error as { status?: number }).status === 409) return "stale"; + throw error; + } +} diff --git a/src/serviceFeatures/fileRepair.unit.spec.ts b/src/serviceFeatures/fileRepair.unit.spec.ts index af12a34b..5d319dc1 100644 --- a/src/serviceFeatures/fileRepair.unit.spec.ts +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -1,9 +1,8 @@ +import PouchDB from "pouchdb-core"; +import MemoryAdapter from "pouchdb-adapter-memory"; +PouchDB.plugin(MemoryAdapter); import { describe, expect, it, vi } from "vitest"; -import { - discardLiveBranch, - discardUnreadableLiveRevision, - inspectFileRepair, -} from "./fileRepair"; +import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, repairRevisionSize } from "./fileRepair"; function createCore() { const current = { @@ -48,6 +47,7 @@ function createCore() { getFilesIncludeHidden: vi.fn(async () => ["note.md"]), }, localDatabase: { + putRaw: vi.fn(async (_doc: unknown) => ({ ok: true, id: "f:note", rev: "4-repaired" })), localDatabase: { get: vi.fn(async (_id: string, options?: { rev?: string }) => options?.rev === "2-conflict" ? conflict : current @@ -94,6 +94,20 @@ function createCore() { } describe("file repair inspection", () => { + it("reports a size-only mismatch even when Vault bytes match", async () => { + const { core, current } = createCore(); + current._conflicts = []; + current.size = 45; + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(true); + }); + + it("omits matching bytes with correct size despite differing timestamps", async () => { + const { core, current } = createCore(); + current._conflicts = []; + current.mtime = 9000; + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false); + }); + it("shows the winner and every conflict revision independently", async () => { const { core } = createCore(); @@ -146,12 +160,10 @@ describe("file repair inspection", () => { it("rechecks liveness and readability before discarding an exact revision", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") - ).resolves.toBe("discarded"); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "3-current") - ).resolves.toBe("revision-is-readable"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe("discarded"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "3-current")).resolves.toBe( + "revision-is-readable" + ); expect(deleteRevisionFromDB).toHaveBeenCalledOnce(); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "2-conflict"); @@ -175,9 +187,7 @@ describe("file repair inspection", () => { }), }), ]); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "1-root") - ).resolves.toBe("discarded"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "1-root")).resolves.toBe("discarded"); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "1-root"); }); @@ -188,9 +198,9 @@ describe("file repair inspection", () => { _conflicts: [], }); - await expect( - discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") - ).resolves.toBe("no-longer-live"); + await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe( + "no-longer-live" + ); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); @@ -198,9 +208,7 @@ describe("file repair inspection", () => { it("discards an exact readable winner while another live branch remains", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardLiveBranch(core as never, "note.md", "3-current") - ).resolves.toBe("discarded"); + await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("discarded"); expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current"); }); @@ -209,9 +217,7 @@ describe("file repair inspection", () => { const { core, current, deleteRevisionFromDB } = createCore(); current._conflicts = []; - await expect( - discardLiveBranch(core as never, "note.md", "3-current") - ).resolves.toBe("only-live-revision"); + await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("only-live-revision"); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); @@ -219,10 +225,112 @@ describe("file repair inspection", () => { it("refuses to discard a branch which is no longer live", async () => { const { core, deleteRevisionFromDB } = createCore(); - await expect( - discardLiveBranch(core as never, "note.md", "1-stale") - ).resolves.toBe("no-longer-live"); + await expect(discardLiveBranch(core as never, "note.md", "1-stale")).resolves.toBe("no-longer-live"); expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); }); + +describe("recorded size repair", () => { + function repairFixture() { + const fixture = createCore(); + fixture.current._conflicts = []; + fixture.current.size = 45; + return fixture; + } + it("writes a child changing only size and omitting response metadata", async () => { + const { core, current } = repairFixture(); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("repaired"); + const corrected = core.localDatabase.putRaw.mock.calls[0][0]; + const expected = { ...current, size: 7 } as Partial; + delete expected._conflicts; + delete expected._revs_info; + expect(corrected).toEqual(expected); + }); + it.each(["conflict", "different-body", "missing-chunk", "deleted", "absent-vault"])("refuses %s", async (state) => { + const { core, current } = repairFixture(); + if (state === "conflict") current._conflicts = ["2-conflict"]; + if (state === "different-body") + core.storageAccess.readHiddenFileBinary.mockResolvedValue(new TextEncoder().encode("edit").buffer); + if (state === "missing-chunk") core.localDatabase.allDocsRaw.mockResolvedValue({ rows: [] }); + if (state === "deleted") current.deleted = true; + if (state === "absent-vault") core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a selected revision replaced before inspection", async () => { + const { core } = repairFixture(); + expect(await repairRevisionSize(core as never, "note.md", "2-old")).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a revision advancing between inspection and final metadata read", async () => { + const { core, current } = repairFixture(); + const read = core.localDatabase.localDatabase.get; + read.mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce({ ...current, _rev: "4-advanced" }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses a Vault edit after inspection", async () => { + const { core, current } = repairFixture(); + core.storageAccess.readHiddenFileBinary + .mockResolvedValueOnce(new TextEncoder().encode("current").buffer) + .mockResolvedValueOnce(new TextEncoder().encode("new edit").buffer); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("treats a document removed after inspection as stale", async () => { + const { core, current } = repairFixture(); + core.localDatabase.localDatabase.get + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(current) + .mockRejectedValueOnce({ status: 404 }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); + it("refuses advancement during the last Vault read through CAS", async () => { + const { core, current } = repairFixture(); + core.localDatabase.putRaw.mockRejectedValue({ status: 409 }); + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale"); + }); + it("does not repeat a correction or change mtime for matching content", async () => { + const { core, current } = repairFixture(); + current.size = 7; + expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unchanged"); + expect(core.localDatabase.putRaw).not.toHaveBeenCalled(); + }); +}); + +describe("size repair revision ancestry", () => { + it("preserves metadata and chunks in a real PouchDB child and is idempotent", async () => { + const { core, current } = createCore(); + const database = new PouchDB(`size-repair-${crypto.randomUUID()}`, { adapter: "memory" }); + try { + const metadata = { ...current } as Partial & { _id: string }; + delete metadata._rev; + delete metadata._conflicts; + delete metadata._revs_info; + const stored = await database.put({ ...metadata, size: 45 }); + core.localDatabase.localDatabase = database as never; + core.localDatabase.putRaw.mockImplementation((doc) => database.put(doc as never)); + core.localDatabase.getDBEntryFromMeta.mockImplementation(async (meta) => ({ ...meta, data: ["current"] })); + expect(await repairRevisionSize(core as never, "note.md", stored.rev)).toBe("repaired"); + const corrected = (await database.get(current._id, { revs: true })) as typeof current & { + _revisions: { ids: string[] }; + }; + expect(corrected.size).toBe(7); + expect(corrected.children).toEqual(current.children); + expect(corrected.ctime).toBe(current.ctime); + expect(corrected.mtime).toBe(current.mtime); + expect(corrected.path).toBe(current.path); + expect(corrected).not.toHaveProperty("data"); + expect(corrected._revisions.ids[1]).toBe(stored.rev.split("-")[1]); + expect(await repairRevisionSize(core as never, "note.md", corrected._rev)).toBe("unchanged"); + expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false); + expect(core.localDatabase.putRaw).toHaveBeenCalledOnce(); + } finally { + await database.destroy(); + } + }); +}); diff --git a/src/serviceFeatures/fileRepairPresentation.ts b/src/serviceFeatures/fileRepairPresentation.ts index 4906f4cb..c397956b 100644 --- a/src/serviceFeatures/fileRepairPresentation.ts +++ b/src/serviceFeatures/fileRepairPresentation.ts @@ -8,10 +8,7 @@ import { readAsBlob, } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; -import type { - FileRepairInspection, - FileRepairRevision, -} from "./fileRepair"; +import { canRepairRevisionSize, type FileRepairInspection, type FileRepairRevision } from "./fileRepair"; export type FileRepairRevisionActions = { compareWithVault: boolean; @@ -22,6 +19,7 @@ export type FileRepairRevisionActions = { retryRevision: boolean; discardBranch: boolean; discardRevision: boolean; + repairSize: boolean; }; export type FileRepairTimestampRelation = @@ -87,6 +85,7 @@ export function getFileRepairRevisionActions( !hasConflictBranches && !revision.metadata.deleted && !revision.contentReadable, + repairSize: canRepairRevisionSize(inspection, revision), }; } diff --git a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts index 1f5f968c..6a8d1c32 100644 --- a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts +++ b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts @@ -87,6 +87,7 @@ describe("file repair presentation", () => { retryRevision: false, discardRevision: false, discardBranch: true, + repairSize: false, }); }); @@ -104,6 +105,58 @@ describe("file repair presentation", () => { }); }); + it("offers size correction for a readable, matching, conflict-free winner", () => { + const { inspection, revision } = createInspection({ + role: "winner", + metadata: { + ...createInspection().revision.metadata, + current: true, + recordedSize: 9, + }, + contentMatchesStorage: true, + }); + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + inspection.information.database.currentRevision = revision.metadata.revision; + inspection.information.storage.size = 7; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + repairSize: true, + }); + }); + + it("does not offer size correction for unsafe or already-correct revisions", () => { + const conflict = createInspection({ contentMatchesStorage: true }); + expect(getFileRepairRevisionActions(conflict.inspection, conflict.revision)).toMatchObject({ + repairSize: false, + }); + + const unreadable = createInspection({ + role: "winner", + contentReadable: false, + contentMatchesStorage: true, + }); + unreadable.inspection.information.database.conflictCount = 0; + unreadable.inspection.information.database.conflictRevisions = []; + unreadable.inspection.information.storage.size = 7; + unreadable.revision.metadata.current = true; + unreadable.inspection.information.database.currentRevision = unreadable.revision.metadata.revision; + expect(getFileRepairRevisionActions(unreadable.inspection, unreadable.revision)).toMatchObject({ + repairSize: false, + }); + + const unchanged = createInspection({ + role: "winner", + contentMatchesStorage: true, + }); + unchanged.inspection.information.database.conflictCount = 0; + unchanged.inspection.information.database.conflictRevisions = []; + unchanged.inspection.information.database.currentRevision = unchanged.revision.metadata.revision; + expect(getFileRepairRevisionActions(unchanged.inspection, unchanged.revision)).toMatchObject({ + repairSize: false, + }); + }); + it("does not offer a text comparison for a binary file", () => { const { inspection, revision } = createInspection(); inspection.information.path = "image.png"; @@ -191,6 +244,16 @@ describe("file repair presentation", () => { }); }); + it("does not offer size correction for legacy inline content", () => { + const { inspection, revision } = createInspection({ role: "winner", contentMatchesStorage: true }); + revision.metadata.current = true; + revision.metadata.storageLayout = "legacy-inline"; + inspection.information.database.currentRevision = revision.metadata.revision; + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + expect(getFileRepairRevisionActions(inspection, revision).repairSize).toBe(false); + }); + it("reports recorded, decoded, Vault-size, and timestamp differences", () => { const { inspection, revision } = createInspection(); diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts index 87586b2b..4b9bdba0 100644 --- a/test/e2e-obsidian/scripts/revision-repair.ts +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -11,6 +11,8 @@ import { createTemporaryVault } from "../runner/vault.ts"; import type { Locator, Page } from "playwright"; const path = "revision-repair.md"; +const sizeRepairPath = "revision-size-repair.md"; +const sizeRepairContent = "Size repair fixture with multibyte text: 日本語\n"; const healthyDeletedPath = "healthy-logical-deletion.md"; const baseContent = "Revision repair\n\nShared base.\n"; const branchContents = [ @@ -35,6 +37,29 @@ type VaultWinnerState = { winnerRevision: string; }; +type SizeRepairFixture = { + revisionWithWrongSize: string; + expectedSize: number; + expectedPath: string; + expectedCtime: number; + expectedMtime: number; + expectedChunks: string[]; +}; + +type SizeRepairState = SizeRepairFixture & { + revision: string; + size: number; + path: string; + ctime: number; + mtime: number; + chunks: string[]; + bodyMatches: boolean; + chunksAvailable: boolean; + historyStart: number; + historyIds: string[]; + provenanceRevision: string | null; +}; + async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, @@ -95,6 +120,68 @@ async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.Proce ); } +async function createSizeRepairFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const content=${JSON.stringify(sizeRepairContent)};`, + "const file=app.vault.getAbstractFileByPath(path)??await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); + const saved = await waitForLocalDatabaseEntry(cliBinary, env, sizeRepairPath); + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const expectedSize=new TextEncoder().encode(${JSON.stringify(sizeRepairContent)}).length;`, + `const savedRevision=${JSON.stringify(saved.rev)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const original=await core.localDatabase.localDatabase.get(id);", + "if(original._rev!==savedRevision) throw new Error('Size repair source changed before fixture setup');", + "const wrong=await core.localDatabase.putRaw({...original,size:expectedSize+7});", + "if(!wrong.ok) throw new Error('Could not create size-only Metadata fixture');", + "core.localDatabase.clearCaches();", + "const revision=await core.localDatabase.localDatabase.get(id);", + "return JSON.stringify({revisionWithWrongSize:revision._rev,expectedSize,expectedPath:path,expectedCtime:original.ctime,expectedMtime:original.mtime,expectedChunks:original.children??[]});", + "})()", + ].join(""), + env + ); +} + +async function readSizeRepairState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(sizeRepairPath)};`, + `const expected=${JSON.stringify(sizeRepairContent)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const raw=await core.localDatabase.localDatabase.get(id,{revs:true});", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);", + "if(!entry) throw new Error('Corrected size repair entry cannot be decoded');", + "const body=await new Blob(Array.isArray(entry.data)?entry.data:[entry.data]).text();", + "const children=raw.children??[];", + "const rows=children.length?await core.localDatabase.allDocsRaw({keys:children}):{rows:[]};", + "const available=rows.rows.filter(row=>row.value?.rev&&!row.value?.deleted).length===children.length;", + "const provenance=await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path);", + "const revisions=raw._revisions??{start:0,ids:[]};", + "return JSON.stringify({revision:raw._rev,size:raw.size,path:raw.path,ctime:raw.ctime,mtime:raw.mtime,chunks:children,bodyMatches:body===expected,chunksAvailable:available,historyStart:revisions.start,historyIds:revisions.ids,provenanceRevision:provenance?.revision??null});", + "})()", + ].join(""), + env + ); +} + async function createBrokenConflict( cliBinary: string, env: NodeJS.ProcessEnv, @@ -210,16 +297,16 @@ async function readFileReflectionProvenance( ); } -function repairCard(settings: Locator): Locator { - return settings.locator(".sls-repair-result").filter({ hasText: path }); +function repairCard(settings: Locator, targetPath = path): Locator { + return settings.locator(".sls-repair-result").filter({ hasText: targetPath }); } -function revisionCard(settings: Locator, revision: string): Locator { - return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision }); +function revisionCard(settings: Locator, revision: string, targetPath = path): Locator { + return repairCard(settings, targetPath).locator(".sls-repair-revision").filter({ hasText: revision }); } -async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise { - const actionButton = revisionCard(settings, revision).getByRole("button", { +async function openRevisionActionMenu(page: Page, settings: Locator, revision: string, targetPath = path): Promise { + const actionButton = revisionCard(settings, revision, targetPath).getByRole("button", { name: `More actions for revision ${revision}`, exact: true, }); @@ -250,8 +337,8 @@ async function openRevisionActionMenu(page: Page, settings: Locator, revision: s return menu; } -async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise { - const menu = await openRevisionActionMenu(page, settings, revision); +async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string, targetPath = path): Promise { + const menu = await openRevisionActionMenu(page, settings, revision, targetPath); const item = menu.getByText(action, { exact: true }); await item.waitFor({ state: "visible", timeout: uiTimeoutMs }); await item.click({ timeout: uiTimeoutMs }); @@ -317,6 +404,7 @@ async function main(): Promise { await createAndOpenBaseFile(cliBinary, session.cliEnv); const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv); + const sizeRepairFixture = await createSizeRepairFixture(cliBinary, session.cliEnv); const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev); const healthyDeletionProvenance = await readFileReflectionProvenance( cliBinary, @@ -347,7 +435,8 @@ async function main(): Promise { ); } - await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const sizeRepairPort = session.remoteDebuggingPort; + await withObsidianPage(sizeRepairPort, async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); const settings = await settingsNavigator.openPage("Hatch"); const verifySetting = settings.locator(".setting-item").filter({ @@ -694,8 +783,73 @@ async function main(): Promise { ); } + await withObsidianPage(sizeRepairPort, async (page) => { + const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); + const settings = await settingsNavigator.openPage("Hatch"); + const verifySetting = settings.locator(".setting-item").filter({ + has: settingsNavigator.page.getByText("Inspect conflicts and file/database differences", { + exact: true, + }), + }); + await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({ + timeout: uiTimeoutMs, + }); + const card = repairCard(settings, sizeRepairPath); + await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const winner = revisionCard(settings, sizeRepairFixture.revisionWithWrongSize, sizeRepairPath); + await winner.getByText(/^📦 DB:.*Δsize/u).waitFor({ state: "visible", timeout: uiTimeoutMs }); + const menu = await openRevisionActionMenu( + page, + settings, + sizeRepairFixture.revisionWithWrongSize, + sizeRepairPath + ); + await menu.getByText("Correct recorded file size", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const screenshot = await captureObsidianElement( + sizeRepairPort, + "revision-repair-size-correction-actions.png", + (currentPage) => currentPage.locator(".menu:visible").last() + ); + console.log(`Size correction actions screenshot: ${screenshot}`); + await page.keyboard.press("Escape"); + await selectRevisionAction( + page, + settings, + sizeRepairFixture.revisionWithWrongSize, + "Correct recorded file size", + sizeRepairPath + ); + await card.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const sizeRepairState = await readSizeRepairState(cliBinary, session.cliEnv); + const correctedRevisionGeneration = Number(sizeRepairState.revision.split("-")[0]); + const expectedParentId = sizeRepairFixture.revisionWithWrongSize.split("-").slice(1).join("-"); + if ( + sizeRepairState.revision === sizeRepairFixture.revisionWithWrongSize || + sizeRepairState.size !== sizeRepairFixture.expectedSize || + sizeRepairState.path !== sizeRepairFixture.expectedPath || + sizeRepairState.ctime !== sizeRepairFixture.expectedCtime || + sizeRepairState.mtime !== sizeRepairFixture.expectedMtime || + JSON.stringify(sizeRepairState.chunks) !== JSON.stringify(sizeRepairFixture.expectedChunks) || + !sizeRepairState.bodyMatches || + !sizeRepairState.chunksAvailable || + sizeRepairState.historyStart !== correctedRevisionGeneration || + sizeRepairState.historyIds[1] !== expectedParentId || + sizeRepairState.provenanceRevision !== sizeRepairState.revision + ) { + throw new Error( + `Size repair did not preserve the exact file body, Chunks, Metadata fields, ancestry, and provenance: ${JSON.stringify({ + fixture: sizeRepairFixture, + state: sizeRepairState, + })}` + ); + } + console.log( - "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation." + "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; discarded only the selected branch after confirmation; and corrected a Vault-matching Metadata size through Verify while preserving its body, Chunks, timestamps, ancestry, and provenance." ); console.log(`Repair card screenshot: ${repairCardScreenshot}`); console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`); diff --git a/updates.md b/updates.md index f24a9b73..21c1af91 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Fixed + +#### Recovery + +- Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) + ### Improved #### P2P synchronisation From 966b6384f44d5e6eb78ad07ef950fe8a2201cf25 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 15:53:55 +0000 Subject: [PATCH 07/13] docs: explain the P2P diagnostic workflow Guide users through Diagnostic summary, Diag logging, transfer progress, symptom checks, and evidence collection from both devices. Link the guide from the P2P, troubleshooting, reporting, and documentation entry points. Keep transport compatibility and observation limits explicit. Validated with the troubleshooting documentation Inspector, 145 local references, 34 heading anchors, and current English UI labels. Runtime source and translations are unchanged. --- docs/README.md | 2 +- docs/p2p.md | 4 +- docs/tips/p2p-diagnostics.md | 130 +++++++++++++++++++++++++++++++++++ docs/tips/p2p-sync-tips.md | 2 + docs/to_issue_reporting.md | 1 + docs/troubleshooting.md | 2 +- 6 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 docs/tips/p2p-diagnostics.md diff --git a/docs/README.md b/docs/README.md index 6699b1a9..3d2856cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ A document's original date and reviewed-version fields describe its baseline. Re | Find hosting services proposed by the community | [Third-party hosting listings](../README.md#hosting-services-proposed-by-the-community) | | Set up S3-compatible storage | [Object Storage Setup](setup_object_storage.md) and [transfers and service costs](setup_object_storage_costs.md) | | Set up peer-to-peer synchronisation | [P2P communication model](p2p.md), then [Peer-to-Peer Setup](setup_p2p.md) | -| Diagnose P2P connectivity | [Browser-assisted check](../src/apps/webpeer/README.md#p2p-connection-check), [network tips](tips/p2p-sync-tips.md), and [self-hosted TURN](../docker/coturn/README.md) | +| Diagnose P2P connectivity or transfer activity | [Diagnostic summary and logs](tips/p2p-diagnostics.md), [browser-assisted check](../src/apps/webpeer/README.md#p2p-connection-check), [network tips](tips/p2p-sync-tips.md), and [self-hosted TURN](../docker/coturn/README.md) | | Understand a setting or command | [Settings reference](settings.md) | | Read or customise synchronisation status | [Status indicators and CSS snippets](tips/status-display.md) | | Investigate or recover from a problem | [Troubleshooting](troubleshooting.md), then the relevant [recovery procedure](recovery.md) | diff --git a/docs/p2p.md b/docs/p2p.md index c9f0da82..64fe6339 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -2,11 +2,11 @@ Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices through WebRTC. It does not require a central database containing a copy of the Vault. It does require a signalling relay so that devices can discover one another and establish a connection. -For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). +For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Use the [P2P diagnostic guide](tips/p2p-diagnostics.md) to investigate discovery and transfer problems with the summary, logs, and progress indicators. ## Updating a P2P group -The unreleased transport update uses Trystero 0.26.0, whose data format is +The Trystero 0.26.0 transport uses a data format which is incompatible with the previous transport. Update every device in the P2P group, including CLI and WebPeer clients, before resuming P2P synchronisation. Keeping the same Group ID, passphrase, and relay settings does not make mixed diff --git a/docs/tips/p2p-diagnostics.md b/docs/tips/p2p-diagnostics.md new file mode 100644 index 00000000..ddf96725 --- /dev/null +++ b/docs/tips/p2p-diagnostics.md @@ -0,0 +1,130 @@ +# Diagnose P2P synchronisation + +Use **P2P Status** to distinguish peer discovery, authentication, and data +transfer before changing connection or database settings. + +Participating devices must use +[compatible P2P transport versions](../p2p.md#updating-a-p2p-group). +If your version does not show **Diagnostic summary**, use +[Peer-to-Peer Synchronisation Tips](p2p-sync-tips.md) and include the installed +versions in your report. + +## Capture one attempt + +1. Keep both devices online, with Obsidian visible and the devices awake. + Record which device originates the change and which should receive it. +2. Open **P2P Status** on both devices, using + **Self-hosted LiveSync: P2P Sync : Open P2P Status** in the command palette. +3. Expand **Diagnostic summary** below **Stats**. It starts collapsed. Record + all three rows and their observation times before reconnecting or changing + settings. +4. Turn **Diag** on, then select **Open log**. Clear **Pause** before + reproducing the problem. New diagnostic lines contain `[P2P Diag]`; + **Verbose Log** is not required for these lines. +5. Select **Replicate now** for the intended peer to test a manual round trip. + Check **P2P Connection Request** prompts on both devices. + For a problem with later automatic changes, first establish that the manual + operation works, then make a small, non-private test edit on the source. + Observe the receiving device as well as the source. +6. Record the last relevant messages and progress on both devices, then + [collect a report](#collect-a-report) before restarting or retrying. + +The summary continues updating with **Diag** off. **Diag** controls new detailed +output in the Log Pane; enabling it does not replay earlier hidden lines. +Diagnostic messages also enter the existing recent report history while +**Diag** is off. Opening the Log Pane displays messages already in its display +buffer. Repeated unchanged non-failure observations may be omitted, so the log +is not a record of every network message. **Pause** affects only the log display; +replication and the summary continue updating. + +## Read the summary + +Each row holds the latest recorded observation in one category on this device. +A new event from another peer can replace that row. Evidence can remain after +a peer leaves or a connection closes; use the peer identifier and timestamp +rather than reading the rows as a current connection-health checklist. The +summary is held in memory and does not preserve history across a restart. + +| Row | Examples | What it establishes | +| --------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. `Room opened; relay replies not observed` does not prove that a relay accepted announcements, that a peer was discovered, or that a WebRTC data path works. | +| **Peer** | `Peer advertised`, `Peer left`, `Authentication accepted`, `Authentication refused`, `Authentication timed out` | Discovery or the observed authentication outcome for the identified peer. A timeout means the request did not finish in time; it is not an explicit refusal. | +| **Sync** | `Pull started`, `Pull completed`, `Watching peer changes`, `Announcing changes`, `Change notification received` | The latest observed pull, watch, announcement, or notification activity. A notification asks a peer to fetch; it does not itself carry Vault data. | + +- **Not observed** means no event has been recorded for that category. It does + not mean success, failure, or a measured zero. +- **Unavailable** means the diagnostic capability is unavailable in the + current implementation. Include the installed version in the report. +- `Authentication failed; authentication response not observed` and + `Automatic watch skipped; peer announcement state not observed` identify an + unavailable response, rather than claiming refusal or disabled announcements. +- Summary times are local observation times. `[P2P Diag]` lines also include an + `observed` UTC timestamp. Account for device-clock differences when comparing + reports. A timestamp is not a heartbeat or a continuously refreshed health check. + +Relay rejection details are not available through this diagnostic summary. +Some existing RTC diagnostics also lack peer or room attribution and are not +assigned to a peer in these rows. **Stats** describes connection activity, +not transferred document or Vault file totals. If the summary cannot explain a +failure, preserve the logs and state what remains unobserved in the report. + +## Understand transfer progress + +| Indicator | Meaning | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **↑ / ↓** | Successfully sent or received database document revisions for the observed peer and direction, including both Metadata and Chunks. These are not counts of Vault files written. A new replication attempt starts new counts. | +| **⏩↑ / ⏩↓** | Changes-feed entries compared in that direction, including entries already held by the receiver. The count can advance while no revisions need transferring. | +| **seq** | A compatibility display of database sequence progress when document statistics are unavailable. Interpret it as a sequence value, not a document total or percentage. | + +Each directional comparison label disappears three seconds after its count +last changes. Its disappearance does not establish that replication completed. +A zero transfer count can be normal when the receiver already has the data. + +`Pull completed` records completion of a single database pull. It does not +establish that the reverse direction completed, that every peer has caught up, +or that every received revision has been reflected into a Vault file. Check +the test note on the receiving device. For missing files, follow +[the corresponding troubleshooting procedure](../troubleshooting.md#files-are-missing-or-excluded). + +## Choose the next check + +| Symptom or observation | Next check | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The peer does not appear | Capture the **Relay** and **Peer** rows, then check both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). `Connected` alone is insufficient. | +| Authentication is refused | Confirm the peer identity and intended approval on both devices. The summary records the outcome; it does not grant approval. | +| Authentication times out, or a peer appears but cannot transfer | Keep both devices visible and check for an unanswered **P2P Connection Request** before treating a timeout as a network failure. Compare their logs, then use the existing [WebRTC and TURN checks](p2p-sync-tips.md#a-peer-appears-but-synchronisation-cannot-connect) to investigate the data path. | +| Manual replication works, but later edits do not arrive | Confirm **Announce changes** on the source and **Follow changes** for that source on the receiver. Compare `Announcing changes`, `Watching peer changes`, and notification events on both devices. `Change notification ignored; peer is not watched` identifies a missing follow on the receiver. | +| A pull remains the last observation | Compare its timestamp, ↑/↓ and ⏩ changes, and both devices' logs. Another operation may have replaced a summary row; an old `Pull started` or a hidden ⏩ label alone does not establish a stalled transfer. For a repeatable network-specific stall, use [message-size checks](p2p-sync-tips.md#a-connection-opens-but-a-transfer-stalls). | + +After capturing the original symptom, **Disconnect** followed by +**Open connection** and **Replicate now** can test reconnection. Disconnecting +can cancel an active operation, so record whether a cancellation followed that +action. Reconnection, a changed message-size preset, or a different network +helps compare conditions; it does not identify or repair every underlying cause. + +## Collect a report + +Collect evidence from both the source and receiving devices around the same +attempt. Include: + +- the plug-in or client version on each device, and which device originated the change; +- the **Diagnostic summary** rows and times, plus the surrounding `[P2P Diag]` + lines and any other relevant errors; +- whether the peer appeared, whether manual **Replicate now** worked, and the + observed ↑/↓, ⏩, or `seq` values; +- whether **Announce changes** and **Follow changes** were enabled, and whether + either application was in the background or the device had been asleep; and +- the network types and whether TURN or a VPN was used, especially when the + same devices work on one network but not another. + +Run **Generate full report for opening the issue with debug info** before the +recent history is overwritten. It includes up to 1,000 recent log lines, +including verbose diagnostic messages even when **Verbose Log** is off. +For a problem which crosses a restart, follow the existing +[persistent logging procedure](../to_issue_reporting.md#a-problem-which-crosses-a-restart). + +Review the complete report using the +[issue-reporting redaction guidance](../to_issue_reporting.md#full-livesync-report). +New `[P2P Diag]` messages use fixed descriptions and shortened peer identifiers, +but other log messages can still contain private information. Keep consistent +device aliases across the reports when replacing private identifiers. diff --git a/docs/tips/p2p-sync-tips.md b/docs/tips/p2p-sync-tips.md index 74a28370..c9e5a59f 100644 --- a/docs/tips/p2p-sync-tips.md +++ b/docs/tips/p2p-sync-tips.md @@ -67,6 +67,8 @@ Keep Obsidian visible and the device awake during initial transfer, rebuild, or ## Collect evidence +Use the [P2P diagnostic guide](p2p-diagnostics.md) to capture the latest observations, enable **Diag**, and compare transfer progress and logs on both devices before changing settings. + If the same room works on one network but not another, include both network types in the report. Run `Generate full report for opening the issue with debug info`, remove credentials and private relay details, and state whether: - both devices reached `Connected`; diff --git a/docs/to_issue_reporting.md b/docs/to_issue_reporting.md index dc64edc1..0ffb7ae1 100644 --- a/docs/to_issue_reporting.md +++ b/docs/to_issue_reporting.md @@ -11,6 +11,7 @@ If a maintainer requests additional evidence, follow the relevant procedure belo | Database integrity, current conflicts, or differences between sources | [Integrity report](#integrity-report) | | Local revision and Chunk usage | [Usage report](#usage-report) | | More information about a path pseudonym in an existing report | [File follow-up](#file-follow-up) | +| P2P discovery, authentication, or transfer activity | [P2P summary and logs](tips/p2p-diagnostics.md#collect-a-report) | Maintainers can link directly to each procedure and adapt the [example requests](#requesting-diagnostics). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8da3a3d5..ac8bbbab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -270,7 +270,7 @@ Use Safari on a Mac and follow Apple's [Inspecting iOS and iPadOS](https://devel ## P2P does not connect or transfer changes -Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport. +Start with [P2P diagnostic observations, logs, and transfer progress](tips/p2p-diagnostics.md). Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for network and configuration checks. Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport. ## Obsidian or LiveSync remains suspended From 6b18025594603e72bbd59e2eb07827920fa635c8 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 00:57:55 +0000 Subject: [PATCH 08/13] fix: select published Commonlib 0.4.0 for file size preservation --- package-lock.json | 48 +++++++++++++++++++++++------------------------ package.json | 2 +- updates.md | 1 + 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3e355e7..6616e73e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", @@ -1949,9 +1949,9 @@ } }, "node_modules/@noble/secp256k1": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz", - "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz", + "integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -3281,6 +3281,22 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@trystero-p2p/core": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.26.0.tgz", + "integrity": "sha512-CZpeXPgfbDdFKrGxfD98F9tn/fTjya4mQ2o/Ax6H0qpBaJXCBz5+jyPtjsYEPAAcKRK/btOatCetFiQ5qIMCOA==", + "license": "MIT" + }, + "node_modules/@trystero-p2p/nostr": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.26.0.tgz", + "integrity": "sha512-HArp9fonBkqFZgw3U2D823fCePQv7haKkvjtwbGRkWzy+Wo9G3cCDEnG/GENDWwzERHrhRcRacpFsFt4JpwRJQ==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "^3.2.0", + "@trystero-p2p/core": "0.26.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -4065,9 +4081,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.3.2.tgz", - "integrity": "sha512-PiKUdJUiBI4ot0AV1BjlkDoaY4Lbn9oTdEjL8FMX1qf2VCOwSnbsLoQMUr/45wmINpV31KWmqgr2zxqcYO+hpA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz", + "integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "5.2.0", @@ -4077,7 +4093,7 @@ "@smithy/middleware-apply-body-checksum": "^4.3.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@trystero-p2p/nostr": "0.25.3", + "@trystero-p2p/nostr": "0.26.0", "@vrtmrz/value-store": "0.1.0", "diff-match-patch": "^1.0.5", "events": "^3.3.0", @@ -4115,22 +4131,6 @@ } } }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/core": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz", - "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==", - "license": "MIT" - }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/nostr": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz", - "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", - "license": "MIT", - "dependencies": { - "@noble/secp256k1": "^3.1.0", - "@trystero-p2p/core": "0.25.3" - } - }, "node_modules/@vrtmrz/livesync-commonlib/node_modules/minimatch": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", diff --git a/package.json b/package.json index 681ef357..0bcf3773 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", diff --git a/updates.md b/updates.md index 21c1af91..4e83c9c8 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Recovery +- Files saved while host size information is stale retain the correct content size, preventing an incorrect size record from blocking reflection to other devices. (#1324) - Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) ### Improved From b64568aff4562770db46e6cc02417d1a76852f2b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 01:54:49 +0000 Subject: [PATCH 09/13] build: pin released Commonlib 0.4.0 Adopt the published transport, progress, diagnostic, and cleanup APIs through the exact registry package and lockfile. Note that the content-size correction prevents new incorrect Metadata; existing records need repair. --- package-lock.json | 48 +++++++++++++++++++++++------------------------ package.json | 2 +- updates.md | 8 ++++++++ 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3e355e7..6616e73e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", @@ -1949,9 +1949,9 @@ } }, "node_modules/@noble/secp256k1": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz", - "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz", + "integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -3281,6 +3281,22 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@trystero-p2p/core": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.26.0.tgz", + "integrity": "sha512-CZpeXPgfbDdFKrGxfD98F9tn/fTjya4mQ2o/Ax6H0qpBaJXCBz5+jyPtjsYEPAAcKRK/btOatCetFiQ5qIMCOA==", + "license": "MIT" + }, + "node_modules/@trystero-p2p/nostr": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.26.0.tgz", + "integrity": "sha512-HArp9fonBkqFZgw3U2D823fCePQv7haKkvjtwbGRkWzy+Wo9G3cCDEnG/GENDWwzERHrhRcRacpFsFt4JpwRJQ==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "^3.2.0", + "@trystero-p2p/core": "0.26.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -4065,9 +4081,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.3.2.tgz", - "integrity": "sha512-PiKUdJUiBI4ot0AV1BjlkDoaY4Lbn9oTdEjL8FMX1qf2VCOwSnbsLoQMUr/45wmINpV31KWmqgr2zxqcYO+hpA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz", + "integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "5.2.0", @@ -4077,7 +4093,7 @@ "@smithy/middleware-apply-body-checksum": "^4.3.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@trystero-p2p/nostr": "0.25.3", + "@trystero-p2p/nostr": "0.26.0", "@vrtmrz/value-store": "0.1.0", "diff-match-patch": "^1.0.5", "events": "^3.3.0", @@ -4115,22 +4131,6 @@ } } }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/core": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz", - "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==", - "license": "MIT" - }, - "node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/nostr": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz", - "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", - "license": "MIT", - "dependencies": { - "@noble/secp256k1": "^3.1.0", - "@trystero-p2p/core": "0.25.3" - } - }, "node_modules/@vrtmrz/livesync-commonlib/node_modules/minimatch": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", diff --git a/package.json b/package.json index 681ef357..0bcf3773 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.3.2", + "@vrtmrz/livesync-commonlib": "0.4.0", "@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/value-store": "0.1.0", diff --git a/updates.md b/updates.md index b13fbecf..67877b6a 100644 --- a/updates.md +++ b/updates.md @@ -41,6 +41,14 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi before resuming P2P synchronisation. The updated transport uses Trystero 0.26.0 and cannot exchange data with the previous transport. +### Fixed + +#### Synchronisation + +- New saves record the byte length of the stored content, preventing stale host + file-size information from causing a receiving device to reject the file. (#1324) + - Existing incorrect Metadata needs explicit repair. + ## 1.0.45 5th November, 2026 From a5d3769cc1fc6de93d18e06cdda7638dd44ee601 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 01:54:49 +0000 Subject: [PATCH 10/13] test: verify P2P progress and native cleanup in Obsidian Check diagnostic disclosure, responsive peer controls, and current-connection document counters after normal bidirectional synchronisation. Verify that the native API cleanup callback runs once through normal and failed unload. Retain the existing Fetch, recovery, data-preservation, and reload checks, and describe the native scenarios in the E2E guide. --- test/e2e-obsidian/README.md | 6 +- test/e2e-obsidian/scripts/log-lifecycle.ts | 13 ++ test/e2e-obsidian/scripts/p2p-pane.ts | 17 +++ .../scripts/p2p-setup-uri-workflow.ts | 123 ++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 7d40b9f9..e736d23d 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -162,7 +162,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe The Harness also measures ID generation with fixed in-memory data on desktop and mobile displays, checks that live settings remain unchanged, and verifies that the copied report includes both per-1,000-ID and per-ID timings, key derivation, and JavaScript heap availability. Mobile test mode verifies the UI and execution path; measure native device performance by running the same Harness on that device. -`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. +`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile emulation. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile-emulation screenshots. The mobile-emulation session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state, and measures the rendered Refresh touch target at 44 CSS pixels or larger with the default application font. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. `test:e2e:obsidian:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database. @@ -238,7 +238,7 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur `test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` selects **Compatible (no time limit)** in the real generation dialogue and uses Persistent mode for the bootstrap tool. All three Object Storage sharing scenarios require the generated independent ID key to survive import and natural restarts on both devices, remain encrypted in local settings, and retain document and Chunk IDs during the bidirectional transfer. Before valid setup, they submit an incorrect passphrase and a URI generated in a past window, require the visible rejection, and verify unchanged runtime and persisted settings. Only an isolated fixture worker uses the past clock; Obsidian and the runner use real time. -`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. +`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, and checks the live peer pane: Stats remains visible, Diagnostic summary starts collapsed and can be expanded to show Relay, Peer, and Sync observations plus Open log, then can be collapsed again. It measures the peer-action buttons at 44 CSS pixels or larger and checks that they fit the peer item and viewport, while retaining the menu check for the three persistent per-peer actions. After reconnecting both P2P sessions in join order, it verifies the B-to-A return journey through the normal bidirectional synchronisation action and checks numeric ↑ and ↓ document counters on that current connection. Zero-document comparisons are valid; the checks do not require counters to survive a reconnect. Every started session remains tracked until teardown completes. On headless Linux, run this two-device scenario without an inherited display, for example `env -u DISPLAY -u WAYLAND_DISPLAY npm run test:e2e:obsidian:p2p-setup-uri-workflow`. The shared launcher then gives each Obsidian session its own Xvfb display. Wrapping both sessions in one outer `xvfb-run` can hide the source window, trigger its normal suspension lifecycle, and close the P2P connection before the other device fetches. @@ -274,7 +274,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. Its unload check requires the queue to report cancellation, closes active and waiting dialogues, and compares Vault contents, conflict leaves, and revision ancestry after reload. -`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. It verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service. +`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. Through the actual core API, it registers one marker cleanup callback and verifies that it runs exactly once after plug-in disable completes. It also verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service. `test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected current branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. diff --git a/test/e2e-obsidian/scripts/log-lifecycle.ts b/test/e2e-obsidian/scripts/log-lifecycle.ts index abe7c5a3..5daa8ed5 100644 --- a/test/e2e-obsidian/scripts/log-lifecycle.ts +++ b/test/e2e-obsidian/scripts/log-lifecycle.ts @@ -22,6 +22,7 @@ type FixtureLog = { type ShutdownObservation = { closeCalls: number; + cleanupCalls: number; disposeCalls: number; disposeCompleted: boolean; fixtureLogs: FixtureLog[]; @@ -106,6 +107,7 @@ function createShutdownScript(options: { duringMarker: string; failureMarker: string; afterMarker: string; + cleanupMarker: string; reloadMarker: string; oldReloadMarker: string; injectFailure: boolean; @@ -133,6 +135,7 @@ function createShutdownScript(options: { consoleLogs:[], uiAddLogCalls:0, events:[], + cleanupCalls:0, closeStarted:false, closeCalls:0, disposeCalls:0, @@ -141,6 +144,10 @@ function createShutdownScript(options: { disableError:"", }; app.__livesyncLogLifecycleState=state; + api.registerCleanup(()=>{ + state.cleanupCalls++; + state.events.push("cleanup:"+fixtures.cleanupMarker); + }); const originalAddLog=module.__addLog; const originalConsoleLog=console.log; @@ -307,8 +314,13 @@ function createShutdownScript(options: { "The disposed ModuleLog UI handler received a post-disposal log"); await state.disablePromise; require(!state.disableError,"Disabling the plug-in failed: "+state.disableError); + require(state.cleanupCalls===1, + "The registered API cleanup callback did not run exactly once after plug-in disable completed"); + require(state.events.filter(event=>event==="cleanup:"+fixtures.cleanupMarker).length===1, + "The registered API cleanup marker was not recorded exactly once"); return JSON.stringify({ closeCalls:state.closeCalls, + cleanupCalls:state.cleanupCalls, disposeCalls:state.disposeCalls, disposeCompleted:state.disposeCompleted, fixtureLogs:state.fixtureLogs, @@ -353,6 +365,7 @@ async function runCase(options: { const markers = { duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`, afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(), + cleanupMarker: `log-lifecycle-${options.caseName}-api-cleanup-${randomUUID()}`, failureMarker: options.injectFailure ? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}` : "", diff --git a/test/e2e-obsidian/scripts/p2p-pane.ts b/test/e2e-obsidian/scripts/p2p-pane.ts index fc57b6c8..015ec2f2 100644 --- a/test/e2e-obsidian/scripts/p2p-pane.ts +++ b/test/e2e-obsidian/scripts/p2p-pane.ts @@ -180,6 +180,23 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise { + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + rootFontSize: getComputedStyle(document.documentElement).fontSize, + }; + }); + if (touchTarget.width < 44 || touchTarget.height < 44) { + throw new Error( + `The mobile P2P Refresh touch target was smaller than 44 CSS pixels at the default font: ${JSON.stringify(touchTarget)}` + ); + } + } await pane.getByRole("button", { name: "Open connection" }).waitFor({ state: "visible", timeout: uiTimeoutMs, diff --git a/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts index c491aa61..c32e7168 100644 --- a/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts +++ b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { connect } from "node:net"; import { join } from "node:path"; import { promisify } from "node:util"; +import type { Locator, Page } from "playwright"; import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts"; @@ -602,6 +603,14 @@ async function waitForDetectedPeer(port: number): Promise { async function capturePeerActionsMenu(port: number): Promise { await withObsidianPage(port, async (page) => { + const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last(); + await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = detectedPeers.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await assertP2PDiagnosticSummary(pane); + await assertPeerActionButtonsFit(pane); + const moreActions = page.getByRole("button", { name: /^More actions for /u }).first(); await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs }); await moreActions.click({ timeout: uiTimeoutMs }); @@ -641,6 +650,117 @@ async function capturePeerActionsMenu(port: number): Promise { return screenshot; } +async function assertP2PDiagnosticSummary(pane: Locator): Promise { + const stats = pane.getByRole("heading", { name: "Stats", exact: true }); + await stats.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const diagnosticDetails = pane.locator("details.diag-details"); + await diagnosticDetails.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) { + throw new Error("The P2P diagnostic summary was open before the user expanded it."); + } + const diagnosticSummary = diagnosticDetails.locator("summary"); + await diagnosticSummary.click({ timeout: uiTimeoutMs }); + const diagnosticRows = diagnosticDetails.locator(".diag-summary > div"); + await diagnosticRows.first().waitFor({ state: "visible", timeout: uiTimeoutMs }); + const rowText = await diagnosticRows.allInnerTexts(); + for (const label of ["Relay:", "Peer:", "Sync:"]) { + if (!rowText.some((text) => text.trim().startsWith(label))) { + throw new Error(`The expanded P2P diagnostics did not show the ${label} observation.`); + } + } + const openLog = diagnosticDetails.getByRole("button", { name: "Open log", exact: true }); + await openLog.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (!(await stats.isVisible())) { + throw new Error("The P2P Stats section was hidden while diagnostic observations were expanded."); + } + await diagnosticSummary.click({ timeout: uiTimeoutMs }); + await diagnosticRows.first().waitFor({ state: "hidden", timeout: uiTimeoutMs }); + if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) { + throw new Error("The P2P diagnostic summary did not collapse after it was toggled again."); + } + if (!(await stats.isVisible())) { + throw new Error("The P2P Stats section was hidden after collapsing diagnostic observations."); + } +} + +async function assertPeerActionButtonsFit(pane: Locator): Promise { + const peerItem = pane.locator(".peers-section .peer-item").first(); + await peerItem.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await peerItem.scrollIntoViewIfNeeded(); + const actionButtons = await peerItem.evaluate((element) => { + const item = element.getBoundingClientRect(); + const viewport = { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + }; + return { + hasHorizontalOverflow: element.scrollWidth > element.clientWidth, + buttons: Array.from(element.querySelectorAll("button")) + .filter((button) => button.getClientRects().length > 0) + .map((button) => { + const rect = button.getBoundingClientRect(); + return { + label: button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "", + width: rect.width, + height: rect.height, + insidePeer: + rect.left >= item.left && + rect.top >= item.top && + rect.right <= item.right && + rect.bottom <= item.bottom, + insideViewport: + rect.left >= 0 && + rect.top >= 0 && + rect.right <= viewport.width && + rect.bottom <= viewport.height, + }; + }), + }; + }); + if ( + actionButtons.buttons.length === 0 || + actionButtons.hasHorizontalOverflow || + actionButtons.buttons.some( + (button) => + button.width < 44 || + button.height < 44 || + !button.insidePeer || + !button.insideViewport + ) + ) { + throw new Error( + `P2P peer action buttons were too small or did not fit their peer item and viewport: ${JSON.stringify(actionButtons)}` + ); + } +} + +async function assertPeerDocumentProgress(page: Page, directions: readonly ("↑" | "↓")[]): Promise { + const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last(); + await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = detectedPeers.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + const progress = pane.locator(".peers-section .peer-item .peer-progress").first(); + await progress.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const deadline = Date.now() + uiTimeoutMs; + let message = ""; + while (Date.now() < deadline) { + message = (await progress.innerText()).replace(/\s+/gu, " ").trim(); + const documentProgress = message.replace(/⏩[↑↓]\s+\d+/gu, ""); + if ( + directions.every((direction) => + new RegExp(`${direction}\\s+\\d+(?:\\s|$)`, "u").test(documentProgress) + ) + ) { + return; + } + await page.waitForTimeout(100); + } + throw new Error( + `Completed P2P transfer progress did not show document counters for ${directions.join(" and ")}: ${message}` + ); +} + async function captureNote(port: number, path: string, text: string, filename: string): Promise { await withObsidianPage(port, async (page) => { await page.evaluate((notePath) => { @@ -740,6 +860,9 @@ async function main(): Promise { console.log("P2P workflow: return replication requested; waiting for the second device's note."); await waitForPathContent(vaultA, noteFromSecond, secondContent); console.log("P2P workflow: return note reached the first device."); + await withObsidianPage(portA, async (page) => { + await assertPeerDocumentProgress(page, ["↑", "↓"]); + }); } finally { returnJourneyFinished = true; await returnJourneyAcceptor; From e2d0cfa9f6982f4bedfffb95ee21823ced4bcd2c Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 02:32:06 +0000 Subject: [PATCH 11/13] docs: refine tone and clarify P2P diagnostic guidance --- docs/tips/p2p-diagnostics.md | 74 ++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/docs/tips/p2p-diagnostics.md b/docs/tips/p2p-diagnostics.md index ddf96725..94ce8e56 100644 --- a/docs/tips/p2p-diagnostics.md +++ b/docs/tips/p2p-diagnostics.md @@ -15,12 +15,13 @@ versions in your report. Record which device originates the change and which should receive it. 2. Open **P2P Status** on both devices, using **Self-hosted LiveSync: P2P Sync : Open P2P Status** in the command palette. -3. Expand **Diagnostic summary** below **Stats**. It starts collapsed. Record - all three rows and their observation times before reconnecting or changing - settings. -4. Turn **Diag** on, then select **Open log**. Clear **Pause** before - reproducing the problem. New diagnostic lines contain `[P2P Diag]`; - **Verbose Log** is not required for these lines. +3. In the **P2P Status** pane, expand **Diagnostic summary** below **Stats**. + It starts collapsed. Record all three rows and their observation times before + changing connection or database settings. +4. Turn the **Diag** toggle on in the **P2P Status** card, then select + **Open log** inside the expanded summary. In the opened Log Pane, ensure + **Pause** is cleared before reproducing the problem. New diagnostic lines + contain `[P2P Diag]`; **Verbose Log** is not required for these lines. 5. Select **Replicate now** for the intended peer to test a manual round trip. Check **P2P Connection Request** prompts on both devices. For a problem with later automatic changes, first establish that the manual @@ -47,29 +48,35 @@ summary is held in memory and does not preserve history across a restart. | Row | Examples | What it establishes | | --------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. `Room opened; relay replies not observed` does not prove that a relay accepted announcements, that a peer was discovered, or that a WebRTC data path works. | -| **Peer** | `Peer advertised`, `Peer left`, `Authentication accepted`, `Authentication refused`, `Authentication timed out` | Discovery or the observed authentication outcome for the identified peer. A timeout means the request did not finish in time; it is not an explicit refusal. | -| **Sync** | `Pull started`, `Pull completed`, `Watching peer changes`, `Announcing changes`, `Change notification received` | The latest observed pull, watch, announcement, or notification activity. A notification asks a peer to fetch; it does not itself carry Vault data. | +| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. 'Room opened; relay replies not observed' confirms that the local room was established, though relay acceptance, peer discovery, and WebRTC data connectivity must be verified through subsequent rows and events. | +| **Peer** | `Peer advertised`, `Peer left`, `Authentication accepted`, `Authentication refused`, `Authentication timed out` | Discovery or the observed authentication outcome for the identified peer. A timeout indicates that the request exceeded its time limit, rather than an explicit rejection. | +| **Sync** | `Pull started`, `Pull completed`, `Watching peer changes`, `Announcing changes`, `Change notification received` | The latest observed pull, watch, announcement, or notification activity. Note that notifications signal a peer to fetch changes, rather than carrying Vault data directly. | -- **Not observed** means no event has been recorded for that category. It does - not mean success, failure, or a measured zero. +- **Not observed** indicates that no event has been recorded yet for that + category, rather than confirming success, failure, or a zero count. - **Unavailable** means the diagnostic capability is unavailable in the current implementation. Include the installed version in the report. - `Authentication failed; authentication response not observed` and `Automatic watch skipped; peer announcement state not observed` identify an unavailable response, rather than claiming refusal or disabled announcements. -- Summary times are local observation times. `[P2P Diag]` lines also include an - `observed` UTC timestamp. Account for device-clock differences when comparing - reports. A timestamp is not a heartbeat or a continuously refreshed health check. +- Summary times are local observation times, while `[P2P Diag]` lines include an + `observed` UTC timestamp. Consider any device-clock differences when comparing + reports across devices. Note that timestamps record when specific events occurred, + rather than representing an ongoing heartbeat or live health check. -Relay rejection details are not available through this diagnostic summary. +Relay rejection details are not provided by this diagnostic summary. Some existing RTC diagnostics also lack peer or room attribution and are not -assigned to a peer in these rows. **Stats** describes connection activity, -not transferred document or Vault file totals. If the summary cannot explain a -failure, preserve the logs and state what remains unobserved in the report. +assigned to individual peers in these rows. **Stats** describes connection activity, +whereas transferred document totals appear in transfer progress. If the summary +cannot fully identify a failure, preserve the log output and note any unobserved +categories in your report. ## Understand transfer progress +During synchronisation, transfer counters appear next to each peer in the +P2P Status pane, in the editor status bar, and in the main status bar when +P2P is the active synchronisation method. + | Indicator | Meaning | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **↑ / ↓** | Successfully sent or received database document revisions for the observed peer and direction, including both Metadata and Chunks. These are not counts of Vault files written. A new replication attempt starts new counts. | @@ -77,30 +84,31 @@ failure, preserve the logs and state what remains unobserved in the report. | **seq** | A compatibility display of database sequence progress when document statistics are unavailable. Interpret it as a sequence value, not a document total or percentage. | Each directional comparison label disappears three seconds after its count -last changes. Its disappearance does not establish that replication completed. -A zero transfer count can be normal when the receiver already has the data. +last changes. Its absence indicates comparison inactivity rather than +confirming that overall replication is complete. A zero transfer count can +be normal when the receiver already has the data. -`Pull completed` records completion of a single database pull. It does not -establish that the reverse direction completed, that every peer has caught up, -or that every received revision has been reflected into a Vault file. Check -the test note on the receiving device. For missing files, follow +`Pull completed` confirms that a single database pull has finished. To verify +bidirectional synchronisation and full reflection, ensure the reverse direction +completes and check the updated notes on the receiving device. For missing files, +follow [the corresponding troubleshooting procedure](../troubleshooting.md#files-are-missing-or-excluded). ## Choose the next check | Symptom or observation | Next check | | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The peer does not appear | Capture the **Relay** and **Peer** rows, then check both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). `Connected` alone is insufficient. | -| Authentication is refused | Confirm the peer identity and intended approval on both devices. The summary records the outcome; it does not grant approval. | -| Authentication times out, or a peer appears but cannot transfer | Keep both devices visible and check for an unanswered **P2P Connection Request** before treating a timeout as a network failure. Compare their logs, then use the existing [WebRTC and TURN checks](p2p-sync-tips.md#a-peer-appears-but-synchronisation-cannot-connect) to investigate the data path. | +| The peer does not appear | Capture the **Relay** and **Peer** rows, then verify both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). Note that a `Connected` status confirms signalling readiness, while peer discovery requires matching group and network settings. | +| Authentication is refused | Confirm the peer identity on both devices and check for pending approval prompts. The summary displays the observed outcome; please ensure the connection request is explicitly accepted on the receiving device. | +| Authentication times out, or a peer appears but cannot transfer | Keep both devices active and check for an unanswered **P2P Connection Request** before troubleshooting network connectivity. Compare logs between devices, then follow existing [WebRTC and TURN checks](p2p-sync-tips.md#a-peer-appears-but-synchronisation-cannot-connect) to inspect the data path. | | Manual replication works, but later edits do not arrive | Confirm **Announce changes** on the source and **Follow changes** for that source on the receiver. Compare `Announcing changes`, `Watching peer changes`, and notification events on both devices. `Change notification ignored; peer is not watched` identifies a missing follow on the receiver. | -| A pull remains the last observation | Compare its timestamp, ↑/↓ and ⏩ changes, and both devices' logs. Another operation may have replaced a summary row; an old `Pull started` or a hidden ⏩ label alone does not establish a stalled transfer. For a repeatable network-specific stall, use [message-size checks](p2p-sync-tips.md#a-connection-opens-but-a-transfer-stalls). | +| A pull remains the last observation | Compare its timestamp, ↑/↓ and ⏩ changes, and both devices' logs. A subsequent operation may have replaced the summary row; verify with the surrounding logs and transfer counters to confirm whether a transfer is truly stalled. For a repeatable network-specific stall, use [message-size checks](p2p-sync-tips.md#a-connection-opens-but-a-transfer-stalls). | -After capturing the original symptom, **Disconnect** followed by -**Open connection** and **Replicate now** can test reconnection. Disconnecting -can cancel an active operation, so record whether a cancellation followed that -action. Reconnection, a changed message-size preset, or a different network -helps compare conditions; it does not identify or repair every underlying cause. +After capturing the initial symptom, selecting **Disconnect** followed by +**Open connection** and **Replicate now** can test reconnection. Because +disconnecting cancels active operations, record whether a cancellation occurred. +Testing reconnection, adjusting the message-size preset, or trying an +alternative network can help isolate environmental differences. ## Collect a report From 623292d4659061dec710367ca29b131b6a2c664c Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 02:35:16 +0000 Subject: [PATCH 12/13] docs: clarify recorded file size inspection and repair in settings --- docs/settings.md | 2 +- updates.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index d6f8b252..abf32307 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -963,7 +963,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded. -Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded size when the readable revision exactly matches the Vault and has no conflict, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. +Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded file size, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction is available when a readable revision exactly matches the Vault with no conflicts; it preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices. diff --git a/updates.md b/updates.md index 4e83c9c8..2351998b 100644 --- a/updates.md +++ b/updates.md @@ -17,7 +17,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Recovery - Files saved while host size information is stale retain the correct content size, preventing an incorrect size record from blocking reflection to other devices. (#1324) -- Verify reports a file whose recorded size differs from its readable content, and offers a guarded correction when that exact content matches the Vault and has no conflict. (#1324) +- **Inspect conflicts and file/database differences** reports files whose recorded size differs from their readable content, and offers a guarded **Correct recorded file size** action when that exact content matches the Vault without conflicts. (#1324) ### Improved From 52752c151117a523f711d1faeaea83907befe034 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 7 Nov 2026 02:55:13 +0000 Subject: [PATCH 13/13] docs: clarify P2P diagnostic observation limits --- docs/tips/p2p-diagnostics.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tips/p2p-diagnostics.md b/docs/tips/p2p-diagnostics.md index 94ce8e56..d660ea8c 100644 --- a/docs/tips/p2p-diagnostics.md +++ b/docs/tips/p2p-diagnostics.md @@ -48,7 +48,7 @@ summary is held in memory and does not preserve history across a restart. | Row | Examples | What it establishes | | --------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. 'Room opened; relay replies not observed' confirms that the local room was established, though relay acceptance, peer discovery, and WebRTC data connectivity must be verified through subsequent rows and events. | +| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. 'Room opened; relay replies not observed' records local room opening. Relay acceptance remains unobserved; use later peer and sync observations to investigate discovery and data transfer. | | **Peer** | `Peer advertised`, `Peer left`, `Authentication accepted`, `Authentication refused`, `Authentication timed out` | Discovery or the observed authentication outcome for the identified peer. A timeout indicates that the request exceeded its time limit, rather than an explicit rejection. | | **Sync** | `Pull started`, `Pull completed`, `Watching peer changes`, `Announcing changes`, `Change notification received` | The latest observed pull, watch, announcement, or notification activity. Note that notifications signal a peer to fetch changes, rather than carrying Vault data directly. | @@ -61,8 +61,8 @@ summary is held in memory and does not preserve history across a restart. unavailable response, rather than claiming refusal or disabled announcements. - Summary times are local observation times, while `[P2P Diag]` lines include an `observed` UTC timestamp. Consider any device-clock differences when comparing - reports across devices. Note that timestamps record when specific events occurred, - rather than representing an ongoing heartbeat or live health check. + reports across devices. Timestamps record when this device observed an event; + they are not an ongoing heartbeat or live health check. Relay rejection details are not provided by this diagnostic summary. Some existing RTC diagnostics also lack peer or room attribution and are not @@ -84,9 +84,9 @@ P2P is the active synchronisation method. | **seq** | A compatibility display of database sequence progress when document statistics are unavailable. Interpret it as a sequence value, not a document total or percentage. | Each directional comparison label disappears three seconds after its count -last changes. Its absence indicates comparison inactivity rather than -confirming that overall replication is complete. A zero transfer count can -be normal when the receiver already has the data. +last changes. A hidden label alone does not establish whether comparison or +replication has finished. A zero transfer count can be normal when the receiver +already has the data. `Pull completed` confirms that a single database pull has finished. To verify bidirectional synchronisation and full reflection, ensure the reverse direction @@ -98,7 +98,7 @@ follow | Symptom or observation | Next check | | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The peer does not appear | Capture the **Relay** and **Peer** rows, then verify both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). Note that a `Connected` status confirms signalling readiness, while peer discovery requires matching group and network settings. | +| The peer does not appear | Capture the **Relay** and **Peer** rows, then verify both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). A `Connected` status indicates that the local room is open; relay replies remain unobserved. Use peer and sync observations and both devices' logs to investigate discovery and transfer. | | Authentication is refused | Confirm the peer identity on both devices and check for pending approval prompts. The summary displays the observed outcome; please ensure the connection request is explicitly accepted on the receiving device. | | Authentication times out, or a peer appears but cannot transfer | Keep both devices active and check for an unanswered **P2P Connection Request** before troubleshooting network connectivity. Compare logs between devices, then follow existing [WebRTC and TURN checks](p2p-sync-tips.md#a-peer-appears-but-synchronisation-cannot-connect) to inspect the data path. | | Manual replication works, but later edits do not arrive | Confirm **Announce changes** on the source and **Follow changes** for that source on the receiver. Compare `Announcing changes`, `Watching peer changes`, and notification events on both devices. `Change notification ignored; peer is not watched` identifies a missing follow on the receiver. |