diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6a1174..17a3bc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,4 +42,4 @@ jobs: release/checksums.txt release/owd-sync-*.zip --generate-notes - --title "OWD Sync $GITHUB_REF_NAME (private beta)" + --title "OWD Sync $GITHUB_REF_NAME (alpha)" diff --git a/README.md b/README.md index dc44e2d..f2f5a0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OWD Sync -[![Release](https://img.shields.io/badge/release-0.1.6-90a5ff.svg)](https://github.com/msinclair25/owd-sync/releases/tag/0.1.6) +[![Release](https://img.shields.io/badge/release-0.1.7-90a5ff.svg)](https://github.com/msinclair25/owd-sync/releases/tag/0.1.7) [![License](https://img.shields.io/badge/license-Apache--2.0-d7ff64.svg)](LICENSE) [![Private alpha](https://img.shields.io/badge/OWD-private%20alpha-f0a23a.svg)](https://mdevolved.com/#alpha-access) @@ -15,7 +15,7 @@ encrypted recovery. [Request private alpha access](https://mdevolved.com/#alpha-access) · [See OWD Platform](https://github.com/msinclair25/owd-platform) · -[Download OWD Sync 0.1.6](https://github.com/msinclair25/owd-sync/releases/tag/0.1.6) +[Download OWD Sync 0.1.7](https://github.com/msinclair25/owd-sync/releases/tag/0.1.7) ## The connection boundary @@ -38,7 +38,7 @@ flowchart LR | Supports current-library and recovery workflows | Turns synchronization into an unreviewed agent write path | > [!IMPORTANT] -> OWD Sync `0.1.6` is an alpha release. It is not yet listed in Obsidian +> OWD Sync `0.1.7` is an alpha release. It is not yet listed in Obsidian > Community Plugins. OWD Platform provides a temporary one-click desktop > installer for invited testers; BRAT is the fallback. Use synthetic test > vaults until the Community Plugin publication and personal-vault gates pass. @@ -52,7 +52,7 @@ The normal alpha path starts in the authenticated OWD **Vaults** folder: consent. 2. Fully quit Obsidian with **Obsidian → Quit Obsidian** or **⌘Q**. Closing the macOS window is not enough. -3. In OWD, choose **Choose vault and install OWD Sync 0.1.6**. +3. In OWD, choose **Choose vault and install OWD Sync 0.1.7**. 4. In current Chrome or Edge, select the exact vault root containing your notes and hidden `.obsidian` folder—not `.obsidian` itself—and choose **Allow** if Chrome requests local write access. @@ -76,14 +76,14 @@ fallback: 2. Install and enable BRAT from Obsidian Community Plugins, then wait until BRAT appears in the Command Palette. 3. Open the version-pinned - [OWD Sync BRAT form](obsidian://brat?plugin=msinclair25/owd-sync&version=0.1.6). + [OWD Sync BRAT form](obsidian://brat?plugin=msinclair25/owd-sync&version=0.1.7). This link opens BRAT's form; it does not finish the installation. Verify the repository and version, choose **Add Plugin**, and wait for BRAT to finish. -4. Enable OWD Sync `0.1.6` under **Settings → Community plugins**. +4. Enable OWD Sync `0.1.7` under **Settings → Community plugins**. If the prefilled link does nothing, run **BRAT: Plugins: Add a beta plugin for testing (with or without version)** from the Command Palette, paste -`https://github.com/msinclair25/owd-sync`, select `0.1.6`, and choose **Add +`https://github.com/msinclair25/owd-sync`, select `0.1.7`, and choose **Add Plugin**. Use either the direct installer or BRAT, not both. BRAT is a testing bridge, @@ -93,9 +93,9 @@ gates pass. ## Pair one vault -1. Open the exact vault you intend to connect and confirm OWD Sync `0.1.6` is +1. Open the exact vault you intend to connect and confirm OWD Sync `0.1.7` is enabled. -2. In OWD, choose **I see OWD Sync 0.1.6 — create request**. +2. In OWD, choose **I see OWD Sync 0.1.7 — create request**. 3. Choose **Open Obsidian and pair**. 4. In Obsidian, verify the current vault name and OWD workspace. 5. Choose **Pair and start sync**. @@ -134,14 +134,14 @@ plugin from silently expanding an agent's authority. | Component | Required version | | ---------------- | ------------------------------------ | -| OWD Sync | `0.1.6` | -| OWD Platform | `1.0.0-alpha.3` | +| OWD Sync | `0.1.7` | +| OWD Platform | `1.0.0-alpha.7` | | Obsidian desktop | Current alpha-tested desktop release | | Direct installer | Current Chrome or Edge over HTTPS | Do not mix `main.js`, `manifest.json`, or `styles.css` from different releases. The complete versioned package and SHA-256 checksums are available on the -[OWD Sync 0.1.6 release page](https://github.com/msinclair25/owd-sync/releases/tag/0.1.6). +[OWD Sync 0.1.7 release page](https://github.com/msinclair25/owd-sync/releases/tag/0.1.7). The ZIP is a maintainer diagnostic artifact, not the normal tester installation path. @@ -149,7 +149,7 @@ path. ### Obsidian reports `unrecognized URI action` -OWD Sync is not loaded at version `0.1.6` in the vault Obsidian opened. Confirm +OWD Sync is not loaded at version `0.1.7` in the vault Obsidian opened. Confirm the plugin version and enabled state in that exact vault, then reopen the pairing request. Use OWD's manual fallback if the direct handoff remains blocked. diff --git a/SOURCE.md b/SOURCE.md index fde30a8..744078a 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,14 +1,13 @@ # Source provenance -OWD Sync is developed in the private OWD Platform monorepo and promoted to this -sanitized public plugin repository while the complete platform remains in -private beta. +OWD Sync is developed in the public OWD Platform monorepo and promoted to this +focused plugin distribution repository. -The current `0.1.6` public source and assets correspond to: +The current `0.1.7` public source and assets correspond to: -- OWD Platform plugin tag: `owd-sync-v0.1.6` -- source commit: `2322e7077c93549468c983d202ab28bb161142af` -- public release tag: `0.1.6` +- OWD Platform plugin tag: `owd-sync-v0.1.7` +- source commit: `f56dec9cc9d522a5070717283e24a10d5cf70c5d` +- public release tag: `0.1.7` - plugin ID: `owd-sync` The public release uses a tag exactly equal to `manifest.json`'s semantic diff --git a/manifest.json b/manifest.json index 8574930..546fa71 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "owd-sync", "name": "OWD Sync", - "version": "0.1.6", + "version": "0.1.7", "minAppVersion": "1.5.0", "description": "Private, self-hosted Obsidian sync through your OWD Cloudflare deployment.", "author": "OWD Platform contributors", diff --git a/package.json b/package.json index 5664a7f..6cf5461 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owd-sync", - "version": "0.1.6", + "version": "0.1.7", "private": true, "description": "The explicit vault connection between Obsidian and an owner-controlled OWD workspace.", "license": "Apache-2.0 AND 0BSD", @@ -24,7 +24,7 @@ "dependencies": { "fast-diff": "1.3.0", "fflate": "0.8.2", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "partyserver": "0.3.2", "y-codemirror.next": "0.3.5", "y-indexeddb": "9.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546ffa7..d020a91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,8 +14,8 @@ importers: specifier: 0.8.2 version: 0.8.2 js-yaml: - specifier: 4.3.0 - version: 4.3.0 + specifier: 4.3.1 + version: 4.3.1 partyserver: specifier: 0.3.2 version: 0.3.2(@cloudflare/workers-types@4.20260702.1) @@ -1059,10 +1059,10 @@ packages: integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==, } - js-yaml@4.3.0: + js-yaml@4.3.1: resolution: { - integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==, + integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==, } hasBin: true @@ -1943,7 +1943,7 @@ snapshots: isomorphic.js@0.2.5: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 diff --git a/test/socket-ticket-retry.test.ts b/test/socket-ticket-retry.test.ts new file mode 100644 index 0000000..11ef3a8 --- /dev/null +++ b/test/socket-ticket-retry.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { CAPABILITY_REFRESH_INTERVAL_MS } from "../vendor/yaos-src/runtime/capabilityPolicy"; +import { SocketTicketHttpError } from "../vendor/yaos-src/sync/socketTicketError"; +import { + decideSocketTicketFailure, + SOCKET_TICKET_RETRY_BASE_MS, + SOCKET_TICKET_RETRY_MAX_MS, + socketTicketRetryDelayMs, +} from "../vendor/yaos-src/sync/socketTicketRetry"; + +describe("socket ticket retry policy", () => { + it.each([401, 403] as const)( + "turns HTTP %s into terminal authorization state with no retry", + (status) => { + const decision = decideSocketTicketFailure( + new SocketTicketHttpError(status), + 0, + 0.5, + ); + + expect(decision).toMatchObject({ + kind: "fatal", + code: "unauthorized", + status, + }); + expect("delayMs" in decision).toBe(false); + if (decision.kind !== "fatal") { + throw new Error("expected a fatal socket-ticket decision"); + } + expect(decision.reason).toContain("Re-pair"); + }, + ); + + it.each([429, 500, 503])("backs off retryable HTTP %s failures", (status) => { + const decision = decideSocketTicketFailure( + new SocketTicketHttpError(status), + 2, + 0.5, + ); + expect(decision).toEqual({ + kind: "retry", + delayMs: SOCKET_TICKET_RETRY_BASE_MS * 4, + nextAttempt: 3, + }); + }); + + it("backs off network failures exponentially with bounded jitter and a hard cap", () => { + expect(socketTicketRetryDelayMs(0, 0)).toBe(24_000); + expect(socketTicketRetryDelayMs(0, 1)).toBe(36_000); + expect(socketTicketRetryDelayMs(1, 0.5)).toBe(60_000); + expect(socketTicketRetryDelayMs(16, 1)).toBe(SOCKET_TICKET_RETRY_MAX_MS); + + const decision = decideSocketTicketFailure(new TypeError("offline"), 16, 1); + expect(decision).toEqual({ + kind: "retry", + delayMs: SOCKET_TICKET_RETRY_MAX_MS, + nextAttempt: 16, + }); + expect( + decideSocketTicketFailure(new TypeError("offline"), Number.NaN, 0.5), + ).toEqual({ + kind: "retry", + delayMs: SOCKET_TICKET_RETRY_BASE_MS, + nextAttempt: 1, + }); + }); + + it("limits degraded capability polling to once every five minutes", () => { + expect(CAPABILITY_REFRESH_INTERVAL_MS).toBe(5 * 60_000); + }); +}); diff --git a/vendor/yaos-src/main.ts b/vendor/yaos-src/main.ts index b4b9d2b..c8cef80 100644 --- a/vendor/yaos-src/main.ts +++ b/vendor/yaos-src/main.ts @@ -775,7 +775,12 @@ export default class VaultCrdtSyncPlugin extends Plugin { const waitingForR2 = !!this.settings.host && (!capabilityState || !capabilityState.attachments || !capabilityState.snapshots); - if (waitingForR2 && (this.capabilityUpdateService?.shouldRefreshCapabilities() ?? false)) { + const authRetryBlocked = this.vaultSync?.fatalAuthError ?? false; + if ( + waitingForR2 && + !authRetryBlocked && + (this.capabilityUpdateService?.shouldRefreshCapabilities() ?? false) + ) { void this.refreshServerCapabilities("background-poll"); } }, 3000); diff --git a/vendor/yaos-src/runtime/capabilityPolicy.ts b/vendor/yaos-src/runtime/capabilityPolicy.ts new file mode 100644 index 0000000..39a45a5 --- /dev/null +++ b/vendor/yaos-src/runtime/capabilityPolicy.ts @@ -0,0 +1,3 @@ +// Capability changes require a server deployment, so a five-minute degraded +// poll is responsive without creating a permanent per-client request loop. +export const CAPABILITY_REFRESH_INTERVAL_MS = 5 * 60_000; diff --git a/vendor/yaos-src/runtime/capabilityUpdateService.ts b/vendor/yaos-src/runtime/capabilityUpdateService.ts index 6abd7ab..1a93624 100644 --- a/vendor/yaos-src/runtime/capabilityUpdateService.ts +++ b/vendor/yaos-src/runtime/capabilityUpdateService.ts @@ -13,6 +13,9 @@ import { attachmentSizeCapKB } from "../settings/settingsStore"; import { obsidianRequest } from "../utils/http"; import { formatUnknown } from "../utils/format"; import { compareSemver } from "../utils/semver"; +import { CAPABILITY_REFRESH_INTERVAL_MS } from "./capabilityPolicy"; + +export { CAPABILITY_REFRESH_INTERVAL_MS } from "./capabilityPolicy"; export type PersistedServerCapabilitiesCache = { host: string; @@ -45,7 +48,6 @@ const UPDATE_MANIFEST_URLS = [ "https://github.com/msinclair25/owd-platform/releases/latest/download/update-manifest.json", ] as const; const UPDATE_MANIFEST_CACHE_MS = 24 * 60 * 60 * 1000; -export const CAPABILITY_REFRESH_INTERVAL_MS = 30_000; export function isServerCapabilities(value: unknown): value is ServerCapabilities { if (typeof value !== "object" || value === null) return false; diff --git a/vendor/yaos-src/runtime/connectionController.ts b/vendor/yaos-src/runtime/connectionController.ts index 1874d23..0abb9ce 100644 --- a/vendor/yaos-src/runtime/connectionController.ts +++ b/vendor/yaos-src/runtime/connectionController.ts @@ -291,6 +291,10 @@ export class ConnectionController { this.onlineHandler = () => { this.deps.log("Network online event — requesting fast reconnect"); this.deps.scheduleTraceStateSnapshot("network-online"); + if (this.deps.getVaultSync()?.fatalAuthError) { + this.deps.refreshStatusBar(); + return; + } this.deps.refreshServerCapabilities("network-online"); this.requestFastReconnect("network-online"); }; diff --git a/vendor/yaos-src/sync/socketTicket.ts b/vendor/yaos-src/sync/socketTicket.ts index 916cb84..98dd555 100644 --- a/vendor/yaos-src/sync/socketTicket.ts +++ b/vendor/yaos-src/sync/socketTicket.ts @@ -23,23 +23,14 @@ */ import { obsidianRequest } from "../utils/http"; +import { SocketTicketHttpError } from "./socketTicketError"; + +export { SocketTicketHttpError } from "./socketTicketError"; // --------------------------------------------------------------------------- // Typed HTTP error // --------------------------------------------------------------------------- -/** - * Thrown by fetchSocketTicket when the server responds with a non-200 status. - * Using a typed error instead of parsing the status code out of a string lets - * callers branch on `err.status` rather than regexing an English message. - */ -export class SocketTicketHttpError extends Error { - constructor(readonly status: number) { - super(`socket ticket request failed (${status})`); - this.name = "SocketTicketHttpError"; - } -} - /** * Returns true when the ticket endpoint does not exist on this server — * clean "old server" signals only: diff --git a/vendor/yaos-src/sync/socketTicketError.ts b/vendor/yaos-src/sync/socketTicketError.ts new file mode 100644 index 0000000..62daf87 --- /dev/null +++ b/vendor/yaos-src/sync/socketTicketError.ts @@ -0,0 +1,7 @@ +/** Typed failure returned by the socket-ticket HTTP endpoint. */ +export class SocketTicketHttpError extends Error { + constructor(readonly status: number) { + super(`socket ticket request failed (${status})`); + this.name = "SocketTicketHttpError"; + } +} diff --git a/vendor/yaos-src/sync/socketTicketRetry.ts b/vendor/yaos-src/sync/socketTicketRetry.ts new file mode 100644 index 0000000..02ac655 --- /dev/null +++ b/vendor/yaos-src/sync/socketTicketRetry.ts @@ -0,0 +1,76 @@ +import { SocketTicketHttpError } from "./socketTicketError"; + +export const SOCKET_TICKET_RETRY_BASE_MS = 30_000; +export const SOCKET_TICKET_RETRY_MAX_MS = 30 * 60_000; +export const SOCKET_TICKET_RETRY_JITTER_RATIO = 0.2; + +export type SocketTicketFailureDecision = + | { + kind: "fatal"; + code: "unauthorized"; + reason: string; + status: 401 | 403; + } + | { + kind: "retry"; + delayMs: number; + nextAttempt: number; + }; + +/** + * Exponential retry delay for transient ticket failures. The caller supplies + * randomUnit in tests; production uses Math.random because this jitter is for + * traffic de-correlation, not security. + */ +export function socketTicketRetryDelayMs( + attempt: number, + randomUnit = Math.random(), +): number { + const safeAttempt = + Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 0; + const boundedRandom = Number.isFinite(randomUnit) + ? Math.min(1, Math.max(0, randomUnit)) + : 0.5; + const exponential = Math.min( + SOCKET_TICKET_RETRY_MAX_MS, + SOCKET_TICKET_RETRY_BASE_MS * 2 ** Math.min(safeAttempt, 16), + ); + const jitterMultiplier = + 1 - + SOCKET_TICKET_RETRY_JITTER_RATIO + + 2 * SOCKET_TICKET_RETRY_JITTER_RATIO * boundedRandom; + return Math.min( + SOCKET_TICKET_RETRY_MAX_MS, + Math.max(1_000, Math.round(exponential * jitterMultiplier)), + ); +} + +/** + * Authentication rejection is terminal until the owner re-pairs the vault. + * Every other failure is transient and receives bounded exponential backoff. + */ +export function decideSocketTicketFailure( + error: unknown, + attempt: number, + randomUnit = Math.random(), +): SocketTicketFailureDecision { + if ( + error instanceof SocketTicketHttpError && + (error.status === 401 || error.status === 403) + ) { + return { + kind: "fatal", + code: "unauthorized", + reason: + "The vault credential was rejected. Re-pair this vault before syncing again.", + status: error.status, + }; + } + const safeAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 0; + + return { + kind: "retry", + delayMs: socketTicketRetryDelayMs(safeAttempt, randomUnit), + nextAttempt: Math.min(safeAttempt + 1, 16), + }; +} diff --git a/vendor/yaos-src/sync/vaultSync.ts b/vendor/yaos-src/sync/vaultSync.ts index 72f0584..cabee0b 100644 --- a/vendor/yaos-src/sync/vaultSync.ts +++ b/vendor/yaos-src/sync/vaultSync.ts @@ -37,6 +37,7 @@ import { import type { CandidateStore, ScopeKey, ScopeMetadata } from "./candidateStore"; import type { ProductFlightPathEventInput } from "../observability/traceSink"; import { TICKET_REFRESH_BUFFER_MS, patchTicketInUrl } from "./socketTicket"; +import { decideSocketTicketFailure } from "./socketTicketRetry"; /** Current schema version. Stored in sys.schemaVersion. */ export { SCHEMA_VERSION } from "./schema"; @@ -283,6 +284,10 @@ export class VaultSync { /** Timer handle for the proactive provider URL ticket refresh. */ private _socketTicketRefreshTimer: ReturnType | null = null; + private _socketTicketRetryAttempt = 0; + private _socketTicketRefreshInFlight = false; + private _socketTicketRetryPausedProvider = false; + private _destroyed = false; constructor( settings: VaultSyncSettings, @@ -424,7 +429,13 @@ export class VaultSync { if (event.status === "connected") { this._connectionGeneration++; this.log(`Connection generation: ${this._connectionGeneration}`); - } else if (event.status === "disconnected" && this._getSocketTicket) { + } else if ( + event.status === "disconnected" && + this._getSocketTicket && + !this._fatalAuthError && + !this._socketTicketRetryPausedProvider && + !this._destroyed + ) { // Best-effort: refresh provider.url before the reconnect timer fires. // The proactive timer (scheduleSocketTicketRefresh) is the primary // mechanism; this handles edge cases like laptop sleep where the @@ -435,22 +446,8 @@ export class VaultSync { const handleFatalAuthPayload = (payload: string) => { const msg = parseFatalAuthMessage(payload); - if (!msg) { - return; - } - const firstFatal = !this._fatalAuthError; - this._fatalAuthError = true; - this._fatalAuthCode = msg.code; - this._fatalAuthDetails = { - clientSchemaVersion: msg.clientSchemaVersion, - roomSchemaVersion: msg.roomSchemaVersion, - reason: msg.reason, - }; - if (firstFatal) { - this.log(`Fatal auth error: ${msg.code} — stopping reconnection`); - } - this.provider.disconnect(); - this.resolvePendingProviderSyncWaiters(false); + if (!msg) return; + this.markFatalAuth(msg); }; // y-partyserver emits "__YPS:" control payloads via "custom-message". @@ -472,6 +469,7 @@ export class VaultSync { }); void this.provider.connect().catch((err: unknown) => { this.log(`Provider connect failed: ${formatUnknown(err)}`); + this.handleSocketTicketFailure(err, "initial-connect"); }); } @@ -1967,6 +1965,7 @@ export class VaultSync { localExpiresAt: number; ttlMs: number; }): void { + if (this._fatalAuthError || this._destroyed) return; this.clearSocketTicketRefreshTimer(); const ttlRemaining = ticket.localExpiresAt - Date.now(); const buffer = Math.min(TICKET_REFRESH_BUFFER_MS, Math.floor(ttlRemaining / 2)); @@ -1984,6 +1983,60 @@ export class VaultSync { } } + private markFatalAuth(message: FatalAuthMessage): void { + const firstFatal = !this._fatalAuthError; + this._fatalAuthError = true; + this._fatalAuthCode = message.code; + this._fatalAuthDetails = { + clientSchemaVersion: message.clientSchemaVersion, + roomSchemaVersion: message.roomSchemaVersion, + reason: message.reason, + }; + this._socketTicketRetryAttempt = 0; + this._socketTicketRetryPausedProvider = false; + this.clearSocketTicketRefreshTimer(); + if (firstFatal) { + this.log(`Fatal auth error: ${message.code} — stopping reconnection`); + } + this.provider.disconnect(); + this.resolvePendingProviderSyncWaiters(false); + } + + private handleSocketTicketFailure(error: unknown, phase: string): void { + if (!this._getSocketTicket || this._fatalAuthError || this._destroyed) return; + const decision = decideSocketTicketFailure( + error, + this._socketTicketRetryAttempt, + ); + if (decision.kind === "fatal") { + this.log( + `Socket ticket ${phase} rejected (${decision.status}) — stopping network retries`, + ); + this.markFatalAuth({ + code: decision.code, + clientSchemaVersion: null, + roomSchemaVersion: null, + reason: decision.reason, + }); + return; + } + + this._socketTicketRetryAttempt = decision.nextAttempt; + this._socketTicketRetryPausedProvider = true; + this.clearSocketTicketRefreshTimer(); + // Stop y-partyserver's independent reconnect loop while the ticket + // request is backing off; otherwise it can hammer the sync route with an + // expired URL even though ticket refresh itself is bounded. + this.provider.disconnect(); + this.log( + `Socket ticket ${phase} failed — retry ${decision.nextAttempt} in ${decision.delayMs}ms`, + ); + this._socketTicketRefreshTimer = setTimeout(() => { + this._socketTicketRefreshTimer = null; + void this.refreshProviderTicketUrl(true); + }, decision.delayMs); + } + /** * Replace the ticket value in provider.url, removing any legacy ?token=. * Preserves all other query params (schemaVersion, _pk, device, trace, boot). @@ -2000,34 +2053,44 @@ export class VaultSync { /** * Fetch a fresh ticket (optionally bypassing the cache) and patch * provider.url. Reschedules the refresh timer on success. - * On transient failure, retries after TICKET_REFRESH_BUFFER_MS so the - * proactive refresh cycle survives intermittent network errors. + * On transient failure, pauses the provider reconnect loop and uses the + * bounded exponential ticket policy before resuming with a fresh URL. */ private async refreshProviderTicketUrl(force = false): Promise { - if (!this._getSocketTicket) return; + if ( + !this._getSocketTicket || + this._fatalAuthError || + this._destroyed || + this._socketTicketRefreshInFlight + ) return; + this._socketTicketRefreshInFlight = true; try { const ticket = await this._getSocketTicket(force); - if (ticket) { + if (ticket && !this._fatalAuthError && !this._destroyed) { + const reconnectProvider = this._socketTicketRetryPausedProvider; + this._socketTicketRetryPausedProvider = false; + this._socketTicketRetryAttempt = 0; this.patchProviderTicket(ticket.value); this.scheduleSocketTicketRefresh(ticket); + if (reconnectProvider) { + void this.provider.connect().catch((err: unknown) => { + this.log(`Provider reconnect failed: ${formatUnknown(err)}`); + this.handleSocketTicketFailure(err, "reconnect"); + }); + } } } catch (err) { this.log(`socket ticket refresh failed: ${formatUnknown(err)}`); - // Clear any existing timer before scheduling the retry so we never - // lose a handle and fire duplicate refreshes. This matters when the - // disconnected best-effort path calls here while the proactive timer - // is already scheduled: without the clear, the proactive timer - // handle is overwritten but the timer still fires. - this.clearSocketTicketRefreshTimer(); - this._socketTicketRefreshTimer = setTimeout(() => { - this._socketTicketRefreshTimer = null; - void this.refreshProviderTicketUrl(true); - }, TICKET_REFRESH_BUFFER_MS); + this.handleSocketTicketFailure(err, "refresh"); + } finally { + this._socketTicketRefreshInFlight = false; } } async destroy(): Promise { this.log("Destroying VaultSync"); + this._destroyed = true; + this._socketTicketRetryPausedProvider = false; if (this._renameTimer) clearTimeout(this._renameTimer); this.clearSocketTicketRefreshTimer(); this.clearPendingRenames(); diff --git a/versions.json b/versions.json index 59142f9..dc88636 100644 --- a/versions.json +++ b/versions.json @@ -5,5 +5,6 @@ "0.1.3": "1.5.0", "0.1.4": "1.5.0", "0.1.5": "1.5.0", - "0.1.6": "1.5.0" + "0.1.6": "1.5.0", + "0.1.7": "1.5.0" }