diff --git a/.changeset/classic-sync-in-the-tab.md b/.changeset/classic-sync-in-the-tab.md new file mode 100644 index 000000000..f48fda91a --- /dev/null +++ b/.changeset/classic-sync-in-the-tab.md @@ -0,0 +1,8 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork": patch +--- + +Classic automerge-repo sync connects from the tab's own Repo instead of the automerge worker, so a worker restart no longer drops it and tabs no longer ask the replacement to connect again. + +`connectClassicSync` in `@inkandswitch/patchwork-bootloader` now takes the Repo: `connectClassicSync(repo, server?)`. `setupServiceWorker()` no longer returns `connectClassicSync`. `patchwork.sw.connectClassicSync(server?)` works as before. The `ConnectClassicSyncMessage` type is gone. diff --git a/.changeset/drop-package-memory.md b/.changeset/drop-package-memory.md new file mode 100644 index 000000000..01cfc5026 --- /dev/null +++ b/.changeset/drop-package-memory.md @@ -0,0 +1,6 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork-filesystem": patch +--- + +The automerge worker no longer measures package memory. Removed: the `@patchwork/package-memory` channel and its message types (`PACKAGE_MEMORY_CHANNEL`, `PackageMemoryRequestMessage`, `PackageMemoryDocument`, `PackageMemoryAcceptedMessage`, `PackageMemoryReplyMessage`), and the records a restarting worker saved for its replacement (`LOADED_CACHE`). The service worker clears that cache along with every other cache but its own. `packageDocuments` is gone from `@inkandswitch/patchwork-filesystem`; nothing else walked a package. diff --git a/.changeset/every-tab-its-own-node.md b/.changeset/every-tab-its-own-node.md new file mode 100644 index 000000000..bd1b3dc00 --- /dev/null +++ b/.changeset/every-tab-its-own-node.md @@ -0,0 +1,13 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork": patch +"@inkandswitch/patchwork-filesystem": patch +--- + +Every tab is its own Subduction node again, with its own socket to the sync server. The sync node in the automerge worker is gone. When the worker restarts (to recycle its wasm memory, for a new build, or after a trap), it takes only its resolver Repo with it. Before, every restart threw away the node every tab synced through, and each tab had to reconnect and resubscribe. The worker's resolver Repo has its own socket too. + +The automerge worker still holds `AUTOMERGE_WORKER_LOCK` for as long as it lives. The lock now does one thing: it lets tabs and the service worker tell when the worker has died. A replacement no longer waits for it before it builds its Repo, answers handoffs or announces itself `online`. An instance that's restarting already stops taking handoffs and package-memory requests, so the two don't answer at once. + +Removed from `@inkandswitch/patchwork-bootloader`: `getSyncRemote`, `watchDocument`, `relayKeyhive` and the `PortSyncRemote` type. Pass `subductionWebsocketEndpoints: [syncServerUrl]` to the Repo instead of `subductionRemote: getSyncRemote()`. On keyhive sites, use `new Repo(config)` directly rather than wrapping it in `relayKeyhive`. `loadKeyhiveSigner` is gone from `@inkandswitch/patchwork-bootloader/signer`. `startWorkerControl` no longer returns `holding`. + +Tabs still follow package docs without loading them. `watchDocument(repo, url, onChange)`, now exported from `@inkandswitch/patchwork`, subscribes to the document through the tab's own Subduction connection and listens on the origin's heads channel. Every Repo relays the server's heads there, so the watcher hears both the server's pushes and other tabs' writes. `onChange(seen)` gets the same summary `ModuleWatcher` already compares, so a repeat costs no lookup. Subduction can't unsubscribe from a single document, so after `unwatch()` the server keeps pushing it to that tab until the connection drops. diff --git a/.changeset/hashed-worker-files.md b/.changeset/hashed-worker-files.md new file mode 100644 index 000000000..b76d7b661 --- /dev/null +++ b/.changeset/hashed-worker-files.md @@ -0,0 +1,21 @@ +--- +"@inkandswitch/patchwork": patch +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork-e2e": patch +--- + +A build emits every file the automerge worker and the page load under a content-hashed name in `/assets`, served `immutable`, instead of at a fixed path: the `/packages/…` chunks, `automerge.wasm`, `subduction.wasm`, `keyhive_wasm.wasm`, the worker's second stage (`automerge-protocol-handler.js`) and `module-loader-worker.js`. A fixed path could only be revalidated, which fails offline, or cached on a guess, which after a deploy could start the worker on one build's files and the rest of another's. Now a build only ever loads its own copies, and they start offline from the HTTP cache. + +Only `service-worker.js` and the automerge worker's script, `automerge-protocol-handler-worker.js`, keep their fixed paths, since the browser starts both by URL from one build to the next. The worker's script is served `max-age=0, stale-while-revalidate=31536000`, so the browser starts it from its copy when it can't check for a newer one. Everything that copy imports is hashed, so even a copy from an older build brings up one whole build. A worker that restarts because a newer tab connected fetches its script past the cache first, so the replacement starts on the newer build rather than the cached one, which would carry on serving the newer tab. + +The fixed paths still work: the generated `_redirects` sends each one to its hashed name with a 302, and `vite preview` does the same, whether or not the site writes Netlify files. But a module imported both by a fixed path and by its hashed name is two modules, so code should import the packages by bare specifier (the import map points at the hashed names) and load the rest by the URLs in `@inkandswitch/patchwork-bootloader/emitted`: `AUTOMERGE_WASM`, `SUBDUCTION_WASM`, `KEYHIVE_WASM`, `AUTOMERGE_PROTOCOL_HANDLER` and `MODULE_LOADER_WORKER`. In dev these are the fixed paths. + +API changes: + +- `emitWasmAssets` (`@inkandswitch/patchwork-bootloader/externals`) emits the wasm under hashed names and returns each one's reference id by file name. +- `@inkandswitch/patchwork/vite` has a `hashed()` plugin, included in `patchwork()`, which swaps in the hashed names. `hashedFiles(config)` gives a build's table of them, also exported from `@inkandswitch/patchwork/vite/hashed`. +- `workers` entries say whether a worker is `hashed`. The workers no longer import the packages from `/packages/…` URLs: they link to the build's chunks like the page does. +- `buildHeaders(options, hashed?)` takes that table for the wasm preloads. +- The new `buildRedirects(hashed?)` writes the redirects. `REVALIDATED_PATHS` and `REVALIDATED_CACHE_CONTROL` name the stale-while-revalidate files and their header. + +The e2e offline test waits for the wasm by the URLs the page preloads. diff --git a/.changeset/shrink-automerge-repo-patch.md b/.changeset/shrink-automerge-repo-patch.md new file mode 100644 index 000000000..6cd7fd193 --- /dev/null +++ b/.changeset/shrink-automerge-repo-patch.md @@ -0,0 +1,5 @@ +--- +"@inkandswitch/patchwork": patch +--- + +The shipped automerge-repo patch no longer adds `subductionRemote`, the option that let a Repo sync through another node; nothing uses it since every tab became its own node again. The `SubductionRemote`, `SubductionRemoteHandlers` and `SubductionRemoteSyncResult` types are gone, as are the `SubductionStorageBridge`, `INTERCEPTOR_PREFIX` and `SubductionConnections` exports. `subductionSaveThrottle` stays. A site applying the patch from `@inkandswitch/patchwork/dist/patches` should take the new one. diff --git a/.changeset/tabs-resolve-handoffs.md b/.changeset/tabs-resolve-handoffs.md new file mode 100644 index 000000000..5a3de7f2e --- /dev/null +++ b/.changeset/tabs-resolve-handoffs.md @@ -0,0 +1,12 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork": patch +--- + +The service worker asks a tab to resolve `automerge:` requests before falling back to the automerge worker. Every tab is its own node with its own Repo, so the tab that made a request can usually answer it from documents it already holds. + +The service worker asks the tab that made the request first, then any other open tab (focused first, then visible). This covers requests from the module-loader worker and navigations, which have no window of their own. Only when no tab can answer does it broadcast to the automerge worker, which now handles just those requests. + +The tab replies `accepted` straight away, or `unavailable` if it has no Repo yet. A tab that says nothing within 250ms is passed over. That number is provisional: the service worker logs each tab's accept latency under `patchwork:serviceworker`, to measure from. A tab that has accepted holds a Web Lock for that one request until it has replied. If the lock is released with no reply, the tab has gone (closed, crashed, navigated away) and the next candidate is asked. + +New in `@inkandswitch/patchwork-bootloader`: `resolveHandoffsWith(repo)` makes this tab answer with `repo`. `@inkandswitch/patchwork` calls it with the Repo it creates; a site that passes its own Repo isn't asked and keeps resolving through the automerge worker. The resolving code the worker used is shared by both, in `resolver.ts`. The new messages are `ResolveRequestMessage`, `ResolveAcceptedMessage` and `ResolveUnavailableMessage`. diff --git a/.changeset/worker-recycle-fixes.md b/.changeset/worker-recycle-fixes.md new file mode 100644 index 000000000..70ca4a592 --- /dev/null +++ b/.changeset/worker-recycle-fixes.md @@ -0,0 +1,9 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +--- + +Fixes to how the automerge worker recycles itself to give back wasm memory: + +- The service worker no longer deletes the cache a restarting automerge worker saves what it had loaded into (`patchwork-package-memory`, now `LOADED_CACHE` in the bootloader's types). It cleared every cache but its own on each activation, so the replacement never had anything to restore. +- A worker with classic sync connected recycles like any other. Tabs ask the replacement to connect classic sync again, after a restart and after the worker goes unexpectedly, so connecting it no longer stops the worker from ever recycling. +- The recycle check counts subduction's wasm memory too, not only automerge's. diff --git a/.changeset/worker-repo-on-self.md b/.changeset/worker-repo-on-self.md new file mode 100644 index 000000000..29f845db8 --- /dev/null +++ b/.changeset/worker-repo-on-self.md @@ -0,0 +1,5 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +--- + +The automerge worker's resolver Repo is on `self.repo` again from the moment the worker starts, for the console. The Repo is only built once something has to be resolved, so until then reading `self.repo` starts building it and gives its promise. `await repo` works either way. diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 1fb1010a9..22e917b4a 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -11,24 +11,15 @@ env: MARKER: "" jobs: - bench: - name: benchmarks + # Say a run is under way, keeping the last results below it, marked as out + # of date, until this one replaces them. + announce: + name: mark the comment out of date runs-on: ubuntu-latest - timeout-minutes: 120 permissions: - contents: read pull-requests: write - # main and the PR on the same runner, one after the other: runner numbers - # drift between runs, so a PR is only ever compared with main measured - # alongside it. Only Patchwork as it ships (`patchwork` mode, whose tests - # are titled "patchwork: …" or "S5 patchwork, …"): the other modes and the - # storage adapters are alternatives the bench explores, not what merging - # changes. steps: - # Say a run is under way, keeping the last results below it, marked as - # out of date, until this one replaces them. - - name: mark the comment out of date - uses: actions/github-script@v7 + - uses: actions/github-script@v7 with: script: | const marker = process.env.MARKER @@ -59,6 +50,24 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }) } + # One runner per browser, so the browsers run at once without sharing a + # machine. On each, main and the PR run one after the other: runner numbers + # drift between runs, so a PR is only ever compared with main measured + # alongside it, in the same browser. Only Patchwork as it ships (`patchwork` + # mode, whose tests are titled "patchwork: …" or "S5 patchwork, …"): the + # other modes and the storage adapters are alternatives the bench explores, + # not what merging changes. + bench: + name: bench (${{ matrix.browser }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + steps: - uses: actions/checkout@v4 with: path: pr @@ -89,27 +98,31 @@ jobs: working-directory: pr run: pnpm install --frozen-lockfile && pnpm build - - name: install chromium + - name: install ${{ matrix.browser }} run: | - (cd main/sites/bench && pnpm exec playwright install --with-deps chromium) - (cd pr/sites/bench && pnpm exec playwright install --with-deps chromium) + (cd main/sites/bench && pnpm exec playwright install --with-deps ${{ matrix.browser }}) + (cd pr/sites/bench && pnpm exec playwright install --with-deps ${{ matrix.browser }}) # A single run of a benchmark can be off by more than the PR changes it, # so each side runs ROUNDS times, alternating, so that the runner # slowing down partway affects both. Failed tests still record their - # rows, so every round reports whatever got measured; main failing - # doesn't fail the PR, the PR failing does, once it's been reported. + # rows, so every round reports whatever got measured. A side whose + # bench.config.ts has no project for this browser records nothing. + # Whether the PR passes is the report's call, from what's uploaded: a + # PR whose tests threw leaves `threw` beside its runs. - name: bench main and the PR, alternating env: ROUNDS: 3 + BROWSER: ${{ matrix.browser }} run: | for side in main pr; do (cd $side/sites/bench && pnpm exec vite build); done + mkdir -p runs for round in $(seq "$ROUNDS"); do for side in main pr; do echo "::group::$side, round $round" rm -f $side/sites/bench/bench-results/*.jsonl - if ! (cd $side/sites/bench && pnpm exec playwright test -c bench.config.ts --grep "patchwork[:,]"); then - if [ "$side" = pr ]; then touch pr-failed; fi + if ! (cd $side/sites/bench && pnpm exec playwright test -c bench.config.ts --project="$BROWSER" --grep "patchwork[:,]"); then + if [ "$side" = pr ]; then touch runs/threw; fi fi mkdir -p "runs/$side-$round" cp $side/sites/bench/bench-results/*.jsonl "runs/$side-$round/" 2>/dev/null || true @@ -117,14 +130,61 @@ jobs: done done - - name: compare + - uses: actions/upload-artifact@v4 if: always() + with: + name: bench-runs-${{ matrix.browser }} + path: runs/ + retention-days: 7 + + # Every browser's runs side by side, in one comment, and the one check that + # says whether the PR passes: it fails when the PR's benchmarks threw in any + # browser, or a check (an "ok" row) that main passes failed in any round of + # the PR (see compare.ts). Main failing doesn't fail the PR. + report: + name: benchmarks + needs: bench + # Not for a run cancelled by a newer push: that one reports instead. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: sites/bench/compare.ts + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: actions/download-artifact@v4 + with: + pattern: bench-runs-* + path: runs + + - name: compare run: | - node pr/sites/bench/compare.ts --main runs/main-* --pr runs/pr-* > compare.md + threw="" + for browser in chromium firefox webkit; do + dir="runs/bench-runs-$browser" + if [ ! -d "$dir" ]; then + threw="$threw $browser (recorded nothing)" + elif [ -e "$dir/threw" ]; then + threw="$threw $browser" + fi + done + node sites/bench/compare.ts --main runs/*/main-* --pr runs/*/pr-* --why why.txt > compare.md || touch pr-regressed + if [ -n "$threw" ]; then + echo "$threw" > pr-failed + { echo "**The PR's benchmarks threw in:$threw.** See the run's logs."; echo; cat compare.md; } > compare.tmp + mv compare.tmp compare.md + fi cat compare.md >> "$GITHUB_STEP_SUMMARY" - uses: actions/github-script@v7 - if: always() with: script: | const fs = require("fs") @@ -140,7 +200,7 @@ jobs: marker, `## Benchmarks: Patchwork with this PR against main`, ``, - `\`${head}\` against main at \`${base}\`, Patchwork as it ships (\`patchwork\` mode), measured one after the other on the same runner ([run](${run})). Lower is better throughout. A row is in the table when every run of the PR came out on the same side of every run of main, with the medians more than 20% apart; every benchmark is below it.`, + `\`${head}\` against main at \`${base}\`, Patchwork as it ships (\`patchwork\` mode), in chromium, firefox and webkit, each on its own runner with main and the PR measured one after the other ([run](${run})). Lower is better throughout. A row is in the table when every run of the PR came out on the same side of every run of main, with the medians more than 20% apart; every benchmark is below it.`, ``, compare, ].join("\n") @@ -154,6 +214,23 @@ jobs: await github.rest.issues.createComment({owner, repo, issue_number, body}) } + # Says why as error annotations, which show on the run's summary page and + # the PR's checks. The comment has the same, with the tables. - name: fail if the PR's benchmarks failed - if: always() - run: test ! -e pr-failed + run: | + failed=0 + if [ -e pr-failed ]; then + echo "::error title=benchmarks threw::The PR's benchmarks threw in:$(cat pr-failed). See that browser's bench job for the test that threw." + failed=1 + fi + if [ -e pr-regressed ]; then + if [ -s why.txt ]; then + while IFS= read -r line; do + echo "::error title=check failed::${line//%/%25}" + done < why.txt + else + echo "::error title=compare failed::compare.ts exited without naming a check that failed; see the compare step." + fi + failed=1 + fi + exit $failed diff --git a/core/bootloader/package.json b/core/bootloader/package.json index b402ba470..0ecfe9646 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -23,6 +23,10 @@ "import": "./dist/externals.js", "types": "./dist/externals.d.ts" }, + "./emitted": { + "import": "./dist/emitted.js", + "types": "./dist/emitted.d.ts" + }, "./externals-list": { "import": "./dist/externals-list.js", "types": "./dist/externals-list.d.ts" @@ -109,7 +113,6 @@ }, "scripts": { "build": "tsc && cp src/global.css dist/global.css", - "test": "vitest run", "dev": "tsc -w --preserveWatchOutput" } } diff --git a/core/bootloader/src/automerge-protocol-handler-worker.ts b/core/bootloader/src/automerge-protocol-handler-worker.ts index 4890f374e..13bd745b0 100644 --- a/core/bootloader/src/automerge-protocol-handler-worker.ts +++ b/core/bootloader/src/automerge-protocol-handler-worker.ts @@ -3,16 +3,13 @@ // it starts, then imports the worker itself (automerge-protocol-handler.ts). // Keep its imports small: none of this runs until they've all loaded. import { AUTOMERGE_WORKER_LOCK } from "./types.js"; +import { AUTOMERGE_PROTOCOL_HANDLER } from "./emitted.js"; import { importRest, startEarly } from "./worker-early.js"; -// Built as a file of its own next to this one, so a variable rather than a -// literal: the bundler leaves the import alone. -const REST = "./automerge-protocol-handler.js"; - // Not awaited: this script should finish evaluating now, so nothing about the // worker waits on the import but the code that's in it. void importRest( startEarly(AUTOMERGE_WORKER_LOCK), "automerge-protocol-handler-worker", - new URL(REST, import.meta.url).href + new URL(AUTOMERGE_PROTOCOL_HANDLER, import.meta.url).href ); diff --git a/core/bootloader/src/automerge-protocol-handler.ts b/core/bootloader/src/automerge-protocol-handler.ts index a2157b868..9f0a457ec 100644 --- a/core/bootloader/src/automerge-protocol-handler.ts +++ b/core/bootloader/src/automerge-protocol-handler.ts @@ -1,73 +1,42 @@ -// The Repo that resolves `automerge:` URLs for the service worker, in a -// SharedWorker: one instance serves every tab and lives as long as any tab -// does. The worker's script is automerge-protocol-handler-worker.ts, which -// takes the lock and then imports this. +// The Repo that resolves `automerge:` URLs for the service worker when no tab +// can, in a SharedWorker: one instance serves every tab and lives as long as +// any tab does. The worker's script is automerge-protocol-handler-worker.ts, +// which takes the lock and then imports this. // -// It also hosts the origin's sync node (sync-node.ts), the one Subduction node -// with a connection to the sync server. Every tab's Repo syncs through it, and -// so does the resolver Repo here, which is otherwise a Repo like any tab's: the -// same IndexedDB and the storage channel to the tabs. -// When the service worker misses the cache for a request that looks like a URL -// encoded URL, it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we -// resolve the automerge URL, write the response into the service worker's +// It is a node like any tab's: the same IndexedDB, its own sync-server socket, +// and the storage channel to the tabs. Resolving requests is its whole job. +// When the service worker can't get a tab to resolve a request that looks like +// a URL encoded URL, it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; +// we resolve the automerge URL, write the response into the service worker's // cache (keyed by a Request reconstructed to match the one it's holding), and // reply on the same channel. -import { - initializeWasm, - getChangesMetaSince, - hasHeads, - stats, -} from "@automerge/automerge/slim"; +import { initializeWasm } from "@automerge/automerge/slim"; // eslint-disable-next-line // @ts-ignore — the default init is a wasm-bindgen runtime helper not in the .d.ts import initSubduction from "@automerge/automerge-subduction/slim"; import { - INTERCEPTOR_PREFIX, Repo, - isImmutableString, - isValidAutomergeUrl, - parseAutomergeUrl, - stringifyAutomergeUrl, - type AnyDocumentId, - type AutomergeUrl, - type DocHandle, type DocumentId, type PeerId, - type StorageId, } from "@automerge/automerge-repo/slim"; -import { - getDefaultBranch, - packageDocuments, - resolvePath, -} from "@inkandswitch/patchwork-filesystem"; import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"; -import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket"; import type { AutomergeRepoKeyhive, SyncServerSelection, } from "@automerge/automerge-repo-keyhive"; -import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; -import { loadKeyhiveSigner, loadOrCreateSigner } from "./signer.js"; +import { loadOrCreateSigner } from "./signer.js"; import { keyhiveStorageName, storagePrefix } from "./storage.js"; import { startWorkerControl } from "./worker-control.js"; -import { SyncNode } from "./sync-node.js"; -import { PortSyncRemote, relayKeyhive } from "./sync-remote.js"; +import { AUTOMERGE_WASM, SUBDUCTION_WASM } from "./emitted.js"; +import { createResolver } from "./resolver.js"; import { AUTOMERGE_WORKER_LOCK, HANDOFF_CHANNEL, - PACKAGE_MEMORY_CHANNEL, - type HandoffCachedMessage, type HandoffOnlineMessage, - type HandoffAbortMessage, type HandoffRequestMessage, - type HandoffResponseMessage, - type PackageMemoryAcceptedMessage, - type PackageMemoryDocument, - type PackageMemoryReplyMessage, - type PackageMemoryRequestMessage, } from "./types.js"; declare const __SYNC_SERVER__: { @@ -81,23 +50,14 @@ const syncServer = ? __SYNC_SERVER__ : { url: "wss://subduction.sync.inkandswitch.com" }; -const RESOLVE_TIMEOUT_MS = 30_000; - -const CACHEABLE_STATUSES = [200, 203, 204]; - const control = startWorkerControl("automerge-protocol-handler-worker", { - onMessage: handleControlMessage, onRestart: drainForRestart, + // Held only so the tabs and the service worker can tell whether this + // instance is still around; nothing here waits for it. lock: AUTOMERGE_WORKER_LOCK, }); -// The sync node and the Repo wait for the lock: until the previous instance -// is gone, it's still syncing and resolving, against the same IndexedDB. -let holdingLock = false; -void control.holding.then(() => (holdingLock = true)); const log = control.log; -// ── The sync node ────────────────────────────────────────────────────── - let wasmPromise: Promise | null = null; function initWasm(): Promise { @@ -105,8 +65,8 @@ function initWasm(): Promise { log("fetching wasm"); watchWasmMemories(); wasmPromise = Promise.all([ - initializeWasm(new Request("/automerge.wasm")), - initSubduction(new Request("/subduction.wasm")), + initializeWasm(new Request(AUTOMERGE_WASM)), + initSubduction(new Request(SUBDUCTION_WASM)), ]).then(() => log("wasm initialized")); wasmPromise.catch(() => { wasmPromise = null; @@ -115,59 +75,6 @@ function initWasm(): Promise { return wasmPromise; } -let syncNodePromise: Promise | null = null; - -function getSyncNode(): Promise { - if (!syncNodePromise) { - syncNodePromise = (async () => { - await control.holding; - await initWasm(); - const storage = new IndexedDBStorageAdapter(); - // On keyhive sites the Repos store encrypted documents under the - // interceptor's prefix and sign as the keyhive identity. - const node = new SyncNode({ - signer: syncServer.keyhive - ? await loadKeyhiveSigner( - new IndexedDBStorageAdapter(keyhiveStorageName) - ) - : await loadOrCreateSigner(storage), - storage, - storagePrefix: syncServer.keyhive ? INTERCEPTOR_PREFIX : undefined, - endpoints: [syncServer.url], - log, - }); - (self as any).syncNode = node; - return node; - })(); - // Nothing syncs without it: start again from scratch, as a new instance. - syncNodePromise.catch((error) => { - syncNodePromise = null; - control.fail("the sync node failed to start", error); - }); - } - return syncNodePromise; -} - -/** Serve a Repo on `port` once the node is up; messages queue until then. */ -function connectToSyncNode(port: MessagePort): void { - getSyncNode().then( - (node) => node.connect(port), - (error) => { - console.error("sync node failed to start", error); - port.close(); - } - ); -} - -/** The resolver Repo's connection to the node, which lives right here. */ -function resolverRemote(): PortSyncRemote { - return new PortSyncRemote(() => { - const { port1, port2 } = new MessageChannel(); - connectToSyncNode(port2); - return port1; - }); -} - // ── The repo ─────────────────────────────────────────────────────────── let repoPromise: Promise | null = null; @@ -196,7 +103,6 @@ Object.defineProperty(self, "repo", { }); async function buildRepo(): Promise { - await control.holding; await initWasm(); const { repo, hive } = syncServer.keyhive @@ -215,7 +121,7 @@ async function buildPlainRepo(): Promise { storage, peerId: `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId, - subductionRemote: resolverRemote(), + subductionWebsocketEndpoints: [syncServer.url], headsChannel: `${storagePrefix}-heads`, enableRemoteHeadsGossiping: true, }); @@ -227,16 +133,12 @@ async function buildKeyhiveRepo( const { initKeyhiveWasm, initializeAutomergeRepoKeyhive } = await import("@automerge/automerge-repo-keyhive"); initKeyhiveWasm(); - const remote = resolverRemote(); const { hive, repo } = await initializeAutomergeRepoKeyhive({ // ARK injects an `idFactory` deriving document ids from keyhive. A site // can opt out of it with `keyhive: { useIdFactory: false }`. createRepo: ({ idFactory, ...config }) => - relayKeyhive( - new Repo( - syncServer.useIdFactory === false ? config : { ...config, idFactory } - ), - remote + new Repo( + syncServer.useIdFactory === false ? config : { ...config, idFactory } ), storage: new IndexedDBStorageAdapter(keyhiveStorageName), peerIdSuffix: @@ -248,7 +150,7 @@ async function buildKeyhiveRepo( syncServer: keyhiveSyncServer, repo: { storage: new IndexedDBStorageAdapter(), - subductionRemote: remote, + subductionWebsocketEndpoints: [syncServer.url], headsChannel: `${storagePrefix}-heads`, enableRemoteHeadsGossiping: true, }, @@ -261,248 +163,15 @@ async function buildKeyhiveRepo( return { repo, hive }; } -// ── Classic sync ─────────────────────────────────────────────────────── - -let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER; -let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null; -let classicSyncConnect: Promise | null = null; - -function connectClassicSyncNetwork(server: string): Promise { - const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER; - if (classicSyncConnect && classicSyncServer === url) - return classicSyncConnect; - - if (classicSyncAdapter && classicSyncServer !== url) { - classicSyncAdapter.disconnect(); - classicSyncAdapter = null; - } - - classicSyncServer = url; - const connecting = (async () => { - const repo = await getRepo(); - if (!classicSyncAdapter) { - classicSyncAdapter = new WebSocketWorkerClientAdapter(url); - repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter); - } - await classicSyncAdapter.whenReady(); - log("classic sync connected", url); - })(); - - // Clear the memo on failure so a later attempt can retry, and swallow the - // rejection on this copy so it isn't reported as unhandled — callers get it - // from the promise we return. - classicSyncConnect = connecting; - connecting.catch(() => { - if (classicSyncConnect === connecting) classicSyncConnect = null; - }); - return connecting; -} - -// ── Control protocol ─────────────────────────────────────────────────── - -function handleControlMessage( - data: any, - controlPort: MessagePort, - event: MessageEvent -): void { - if (data?.type === "sync-connect") { - const [port] = event.ports; - if (port) connectToSyncNode(port); - return; - } - if (data?.type !== "connect-classic-sync") return; - const [replyPort] = event.ports; - const server = - typeof data.server === "string" ? data.server : DEFAULT_CLASSIC_SYNC_SERVER; - connectClassicSyncNetwork(server).then( - () => { - replyPort?.postMessage({ type: "connect-classic-sync-ready" }); - replyPort?.close(); - }, - (err) => { - console.error("connectClassicSyncNetwork failed", err); - replyPort?.postMessage({ - type: "connect-classic-sync-failed", - error: String(err), - }); - replyPort?.close(); - } - ); -} - -// ── Resolving ────────────────────────────────────────────────────────── - -function waitForHeads( - handle: DocHandle, - hexHeads: string[], - signal: AbortSignal -): Promise { - if (hasHeads(handle.doc(), hexHeads)) return Promise.resolve(true); - if (signal.aborted) return Promise.resolve(false); - return new Promise((resolve) => { - const cleanup = () => { - handle.off("heads-changed", check); - signal.removeEventListener("abort", onAbort); - }; - const check = () => { - if (!hasHeads(handle.doc(), hexHeads)) return; - cleanup(); - resolve(true); - }; - const onAbort = () => { - cleanup(); - resolve(false); - }; - handle.on("heads-changed", check); - signal.addEventListener("abort", onAbort); - // The heads may have landed between the check above and subscribing. - check(); - }); -} - -const CATCH_UP_MS = 3_000; - -async function caughtUpWithPeers( - repo: Repo, - handle: DocHandle, - signal: AbortSignal -): Promise { - if (!repo.isSubductionConnected()) return; - const peers = (await repo.connectedSubductionPeerIds()) as StorageId[]; - const caughtUp = () => { - const states = peers.map((peer) => handle.isCaughtUpWith(peer)); - return ( - states.some((state) => state !== undefined) && !states.includes(false) - ); - }; - if (caughtUp() || signal.aborted) return; - await new Promise((resolve) => { - const done = () => { - clearTimeout(timer); - handle.off("remote-heads", check); - handle.off("heads-changed", check); - signal.removeEventListener("abort", done); - resolve(); - }; - const check = () => { - if (caughtUp()) done(); - }; - const timer = setTimeout(done, CATCH_UP_MS); - handle.on("remote-heads", check); - handle.on("heads-changed", check); - signal.addEventListener("abort", done); - if (signal.aborted) done(); - else check(); - }); -} - -/** - * Thrown instead of returning a Response when the request should fail as a - * network error rather than resolve to something the caller can memoize. - * See {@link HandoffAbortMessage}. - */ -class AbortHandoff extends Error {} - -async function resolveAutomergeUrl( - automergeURL: URL, - signal: AbortSignal -): Promise { - const repo = await getRepo(); - // What it finds, to record against the package. - const found: DocHandle[] = []; - const find = async (id: AnyDocumentId) => { - const handle = await repo.find(id, { signal }); - found.push(handle as DocHandle); - return handle; - }; - // `resolvePath` only finds documents. - const tracked: Repo = Object.create(repo, { find: { value: find } }); - const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/"); - - if (!isValidAutomergeUrl(maybeAutomergeUrl)) { - return new Response("invalid automerge url", { status: 400 }); - } - - if (path.length && !path[path.length - 1]) path.pop(); - - const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl); - - // todo, maybe a bad idea? maybe we should throw instead of es-module-caching - // the headless req - if (!heads) { - let folder = await find(maybeAutomergeUrl); - await caughtUpWithPeers(repo, folder, signal); - // Packages used to be published as a branches doc, which stands for its - // default branch. The tab's ModuleWatcher looks packages up through here, - // so this is where that's followed. - const branch = getDefaultBranch(folder.doc()); - if (branch) { - folder = await find(branch); - await caughtUpWithPeers(repo, folder, signal); - branchOf.set(documentId, folder.documentId); - } - const url = stringifyAutomergeUrl({ - documentId: folder.documentId, - heads: folder.heads(), - }); - const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`; - return Response.redirect(location, 307); - } - - const baseHandle = await find(stringifyAutomergeUrl({ documentId })); - if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) { - throw new AbortHandoff( - `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms` - ); - } - - const resolved = await resolvePath( - tracked, - baseHandle.view(heads), - path.map(decodeURIComponent) - ); - if (!resolved) { - throw new Error( - `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}` - ); - } - recordLoaded(documentId, found, path.map(decodeURIComponent).join("/")); - - const body: BodyInit = - resolved.content instanceof Uint8Array - ? (new Uint8Array(resolved.content) as BlobPart) - : resolved.content; - - return new Response(body, { - status: 200, - headers: { - "content-type": resolved.type, - "cache-control": resolved.pinned - ? "public, max-age=31536000, immutable" - : "no-store", - }, - }); -} +// ── Answering handoffs ───────────────────────────────────────────────── +// What nothing else could answer: the service worker asks the tab that made a +// request first, then any other tab, and broadcasts it here only when none of +// them can (one still booting, one from an older build, a site that brings +// its own Repo). +const resolver = createResolver(getRepo, log); const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL); -function replyToHandoff(id: string, status: number, body: string): void { - handoffChannel.postMessage({ - id, - type: "response", - response: { status, body, headers: { "content-type": "text/plain" } }, - } satisfies HandoffResponseMessage); -} - -function impatience(limit: number) { - return new Promise((_, reject) => - setTimeout( - () => reject(new Error(`resolve timeout after ${limit}ms`)), - limit - ) - ); -} - const EVICT_IDLE_MS = 5_000; let handoffsInFlight = 0; let evictTimer: ReturnType | undefined; @@ -528,10 +197,11 @@ function handoffFinished() { // needed at once until its global goes. Resolving packages at boot takes the // resolver Repo's automerge to 90–130MB, and letting the documents go gives // none of it back. So once handoffs stop for RECYCLE_IDLE_MS, an instance whose -// automerge holds more than RECYCLE_AT_MB restarts itself, as it does for a -// new build: tabs spawn the replacement and reconnect their sync, the service -// worker re-sends any handoff still unanswered, and what was resolved is in -// its cache, so the replacement doesn't load it all again. +// wasm has grown by more than RECYCLE_AT_MB restarts itself, as it does for a +// new build: tabs spawn the replacement, the service worker re-sends any +// handoff still unanswered, and what was resolved is in its cache, so the +// replacement doesn't load it all again. Only the resolver goes with it: every +// tab is its own node, with its own socket. const RECYCLE_AT_MB = 64; const RECYCLE_IDLE_MS = 15_000; @@ -565,25 +235,27 @@ function watchWasmMemories() { }; } -function automergeMemoryMb(): number { - let bytes = 0; - for (const { url, memory } of wasmMemories) { - if (new URL(url, self.location.href).pathname.endsWith("/automerge.wasm")) { - bytes += memory.buffer.byteLength; - } - } +const wasmBytes = (file: string) => + wasmMemories + .filter( + ({ url }) => + new URL(url, self.location.href).pathname === + new URL(file, self.location.href).pathname + ) + .reduce((bytes, { memory }) => bytes + memory.buffer.byteLength, 0); + +/** How much memory this instance's wasm holds. */ +function wasmGrowthMb(): number { + const bytes = wasmBytes(AUTOMERGE_WASM) + wasmBytes(SUBDUCTION_WASM); return Math.round(bytes / 1048576); } function recycleIfHolding() { if (handoffsInFlight > 0 || draining) return; - // A replacement doesn't connect to it again; whoever asked for it would - // have to. - if (classicSyncConnect) return; - const mb = automergeMemoryMb(); + const mb = wasmGrowthMb(); if (mb <= RECYCLE_AT_MB) return; control.restart( - `recycling: automerge holds ${mb}MB of wasm memory with nothing to resolve` + `recycling: wasm memory has grown by ${mb}MB with nothing to resolve` ); } @@ -601,146 +273,18 @@ async function evictLoaded() { } async function handleHandoffRequest(message: HandoffRequestMessage) { - const { id, request } = message; - - let handoff: URL; - try { - handoff = new URL(request.handoffURL); - } catch { - console.error("couldn't parse handoff url", request); - replyToHandoff( - id, - 400, - `couldn't parse a special url out of ${request.url}` - ); - return; - } - - // Other handlers may be listening on the channel for other schemes, so stay - // quiet rather than clobbering their reply with an error. - if (handoff.protocol !== "automerge:") { - log( - `ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys` - ); - return; - } - handoffStarted(); try { - await respondToHandoff(message, handoff); - } finally { - handoffFinished(); - } -} - -function resolveWithin(handoff: URL): Promise { - const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS); - return Promise.race([ - resolveAutomergeUrl(handoff, signal), - impatience(RESOLVE_TIMEOUT_MS), - ]); -} - -/** Lookups of a url without heads in flight, by that url. */ -const latestLookups = new Map>(); - -/** - * Resolve `handoff`, sharing the lookup with any already in flight for the - * same url without heads. Every tab's ModuleWatcher asks for each package's - * latest heads, again each time it changes, and none of it is cached, since - * the answer moves; on a busy worker those pile up into one full resolve - * each. One that joins late gets the shared lookup's answer, or its timeout, - * a little sooner than it would have its own. - */ -function resolveHandoff(handoff: URL): Promise { - const [maybeAutomergeUrl] = handoff.href.split("/"); - if ( - !isValidAutomergeUrl(maybeAutomergeUrl) || - parseAutomergeUrl(maybeAutomergeUrl).heads - ) { - return resolveWithin(handoff); - } - let lookup = latestLookups.get(handoff.href); - if (!lookup) { - lookup = resolveWithin(handoff).finally(() => { - latestLookups.delete(handoff.href); - }); - latestLookups.set(handoff.href, lookup); - } - // A redirect, so there's no body to share; each reply gets its own anyway. - return lookup.then((response) => response.clone()); -} - -async function respondToHandoff(message: HandoffRequestMessage, handoff: URL) { - const { id, cachename, request } = message; - - let response: Response; - try { - log(`resolving handoff ${id} for ${handoff}`); - response = await resolveHandoff(handoff); - } catch (error) { - if (error instanceof AbortHandoff) { - handoffChannel.postMessage({ - id, - type: "abort", - reason: error.message, - } satisfies HandoffAbortMessage); - return; - } - console.error(`error resolving ${request.url}`, error); - replyToHandoff( - id, - 557, - error instanceof Error - ? `${error.message}\n\n${error.stack}` - : String(error) - ); - return; - } - - try { - const immutable = - CACHEABLE_STATUSES.includes(response.status) && - !/(^|,)\s*no-store(\s*,|$)/.test( - response.headers.get("cache-control") ?? "" - ); - if (!immutable) { - // Errors, redirects and the like go back inline for the service worker to - // serve directly. Mutable responses aren't cached because the same URL - // can resolve to different content later. - log(`responding inline to ${request.url} with ${response.status}`); - handoffChannel.postMessage({ - id, - type: "response", - response: { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: response.body - ? new Uint8Array(await response.arrayBuffer()) - : undefined, - }, - } satisfies HandoffResponseMessage); + const reply = await resolver.answer(message); + // Other handlers may be listening on the channel for other schemes, so + // stay quiet rather than clobbering their reply with an error. + if (!reply) { + log(`ignoring handoff ${message.id} for a url that isn't automerge:`); return; } - - // Reconstruct the request the service worker is holding so the entry matches - // its cache.match. `destination` isn't constructible but doesn't participate - // in cache matching. - const cacheKey = new Request(request.url, { - method: request.method, - headers: request.headers, - referrer: request.referrer, - }); - const cache = await caches.open(cachename); - await cache.put(cacheKey, response); - log(`cached ${cacheKey.url} in ${cachename}`); - handoffChannel.postMessage({ - id, - type: "cached", - } satisfies HandoffCachedMessage); - } catch (error) { - console.error(`failed to reply for ${request.url}`, error); - replyToHandoff(id, 558, String(error)); + handoffChannel.postMessage(reply); + } finally { + handoffFinished(); } } @@ -765,11 +309,6 @@ async function drainForRestart(): Promise { } const repo = await repoPromise?.catch(() => null); await repo?.flush(); - await saveLoaded().catch((error) => - console.warn("couldn't keep the package records for the next instance", error) - ); - const node = await syncNodePromise?.catch(() => null); - await node?.shutdown(); } handoffChannel.addEventListener("message", (event) => { @@ -778,299 +317,9 @@ handoffChannel.addEventListener("message", (event) => { log(`ignoring handoff ${event.data.id} while restarting`); return; } - // The previous instance is still answering; once it's gone, `online` gets - // the service worker to send us whatever it left unanswered. - if (!holdingLock) return; void handleHandoffRequest(event.data as HandoffRequestMessage); }); // Announce ourselves so the service worker can re-broadcast handoff requests // sent while we were booting, or left by the instance before us. -void control.holding.then(() => - handoffChannel.postMessage({ type: "online" } satisfies HandoffOnlineMessage) -); - -// ── Measuring packages ───────────────────────────────────────────────── - -/** - * What resolves have loaded of each package, by its root doc: each document - * measured when it was loaded (again only if its heads have moved), and the - * paths asked for through it. - */ -const loadedByPackage = new Map< - DocumentId, - Map< - DocumentId, - { - heads: string; - measured: PackageMemoryDocument; - through: Set; - /** It's the file a path ended on, not a folder on the way. */ - file: boolean; - } - > ->(); - -/** Branches docs followed to their default branch, which holds the package. */ -const branchOf = new Map(); - -// What's been loaded outlives the instance: a restart (a recycle, or a new -// build) saves it for the replacement, which resolves from the service -// worker's cache rather than loading the packages again. -const LOADED_CACHE = "patchwork-package-memory"; -const LOADED_KEY = "/loaded-by-package"; - -type SavedLoaded = { - packages: Array< - [ - DocumentId, - Array< - [ - DocumentId, - { - heads: string; - measured: PackageMemoryDocument; - through: string[]; - file: boolean; - }, - ] - >, - ] - >; - branches: Array<[DocumentId, DocumentId]>; -}; - -async function saveLoaded() { - await restored; - const saved: SavedLoaded = { - packages: [...loadedByPackage].map(([packageId, documents]) => [ - packageId, - [...documents].map(([id, entry]) => [ - id, - { ...entry, through: [...entry.through] }, - ]), - ]), - branches: [...branchOf], - }; - const cache = await caches.open(LOADED_CACHE); - await cache.put(LOADED_KEY, new Response(JSON.stringify(saved))); -} - -/** Settles once what the instance before saved has been taken back. */ -const restored: Promise = (async () => { - try { - const cache = await caches.open(LOADED_CACHE); - const response = await cache.match(LOADED_KEY); - if (!response) return; - const saved = (await response.json()) as SavedLoaded; - for (const [packageId, documents] of saved.packages) { - const into = loadedByPackage.get(packageId) ?? new Map(); - for (const [id, entry] of documents) { - // Loaded since by this instance: that's newer. - if (into.has(id)) continue; - into.set(id, { ...entry, through: new Set(entry.through) }); - } - loadedByPackage.set(packageId, into); - } - for (const [from, to] of saved.branches) { - if (!branchOf.has(from)) branchOf.set(from, to); - } - } catch (error) { - console.warn("couldn't take back the package records", error); - } -})(); - -/** `handles` were found in turn resolving `path`: the root first, the file last. */ -function recordLoaded( - packageId: DocumentId, - handles: DocHandle[], - path: string -) { - let documents = loadedByPackage.get(packageId); - if (!documents) loadedByPackage.set(packageId, (documents = new Map())); - handles.forEach((handle, i) => { - const heads = handle.heads().join(","); - let entry = documents.get(handle.documentId); - if (!entry || entry.heads !== heads) { - entry = { - heads, - measured: measureDocument("", handle), - through: entry?.through ?? new Set(), - file: entry?.file ?? false, - }; - documents.set(handle.documentId, entry); - } - entry.through.add(path); - if (i > 0 && i === handles.length - 1) entry.file = true; - }); -} - -/** What's been loaded of the package at `url`, root first. */ -function loadedOf(url: string): PackageMemoryDocument[] { - if (!isValidAutomergeUrl(url)) return []; - const { documentId } = parseAutomergeUrl(url); - const packageId = branchOf.get(documentId) ?? documentId; - return [...(loadedByPackage.get(packageId) ?? [])].map(([id, entry]) => { - const paths = [...entry.through]; - const path = - id === packageId - ? "" - : entry.file - ? paths[0] - : sharedDirectory(paths); - return { ...entry.measured, path }; - }); -} - -/** The directory every one of `paths` is in. */ -function sharedDirectory(paths: string[]): string { - const dirs = paths.map((path) => path.split("/").slice(0, -1)); - const shared = dirs[0] ?? []; - let n = shared.length; - for (const dir of dirs) { - while (n > 0 && dir.slice(0, n).join("/") !== shared.slice(0, n).join("/")) n--; - } - return shared.slice(0, n).join("/"); -} - -const MEASURE_TIMEOUT_MS = 300_000; -// Most of a package's documents (READMEs, images) aren't stored here until -// it's measured, since modules never import them; one that doesn't turn up -// in this long is reported as unavailable. -const MEASURE_FIND_TIMEOUT_MS = 15_000; -const packageMemoryChannel = new BroadcastChannel(PACKAGE_MEMORY_CHANNEL); - -// One package at a time, so measuring them all doesn't load them all at once. -let measuring: Promise = Promise.resolve(); - -packageMemoryChannel.addEventListener("message", (event) => { - const message = event.data as PackageMemoryRequestMessage; - // Only one instance answers: the one holding the lock. - if (message?.type !== "request" || draining || !holdingLock) return; - packageMemoryChannel.postMessage({ - id: message.id, - type: "accepted", - } satisfies PackageMemoryAcceptedMessage); - if (!message.walk) { - void restored.then(() => - packageMemoryChannel.postMessage({ - id: message.id, - type: "reply", - documents: loadedOf(message.url), - } satisfies PackageMemoryReplyMessage) - ); - return; - } - measuring = measuring.then(() => replyWithPackageMemory(message)); -}); - -async function replyWithPackageMemory({ id, url }: PackageMemoryRequestMessage) { - const documents: PackageMemoryDocument[] = []; - let error: string | undefined; - // Holds off the eviction of everything loaded, as a handoff does; the - // documents measured are let go one at a time instead. - handoffStarted(); - try { - await measurePackage(url, (document) => documents.push(document)); - } catch (e) { - error = e instanceof Error ? e.message : String(e); - } finally { - handoffFinished(); - } - packageMemoryChannel.postMessage({ - id, - type: "reply", - documents, - error, - } satisfies PackageMemoryReplyMessage); -} - -/** - * Find the package at `url` the way a resolve does, then each of its - * documents in turn, letting each go once it's measured unless a resolve is - * under way, which may be using it too. What's left goes with the rest once - * handoffs stop. - */ -async function measurePackage( - url: string, - found: (document: PackageMemoryDocument) => void -) { - if (!isValidAutomergeUrl(url)) throw new Error(`not an automerge url: ${url}`); - const repo = await getRepo(); - const signal = AbortSignal.timeout(MEASURE_TIMEOUT_MS); - const release = async (id: DocumentId) => { - // This measurement is the only handoff in flight. - if (handoffsInFlight === 1 && repo.handles[id]) { - await repo.removeFromCache(id); - } - }; - const find = (id: AnyDocumentId) => { - return repo.find(id, { - signal: AbortSignal.any([ - signal, - AbortSignal.timeout(MEASURE_FIND_TIMEOUT_MS), - ]), - }); - }; - const tracked: Repo = Object.create(repo, { find: { value: find } }); - const { documentId, heads, hexHeads } = parseAutomergeUrl(url); - let root = await find(stringifyAutomergeUrl({ documentId })); - if (heads) { - if (!(await waitForHeads(root, hexHeads ?? [], signal))) { - throw new Error(`heads not found for ${url}`); - } - root = root.view(heads); - } else { - await caughtUpWithPeers(repo, root, signal); - const branch = getDefaultBranch(root.doc()); - if (branch) { - await release(root.documentId); - root = await find(branch); - await caughtUpWithPeers(repo, root, signal); - } - } - for await (const document of packageDocuments(tracked, root)) { - if ("error" in document) { - const { path, url, error } = document; - found({ - url, - path, - ops: 0, - changes: 0, - largestChange: 0, - error: error instanceof Error ? error.message : String(error), - }); - continue; - } - found(measureDocument(document.path, document.handle)); - await release(document.handle.documentId); - } -} - -function measureDocument( - path: string, - handle: DocHandle -): PackageMemoryDocument { - const doc = handle.fullDoc(); - const { numOps, numChanges } = stats(doc); - let largestChange = 0; - for (const { startOp, maxOp } of getChangesMetaSince(doc, [])) { - largestChange = Math.max(largestChange, maxOp - startOp + 1); - } - const content = (doc as { content?: unknown } | undefined)?.content; - return { - url: handle.url, - path, - ops: numOps, - changes: numChanges, - largestChange, - content: - typeof content === "string" - ? { kind: "text", length: content.length } - : isImmutableString(content) - ? { kind: "immutable", length: String(content).length } - : content instanceof Uint8Array - ? { kind: "bytes", length: content.byteLength } - : undefined, - }; -} +handoffChannel.postMessage({ type: "online" } satisfies HandoffOnlineMessage); diff --git a/core/bootloader/src/classic-sync.ts b/core/bootloader/src/classic-sync.ts new file mode 100644 index 000000000..3be225536 --- /dev/null +++ b/core/bootloader/src/classic-sync.ts @@ -0,0 +1,47 @@ +// Classic automerge-repo sync, in the tab's own Repo: a WebSocket to a legacy +// sync server, alongside the Subduction connection. +import type { Repo } from "@automerge/automerge-repo/slim"; +// Not the package root, which also brings in the server adapter and `ws`. +import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket/WebSocketClientAdapter"; + +import { + DEFAULT_CLASSIC_SYNC_SERVER, + readClassicSyncServer, +} from "./sync-config.js"; + +const connections = new WeakMap< + Repo, + { url: string; adapter: WebSocketClientAdapter; ready: Promise } +>(); + +/** + * Connect `repo` to the classic sync server at `server`, resolving once the + * socket is up. Connecting to the server it's already connected to returns + * the same connection; connecting to another drops the first. + */ +export function connectClassicSync( + repo: Repo, + server: string = readClassicSyncServer() +): Promise { + const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER; + if (!/^wss?:\/\//.test(url)) { + return Promise.reject( + new Error(`invalid classic sync server URL: ${server}`) + ); + } + const current = connections.get(repo); + if (current?.url === url) return current.ready; + if (current) repo.networkSubsystem.removeNetworkAdapter(current.adapter); + + const adapter = new WebSocketClientAdapter(url); + repo.networkSubsystem.addNetworkAdapter(adapter); + const ready = adapter.whenReady(); + connections.set(repo, { url, adapter, ready }); + // A later attempt starts over rather than waiting on this one. + ready.catch(() => { + if (connections.get(repo)?.adapter !== adapter) return; + repo.networkSubsystem.removeNetworkAdapter(adapter); + connections.delete(repo); + }); + return ready; +} diff --git a/core/bootloader/src/emitted.ts b/core/bootloader/src/emitted.ts new file mode 100644 index 000000000..a926585c1 --- /dev/null +++ b/core/bootloader/src/emitted.ts @@ -0,0 +1,15 @@ +// Files a build emits under content-hashed names, by the path the dev server +// serves each one at. @inkandswitch/patchwork's vite plugin swaps each path +// here for the URL of the file it emitted, so a build only ever loads its own +// copies: the browser can keep them forever, offline included, and a worker +// can't pick up a file from one build and the rest from another. Read them +// from here rather than writing the paths out, or a build fetches a path that +// only redirects to the file (see the plugin's _redirects). + +export const AUTOMERGE_WASM = "/automerge.wasm"; +export const SUBDUCTION_WASM = "/subduction.wasm"; +export const KEYHIVE_WASM = "/keyhive_wasm.wasm"; + +/** What the automerge worker's script imports once it holds the lock. */ +export const AUTOMERGE_PROTOCOL_HANDLER = "/automerge-protocol-handler.js"; +export const MODULE_LOADER_WORKER = "/module-loader-worker.js"; diff --git a/core/bootloader/src/externals.ts b/core/bootloader/src/externals.ts index 59a812735..d5fe85616 100644 --- a/core/bootloader/src/externals.ts +++ b/core/bootloader/src/externals.ts @@ -40,9 +40,9 @@ export async function resolveExternal( } /** - * automerge/keyhive/subduction's wasm binaries, by the file name the service - * worker fetches them under. Resolved from this package's node_modules for the - * same reason as {@link resolveExternal}. + * automerge/keyhive/subduction's wasm binaries, by the file name the dev + * server serves them under (see emitted.ts). Resolved from this package's + * node_modules for the same reason as {@link resolveExternal}. */ export function wasmAssets(): { fileName: string; path: string }[] { return [ @@ -61,13 +61,22 @@ export function wasmAssets(): { fileName: string; path: string }[] { ]; } -/** Emits automerge/keyhive/subduction's wasm binaries as build assets, so the service worker can fetch them. */ -export function emitWasmAssets(this: import("rollup").PluginContext): void { - for (const { fileName, path } of wasmAssets()) { - this.emitFile({ - type: "asset", +/** + * Emits automerge/keyhive/subduction's wasm binaries as build assets, under + * content-hashed names (the build's `assetFileNames`). Returns each one's + * reference id by its file name, for `this.getFileName`. + */ +export function emitWasmAssets( + this: import("rollup").PluginContext +): Map { + return new Map( + wasmAssets().map(({ fileName, path }) => [ fileName, - source: readFileSync(path), - }); - } + this.emitFile({ + type: "asset", + name: fileName, + source: readFileSync(path), + }), + ]) + ); } diff --git a/core/bootloader/src/module-loader.ts b/core/bootloader/src/module-loader.ts index 7722326e2..ae717ce52 100644 --- a/core/bootloader/src/module-loader.ts +++ b/core/bootloader/src/module-loader.ts @@ -9,6 +9,7 @@ import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem"; import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; +import { MODULE_LOADER_WORKER } from "./emitted.js"; type Descriptor = Record & { id?: string; type?: string }; @@ -16,8 +17,6 @@ type WorkerReply = | { type: "descriptors"; id: number; descriptors: Descriptor[] } | { type: "error"; id: number; error: string }; -const WORKER_PATH = "/module-loader-worker.js"; - let worker: Worker | undefined; let nextRequestId = 1; const pending = new Map< @@ -27,7 +26,7 @@ const pending = new Map< function getWorker(): Worker { if (worker) return worker; - worker = new Worker(WORKER_PATH, { + worker = new Worker(MODULE_LOADER_WORKER, { type: "module", name: "patchwork-module-loader", }); diff --git a/core/bootloader/src/resolver.ts b/core/bootloader/src/resolver.ts new file mode 100644 index 000000000..72e0f0395 --- /dev/null +++ b/core/bootloader/src/resolver.ts @@ -0,0 +1,311 @@ +// Turning a handoff request into a reply, given a Repo: find the automerge +// URL's document, resolve the path in it, and either write the response into +// the service worker's cache or hand it back inline. A tab answers the service +// worker with its own Repo this way (see tab-resolver.ts), and the automerge +// worker answers the broadcasts nobody else could (automerge-protocol-handler). +import { hasHeads } from "@automerge/automerge/slim"; +import { + isValidAutomergeUrl, + parseAutomergeUrl, + stringifyAutomergeUrl, + type DocHandle, + type Repo, + type StorageId, +} from "@automerge/automerge-repo/slim"; +import { + getDefaultBranch, + resolvePath, +} from "@inkandswitch/patchwork-filesystem"; + +import type { + HandoffReplyMessage, + HandoffRequestMessage, +} from "./types.js"; + +const RESOLVE_TIMEOUT_MS = 30_000; + +const CACHEABLE_STATUSES = [200, 203, 204]; + +const CATCH_UP_MS = 3_000; + +function waitForHeads( + handle: DocHandle, + hexHeads: string[], + signal: AbortSignal +): Promise { + if (hasHeads(handle.doc(), hexHeads)) return Promise.resolve(true); + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const cleanup = () => { + handle.off("heads-changed", check); + signal.removeEventListener("abort", onAbort); + }; + const check = () => { + if (!hasHeads(handle.doc(), hexHeads)) return; + cleanup(); + resolve(true); + }; + const onAbort = () => { + cleanup(); + resolve(false); + }; + handle.on("heads-changed", check); + signal.addEventListener("abort", onAbort); + // The heads may have landed between the check above and subscribing. + check(); + }); +} + +async function caughtUpWithPeers( + repo: Repo, + handle: DocHandle, + signal: AbortSignal +): Promise { + if (!repo.isSubductionConnected()) return; + const peers = (await repo.connectedSubductionPeerIds()) as StorageId[]; + const caughtUp = () => { + const states = peers.map((peer) => handle.isCaughtUpWith(peer)); + return ( + states.some((state) => state !== undefined) && !states.includes(false) + ); + }; + if (caughtUp() || signal.aborted) return; + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + handle.off("remote-heads", check); + handle.off("heads-changed", check); + signal.removeEventListener("abort", done); + resolve(); + }; + const check = () => { + if (caughtUp()) done(); + }; + const timer = setTimeout(done, CATCH_UP_MS); + handle.on("remote-heads", check); + handle.on("heads-changed", check); + signal.addEventListener("abort", done); + if (signal.aborted) done(); + else check(); + }); +} + +/** + * Thrown instead of returning a Response when the request should fail as a + * network error rather than resolve to something the caller can memoize. + * See {@link HandoffAbortMessage}. + */ +class AbortHandoff extends Error {} + +async function resolveAutomergeUrl( + repo: Repo, + automergeURL: URL, + signal: AbortSignal +): Promise { + const find = (id: Parameters[0]) => + repo.find(id, { signal }); + const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/"); + + if (!isValidAutomergeUrl(maybeAutomergeUrl)) { + return new Response("invalid automerge url", { status: 400 }); + } + + if (path.length && !path[path.length - 1]) path.pop(); + + const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl); + + // todo, maybe a bad idea? maybe we should throw instead of es-module-caching + // the headless req + if (!heads) { + let folder = await find(maybeAutomergeUrl); + await caughtUpWithPeers(repo, folder, signal); + // Packages used to be published as a branches doc, which stands for its + // default branch. The tab's ModuleWatcher looks packages up through here, + // so this is where that's followed. + const branch = getDefaultBranch(folder.doc()); + if (branch) { + folder = await find(branch); + await caughtUpWithPeers(repo, folder, signal); + } + const url = stringifyAutomergeUrl({ + documentId: folder.documentId, + heads: folder.heads(), + }); + const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`; + return Response.redirect(location, 307); + } + + const baseHandle = await find(stringifyAutomergeUrl({ documentId })); + if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) { + throw new AbortHandoff( + `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms` + ); + } + + const resolved = await resolvePath( + repo, + baseHandle.view(heads), + path.map(decodeURIComponent) + ); + if (!resolved) { + throw new Error( + `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}` + ); + } + + const body: BodyInit = + resolved.content instanceof Uint8Array + ? (new Uint8Array(resolved.content) as BlobPart) + : resolved.content; + + return new Response(body, { + status: 200, + headers: { + "content-type": resolved.type, + "cache-control": resolved.pinned + ? "public, max-age=31536000, immutable" + : "no-store", + }, + }); +} + +function impatience(limit: number) { + return new Promise((_, reject) => + setTimeout( + () => reject(new Error(`resolve timeout after ${limit}ms`)), + limit + ) + ); +} + +export type Resolver = { + /** + * Resolve `message`'s request and say how the service worker should answer + * it, having written the response into its cache if it's one to keep. + * Undefined if the special URL isn't an `automerge:` one. + */ + answer(message: HandoffRequestMessage): Promise; +}; + +export function createResolver( + getRepo: () => Promise, + log: (...args: unknown[]) => void = () => {} +): Resolver { + const resolveWithin = async (handoff: URL): Promise => { + const repo = await getRepo(); + const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS); + return Promise.race([ + resolveAutomergeUrl(repo, handoff, signal), + impatience(RESOLVE_TIMEOUT_MS), + ]); + }; + + /** Lookups of a url without heads in flight, by that url. */ + const latestLookups = new Map>(); + + /** + * Resolve `handoff`, sharing the lookup with any already in flight for the + * same url without heads. Every tab's ModuleWatcher asks for each package's + * latest heads, again each time it changes, and none of it is cached, since + * the answer moves; on a busy resolver those pile up into one full resolve + * each. One that joins late gets the shared lookup's answer, or its timeout, + * a little sooner than it would have its own. + */ + const resolveHandoff = (handoff: URL): Promise => { + const [maybeAutomergeUrl] = handoff.href.split("/"); + if ( + !isValidAutomergeUrl(maybeAutomergeUrl) || + parseAutomergeUrl(maybeAutomergeUrl).heads + ) { + return resolveWithin(handoff); + } + let lookup = latestLookups.get(handoff.href); + if (!lookup) { + lookup = resolveWithin(handoff).finally(() => { + latestLookups.delete(handoff.href); + }); + latestLookups.set(handoff.href, lookup); + } + // A redirect, so there's no body to share; each reply gets its own anyway. + return lookup.then((response) => response.clone()); + }; + + const inline = (id: string, status: number, body: string) => + ({ + id, + type: "response", + response: { status, body, headers: { "content-type": "text/plain" } }, + }) satisfies HandoffReplyMessage; + + return { + async answer({ id, cachename, request }) { + let handoff: URL; + try { + handoff = new URL(request.handoffURL); + } catch { + console.error("couldn't parse handoff url", request); + return inline(id, 400, `couldn't parse a special url out of ${request.url}`); + } + if (handoff.protocol !== "automerge:") return undefined; + + let response: Response; + try { + log(`resolving handoff ${id} for ${handoff}`); + response = await resolveHandoff(handoff); + } catch (error) { + if (error instanceof AbortHandoff) { + return { id, type: "abort", reason: error.message }; + } + console.error(`error resolving ${request.url}`, error); + return inline( + id, + 557, + error instanceof Error + ? `${error.message}\n\n${error.stack}` + : String(error) + ); + } + + try { + const immutable = + CACHEABLE_STATUSES.includes(response.status) && + !/(^|,)\s*no-store(\s*,|$)/.test( + response.headers.get("cache-control") ?? "" + ); + if (!immutable) { + // Errors, redirects and the like go back inline for the service + // worker to serve directly. Mutable responses aren't cached because + // the same URL can resolve to different content later. + log(`responding inline to ${request.url} with ${response.status}`); + return { + id, + type: "response", + response: { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: response.body + ? new Uint8Array(await response.arrayBuffer()) + : undefined, + }, + }; + } + + // Reconstruct the request the service worker is holding so the entry + // matches its cache.match. `destination` isn't constructible but + // doesn't participate in cache matching. + const cacheKey = new Request(request.url, { + method: request.method, + headers: request.headers, + referrer: request.referrer, + }); + const cache = await caches.open(cachename); + await cache.put(cacheKey, response); + log(`cached ${cacheKey.url} in ${cachename}`); + return { id, type: "cached" }; + } catch (error) { + console.error(`failed to reply for ${request.url}`, error); + return inline(id, 558, String(error)); + } + }, + }; +} diff --git a/core/bootloader/src/service-worker.ts b/core/bootloader/src/service-worker.ts index 261c77991..4a4878598 100644 --- a/core/bootloader/src/service-worker.ts +++ b/core/bootloader/src/service-worker.ts @@ -6,6 +6,8 @@ import { type AutomergeWorkerMissingMessage, type HandoffReplyMessage, type HandoffRequestMessage, + type ResolveAcceptedMessage, + type ResolveRequestMessage, } from "./types.js"; // Referenced so this script's bytes change with every build. The browser only // installs a replacement service worker when they do, and the controllerchange @@ -21,8 +23,8 @@ let debugging = false; // same no-cors consumer. these are big, so unfortunate. const CACHEABLE_STATUSES = [0, 200, 203, 204]; -// The automerge worker times its own resolution out after 30s and replies with -// an error, so this only fires when nobody is listening at all. +// A tab or the automerge worker times its own resolution out after 30s and +// replies with an error, so this only fires when nobody is answering at all. const HANDOFF_TIMEOUT_MS = 35_000; function log(...args: any[]) { @@ -222,14 +224,111 @@ async function startWorkerIfMissing(id: string) { /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */ class HandoffAborted extends Error {} +// How long a tab has to say it'll resolve a request before the service +// worker asks the next one. A tab that hasn't been handed its Repo yet says it +// can't at once, so this is for one that can't answer at all: a frozen +// background tab, or one from a build before tabs resolved. +const ACCEPT_TIMEOUT_MS = 250; +// A tab posts its reply and then lets go of its lock, but the lock can be +// granted here before the reply is delivered. +const REPLY_AFTER_LOCK_MS = 100; + +type TabAnswer = + | { answered: HandoffReplyMessage } + | { declined: "unavailable" | "silent" | "gone" }; + +/** + * The tabs that might resolve a request from `clientId`, best first: the tab + * that made it, which is likely to hold the documents already, then the + * focused tab, then visible ones. A request from a dedicated worker (the + * module loader) or a navigation has no window of its own. + */ +async function resolvingTabs(clientId: string): Promise { + const windows = (await self.clients.matchAll({ + type: "window", + })) as WindowClient[]; + const rank = (client: WindowClient) => + client.id === clientId + ? 0 + : client.focused + ? 1 + : client.visibilityState === "visible" + ? 2 + : 3; + return windows.sort((a, b) => rank(a) - rank(b)); +} + +/** + * Ask `tab` to resolve `message`, and wait for its answer until `deadline`, + * which bounds a tab that accepted and then hangs. + */ +function askTab( + tab: WindowClient, + message: HandoffRequestMessage, + deadline: number +): Promise { + const { port1, port2 } = new MessageChannel(); + const sentAt = performance.now(); + const timers: Array> = []; + const stopWaitingOnLock = new AbortController(); + return new Promise((resolve) => { + let accepted = false; + const silent = setTimeout( + () => resolve({ declined: "silent" }), + ACCEPT_TIMEOUT_MS + ); + timers.push( + silent, + setTimeout(() => resolve({ declined: "silent" }), deadline - Date.now()) + ); + port1.onmessage = ({ data }: MessageEvent) => { + if (data?.type === "unavailable") { + resolve({ declined: "unavailable" }); + return; + } + if (data?.type === "accepted") { + clearTimeout(silent); + accepted = true; + log( + `tab ${tab.id} accepted handoff ${message.id} after ${Math.round(performance.now() - sentAt)}ms` + ); + const { lock } = data as ResolveAcceptedMessage; + if (!lock || !navigator.locks) return; + navigator.locks + .request(lock, { signal: stopWaitingOnLock.signal }, () => { + timers.push( + setTimeout(() => resolve({ declined: "gone" }), REPLY_AFTER_LOCK_MS) + ); + }) + .catch(() => {}); + return; + } + if (accepted && data?.id === message.id) { + resolve({ answered: data as HandoffReplyMessage }); + } + }; + tab.postMessage( + { ...message, type: "resolve" } satisfies ResolveRequestMessage, + [port2] + ); + }).finally(() => { + for (const timer of timers) clearTimeout(timer); + stopWaitingOnLock.abort(); + port1.close(); + }); +} + +/** + * Get `request` resolved: by a tab if one can, since each tab is its own node + * with its own Repo, and failing that by the automerge worker. + */ async function handoff( - request: Request, + fetchEvent: FetchEvent, handoffURL: URL ): Promise { - const id = crypto.randomUUID(); - const resolvers = Promise.withResolvers(); + const request = fetchEvent.request; const message: HandoffRequestMessage = { - id, + id: crypto.randomUUID(), type: "request", cachename, request: { @@ -241,7 +340,23 @@ async function handoff( referrer: request.referrer, }, }; + const deadline = Date.now() + HANDOFF_TIMEOUT_MS; + for (const tab of await resolvingTabs(fetchEvent.clientId)) { + const answer = await askTab(tab, message, deadline); + if ("answered" in answer) return answer.answered; + log(`tab ${tab.id} ${answer.declined} for handoff ${message.id}`); + if (Date.now() >= deadline) break; + } + return broadcastHandoff(message, Math.max(0, deadline - Date.now())); +} +/** Leave `message` to the automerge worker, over HANDOFF_CHANNEL. */ +async function broadcastHandoff( + message: HandoffRequestMessage, + timeoutMs: number +): Promise { + const { id } = message; + const resolvers = Promise.withResolvers(); pendingHandoffs.set(id, { message, resolvers }); log(`broadcasting handoff request for cache ${cachename}`, message); handoffChannel.postMessage(message); @@ -250,14 +365,14 @@ async function handoff( const timeout = setTimeout(() => { lifecycle( "warn", - `asset handoff ${id} stranded: no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})` + `asset handoff ${id} stranded: no reply from a tab or the automerge worker after ${HANDOFF_TIMEOUT_MS}ms (${message.request.handoffURL})` ); resolvers.reject( new Error( - `no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms` + `no reply from a tab or the automerge worker after ${HANDOFF_TIMEOUT_MS}ms` ) ); - }, HANDOFF_TIMEOUT_MS); + }, timeoutMs); return resolvers.promise.finally(() => { clearTimeout(timeout); @@ -350,8 +465,8 @@ async function serveHandoff( return cached; } - log(`handing ${handoffURL} off to the automerge worker`); - const replyPromise = handoff(fetchEvent.request, handoffURL); + log(`handing ${handoffURL} off`); + const replyPromise = handoff(fetchEvent, handoffURL); fetchEvent.waitUntil(replyPromise.catch(() => {})); const reply = await replyPromise; @@ -373,7 +488,7 @@ async function serveHandoff( const stored = await cache.match(fetchEvent.request); if (!stored) { return new Response( - `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, + `${handoffURL} was reported cached, but it has no match in ${cachename}`, { status: 555 } ); } diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index c354a5ba5..0b1eb1335 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -5,15 +5,8 @@ import type { } from "./types.js"; import { buildId, isNewerBuild } from "./build-id.js"; import { promptForNewBuild } from "./new-build-prompt.js"; -import { - readClassicSyncServer, - DEFAULT_CLASSIC_SYNC_SERVER, -} from "./sync-config.js"; import debug from "debug"; -import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; -import { PortSyncRemote, getSedimentreeId } from "./sync-remote.js"; -import type { SyncConnectMessage } from "./sync-protocol.js"; -export { relayKeyhive, type PortSyncRemote } from "./sync-remote.js"; +import { handleResolveRequest } from "./tab-resolver.js"; import { forwardWorkerConsole, lifecycleLog, @@ -21,6 +14,8 @@ import { } from "./shared-worker-lifecycle.js"; export { lifecycleLog }; +export { resolveHandoffsWith } from "./tab-resolver.js"; +export { connectClassicSync } from "./classic-sync.js"; const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker"); const workerDebugging = debug.enabled("patchwork:automergeworker"); @@ -51,13 +46,16 @@ export function bumpServiceWorkerCache( } // The service worker has no localStorage, so it can't read the debug config — -// it always emits lifecycle markers and forwards them here to be filtered. +// it always emits lifecycle markers and forwards them here to be filtered. It +// also asks this tab to resolve requests (see tab-resolver.ts), which needs a +// listener from the start so a tab without a Repo yet can say so. let logForwardingInstalled = false; function installServiceWorkerLogForwarding(): void { if (logForwardingInstalled) return; if (typeof navigator === "undefined" || !navigator.serviceWorker) return; logForwardingInstalled = true; navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => { + if (handleResolveRequest(event)) return; if (event.data?.type === "automerge-worker-missing") { automergeProtocolHandlerWorker.revive(); return; @@ -65,6 +63,9 @@ function installServiceWorkerLogForwarding(): void { if (event.data?.type !== "sw-lifecycle") return; lifecycleLog("[service-worker] %s", event.data.msg); }); + // A listener added with addEventListener only hears the service worker once + // the document has loaded; a request to resolve can't wait for that. + navigator.serviceWorker.startMessages(); } // ── New builds ───────────────────────────────────────────────────────── @@ -99,11 +100,11 @@ function serviceWorkerBuild(sw: ServiceWorker): Promise { } // ── The automerge worker ─────────────────────────────────────────────── -// A SharedWorker holding the Repo that resolves `automerge:` URLs for the -// service worker, and the sync node every tab's Repo syncs through. Only a -// page can start a SharedWorker, so each tab keeps it alive and starts a new -// one when it goes: when it says it's closing, when its port closes, or when -// the Web Lock it holds for its lifetime is released (see +// A SharedWorker holding a Repo that resolves `automerge:` URLs for the +// service worker when no tab can. Tabs don't sync through it — each tab is its +// own node — but only a page can start a SharedWorker, so each tab keeps it +// alive and starts a new one when it goes: when it says it's closing, when its +// port closes, or when the Web Lock it holds for its lifetime is released (see // AUTOMERGE_WORKER_LOCK). let automergeProtocolHandlerWorkerPath = @@ -117,14 +118,7 @@ const automergeProtocolHandlerWorker = sharedWorkerHandle( onMessage(event) { forwardWorkerConsole("automerge-protocol-handler-worker", event.data); }, - onGone() { - // Its sync node went with it, port and all. - syncRemote?.reconnect(); - }, onRestarting(message) { - // Ask the replacement for a sync port now: posts are held until it's - // up, and this one's sync node is going away with it. - syncRemote?.reconnect(); // The worker restarts because a newer tab connected. If that build is // newer than ours too, we're the stale one. if (isNewerBuild(message.build)) { @@ -142,48 +136,6 @@ export function getAutomergeProtocolHandlerWorker(): SharedWorker { return automergeProtocolHandlerWorker.get(); } -let syncRemote: PortSyncRemote | undefined; - -/** - * This tab's connection to the sync node in the automerge worker, to pass to - * its Repo as `subductionRemote`. One per tab: it follows the worker across - * restarts. - */ -export function getSyncRemote(): PortSyncRemote { - syncRemote ??= new PortSyncRemote(() => { - const { port1, port2 } = new MessageChannel(); - automergeProtocolHandlerWorker.post( - { type: "sync-connect" } satisfies SyncConnectMessage, - [port2] - ); - return port1; - }); - return syncRemote; -} - -/** - * Call `onChange` whenever the document at `url` may have new heads, until the - * returned function is called. Nothing is loaded for it: the sync node keeps - * the document subscribed with the server and says when something arrives for - * it, whether from the server or from another tab. That's the way to follow a - * document whose contents you only need now and then. - * - * `seen` sums up what the sync node holds of the document: the same `seen` - * twice means nothing arrived in between. It comes straight after watching, - * and after the automerge worker restarts, whether or not anything moved. It - * isn't a version of the document: the node's heads are Subduction's, which - * can include ancestors of the Automerge heads, so find the document to know - * where it is. It's absent when the node doesn't say. - */ -export function watchDocument( - url: AutomergeUrl, - onChange: (seen?: string) => void -): () => void { - return getSyncRemote().watch(getSedimentreeId(url), (heads) => - onChange(heads && [...heads].sort().join("|")) - ); -} - /** Dev escape hatch: make the shared worker close so the next use respawns it. */ export function restartAutomergeProtocolHandlerWorker( reason = "restartAutomergeProtocolHandlerWorker() called" @@ -191,36 +143,6 @@ export function restartAutomergeProtocolHandlerWorker( automergeProtocolHandlerWorker.restart(reason); } -export function connectClassicSync( - server: string = readClassicSyncServer() -): Promise { - const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER; - if (!/^wss?:\/\//.test(url)) { - return Promise.reject( - new Error(`invalid classic sync server URL: ${server}`) - ); - } - - const { port1, port2 } = new MessageChannel(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - port1.close(); - reject(new Error("connect-classic-sync timeout")); - }, 30_000); - port1.onmessage = (event) => { - clearTimeout(timeout); - port1.close(); - if (event.data?.type === "connect-classic-sync-ready") resolve(); - else - reject(new Error(event.data?.error ?? "connect-classic-sync failed")); - }; - automergeProtocolHandlerWorker.post( - { type: "connect-classic-sync", server: url }, - [port2] - ); - }); -} - // ── Boot ─────────────────────────────────────────────────────────────── function waitForActive(reg: ServiceWorkerRegistration): Promise { @@ -303,7 +225,7 @@ export default async function setupServiceWorker( "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px" ); - return { shared, connectClassicSync }; + return { shared }; } (window as any).bumpServiceWorkerCache = bumpServiceWorkerCache; diff --git a/core/bootloader/src/signer.ts b/core/bootloader/src/signer.ts index 809b79ed2..a535f3e43 100644 --- a/core/bootloader/src/signer.ts +++ b/core/bootloader/src/signer.ts @@ -30,36 +30,3 @@ export async function loadOrCreateSigner( return MemorySigner.fromBytes(seed); }); } - -/** Where automerge-repo-keyhive keeps the identity's key pair, as JWK JSON. */ -const KEYHIVE_KEY_PAIR_KEY = ["active-key-pair-2"]; - -/** - * The Subduction signer every Repo on a keyhive site presents, read from the - * keyhive database without starting keyhive: automerge-repo-keyhive signs - * Subduction traffic with the keyhive identity's Ed25519 scalar - * (`constructSubductionSigner`), and the sync node has to sign as that same - * peer for the server to let it fetch and put. - * - * Every context that syncs through the node bootstraps keyhive first, which - * creates the key pair if there isn't one, so it's normally there already; - * otherwise this waits for it. - */ -export async function loadKeyhiveSigner( - keyhiveStorage: StorageAdapterInterface -): Promise { - for (;;) { - const stored = await keyhiveStorage.load(KEYHIVE_KEY_PAIR_KEY); - if (stored) { - const { privateKey } = JSON.parse(new TextDecoder().decode(stored)); - const base64 = privateKey.d.replace(/-/g, "+").replace(/_/g, "/"); - return MemorySigner.fromBytes( - Uint8Array.from( - atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), - (c) => c.charCodeAt(0) - ) - ); - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } -} diff --git a/core/bootloader/src/sync-config.ts b/core/bootloader/src/sync-config.ts index f0d178ab2..c2e61cca8 100644 --- a/core/bootloader/src/sync-config.ts +++ b/core/bootloader/src/sync-config.ts @@ -21,8 +21,3 @@ export function readClassicSyncServer( } return override; } - -export type ConnectClassicSyncMessage = { - type: "connect-classic-sync"; - server: string; -}; diff --git a/core/bootloader/src/sync-node.ts b/core/bootloader/src/sync-node.ts deleted file mode 100644 index 4998c5122..000000000 --- a/core/bootloader/src/sync-node.ts +++ /dev/null @@ -1,749 +0,0 @@ -// The origin's one networked Subduction node. It lives in the automerge -// protocol handler SharedWorker and holds the only connections to the sync -// server; every Repo on the origin (each tab's, and the worker's own resolver) -// syncs through it as a SubductionRemote over a MessagePort. -// -// The Repos and the node share one IndexedDB, in the layout automerge-repo's -// SubductionStorageBridge writes, so neither side ships document data to the -// other just to persist it: -// -// - A Repo saves its own commits straight to the database and then tells the -// node which records it stored. The node's in-memory sedimentree can't see -// writes made after it was hydrated, so it reads those records back and -// stores them into its own tree; the sync round the Repo asks for next is -// what pushes them. -// - What peers send, the node persists through the same bridge and forwards -// to the Repos holding that document, which apply it the way they'd apply a -// commit from their own connection. -// - A Repo can also watch a document it doesn't hold. The node keeps it -// subscribed with the server and says `changed` when anything arrives for -// it, without sending the data, so the Repo can leave it unloaded until it -// wants the new heads. -// -// On keyhive sites the documents are encrypted and the node never decrypts -// them: it stores and forwards the ciphertext the Repos wrote and the server -// sent. Each Repo's keyhive keeps syncing with the server itself, over the -// node's connection: the node relays keyhive frames both ways. The server -// sees one peer either way (every context signs as the keyhive identity), so -// a frame it sends goes to every listening Repo, as it would have reached -// whichever of their sockets the server picked. -import { - CommitId, - CommitInput, - FragmentInput, - PeerId, - SedimentreeId, - Subduction, - Topic, -} from "@automerge/automerge-subduction/slim"; -import { - SubductionConnections, - SubductionStorageBridge, - WebSocketEndpoint, - type StorageAdapterInterface, - type StorageKey, - type WebSocketEndpointInterface, -} from "@automerge/automerge-repo/slim"; - -import type { - NodeMessage, - RecordKind, - RemoteRequest, - SyncResult, -} from "./sync-protocol.js"; - -type DataRecord = { kind: RecordKind; id: string; blob: Uint8Array }; - -type Client = { - port: MessagePort; - /** Sedimentrees this Repo holds, which it wants to hear about. */ - interests: Set; - /** Sedimentrees this Repo wants `changed` for, held or not. */ - watching: Set; - ephemeral: Set; - keyhive: boolean; - /** Per sedimentree, the tick of this Repo's last write the node heard of. */ - wrote: Map; -}; - -type Round = { result: Promise; startedAt: number }; - -export type SyncNodeOptions = { - /** Must be the same identity the Repos sign with. */ - signer: unknown; - /** The database the Repos use. */ - storage: StorageAdapterInterface; - /** The bridge's key prefix; the interceptor prefix on keyhive sites. */ - storagePrefix?: string; - endpoints: Array; - log: (...args: unknown[]) => void; -}; - -const sedimentreeId = (sid: string): SedimentreeId => - SedimentreeId.fromBytes( - Uint8Array.from(sid.match(/../g) ?? [], (byte) => parseInt(byte, 16)) - ); - -// A watched document's round has no Repo waiting on it to pick a deadline. -const WATCH_SYNC_TIMEOUT_MS = 60_000; - -const topic = (sid: string) => Topic.fromBytes(sedimentreeId(sid).toBytes()); - -export class SyncNode { - readonly subduction: Subduction; - readonly #bridge: SubductionStorageBridge; - readonly #connections: SubductionConnections; - readonly #log: (...args: unknown[]) => void; - readonly #storagePrefix: string | undefined; - readonly #clients = new Set(); - /** Per sedimentree, the tail of the chain of reads-back of Repo writes. */ - readonly #ingesting = new Map>(); - /** - * Per sedimentree, Repo writes whose read-back failed. A Repo says it - * stored something only once, and a node that has already hydrated a - * sedimentree won't look in the database for it again, so these are tried - * again before the next round rather than left out of what it pushes. - */ - readonly #unread = new Map< - string, - Array<{ from: Client; records: Array<{ kind: RecordKind; id: string }> }> - >(); - /** - * `${sid}:${id}` of records being stored back from a Repo's write. The - * bridge announces them like anything a peer sent; they're forwarded from - * the read-back instead, so the Repo that wrote them can be left out. - */ - readonly #echoes = new Set(); - /** Per sedimentree, how many reads-back are storing records into it. */ - readonly #echoing = new Map(); - /** Peer deliveries waiting for this microtask's flush, per sedimentree. */ - readonly #outbound = new Map(); - /** - * Sedimentrees to tell watchers about shortly, each with whether to stay - * quiet when this node doesn't know its heads, and the records that moved - * it, which its heads have to include before they're worth sending. - */ - readonly #changed = new Map< - string, - { ifKnown: boolean; records: Array<{ kind: RecordKind; id: string }> } - >(); - readonly #ephemeralSubscribers = new Map(); - /** Per sedimentree, the round with the server in progress. */ - readonly #rounds = new Map(); - /** Orders Repo writes against round starts. */ - #tick = 0; - /** Per sedimentree, the round queued behind it, shared by every asker. */ - readonly #queued = new Map>(); - /** - * Sedimentrees the server has said it will push to us, since we last - * connected. A round subscribes; a dropped connection forgets them all. - */ - readonly #subscribed = new Set(); - #connected = false; - - constructor({ - signer, - storage, - storagePrefix, - endpoints, - log, - }: SyncNodeOptions) { - this.#log = log; - this.#storagePrefix = storagePrefix; - this.#bridge = new SubductionStorageBridge( - this.#skippingEchoes(storage), - storagePrefix - ); - this.subduction = new Subduction({ - signer: signer as any, - storage: this.#bridge, - onRemoteHeads: ( - id: SedimentreeId, - peer: { toString(): string }, - heads: Array<{ toHexString(): string }> - ) => { - const sid = id.toString(); - this.#sendToInterested(sid, { - type: "heads", - sid, - peerId: peer.toString(), - heads: heads.map((head) => head.toHexString()), - }); - }, - onEphemeral: ( - id: Topic, - sender: { toString(): string }, - payload: Uint8Array - ) => { - // Topic.toString() returns an abbreviated hex ("3449915b…"); the sid - // it was subscribed under is the full hex of the sedimentree id with - // the exact same bytes. - const sid = SedimentreeId.fromBytes(id.toBytes()).toString(); - const message: NodeMessage = { - type: "ephemeral", - sid, - senderId: sender.toString(), - payload, - }; - for (const client of this.#clients) { - if (client.ephemeral.has(sid)) this.#send(client, message); - } - }, - }); - - void this.subduction.registerFrameHandler({ - onMessage: (payload: Uint8Array, peer: PeerId) => - this.#sendToKeyhive({ - type: "keyhive-frame", - peer: peer.toBytes(), - payload, - }), - onPeerDisconnect: (peer: PeerId) => - this.#sendToKeyhive({ - type: "keyhive-peer-disconnected", - peer: peer.toBytes(), - }), - }); - - this.#bridge.on("commit-saved", (id, commitId, blob) => - this.#saved(id.toString(), "commit", commitId.toHexString(), blob) - ); - this.#bridge.on("fragment-saved", (id, head, blob) => - this.#saved(id.toString(), "fragment", head.toHexString(), blob) - ); - - this.#connections = new SubductionConnections( - Promise.resolve(this.subduction) - ); - this.#connections.onChange(() => { - const state = this.#state(); - this.#log("sync node connection state", state); - for (const client of this.#clients) this.#send(client, state); - this.#handleConnectionChange(); - }); - for (const endpoint of endpoints) { - void this.#connections.manageConnection( - typeof endpoint === "string" - ? new WebSocketEndpoint(endpoint) - : endpoint - ); - } - } - - /** Serve a Repo over `port` until it closes. */ - connect(port: MessagePort): void { - const client: Client = { - port, - interests: new Set(), - watching: new Set(), - ephemeral: new Set(), - wrote: new Map(), - keyhive: false, - }; - this.#clients.add(client); - port.addEventListener("message", (event) => { - void this.#handle(client, event.data as RemoteRequest); - }); - // Fires when the tab holding the other end goes away. - port.addEventListener("close", () => this.#disconnect(client)); - port.start(); - this.#send(client, this.#state()); - } - - /** Persist everything stored back so far, and stop reconnecting. */ - async shutdown(): Promise { - this.#connections.shutdown(); - await Promise.all(this.#ingesting.values()); - await this.#bridge.awaitSettled(); - for (const client of this.#clients) client.port.close(); - this.#clients.clear(); - } - - #state(): NodeMessage { - return { - type: "state", - connecting: this.#connections.isConnecting(), - connected: this.#connections.isConnected(), - }; - } - - #disconnect(client: Client) { - if (!this.#clients.delete(client)) return; - for (const sid of client.ephemeral) void this.#unsubscribeEphemeral(sid); - } - - #send(client: Client, message: NodeMessage) { - try { - client.port.postMessage(message); - } catch (error) { - console.warn("sync node: couldn't reach a Repo, dropping it", error); - this.#disconnect(client); - } - } - - #sendToKeyhive(message: NodeMessage) { - for (const client of this.#clients) { - if (client.keyhive) this.#send(client, message); - } - } - - /** - * Tell every Repo watching `sid` that it may have new heads, and what they - * are, so it can tell for itself whether they've moved. With `ifKnown` - * (answering a watch), say nothing for a sedimentree this node has no - * heads for: there's nothing to report until a round brings some. - * `records` are what arrived, if anything did. - */ - #sendChanged( - sid: string, - { - ifKnown = false, - records = [], - }: { - ifKnown?: boolean; - records?: Array<{ kind: RecordKind; id: string }>; - } = {} - ) { - if (![...this.#clients].some((client) => client.watching.has(sid))) return; - // Not a microtask: a record is stored before subduction adds it to the - // tree, and the heads are read from the tree. - if (this.#changed.size === 0) { - setTimeout(() => - this.#announceChanged().catch((error) => - this.#log("couldn't announce changes", error) - ) - ); - } - const queued = this.#changed.get(sid); - this.#changed.set(sid, { - ifKnown: ifKnown && (queued?.ifKnown ?? true), - records: [...(queued?.records ?? []), ...records], - }); - } - - /** - * Whether the tree has every one of `records` in it yet, so that its heads - * account for them. Heads read before then could be the ones from before - * they arrived, which would tell a watcher nothing moved. - */ - async #treeHas( - sid: string, - records: Array<{ kind: RecordKind; id: string }> - ): Promise { - if (records.length === 0) return true; - const id = sedimentreeId(sid); - const [commits, fragments] = await Promise.all([ - this.subduction.getCommits(id), - this.subduction.getFragments(id), - ]); - const known = new Set([ - ...(commits ?? []).map((commit) => commit.commitId.toHexString()), - ...(fragments ?? []).map((fragment) => fragment.head.toHexString()), - ]); - return records.every((record) => known.has(record.id)); - } - - // One getAllHeads for everything queued: a restarted worker's tabs watch - // every package again at once. - async #announceChanged() { - const changed = new Map(this.#changed); - this.#changed.clear(); - const heads = new Map(); - // Without heads a change still goes out, and the Repo looks for itself. - const trees = await this.subduction.getAllHeads().catch((error) => { - this.#log("couldn't read heads", error); - return []; - }); - for (const tree of trees) { - const sid = tree.id.toString(); - // An empty list is a sedimentree with no heads yet. - if (changed.has(sid) && tree.heads.length > 0) { - heads.set( - sid, - tree.heads.map((head) => head.toHexString()) - ); - } - } - for (const [sid, { ifKnown, records }] of changed) { - let known = heads.get(sid); - if (!known && ifKnown) continue; - // Without heads the watcher looks for itself, which is right either way. - if (known && !(await this.#treeHas(sid, records).catch(() => false))) { - known = undefined; - } - const message: NodeMessage = { type: "changed", sid, heads: known }; - for (const client of this.#clients) { - if (client.watching.has(sid)) this.#send(client, message); - } - } - } - - /** - * The Repos holding a document resync it themselves when the connection - * comes back. Nobody holds a watched one, so the node resubscribes it. - */ - #handleConnectionChange() { - const connected = this.#connections.isConnected(); - if (connected === this.#connected) return; - this.#connected = connected; - if (!connected) { - this.#subscribed.clear(); - return; - } - const watched = new Set(); - for (const client of this.#clients) { - for (const sid of client.watching) watched.add(sid); - } - for (const sid of watched) this.#subscribe(sid); - } - - /** - * Run a round for `sid` unless the server is already pushing it to us. A - * round already running may have started before the connection was up, so - * look again once it's done, but only once: a round the server turns down - * would otherwise be asked for again forever. - */ - #subscribe(sid: string, waitForRunning = true) { - if (this.#subscribed.has(sid)) return; - const running = this.#rounds.get(sid); - if (running) { - if (waitForRunning) { - void running.result - .catch(() => {}) - .then(() => this.#subscribe(sid, false)); - } - return; - } - this.#startRound(sid, WATCH_SYNC_TIMEOUT_MS).catch((error) => - this.#log(`couldn't subscribe to ${sid}`, error) - ); - } - - #sendToInterested(sid: string, message: NodeMessage, except?: Client) { - for (const client of this.#clients) { - if (client !== except && client.interests.has(sid)) { - this.#send(client, message); - } - } - } - - async #handle(client: Client, request: RemoteRequest) { - switch (request.type) { - case "sync": { - client.interests.add(request.sid); - await this.#reply(client, request.id, () => - this.#sync(client, request.sid, request.timeoutMs) - ); - return; - } - case "peers": { - await this.#reply(client, request.id, async () => - (await this.subduction.getConnectedPeerIds()).map(String) - ); - return; - } - case "keyhive-peers": { - await this.#reply(client, request.id, async () => - (await this.subduction.getConnectedPeerIds()).map((peer) => - peer.toBytes() - ) - ); - return; - } - case "keyhive-listen": - client.keyhive = true; - return; - case "keyhive-unlisten": - client.keyhive = false; - return; - case "keyhive-send": - await this.subduction - .sendKeyhiveMessage(request.payload, new PeerId(request.peer)) - .catch((error) => this.#log("keyhive send failed", error)); - return; - case "stored": - client.interests.add(request.sid); - client.wrote.set(request.sid, ++this.#tick); - this.#ingest(client, request.sid, request.records); - return; - case "release": - client.interests.delete(request.sid); - client.wrote.delete(request.sid); - return; - case "watch": - client.watching.add(request.sid); - this.#sendChanged(request.sid, { ifKnown: true }); - this.#subscribe(request.sid); - return; - case "unwatch": - client.watching.delete(request.sid); - return; - case "ephemeral-subscribe": - if (client.ephemeral.has(request.sid)) return; - client.ephemeral.add(request.sid); - await this.#subscribeEphemeral(request.sid); - return; - case "ephemeral-unsubscribe": - if (!client.ephemeral.delete(request.sid)) return; - await this.#unsubscribeEphemeral(request.sid); - return; - case "ephemeral-publish": { - const { sid, payload } = request; - // The server doesn't echo it back to us, so the other Repos on the - // origin hear it from here. - const message: NodeMessage = { - type: "ephemeral", - sid, - senderId: "local", - payload, - }; - for (const other of this.#clients) { - if (other !== client && other.ephemeral.has(sid)) { - this.#send(other, message); - } - } - await this.subduction - .publishEphemeral(topic(sid), payload) - .catch((error) => this.#log("ephemeral publish failed", error)); - return; - } - } - } - - async #reply(client: Client, id: number, run: () => Promise) { - try { - this.#send(client, { type: "reply", id, value: await run() }); - } catch (error) { - this.#send(client, { type: "reply", id, error: String(error) }); - } - } - - /** - * Every Repo holding a document asks for its own rounds, and tabs opening - * the same document all ask at once. A Repo that hasn't written since the - * running round started shares that round. One that has waits for the - * next, since the running round's tree was read before that write was - * stored, and every such Repo shares the next one. So a crowd of tabs - * opening a document costs one round, and a crowd of writers at most two. - * The queued round runs with the first asker's timeout. - */ - #sync( - client: Client, - sid: string, - timeoutMs: number | null - ): Promise { - const running = this.#rounds.get(sid); - if (!running) return this.#startRound(sid, timeoutMs); - if ((client.wrote.get(sid) ?? 0) <= running.startedAt) { - return running.result; - } - const queued = this.#queued.get(sid); - if (queued) return queued; - const next = running.result - .catch(() => {}) - .then(() => { - this.#queued.delete(sid); - return this.#startRound(sid, timeoutMs); - }); - this.#queued.set(sid, next); - return next; - } - - #startRound(sid: string, timeoutMs: number | null) { - const round: Round = { - result: this.#round(sid, timeoutMs), - startedAt: this.#tick, - }; - this.#rounds.set(sid, round); - void round.result - .catch(() => {}) - .finally(() => { - if (this.#rounds.get(sid) === round) this.#rounds.delete(sid); - }); - return round.result; - } - - async #round(sid: string, timeoutMs: number | null): Promise { - // Whatever a Repo stored before asking has to be in the tree the round - // reads, or the round pushes around it. - const unread = this.#unread.get(sid); - this.#unread.delete(sid); - for (const { from, records } of unread ?? []) { - this.#ingest(from, sid, records); - } - await this.#ingesting.get(sid); - const results = await this.subduction.syncWithAllPeers( - sedimentreeId(sid), - true, - timeoutMs - ); - // The Repo reads what arrived out of the database as soon as we reply. - await this.#bridge.awaitSettled([sid]); - this.#flush(sid); - // What did arrive is worth having, but this round didn't push everything - // the Repos wrote, so it isn't a success: the Repo asks again later, and - // the next round tries those writes again. - if (this.#unread.has(sid)) { - throw new Error(`sync node: some writes to ${sid} aren't picked up yet`); - } - const mapped = results.entries().map((result) => ({ - success: result.success, - transportErrors: result.transportErrors?.map(String), - stats: result.stats && { - commitsReceived: result.stats.commitsReceived, - fragmentsReceived: result.stats.fragmentsReceived, - }, - })); - if (mapped.some((result) => result.success)) this.#subscribed.add(sid); - return mapped; - } - - #ingest( - from: Client, - sid: string, - records: Array<{ kind: RecordKind; id: string }> - ) { - const previous = this.#ingesting.get(sid) ?? Promise.resolve(); - const next = previous - .then(() => this.#storeBack(from, sid, records)) - .catch((error) => { - console.error(`sync node: couldn't pick up writes to ${sid}`, error); - let unread = this.#unread.get(sid); - if (!unread) this.#unread.set(sid, (unread = [])); - unread.push({ from, records }); - }); - this.#ingesting.set(sid, next); - void next.finally(() => { - if (this.#ingesting.get(sid) === next) this.#ingesting.delete(sid); - }); - } - - /** - * Store a Repo's records into this node's tree. They're signed by the - * identity this node shares with every Repo, so storing them again writes - * the same bytes to the same keys. - */ - async #storeBack( - from: Client, - sid: string, - records: Array<{ kind: RecordKind; id: string }> - ) { - const id = sedimentreeId(sid); - const commits: CommitInput[] = []; - const fragments: FragmentInput[] = []; - const forward: DataRecord[] = []; - for (const record of records) { - const commitId = CommitId.fromHexString(record.id); - // Gone means compacted away: automerge absorbed it into a fragment, - // which the Repo reports separately. - if (record.kind === "commit") { - const stored = await this.#bridge.loadCommit(id, commitId); - if (!stored) continue; - commits.push(new CommitInput(stored.signed.payload, stored.blob)); - forward.push({ ...record, blob: stored.blob }); - } else { - const stored = await this.#bridge.loadFragment(id, commitId); - if (!stored) continue; - fragments.push(new FragmentInput(stored.signed.payload, stored.blob)); - forward.push({ ...record, blob: stored.blob }); - } - } - if (forward.length === 0) return; - // The writer already persisted these, so the other Repos holding this - // document can have them now rather than after this node's own store, - // and rather than by reloading the whole document when the writer's - // heads announcement lands. - this.#sendToInterested(sid, { type: "data", sid, records: forward }, from); - // The writer too: what watches a document in a Repo isn't what holds it. - this.#sendChanged(sid, { records: forward }); - - const keys = forward.map((record) => `${sid}:${record.id}`); - for (const key of keys) this.#echoes.add(key); - this.#echoing.set(sid, (this.#echoing.get(sid) ?? 0) + 1); - try { - await this.subduction.storeBuiltBatch(id, commits, fragments); - await this.#bridge.awaitSettled([sid]); - } finally { - for (const key of keys) this.#echoes.delete(key); - const left = this.#echoing.get(sid)! - 1; - if (left) this.#echoing.set(sid, left); - else this.#echoing.delete(sid); - } - } - - /** - * The bridge's storage, minus the writes of records being stored back. - * The Repo already wrote them, and this node signs as the same identity - * with deterministic signatures, so they'd be the same bytes under the - * same keys: what's left of storing back is getting them into this node's - * tree, without four sequential writes holding up the round behind it. - * The sedimentree's id marker is skipped too while it's being stored back - * into, since the Repo's save wrote it first. - */ - #skippingEchoes(storage: StorageAdapterInterface): StorageAdapterInterface { - // The bridge's default prefix. Were it ever to drift, nothing would be - // skipped, which is only slower. - const prefix = this.#storagePrefix ?? "subduction"; - const echoed = (key: StorageKey) => { - if (key[0] !== prefix) return false; - if (key.length === 4) return this.#echoes.has(`${key[2]}:${key[3]}`); - return key.length === 3 && key[1] === "ids" && this.#echoing.has(key[2]); - }; - return { - load: (key) => storage.load(key), - loadRange: (prefix) => storage.loadRange(prefix), - remove: (key) => storage.remove(key), - removeRange: (prefix) => storage.removeRange(prefix), - save: async (key, data) => { - if (!echoed(key)) await storage.save(key, data); - }, - saveBatch: async (entries) => { - const kept = entries.filter(([key]) => !echoed(key)); - if (kept.length === 0) return; - if (storage.saveBatch) await storage.saveBatch(kept); - else for (const [key, data] of kept) await storage.save(key, data); - }, - }; - } - - #saved(sid: string, kind: RecordKind, id: string, blob: Uint8Array) { - if (this.#echoes.has(`${sid}:${id}`)) return; - let queued = this.#outbound.get(sid); - if (!queued) { - queued = []; - this.#outbound.set(sid, queued); - queueMicrotask(() => this.#flush(sid)); - } - queued.push({ kind, id, blob }); - } - - #flush(sid: string) { - const records = this.#outbound.get(sid); - if (!records) return; - this.#outbound.delete(sid); - this.#sendToInterested(sid, { type: "data", sid, records }); - this.#sendChanged(sid, { records }); - } - - async #subscribeEphemeral(sid: string) { - const count = this.#ephemeralSubscribers.get(sid) ?? 0; - this.#ephemeralSubscribers.set(sid, count + 1); - if (count > 0) return; - await this.subduction - .subscribeEphemeral([topic(sid)]) - .catch((error) => this.#log("ephemeral subscribe failed", error)); - } - - async #unsubscribeEphemeral(sid: string) { - const count = this.#ephemeralSubscribers.get(sid) ?? 0; - if (count > 1) { - this.#ephemeralSubscribers.set(sid, count - 1); - return; - } - this.#ephemeralSubscribers.delete(sid); - if (count === 0) return; - await this.subduction - .unsubscribeEphemeral([topic(sid)]) - .catch((error) => this.#log("ephemeral unsubscribe failed", error)); - } -} diff --git a/core/bootloader/src/sync-protocol.ts b/core/bootloader/src/sync-protocol.ts deleted file mode 100644 index d44a27f64..000000000 --- a/core/bootloader/src/sync-protocol.ts +++ /dev/null @@ -1,66 +0,0 @@ -// What a Repo and the sync node say to each other over a MessagePort. The -// Repo's half is a SubductionRemote (sync-remote.ts); the node's is -// sync-node.ts. Sedimentree ids travel as `SedimentreeId.toString()`, commit -// ids and heads as hex, Subduction peer ids as their bytes. - -export type RecordKind = "commit" | "fragment"; - -export type SyncResult = { - success: boolean; - transportErrors?: string[]; - stats?: { commitsReceived: number; fragmentsReceived: number }; -}; - -/** Repo → node. Those carrying an `id` get a {@link ReplyMessage}. */ -export type RemoteRequest = - | { type: "sync"; id: number; sid: string; timeoutMs: number | null } - | { type: "peers"; id: number } - | { type: "keyhive-peers"; id: number } - | { - type: "stored"; - sid: string; - records: Array<{ kind: RecordKind; id: string }>; - } - | { type: "release"; sid: string } - /** - * Hear when a sedimentree may have new heads, without holding it: the node - * says `changed` and sends no data, so the Repo needn't load the document. - * It says so straight away too, with the heads it has, if it has any. - */ - | { type: "watch"; sid: string } - | { type: "unwatch"; sid: string } - | { type: "ephemeral-subscribe"; sid: string } - | { type: "ephemeral-unsubscribe"; sid: string } - | { type: "ephemeral-publish"; sid: string; payload: Uint8Array } - /** Start (or stop) hearing the keyhive frames the node receives. */ - | { type: "keyhive-listen" } - | { type: "keyhive-unlisten" } - | { type: "keyhive-send"; peer: Uint8Array; payload: Uint8Array }; - -export type ReplyMessage = - | { type: "reply"; id: number; value: unknown } - | { type: "reply"; id: number; error: string }; - -/** Node → Repo. */ -export type NodeMessage = - | ReplyMessage - | { type: "state"; connecting: boolean; connected: boolean } - | { - type: "data"; - sid: string; - records: Array<{ kind: RecordKind; id: string; blob: Uint8Array }>; - } - | { type: "heads"; sid: string; peerId: string; heads: string[] } - /** - * `heads` are the node's own heads for the sedimentree, as hex. They're - * Subduction's heads, not the document's: they can include ancestors of - * its automerge heads. The same heads twice means nothing arrived. Absent - * from a node that doesn't know them (or predates them). - */ - | { type: "changed"; sid: string; heads?: string[] } - | { type: "ephemeral"; sid: string; senderId: string; payload: Uint8Array } - | { type: "keyhive-frame"; peer: Uint8Array; payload: Uint8Array } - | { type: "keyhive-peer-disconnected"; peer: Uint8Array }; - -/** Tab → shared worker, on its control port, carrying the Repo's end. */ -export type SyncConnectMessage = { type: "sync-connect" }; diff --git a/core/bootloader/src/sync-remote.ts b/core/bootloader/src/sync-remote.ts deleted file mode 100644 index da5334455..000000000 --- a/core/bootloader/src/sync-remote.ts +++ /dev/null @@ -1,337 +0,0 @@ -// A Repo's end of the sync node (sync-node.ts): a SubductionRemote over a -// MessagePort. The port can die under it — the SharedWorker restarts after a -// deploy, or the browser terminates it — so it is opened through `open`, and -// `reconnect()` swaps in a fresh one. -import { PeerId } from "@automerge/automerge-subduction/slim"; -import { - parseAutomergeUrl, - type AutomergeUrl, - type SubductionRemote, - type SubductionRemoteHandlers, - type SubductionRemoteSyncResult, -} from "@automerge/automerge-repo/slim"; - -import type { - NodeMessage, - RecordKind, - RemoteRequest, -} from "./sync-protocol.js"; - -/** What ARK's keyhive sync calls on what it takes to be the Repo's Subduction. */ -type KeyhiveFrameHandler = { - onMessage(payload: Uint8Array, peer: PeerId): void; - onPeerDisconnect(peer: PeerId): void; -}; - -export type KeyhiveRelay = { - getConnectedPeerIds(): Promise; - registerFrameHandler(handler?: KeyhiveFrameHandler | null): Promise; - sendKeyhiveMessage(payload: Uint8Array, peer: PeerId): Promise; -}; - -type WithoutId = T extends unknown ? Omit : never; - -type Pending = { - resolve: (value: any) => void; - reject: (error: Error) => void; - /** What a request settles to when its port dies under it. */ - orphaned: () => void; -}; - -export class PortSyncRemote implements SubductionRemote { - readonly #open: () => MessagePort; - #port: MessagePort | null = null; - #handlers: SubductionRemoteHandlers | null = null; - #onChange: (() => void) | null = null; - #shutdown = false; - // Until the node says otherwise it is presumed to be connecting, so a - // Repo's first queries wait for it rather than reporting unavailable. - #nodeConnecting = true; - #nodeConnected = false; - #generation = 0; - #nextId = 0; - readonly #pending = new Map(); - readonly #ephemeral = new Set(); - /** Per sedimentree, who wants to hear it may have changed. */ - readonly #watchers = new Map void>>(); - #keyhiveHandler: KeyhiveFrameHandler | null = null; - /** - * Writes the node hasn't heard about because there was no port to tell it - * on. A replacement node hydrates from the database, but only the first - * time it touches a sedimentree, so these still have to be sent. - */ - #unsent: Array> = []; - - constructor(open: () => MessagePort) { - this.#open = open; - this.reconnect(); - } - - /** Drop the current port, if any, and open a new one. */ - reconnect(): void { - if (this.#shutdown) return; - this.#closePort(); - const port = this.#open(); - this.#port = port; - port.addEventListener("message", (event) => - this.#receive(port, event.data as NodeMessage) - ); - port.addEventListener("close", () => { - if (this.#port === port) this.reconnect(); - }); - port.start(); - for (const sid of this.#ephemeral) { - this.#post({ type: "ephemeral-subscribe", sid }); - } - // A replacement node answers each watch with the heads it has, which - // include whatever the node before it stored, so a watcher whose - // document moved while there was no node to say so hears it then. - for (const sid of this.#watchers.keys()) { - this.#post({ type: "watch", sid }); - } - if (this.#keyhiveHandler) this.#post({ type: "keyhive-listen" }); - const unsent = this.#unsent; - this.#unsent = []; - for (const message of unsent) this.#post(message); - } - - #closePort() { - const port = this.#port; - if (!port) return; - this.#port = null; - port.close(); - for (const pending of this.#pending.values()) pending.orphaned(); - this.#pending.clear(); - this.#setState(true, false); - } - - #post(message: RemoteRequest): boolean { - if (!this.#port) return false; - try { - this.#port.postMessage(message); - return true; - } catch (error) { - console.warn("sync remote: couldn't reach the sync node", error); - return false; - } - } - - #request( - message: WithoutId>, - orphaned: T - ): Promise { - const id = this.#nextId++; - return new Promise((resolve, reject) => { - const pending = { resolve, reject, orphaned: () => resolve(orphaned) }; - this.#pending.set(id, pending); - if (!this.#post({ ...message, id } as RemoteRequest)) { - this.#pending.delete(id); - pending.orphaned(); - } - }); - } - - #receive(port: MessagePort, message: NodeMessage) { - if (port !== this.#port) return; - switch (message.type) { - case "reply": { - const pending = this.#pending.get(message.id); - if (!pending) return; - this.#pending.delete(message.id); - if ("error" in message) pending.reject(new Error(message.error)); - else pending.resolve(message.value); - return; - } - case "state": - this.#setState(message.connecting, message.connected); - return; - case "data": - this.#handlers?.onData(message.sid, message.records); - return; - case "heads": - this.#handlers?.onRemoteHeads( - message.sid, - message.peerId, - message.heads - ); - return; - case "changed": - for (const onChange of this.#watchers.get(message.sid) ?? []) { - onChange(message.heads); - } - return; - case "ephemeral": - this.#handlers?.onEphemeral( - message.sid, - message.senderId, - message.payload - ); - return; - case "keyhive-frame": - this.#keyhiveHandler?.onMessage( - message.payload, - new PeerId(message.peer) - ); - return; - case "keyhive-peer-disconnected": - this.#keyhiveHandler?.onPeerDisconnect(new PeerId(message.peer)); - return; - } - } - - #setState(connecting: boolean, connected: boolean) { - if ( - connecting === this.#nodeConnecting && - connected === this.#nodeConnected - ) { - return; - } - this.#nodeConnecting = connecting; - this.#nodeConnected = connected; - this.#generation++; - this.#onChange?.(); - } - - // ── ConnectionManager ──────────────────────────────────────────────── - - isConnecting(): boolean { - return !this.#shutdown && (this.#port === null || this.#nodeConnecting); - } - - isConnected(): boolean { - return this.#port !== null && this.#nodeConnected; - } - - generation(): number { - return this.#generation; - } - - onChange(callback: () => void): void { - this.#onChange = callback; - } - - shutdown(): void { - this.#shutdown = true; - this.#closePort(); - } - - // ── SubductionRemote ───────────────────────────────────────────────── - - bind(handlers: SubductionRemoteHandlers): void { - this.#handlers = handlers; - } - - // A round cut off by a lost port reports no peers: the source re-syncs as - // soon as the replacement's state bumps the generation, where a failure - // would leave it to the healer's backoff. - sync( - sid: string, - timeoutMs: number | null - ): Promise { - return this.#request({ type: "sync", sid, timeoutMs }, []); - } - - connectedPeerIds(): Promise { - return this.#request({ type: "peers" }, []); - } - - stored(sid: string, records: Array<{ kind: RecordKind; id: string }>) { - const message = { type: "stored" as const, sid, records }; - if (!this.#post(message)) this.#unsent.push(message); - } - - release(sid: string): void { - this.#post({ type: "release", sid }); - } - - /** - * Call `onChange` whenever the sedimentree `sid` may have new heads, whether - * or not this Repo holds it, until the returned function is called. The - * node keeps it subscribed with the server meanwhile. It's called with the - * node's heads for it (hex sedimentree heads), which may not have moved, - * straight after watching and after a worker restart too; without them - * when the node doesn't say. - */ - watch(sid: string, onChange: (heads?: string[]) => void): () => void { - let watchers = this.#watchers.get(sid); - if (!watchers) { - watchers = new Set(); - this.#watchers.set(sid, watchers); - } - // Again for a second watcher, so it hears the heads too. - this.#post({ type: "watch", sid }); - // Wrapped, so that watching twice with one function takes two unwatches. - const watcher = (heads?: string[]) => onChange(heads); - watchers.add(watcher); - return () => { - if (!watchers.delete(watcher) || watchers.size > 0) return; - this.#watchers.delete(sid); - this.#post({ type: "unwatch", sid }); - }; - } - - async subscribeEphemeral(sid: string): Promise { - this.#ephemeral.add(sid); - this.#post({ type: "ephemeral-subscribe", sid }); - } - - async unsubscribeEphemeral(sid: string): Promise { - this.#ephemeral.delete(sid); - this.#post({ type: "ephemeral-unsubscribe", sid }); - } - - async publishEphemeral(sid: string, payload: Uint8Array): Promise { - this.#post({ type: "ephemeral-publish", sid, payload }); - } - - // ── Keyhive ────────────────────────────────────────────────────────── - - /** - * Stands in for the Repo's Subduction as ARK's keyhive sync sees it: the - * frames it sends go out over the node's connection, and the frames the - * node receives come back here. - */ - keyhiveRelay(): KeyhiveRelay { - return { - getConnectedPeerIds: async () => - (await this.#request({ type: "keyhive-peers" }, [])).map( - (bytes) => new PeerId(bytes) - ), - registerFrameHandler: async (handler) => { - this.#keyhiveHandler = handler ?? null; - this.#post({ type: handler ? "keyhive-listen" : "keyhive-unlisten" }); - }, - sendKeyhiveMessage: async (payload, peer) => { - this.#post({ type: "keyhive-send", peer: peer.toBytes(), payload }); - }, - }; - } -} - -/** - * The sedimentree a document syncs as: its 16 id bytes, zero-padded to 32, in - * hex, which is what `SedimentreeId.toString()` gives. - */ -export function getSedimentreeId(url: AutomergeUrl): string { - const bytes = new Uint8Array(32); - bytes.set(parseAutomergeUrl(url).binaryDocumentId.subarray(0, 32)); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( - "" - ); -} - -/** - * automerge-repo-keyhive runs keyhive sync over `repo.subduction`, the Repo's - * own Subduction node, which has no connection when the Repo syncs through - * the node. Hand it the remote's relay there instead. ARK is the only reader - * of `repo.subduction`. - */ -export function relayKeyhive( - repo: R, - remote: PortSyncRemote -): R { - Object.defineProperty(repo, "subduction", { - value: Promise.resolve(remote.keyhiveRelay()), - }); - return repo; -} diff --git a/core/bootloader/src/tab-resolver.ts b/core/bootloader/src/tab-resolver.ts new file mode 100644 index 000000000..c0f6ec0af --- /dev/null +++ b/core/bootloader/src/tab-resolver.ts @@ -0,0 +1,66 @@ +// Answering the service worker's requests with this tab's own Repo. The +// service worker asks the tab that made a request first, then any other tab, +// and only broadcasts to the automerge worker when none of them can; a tab +// that hasn't been handed its Repo yet says so straight away. +import type { Repo } from "@automerge/automerge-repo/slim"; + +import { createResolver, type Resolver } from "./resolver.js"; +import type { + HandoffReplyMessage, + ResolveAcceptedMessage, + ResolveRequestMessage, + ResolveUnavailableMessage, +} from "./types.js"; + +let resolver: Resolver | undefined; + +/** + * Resolve the service worker's `automerge:` requests with `repo` from now on, + * rather than leaving them to the automerge worker. What they load stays in + * `repo`. + */ +export function resolveHandoffsWith(repo: Repo): void { + resolver = createResolver(() => Promise.resolve(repo)); +} + +/** Answer a ResolveRequestMessage; false if `event` isn't one. */ +export function handleResolveRequest(event: MessageEvent): boolean { + if (event.data?.type !== "resolve") return false; + const [port] = event.ports; + if (!port) return true; + const message = event.data as ResolveRequestMessage; + const current = resolver; + if (!current) { + port.postMessage({ type: "unavailable" } satisfies ResolveUnavailableMessage); + return true; + } + + const answer = () => + current + .answer({ ...message, type: "request" }) + .catch( + (error): HandoffReplyMessage => ({ + id: message.id, + type: "response", + response: { status: 557, body: String(error) }, + }) + ) + .then((reply) => + port.postMessage( + reply ?? ({ type: "unavailable" } satisfies ResolveUnavailableMessage) + ) + ); + + if (!navigator.locks) { + port.postMessage({ type: "accepted" } satisfies ResolveAcceptedMessage); + void answer(); + return true; + } + // Released once the reply is posted, or when this tab goes. + const lock = `@patchwork/resolve/${message.id}/${crypto.randomUUID()}`; + void navigator.locks.request(lock, () => { + port.postMessage({ type: "accepted", lock } satisfies ResolveAcceptedMessage); + return answer(); + }); + return true; +} diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 35b577abd..0ddc6d4ec 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -8,10 +8,10 @@ export const HANDOFF_CHANNEL = "@patchwork/handoff"; /** * The Web Lock the automerge worker holds for as long as it lives. The - * browser releases it when the instance goes, however it goes, so a - * replacement waits for it before starting (two never sync or resolve at - * once), tabs waiting on it hear the worker die, and the service worker can - * ask whether there's a worker at all. + * browser releases it when the instance goes, however it goes, so tabs + * waiting on it hear the worker die, and the service worker can ask whether + * there's a worker at all. It's only for telling: no instance waits for it + * before working. */ export const AUTOMERGE_WORKER_LOCK = "@patchwork/automerge-worker"; @@ -117,86 +117,39 @@ export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage; /** - * Automerge worker → world: broadcast once on startup so the service worker - * can re-send any handoff requests that raced the worker's boot. + * Service worker → a tab, posted to it with a port to reply on: resolve this + * request with your own Repo. The same fields as a + * {@link HandoffRequestMessage}. The tab answers with a + * {@link ResolveAcceptedMessage} or a {@link ResolveUnavailableMessage} + * straight away, and once it's resolved, the same reply the automerge worker + * would broadcast. A tab that doesn't answer at once is taken to be one that + * can't (from an older build, or frozen), and the service worker asks another. */ -export interface HandoffOnlineMessage { - type: "online"; +export interface ResolveRequestMessage extends Omit { + type: "resolve"; } /** - * The BroadcastChannel tools ask the automerge worker on how much memory a - * package's documents take to load. The worker is what loads them, to serve - * the package's files, so it measures them where they'd be loaded anyway - * rather than in the tab asking. A site whose worker doesn't listen never - * answers. - * - * Like {@link HANDOFF_CHANNEL}, the shapes can only change additively. + * Tab → service worker: resolving. The tab holds the Web Lock `lock` until it + * has replied, so it's released either then or when the tab goes, however it + * goes; the service worker waits on it to tell the two apart. */ -export const PACKAGE_MEMORY_CHANNEL = "@patchwork/package-memory"; - -/** - * Tool → automerge worker: how much memory does the package at `url` take? - * - * By default the answer is the package's documents the worker has loaded to - * serve its files since it started, each measured as it was loaded. That's - * what the package actually costs, and answering loads nothing. - * - * With `walk`, the worker loads every document in the package to measure it, - * fetching any it hasn't stored. That covers files nothing has asked for - * (READMEs, lockfiles, images), but it's slow, and loading a document with - * one huge change costs the worker that much memory: measuring can itself be - * the most expensive thing the worker does. - */ -export interface PackageMemoryRequestMessage { - id: string; - type: "request"; - /** The package's root doc, at heads or without (for the latest). */ - url: string; - walk?: boolean; +export interface ResolveAcceptedMessage { + type: "accepted"; + lock?: string; } -/** - * One document in a package. What it costs automerge to load grows faster - * than its ops: much faster for one big change than for the same ops spread - * over many. A file's text written as a plain string in one change is an op - * per character, all in that change; one of 585k characters took ~300 MB of - * wasm memory to load, where as an `ImmutableString` it took none. - */ -export interface PackageMemoryDocument { - url: string; - /** Where in the package it is, `""` for the root. */ - path: string; - ops: number; - changes: number; - /** The ops in its biggest single change. */ - largestChange: number; - /** How a file doc's `content` is stored, and how long it is. */ - content?: { - kind: "text" | "immutable" | "bytes"; - length: number; - }; - /** Set if it couldn't be found, and the counts are 0. */ - error?: string; +/** Tab → service worker: this tab can't resolve it (it has no Repo yet). */ +export interface ResolveUnavailableMessage { + type: "unavailable"; } /** - * Automerge worker → tool: the request arrived and is queued behind any - * others. Sent straight away, so a tool can tell a worker that can't measure - * (it never hears this) from one that's still measuring. + * Automerge worker → world: broadcast once on startup so the service worker + * can re-send any handoff requests that raced the worker's boot. */ -export interface PackageMemoryAcceptedMessage { - id: string; - type: "accepted"; -} - -/** Automerge worker → tool: the package's documents, root first. */ -export interface PackageMemoryReplyMessage { - id: string; - type: "reply"; - documents: PackageMemoryDocument[]; - /** Set if the package couldn't be measured; `documents` has what was. */ - error?: string; +export interface HandoffOnlineMessage { + type: "online"; } /** @@ -239,6 +192,4 @@ export type SetupServiceWorkerOptions = { export type SetupServiceWorkerResult = { shared?: SharedWorker; kill?: () => void; - /** Open a classic Automerge sync WebSocket from the automerge worker. */ - connectClassicSync: (server?: string) => Promise; }; diff --git a/core/bootloader/src/worker-control.ts b/core/bootloader/src/worker-control.ts index 58f4937be..dd93f98ba 100644 --- a/core/bootloader/src/worker-control.ts +++ b/core/bootloader/src/worker-control.ts @@ -86,10 +86,10 @@ export function startWorkerControl( */ onRestart?: () => Promise | void; /** - * A Web Lock to hold for as long as this instance lives. A replacement - * waits for it before `holding` resolves, so two instances' work never - * overlaps however the first one goes; and tabs are told the name, so - * they can wait on it to hear when this one has gone. + * A Web Lock to hold for as long as this instance lives, so others can + * tell whether it's still around: tabs are told the name and wait on it + * to hear when this one has gone. Nothing waits for it before working; a + * replacement only says it's alive once its predecessor lets go. */ lock?: string; } = {} @@ -101,8 +101,6 @@ export function startWorkerControl( * trap anywhere does this by itself, if it's thrown uncaught or logged. */ fail: (reason: string, error?: unknown) => void; - /** Resolves once this instance holds `lock` (straight away without one). */ - holding: Promise; } { const ports = new Set(); let alive: AliveMessage | undefined; @@ -168,9 +166,18 @@ export function startWorkerControl( console.warn(`[lifecycle] ${name} SharedWorker restarting: ${reason}`); for (const port of ports) postToPort(port, restarting); - const drained = (async () => { - if (drain) await handlers.onRestart?.(); - })(); + const drained = Promise.all([ + drain && handlers.onRestart?.(), + // The browser may start the replacement from the copy of this script + // it has cached, which is still this build: the script is served + // stale-while-revalidate so it starts offline. The newer tab would then + // restart that one too, and the next. Fetching the script past the + // cache now puts the newer copy there before the tabs start another. + build !== undefined && + fetch(self.location.href, { cache: "no-cache" }) + .then((response) => response.arrayBuffer()) + .catch(() => {}), + ]); const deadline = new Promise((resolve) => setTimeout(resolve, RESTART_DRAIN_MS) ); @@ -280,15 +287,7 @@ export function startWorkerControl( }) : Promise.resolve(); if (lock && self.navigator?.locks) { - const waiting = setTimeout( - () => - console.warn( - `[lifecycle] ${name} SharedWorker waiting for the previous instance to go` - ), - 1_000 - ); void holding.then(() => { - clearTimeout(waiting); alive = { type: "alive", lock }; for (const port of ports) postToPort(port, alive); }); @@ -300,6 +299,5 @@ export function startWorkerControl( }, restart: (reason) => restart(reason), fail, - holding, }; } diff --git a/core/bootloader/test/harness.ts b/core/bootloader/test/harness.ts deleted file mode 100644 index 0938ed19f..000000000 --- a/core/bootloader/test/harness.ts +++ /dev/null @@ -1,90 +0,0 @@ -// An in-process stand-in for the sync server and the origin's database. -import type { - Chunk, - ManagedTransport, - StorageAdapterInterface, - StorageKey, - WebSocketEndpointInterface, -} from "@automerge/automerge-repo"; -import { - MessagePortTransport, - type Subduction, -} from "@automerge/automerge-subduction"; - -export const SERVICE = "sync.test"; - -export class MemoryAdapter implements StorageAdapterInterface { - readonly data = new Map(); - /** How many times each key has been written. */ - readonly writes = new Map(); - #key = (key: StorageKey) => key.join("\u0000"); - #wrote(key: string) { - this.writes.set(key, (this.writes.get(key) ?? 0) + 1); - } - - async load(key: StorageKey) { - return this.data.get(this.#key(key)); - } - async save(key: StorageKey, value: Uint8Array) { - this.data.set(this.#key(key), value); - this.#wrote(this.#key(key)); - } - async saveBatch(entries: Array<[StorageKey, Uint8Array]>) { - for (const [key, value] of entries) { - this.data.set(this.#key(key), value); - this.#wrote(this.#key(key)); - } - } - async remove(key: StorageKey) { - this.data.delete(this.#key(key)); - } - async loadRange(prefix: StorageKey): Promise { - const start = this.#key(prefix); - return [...this.data] - .filter(([key]) => key === start || key.startsWith(start + "\u0000")) - .map(([key, data]) => ({ key: key.split("\u0000"), data })); - } - async removeRange(prefix: StorageKey) { - for (const { key } of await this.loadRange(prefix)) await this.remove(key); - } -} - -export type Server = { - subduction: Subduction; - up: boolean; - /** The client ends of the connections made to it, to drop them. */ - connections?: Set<{ disconnect(): Promise }>; -}; - -export function endpointTo(server: Server): WebSocketEndpointInterface { - return { - url: `ws://${SERVICE}`, - async connect() { - if (!server.up) throw new Error("server is down"); - const { port1, port2 } = new MessageChannel(); - void server.subduction - .acceptTransport(new MessagePortTransport(port2), SERVICE) - .catch(() => {}); - const transport = new MessagePortTransport(port1); - const closed = new Promise((resolve) => - transport.onDisconnect(() => resolve()) - ); - server.connections?.add(transport); - void closed.then(() => server.connections?.delete(transport)); - return Object.assign(transport, { - closed: () => closed, - }) as unknown as ManagedTransport; - }, - }; -} - -export async function eventually( - check: () => boolean | Promise, - ms = 10_000 -) { - const deadline = Date.now() + ms; - while (!(await check())) { - if (Date.now() > deadline) throw new Error(`not within ${ms}ms`); - await new Promise((resolve) => setTimeout(resolve, 25)); - } -} diff --git a/core/bootloader/test/keyhive.test.ts b/core/bootloader/test/keyhive.test.ts deleted file mode 100644 index 29607d47f..000000000 --- a/core/bootloader/test/keyhive.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Keyhive tabs syncing through the sync node: encrypted documents stored under -// the interceptor's prefix, pushed by the node as ciphertext, and decrypted by -// the tab they're forwarded to. There's no keyhive server here, so ARK's own -// keyhive sync never gets an answer; tabs share keyhive state the way they do -// in a browser, through the keyhive database. -import { afterEach, describe, expect, it } from "vitest"; -import { Repo, type DocHandle } from "@automerge/automerge-repo"; -import { - initializeAutomergeRepoKeyhive, - initKeyhiveWasm, -} from "@automerge/automerge-repo-keyhive"; -import { - MemorySigner, - MemoryStorage, - Subduction, -} from "@automerge/automerge-subduction"; -import { INTERCEPTOR_PREFIX } from "@automerge/automerge-repo"; - -import { SyncNode } from "../src/sync-node.js"; -import { PortSyncRemote, relayKeyhive } from "../src/sync-remote.js"; -import { loadKeyhiveSigner } from "../src/signer.js"; -import { MemoryAdapter, endpointTo, eventually, SERVICE } from "./harness.js"; - -const cleanup: Array<() => Promise> = []; -afterEach(async () => { - await Promise.all(cleanup.splice(0).map((stop) => stop().catch(() => {}))); -}); - -type Doc = { text?: string }; - -describe("sync node on a keyhive site", () => { - it("pushes ciphertext and carries edits between tabs", async () => { - initKeyhiveWasm(); - const server = { - subduction: new Subduction({ - signer: MemorySigner.generate(), - storage: new MemoryStorage(), - serviceName: SERVICE, - }), - up: true, - }; - const storage = new MemoryAdapter(); - const keyhiveStorage = new MemoryAdapter(); - let node: SyncNode | undefined; - const connect = (port: MessagePort) => { - void nodeReady.then((ready) => ready.connect(port)); - }; - let startNode!: () => void; - const nodeReady = new Promise((resolve) => { - startNode = async () => { - node = new SyncNode({ - signer: await loadKeyhiveSigner(keyhiveStorage), - storage, - storagePrefix: INTERCEPTOR_PREFIX, - endpoints: [endpointTo(server)], - log: () => {}, - }); - cleanup.push(() => node!.shutdown()); - resolve(node); - }; - }); - - const tab = async (name: string) => { - const remote = new PortSyncRemote(() => { - const { port1, port2 } = new MessageChannel(); - connect(port2); - return port1; - }); - const { hive, repo } = await initializeAutomergeRepoKeyhive({ - createRepo: (config) => relayKeyhive(new Repo(config), remote), - storage: keyhiveStorage, - peerIdSuffix: name, - syncServer: "subduction", - repo: { - storage, - subductionRemote: remote, - headsChannel: `keyhive-test-heads`, - }, - }); - cleanup.push(async () => { - hive.close?.(); - await repo.shutdown(); - }); - return repo; - }; - - const a = await tab("a"); - // The node signs as the keyhive identity, which the first tab created. - startNode(); - // `create2` is what mints a keyhive (protected, encrypted) document. - const inA: DocHandle = await a.create2(); - inA.change((doc) => { - doc.text = "secret"; - }); - await a.flush(); - - // What reached the server is ciphertext. - await eventually(async () => { - const heads = await server.subduction.getAllHeads(); - return heads.length > 0; - }); - const sids = await server.subduction.sedimentreeIds(); - const blobs = (await server.subduction.getBlobs(sids[0])) ?? []; - expect(blobs.length).toBeGreaterThan(0); - const plaintext = new TextEncoder().encode("secret"); - const contains = (haystack: Uint8Array) => - haystack.some((_, i) => - plaintext.every((byte, j) => haystack[i + j] === byte) - ); - expect(blobs.some(contains)).toBe(false); - - // A second tab, booted after the first wrote its keyhive state. - const b = await tab("b"); - const inB = await b.find(inA.url); - expect(inB.doc().text).toBe("secret"); - - inA.change((doc) => { - doc.text = "still secret"; - }); - await eventually(() => inB.doc().text === "still secret"); - }); -}); diff --git a/core/bootloader/test/sync-node.test.ts b/core/bootloader/test/sync-node.test.ts deleted file mode 100644 index e763243d3..000000000 --- a/core/bootloader/test/sync-node.test.ts +++ /dev/null @@ -1,594 +0,0 @@ -// Tabs syncing through the sync node. The tabs and the node share one storage -// adapter (standing in for the origin's IndexedDB) and one signer; the "sync -// server" is an in-process Subduction node reached over a MessageChannel, and -// "other" is someone else's Repo connected straight to it. -import { afterEach, describe, expect, it } from "vitest"; -import { - Repo, - generateAutomergeUrl, - type AutomergeUrl, - type DocHandle, - type PeerId, -} from "@automerge/automerge-repo"; -import { - MemorySigner, - MemoryStorage, - SedimentreeId, - Subduction, -} from "@automerge/automerge-subduction"; -import { decodeHeads, parseAutomergeUrl } from "@automerge/automerge-repo"; - -import { SyncNode } from "../src/sync-node.js"; -import { PortSyncRemote, getSedimentreeId } from "../src/sync-remote.js"; -import { - MemoryAdapter, - SERVICE, - endpointTo, - eventually, - type Server, -} from "./harness.js"; - -function startServer(): Server { - return { - subduction: new Subduction({ - signer: MemorySigner.generate(), - storage: new MemoryStorage(), - serviceName: SERVICE, - }), - up: true, - }; -} - -const cleanup: Array<() => Promise> = []; -afterEach(async () => { - await Promise.all(cleanup.splice(0).map((stop) => stop().catch(() => {}))); -}); - -let origins = 0; - -/** An origin: its storage, its signer, its sync node, and tabs on demand. */ -function origin(server: Server) { - const name = `origin-${origins++}`; - const seed = crypto.getRandomValues(new Uint8Array(32)); - const storage = new MemoryAdapter(); - const startNode = () => { - const node = new SyncNode({ - signer: MemorySigner.fromBytes(seed), - storage, - endpoints: [endpointTo(server)], - log: () => {}, - }); - cleanup.push(() => node.shutdown()); - return node; - }; - let node = startNode(); - let tabs = 0; - const remote = () => - new PortSyncRemote(() => { - const { port1, port2 } = new MessageChannel(); - node.connect(port2); - return port1; - }); - return { - storage, - remote, - get node() { - return node; - }, - /** What a worker restart does: a fresh node on the same database. */ - async restartNode() { - const old = node; - node = startNode(); - await old.shutdown(); - }, - tab() { - const repo = new Repo({ - peerId: `${name}-tab-${tabs++}` as PeerId, - storage, - signer: MemorySigner.fromBytes(seed), - subductionRemote: remote(), - headsChannel: `${name}-heads`, - }); - cleanup.push(() => repo.shutdown()); - return repo; - }, - }; -} - -function elsewhere(server: Server) { - const repo = new Repo({ - peerId: `other-${origins++}` as PeerId, - subductionWebsocketEndpoints: [endpointTo(server)], - }); - cleanup.push(() => repo.shutdown()); - return repo; -} - -/** - * Look the document up from a fresh direct Repo until it shows `check`. A - * Repo that synced before the push landed doesn't hear about it later, so - * each attempt starts over. - */ -async function seenElsewhere( - server: Server, - url: AutomergeUrl, - check: (doc: Doc) => boolean, - ms = 20_000 -) { - const deadline = Date.now() + ms; - for (;;) { - const repo = elsewhere(server); - const doc = await repo - .find(url, { signal: AbortSignal.timeout(2_000) }) - .then((handle) => handle.doc()) - .catch(() => undefined); - void repo.shutdown(); - if (doc && check(doc)) return; - if (Date.now() > deadline) - throw new Error(`${url} not seen within ${ms}ms`); - await new Promise((resolve) => setTimeout(resolve, 250)); - } -} - -type Doc = { text?: string; count?: number }; - -/** - * Count the sync rounds the node runs with the server, each held open for - * `delayMs` so that requests arriving meanwhile overlap it, as they would - * behind a real network round trip. - */ -function countRounds(node: SyncNode, delayMs = 200) { - const subduction = node.subduction as any; - const real = subduction.syncWithAllPeers.bind(subduction); - const counter = { rounds: 0 }; - subduction.syncWithAllPeers = async (...args: unknown[]) => { - counter.rounds++; - await new Promise((resolve) => setTimeout(resolve, delayMs)); - return real(...args); - }; - return counter; -} - -describe("sync node", () => { - it("pushes a tab's edits to the server", async () => { - const server = startServer(); - const tab = origin(server).tab(); - const handle = tab.create({ count: 1 }); - handle.change((doc) => { - doc.count = 2; - }); - - await seenElsewhere(server, handle.url, (doc) => doc.count === 2); - }); - - it("finds a document only the server has", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ text: "from elsewhere" }); - await other.flush(); - - const tab = origin(server).tab(); - const ours = await tab.find(theirs.url); - expect(ours.doc().text).toBe("from elsewhere"); - }); - - it("delivers live edits from the server to a tab", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const ours = await origin(server).tab().find(theirs.url); - theirs.change((doc) => { - doc.count = 7; - }); - await eventually(() => ours.doc().count === 7); - }); - - it("carries one tab's edits to another tab", async () => { - const server = startServer(); - const site = origin(server); - const a = site.tab(); - const b = site.tab(); - const inA = a.create({ count: 0 }); - await a.flush(); - const inB = await b.find(inA.url); - - inA.change((doc) => { - doc.count = 3; - }); - await eventually(() => inB.doc().count === 3); - }); - - it("runs one round for many tabs opening the same document", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 1 }); - await other.flush(); - - const site = origin(server); - const tabs = Array.from({ length: 10 }, () => site.tab()); - const counter = countRounds(site.node); - const found = await Promise.all( - tabs.map((tab) => tab.find(theirs.url)) - ); - - expect(found.every((handle) => handle.doc().count === 1)).toBe(true); - // None of them wrote anything, so the round the first started covers all. - expect(counter.rounds).toBe(1); - }); - - it("pushes an edit made while a round is already running", async () => { - const server = startServer(); - const site = origin(server); - countRounds(site.node, 500); - const tab = site.tab(); - const handle = tab.create({ count: 1 }); - await new Promise((resolve) => setTimeout(resolve, 100)); - handle.change((doc) => { - doc.count = 2; - }); - - await seenElsewhere(server, handle.url, (doc) => doc.count === 2); - }); - - it("pushes every tab's edit when they all write during a round", async () => { - const server = startServer(); - const site = origin(server); - const first = site.tab(); - const created = first.create<{ [tab: string]: number }>({}); - await first.flush(); - const tabs = [first, ...Array.from({ length: 4 }, () => site.tab())]; - const handles = await Promise.all( - tabs.map((tab) => tab.find<{ [tab: string]: number }>(created.url)) - ); - - const counter = countRounds(site.node, 300); - handles.forEach((handle, i) => - handle.change((doc) => { - doc[`tab${i}`] = i; - }) - ); - - await seenElsewhere(server, created.url, (doc: any) => - handles.every((_, i) => doc[`tab${i}`] === i) - ); - expect(counter.rounds).toBeLessThan(tabs.length); - }); - - it("doesn't write a tab's records a second time", async () => { - const server = startServer(); - const site = origin(server); - const tab = site.tab(); - const handle = tab.create({ count: 1 }); - handle.change((doc) => { - doc.count = 2; - }); - await seenElsewhere(server, handle.url, (doc) => doc.count === 2); - - const records = [...site.storage.writes].filter(([key]) => - /\u0000(commits|blobs|fragments|fragment-blobs)\u0000/.test(key) - ); - expect(records.length).toBeGreaterThan(0); - expect(records.filter(([, times]) => times > 1)).toEqual([]); - }); - - it("pushes an edit the node failed to pick up the first time", async () => { - const server = startServer(); - const site = origin(server); - const tab = site.tab(); - const handle = tab.create({ count: 1 }); - await seenElsewhere(server, handle.url, (doc) => doc.count === 1); - - const subduction = site.node.subduction as any; - const real = subduction.storeBuiltBatch.bind(subduction); - let failures = 1; - subduction.storeBuiltBatch = (...args: unknown[]) => - failures-- > 0 ? Promise.reject(new Error("transient")) : real(...args); - handle.change((doc) => { - doc.count = 2; - }); - - await seenElsewhere(server, handle.url, (doc) => doc.count === 2); - }); - - it("follows the node across a worker restart", async () => { - const server = startServer(); - const site = origin(server); - const tab = site.tab(); - const handle = tab.create({ count: 1 }); - await seenElsewhere(server, handle.url, (doc) => doc.count === 1); - - await site.restartNode(); - handle.change((doc) => { - doc.count = 2; - }); - await seenElsewhere(server, handle.url, (doc) => doc.count === 2); - }); - - it("reports a document nobody has as unavailable", async () => { - const server = startServer(); - const tab = origin(server).tab(); - await expect(tab.find(generateAutomergeUrl())).rejects.toThrow( - /unavailable/ - ); - }); - - it("keeps looking while the server is unreachable", async () => { - const server = startServer(); - server.up = false; - const tab = origin(server).tab(); - let settled = false; - void tab.find(generateAutomergeUrl()).then( - () => (settled = true), - () => (settled = true) - ); - await new Promise((resolve) => setTimeout(resolve, 2_000)); - expect(settled).toBe(false); - }); - - it("pushes edits made while the server was unreachable once it's back", async () => { - const server = startServer(); - server.up = false; - const tab = origin(server).tab(); - const handle = tab.create({ text: "offline" }); - await tab.flush(); - - server.up = true; - await seenElsewhere(server, handle.url, (doc) => doc.text === "offline"); - }); - - it("relays keyhive frames between the server and every listening tab", async () => { - const server = startServer(); - const toServer: Uint8Array[] = []; - await server.subduction.registerFrameHandler({ - onMessage: (payload: Uint8Array) => toServer.push(payload), - onPeerDisconnect: () => {}, - }); - const site = origin(server); - const relays = [site.remote().keyhiveRelay(), site.remote().keyhiveRelay()]; - const heard: Uint8Array[][] = relays.map(() => []); - await Promise.all( - relays.map((relay, i) => - relay.registerFrameHandler({ - onMessage: (payload) => heard[i].push(payload), - onPeerDisconnect: () => {}, - }) - ) - ); - - // What ARK does first: wait for the server to show up as a peer. - let peers: Awaited> = - []; - await eventually(() => { - void relays[0].getConnectedPeerIds().then((found) => (peers = found)); - return peers.length > 0; - }); - const serverPeer = peers[0]; - - await relays[1].sendKeyhiveMessage(new Uint8Array([1, 2, 3]), serverPeer); - await eventually(() => toServer.length === 1); - expect([...toServer[0]]).toEqual([1, 2, 3]); - - const [nodePeer] = await server.subduction.getConnectedPeerIds(); - await server.subduction.sendKeyhiveMessage( - new Uint8Array([4, 5]), - nodePeer - ); - await eventually(() => heard.every((frames) => frames.length === 1)); - expect(heard.map((frames) => [...frames[0]])).toEqual([ - [4, 5], - [4, 5], - ]); - }); -}); - -describe("watching a document", () => { - /** A Repo-less connection to the node, counting `changed` for `url`. */ - function startWatching(site: ReturnType, url: AutomergeUrl) { - const remote = site.remote(); - cleanup.push(async () => remote.shutdown()); - const heard = { changes: 0, unwatch: () => {} }; - heard.unwatch = remote.watch(getSedimentreeId(url), () => heard.changes++); - return heard; - } - - it("names a document's sedimentree the way subduction does", () => { - const url = generateAutomergeUrl(); - const bytes = new Uint8Array(32); - bytes.set(parseAutomergeUrl(url).binaryDocumentId); - expect(getSedimentreeId(url)).toBe( - SedimentreeId.fromBytes(bytes).toString() - ); - }); - - it("hears the server's edits to a document nobody on the origin holds", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const heard = startWatching(origin(server), theirs.url); - // Give the node time to subscribe before the edit goes out. - await new Promise((resolve) => setTimeout(resolve, 500)); - const before = heard.changes; - theirs.change((doc) => { - doc.count = 1; - }); - await eventually(() => heard.changes > before); - }); - - it("hears a tab's edit, the writing tab included", async () => { - const server = startServer(); - const site = origin(server); - const tab = site.tab(); - const handle = tab.create({ count: 0 }); - await tab.flush(); - - const heard = startWatching(site, handle.url); - await new Promise((resolve) => setTimeout(resolve, 200)); - const before = heard.changes; - handle.change((doc) => { - doc.count = 1; - }); - await eventually(() => heard.changes > before); - }); - - it("stops hearing once unwatched", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const heard = startWatching(origin(server), theirs.url); - await new Promise((resolve) => setTimeout(resolve, 500)); - heard.unwatch(); - await new Promise((resolve) => setTimeout(resolve, 100)); - const before = heard.changes; - theirs.change((doc) => { - doc.count = 1; - }); - await new Promise((resolve) => setTimeout(resolve, 1_000)); - expect(heard.changes).toBe(before); - }); - - // The in-process server pushes to every peer, subscribed or not, so what's - // checked here is that the node asks. - it("subscribes a watched document, and again when the connection is back", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - // The same server, but only the origin's connections get dropped. - const dropping = { ...server, connections: new Set() }; - const site = origin(dropping); - const counter = countRounds(site.node, 0); - // Connected first, so it's the watch that asks, not the connection. - await eventually(() => dropping.connections.size > 0); - await new Promise((resolve) => setTimeout(resolve, 100)); - startWatching(site, theirs.url); - await eventually(() => counter.rounds >= 1); - await new Promise((resolve) => setTimeout(resolve, 300)); - const before = counter.rounds; - - for (const connection of dropping.connections) - await connection.disconnect(); - await eventually(() => counter.rounds > before); - }); - - it("says the document's heads, and the replacement answers a watch with them", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const site = origin(server); - const remote = site.remote(); - cleanup.push(async () => remote.shutdown()); - const heard: Array = []; - remote.watch(getSedimentreeId(theirs.url), (heads) => heard.push(heads)); - await new Promise((resolve) => setTimeout(resolve, 500)); - theirs.change((doc) => { - doc.count = 1; - }); - const moved = decodeHeads(theirs.heads()); - await eventually(() => - heard.some((heads) => heads?.join() === moved.join()) - ); - - const before = heard.length; - await site.restartNode(); - await eventually(() => heard.length > before); - // Nothing moved, and every call since says so. - await new Promise((resolve) => setTimeout(resolve, 500)); - expect(heard.slice(before).map((heads) => heads?.join())).toEqual( - heard.slice(before).map(() => moved.join()) - ); - - // A document the node already has: heads read before the edit is in its - // tree would be the old ones, and say nothing moved. - const since = heard.length; - theirs.change((doc) => { - doc.count = 2; - }); - const movedAgain = decodeHeads(theirs.heads()); - await eventually(() => heard.length > since); - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(heard.slice(since).map((heads) => heads?.join())).not.toContain( - moved.join() - ); - await eventually(() => - heard.some((heads) => heads?.join() === movedAgain.join()) - ); - }); - - it("keeps hearing the server across a worker restart", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const site = origin(server); - const heard = startWatching(site, theirs.url); - await new Promise((resolve) => setTimeout(resolve, 500)); - await site.restartNode(); - // The replacement answers the watch with the heads it has. - await eventually(() => heard.changes > 0); - await new Promise((resolve) => setTimeout(resolve, 500)); - const before = heard.changes; - theirs.change((doc) => { - doc.count = 1; - }); - await eventually(() => heard.changes > before); - }); -}); - -describe("ephemeral messages", () => { - /** - * Broadcast on `from` until `to` hears it. Subscribing reaches the server - * a moment after a handle opens, and what's sent before then is lost. - */ - async function hears(from: DocHandle, to: DocHandle) { - const received: unknown[] = []; - to.on("ephemeral-message", ({ message }) => received.push(message)); - const pump = setInterval(() => from.broadcast({ hello: "there" }), 50); - try { - await eventually(() => received.length > 0); - } finally { - clearInterval(pump); - } - expect(received[0]).toEqual({ hello: "there" }); - } - - it("delivers another peer's ephemeral messages to a tab", async () => { - const server = startServer(); - const other = elsewhere(server); - const theirs = other.create({ count: 0 }); - await other.flush(); - - const ours = await origin(server).tab().find(theirs.url); - await hears(theirs, ours); - }); - - it("delivers a tab's ephemeral messages to another peer", async () => { - const server = startServer(); - const tab = origin(server).tab(); - const ours = tab.create({ count: 0 }); - await tab.flush(); - - const theirs = await elsewhere(server).find(ours.url); - await hears(ours, theirs); - }); - - it("carries one tab's ephemeral messages to another tab", async () => { - const server = startServer(); - const site = origin(server); - const a = site.tab(); - const inA = a.create({ count: 0 }); - await a.flush(); - const inB = await site.tab().find(inA.url); - - await hears(inA, inB); - }); -}); diff --git a/core/filesystem/src/module-watcher.ts b/core/filesystem/src/module-watcher.ts index 845ff4b6a..3e46aeb5c 100644 --- a/core/filesystem/src/module-watcher.ts +++ b/core/filesystem/src/module-watcher.ts @@ -147,8 +147,8 @@ export class ModuleWatcher { /** * How a package's doc is looked up at its latest heads and watched for new * ones. Defaults to holding each package doc in `repo`. The browser setup - * passes {@link trackPackagesViaServiceWorker} instead, so package docs stay - * out of this thread's memory. + * passes {@link trackPackagesViaServiceWorker}, looking them up through the + * service worker and watching them in `repo`. */ tracker: PackageTracker; diff --git a/core/filesystem/src/package-tracker.ts b/core/filesystem/src/package-tracker.ts index 769a147a5..739fb5a6f 100644 --- a/core/filesystem/src/package-tracker.ts +++ b/core/filesystem/src/package-tracker.ts @@ -82,12 +82,10 @@ export function trackPackagesInRepo(repo: Repo): PackageTracker { } /** - * Package docs that never load on this thread. `watch` is the sync node's - * (`watchDocument` from `@inkandswitch/patchwork-bootloader`), which says what - * it holds, so a restarted node saying the same needs no lookup. The service - * worker finds the latest version: the automerge worker redirects a package - * url without heads to the one at the doc's latest heads, and lets go of the - * doc soon after. + * Package docs looked up by the service worker: the automerge worker + * redirects a package url without heads to the one at the doc's latest heads, + * and lets go of the doc soon after. Whether they load on this thread is up + * to `watch`. */ export function trackPackagesViaServiceWorker( watch: PackageTracker["watch"] diff --git a/core/filesystem/src/resolve.ts b/core/filesystem/src/resolve.ts index 30839bc18..98a8368ca 100644 --- a/core/filesystem/src/resolve.ts +++ b/core/filesystem/src/resolve.ts @@ -240,83 +240,3 @@ export async function resolvePath( pinned: hasHeads(rootHandle.url), }); } - -export type PackageDocument = - | { - /** Where in the package it is, `""` for the root. */ - path: string; - url: AutomergeUrl; - handle: DocHandle; - } - | { - path: string; - url: AutomergeUrl; - /** Why it couldn't be found. What it links to isn't walked. */ - error: unknown; - }; - -/** - * Every document in a package that {@link resolvePath} can reach, root first, - * each found only once it's reached. A caller done with one can let it go - * before asking for the next: its links have already been read. One that - * can't be found is handed back with the error, and the walk goes on. - */ -export async function* packageDocuments( - repo: Repo, - root: DocHandle -): AsyncGenerator { - const seen = new Set([root.documentId]); - const queue: { path: string; url?: AutomergeUrl }[] = [{ path: "" }]; - for (let next = queue.shift(); next; next = queue.shift()) { - const { path, url = root.url } = next; - let handle: DocHandle; - try { - handle = next.url ? await repo.find(next.url) : root; - } catch (error) { - yield { path, url, error }; - continue; - } - for (const link of linksIn(handle.doc(), path)) { - const { documentId } = parseAutomergeUrl(link.url); - if (seen.has(documentId)) continue; - seen.add(documentId); - queue.push(link); - } - yield { path, url, handle }; - } -} - -/** The links out of a directory or folder doc, by the path each is at. */ -function linksIn( - doc: unknown, - path: string -): { path: string; url: AutomergeUrl }[] { - const at = (name: string) => (path ? `${path}/${name}` : name); - if (folderStrategy.matches(doc)) { - return (doc as FolderDoc).docs.flatMap((link) => { - const url = asLink(link?.url); - return url ? [{ path: at(String(link.name)), url }] : []; - }); - } - if (!directoryStrategy.matches(doc)) return []; - const links: { path: string; url: AutomergeUrl }[] = []; - const walk = (node: unknown, path: string) => { - const url = asLink(node); - if (url) links.push({ path, url }); - else if ( - node && - typeof node === "object" && - !Array.isArray(node) && - !(node instanceof Uint8Array) && - !isImmutableString(node) - ) { - for (const [key, value] of Object.entries(node)) { - walk(value, path ? `${path}/${key}` : key); - } - } - }; - for (const [key, value] of Object.entries(doc as Record)) { - if (key !== "@patchwork") walk(value, at(key)); - } - return links; -} diff --git a/core/filesystem/test/resolve.test.ts b/core/filesystem/test/resolve.test.ts index af87ef45b..eb05a3d53 100644 --- a/core/filesystem/test/resolve.test.ts +++ b/core/filesystem/test/resolve.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { ImmutableString } from "@automerge/automerge"; import { Repo, type AutomergeUrl } from "@automerge/automerge-repo"; -import { packageDocuments, resolvePath } from "../src/resolve.js"; +import { resolvePath } from "../src/resolve.js"; const decode = (content: string | Uint8Array) => typeof content === "string" ? content : new TextDecoder().decode(content); @@ -59,74 +59,3 @@ describe("resolvePath", () => { expect(hello && decode(hello.content)).toBe("hello"); }); }); - -describe("packageDocuments", () => { - const paths = async (repo: Repo, root: Parameters[1]) => { - const found: string[] = []; - for await (const { path } of packageDocuments(repo, root)) found.push(path); - return found; - }; - - it("finds every document in a directory doc, once each", async () => { - const repo = new Repo(); - const root = publish(repo, (url) => new ImmutableString(url)); - // a second key linking the same file isn't a second document - root.change((d) => { - d["dist/again.js"] = d["dist/index.js"]; - }); - expect(await paths(repo, root)).toEqual(["", "package.json", "dist/index.js"]); - }); - - it("follows folder docs into their subfolders", async () => { - const repo = new Repo(); - const file = (content: string) => repo.create({ content, mimeType: "text/plain" }).url; - const sub = repo.create({ - title: "dist", - docs: [{ name: "index.js", type: "file", url: file("1") }], - }); - const root = repo.create<{ title: string; docs: unknown[] }>({ - title: "pkg", - docs: [ - { name: "package.json", type: "file", url: file("{}") }, - { name: "dist", type: "folder", url: new ImmutableString(sub.url) }, - ], - }); - // a cycle back to the root is ignored - sub.change((d: any) => { - d.docs.push({ name: "up", type: "folder", url: root.url }); - }); - expect(await paths(repo, root)).toEqual([ - "", - "package.json", - "dist", - "dist/index.js", - ]); - }); - - it("hands back a document it can't find, and goes on", async () => { - const repo = new Repo(); - const root = publish(repo, (url) => url); - const missing = "automerge:4ZTwsiaeyyK6BwDipkqpJwygKScz" as AutomergeUrl; - root.change((d) => { - d["art/missing.png"] = missing; - }); - const missingRepo: Repo = Object.create(repo, { - find: { - value: (url: AutomergeUrl) => - url === missing - ? Promise.reject(new Error("unavailable")) - : repo.find(url), - }, - }); - const found = []; - for await (const document of packageDocuments(missingRepo, root)) { - found.push([document.path, "error" in document]); - } - expect(found).toEqual([ - ["", false], - ["package.json", false], - ["dist/index.js", false], - ["art/missing.png", true], - ]); - }); -}); diff --git a/core/patchwork/package.json b/core/patchwork/package.json index 00cb9d295..8a00186cb 100644 --- a/core/patchwork/package.json +++ b/core/patchwork/package.json @@ -31,6 +31,10 @@ "import": "./dist/vite/dev-plugin.js", "types": "./dist/vite/dev-plugin.d.ts" }, + "./vite/hashed": { + "import": "./dist/vite/hashed-plugin.js", + "types": "./dist/vite/hashed-plugin.d.ts" + }, "./vite/html": { "import": "./dist/vite/html-plugin.js", "types": "./dist/vite/html-plugin.d.ts" @@ -95,6 +99,7 @@ }, "scripts": { "build": "tsc && node build-head.js && cp src/global.css dist/global.css && cp src/client.d.ts dist/client.d.ts && rm -rf dist/patches && mkdir -p dist/patches && cp ../../patches/@automerge__automerge-repo@*.patch dist/patches/", - "dev": "tsc -w --preserveWatchOutput" + "dev": "tsc -w --preserveWatchOutput", + "test": "vitest run" } } diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index 13c860794..b3b784b47 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -48,7 +48,8 @@ import { } from "@inkandswitch/patchwork-plugins"; import * as plugins from "@inkandswitch/patchwork-plugins"; import setupServiceWorker, { - watchDocument, + connectClassicSync, + resolveHandoffsWith, } from "@inkandswitch/patchwork-bootloader"; import debug from "debug"; @@ -60,6 +61,7 @@ import type { SyncStateDocMessage, } from "./types.js"; import { createRepo, initWasm } from "./repo.js"; +import { watchDocument } from "./watch-document.js"; import { createRouter, type Router } from "./router.js"; import { createDefaultAccount } from "./createAccount.js"; @@ -137,6 +139,10 @@ async function doSetup(options: PatchworkOptions): Promise { hive = options.hive; } else { ({ repo, hive, signerIdentity } = await createRepo()); + // The service worker asks this tab to resolve its own `automerge:` + // requests, and leaves them to the automerge worker until now. A site's + // own Repo isn't asked: it may not be able to find them. + resolveHandoffsWith(repo); } // Dev-console / tool-runtime globals (e2e and loaded tools read these). The @@ -184,8 +190,11 @@ async function doSetup(options: PatchworkOptions): Promise { ? undefined : importAutomergePackageViaWorker, // Look packages up through the automerge worker and watch them through - // the sync node, so their docs never load into this tab's Repo. - trackPackagesViaServiceWorker(watchDocument) + // this tab's Subduction subscriptions, so their docs never load into this + // tab's Repo. + trackPackagesViaServiceWorker((url, onChange) => + watchDocument(repo, url, onChange) + ) ); const accountDocHandle = (await resolveAccountHandle(repo, { @@ -230,7 +239,7 @@ async function doSetup(options: PatchworkOptions): Promise { packages: moduleWatcher, plugins, sw: { - connectClassicSync: sw.connectClassicSync, + connectClassicSync: (server) => connectClassicSync(repo, server), subscribeSyncState: (documentId, listener) => subscribeSyncState(repo, documentId, listener), }, @@ -408,6 +417,7 @@ function subscribeSyncState( // ── Named exports ──────────────────────────────────────────────────────── export { createRepo, initWasm } from "./repo.js"; +export { watchDocument } from "./watch-document.js"; export { createRouter } from "./router.js"; export { showLoadingAnimation, diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 4cdb3e2f7..6e8471673 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -2,9 +2,9 @@ import { initializeWasm, Repo } from "@automerge/vanillajs/slim"; import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"; import { loadOrCreateSigner } from "@inkandswitch/patchwork-bootloader/signer"; import { - getSyncRemote, - relayKeyhive, -} from "@inkandswitch/patchwork-bootloader"; + AUTOMERGE_WASM, + SUBDUCTION_WASM, +} from "@inkandswitch/patchwork-bootloader/emitted"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; import type { AutomergeRepoKeyhive, @@ -39,16 +39,22 @@ let wasmReady: Promise | undefined; export function initWasm(): Promise { if (!wasmReady) { wasmReady = Promise.all([ - initializeWasm(new Request("/automerge.wasm")), - initSubduction(new Request("/subduction.wasm")), + initializeWasm(new Request(AUTOMERGE_WASM)), + initSubduction(new Request(SUBDUCTION_WASM)), ]).then(() => undefined); } return wasmReady; } -// A tab tells the sync node about an edit only once it has saved it, so the -// save throttle (automerge-repo's default is 100ms) is on the path from an -// edit to the other tabs and the server. +/** + * The BroadcastChannel every Repo on the origin announces its saves and the + * server's heads on (automerge-repo's `headsChannel`). + */ +export const HEADS_CHANNEL = `${storagePrefix}-heads`; + +// Other tabs hear about an edit, and the server is sent it, only once it has +// been saved, so the save throttle (automerge-repo's default is 100ms) is on +// the path from an edit to both. const SAVE_THROTTLE_MS = 20; export type TabRepo = { @@ -58,11 +64,10 @@ export type TabRepo = { }; /** - * The tab's own Repo: this origin's IndexedDB, opened on this thread, and the - * storage channel to every other Repo on the origin. It has no connection of - * its own; it syncs through the sync node in the automerge worker, which holds - * the origin's one socket to the sync server. Every Repo on the origin signs - * as the same identity. + * The tab's own node: this origin's IndexedDB, opened on this thread, a socket + * to the sync server, and the storage channel to every other Repo on the + * origin. Nothing is shared with other tabs except the database underneath + * and the identity every node on the origin signs with. */ export async function createRepo(): Promise { if (syncServer.keyhive) { @@ -70,18 +75,14 @@ export async function createRepo(): Promise { const { initKeyhiveWasm, initializeAutomergeRepoKeyhive } = await import("@automerge/automerge-repo-keyhive"); initKeyhiveWasm(); - const remote = getSyncRemote(); const { hive, repo } = await initializeAutomergeRepoKeyhive({ // ARK injects an `idFactory` deriving document ids from keyhive. A site // can opt out of it with `keyhive: { useIdFactory: false }`. createRepo: ({ idFactory, ...repoConfig }) => - relayKeyhive( - new Repo( - syncServer.useIdFactory === false - ? repoConfig - : { ...repoConfig, idFactory } - ), - remote + new Repo( + syncServer.useIdFactory === false + ? repoConfig + : { ...repoConfig, idFactory } ), storage: new IndexedDBStorageAdapter(keyhiveStorageName), peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2), @@ -91,9 +92,9 @@ export async function createRepo(): Promise { syncServer: syncServer.keyhive, repo: { storage: new IndexedDBStorageAdapter(), - subductionRemote: remote, + subductionWebsocketEndpoints: [syncServer.url], subductionSaveThrottle: SAVE_THROTTLE_MS, - headsChannel: `${storagePrefix}-heads`, + headsChannel: HEADS_CHANNEL, enableRemoteHeadsGossiping: true, }, }); @@ -111,9 +112,9 @@ export async function createRepo(): Promise { storage, peerId: `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId, - subductionRemote: getSyncRemote(), + subductionWebsocketEndpoints: [syncServer.url], subductionSaveThrottle: SAVE_THROTTLE_MS, - headsChannel: `${storagePrefix}-heads`, + headsChannel: HEADS_CHANNEL, enableRemoteHeadsGossiping: true, }); const signerIdentity = { diff --git a/core/patchwork/src/site-kit/index.ts b/core/patchwork/src/site-kit/index.ts index 45d25131d..d2a6932bb 100644 --- a/core/patchwork/src/site-kit/index.ts +++ b/core/patchwork/src/site-kit/index.ts @@ -21,4 +21,10 @@ export { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js"; export { getIcons, ICON_SPECS, type IconSpec } from "./icons.js"; export { buildHtml, escapeHtml } from "./html.js"; export { buildManifest } from "./manifest.js"; -export { buildHeaders, REDIRECTS } from "./netlify.js"; +export { + buildHeaders, + buildRedirects, + REDIRECTS, + REVALIDATED_PATHS, + REVALIDATED_CACHE_CONTROL, +} from "./netlify.js"; diff --git a/core/patchwork/src/site-kit/netlify.ts b/core/patchwork/src/site-kit/netlify.ts index 0a09a6094..3ff87278e 100644 --- a/core/patchwork/src/site-kit/netlify.ts +++ b/core/patchwork/src/site-kit/netlify.ts @@ -1,9 +1,26 @@ import type { PatchworkSiteOptions } from "./options.js"; import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js"; -function buildLinkHeader(syncServers: string[]): string { +/** + * Files a build emits at a fixed path whose copy the browser may start from + * when it can't check for a newer one, so they start offline: a worker's + * script, which every build is started by. All it imports is its own build's + * hashed files, so a copy from an older build still brings up a whole build. + * The service worker isn't here: the browser checks it for updates past its + * cache. + */ +export const REVALIDATED_PATHS = ["/automerge-protocol-handler-worker.js"]; + +export const REVALIDATED_CACHE_CONTROL = + "public, max-age=0, stale-while-revalidate=31536000"; + +function buildLinkHeader( + syncServers: string[], + hashed: ReadonlyMap +): string { const parts = PRELOAD_WASM_ASSETS.map( - (asset) => `; rel=preload; as=fetch; crossorigin` + (asset) => + `<${hashed.get(`/${asset}`) ?? `/${asset}`}>; rel=preload; as=fetch; crossorigin` ); for (const server of syncServers) { parts.push(`<${server}>; rel=preconnect`); @@ -14,8 +31,15 @@ function buildLinkHeader(syncServers: string[]): string { return parts.join(", "); } -/** Builds the generated Netlify _headers file content — no bundler involved. */ -export function buildHeaders(options: PatchworkSiteOptions): string { +/** + * Builds the generated Netlify _headers file content — no bundler involved. + * `hashed` is each fixed path the build emitted under a hashed name, with + * that name's URL, so the preloads name the file the page will fetch. + */ +export function buildHeaders( + options: PatchworkSiteOptions, + hashed: ReadonlyMap = new Map() +): string { const syncServers = resolveSyncServers(options); const immutableAssets = options.netlify === false || options.netlify?.immutableAssets === false @@ -25,11 +49,29 @@ export function buildHeaders(options: PatchworkSiteOptions): string { return [ "/*\n Access-Control-Allow-Origin: *", immutableAssets, - `/\n Link: ${buildLinkHeader(syncServers)}`, + ...REVALIDATED_PATHS.map( + (path) => `${path}\n Cache-Control: ${REVALIDATED_CACHE_CONTROL}\n` + ), + `/\n Link: ${buildLinkHeader(syncServers, hashed)}`, ] .filter(Boolean) .join("\n"); } -/** The generated Netlify _redirects file content — always the same SPA fallback. */ +/** The SPA fallback every generated _redirects file ends with. */ export const REDIRECTS = "/* /index.html 200\n"; + +/** + * The generated Netlify _redirects file content: from each fixed path the + * build emitted under a hashed name (see buildHeaders) to that name, for code + * that still asks for the fixed path, then the SPA fallback. A 302, since a + * browser keeps a 301 and the next build's file is somewhere else. + */ +export function buildRedirects( + hashed: ReadonlyMap = new Map() +): string { + return ( + [...hashed].map(([path, url]) => `${path} ${url} 302\n`).join("") + + REDIRECTS + ); +} diff --git a/core/patchwork/src/vite/config-plugin.ts b/core/patchwork/src/vite/config-plugin.ts index a72eeff2d..02d623a42 100644 --- a/core/patchwork/src/vite/config-plugin.ts +++ b/core/patchwork/src/vite/config-plugin.ts @@ -8,6 +8,11 @@ import { DEFAULT_SYNC_SERVERS, resolvePrimarySyncServer, } from "../site-kit/sync-servers.js"; +import { + REVALIDATED_CACHE_CONTROL, + REVALIDATED_PATHS, +} from "../site-kit/netlify.js"; +import { builtHashedFiles } from "./hashed-plugin.js"; const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" }; @@ -97,18 +102,29 @@ export function config(options: PatchworkVitePluginOptions = {}): Plugin { // page's service worker, so offline boot needs the browser's HTTP cache to // serve them without revalidating. Content hashes make that safe; a new // build gets new URLs. Production gets this from the generated _headers - // file — vite preview doesn't read that, so mirror it here. + // and _redirects files — vite preview reads neither, so mirror them here. + // The redirects are mirrored either way: code a site bundles as its own + // worker still asks for the fixed paths (see hashed-plugin.ts). configurePreviewServer(server) { - if ( - options.netlify === false || - options.netlify?.immutableAssets === false - ) { - return; - } + const headers = + options.netlify !== false && options.netlify?.immutableAssets !== false; + let redirects: Map | undefined; server.middlewares.use((req, res, next) => { - if (req.url?.startsWith("/assets/")) { + const path = req.url?.split("?")[0] ?? ""; + redirects ??= builtHashedFiles(server.config); + const to = redirects.get(path); + if (to) { + res.statusCode = 302; + res.setHeader("Location", to); + res.end(); + return; + } + if (headers && path.startsWith("/assets/")) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } + if (headers && REVALIDATED_PATHS.includes(path)) { + res.setHeader("Cache-Control", REVALIDATED_CACHE_CONTROL); + } next(); }); }, diff --git a/core/patchwork/src/vite/dev-plugin.ts b/core/patchwork/src/vite/dev-plugin.ts index 57c7cc165..ae4be7380 100644 --- a/core/patchwork/src/vite/dev-plugin.ts +++ b/core/patchwork/src/vite/dev-plugin.ts @@ -28,8 +28,8 @@ const builtinPaths = new Map(chunks.map((id) => [builtins[id]!, id])); /** * Workers are `type: "module"` scripts the browser fetches directly, so import * maps don't apply to them and their heavy imports have to resolve to real - * URLs. The build rewrites those to the `/packages/...` chunks it emits; here - * they become the dev server's own optimized-dep URLs. + * URLs. A build links them to the chunks it emits for those; here they become + * the dev server's own optimized-dep URLs. */ function externalBuiltins(): esbuild.Plugin { return { diff --git a/core/patchwork/src/vite/hashed-plugin.ts b/core/patchwork/src/vite/hashed-plugin.ts new file mode 100644 index 000000000..b2e6fbf07 --- /dev/null +++ b/core/patchwork/src/vite/hashed-plugin.ts @@ -0,0 +1,168 @@ +import type { Plugin, ResolvedConfig } from "vite"; +import type { PluginContext } from "rollup"; +import { fileURLToPath } from "node:url"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * The files a build emits under content-hashed names that the dev server + * serves at a fixed path: the `/packages/…` chunks, the wasm, and the + * automerge worker's second stage. A fixed path can only be revalidated, + * which a browser that's offline can't do, or cached on a guess, which mixes + * one build's files with another's after a deploy. A hashed one can be kept + * forever. The automerge worker's script and the service worker keep fixed + * paths, since the browser starts them by URL across builds. + * + * One per build, shared by the plugins that emit these files and the ones + * that write their URLs out. + */ +export class HashedFiles { + #refs = new Map(); + #urls: Map | undefined; + #emitting: Promise[] = []; + + /** + * Say a plugin will add files here in its buildStart, from configResolved, + * which every plugin has had before any buildStart. Call what this returns + * once they're added. + */ + expect(): () => void { + let done!: () => void; + this.#emitting.push(new Promise((resolve) => (done = resolve))); + return done; + } + + /** Resolves once every plugin that said it would add files has. */ + get added(): Promise { + return Promise.all(this.#emitting).then(() => {}); + } + + /** Note that the file emitted as `ref` is what's at `path` in dev. */ + add(path: string, ref: string): void { + this.#refs.set(path, ref); + } + + ref(path: string): string | undefined { + return this.#refs.get(path); + } + + /** Called in generateBundle, the first point the file names are known. */ + resolve(context: PluginContext, base: string): void { + this.#urls = new Map( + [...this.#refs].map(([path, ref]) => [ + path, + `${base}${context.getFileName(ref)}`, + ]) + ); + } + + /** Each fixed path's hashed URL. Only once the bundle is being written. */ + get urls(): ReadonlyMap { + if (!this.#urls) { + throw new Error( + "[patchwork] the hashed file names aren't known until generateBundle" + ); + } + return this.#urls; + } +} + +const builds = new WeakMap(); + +export function hashedFiles(config: ResolvedConfig): HashedFiles { + let files = builds.get(config); + if (!files) builds.set(config, (files = new HashedFiles())); + return files; +} + +// Where a build leaves its table of hashed names for `vite preview`, which +// redirects the fixed paths the way a deploy's _redirects does, whether or +// not the site writes one. By output directory, so builds to two don't mix. +const tableFile = (config: ResolvedConfig) => + join(config.cacheDir, "patchwork-hashed.json"); +const outDir = (config: ResolvedConfig) => + resolve(config.root, config.build.outDir); + +function readTables( + config: ResolvedConfig +): Record> { + try { + return JSON.parse(readFileSync(tableFile(config), "utf8")); + } catch { + return {}; + } +} + +/** The hashed URL of each fixed path, as the last build to `outDir` wrote them. */ +export function builtHashedFiles(config: ResolvedConfig): Map { + return new Map(Object.entries(readTables(config)[outDir(config)] ?? {})); +} + +const self = fileURLToPath(import.meta.url); +const EMITTED = "@inkandswitch/patchwork-bootloader/emitted"; + +/** + * Points everything at the hashed names in a build: the paths in the + * bootloader's emitted.ts, which the page and the workers load these files + * by, and the import map and wasm preloads in index.html. + */ +export function hashed(): Plugin { + let config: ResolvedConfig; + let files: HashedFiles; + let emittedId: string | undefined; + + return { + name: "@patchwork/hashed", + configResolved(resolved) { + config = resolved; + files = hashedFiles(resolved); + }, + async buildStart() { + if (config.command !== "build") return; + emittedId = (await this.resolve(EMITTED, self))?.id; + if (!emittedId) { + this.error(`couldn't resolve ${EMITTED}`); + } + }, + async transform(code, id) { + if (id !== emittedId) return; + // The bundler loads each chunk as soon as it's emitted, so this can + // come before the rest of the files are. + await files.added; + // The bundler turns each into the emitted file's URL. Rollup and + // rolldown both do this one, where resolveImportMeta is rollup's alone. + return code.replace(/"(\/[^"]+)"/g, (_, path: string) => { + const ref = files.ref(path); + if (!ref) { + this.error( + `${path} in ${EMITTED} isn't a file this build emitted, so nothing would be there` + ); + } + return `import.meta.ROLLUP_FILE_URL_${ref}`; + }); + }, + generateBundle: { + // Before vite writes index.html, which reads the names. + order: "pre", + handler() { + files.resolve(this, config.base); + }, + }, + writeBundle() { + const tables = readTables(config); + tables[outDir(config)] = Object.fromEntries(files.urls); + mkdirSync(config.cacheDir, { recursive: true }); + writeFileSync(tableFile(config), JSON.stringify(tables, null, 2)); + }, + transformIndexHtml: { + order: "post", + handler(html, context) { + if (!context.bundle) return html; + for (const [path, url] of files.urls) { + html = html.replaceAll(`"${path}"`, `"${url}"`); + } + return html; + }, + }, + }; +} diff --git a/core/patchwork/src/vite/importmap-plugin.ts b/core/patchwork/src/vite/importmap-plugin.ts index 05c57b233..8fca7c3fb 100644 --- a/core/patchwork/src/vite/importmap-plugin.ts +++ b/core/patchwork/src/vite/importmap-plugin.ts @@ -1,10 +1,11 @@ -import type { Plugin } from "vite"; +import type { Plugin, ResolvedConfig } from "vite"; import type { ImportMap, PatchworkVitePluginOptions, } from "./patchwork-plugin.js"; import { fileURLToPath } from "node:url"; import { relative } from "node:path"; +import { hashedFiles } from "./hashed-plugin.js"; /** * bootloader owns resolving/emitting its own dependencies (it's the one that @@ -17,6 +18,8 @@ import externals, { emitWasmAssets, } from "@inkandswitch/patchwork-bootloader/externals"; +// The paths the dev server serves these at, and the import map's in a build +// until the hashed plugin swaps in the hashed names they're emitted under. export const builtins = externals.reduce( (builtins, name) => ( (builtins[name] = `/packages/${slim[name] ?? name}.js`), @@ -82,7 +85,9 @@ export function devDependencyId(id: string): string { export function importmap(options?: PatchworkVitePluginOptions): Plugin { const { importmap, builtins } = createImportMap(options); + let config: ResolvedConfig; let serve = false; + let added: (() => void) | undefined; return { name: "@patchwork/vite", config() { @@ -92,22 +97,29 @@ export function importmap(options?: PatchworkVitePluginOptions): Plugin { }, }; }, - configResolved(config) { - serve = config.command === "serve"; + configResolved(resolved) { + config = resolved; + serve = resolved.command === "serve"; + if (!serve) added = hashedFiles(resolved).expect(); }, async buildStart() { if (serve) return; + const files = hashedFiles(config); for (const id of chunks) { - this.emitFile({ + const path = builtins[id]!; + const ref = this.emitFile({ type: "chunk", - fileName: builtins[id]!.slice(1), + name: path.slice("/packages/".length, -".js".length), id: await resolveBuiltin(this, id), preserveSignature: "strict", }); + files.add(path, ref); } - // Emitted so the service worker can fetch them - emitWasmAssets.call(this); + for (const [fileName, ref] of emitWasmAssets.call(this)) { + files.add(`/${fileName}`, ref); + } + added?.(); }, async resolveId(id) { if (id in builtins) { diff --git a/core/patchwork/src/vite/netlify-plugin.ts b/core/patchwork/src/vite/netlify-plugin.ts index 472e70811..b3d3ad19b 100644 --- a/core/patchwork/src/vite/netlify-plugin.ts +++ b/core/patchwork/src/vite/netlify-plugin.ts @@ -1,33 +1,39 @@ -import type { Plugin } from "vite"; +import type { Plugin, ResolvedConfig } from "vite"; import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js"; -import { buildHeaders, REDIRECTS } from "../site-kit/netlify.js"; +import { buildHeaders, buildRedirects } from "../site-kit/netlify.js"; +import { hashedFiles } from "./hashed-plugin.js"; /** * Netlify's _headers/_redirects are only meaningful on an actual Netlify * deploy, so this only runs for the production build — no dev middleware. * The Link header shares its sync-server/wasm-asset lists with html-plugin - * so the two never drift out of sync with each other. + * so the two never drift out of sync with each other. Both are written once + * the hashed file names are known, which they name. */ export function netlify( options: PatchworkVitePluginOptions = {} ): Plugin | null { if (options.netlify === false) return null; - const headers = buildHeaders(options); - let isBuild = false; + let config: ResolvedConfig; return { name: "@patchwork/netlify", - configResolved(config) { - isBuild = config.command === "build"; + configResolved(resolved) { + config = resolved; }, - buildStart() { - if (!isBuild) return; - this.emitFile({ type: "asset", fileName: "_headers", source: headers }); + generateBundle() { + if (config.command !== "build") return; + const { urls } = hashedFiles(config); + this.emitFile({ + type: "asset", + fileName: "_headers", + source: buildHeaders(options, urls), + }); this.emitFile({ type: "asset", fileName: "_redirects", - source: REDIRECTS, + source: buildRedirects(urls), }); }, }; diff --git a/core/patchwork/src/vite/patchwork-plugin.ts b/core/patchwork/src/vite/patchwork-plugin.ts index d2c4870f4..48fc572c4 100644 --- a/core/patchwork/src/vite/patchwork-plugin.ts +++ b/core/patchwork/src/vite/patchwork-plugin.ts @@ -9,6 +9,7 @@ import { icons } from "./icons.js"; import { html } from "./html-plugin.js"; import { manifest } from "./manifest-plugin.js"; import { netlify } from "./netlify-plugin.js"; +import { hashed } from "./hashed-plugin.js"; import { statics, type PatchworkStaticSource } from "./static-plugin.js"; import { buildInfo } from "./build-info-plugin.js"; import type { PatchworkSiteOptions } from "../site-kit/options.js"; @@ -45,6 +46,7 @@ export default function patchwork(options?: PatchworkVitePluginOptions) { importmap(options), patches(), serviceworker(), + hashed(), dev(options), statics(options), buildInfo(options), @@ -60,6 +62,7 @@ export { icons } from "./icons.js"; export { html } from "./html-plugin.js"; export { manifest } from "./manifest-plugin.js"; export { netlify } from "./netlify-plugin.js"; +export { hashed, hashedFiles, HashedFiles } from "./hashed-plugin.js"; export { statics, resolveStatic } from "./static-plugin.js"; export { buildInfo } from "./build-info-plugin.js"; diff --git a/core/patchwork/src/vite/service-worker-plugin.ts b/core/patchwork/src/vite/service-worker-plugin.ts index 9ea9094a1..cdcd0e74c 100644 --- a/core/patchwork/src/vite/service-worker-plugin.ts +++ b/core/patchwork/src/vite/service-worker-plugin.ts @@ -1,6 +1,6 @@ -import type { Plugin } from "vite"; +import type { Plugin, ResolvedConfig } from "vite"; import { fileURLToPath } from "node:url"; -import { builtins } from "./importmap-plugin.js"; +import { hashedFiles } from "./hashed-plugin.js"; // Anchor resolution at this file (inside @inkandswitch/patchwork's own // installed directory) rather than the site root — these worker specifiers @@ -10,10 +10,15 @@ import { builtins } from "./importmap-plugin.js"; const self = fileURLToPath(import.meta.url); // The service worker and the automerge shared worker are emitted as their -// own chunks. Their heavy imports are marked external and resolved to -// /packages/... URLs (both workers are created with type:"module", so the -// browser fetches those as regular network requests). -export const workers = [ +// own chunks. `fileName` is where the dev server serves each one. In a build +// the `hashed` ones are emitted under a hashed name instead (see +// hashed-plugin.ts), and loaded by the URL in the bootloader's emitted.ts; the +// others are started by URL from one build to the next, so they keep theirs. +export const workers: { + specifier: string; + fileName: string; + hashed?: boolean; +}[] = [ { specifier: "@inkandswitch/patchwork-bootloader/service-worker", fileName: "service-worker.js", @@ -28,16 +33,22 @@ export const workers = [ { specifier: "@inkandswitch/patchwork-bootloader/automerge-protocol-handler", fileName: "automerge-protocol-handler.js", + hashed: true, }, { specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker", fileName: "module-loader-worker.js", + hashed: true, }, ]; export function serviceworker(): Plugin { - const entryIds = new Set(); - let serve = false; + // The files they were written to, which vite's module preloading has to + // leave alone: it adds tags to a document a worker doesn't have. + const workerFiles = new Set(workers.map(({ fileName }) => fileName)); + const refs: string[] = []; + let config: ResolvedConfig; + let added: (() => void) | undefined; return { name: "@patchwork/service-worker", @@ -47,31 +58,42 @@ export function serviceworker(): Plugin { build: { modulePreload: { resolveDependencies: (_url, deps, { hostId }) => - workers.some(({ fileName }) => fileName === hostId) ? [] : deps, + workerFiles.has(hostId) ? [] : deps, }, }, }; }, - configResolved(config) { - serve = config.command === "serve"; + configResolved(resolved) { + config = resolved; + if (resolved.command === "build") added = hashedFiles(resolved).expect(); }, async buildStart() { // emitFile throws in serve mode. - if (serve) return; - for (const { specifier, fileName } of workers) { + if (config.command === "serve") return; + const files = hashedFiles(config); + refs.length = 0; + for (const { specifier, fileName, hashed } of workers) { const resolved = await this.resolve(specifier, self); - entryIds.add(resolved!.id); - this.emitFile({ + const ref = this.emitFile({ type: "chunk", id: resolved!.id, - fileName, + ...(hashed + ? { name: fileName.slice(0, -".js".length) } + : { fileName }), }); + if (hashed) { + files.add(`/${fileName}`, ref); + refs.push(ref); + } } + added?.(); }, - resolveId(source, importer) { - if (importer && entryIds.has(importer) && source in builtins) { - return { id: builtins[source], external: true }; - } + generateBundle: { + // Before vite's own generateBundle, which renders the preloads. + order: "pre", + handler() { + for (const ref of refs) workerFiles.add(this.getFileName(ref)); + }, }, }; } diff --git a/core/patchwork/src/watch-document.ts b/core/patchwork/src/watch-document.ts new file mode 100644 index 000000000..9e999d7b5 --- /dev/null +++ b/core/patchwork/src/watch-document.ts @@ -0,0 +1,80 @@ +import { SedimentreeId } from "@automerge/automerge-subduction/slim"; +import { + parseAutomergeUrl, + type AutomergeUrl, + type Repo, +} from "@automerge/automerge-repo/slim"; +import debug from "debug"; +import { HEADS_CHANNEL } from "./repo.js"; + +const log = debug("patchwork:watch"); + +// A watched document has no Repo waiting on its round to pick a deadline. +const WATCH_SYNC_TIMEOUT_MS = 60_000; + +/** What a Repo on the origin relays on {@link HEADS_CHANNEL} of a server's heads. */ +type RemoteHeadsMessage = { kind: "remote"; sid: string; heads: string[] }; + +const summarize = (heads: readonly string[]) => [...heads].sort().join("|"); + +/** + * Call `onChange` whenever the document at `url` may have new heads, until the + * returned function is called, without loading it into `repo`. The tab's + * Subduction node subscribes to the document with the server, which pushes + * each change along with its heads. Every Repo on the origin, this tab's + * included, relays the server's heads on the heads channel whether or not it + * holds the document, so listening there hears this tab's pushes and other + * tabs' writes alike: a write comes back as the heads the server acknowledges + * it with. + * + * `seen` sums up the server's heads: the same `seen` twice means nothing + * arrived in between. It isn't a version of the document: Subduction's heads + * can include ancestors of the Automerge heads, so find the document to know + * where it is. + * + * Subduction can't unsubscribe from one document, so the server keeps + * pushing it to this tab after the watch stops; nobody's told. + */ +export function watchDocument( + repo: Repo, + url: AutomergeUrl, + onChange: (seen: string) => void +): () => void { + const { binaryDocumentId } = parseAutomergeUrl(url); + // A document's 16 id bytes, zero-padded to 32. + const bytes = new Uint8Array(32); + bytes.set(binaryDocumentId.subarray(0, 32)); + const sid = SedimentreeId.fromBytes(bytes); + const sidHex = sid.toString(); + let stopped = false; + + // The server forgets subscriptions with the connection, so this runs again + // each time the Repo comes back online. + const subscribe = () => { + if (stopped || !repo.isSubductionConnected()) return; + repo.subduction + .then((subduction) => + subduction.syncWithAllPeers(sid, true, WATCH_SYNC_TIMEOUT_MS) + ) + .catch((error) => log(`couldn't subscribe to ${url}`, error)); + }; + + const onConnection = ({ connected }: { connected: boolean }) => { + if (connected) subscribe(); + }; + const channel = new BroadcastChannel(HEADS_CHANNEL); + channel.onmessage = ({ data }: MessageEvent) => { + if (data?.kind === "remote" && data.sid === sidHex) { + onChange(summarize(data.heads)); + } + }; + + repo.on("subduction-connection", onConnection); + subscribe(); + + return () => { + stopped = true; + repo.off("subduction-connection", onConnection); + channel.close(); + }; +} diff --git a/core/bootloader/test/setup.ts b/core/patchwork/test/setup.ts similarity index 100% rename from core/bootloader/test/setup.ts rename to core/patchwork/test/setup.ts diff --git a/core/patchwork/test/watch-document.test.ts b/core/patchwork/test/watch-document.test.ts new file mode 100644 index 000000000..698360394 --- /dev/null +++ b/core/patchwork/test/watch-document.test.ts @@ -0,0 +1,161 @@ +// Watching a document through a tab's own Subduction node. The "sync server" +// is an in-process Subduction node reached over a MessageChannel; an origin is +// its tabs' shared storage and signer, and "other" is someone else's Repo. +import { afterEach, describe, expect, it } from "vitest"; +import { + Repo, + type AutomergeUrl, + type ManagedTransport, + type PeerId, + type WebSocketEndpointInterface, +} from "@automerge/automerge-repo"; +import { + MemorySigner, + MemoryStorage, + MessagePortTransport, + Subduction, +} from "@automerge/automerge-subduction"; +import { DummyStorageAdapter } from "@automerge/automerge-repo/helpers/DummyStorageAdapter.js"; + +import { HEADS_CHANNEL } from "../src/repo.js"; +import { watchDocument } from "../src/watch-document.js"; + +const SERVICE = "sync.test"; + +type Server = { subduction: Subduction }; + +function startServer(): Server { + return { + subduction: new Subduction({ + signer: MemorySigner.generate(), + storage: new MemoryStorage(), + serviceName: SERVICE, + }), + }; +} + +function endpointTo(server: Server): WebSocketEndpointInterface { + return { + url: `ws://${SERVICE}`, + async connect() { + const { port1, port2 } = new MessageChannel(); + void server.subduction + .acceptTransport(new MessagePortTransport(port2), SERVICE) + .catch(() => {}); + const transport = new MessagePortTransport(port1); + const closed = new Promise((resolve) => + transport.onDisconnect(() => resolve()) + ); + return Object.assign(transport, { + closed: () => closed, + }) as unknown as ManagedTransport; + }, + }; +} + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((stop) => stop().catch(() => {}))); +}); + +let repos = 0; + +function connectedRepo(server: Server, config: Partial[0]> = {}) { + const repo = new Repo({ + peerId: `repo-${repos++}` as PeerId, + subductionWebsocketEndpoints: [endpointTo(server)], + ...config, + }); + cleanup.push(() => repo.shutdown()); + return repo; +} + +/** Tabs on one origin: one database, one signer, one heads channel. */ +function origin(server: Server) { + const seed = crypto.getRandomValues(new Uint8Array(32)); + const storage = new DummyStorageAdapter(); + return () => + connectedRepo(server, { + storage, + signer: MemorySigner.fromBytes(seed), + headsChannel: HEADS_CHANNEL, + }); +} + +async function eventually(check: () => boolean, ms = 10_000) { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) throw new Error(`not within ${ms}ms`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +const connected = (repo: Repo) => eventually(() => repo.isSubductionConnected()); + +type Doc = { count: number }; + +/** Watch `url` from `repo`, recording every `seen`. */ +function record(repo: Repo, url: AutomergeUrl) { + const heard: Array = []; + const unwatch = watchDocument(repo, url, (seen) => heard.push(seen)); + cleanup.push(async () => unwatch()); + return heard; +} + +describe("watchDocument", () => { + it("hears someone else's change without loading the document", async () => { + const server = startServer(); + const other = connectedRepo(server); + const handle = other.create({ count: 0 }); + await connected(other); + await other.flush(); + + const tab = origin(server)(); + await connected(tab); + const heard = record(tab, handle.url); + await eventually(() => heard.length > 0); + const before = heard.at(-1); + + handle.change((doc) => void doc.count++); + await eventually(() => heard.some((seen) => seen && seen !== before)); + + expect(tab.handles[handle.documentId]).toBeUndefined(); + }); + + it("hears a change another tab on the origin makes", async () => { + const server = startServer(); + const tab = origin(server); + const writer = tab(); + const watcher = tab(); + await Promise.all([connected(writer), connected(watcher)]); + const handle = writer.create({ count: 0 }); + await writer.flush(); + + const heard = record(watcher, handle.url); + const already = heard.length; + handle.change((doc) => void doc.count++); + await eventually(() => heard.length > already); + + expect(watcher.handles[handle.documentId]).toBeUndefined(); + }); + + it("stops calling once unwatched", async () => { + const server = startServer(); + const other = connectedRepo(server); + const handle = other.create({ count: 0 }); + await connected(other); + await other.flush(); + + const tab = origin(server)(); + await connected(tab); + const heard: Array = []; + const unwatch = watchDocument(tab, handle.url, (seen) => heard.push(seen)); + await eventually(() => heard.length > 0); + unwatch(); + const stopped = heard.length; + + handle.change((doc) => void doc.count++); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + expect(heard.length).toBe(stopped); + }); +}); diff --git a/core/bootloader/vitest.config.ts b/core/patchwork/vitest.config.ts similarity index 100% rename from core/bootloader/vitest.config.ts rename to core/patchwork/vitest.config.ts diff --git a/packages/e2e/tests/offline.spec.ts b/packages/e2e/tests/offline.spec.ts index c6bcad7de..fd4c975f3 100644 --- a/packages/e2e/tests/offline.spec.ts +++ b/packages/e2e/tests/offline.spec.ts @@ -81,10 +81,14 @@ test("the app boots from the SW cache while offline and keeps its docs", async ( .poll( () => page.evaluate(async () => { + // The wasm is under hashed names; the page preloads it by them. const urls = [ location.href, - new URL("/automerge.wasm", location.origin).href, - new URL("/subduction.wasm", location.origin).href, + ...[ + ...document.querySelectorAll( + 'link[rel="preload"][as="fetch"]', + ), + ].map((link) => link.href), ]; const matches = await Promise.all(urls.map((u) => caches.match(u))); return matches.filter((m) => !m).length; diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch index d55ec4904..083083ac5 100644 --- a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch +++ b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch @@ -12,24 +12,15 @@ index 08d36a6efcf9b93ba170dfe8a6a54e51a1b8f92d..fafdbda628f968bb266588d5a45192f5 /** * Optional: drain any pending writes for the given documents (or all diff --git a/dist/Repo.d.ts b/dist/Repo.d.ts -index 15d4ca65aa613fb567335f09c8fcb8cb9c4a954a..28123251648adbceb7ad23b5ae2e177faf5a187f 100644 +index 15d4ca65aa613fb567335f09c8fcb8cb9c4a954a..d1839005b1a01612be7bd85e9369443f81e7af3a 100644 --- a/dist/Repo.d.ts +++ b/dist/Repo.d.ts -@@ -11,7 +11,7 @@ import { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js - import type { AnyDocumentId, AutomergeUrl, DocumentId, PeerId, UrlHeads } from "./types.js"; - import { AbortOptions } from "./helpers/abortable.js"; - import { type Subduction } from "@automerge/automerge-subduction/slim"; --import { type SubductionTimeouts, type BlobInterceptor } from "./subduction/source.js"; -+import { type SubductionTimeouts, type BlobInterceptor, type SubductionRemote } from "./subduction/source.js"; - import type { WebSocketEndpointInterface } from "./subduction/websocket-endpoint.js"; - import type { Policy as SubductionPolicy } from "@automerge/automerge-subduction/slim"; - export type { FindProgressWithMethods, ProgressSignal } from "./_compat.js"; @@ -36,7 +36,7 @@ export declare class Repo extends EventEmitter { /** maps peer id to to persistence information (storageId, isEphemeral), access by collection synchronizer */ /** @hidden */ peerMetadataByPeerId: Record; - constructor({ storage, network, peerId, sharePolicy, shareConfig, isEphemeral, enableRemoteHeadsGossiping, denylist, saveDebounceRate, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, }?: RepoConfig); -+ constructor({ storage, network, peerId, sharePolicy, shareConfig, isEphemeral, enableRemoteHeadsGossiping, denylist, saveDebounceRate, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, subductionSaveThrottle, headsChannel, subductionRemote, }?: RepoConfig); ++ constructor({ storage, network, peerId, sharePolicy, shareConfig, isEphemeral, enableRemoteHeadsGossiping, denylist, saveDebounceRate, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, subductionSaveThrottle, headsChannel, }?: RepoConfig); /** Returns all the handles we have cached. */ get handles(): Record>; /** Returns a list of all connected peer ids */ @@ -47,30 +38,21 @@ index 15d4ca65aa613fb567335f09c8fcb8cb9c4a954a..28123251648adbceb7ad23b5ae2e177f }[]; /** * Tunable timeouts for the Subduction sync engine and its -@@ -284,6 +287,21 @@ export interface RepoConfig { +@@ -284,6 +287,12 @@ export interface RepoConfig { * `storage` when only one of the two Repos runs the interceptor. */ subductionBlobInterceptor?: BlobInterceptor; + /** + * How long, in ms, a document's subduction save waits for further changes -+ * to batch with. Defaults to 100. A Repo syncing through a -+ * `subductionRemote` only tells the remote about a change once it has -+ * saved it, so this is on the path from an edit to everyone else. ++ * to batch with. Defaults to 100. + */ + subductionSaveThrottle?: number; + headsChannel?: string; -+ /** -+ * Sync through another node rather than over this Repo's own -+ * connections, e.g. one SharedWorker doing the networking for every tab -+ * on an origin. The remote must share this Repo's `storage`. Leave -+ * `subductionWebsocketEndpoints` and `subductionAdapters` unset with it. -+ */ -+ subductionRemote?: SubductionRemote; } /** A function that determines whether we should share a document with a peer * diff --git a/dist/Repo.js b/dist/Repo.js -index adfddd9b3926510604274217c42f97f104e119a3..096ca1f8c00cf6cd73aeed8c4fded6211601f431 100644 +index adfddd9b3926510604274217c42f97f104e119a3..6662564892a1ba850e12072fac41bf37f644a826 100644 --- a/dist/Repo.js +++ b/dist/Repo.js @@ -57,7 +57,7 @@ export class Repo extends EventEmitter { @@ -78,32 +60,20 @@ index adfddd9b3926510604274217c42f97f104e119a3..096ca1f8c00cf6cd73aeed8c4fded621 #idFactory; #peerId; - constructor({ storage, network = [], peerId = randomPeerId(), sharePolicy, shareConfig, isEphemeral = storage === undefined, enableRemoteHeadsGossiping = false, denylist = [], saveDebounceRate = 100, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, } = {}) { -+ constructor({ storage, network = [], peerId = randomPeerId(), sharePolicy, shareConfig, isEphemeral = storage === undefined, enableRemoteHeadsGossiping = false, denylist = [], saveDebounceRate = 100, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, subductionSaveThrottle, headsChannel, subductionRemote, } = {}) { ++ constructor({ storage, network = [], peerId = randomPeerId(), sharePolicy, shareConfig, isEphemeral = storage === undefined, enableRemoteHeadsGossiping = false, denylist = [], saveDebounceRate = 100, idFactory, signer, subductionPolicy, subductionWebsocketEndpoints, subductionAdapters, subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, subductionSaveThrottle, headsChannel, } = {}) { super(); this.#peerId = peerId; this.#remoteHeadsGossipingEnabled = enableRemoteHeadsGossiping; -@@ -119,6 +119,9 @@ export class Repo extends EventEmitter { +@@ -119,6 +119,8 @@ export class Repo extends EventEmitter { timeouts: subductionTimeouts, compactionDeleteConcurrency: subductionCompactionDeleteConcurrency, blobInterceptor: subductionBlobInterceptor, + saveThrottle: subductionSaveThrottle, + headsChannel, -+ remote: subductionRemote, onRemoteHeadsChanged: (documentId, storageId, heads, timestamp) => { const handle = this.#queries[documentId]?.handle; if (handle) { -@@ -653,9 +656,7 @@ export class Repo extends EventEmitter { - const source = this.#subductionSource; - if (!source) - return []; -- const subduction = await source.getSubduction(); -- const peers = await subduction.getConnectedPeerIds(); -- return peers.map(p => p.toString()); -+ return source.connectedPeerIds(); - }; - /** - * Force a fresh Subduction sync round for a document, re-arming the -@@ -702,11 +703,10 @@ export class Repo extends EventEmitter { +@@ -702,11 +704,10 @@ export class Repo extends EventEmitter { * @param documentId - documentId of the DocHandle to remove from handleCache, if present in cache. */ async removeFromCache(documentId) { @@ -117,40 +87,6 @@ index adfddd9b3926510604274217c42f97f104e119a3..096ca1f8c00cf6cd73aeed8c4fded621 } async shutdown() { // Quiesce Subduction first — stops reconnect loops, flushes pending -diff --git a/dist/index.d.ts b/dist/index.d.ts -index 1ca836a0b386a69c63e0fa2f655b9f78d5dfe508..f4ab209408e8a9e94081f7198fc8737d2f3e56a8 100644 ---- a/dist/index.d.ts -+++ b/dist/index.d.ts -@@ -51,13 +51,15 @@ export type { AbortOptions } from "./helpers/abortable.js"; - export type { DocumentSource } from "./DocumentSource.js"; - export type { Policy as SubductionPolicy } from "@automerge/automerge-subduction/slim"; - export { setSubductionLogLevel } from "@automerge/automerge-subduction/slim"; --export type { SubductionTimeouts, BlobInterceptor, } from "./subduction/source.js"; -+export type { SubductionTimeouts, BlobInterceptor, SubductionRemote, SubductionRemoteHandlers, SubductionRemoteSyncResult, } from "./subduction/source.js"; - export { WebSocketEndpoint, WorkerWebSocketEndpoint, type ManagedTransport, type WebSocketEndpointInterface, type WorkerWebSocketEndpointOptions, } from "./subduction/websocket-endpoint.js"; - export { WorkerWebSocketError, type WorkerPortLike, type WorkerPortSource, type WorkerWebSocketErrorCode, } from "./subduction/worker-websocket/protocol.js"; - export { WorkerWebSocketTransport, type WorkerWebSocketConnectOptions, } from "./subduction/worker-websocket/transport.js"; - export { attachWebSocketHost, type WebSocketHostOptions, type WebSocketLike, } from "./subduction/worker-websocket/host.js"; - export { createErrorRelay, donatePort, isPortProvisionMessage, isWorkerErrorMessage, isWorkerStatsMessage, makePortProvider, PORT_PROVISION_CHANNEL, PortProtocolMismatchError, startDriftProbe, WORKER_ERROR_CHANNEL, WORKER_PORT_PROTOCOL_VERSION, WORKER_STATS_CHANNEL, workerPortVersionMismatch, workerPortVersionOk, type DonatablePort, type DonatePortOptions, type DriftProbeOptions, type ErrorRelay, type PortProvider, type PortProviderOptions, type PortProvisionMessage, type WorkerErrorMessage, type WorkerStatsMessage, } from "./worker-port/index.js"; - export { WebSocketTransport } from "./subduction/websocket-transport.js"; -+export { SubductionStorageBridge, INTERCEPTOR_PREFIX, } from "./subduction/storage.js"; -+export { SubductionConnections } from "./subduction/SubductionConnections.js"; - export type { FindProgress, FindProgressWithMethods, ProgressSignal, } from "./_compat.js"; - export type { NetworkAdapterEvents, OpenPayload, PeerCandidatePayload, PeerDisconnectedPayload, PeerMetadata, } from "./network/NetworkAdapterInterface.js"; - export type { NetworkSubsystemEvents, PeerPayload, } from "./network/NetworkSubsystem.js"; -diff --git a/dist/index.js b/dist/index.js -index 07c1a138fa618dd1f0538a490519ad5b2cf85f11..136586003e794798d6a58f8cb1927a32c0876fec 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -48,6 +48,8 @@ export { WorkerWebSocketTransport, } from "./subduction/worker-websocket/transpo - export { attachWebSocketHost, } from "./subduction/worker-websocket/host.js"; - export { createErrorRelay, donatePort, isPortProvisionMessage, isWorkerErrorMessage, isWorkerStatsMessage, makePortProvider, PORT_PROVISION_CHANNEL, PortProtocolMismatchError, startDriftProbe, WORKER_ERROR_CHANNEL, WORKER_PORT_PROTOCOL_VERSION, WORKER_STATS_CHANNEL, workerPortVersionMismatch, workerPortVersionOk, } from "./worker-port/index.js"; - export { WebSocketTransport } from "./subduction/websocket-transport.js"; -+export { SubductionStorageBridge, INTERCEPTOR_PREFIX, } from "./subduction/storage.js"; -+export { SubductionConnections } from "./subduction/SubductionConnections.js"; - export * from "./types.js"; - export { cursor } from "./subdoc-handles/utils.js"; - // Automerge re-exports diff --git a/dist/subduction/AdapterConnections.d.ts b/dist/subduction/AdapterConnections.d.ts index 55231beca6494ed4ab0eaeb45383b822bcea5fe3..419c4ec3d17dc9f521292d780d293f5b1aafa8ce 100644 --- a/dist/subduction/AdapterConnections.d.ts @@ -199,161 +135,23 @@ index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af5 return true; } return false; -diff --git a/dist/subduction/SyncScheduler.d.ts b/dist/subduction/SyncScheduler.d.ts -index 1d5869050482c320814d96b8a18151af8481a3e9..e8d967d4ce913a2cc8599857dcd8ad61edf500dc 100644 ---- a/dist/subduction/SyncScheduler.d.ts -+++ b/dist/subduction/SyncScheduler.d.ts -@@ -3,9 +3,25 @@ import { DocumentId } from "../types.js"; - import { type Logger } from "../Logger.js"; - /** Callback when heal sync gives up after all retry attempts. */ - export type OnHealExhausted = (documentId: DocumentId) => void; -+/** The parts of a `PeerResultMap` a heal round reads. */ -+export interface HealRound { -+ entries(): Array<{ -+ success: boolean; -+ transportErrors?: unknown[]; -+ stats?: { -+ commitsReceived: number; -+ fragmentsReceived: number; -+ }; -+ }>; -+} - export interface SyncSchedulerOptions { - subduction: Promise; - log: Logger; -+ /** -+ * Runs a heal round. Defaults to `subduction.syncWithAllPeers`, with -+ * subscription; a source syncing through a remote routes it there. -+ */ -+ syncWithAllPeers?: (sedimentreeId: SedimentreeId, timeoutMs: number | null) => Promise; - /** - * Called when a heal sync receives new data from peers. The source - * should load the blobs into the document handle. -diff --git a/dist/subduction/SyncScheduler.js b/dist/subduction/SyncScheduler.js -index 609e087a8829d5170733ff14da63f8fbe81194b4..703500fc12b38fb602a49603def22d28c9c19f00 100644 ---- a/dist/subduction/SyncScheduler.js -+++ b/dist/subduction/SyncScheduler.js -@@ -22,6 +22,7 @@ const DEFAULT_HEAL_MAX_ATTEMPTS = 10; - */ - export class SyncScheduler { - #subduction; -+ #syncWithAllPeers; - #log; - #onSyncDataReceived; - #onHealExhausted; -@@ -37,6 +38,9 @@ export class SyncScheduler { - #healMaxAttempts; - constructor(options) { - this.#subduction = options.subduction; -+ this.#syncWithAllPeers = -+ options.syncWithAllPeers ?? -+ (async (sedimentreeId, timeoutMs) => (await this.#subduction).syncWithAllPeers(sedimentreeId, true, timeoutMs)); - this.#log = options.log; - this.#onSyncDataReceived = options.onSyncDataReceived; - this.#onHealExhausted = options.onHealExhausted; -@@ -81,7 +85,7 @@ export class SyncScheduler { - this.#log.debug(`executing heal sync for ${key.slice(0, 8)}...`); - try { - const subduction = await this.#subduction; -- const peerResultMap = await subduction.syncWithAllPeers(sedimentreeId, true, this.#syncTimeout); -+ const peerResultMap = await this.#syncWithAllPeers(sedimentreeId, this.#syncTimeout); - const results = peerResultMap.entries(); - const anyFailed = results.some(r => !r.success || (r.transportErrors?.length ?? 0) > 0); - // If new data was received during the heal, load it into the handle diff --git a/dist/subduction/source.d.ts b/dist/subduction/source.d.ts -index c1f722a51f7e273ee8161e4569b1602c2e3c3d11..7eaa50966c29602a9538c060b416112e427c3b3f 100644 +index c1f722a51f7e273ee8161e4569b1602c2e3c3d11..670510ad8797d5bd0b66945b89d52544efd42503 100644 --- a/dist/subduction/source.d.ts +++ b/dist/subduction/source.d.ts -@@ -3,6 +3,7 @@ import { DocumentSource } from "../DocumentSource.js"; - import { DocumentQuery, SourcePriority } from "../DocumentQuery.js"; - import { DocumentId, PeerId } from "../types.js"; - import { NetworkAdapterInterface } from "../index.js"; -+import { ConnectionManager } from "./ConnectionManager.js"; - import type { StorageId } from "../storage/types.js"; - import type { UrlHeads } from "../types.js"; - import { SubductionStorageBridge } from "./storage.js"; -@@ -119,17 +120,94 @@ export interface SubductionSourceOptions { +@@ -119,16 +119,19 @@ export interface SubductionSourceOptions { */ compactionDeleteConcurrency?: number; blobInterceptor?: BlobInterceptor; + /** ms a document's save waits to batch further changes. Defaults to 100. */ + saveThrottle?: number; + headsChannel?: string; -+ /** -+ * Sync through another node instead of this one's own connections. See -+ * {@link SubductionRemote}. -+ */ -+ remote?: SubductionRemote; -+} -+/** One peer's outcome from a {@link SubductionRemote.sync} round. */ -+export interface SubductionRemoteSyncResult { -+ success: boolean; -+ transportErrors?: unknown[]; -+ stats?: { -+ commitsReceived: number; -+ fragmentsReceived: number; -+ }; -+} -+/** What a {@link SubductionRemote} reports back to the source it serves. */ -+export interface SubductionRemoteHandlers { -+ /** -+ * Records a peer delivered for a sedimentree, already persisted to the -+ * shared storage. `id` is the commit id (or fragment head) in hex. -+ */ -+ onData(sid: string, records: Array<{ -+ kind: "commit" | "fragment"; -+ id: string; -+ blob: Uint8Array; -+ }>): void; -+ /** A peer's heads for a sedimentree, as hex commit ids. */ -+ onRemoteHeads(sid: string, peerId: string, heads: string[]): void; -+ onEphemeral(sid: string, senderId: string, payload: Uint8Array): void; -+} -+/** -+ * A node that does this source's networking for it. -+ * -+ * Several Repos sharing one storage (the tabs of an origin, say) can hand -+ * their sync to a single node holding the only connections. Each Repo -+ * keeps reading and writing the shared storage; the remote pushes what -+ * they wrote, persists what peers send and tells them about it. -+ * -+ * Sedimentree ids cross this interface as `SedimentreeId.toString()` so an -+ * implementation can relay them over a MessagePort. -+ */ -+export interface SubductionRemote extends ConnectionManager { -+ /** Called once, by the source this remote serves. */ -+ bind(handlers: SubductionRemoteHandlers): void; -+ /** -+ * Sync one sedimentree with the remote's peers and subscribe to it. -+ * Resolves once everything received is in the shared storage, and only -+ * after the remote has picked up what this source had already stored. -+ */ -+ sync(sid: string, timeoutMs: number | null): Promise; -+ /** -+ * This source just stored these records (hex ids) in the shared storage. -+ * The remote's own view of the sedimentree won't see them otherwise; the -+ * sync round that follows every save is what pushes them. -+ */ -+ stored(sid: string, records: Array<{ -+ kind: "commit" | "fragment"; -+ id: string; -+ }>): void; -+ /** This source no longer holds the sedimentree. */ -+ release(sid: string): void; -+ connectedPeerIds(): Promise; -+ subscribeEphemeral(sid: string): Promise; -+ unsubscribeEphemeral(sid: string): Promise; -+ publishEphemeral(sid: string, payload: Uint8Array): Promise; -+} -+/** The parts of a `PeerResultMap` the sync paths read. */ -+export interface SyncRound { -+ entries(): SubductionRemoteSyncResult[]; } export declare class SubductionSource implements DocumentSource { #private; readonly priority: SourcePriority; - constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority, policy, timeouts, compactionDeleteConcurrency, blobInterceptor, }: SubductionSourceOptions); -+ constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority, policy, timeouts, compactionDeleteConcurrency, blobInterceptor, saveThrottle, headsChannel, remote, }: SubductionSourceOptions); ++ constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority, policy, timeouts, compactionDeleteConcurrency, blobInterceptor, saveThrottle, headsChannel, }: SubductionSourceOptions); /** True if any connection manager has an established connection. */ isConnected(): boolean; getSubduction(): Promise; @@ -361,22 +159,15 @@ index c1f722a51f7e273ee8161e4569b1602c2e3c3d11..7eaa50966c29602a9538c060b416112e - detach(_documentId: DocumentId): void; + detach(documentId: DocumentId): Promise; shareConfigChanged(): void; -+ /** -+ * The peers this source syncs with directly, as stringified peer ids: -+ * the remote's when there is one. -+ */ -+ connectedPeerIds(): Promise; /** Check whether a sedimentree is currently in heal-backoff. */ isHealing(sedimentreeId: SedimentreeId): boolean; - /** diff --git a/dist/subduction/source.js b/dist/subduction/source.js -index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b212368441dd38 100644 +index 4692619444edfae0ef5f111cef6ce29499507afa..8625c93795d5870df6472d144725aae1d76c3ae9 100644 --- a/dist/subduction/source.js +++ b/dist/subduction/source.js @@ -1,7 +1,7 @@ import * as Automerge from "@automerge/automerge/slim"; --import { BlobMeta, CommitId, CommitInput, Fragment, FragmentInput, LooseCommit, Subduction, Topic, setSubductionLogLevel, } from "@automerge/automerge-subduction/slim"; -+import { BlobMeta, CommitId, CommitInput, Fragment, FragmentInput, LooseCommit, SedimentreeId, Subduction, Topic, setSubductionLogLevel, } from "@automerge/automerge-subduction/slim"; + import { BlobMeta, CommitId, CommitInput, Fragment, FragmentInput, LooseCommit, Subduction, Topic, setSubductionLogLevel, } from "@automerge/automerge-subduction/slim"; import { toSedimentreeId, toDocumentId } from "./helpers.js"; -import { encodeHeads } from "../AutomergeUrl.js"; +import { encodeHeads, decodeHeads } from "../AutomergeUrl.js"; @@ -418,11 +209,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 /** * Deadline for draining in-flight storage-bridge operations at shutdown. * An adapter promise that never settles (e.g. IndexedDB in a frozen or -@@ -41,17 +69,22 @@ const SHUTDOWN_IDLE_TIMEOUT_MS = 5_000; - * `RepoConfig.subductionCompactionDeleteConcurrency`. - */ - const DEFAULT_COMPACTION_DELETE_CONCURRENCY = 16; -+const sedimentreeIdFromString = (sid) => SedimentreeId.fromBytes(Uint8Array.from(sid.match(/../g) ?? [], byte => parseInt(byte, 16))); +@@ -44,6 +72,8 @@ const DEFAULT_COMPACTION_DELETE_CONCURRENCY = 16; export class SubductionSource { #subduction; #storage; @@ -431,9 +218,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 #entries = new Map(); #log; #connectionManagers = []; -+ #remote; - /** Normalized endpoints; kept so `shutdown()` can run their teardown hooks. */ - #websocketEndpoints = []; +@@ -52,6 +82,7 @@ export class SubductionSource { #scheduler; #syncTimeoutMs; #syncTimeout; @@ -441,7 +226,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 #compactionDeleteConcurrency; #blobInterceptor; #onRemoteHeadsChanged; -@@ -59,8 +92,13 @@ export class SubductionSource { +@@ -59,8 +90,13 @@ export class SubductionSource { /** Last reported aggregate connectedness, to fire onConnectionChanged only * on transitions. */ #lastConnected = false; @@ -457,12 +242,12 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 #lastRemoteHeads = new Map(); /** * Set at the top of `shutdown()`. Makes `#scheduleRecompute` a -@@ -70,12 +108,15 @@ export class SubductionSource { +@@ -70,12 +106,15 @@ export class SubductionSource { */ #shuttingDown = false; priority; - constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority = 2, policy, timeouts, compactionDeleteConcurrency = DEFAULT_COMPACTION_DELETE_CONCURRENCY, blobInterceptor, }) { -+ constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority = 2, policy, timeouts, compactionDeleteConcurrency = DEFAULT_COMPACTION_DELETE_CONCURRENCY, blobInterceptor, saveThrottle = 100, headsChannel, remote, }) { ++ constructor({ peerId, storage, signer, websocketEndpoints, adapters, onRemoteHeadsChanged, onEphemeral, onHealExhausted, onConnectionChanged, priority = 2, policy, timeouts, compactionDeleteConcurrency = DEFAULT_COMPACTION_DELETE_CONCURRENCY, blobInterceptor, saveThrottle = 100, headsChannel, }) { + this.#saveThrottle = saveThrottle; this.#blobInterceptor = blobInterceptor; this.#onConnectionChanged = onConnectionChanged; @@ -474,7 +259,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#compactionDeleteConcurrency = compactionDeleteConcurrency; // Default roundtrip deadline forwarded to the Subduction constructor. // The field must be *omitted* (not passed as `undefined`) to get the -@@ -113,7 +154,20 @@ export class SubductionSource { +@@ -113,7 +152,20 @@ export class SubductionSource { // raw subduction CommitId hex would never compare equal to a doc's // own heads). const urlHeads = encodeHeads(heads.map(h => h.toHexString())); @@ -496,7 +281,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 }; // Construct without hydrating: skip preloading persisted sedimentrees // from storage at startup. State is loaded lazily on demand instead, -@@ -128,6 +182,27 @@ export class SubductionSource { +@@ -128,6 +180,27 @@ export class SubductionSource { onEphemeral, ...defaultTimeoutOption, })); @@ -524,43 +309,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 // ── Connection managers ───────────────────────────────────────── // Endpoints own *where* their socket lives (in-thread, Worker, ...); // the shared manager owns the reconnect/backoff loop. Plain URL -@@ -143,6 +218,27 @@ export class SubductionSource { - adapterConnections.addAdapter(adapter, serviceName, role ?? "connect"); - } - this.#connectionManagers.push(adapterConnections); -+ if (remote) { -+ this.#remote = remote; -+ this.#connectionManagers.push(remote); -+ remote.bind({ -+ onData: (sid, records) => { -+ const sedimentreeId = sedimentreeIdFromString(sid); -+ const entry = this.#entries.get(sid); -+ for (const { kind, id, blob } of records) { -+ if (entry) { -+ if (kind === "commit") -+ entry.persistedCommitHashes.add(id); -+ else -+ entry.persistedFragmentHashes.add(id); -+ } -+ this.#handleDataFound(sedimentreeId, CommitId.fromHexString(id), blob); -+ } -+ }, -+ onRemoteHeads: (sid, peerId, heads) => onRemoteHeads(sedimentreeIdFromString(sid), { toString: () => peerId }, heads.map(hex => ({ toHexString: () => hex }))), -+ onEphemeral: (sid, senderId, payload) => onEphemeral?.(sedimentreeIdFromString(sid), { toString: () => senderId }, payload), -+ }); -+ } - for (const mgr of this.#connectionManagers) { - mgr.onChange(() => { - this.#scheduleRecompute(); -@@ -164,6 +260,7 @@ export class SubductionSource { - // ── Sync scheduler ──────────────────────────────────────────────── - this.#scheduler = new SyncScheduler({ - subduction: this.#subduction, -+ syncWithAllPeers: (sedimentreeId, timeoutMs) => this.#syncWithAllPeers(sedimentreeId, timeoutMs), - log: this.#log, - onSyncDataReceived: async (sedimentreeId, subduction) => { - const entry = this.#entries.get(sedimentreeId.toString()); -@@ -184,16 +281,28 @@ export class SubductionSource { +@@ -184,16 +257,28 @@ export class SubductionSource { * when `persist` is set, durably store it so the last-known sync state * survives reload and replays on the next attach. `heads` must already be * in automerge-repo `UrlHeads` (bs58check) form. @@ -596,7 +345,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 const documentId = toDocumentId(sedimentreeId); this.#onRemoteHeadsChanged?.(documentId, storageId, heads, timestamp); if (persist) { -@@ -201,6 +310,21 @@ export class SubductionSource { +@@ -201,6 +286,21 @@ export class SubductionSource { .saveRemoteHeads(sedimentreeId, storageId, heads, timestamp) .catch(e => this.#log.debug("saveRemoteHeads failed: %O", e)); } @@ -618,7 +367,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } #anyConnectionManagerConnecting() { return this.#connectionManagers.some(mgr => mgr.isConnecting()); -@@ -244,6 +368,7 @@ export class SubductionSource { +@@ -244,6 +344,7 @@ export class SubductionSource { // — recomputes are targeted, so other entries' completions no // longer walk this one. Mark it dirty so the walk retries the load. if (entry.syncState === "initializing") { @@ -626,7 +375,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#scheduleRecompute(entry); return; } -@@ -356,14 +481,15 @@ export class SubductionSource { +@@ -356,14 +457,15 @@ export class SubductionSource { // Save throttle. Concurrency is enforced inside `#save` itself // (the `saveInProgress` gate makes concurrent invocations // early-return), so the throttle just needs to coalesce rapid @@ -645,7 +394,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#entries.set(sidStr, { syncState: "initializing", query, -@@ -372,17 +498,23 @@ export class SubductionSource { +@@ -372,17 +474,23 @@ export class SubductionSource { syncInFlight: false, lastSyncResult: null, lastSyncGeneration: -1, @@ -669,7 +418,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 saveSettled, saveInProgress: false, saveDeltaPending: false, -@@ -394,13 +526,17 @@ export class SubductionSource { +@@ -394,7 +502,7 @@ export class SubductionSource { transformRetryScheduled: false, }); query.sourcePending("subduction"); @@ -678,18 +427,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 throttledSave(); // Subscribe to ephemeral messages for this sedimentree void (async () => { - try { -- const subduction = await this.#subduction; - const sid = toSedimentreeId(query.documentId); -+ if (this.#remote) { -+ await this.#remote.subscribeEphemeral(sid.toString()); -+ return; -+ } -+ const subduction = await this.#subduction; - await subduction.subscribeEphemeral([Topic.fromBytes(sid.toBytes())]); - } - catch (e) { -@@ -441,6 +577,7 @@ export class SubductionSource { +@@ -441,6 +549,7 @@ export class SubductionSource { this.#log.debug(`failed to seed persistedHashes for ${sidStr.slice(0, 8)}: %O`, e); } })(); @@ -697,7 +435,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 // Replay the last-known remote heads for this sedimentree so the // DocHandle reflects the last sync state immediately on (re)attach, // before any network round-trip completes. Does not re-persist. -@@ -457,7 +594,53 @@ export class SubductionSource { +@@ -457,7 +566,49 @@ export class SubductionSource { })(); this.#scheduleRecompute(this.#entries.get(sidStr)); } @@ -736,14 +474,10 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 + if (this.#entries.has(sidStr)) + return; + this.#scheduler.resetHealState(sidStr); -+ this.#remote?.release(sidStr); + if (!subduction) + return; + try { -+ if (this.#remote) -+ await this.#remote.unsubscribeEphemeral(sidStr); -+ else -+ await subduction.unsubscribeEphemeral([Topic.fromBytes(sid.toBytes())]); ++ await subduction.unsubscribeEphemeral([Topic.fromBytes(sid.toBytes())]); + } + catch (e) { + this.#log.debug("ephemeral unsubscribe failed: %O", e); @@ -752,7 +486,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 shareConfigChanged() { for (const entry of this.#entries.values()) { // A share-config change can flip a previously-denied fetch to -@@ -598,6 +781,208 @@ export class SubductionSource { +@@ -598,6 +749,208 @@ export class SubductionSource { entry.lastSyncResult === "no-peers") && entry.lastSyncGeneration !== this.#connectionGeneration()); } @@ -961,7 +695,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 #recomputeEntry(entry) { // No new background syncs once shutting down: `shutdown()`'s quiesce // round is the sole sync initiator from that point. A recompute walk -@@ -625,6 +1010,10 @@ export class SubductionSource { +@@ -625,6 +978,10 @@ export class SubductionSource { entry.syncInFlight = true; void this.#doSync(entry); } @@ -972,7 +706,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 else if (Automerge.getHeads(entry.handle.fullDoc()).length === 0 && !this.#anyConnectionManagerConnecting()) { // All connections settled and sync failed — give up. -@@ -632,12 +1021,31 @@ export class SubductionSource { +@@ -632,12 +989,31 @@ export class SubductionSource { entry.query.sourceUnavailable("subduction"); } } @@ -1007,18 +741,16 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } return; } -@@ -662,7 +1070,9 @@ export class SubductionSource { - entry.flushSave.flush(); +@@ -663,6 +1039,8 @@ export class SubductionSource { await entry.saveSettled; this.#log.debug(`doSync ${sid} (state=${entry.syncState})`); -- const peerResultMap = await subduction.syncWithAllPeers(sedimentreeId, true, timeoutMs !== undefined ? timeoutMs : this.#syncTimeout); -+ const peerResultMap = await this.#syncWithAllPeers(sedimentreeId, timeoutMs !== undefined ? timeoutMs : this.#syncTimeout); + const peerResultMap = await subduction.syncWithAllPeers(sedimentreeId, true, timeoutMs !== undefined ? timeoutMs : this.#syncTimeout); + if (this.#entries.get(sedimentreeId.toString()) !== entry) + return; const results = peerResultMap.entries(); const anySuccess = results.some(r => r.success); this.#log.debug(`doSync ${sid}: ${results.length} peer(s), success=${anySuccess}`); -@@ -676,7 +1086,7 @@ export class SubductionSource { +@@ -676,7 +1054,7 @@ export class SubductionSource { // arrives, without waiting for further state transitions. // // During "initializing", `#loadBlobsAndTransition` performs one @@ -1027,40 +759,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 // here too would duplicate storage reads on first open. // // Also retry when the handle is empty: a previous load may have -@@ -730,6 +1140,32 @@ export class SubductionSource { - this.#scheduleRecompute(entry); - } - } -+ /** -+ * One sync round for a sedimentree, through the remote when there is one. -+ * The remote reads what this source stored out of the shared storage, so -+ * those writes have to land before it is asked. -+ */ -+ async #syncWithAllPeers(sedimentreeId, timeoutMs) { -+ if (this.#remote) { -+ const sid = sedimentreeId.toString(); -+ await this.#storage.awaitSettled([sid]); -+ const results = await this.#remote.sync(sid, timeoutMs); -+ return { entries: () => results }; -+ } -+ const subduction = await this.#subduction; -+ return subduction.syncWithAllPeers(sedimentreeId, true, timeoutMs); -+ } -+ /** -+ * The peers this source syncs with directly, as stringified peer ids: -+ * the remote's when there is one. -+ */ -+ async connectedPeerIds() { -+ if (this.#remote) -+ return this.#remote.connectedPeerIds(); -+ const subduction = await this.#subduction; -+ const peers = await subduction.getConnectedPeerIds(); -+ return peers.map(p => p.toString()); -+ } - /** - * Pair each stored blob with the commit or fragment id it belongs to. - */ -@@ -754,42 +1190,63 @@ export class SubductionSource { +@@ -754,42 +1132,63 @@ export class SubductionSource { return out; } /** @@ -1143,7 +842,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } let toApply = blobsWithIds.map(u => u.blob); if (this.#blobInterceptor) { -@@ -835,10 +1292,22 @@ export class SubductionSource { +@@ -835,10 +1234,22 @@ export class SubductionSource { this.#scheduleCompaction(entry); return true; } @@ -1168,7 +867,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 entry.syncState = "running"; if (Automerge.getHeads(entry.handle.fullDoc()).length === 0) { if (this.#blobInterceptor && hadBlobs) { -@@ -864,6 +1333,21 @@ export class SubductionSource { +@@ -864,6 +1275,21 @@ export class SubductionSource { } } catch (e) { @@ -1190,7 +889,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#log.debug(`loadBlobsAndTransition threw for ${entry.sedimentreeId .toString() .slice(0, 8)}: %O`, e); -@@ -948,6 +1432,7 @@ export class SubductionSource { +@@ -948,6 +1374,7 @@ export class SubductionSource { entry.saveSettled = new Promise(r => { resolveSaveSettled = r; }); @@ -1198,7 +897,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 try { const doc = entry.handle.fullDoc(); if (!doc) -@@ -989,6 +1474,14 @@ export class SubductionSource { +@@ -989,6 +1416,14 @@ export class SubductionSource { // so future mutations to either side stay isolated. entry.lastSaveError = null; entry.lastSavedHeads = new Set(currentSet); @@ -1213,7 +912,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 // Detect a post-save delta the `heads-changed` listener didn't // fire on. `getChangeByHash` calls inside `#saveNewCommits` can // shift what `getHeads` returns for the same doc reference on -@@ -1014,6 +1507,8 @@ export class SubductionSource { +@@ -1014,6 +1449,8 @@ export class SubductionSource { entry.saveInProgress = false; resolveSaveSettled(); } @@ -1222,7 +921,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 // Trigger an immediate sync — the only broadcast path for newly // saved commits (`#saveNewCommits` is store-only). If a sync is // already in flight, flag a re-sync for when it completes; otherwise -@@ -1062,7 +1557,7 @@ export class SubductionSource { +@@ -1062,7 +1499,7 @@ export class SubductionSource { const newCommitMetas = commitMetas.filter(m => !entry.knownHashes.has(m.head)); const newFragmentMetas = fragmentMetas.filter(m => !entry.knownHashes.has(m.head)); if (newCommitMetas.length === 0 && newFragmentMetas.length === 0) { @@ -1231,31 +930,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } const newCommitBytes = newCommitMetas.length === 0 ? [] -@@ -1082,6 +1577,7 @@ export class SubductionSource { - bytes: newFragmentBytes[i], - })); - const acceptedHashes = []; -+ const storedRecords = []; - const commitInputs = []; - const fragmentInputs = []; - const prepErrors = []; -@@ -1105,6 +1601,7 @@ export class SubductionSource { - const looseCommit = new LooseCommit(entry.sedimentreeId, head, c.parents.map(p => CommitId.fromHexString(p)), new BlobMeta(commitBytes)); - commitInputs.push(new CommitInput(looseCommit, commitBytes)); - acceptedHashes.push(c.head); -+ storedRecords.push({ kind: "commit", id: c.head }); - } - catch (e) { - this.#log.warn(`commit input prep failed for ${c.head}:`, e); -@@ -1129,6 +1626,7 @@ export class SubductionSource { - const fragment = new Fragment(entry.sedimentreeId, head, boundary, checkpoints, new BlobMeta(fragmentBytes)); - fragmentInputs.push(new FragmentInput(fragment, fragmentBytes)); - acceptedHashes.push(f.head); -+ storedRecords.push({ kind: "fragment", id: f.head }); - } - catch (e) { - this.#log.warn(`fragment input prep failed for ${f.head}:`, e); -@@ -1143,7 +1641,7 @@ export class SubductionSource { +@@ -1143,7 +1580,7 @@ export class SubductionSource { : new AggregateError(prepErrors, `${prepErrors.length} inputs failed to prepare`); } if (commitInputs.length === 0 && fragmentInputs.length === 0) { @@ -1264,7 +939,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } // Record hashes BEFORE `storeBuiltBatch` so the synchronous // `commit-saved` / `fragment-saved` events fired from the storage -@@ -1171,8 +1669,21 @@ export class SubductionSource { +@@ -1171,8 +1608,15 @@ export class SubductionSource { `${fragmentInputs.length} fragments):`, e); throw e; } @@ -1272,12 +947,6 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 + // a reload marked while this batch was in flight. + for (const hash of acceptedHashes) + entry.strandedHashes.delete(hash); -+ if (this.#remote) { -+ // The remote reads these out of the shared storage. -+ const sid = entry.sedimentreeId.toString(); -+ await this.#storage.awaitSettled([sid]); -+ this.#remote.stored(sid, storedRecords); -+ } this.#scheduleCompaction(entry); - return { prepFailures: prepErrors.length }; + return { @@ -1287,7 +956,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 } /** * Kick off a compaction pass without blocking the save loop. -@@ -1321,6 +1832,7 @@ export class SubductionSource { +@@ -1321,6 +1765,7 @@ export class SubductionSource { return; this.#scheduler.resetHealState(sid); entry.lastSyncResult = null; @@ -1295,7 +964,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#scheduleRecompute(entry); } // ── Flush ─────────────────────────────────────────────────────────── -@@ -1430,6 +1942,11 @@ export class SubductionSource { +@@ -1430,6 +1875,11 @@ export class SubductionSource { mgr.shutdown(); } this.#scheduler.shutdown(); @@ -1307,7 +976,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 for (const entry of this.#entries.values()) this.#flushInbound(entry); for (const entry of this.#entries.values()) { -@@ -1468,6 +1985,7 @@ export class SubductionSource { +@@ -1468,6 +1918,7 @@ export class SubductionSource { for (const endpoint of this.#websocketEndpoints) { endpoint.shutdown?.(); } @@ -1315,7 +984,7 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#storage.close(); await this.#awaitStorageIdle(); return; -@@ -1481,6 +1999,7 @@ export class SubductionSource { +@@ -1481,6 +1932,7 @@ export class SubductionSource { for (const endpoint of this.#websocketEndpoints) { endpoint.shutdown?.(); } @@ -1323,20 +992,6 @@ index 4692619444edfae0ef5f111cef6ce29499507afa..a60dd664102a17798342777886b21236 this.#storage.close(); await this.#awaitStorageIdle(); } -@@ -1513,8 +2032,12 @@ export class SubductionSource { - /** Publish an ephemeral payload to subduction peers for the given document. */ - async publishEphemeral(documentId, payload) { - try { -- const subduction = await this.#subduction; - const sid = toSedimentreeId(documentId); -+ if (this.#remote) { -+ await this.#remote.publishEphemeral(sid.toString(), payload); -+ return; -+ } -+ const subduction = await this.#subduction; - await subduction.publishEphemeral(Topic.fromBytes(sid.toBytes()), payload); - } - catch (e) { diff --git a/src/DocumentSource.ts b/src/DocumentSource.ts index 363cbbad5af3f2db28ed5fa0ca85ca584a60bb49..d9eb3c5d8429607480867bf105d0e772ba1aecfd 100644 --- a/src/DocumentSource.ts @@ -1351,49 +1006,28 @@ index 363cbbad5af3f2db28ed5fa0ca85ca584a60bb49..d9eb3c5d8429607480867bf105d0e772 shareConfigChanged(): void diff --git a/src/Repo.ts b/src/Repo.ts -index 0b408d6bda6582078083949c2fea63cfd0ef5e1b..7d663b129e15797ec8e4142a4577559ae6122c7d 100644 +index 0b408d6bda6582078083949c2fea63cfd0ef5e1b..e784353a779c092f418b0e0f15d1bb6e7182f47d 100644 --- a/src/Repo.ts +++ b/src/Repo.ts -@@ -53,6 +53,7 @@ import { - SubductionSource, - type SubductionTimeouts, - type BlobInterceptor, -+ type SubductionRemote, - } from "./subduction/source.js" - import type { - WebSocketEndpoint, -@@ -136,6 +137,9 @@ export class Repo extends EventEmitter { +@@ -136,6 +136,8 @@ export class Repo extends EventEmitter { subductionTimeouts, subductionCompactionDeleteConcurrency, subductionBlobInterceptor, + subductionSaveThrottle, + headsChannel, -+ subductionRemote, }: RepoConfig = {}) { super() this.#peerId = peerId -@@ -213,6 +217,9 @@ export class Repo extends EventEmitter { +@@ -213,6 +215,8 @@ export class Repo extends EventEmitter { timeouts: subductionTimeouts, compactionDeleteConcurrency: subductionCompactionDeleteConcurrency, blobInterceptor: subductionBlobInterceptor, + saveThrottle: subductionSaveThrottle, + headsChannel, -+ remote: subductionRemote, onRemoteHeadsChanged: (documentId, storageId, heads, timestamp) => { const handle = this.#queries[documentId]?.handle if (handle) { -@@ -872,9 +879,7 @@ export class Repo extends EventEmitter { - connectedSubductionPeerIds = async (): Promise => { - const source = this.#subductionSource - if (!source) return [] -- const subduction = await source.getSubduction() -- const peers = await subduction.getConnectedPeerIds() -- return peers.map(p => p.toString()) -+ return source.connectedPeerIds() - } - - /** -@@ -930,11 +935,12 @@ export class Repo extends EventEmitter { +@@ -930,11 +934,12 @@ export class Repo extends EventEmitter { * @param documentId - documentId of the DocHandle to remove from handleCache, if present in cache. */ async removeFromCache(documentId: DocumentId): Promise { @@ -1408,7 +1042,7 @@ index 0b408d6bda6582078083949c2fea63cfd0ef5e1b..7d663b129e15797ec8e4142a4577559a } async shutdown() { -@@ -1060,8 +1066,11 @@ export interface RepoConfig { +@@ -1060,8 +1065,11 @@ export interface RepoConfig { adapter: NetworkAdapterInterface serviceName: string /** Whether to initiate ("connect") or accept ("accept") the subduction @@ -1422,57 +1056,21 @@ index 0b408d6bda6582078083949c2fea63cfd0ef5e1b..7d663b129e15797ec8e4142a4577559a }[] /** -@@ -1088,6 +1097,24 @@ export interface RepoConfig { +@@ -1088,6 +1096,14 @@ export interface RepoConfig { * `storage` when only one of the two Repos runs the interceptor. */ subductionBlobInterceptor?: BlobInterceptor + + /** + * How long, in ms, a document's subduction save waits for further changes -+ * to batch with. Defaults to 100. A Repo syncing through a -+ * `subductionRemote` only tells the remote about a change once it has -+ * saved it, so this is on the path from an edit to everyone else. ++ * to batch with. Defaults to 100. + */ + subductionSaveThrottle?: number + + headsChannel?: string -+ -+ /** -+ * Sync through another node rather than over this Repo's own -+ * connections, e.g. one SharedWorker doing the networking for every tab -+ * on an origin. The remote must share this Repo's `storage`. Leave -+ * `subductionWebsocketEndpoints` and `subductionAdapters` unset with it. -+ */ -+ subductionRemote?: SubductionRemote } /** A function that determines whether we should share a document with a peer -diff --git a/src/index.ts b/src/index.ts -index b13005dab37c119a016a8edc03969d3f6abefe1f..bb96ab75ffb47e802f5267b86ebd3fbc217b2dd9 100644 ---- a/src/index.ts -+++ b/src/index.ts -@@ -116,6 +116,9 @@ export { setSubductionLogLevel } from "@automerge/automerge-subduction/slim" - export type { - SubductionTimeouts, - BlobInterceptor, -+ SubductionRemote, -+ SubductionRemoteHandlers, -+ SubductionRemoteSyncResult, - } from "./subduction/source.js" - export { - WebSocketEndpoint, -@@ -165,6 +168,11 @@ export { - type WorkerStatsMessage, - } from "./worker-port/index.js" - export { WebSocketTransport } from "./subduction/websocket-transport.js" -+export { -+ SubductionStorageBridge, -+ INTERCEPTOR_PREFIX, -+} from "./subduction/storage.js" -+export { SubductionConnections } from "./subduction/SubductionConnections.js" - - export type { - FindProgress, diff --git a/src/subduction/AdapterConnections.ts b/src/subduction/AdapterConnections.ts index 4426f11291a00b230332c63d77c683bb7ab02d24..e1fb64697b94637cd9dd968a6cebe8f5e06d89b2 100644 --- a/src/subduction/AdapterConnections.ts @@ -1526,74 +1124,8 @@ index 0d5510500c1e3a8e0d84b6f9b5f87f42096ee8e3..6b8376be24f932f21ab3ddc4b76bc81f } return false } -diff --git a/src/subduction/SyncScheduler.ts b/src/subduction/SyncScheduler.ts -index a59abb973ac316543bb80c83904d74f806ba53ae..09a8ded93568c527ac2edd4ea383da9413b05c66 100644 ---- a/src/subduction/SyncScheduler.ts -+++ b/src/subduction/SyncScheduler.ts -@@ -11,10 +11,28 @@ const DEFAULT_HEAL_MAX_ATTEMPTS = 10 - /** Callback when heal sync gives up after all retry attempts. */ - export type OnHealExhausted = (documentId: DocumentId) => void - -+/** The parts of a `PeerResultMap` a heal round reads. */ -+export interface HealRound { -+ entries(): Array<{ -+ success: boolean -+ transportErrors?: unknown[] -+ stats?: { commitsReceived: number; fragmentsReceived: number } -+ }> -+} -+ - export interface SyncSchedulerOptions { - subduction: Promise - log: Logger - -+ /** -+ * Runs a heal round. Defaults to `subduction.syncWithAllPeers`, with -+ * subscription; a source syncing through a remote routes it there. -+ */ -+ syncWithAllPeers?: ( -+ sedimentreeId: SedimentreeId, -+ timeoutMs: number | null -+ ) => Promise -+ - /** - * Called when a heal sync receives new data from peers. The source - * should load the blobs into the document handle. -@@ -64,6 +82,10 @@ export interface SyncSchedulerOptions { - */ - export class SyncScheduler { - #subduction: Promise -+ #syncWithAllPeers: ( -+ sedimentreeId: SedimentreeId, -+ timeoutMs: number | null -+ ) => Promise - #log: Logger - #onSyncDataReceived: ( - sedimentreeId: SedimentreeId, -@@ -85,6 +107,10 @@ export class SyncScheduler { - - constructor(options: SyncSchedulerOptions) { - this.#subduction = options.subduction -+ this.#syncWithAllPeers = -+ options.syncWithAllPeers ?? -+ (async (sedimentreeId, timeoutMs) => -+ (await this.#subduction).syncWithAllPeers(sedimentreeId, true, timeoutMs)) - this.#log = options.log - this.#onSyncDataReceived = options.onSyncDataReceived - this.#onHealExhausted = options.onHealExhausted -@@ -141,9 +167,8 @@ export class SyncScheduler { - - try { - const subduction = await this.#subduction -- const peerResultMap = await subduction.syncWithAllPeers( -+ const peerResultMap = await this.#syncWithAllPeers( - sedimentreeId, -- true, - this.#syncTimeout - ) - diff --git a/src/subduction/source.ts b/src/subduction/source.ts -index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c602a257f9 100644 +index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..083d991ab3c0937ce6b3c2fa74e1863930872430 100644 --- a/src/subduction/source.ts +++ b/src/subduction/source.ts @@ -16,7 +16,7 @@ import { DocumentSource } from "../DocumentSource.js" @@ -1738,90 +1270,15 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 /** Callback for remote heads changes from subduction peers. */ export type OnRemoteHeadsChanged = ( documentId: DocumentId, -@@ -370,19 +466,101 @@ export interface SubductionSourceOptions { +@@ -370,11 +466,16 @@ export interface SubductionSourceOptions { compactionDeleteConcurrency?: number blobInterceptor?: BlobInterceptor + /** ms a document's save waits to batch further changes. Defaults to 100. */ + saveThrottle?: number + headsChannel?: string -+ -+ /** -+ * Sync through another node instead of this one's own connections. See -+ * {@link SubductionRemote}. -+ */ -+ remote?: SubductionRemote } -+/** One peer's outcome from a {@link SubductionRemote.sync} round. */ -+export interface SubductionRemoteSyncResult { -+ success: boolean -+ transportErrors?: unknown[] -+ stats?: { commitsReceived: number; fragmentsReceived: number } -+} -+ -+/** What a {@link SubductionRemote} reports back to the source it serves. */ -+export interface SubductionRemoteHandlers { -+ /** -+ * Records a peer delivered for a sedimentree, already persisted to the -+ * shared storage. `id` is the commit id (or fragment head) in hex. -+ */ -+ onData( -+ sid: string, -+ records: Array<{ kind: "commit" | "fragment"; id: string; blob: Uint8Array }> -+ ): void -+ /** A peer's heads for a sedimentree, as hex commit ids. */ -+ onRemoteHeads(sid: string, peerId: string, heads: string[]): void -+ onEphemeral(sid: string, senderId: string, payload: Uint8Array): void -+} -+ -+/** -+ * A node that does this source's networking for it. -+ * -+ * Several Repos sharing one storage (the tabs of an origin, say) can hand -+ * their sync to a single node holding the only connections. Each Repo -+ * keeps reading and writing the shared storage; the remote pushes what -+ * they wrote, persists what peers send and tells them about it. -+ * -+ * Sedimentree ids cross this interface as `SedimentreeId.toString()` so an -+ * implementation can relay them over a MessagePort. -+ */ -+export interface SubductionRemote extends ConnectionManager { -+ /** Called once, by the source this remote serves. */ -+ bind(handlers: SubductionRemoteHandlers): void -+ /** -+ * Sync one sedimentree with the remote's peers and subscribe to it. -+ * Resolves once everything received is in the shared storage, and only -+ * after the remote has picked up what this source had already stored. -+ */ -+ sync(sid: string, timeoutMs: number | null): Promise -+ /** -+ * This source just stored these records (hex ids) in the shared storage. -+ * The remote's own view of the sedimentree won't see them otherwise; the -+ * sync round that follows every save is what pushes them. -+ */ -+ stored( -+ sid: string, -+ records: Array<{ kind: "commit" | "fragment"; id: string }> -+ ): void -+ /** This source no longer holds the sedimentree. */ -+ release(sid: string): void -+ connectedPeerIds(): Promise -+ subscribeEphemeral(sid: string): Promise -+ unsubscribeEphemeral(sid: string): Promise -+ publishEphemeral(sid: string, payload: Uint8Array): Promise -+} -+ -+/** The parts of a `PeerResultMap` the sync paths read. */ -+export interface SyncRound { -+ entries(): SubductionRemoteSyncResult[] -+} -+ -+const sedimentreeIdFromString = (sid: string): SedimentreeId => -+ SedimentreeId.fromBytes( -+ Uint8Array.from(sid.match(/../g) ?? [], byte => parseInt(byte, 16)) -+ ) -+ export class SubductionSource implements DocumentSource { #subduction: Promise #storage: SubductionStorageBridge @@ -1830,9 +1287,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 #entries = new Map() #log: Logger #connectionManagers: ConnectionManager[] = [] -+ #remote?: SubductionRemote - /** Normalized endpoints; kept so `shutdown()` can run their teardown hooks. */ - #websocketEndpoints: WebSocketEndpointInterface[] = [] +@@ -383,6 +484,7 @@ export class SubductionSource implements DocumentSource { #scheduler: SyncScheduler #syncTimeoutMs: number #syncTimeout: number | null @@ -1840,7 +1295,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 #compactionDeleteConcurrency: number #blobInterceptor?: BlobInterceptor #onRemoteHeadsChanged?: OnRemoteHeadsChanged -@@ -390,9 +568,14 @@ export class SubductionSource implements DocumentSource { +@@ -390,9 +492,14 @@ export class SubductionSource implements DocumentSource { /** Last reported aggregate connectedness, to fire onConnectionChanged only * on transitions. */ #lastConnected = false @@ -1858,13 +1313,12 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 /** * Set at the top of `shutdown()`. Makes `#scheduleRecompute` a * no-op so no new background sync rounds spin up mid-teardown; the -@@ -418,12 +601,18 @@ export class SubductionSource implements DocumentSource { +@@ -418,12 +525,17 @@ export class SubductionSource implements DocumentSource { timeouts, compactionDeleteConcurrency = DEFAULT_COMPACTION_DELETE_CONCURRENCY, blobInterceptor, + saveThrottle = 100, + headsChannel, -+ remote, }: SubductionSourceOptions) { + this.#saveThrottle = saveThrottle this.#blobInterceptor = blobInterceptor @@ -1877,7 +1331,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#compactionDeleteConcurrency = compactionDeleteConcurrency // Default roundtrip deadline forwarded to the Subduction constructor. // The field must be *omitted* (not passed as `undefined`) to get the -@@ -467,13 +656,28 @@ export class SubductionSource implements DocumentSource { +@@ -467,13 +579,28 @@ export class SubductionSource implements DocumentSource { // raw subduction CommitId hex would never compare equal to a doc's // own heads). const urlHeads = encodeHeads(heads.map(h => h.toHexString())) @@ -1912,7 +1366,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } // Construct without hydrating: skip preloading persisted sedimentrees -@@ -491,6 +695,32 @@ export class SubductionSource implements DocumentSource { +@@ -491,6 +618,32 @@ export class SubductionSource implements DocumentSource { ...defaultTimeoutOption, }) ) @@ -1945,53 +1399,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // ── Connection managers ───────────────────────────────────────── // Endpoints own *where* their socket lives (in-thread, Worker, ...); -@@ -512,6 +742,36 @@ export class SubductionSource implements DocumentSource { - } - this.#connectionManagers.push(adapterConnections) - -+ if (remote) { -+ this.#remote = remote -+ this.#connectionManagers.push(remote) -+ remote.bind({ -+ onData: (sid, records) => { -+ const sedimentreeId = sedimentreeIdFromString(sid) -+ const entry = this.#entries.get(sid) -+ for (const { kind, id, blob } of records) { -+ if (entry) { -+ if (kind === "commit") entry.persistedCommitHashes.add(id) -+ else entry.persistedFragmentHashes.add(id) -+ } -+ this.#handleDataFound(sedimentreeId, CommitId.fromHexString(id), blob) -+ } -+ }, -+ onRemoteHeads: (sid, peerId, heads) => -+ onRemoteHeads( -+ sedimentreeIdFromString(sid), -+ { toString: () => peerId }, -+ heads.map(hex => ({ toHexString: () => hex })) -+ ), -+ onEphemeral: (sid, senderId, payload) => -+ onEphemeral?.( -+ sedimentreeIdFromString(sid), -+ { toString: () => senderId }, -+ payload -+ ), -+ }) -+ } -+ - for (const mgr of this.#connectionManagers) { - mgr.onChange(() => { - this.#scheduleRecompute() -@@ -533,6 +793,8 @@ export class SubductionSource implements DocumentSource { - // ── Sync scheduler ──────────────────────────────────────────────── - this.#scheduler = new SyncScheduler({ - subduction: this.#subduction, -+ syncWithAllPeers: (sedimentreeId, timeoutMs) => -+ this.#syncWithAllPeers(sedimentreeId, timeoutMs), - log: this.#log, - onSyncDataReceived: async (sedimentreeId, subduction) => { - const entry = this.#entries.get(sedimentreeId.toString()) -@@ -554,6 +816,9 @@ export class SubductionSource implements DocumentSource { +@@ -554,6 +707,9 @@ export class SubductionSource implements DocumentSource { * when `persist` is set, durably store it so the last-known sync state * survives reload and replays on the next attach. `heads` must already be * in automerge-repo `UrlHeads` (bs58check) form. @@ -2001,7 +1409,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 */ #surfaceRemoteHeads( sedimentreeId: SedimentreeId, -@@ -561,14 +826,24 @@ export class SubductionSource implements DocumentSource { +@@ -561,14 +717,24 @@ export class SubductionSource implements DocumentSource { heads: UrlHeads, timestamp: number, persist: boolean @@ -2033,7 +1441,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 const documentId = toDocumentId(sedimentreeId) this.#onRemoteHeadsChanged?.( -@@ -582,6 +857,22 @@ export class SubductionSource implements DocumentSource { +@@ -582,6 +748,22 @@ export class SubductionSource implements DocumentSource { .saveRemoteHeads(sedimentreeId, storageId, heads, timestamp) .catch(e => this.#log.debug("saveRemoteHeads failed: %O", e)) } @@ -2056,7 +1464,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } #anyConnectionManagerConnecting(): boolean { -@@ -631,6 +922,7 @@ export class SubductionSource implements DocumentSource { +@@ -631,6 +813,7 @@ export class SubductionSource implements DocumentSource { // — recomputes are targeted, so other entries' completions no // longer walk this one. Mark it dirty so the walk retries the load. if (entry.syncState === "initializing") { @@ -2064,7 +1472,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#scheduleRecompute(entry) return } -@@ -759,13 +1051,14 @@ export class SubductionSource implements DocumentSource { +@@ -759,13 +942,14 @@ export class SubductionSource implements DocumentSource { // Save throttle. Concurrency is enforced inside `#save` itself // (the `saveInProgress` gate makes concurrent invocations // early-return), so the throttle just needs to coalesce rapid @@ -2082,7 +1490,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#entries.set(sidStr, { syncState: "initializing", -@@ -775,17 +1068,23 @@ export class SubductionSource implements DocumentSource { +@@ -775,17 +959,23 @@ export class SubductionSource implements DocumentSource { syncInFlight: false, lastSyncResult: null, lastSyncGeneration: -1, @@ -2106,7 +1514,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 saveSettled, saveInProgress: false, saveDeltaPending: false, -@@ -799,14 +1098,18 @@ export class SubductionSource implements DocumentSource { +@@ -799,7 +989,7 @@ export class SubductionSource implements DocumentSource { query.sourcePending("subduction") @@ -2115,19 +1523,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 throttledSave() // Subscribe to ephemeral messages for this sedimentree - void (async () => { - try { -- const subduction = await this.#subduction - const sid = toSedimentreeId(query.documentId) -+ if (this.#remote) { -+ await this.#remote.subscribeEphemeral(sid.toString()) -+ return -+ } -+ const subduction = await this.#subduction - await subduction.subscribeEphemeral([Topic.fromBytes(sid.toBytes())]) - } catch (e) { - this.#log.debug("ephemeral subscribe failed: %O", e) -@@ -851,6 +1154,7 @@ export class SubductionSource implements DocumentSource { +@@ -851,6 +1041,7 @@ export class SubductionSource implements DocumentSource { } })() @@ -2135,7 +1531,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // Replay the last-known remote heads for this sedimentree so the // DocHandle reflects the last sync state immediately on (re)attach, // before any network round-trip completes. Does not re-persist. -@@ -877,7 +1181,48 @@ export class SubductionSource implements DocumentSource { +@@ -877,7 +1068,45 @@ export class SubductionSource implements DocumentSource { this.#scheduleRecompute(this.#entries.get(sidStr)) } @@ -2172,12 +1568,9 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 + } + if (this.#entries.has(sidStr)) return + this.#scheduler.resetHealState(sidStr) -+ this.#remote?.release(sidStr) + if (!subduction) return + try { -+ if (this.#remote) await this.#remote.unsubscribeEphemeral(sidStr) -+ else -+ await subduction.unsubscribeEphemeral([Topic.fromBytes(sid.toBytes())]) ++ await subduction.unsubscribeEphemeral([Topic.fromBytes(sid.toBytes())]) + } catch (e) { + this.#log.debug("ephemeral unsubscribe failed: %O", e) + } @@ -2185,7 +1578,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 shareConfigChanged(): void { for (const entry of this.#entries.values()) { -@@ -1024,6 +1369,205 @@ export class SubductionSource implements DocumentSource { +@@ -1024,6 +1253,205 @@ export class SubductionSource implements DocumentSource { ) } @@ -2391,7 +1784,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 #recomputeEntry(entry: SedimentreeEntry) { // No new background syncs once shutting down: `shutdown()`'s quiesce // round is the sole sync initiator from that point. A recompute walk -@@ -1051,6 +1595,9 @@ export class SubductionSource implements DocumentSource { +@@ -1051,6 +1479,9 @@ export class SubductionSource implements DocumentSource { entry.query.sourcePending("subduction") entry.syncInFlight = true void this.#doSync(entry) @@ -2401,7 +1794,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } else if ( Automerge.getHeads(entry.handle.fullDoc()).length === 0 && !this.#anyConnectionManagerConnecting() -@@ -1060,13 +1607,31 @@ export class SubductionSource implements DocumentSource { +@@ -1060,13 +1491,31 @@ export class SubductionSource implements DocumentSource { entry.query.sourceUnavailable("subduction") } } @@ -2436,14 +1829,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } return } -@@ -1097,12 +1662,13 @@ export class SubductionSource implements DocumentSource { - await entry.saveSettled - - this.#log.debug(`doSync ${sid} (state=${entry.syncState})`) -- const peerResultMap = await subduction.syncWithAllPeers( -+ const peerResultMap = await this.#syncWithAllPeers( - sedimentreeId, -- true, +@@ -1103,6 +1552,8 @@ export class SubductionSource implements DocumentSource { timeoutMs !== undefined ? timeoutMs : this.#syncTimeout ) @@ -2452,7 +1838,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 const results = peerResultMap.entries() const anySuccess = results.some(r => r.success) this.#log.debug( -@@ -1120,7 +1686,7 @@ export class SubductionSource implements DocumentSource { +@@ -1120,7 +1571,7 @@ export class SubductionSource implements DocumentSource { // arrives, without waiting for further state transitions. // // During "initializing", `#loadBlobsAndTransition` performs one @@ -2461,44 +1847,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // here too would duplicate storage reads on first open. // // Also retry when the handle is empty: a previous load may have -@@ -1177,6 +1743,36 @@ export class SubductionSource implements DocumentSource { - } - } - -+ /** -+ * One sync round for a sedimentree, through the remote when there is one. -+ * The remote reads what this source stored out of the shared storage, so -+ * those writes have to land before it is asked. -+ */ -+ async #syncWithAllPeers( -+ sedimentreeId: SedimentreeId, -+ timeoutMs: number | null -+ ): Promise { -+ if (this.#remote) { -+ const sid = sedimentreeId.toString() -+ await this.#storage.awaitSettled([sid]) -+ const results = await this.#remote.sync(sid, timeoutMs) -+ return { entries: () => results } -+ } -+ const subduction = await this.#subduction -+ return subduction.syncWithAllPeers(sedimentreeId, true, timeoutMs) -+ } -+ -+ /** -+ * The peers this source syncs with directly, as stringified peer ids: -+ * the remote's when there is one. -+ */ -+ async connectedPeerIds(): Promise { -+ if (this.#remote) return this.#remote.connectedPeerIds() -+ const subduction = await this.#subduction -+ const peers = await subduction.getConnectedPeerIds() -+ return peers.map(p => p.toString()) -+ } -+ - /** - * Pair each stored blob with the commit or fragment id it belongs to. - */ -@@ -1204,7 +1800,7 @@ export class SubductionSource implements DocumentSource { +@@ -1204,7 +1655,7 @@ export class SubductionSource implements DocumentSource { } /** @@ -2507,7 +1856,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 * handle via `Automerge.loadIncremental`. If new data was loaded, signal * the query so it can transition to "ready". * -@@ -1213,41 +1809,64 @@ export class SubductionSource implements DocumentSource { +@@ -1213,41 +1664,64 @@ export class SubductionSource implements DocumentSource { */ async #loadBlobsIntoHandle( entry: SedimentreeEntry, @@ -2591,7 +1940,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } let toApply = blobsWithIds.map(u => u.blob) -@@ -1300,10 +1919,32 @@ export class SubductionSource implements DocumentSource { +@@ -1300,10 +1774,32 @@ export class SubductionSource implements DocumentSource { return true } @@ -2626,7 +1975,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 entry.syncState = "running" -@@ -1328,6 +1969,24 @@ export class SubductionSource implements DocumentSource { +@@ -1328,6 +1824,24 @@ export class SubductionSource implements DocumentSource { entry.query.sourcePending("subduction") } } catch (e) { @@ -2651,7 +2000,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#log.debug( `loadBlobsAndTransition threw for ${entry.sedimentreeId .toString() -@@ -1416,6 +2075,7 @@ export class SubductionSource implements DocumentSource { +@@ -1416,6 +1930,7 @@ export class SubductionSource implements DocumentSource { resolveSaveSettled = r }) @@ -2659,7 +2008,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 try { const doc = entry.handle.fullDoc() if (!doc) return -@@ -1438,7 +2098,7 @@ export class SubductionSource implements DocumentSource { +@@ -1438,7 +1953,7 @@ export class SubductionSource implements DocumentSource { `syncInFlight=${entry.syncInFlight}` ) @@ -2668,7 +2017,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 try { result = await this.#saveNewCommits(entry, doc, subduction) } catch (e) { -@@ -1467,6 +2127,14 @@ export class SubductionSource implements DocumentSource { +@@ -1467,6 +1982,14 @@ export class SubductionSource implements DocumentSource { // so future mutations to either side stay isolated. entry.lastSaveError = null entry.lastSavedHeads = new Set(currentSet) @@ -2683,7 +2032,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // Detect a post-save delta the `heads-changed` listener didn't // fire on. `getChangeByHash` calls inside `#saveNewCommits` can -@@ -1498,6 +2166,8 @@ export class SubductionSource implements DocumentSource { +@@ -1498,6 +2021,8 @@ export class SubductionSource implements DocumentSource { resolveSaveSettled() } @@ -2692,7 +2041,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // Trigger an immediate sync — the only broadcast path for newly // saved commits (`#saveNewCommits` is store-only). If a sync is // already in flight, flag a re-sync for when it completes; otherwise -@@ -1521,7 +2191,7 @@ export class SubductionSource implements DocumentSource { +@@ -1521,7 +2046,7 @@ export class SubductionSource implements DocumentSource { entry: SedimentreeEntry, doc: Automerge.Doc, subduction: Subduction @@ -2701,7 +2050,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 // Ask the doc directly for its level-0 loose commits and its // higher-level fragments. Automerge core owns the compaction // policy (which commits get absorbed into which fragments); we -@@ -1558,7 +2228,7 @@ export class SubductionSource implements DocumentSource { +@@ -1558,7 +2083,7 @@ export class SubductionSource implements DocumentSource { ) if (newCommitMetas.length === 0 && newFragmentMetas.length === 0) { @@ -2710,32 +2059,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } const newCommitBytes = -@@ -1583,6 +2253,8 @@ export class SubductionSource implements DocumentSource { - })) - - const acceptedHashes: string[] = [] -+ const storedRecords: Array<{ kind: "commit" | "fragment"; id: string }> = -+ [] - const commitInputs: CommitInput[] = [] - const fragmentInputs: FragmentInput[] = [] - const prepErrors: unknown[] = [] -@@ -1621,6 +2293,7 @@ export class SubductionSource implements DocumentSource { - ) - commitInputs.push(new CommitInput(looseCommit, commitBytes)) - acceptedHashes.push(c.head) -+ storedRecords.push({ kind: "commit", id: c.head }) - } catch (e) { - this.#log.warn(`commit input prep failed for ${c.head}:`, e) - prepErrors.push(e) -@@ -1659,6 +2332,7 @@ export class SubductionSource implements DocumentSource { - ) - fragmentInputs.push(new FragmentInput(fragment, fragmentBytes)) - acceptedHashes.push(f.head) -+ storedRecords.push({ kind: "fragment", id: f.head }) - } catch (e) { - this.#log.warn(`fragment input prep failed for ${f.head}:`, e) - prepErrors.push(e) -@@ -1677,7 +2351,7 @@ export class SubductionSource implements DocumentSource { +@@ -1677,7 +2202,7 @@ export class SubductionSource implements DocumentSource { } if (commitInputs.length === 0 && fragmentInputs.length === 0) { @@ -2744,20 +2068,13 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } // Record hashes BEFORE `storeBuiltBatch` so the synchronous -@@ -1712,9 +2386,23 @@ export class SubductionSource implements DocumentSource { +@@ -1712,9 +2237,16 @@ export class SubductionSource implements DocumentSource { throw e } + // Stored for real now, so not stranded any more — including anything + // a reload marked while this batch was in flight. + for (const hash of acceptedHashes) entry.strandedHashes.delete(hash) -+ -+ if (this.#remote) { -+ // The remote reads these out of the shared storage. -+ const sid = entry.sedimentreeId.toString() -+ await this.#storage.awaitSettled([sid]) -+ this.#remote.stored(sid, storedRecords) -+ } + this.#scheduleCompaction(entry) @@ -2769,7 +2086,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 } /** -@@ -1892,6 +2580,7 @@ export class SubductionSource implements DocumentSource { +@@ -1892,6 +2424,7 @@ export class SubductionSource implements DocumentSource { if (entry === undefined) return this.#scheduler.resetHealState(sid) entry.lastSyncResult = null @@ -2777,7 +2094,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#scheduleRecompute(entry) } -@@ -2021,6 +2710,11 @@ export class SubductionSource implements DocumentSource { +@@ -2021,6 +2554,11 @@ export class SubductionSource implements DocumentSource { this.#scheduler.shutdown() @@ -2789,7 +2106,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 for (const entry of this.#entries.values()) this.#flushInbound(entry) for (const entry of this.#entries.values()) { -@@ -2076,6 +2770,7 @@ export class SubductionSource implements DocumentSource { +@@ -2076,6 +2614,7 @@ export class SubductionSource implements DocumentSource { for (const endpoint of this.#websocketEndpoints) { endpoint.shutdown?.() } @@ -2797,7 +2114,7 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#storage.close() await this.#awaitStorageIdle() return -@@ -2090,6 +2785,7 @@ export class SubductionSource implements DocumentSource { +@@ -2090,6 +2629,7 @@ export class SubductionSource implements DocumentSource { endpoint.shutdown?.() } @@ -2805,17 +2122,3 @@ index 4bd5d5cf682f3792f403efa44914ca88fa8f4006..fed09c115322ce0e26acdec0d24920c6 this.#storage.close() await this.#awaitStorageIdle() } -@@ -2129,8 +2825,12 @@ export class SubductionSource implements DocumentSource { - /** Publish an ephemeral payload to subduction peers for the given document. */ - async publishEphemeral(documentId: DocumentId, payload: Uint8Array) { - try { -- const subduction = await this.#subduction - const sid = toSedimentreeId(documentId) -+ if (this.#remote) { -+ await this.#remote.publishEphemeral(sid.toString(), payload) -+ return -+ } -+ const subduction = await this.#subduction - await subduction.publishEphemeral(Topic.fromBytes(sid.toBytes()), payload) - } catch (e) { - this.#log.debug("ephemeral publish failed: %O", e) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb11dbf0..f1593ba90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ overrides: solid-automerge: ^2.0.1 patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.48': 23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17 + '@automerge/automerge-repo@2.6.0-subduction.48': 3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e importers: @@ -82,7 +82,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-keyhive': specifier: 0.6.0-alpha.1 version: 0.6.0-alpha.1(ws@8.21.1) @@ -171,7 +171,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-keyhive': specifier: 0.6.0-alpha.1 version: 0.6.0-alpha.1(ws@8.21.1) @@ -216,7 +216,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) debug: specifier: ^4.4.3 version: 4.4.3 @@ -247,7 +247,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-keyhive': specifier: 0.6.0-alpha.1 version: 0.6.0-alpha.1(ws@8.21.1) @@ -321,7 +321,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-keyhive': specifier: 0.6.0-alpha.1 version: 0.6.0-alpha.1(ws@8.21.1) @@ -352,7 +352,7 @@ importers: version: 3.5.0 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -367,7 +367,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -380,7 +380,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-react-hooks': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -402,10 +402,10 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17))(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -417,7 +417,7 @@ importers: dependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + version: 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-network-broadcastchannel': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 @@ -2040,7 +2040,7 @@ snapshots: '@automerge/automerge-repo-keyhive@0.6.0-alpha.1(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 '@automerge/automerge-subduction': 0.16.1 '@keyhive/keyhive': 0.3.0-alpha.1 @@ -2056,7 +2056,7 @@ snapshots: '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) transitivePeerDependencies: - bufferutil - supports-color @@ -2064,7 +2064,7 @@ snapshots: '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil @@ -2073,7 +2073,7 @@ snapshots: '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2086,7 +2086,7 @@ snapshots: '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge': 3.5.0 - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2097,13 +2097,13 @@ snapshots: '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17)': + '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e)': dependencies: '@automerge/automerge': 3.5.0 '@automerge/automerge-subduction': 0.16.1 @@ -2127,7 +2127,7 @@ snapshots: '@automerge/vanillajs@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 @@ -3351,9 +3351,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17))(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=23574ea13d620e8dc73db8a302cb148311fe571c8636f7650e592f8dea607b17) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=3560381ba62410bed0716e7ef08af4200bcf8c0af4aa0794c1d5d05d23e97d5e) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14 diff --git a/sites/bench/bench.config.ts b/sites/bench/bench.config.ts index 20319662b..51c8e9b50 100644 --- a/sites/bench/bench.config.ts +++ b/sites/bench/bench.config.ts @@ -1,7 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; -// Benchmarks, not tests: one worker, no retries, chromium only, so the numbers -// come from an otherwise idle browser. `pnpm bench` builds first. +// Benchmarks, not tests: one worker, no retries, one browser at a time, so the +// numbers come from an otherwise idle browser. `pnpm bench` builds first; +// `--project=chromium` (or firefox, webkit) runs just the one. const PORT = Number(process.env.PORT ?? 5199); export default defineConfig({ @@ -15,14 +16,24 @@ export default defineConfig({ globalTeardown: "./tests/global-teardown.ts", outputDir: "bench-results/artifacts", use: { - ...devices["Desktop Chrome"], - // Full chromium in new-headless mode, not the headless shell: the shell - // lacks measureUserAgentSpecificMemory. - channel: "chromium", baseURL: `http://localhost:${PORT}`, serviceWorkers: "allow", }, - projects: [{ name: "chromium" }], + // Renderer memory and the memory ladder read chromium's processes over CDP, + // so they're chromium only; elsewhere those rows are –. + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + // Full chromium in new-headless mode, not the headless shell: the + // shell lacks measureUserAgentSpecificMemory. + channel: "chromium", + }, + }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], webServer: { command: "pnpm preview", url: `http://localhost:${PORT}`, diff --git a/sites/bench/compare.ts b/sites/bench/compare.ts index 4eaa22d89..2cff99f3c 100644 --- a/sites/bench/compare.ts +++ b/sites/bench/compare.ts @@ -1,13 +1,17 @@ // Puts bench runs of main and a PR side by side, as markdown: // -// node compare.ts --main ... --pr ... +// node compare.ts --main ... --pr ... [--why ] +// +// Exits 1 when the PR fails a check main passes, and with --why writes a line +// per such check saying why, for the workflow's error annotations. // // Each directory is one run. Only Patchwork as it ships, `patchwork` mode: the // other modes and the storage adapters are alternatives the bench explores, // not what merging the PR changes. Each side ran its own checkout's -// benchmarks, so a benchmark the PR adds or renames shows – for main. Every -// number is lower-is-better. -import { readFileSync } from "node:fs"; +// benchmarks, so a benchmark (or browser) the PR adds or renames shows – for +// main. A result without a browser is chromium's, from before there were +// others. Every number is lower-is-better. +import { readFileSync, writeFileSync } from "node:fs"; // A row has changed when the two sides' runs don't overlap at all, every run of // the PR above every run of main or below it, so one slow run can't do it @@ -25,12 +29,26 @@ type Result = { tabs?: number; value: number | boolean | null; unit: Unit; + browser?: string; + /** A check on the bench, not Patchwork (see bench.ts): never fails a PR. */ + harness?: true; + /** What the test saw, for a check that failed. */ + note?: string; }; type MemoryRow = { scenario: string; n: number; footprintMb?: number }; -type Row = { name: string; unit: Unit }; -/** A side's measurements of each row, a value per run that measured it. */ -type Side = { runs: number; values: Map }; +const BROWSERS = ["chromium", "firefox", "webkit"]; + +type Row = { name: string; unit: Unit; browser: string; harness?: true }; +/** + * A side's measurements of each row, a value per run that measured it, and + * alongside each value the note its run left, if any. + */ +type Side = { + runs: number; + values: Map; + notes: Map; +}; function lines(path: string): T[] { try { @@ -53,30 +71,40 @@ function measured(value: unknown): value is Value { } function read(dirs: string[]): Side { - const side: Side = { runs: 0, values: new Map() }; + const side: Side = { runs: 0, values: new Map(), notes: new Map() }; for (const dir of dirs) { - const run = new Map(); - const add = (row: Row, value: unknown) => { - if (!rows.has(row.name)) rows.set(row.name, row); - if (measured(value)) run.set(row.name, value); + const run = new Map(); + const add = (row: Row, value: unknown, note?: string) => { + const key = `${row.browser}: ${row.name}`; + if (!rows.has(key)) rows.set(key, row); + // main's checkout may predate the flag. + if (row.harness) rows.get(key)!.harness = true; + if (measured(value)) run.set(key, { value, note }); }; for (const r of lines(`${dir}/results.jsonl`)) { if (r.mode !== "patchwork" || r.storage !== undefined) continue; const name = r.tabs === undefined ? r.metric : `${r.metric} (${r.tabs} tabs)`; - add({ name, unit: r.unit }, r.value); + add( + { name, unit: r.unit, browser: r.browser ?? "chromium", harness: r.harness }, + r.value, + r.note + ); } + // The memory ladder is chromium only. for (const r of lines(`${dir}/memory.jsonl`)) { if (!/\bpatchwork\b/.test(r.scenario)) continue; const name = `renderer memory, ${r.n} tab${r.n === 1 ? "" : "s"}`; - add({ name, unit: "MB" }, r.footprintMb); + add({ name, unit: "MB", browser: "chromium" }, r.footprintMb); } - if (!run.size) continue; - side.runs++; - for (const [name, value] of run) { - side.values.set(name, [...(side.values.get(name) ?? []), value]); + for (const [key, { value, note }] of run) { + side.values.set(key, [...(side.values.get(key) ?? []), value]); + side.notes.set(key, [...(side.notes.get(key) ?? []), note]); } } + // Each browser's runs are their own directories, so a side ran as many + // times as its most-measured row. + side.runs = Math.max(0, ...[...side.values.values()].map((v) => v.length)); return side; } @@ -110,23 +138,30 @@ function cell(values: Value[] | undefined, unit: Unit): string { return `${middle} (${bare(low)}–${bare(high)})`; } -/** The change from main to the PR, and whether it's more than noise. */ +/** + * The change from main to the PR, whether it's more than noise, and whether + * it fails the PR: a check main passes, or doesn't have yet, failing in any run + * of the PR. A check main fails too doesn't. + */ function change( before: Value[] | undefined, after: Value[] | undefined, unit: Unit -): { text: string; moved: boolean } { - if (!before?.length || !after?.length) return { text: "", moved: false }; - if (unit === "ok") { - const mainOk = before.every(Boolean); - const prOk = after.every(Boolean); - if (mainOk && !prOk) { - const failed = after.filter((v) => !v).length; - return { text: `**fails ${failed} of ${after.length}**`, moved: true }; +): { text: string; moved: boolean; breaks?: boolean } { + if (unit === "ok" && after?.length) { + const mainOk = !before?.length || before.every(Boolean); + const failed = after.filter((v) => !v).length; + if (mainOk && failed) { + return { + text: `**fails ${failed} of ${after.length}**`, + moved: true, + breaks: true, + }; } - if (!mainOk && prOk) return { text: "**fixed**", moved: true }; + if (!mainOk && !failed) return { text: "**fixed**", moved: true }; return { text: "", moved: false }; } + if (!before?.length || !after?.length) return { text: "", moved: false }; const a = before.map(Number); const b = after.map(Number); const ma = median(a); @@ -146,16 +181,22 @@ function change( return { text: `**${percent} ${mb > ma ? "worse" : "better"}**`, moved }; } +/** With more than one browser, each row says which. */ function table(keys: string[], main: Side, pr: Side): string { + const browsers = new Set([...rows.values()].map((row) => row.browser)); + const many = browsers.size > 1; return [ - "| benchmark | main | PR | change |", - "| --- | --- | --- | --- |", + `| ${many ? "browser | " : ""}benchmark | main | PR | change |`, + `| ${many ? "--- | " : ""}--- | --- | --- | --- |`, ...keys.map((key) => { - const { name, unit } = rows.get(key)!; + const { name, unit, browser, harness } = rows.get(key)!; const before = main.values.get(key); const after = pr.values.get(key); - const { text } = change(before, after, unit); - return `| ${name} | ${cell(before, unit)} | ${cell(after, unit)} | ${text} |`; + let { text, breaks } = change(before, after, unit); + if (harness && breaks) { + text = `${text.replaceAll("**", "")} (the bench, not the PR)`; + } + return `| ${many ? `${browser} | ` : ""}${name} | ${cell(before, unit)} | ${cell(after, unit)} | ${text} |`; }), ].join("\n"); } @@ -164,6 +205,35 @@ function runs(n: number): string { return n === 1 ? "1 run" : `${n} runs`; } +/** + * Why a check fails the PR, following its name: the runs it failed in, why main's side of it + * doesn't excuse that, and what the test saw in each of those runs. + */ +function why(key: string): string { + const { browser } = rows.get(key)!; + const before = main.values.get(key); + const after = pr.values.get(key)!; + const notes = pr.notes.get(key)!; + const failed = after.flatMap((value, i) => (value ? [] : [i])); + const which = + failed.length === after.length + ? `every run of the PR (${after.length})` + : `run ${failed.map((i) => i + 1).join(" and ")} of the PR's ${after.length}`; + const mainHasBrowser = [...main.values.keys()].some((k) => + k.startsWith(`${browser}: `) + ); + const baseline = before?.length + ? `main passed it in all ${runs(before.length)}` + : mainHasBrowser + ? "main doesn't have this check, so a failure counts" + : `main has no ${browser} results to compare with, so a failure counts`; + const saw = failed + .filter((i) => notes[i]) + .map((i) => ` Run ${i + 1}: ${notes[i]}`) + .join(""); + return `failed in ${which}; ${baseline}.${saw}`; +} + function dirsAfter(flag: string): string[] { const args = process.argv.slice(2); const at = args.indexOf(flag); @@ -182,13 +252,38 @@ if (!mainDirs.length || !prDirs.length) { } const main = read(mainDirs); const pr = read(prDirs); -const keys = [...rows.keys()]; -const moved = keys.filter( - (key) => - change(main.values.get(key), pr.values.get(key), rows.get(key)!.unit).moved +// Grouped by browser, each in the order its rows were first recorded. +const rank = (browser: string) => + BROWSERS.includes(browser) ? BROWSERS.indexOf(browser) : BROWSERS.length; +const keys = [...rows.keys()].sort( + (a, b) => rank(rows.get(a)!.browser) - rank(rows.get(b)!.browser) +); +const changes = new Map( + keys.map((key) => [ + key, + change(main.values.get(key), pr.values.get(key), rows.get(key)!.unit), + ]) +); +const moved = keys.filter((key) => changes.get(key)!.moved); +const broken = keys.filter( + (key) => changes.get(key)!.breaks && !rows.get(key)!.harness ); const out: string[] = []; +if (broken.length) { + const reasons = broken.map((key) => [key, why(key)]); + out.push( + `**This run fails: the PR fails ${broken.length === 1 ? "a check" : `${broken.length} checks`} main passes.**`, + "", + ...reasons.map(([key, reason]) => `- **${key}** ${reason}`), + "" + ); + const args = process.argv.slice(2); + const whyFile = args.includes("--why") ? args[args.indexOf("--why") + 1] : undefined; + if (whyFile) writeFileSync(whyFile, reasons.map((r) => `${r.join(" ")}\n`).join("")); + // The workflow fails on this, once the comment is posted. + process.exitCode = 1; +} if (!pr.runs) out.push("The PR's benchmarks recorded nothing."); else if (!main.runs) out.push( diff --git a/sites/bench/src/main.ts b/sites/bench/src/main.ts index 0577315e8..6acb8ea9e 100644 --- a/sites/bench/src/main.ts +++ b/sites/bench/src/main.ts @@ -52,9 +52,7 @@ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; import { MemorySigner } from "@automerge/automerge-subduction/slim"; import { createRepo, initWasm } from "@inkandswitch/patchwork"; -import setupServiceWorker, { - getSyncRemote, -} from "@inkandswitch/patchwork-bootloader"; +import setupServiceWorker from "@inkandswitch/patchwork-bootloader"; import { loadOrCreateSigner } from "@inkandswitch/patchwork-bootloader/signer"; import { WorkerSubductionEndpoint } from "./worker-link.js"; import type { ControlPort, NodeMessage, TabMessage } from "./protocol.js"; @@ -85,6 +83,15 @@ const mark = (name: string) => (marks[name] ??= performance.now()); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const serverHeads = new Map(); +// When the server was first seen holding each document's current heads, as +// epoch ms, so serverConfirmed reports the event rather than when it polled. +const serverHeadsAt = new Map(); +function noteServerHeads(documentId: string, heads: string[]) { + const previous = serverHeads.get(documentId); + if (previous && sameHeads(previous, heads)) return; + serverHeads.set(documentId, heads); + serverHeadsAt.set(documentId, performance.timeOrigin + performance.now()); +} let serverPeerIds = new Set(); const online = Promise.withResolvers(); let isOnline: () => Promise = async () => false; @@ -160,7 +167,7 @@ async function buildTabNode(): Promise { repo = tab.repo; ownPeerId = tab.signerIdentity!.peerId; } else { - // createRepo() taken apart: the same Repo on the sync node, with or + // createRepo() taken apart: the same Repo with its own socket, with or // without its heads channel, plus classic automerge sync between tabs // over a BroadcastChannel. const storage = new IndexedDBStorageAdapter(); @@ -170,8 +177,8 @@ async function buildTabNode(): Promise { signer, storage, peerId: `bench-tab-${crypto.randomUUID()}` as PeerId, - subductionRemote: getSyncRemote(), - subductionSaveThrottle: saveThrottle ?? 20, + subductionWebsocketEndpoints: [__SYNC_SERVER__.url], + subductionSaveThrottle: saveThrottle, headsChannel: headsChannel ? "bench-heads" : undefined, network: tabBroadcast ? [new BroadcastChannelNetworkAdapter({ channelName: "bench-tabs" })] @@ -227,24 +234,31 @@ async function buildTabNode(): Promise { repo.on("subduction-remote-heads", ({ documentId, storageId, heads }) => { if (!isServer(storageId)) return; - serverHeads.set(documentId, [...heads]); + noteServerHeads(documentId, [...heads]); }); isOnline = async () => (await connectedServers()).length > 0; if (serverUrl === "none") { online.resolve(performance.now()); } else { - // Polled rather than taken from `subduction-connection`: that event is - // the aggregate over every peer, and a sibling can flip it before the - // server link is up. + // Checked on every `subduction-connection`, and polled as well: that event + // is the aggregate over every peer, so a sibling can flip it before the + // server link is up and the server's arrival then doesn't fire it. The + // event is what makes the time exact; the poll only makes sure. + let up = false; + const check = async () => { + if (up) return; + const servers = await connectedServers(); + if (!servers.length || up) return; + up = true; + serverPeerIds = new Set(servers); + online.resolve(performance.now()); + repo.off("subduction-connection", check); + }; + repo.on("subduction-connection", check); void (async () => { - for (;;) { - const servers = await connectedServers(); - if (servers.length) { - serverPeerIds = new Set(servers); - online.resolve(performance.now()); - return; - } + while (!up) { + await check(); await sleep(10); } })(); @@ -292,7 +306,7 @@ async function buildWorkerNode(): Promise { case "remote-heads": remoteHeadsSeen.push(message); if (message.storageId !== serverPeer) return; - serverHeads.set(message.documentId, message.heads); + noteServerHeads(message.documentId, message.heads); return; case "ready": mark("node"); @@ -444,7 +458,8 @@ window.bench = { storageWrites: () => (bareTabMode ? storageWrites : null), syncRounds: () => syncRounds, // Resolves with the epoch time the server was seen holding exactly the heads - // the document has right now. Polled: a few ms of slop is fine here. + // the document has right now: when its heads event arrived (noteServerHeads), + // even if that was before this was called. Polled only to wait for it. async serverConfirmed(url, timeoutMs = 30_000) { const { handle } = await find(url); const target = [...handle.heads()]; @@ -452,10 +467,12 @@ window.bench = { for (;;) { const seen = serverHeads.get(handle.documentId); if (seen && sameHeads(seen, target)) { - return performance.timeOrigin + performance.now(); + return serverHeadsAt.get(handle.documentId)!; } if (performance.now() > deadline) { - throw new Error(`server never confirmed ${url}: saw ${seen}`); + throw new Error( + `server never confirmed ${url}: it holds heads ${seen ?? "none"}, the tab ${target}` + ); } await sleep(10); } diff --git a/sites/bench/tests/bench.ts b/sites/bench/tests/bench.ts index 8e7d34c1e..45d7f525d 100644 --- a/sites/bench/tests/bench.ts +++ b/sites/bench/tests/bench.ts @@ -1,5 +1,6 @@ import { appendFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; +import { test } from "@playwright/test"; import type { Browser, BrowserContext, Page } from "@playwright/test"; export type Mode = @@ -35,10 +36,20 @@ export type Result = { /** null: no measurement, printed as – */ value: number | boolean | null; unit: "ms" | "MB" | "n" | "ok"; + /** The Playwright project it ran in: chromium, firefox or webkit. */ + browser?: string; + /** What the test saw, for a check that failed: shown with the failure. */ + note?: string; + /** + * A check on the bench itself (did the offline switch hold?), not on + * Patchwork: shown, but a PR can't fail it. + */ + harness?: true; }; export function record(result: Result): void { - appendFileSync(RESULTS, JSON.stringify(result) + "\n"); + const browser = result.browser ?? test.info().project.name; + appendFileSync(RESULTS, JSON.stringify({ ...result, browser }) + "\n"); } export function median(values: number[]): number { @@ -117,15 +128,21 @@ export function isOnline(page: Page): Promise { export type OfflineSwitch = { /** Cut or restore the link to the sync server in every tab. */ set(pages: Page[], offline: boolean): Promise; + /** + * Messages a page sent the server through the proxy while offline. Zero when + * the cut held. + */ + leaked(): number; }; /** - * A real offline switch. Playwright's `setOffline` makes new connections fail - * the way a dead network does but doesn't close a WebSocket that is already - * open, so every WebSocket a page opens is also proxied here, purely so the - * live ones can be closed. (Refusing inside the proxy instead would close the - * page's socket without an `error` event, which automerge-repo's connect never - * recovers from — a shape no real network produces.) A worker's socket is out + * A real offline switch. Playwright's `setOffline` doesn't close a WebSocket + * that is already open, nor stop a routed one from connecting, so every + * WebSocket a page opens is proxied here: the live ones are closed, and new + * ones are held going nowhere until it's back online. (Refusing them instead + * would close the page's socket without an `error` event, which + * automerge-repo's connect never recovers from — a shape no real network + * produces.) A worker's socket is out * of reach of both, so the worker modes are told to drop theirs and fail * reconnects while offline. Install before opening any tab. */ @@ -133,40 +150,78 @@ export async function offlineSwitch( context: BrowserContext ): Promise { const live = new Set<{ close(): void }>(); + // Sockets a page opened while offline, held open and going nowhere. + const held = new Set<{ close(): void }>(); + let offline = false; + let leaked = 0; await context.routeWebSocket( (url) => url.protocol === "wss:" || url.protocol === "ws:", (ws) => { + // The proxy's own connection to the server is Playwright's, out of + // reach of `setOffline`, so a page that reconnects while offline would + // get straight through. Its socket is held open with nothing behind it + // instead, the way a network that drops everything looks, until going + // back online closes it and the page reconnects for real. + if (offline) { + const hold = { close: () => ws.close({ code: 1012, reason: "offline" }) }; + held.add(hold); + ws.onClose(() => held.delete(hold)); + return; + } const server = ws.connectToServer(); + let closed = false; const link = { close() { + closed = true; live.delete(link); server.close({ code: 1012, reason: "offline" }); ws.close({ code: 1012, reason: "offline" }); }, }; live.add(link); - ws.onMessage((message) => server.send(message)); - server.onMessage((message) => ws.send(message)); + ws.onMessage((message) => { + // A message the page sent as its link was being cut goes nowhere. + if (closed) return; + if (offline && leaked++ === 0) { + console.log(`[offline switch] ${ws.url()} reached the server while offline`); + } + server.send(message); + }); + server.onMessage((message) => { + if (!closed) ws.send(message); + }); ws.onClose((code, reason) => { + closed = true; live.delete(link); server.close({ code, reason }); }); server.onClose((code, reason) => { + closed = true; live.delete(link); ws.close({ code, reason }); }); } ); return { - async set(pages, offline) { - await context.setOffline(offline); - if (offline) for (const link of [...live]) link.close(); + async set(pages, to) { + // Offline only once the live links are closed, so nothing they had in + // flight counts as a leak. + if (!to) { + offline = false; + for (const hold of [...held]) hold.close(); + } + await context.setOffline(to); + if (to) { + for (const link of [...live]) link.close(); + offline = true; + } await Promise.all( pages.map((page) => - page.evaluate((offline) => window.bench.setOffline(offline), offline) + page.evaluate((offline) => window.bench.setOffline(offline), to) ) ); }, + leaked: () => leaked, }; } @@ -359,11 +414,15 @@ export async function withStall( * only covers the caller's own agent cluster), so the pids come from CDP and * the sizes from the OS: physical footprint on macOS, the same number Activity * Monitor shows, and plain RSS elsewhere, which overcounts shared mappings - * per process and so flatters whichever topology has fewer processes. + * per process and so flatters whichever topology has fewer processes. Null in + * firefox and webkit, which have no CDP. */ export async function rendererMemory( browser: Browser -): Promise<{ mb: number; processes: number }> { +): Promise<{ mb: number | null; processes: number | null }> { + if (browser.browserType().name() !== "chromium") { + return { mb: null, processes: null }; + } const session = await browser.newBrowserCDPSession(); const { processInfo } = (await session.send("SystemInfo.getProcessInfo")) as { processInfo: Array<{ type: string; id: number }>; diff --git a/sites/bench/tests/evict.spec.ts b/sites/bench/tests/evict.spec.ts index 4cd9c2e4e..eed6b9a4c 100644 --- a/sites/bench/tests/evict.spec.ts +++ b/sites/bench/tests/evict.spec.ts @@ -1,6 +1,9 @@ import { expect, test, type Page } from "@playwright/test"; import { awaitField, online, openTab, record, setField, type Mode } from "./bench.js"; +// Garbage collection and measureUserAgentSpecificMemory are chromium only. +test.skip(({ browserName }) => browserName !== "chromium", "chromium only"); + const mode: Mode = "pertab-mesh"; const TEXT_BYTES = 1 << 20; const EDIT_TIMEOUT_MS = 5_000; diff --git a/sites/bench/tests/memory.spec.ts b/sites/bench/tests/memory.spec.ts index 617a7d108..57f29d802 100644 --- a/sites/bench/tests/memory.spec.ts +++ b/sites/bench/tests/memory.spec.ts @@ -146,7 +146,7 @@ async function measure(browser: Browser, pages: Page[]): Promise> { ) : []; return { - footprintMb: footprint.mb, + footprintMb: footprint.mb ?? undefined, processes, heap: medians(heaps), muasm: grouped.length @@ -278,11 +278,17 @@ function save(row: Row): Row[] { return rows; } -test.beforeAll(({}, info) => { +// Every measurement here goes through CDP. The hooks run even when every test +// is skipped, so they check too. +test.skip(({ browserName }) => browserName !== "chromium", "chromium only"); + +test.beforeAll(({ browserName }, info) => { + if (browserName !== "chromium") return; if (info.workerIndex === 0) writeFileSync(ROWS, ""); }); -test.afterAll(() => { +test.afterAll(({ browserName }) => { + if (browserName !== "chromium") return; console.log("\n" + readFileSync(OUT, "utf8").split("## Summary")[1]); }); diff --git a/sites/bench/tests/offline.spec.ts b/sites/bench/tests/offline.spec.ts index 8f070e029..0638d3e8a 100644 --- a/sites/bench/tests/offline.spec.ts +++ b/sites/bench/tests/offline.spec.ts @@ -6,6 +6,7 @@ import { awaitOnline, createDoc, getField, + isOnline, offlineSwitch, online, openTab, @@ -19,6 +20,11 @@ import { // cut is a WebSocket proxy closing a tab's own socket, and a control message // for a worker's (see offlineSwitch). Modes with a local channel converge // while offline; the reconnect timing is only meaningful where they didn't. +// +// The first two rows check the bench, not Patchwork: that the cut held for the +// whole offline stretch. Where it didn't (a browser whose offline emulation +// lets a reconnect through, a socket out of the switch's reach) the tabs were +// never offline, so the rows after them record nothing rather than pass. for (const mode of MODES) { test(`${mode}: concurrent offline edits converge on reconnect`, async ({ browser, @@ -39,27 +45,55 @@ for (const mode of MODES) { awaitOnline(a, false), awaitOnline(b, false), ])).every(Boolean); + await setField(a, url, "x", 1); + await setField(b, url, "y", 1); + await a.waitForTimeout(2_000); + + // Down at the start and still down now, with nothing sent through. + const stillDown = !(await Promise.all([isOnline(a), isOnline(b)])).some( + Boolean + ); + const stayedDown = wentDown && stillDown && network.leaked() === 0; + // A stranger that can't get the doc at all proves nothing either way. + const stranger = await strangerSees(browser, url); + const unseen = + stranger !== undefined && stranger.x !== 1 && stranger.y !== 1; + const held = stayedDown && unseen; + const why = [ + !wentDown && "the link never went down", + wentDown && !stillDown && "a tab was back online before the end", + network.leaked() && `${network.leaked()} messages reached the server`, + stranger === undefined && "the stranger couldn't get the doc", + stranger && !unseen && "the server had the offline edits", + ] + .filter(Boolean) + .join("; "); // In the worker modes the bench closes the socket itself, so this only // says something about the tab modes' proxy. if (!WORKER_MODES.includes(mode)) { record({ metric: "server link seen down while offline", mode, - value: wentDown, + value: stayedDown, unit: "ok", + harness: true, + note: stayedDown ? undefined : why, }); } - await setField(a, url, "x", 1); - await setField(b, url, "y", 1); - await a.waitForTimeout(2_000); - - const leaked = await strangerSees(browser, url); record({ metric: "…and the server did not see the offline edits", mode, - value: leaked?.x !== 1 && leaked?.y !== 1, + value: unseen, unit: "ok", + harness: true, + note: unseen ? undefined : why, }); + if (!held) { + const note = `offline switch didn't hold (${why}), so the offline rows record nothing`; + console.warn(`[${mode}] ${note}`); + test.info().annotations.push({ type: "warning", description: note }); + } + const [ay, bx] = await Promise.all([ getField(a, url, "y"), getField(b, url, "x"), @@ -68,8 +102,9 @@ for (const mode of MODES) { record({ metric: "tabs converged while offline, over a local channel", mode, - value: local, + value: held ? local : null, unit: "ok", + note: local ? undefined : `tab A saw y ${ay}, tab B saw x ${bx}`, }); await network.set([a, b], false); @@ -84,13 +119,21 @@ for (const mode of MODES) { record({ metric: "offline edits in two tabs both survive reconnect", mode, - value: seenY && seenX && confirmed, + value: held ? seenY && seenX && confirmed : null, unit: "ok", + note: + [ + !seenY && "tab A never got B's edit", + !seenX && "tab B never got A's edit", + !confirmed && "the server never confirmed A's heads", + ] + .filter(Boolean) + .join("; ") || undefined, }); record({ metric: "reconnect → tabs converged (only where they hadn't already)", mode, - value: !local && seenY && seenX ? converged : null, + value: held && !local && seenY && seenX ? converged : null, unit: "ms", }); @@ -102,8 +145,9 @@ for (const mode of MODES) { record({ metric: "…and a fresh tab sees both", mode, - value: x === 1 && y === 1, + value: held ? x === 1 && y === 1 : null, unit: "ok", + note: x === 1 && y === 1 ? undefined : `the fresh tab saw x ${x}, y ${y}`, }); }); } diff --git a/sites/bench/tests/scale.spec.ts b/sites/bench/tests/scale.spec.ts index 0bfa50c05..14b723ac1 100644 --- a/sites/bench/tests/scale.spec.ts +++ b/sites/bench/tests/scale.spec.ts @@ -1,6 +1,7 @@ import { test, type Page } from "@playwright/test"; import { type Mode, + WORKER_MODES, awaitField, createDoc, marks, @@ -193,7 +194,9 @@ for (const mode of MODES) { stallMaxMs: stall.maxMs, stallLongTasks: stall.longTasks, stallLongTaskMs: stall.longTaskMs, - workerErrors: workerErrors.length, + // Only the bench's own workers report errors; in the other modes the + // row would always say 0, so it says nothing. + workerErrors: WORKER_MODES.includes(mode) ? workerErrors.length : null, roundsPerSiblingEditInNonEditingTab: round( medianOrNull(roundsPerSiblingEdit), 2 diff --git a/sites/bench/tests/sync.spec.ts b/sites/bench/tests/sync.spec.ts index c00ea46cc..2ab47084c 100644 --- a/sites/bench/tests/sync.spec.ts +++ b/sites/bench/tests/sync.spec.ts @@ -18,6 +18,14 @@ const EDITS = 10; const RACE_TIMEOUT_MS = 5_000; const EDIT_TIMEOUT_MS = 5_000; +// A thrown error's first line, without Playwright's prefix, and with hashes cut +// short enough to read. +const said = (error: unknown) => + String(error instanceof Error ? error.message : error) + .split("\n")[0] + .replace(/^.*?Error: /, "") + .replace(/\b(\w{8})\w{24,}/g, "$1…"); + // Tab A creates and edits; tabs B.. find and watch. Each latency is a median // over EDITS rounds. for (const mode of MODES) { @@ -51,12 +59,17 @@ for (const mode of MODES) { mode, value: finds.every((find) => find.ok), unit: "ok", + note: finds.some((find) => !find.ok) + ? `${finds.filter((find) => !find.ok).length} of ${finds.length} tabs' finds were still unavailable after ${RACE_TIMEOUT_MS / 1000}s` + : undefined, }); + // A count, not a check: whether a find beats the creator's save is a race, + // so one that loses now and then shouldn't fail a PR. record({ - metric: "…and none settled unavailable first", + metric: `…finds that settled unavailable first (of ${TABS - 1})`, mode, - value: finds.every((find) => find.ok && find.attempts === 1), - unit: "ok", + value: finds.filter((find) => !find.ok || find.attempts > 1).length, + unit: "n", }); await serverConfirmed(a, url); @@ -86,6 +99,8 @@ for (const mode of MODES) { const confirmation: number[] = []; let missed = 0; let unconfirmed = 0; + let firstMissed: number | undefined; + let firstUnconfirmed: string | undefined; for (let i = 1; i <= EDITS; i++) { const seen = watchers.map((page) => awaitField(page, url, "counter", i, EDIT_TIMEOUT_MS).catch( @@ -95,12 +110,16 @@ for (const mode of MODES) { const edited = await setField(a, url, "counter", i); const [arrived, confirmed] = await Promise.all([ Promise.all(seen), - serverConfirmed(a, url, EDIT_TIMEOUT_MS).catch(() => undefined), + serverConfirmed(a, url, EDIT_TIMEOUT_MS).catch((error) => { + firstUnconfirmed ??= `edit ${i}: ${said(error)}`; + return undefined; + }), ]); if (arrived.every((at) => at !== undefined)) { propagation.push(Math.max(...(arrived as number[])) - edited); } else { missed++; + firstMissed ??= i; } if (confirmed !== undefined) confirmation.push(confirmed - edited); else unconfirmed++; @@ -118,6 +137,9 @@ for (const mode of MODES) { mode, value: missed === 0, unit: "ok", + note: missed + ? `${missed} of ${EDITS} edits didn't reach every other tab, the first at edit ${firstMissed}` + : undefined, }); if (confirmation.length) { record({ @@ -132,6 +154,9 @@ for (const mode of MODES) { mode, value: unconfirmed === 0, unit: "ok", + note: unconfirmed + ? `${unconfirmed} of ${EDITS} edits unconfirmed; the first, ${firstUnconfirmed}` + : undefined, }); // The server's word, checked: a client that shares nothing with these // tabs asks the server for the doc. @@ -141,6 +166,11 @@ for (const mode of MODES) { mode, value: stranger?.counter === EDITS, unit: "ok", + note: !stranger + ? "the stranger couldn't get the doc" + : stranger.counter !== EDITS + ? `the stranger got counter ${stranger.counter}, not ${EDITS}` + : undefined, }); }); } diff --git a/vitest.config.ts b/vitest.config.ts index a5357c72f..baa96d0fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { workspace: [ - "core/bootloader", "core/filesystem", + "core/patchwork", "packages/edge-handles", ], },