From 195bba076a40663870358718c45b32dff1ffea9b Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 13 Sep 2026 00:22:53 -0400 Subject: [PATCH 1/4] feat(servo): adopt unified theorem v0.5 fork --- .github/workflows/ci.yml | 7 +- .github/workflows/integration-lockfile.yml | 2 +- .github/workflows/servo-integration.yml | 63 ++++- Cargo.lock | 234 +++++------------- Cargo.toml | 12 +- README.md | 10 +- crates/turvo/src/servo/protocols.rs | 18 +- patches/servo/FORK.md | 61 +++++ patches/servo/README.md | 18 +- patches/servo/integration.json | 9 +- patches/servo/upstream-base | 8 + plans/TURVO-1.0-COMPLETION/CONTINUITY.md | 2 +- plans/TURVO-1.0-COMPLETION/disagreements.md | 2 +- plans/TURVO-1.0-COMPLETION/edges.md | 2 +- plans/TURVO-1.0-COMPLETION/lessons.md | 2 +- plans/TURVO-1.0-COMPLETION/manifest.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/D00.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/D01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/E01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/E02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/P00.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/P01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V02I.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V03.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V04.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V05.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V06.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V07.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V08.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V09.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V10.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V11.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/VX1.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W02I.md | 6 +- plans/TURVO-1.0-COMPLETION/nodes/W03.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W04.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W05.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W06.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W07.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W08.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W09.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W10.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W11.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/WX1.md | 2 +- .../TURVO-1.0-COMPLETION/plan-definition.json | 4 +- plans/TURVO-1.0-COMPLETION/projection.md | 2 +- plans/TURVO-1.0-COMPLETION/replay.md | 2 +- plans/TURVO-1.0-COMPLETION/validation.md | 2 +- rust-toolchain.toml | 4 + 53 files changed, 291 insertions(+), 243 deletions(-) create mode 100644 patches/servo/FORK.md create mode 100644 patches/servo/upstream-base create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69cdafd..814c2ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt - toolchain: 1.94.0 + toolchain: 1.95.0 - name: Check formatting run: cargo fmt --all --check - name: Test JavaScript IPC transport @@ -87,7 +87,7 @@ jobs: with: components: clippy targets: ${{ matrix.platform.target }} - toolchain: 1.94.0 + toolchain: 1.95.0 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: @@ -101,6 +101,9 @@ jobs: - name: Test runtime run: cargo test -p turvo --lib --tests --locked --target ${{ matrix.platform.target }} + - name: Verify origin-boundary negative cases + run: cargo test -p turvo --lib --locked --target ${{ matrix.platform.target }} servo::ipc::tests + - name: Lint runtime run: cargo clippy -p turvo --lib --tests --locked --target ${{ matrix.platform.target }} -- -D warnings diff --git a/.github/workflows/integration-lockfile.yml b/.github/workflows/integration-lockfile.yml index 688d7ce..550a6d9 100644 --- a/.github/workflows/integration-lockfile.yml +++ b/.github/workflows/integration-lockfile.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.94.0 + toolchain: 1.95.0 - name: Verify public source metadata run: python3 scripts/check_integration.py --metadata-only - name: Resolve new sources while retaining other locked versions diff --git a/.github/workflows/servo-integration.yml b/.github/workflows/servo-integration.yml index 415c1dd..8eb186c 100644 --- a/.github/workflows/servo-integration.yml +++ b/.github/workflows/servo-integration.yml @@ -4,10 +4,16 @@ on: push: branches: ['integration/**'] paths: + - Cargo.toml + - Cargo.lock + - rust-toolchain.toml - patches/servo/** - .github/workflows/servo-integration.yml pull_request: paths: + - Cargo.toml + - Cargo.lock + - rust-toolchain.toml - patches/servo/** - .github/workflows/servo-integration.yml workflow_dispatch: @@ -38,7 +44,7 @@ jobs: - name: Read the versioned public engine pin id: engine shell: bash - run: jq -r '"repository=\(.repository)\nrevision=\(.revision)"' patches/servo/integration.json >> "$GITHUB_OUTPUT" + run: jq -r '"repository=\(.repository)\nrevision=\(.revision)\nbranch=\(.branch)\nbase_revision=\(.base_revision)\nahead_by=\(.ahead_by)\nbehind_by=\(.behind_by)\nrust_channel=\(.rust_channel)"' patches/servo/integration.json >> "$GITHUB_OUTPUT" - name: Check out the exact public engine revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -46,6 +52,7 @@ jobs: ref: ${{ steps.engine.outputs.revision }} path: pinned-servo persist-credentials: false + fetch-depth: 0 sparse-checkout: | .cargo components @@ -55,12 +62,40 @@ jobs: support tests/unit tests/capi - - name: Verify the versioned patch is present + - name: Verify the published branch and v0.5.0 lineage working-directory: pinned-servo + shell: bash + run: | + test "$(git rev-parse HEAD)" = "${{ steps.engine.outputs.revision }}" + test "$(git ls-remote https://github.com/${{ steps.engine.outputs.repository }}.git refs/heads/${{ steps.engine.outputs.branch }} | cut -f1)" = "${{ steps.engine.outputs.revision }}" + git fetch origin refs/tags/v0.5.0:refs/tags/v0.5.0 + test "$(git rev-parse refs/tags/v0.5.0^{commit})" = "${{ steps.engine.outputs.base_revision }}" + git merge-base --is-ancestor "${{ steps.engine.outputs.base_revision }}" HEAD + read -r behind_by ahead_by < <(git rev-list --left-right --count "${{ steps.engine.outputs.base_revision }}...HEAD") + echo "lineage: ahead_by=$ahead_by behind_by=$behind_by" + test "$ahead_by" = "${{ steps.engine.outputs.ahead_by }}" + test "$behind_by" = "${{ steps.engine.outputs.behind_by }}" + engine_channel="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("rust-toolchain.toml").read_text())["toolchain"]["channel"])')" + embedder_channel="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("../rust-toolchain.toml").read_text())["toolchain"]["channel"])')" + echo "rust channel: servo=$engine_channel turvo=$embedder_channel" + test "$engine_channel" = "${{ steps.engine.outputs.rust_channel }}" + test "$embedder_channel" = "${{ steps.engine.outputs.rust_channel }}" + - name: Verify the ordered versioned patch stack + shell: bash run: | - git apply --reverse --check ../patches/servo/0003-shared-network-test-runtime.patch - git apply --reverse --check ../patches/servo/0002-cancellation-feature-lockfile.patch - git apply --reverse --check ../patches/servo/0001-policy-preserving-http-interception.patch + patch_check="$RUNNER_TEMP/pinned-servo-patch-check" + git clone pinned-servo "$patch_check" + for patch in \ + 0007-sendable-web-resource-responders.patch \ + 0006-align-jemalloc-consumer-graph.patch \ + 0005-storage-engine-factories.patch \ + 0004-fix-security-identify-window-backed-requests.patch \ + 0003-shared-network-test-runtime.patch \ + 0002-cancellation-feature-lockfile.patch \ + 0001-policy-preserving-http-interception.patch + do + git -C "$patch_check" apply --reverse "$GITHUB_WORKSPACE/patches/servo/$patch" + done - name: Install Linux networking test prerequisites if: runner.os == 'Linux' run: | @@ -69,7 +104,7 @@ jobs: echo "RUSTFLAGS=-C link-arg=-fuse-ld=lld" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - toolchain: 1.94.0 + toolchain: 1.95.0 components: rustfmt - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: @@ -78,15 +113,23 @@ jobs: cache-on-failure: true - name: Check new adapter and regression-test formatting working-directory: pinned-servo - run: rustfmt +1.94.0 --edition 2024 --check components/net/request_interceptor.rs components/net/tests/interception.rs + run: rustfmt +1.95.0 --edition 2024 --check components/net/request_interceptor.rs components/net/tests/interception.rs - name: Check the file-manager fixture in isolation working-directory: pinned-servo shell: bash - run: cargo +1.94.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact 2>&1 | tee filemanager-test.log + run: cargo +1.95.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact 2>&1 | tee filemanager-test.log - name: Run all engine networking regression tests working-directory: pinned-servo shell: bash - run: cargo +1.94.0 test -p servo-net --test main --locked 2>&1 | tee net-tests.log + run: cargo +1.95.0 test -p servo-net --test main --locked 2>&1 | tee net-tests.log + - name: Run storage engine regression tests + working-directory: pinned-servo + shell: bash + run: cargo +1.95.0 test -p servo-storage --lib --locked 2>&1 | tee storage-tests.log + - name: Run request-client identity tests + working-directory: pinned-servo + shell: bash + run: cargo +1.95.0 test -p servo-net-traits --test request_client --locked 2>&1 | tee request-client-tests.log - name: Retain exact engine test receipt if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -95,4 +138,6 @@ jobs: path: | pinned-servo/net-tests.log pinned-servo/filemanager-test.log + pinned-servo/storage-tests.log + pinned-servo/request-client-tests.log if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index 1800e44..4ddbe23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,7 +246,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -1662,7 +1662,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1694,7 +1694,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.8.9", + "libloading 0.7.4", ] [[package]] @@ -2033,7 +2033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4835,7 +4835,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.119", @@ -5191,7 +5191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -5665,15 +5665,6 @@ dependencies = [ "toml_edit 0.20.7", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -6226,7 +6217,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6283,7 +6274,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6685,7 +6676,7 @@ dependencies = [ [[package]] name = "servo" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "arboard", @@ -6749,7 +6740,7 @@ dependencies = [ [[package]] name = "servo-allocator" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "libc", "tikv-jemalloc-sys", @@ -6760,7 +6751,7 @@ dependencies = [ [[package]] name = "servo-background-hang-monitor" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "backtrace", "crossbeam-channel", @@ -6775,7 +6766,7 @@ dependencies = [ [[package]] name = "servo-background-hang-monitor-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "serde", "servo-base", @@ -6784,7 +6775,7 @@ dependencies = [ [[package]] name = "servo-base" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "crossbeam-channel", @@ -6809,7 +6800,7 @@ dependencies = [ [[package]] name = "servo-canvas" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "bytemuck", "crossbeam-channel", @@ -6834,7 +6825,7 @@ dependencies = [ [[package]] name = "servo-canvas-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "crossbeam-channel", "euclid", @@ -6856,7 +6847,7 @@ dependencies = [ [[package]] name = "servo-config" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "num_enum", "serde", @@ -6869,7 +6860,7 @@ dependencies = [ [[package]] name = "servo-config-macro" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "proc-macro2", "quote", @@ -6880,7 +6871,7 @@ dependencies = [ [[package]] name = "servo-constellation" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "backtrace", @@ -6924,7 +6915,7 @@ dependencies = [ [[package]] name = "servo-constellation-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "base64 0.23.1", "content-security-policy", @@ -6959,7 +6950,7 @@ dependencies = [ [[package]] name = "servo-default-resources" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "servo-embedder-traits", ] @@ -6967,7 +6958,7 @@ dependencies = [ [[package]] name = "servo-deny-public-fields" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -6977,7 +6968,7 @@ dependencies = [ [[package]] name = "servo-devtools" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "atomic_refcell", "base64 0.23.1", @@ -7007,7 +6998,7 @@ dependencies = [ [[package]] name = "servo-devtools-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "http 1.5.0", "malloc_size_of_derive", @@ -7024,7 +7015,7 @@ dependencies = [ [[package]] name = "servo-dom-struct" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "prettyplease", "proc-macro2", @@ -7035,7 +7026,7 @@ dependencies = [ [[package]] name = "servo-embedder-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "bitflags 2.13.1", @@ -7069,7 +7060,7 @@ dependencies = [ [[package]] name = "servo-fonts" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "app_units", "atomic_refcell", @@ -7128,7 +7119,7 @@ dependencies = [ [[package]] name = "servo-fonts-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "atomic_refcell", "dwrote", @@ -7153,7 +7144,7 @@ dependencies = [ [[package]] name = "servo-geometry" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "app_units", "euclid", @@ -7166,7 +7157,7 @@ dependencies = [ [[package]] name = "servo-hyper-serde" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "cookie 0.18.2", "headers", @@ -7180,7 +7171,7 @@ dependencies = [ [[package]] name = "servo-jstraceable-derive" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -7190,7 +7181,7 @@ dependencies = [ [[package]] name = "servo-layout" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "app_units", @@ -7249,7 +7240,7 @@ dependencies = [ [[package]] name = "servo-layout-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "app_units", "atomic_refcell", @@ -7283,7 +7274,7 @@ dependencies = [ [[package]] name = "servo-malloc-size-of" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "app_units", "atomic_refcell", @@ -7326,7 +7317,7 @@ dependencies = [ [[package]] name = "servo-media" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "servo-base", "servo-media-audio", @@ -7339,7 +7330,7 @@ dependencies = [ [[package]] name = "servo-media-audio" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "byte-slice-cast", "euclid", @@ -7363,7 +7354,7 @@ dependencies = [ [[package]] name = "servo-media-derive" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "proc-macro2", "quote", @@ -7373,7 +7364,7 @@ dependencies = [ [[package]] name = "servo-media-dummy" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "servo-base", "servo-media", @@ -7387,7 +7378,7 @@ dependencies = [ [[package]] name = "servo-media-ohos" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "crossbeam-channel", "libc", @@ -7410,7 +7401,7 @@ dependencies = [ [[package]] name = "servo-media-player" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "malloc_size_of_derive", "serde", @@ -7423,7 +7414,7 @@ dependencies = [ [[package]] name = "servo-media-streams" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "malloc_size_of_derive", "servo-malloc-size-of", @@ -7433,7 +7424,7 @@ dependencies = [ [[package]] name = "servo-media-thread" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "euclid", "ipc-channel", @@ -7452,7 +7443,7 @@ dependencies = [ [[package]] name = "servo-media-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "malloc_size_of_derive", "servo-malloc-size-of", @@ -7461,7 +7452,7 @@ dependencies = [ [[package]] name = "servo-media-webrtc" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "log", "servo-media-streams", @@ -7471,7 +7462,7 @@ dependencies = [ [[package]] name = "servo-metrics" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "malloc_size_of_derive", "servo-base", @@ -7486,7 +7477,7 @@ dependencies = [ [[package]] name = "servo-net" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "async-compression", "async-recursion", @@ -7558,7 +7549,7 @@ dependencies = [ [[package]] name = "servo-net-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "content-security-policy", "cookie 0.18.2", @@ -7599,7 +7590,7 @@ dependencies = [ [[package]] name = "servo-paint" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "bitflags 2.13.1", "crossbeam-channel", @@ -7636,7 +7627,7 @@ dependencies = [ [[package]] name = "servo-paint-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "bitflags 2.13.1", "crossbeam-channel", @@ -7671,7 +7662,7 @@ dependencies = [ [[package]] name = "servo-pixels" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "euclid", "image", @@ -7686,7 +7677,7 @@ dependencies = [ [[package]] name = "servo-profile" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "libc", "log", @@ -7704,7 +7695,7 @@ dependencies = [ [[package]] name = "servo-profile-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "crossbeam-channel", "ipc-channel", @@ -7720,7 +7711,7 @@ dependencies = [ [[package]] name = "servo-script" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "aes", "aes-gcm", @@ -7863,7 +7854,7 @@ dependencies = [ [[package]] name = "servo-script-bindings" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "atomic_refcell", "bitflags 2.13.1", @@ -7903,7 +7894,7 @@ dependencies = [ [[package]] name = "servo-script-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "accesskit", "crossbeam-channel", @@ -7937,7 +7928,7 @@ dependencies = [ [[package]] name = "servo-storage" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "libc", "log", @@ -7962,7 +7953,7 @@ dependencies = [ [[package]] name = "servo-storage-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "malloc_size_of_derive", "serde", @@ -7976,7 +7967,7 @@ dependencies = [ [[package]] name = "servo-timers" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "crossbeam-channel", "malloc_size_of_derive", @@ -7986,7 +7977,7 @@ dependencies = [ [[package]] name = "servo-tracing" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "proc-macro2", "quote", @@ -7996,7 +7987,7 @@ dependencies = [ [[package]] name = "servo-url" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "encoding_rs", "malloc_size_of_derive", @@ -8010,7 +8001,7 @@ dependencies = [ [[package]] name = "servo-wakelock" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "serde", "servo-embedder-traits", @@ -8019,7 +8010,7 @@ dependencies = [ [[package]] name = "servo-webgl" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "bitflags 2.13.1", "byteorder", @@ -8043,7 +8034,7 @@ dependencies = [ [[package]] name = "servo-webvtt" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "html5ever 0.39.0", "markup5ever 0.39.0", @@ -8053,7 +8044,7 @@ dependencies = [ [[package]] name = "servo-webxr-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "euclid", "ipc-channel", @@ -8069,7 +8060,7 @@ dependencies = [ [[package]] name = "servo-xpath" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=c535d2b639bde66570dbcf0f07c3fce009c01b9a#c535d2b639bde66570dbcf0f07c3fce009c01b9a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" dependencies = [ "log", "malloc_size_of_derive", @@ -9133,7 +9124,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -9161,7 +9152,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9562,18 +9553,6 @@ dependencies = [ "winnow 0.7.15", ] -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap 2.14.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -10485,7 +10464,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10717,15 +10696,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -10759,30 +10729,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -10822,12 +10775,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -10840,12 +10787,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -10858,24 +10799,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -10888,12 +10817,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -10906,12 +10829,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -10924,12 +10841,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -10942,12 +10853,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -10971,9 +10876,6 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index 16e1af3..a2da420 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.94" +rust-version = "1.95" license = "MIT OR Apache-2.0" homepage = "https://github.com/Travis-Gilbert/Turvo" repository = "https://github.com/Travis-Gilbert/Turvo" @@ -25,11 +25,11 @@ ureq = { version = "3.4", default-features = false } # Public development integration only. Registry release remains gated by E02; # published versions do not yet provide these runtime/origin contracts. [patch.crates-io] -servo = { git = "https://github.com/Travis-Gilbert/servo", rev = "c535d2b639bde66570dbcf0f07c3fce009c01b9a" } -servo-net-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "c535d2b639bde66570dbcf0f07c3fce009c01b9a" } -servo-base = { git = "https://github.com/Travis-Gilbert/servo", rev = "c535d2b639bde66570dbcf0f07c3fce009c01b9a" } -servo-storage-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "c535d2b639bde66570dbcf0f07c3fce009c01b9a" } -servo-url = { git = "https://github.com/Travis-Gilbert/servo", rev = "c535d2b639bde66570dbcf0f07c3fce009c01b9a" } +servo = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } +servo-net-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } +servo-base = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } +servo-storage-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } +servo-url = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } tauri = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } tauri-runtime = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } tauri-utils = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } diff --git a/README.md b/README.md index be09dc4..2ed99fe 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,13 @@ default model is GPT-5.3 Codex Spark and can be overridden with the ## Scope -Turvo is an application shell for content the application ships. Compatibility -with arbitrary third-party websites is not a project goal. Mobile targets keep -Tauri's Wry runtime; Turvo is desktop-only. +Turvo is the desktop home of Theorem's Servo integration. It must preserve web +origin boundaries for application content and third-party sites alike; remote, +nested, opaque, and sandboxed callers never inherit local application +capabilities or bundled-asset authority. Compatibility defects against +third-party sites are in scope when they violate the supported web-platform or +security contract. Mobile targets keep Tauri's Wry runtime; Turvo is +desktop-only. For an application that also ships on mobile, make the runtime dependency target-specific so the desktop graph does not enable Wry and the mobile graph diff --git a/crates/turvo/src/servo/protocols.rs b/crates/turvo/src/servo/protocols.rs index a449768..da45789 100644 --- a/crates/turvo/src/servo/protocols.rs +++ b/crates/turvo/src/servo/protocols.rs @@ -25,6 +25,7 @@ use super::ipc::{ const MAX_CODE_SERVER_RESOURCE_BYTES: u64 = 64 * 1024 * 1024; const VSCODE_RESOURCE_SUFFIX: &str = ".vscode-resource.vscode-cdn.net"; const VSCODE_WEBVIEW_SUFFIX: &str = ".vscode-cdn.net"; +const VSCODE_WEBVIEW_ENTRY_DIRECTORY: &str = "/out/vs/workbench/contrib/webview/browser/pre/"; #[derive(Clone)] struct CodeServerProxy { @@ -72,21 +73,34 @@ impl CodeServerProxy { let path = percent_decode_str(source.path()) .decode_utf8() .map_err(|_| http::StatusCode::BAD_REQUEST)?; + // The URL path contributes one structural slash before VS Code's + // percent-encoded absolute resource path. Preserve absolute and UNC + // paths while removing only that duplicated structural slash. + let path = if path.starts_with("//") { + &path[1..] + } else { + path.as_ref() + }; if path.as_bytes().contains(&0) { return Err(http::StatusCode::BAD_REQUEST); } let mut target = self.endpoint("vscode-remote-resource"); - target.query_pairs_mut().append_pair("path", &path); + target.query_pairs_mut().append_pair("path", path); return Ok(Some(target)); } let path = percent_decode_str(source.path()) .decode_utf8() .map_err(|_| http::StatusCode::BAD_REQUEST)?; + let is_webview_entry = ["index.html", "fake.html"].into_iter().any(|entry| { + path + .strip_suffix(entry) + .is_some_and(|prefix| prefix.ends_with(VSCODE_WEBVIEW_ENTRY_DIRECTORY)) + }); if path .split('/') .any(|component| matches!(component, "." | "..")) - || !matches!(path.rsplit('/').next(), Some("index.html" | "fake.html")) + || !is_webview_entry { return Err(http::StatusCode::NOT_FOUND); } diff --git a/patches/servo/FORK.md b/patches/servo/FORK.md new file mode 100644 index 0000000..cdaf56f --- /dev/null +++ b/patches/servo/FORK.md @@ -0,0 +1,61 @@ +# Unified Theorem/Turvo Servo fork + +Turvo owns the Servo integration, exact pin, migration policy, and hosted +verification for Theorem desktop. GPUI remains the native window and chrome +owner; this fork supplies the embeddable web engine and does not make Servo or +Tauri the application window system. + +## Lineage + +| Fact | Value | +|---|---| +| Upstream tag | `refs/tags/v0.5.0` = `1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019` | +| Previous-release merge base | `b5675b1bc38498a26530b27e578122a8068af3b6` | +| Fork branch | `Travis-Gilbert/servo:theorem/v0.5.0` | +| Current pin | `b70d4e64c0005d5dc2d5257c09f997dba235410a` | +| Relationship | `ahead_by=20`, `behind_by=0` against upstream `v0.5.0` | +| Rust channel | `1.95.0`, identical to upstream `v0.5.0` and Turvo's `rust-toolchain.toml` | +| Recorded in | `integration.json` and `upstream-base` | +| Enforced by | `.github/workflows/servo-integration.yml` | + +The old `v0.4.0` release branch is not an ancestor of `v0.5.0`; both descend +from the recorded merge base. ADDENDUM-1 deliberately invokes the former +Theorem fork ledger's rebase escape clause: update the base record in the same +change, replay every surviving fork commit onto the named release tag, and +invalidate receipts bound to the old SHA. + +## Carried engine work + +| Commit | Purpose | Disposition | +|---|---|---| +| `5cd7e545ab` | Document layout snapshot and hit-test series | Local embedder read seam | +| `5585210c17` | Preserve parent profiler identity | Local multiprocess fix | +| `4a965593cb` | Trace content-process startup | Local diagnostic | +| `b491fb1e61` | Forward the content diagnostic gate | Local diagnostic | +| `a8854be8df` | Signal random pipeline closures | Local multiprocess fix | +| `047688d947` | Own the selected pipeline identifier | Local multiprocess fix | +| `42ce5917ce` | Preserve fetch policy for intercepted HTTP responses | Local transport seam | +| `6832eaa774` | Lock the cancellation-token feature dependency | Local dependency fix | +| `25eceaf39a` | Reuse the shared asynchronous network test runtime | Local test fix | +| `38a205376b` | Identify window-backed requests | Local origin-security fix | +| `8ae21c0bbc` | Add embeddable storage-engine factories | Local engine seam | +| `a9204f3535` | Align the external engine allocator graph | Local dependency fix | +| `e2a2d5e575` | Make web-resource responders sendable | Local embedder seam | +| `65d71b0bfe` | Escape keyword-named Promise wrapper methods | Candidate upstream generator fix | +| `4182b51681` through `b70d4e64c0` | Repair and verify the v0.5 carry against current module, media, sandbox, and lifecycle APIs | Rebase adaptation | + +The versioned patch files retain Turvo's seven original engine commits for +digest and reverse-application checks. The branch is authoritative when those +patch artifacts and the exact pin disagree. + +## Rules + +1. Every engine change receives a row here and an executable regression oracle. +2. Grow `theorem/v0.5.0` by commits; do not silently rebase or retarget it. +3. Adopting another upstream release requires updating `upstream-base`, this + ledger, `integration.json`, the Rust channel, and all invalidated receipts in + one reviewed change. +4. Theorem consumes Turvo and does not declare a duplicate Servo pin or fork + ledger. +5. No generated or AI-authored change is submitted upstream; upstream + contribution policy remains binding. diff --git a/patches/servo/README.md b/patches/servo/README.md index 7179838..d3dfc92 100644 --- a/patches/servo/README.md +++ b/patches/servo/README.md @@ -1,9 +1,11 @@ # Public Servo HTTP integration -The exact source and patch digests are in `integration.json`. The patches apply -to published Servo 0.5.0's recorded commit -`77fccacc1f1fdce10498d50173aafaa09d02879e` and are published on the isolated -`Travis-Gilbert/servo:turvo/storage-engines-1.0` branch. Theorem branches are untouched. +The exact source and patch digests are in `integration.json`. The unified fork +descends from the upstream Servo `v0.5.0` tag at +`1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019` and is published as +`Travis-Gilbert/servo:theorem/v0.5.0`. It carries both the prior Theorem +embedder work and Turvo's engine/security patches; Turvo is the sole consumer +repository that declares the product pin. See `FORK.md` and `upstream-base`. The second patch locks the added Tokio cancellation feature's existing `futures-util` dependency. The third patch makes the file-manager test reuse the networking suite's shared runtime instead of initializing and dropping its @@ -20,7 +22,8 @@ The seventh patch makes the public web-resource response handle `Send`, so a bounded Turvo interceptor can finish Servo-owned responses from its worker thread without an unsafe wrapper or a duplicate response path. The current public revision is -`c535d2b639bde66570dbcf0f07c3fce009c01b9a`. +`b70d4e64c0005d5dc2d5257c09f997dba235410a`, 20 commits ahead of and zero +commits behind upstream `v0.5.0`. The request interceptor replaces only HTTP transport, after request policy selection and before normal response processing. CSP, CORS/preflight, redirects, @@ -48,8 +51,9 @@ request-client tests cover caller-kind serialization, fail-closed compatibility, and builder propagation. Run: ```sh -cargo +1.94.0 test -p servo-net --test main --locked -cargo +1.94.0 test -p servo-net-traits --test request_client --locked +cargo +1.95.0 test -p servo-net --test main --locked +cargo +1.95.0 test -p servo-net-traits --test request_client --locked +cargo +1.95.0 test -p servo-storage --lib --locked ``` Hosted test and native application receipts are required before acceptance. diff --git a/patches/servo/integration.json b/patches/servo/integration.json index 862eb1a..cc49d86 100644 --- a/patches/servo/integration.json +++ b/patches/servo/integration.json @@ -1,9 +1,12 @@ { "upstream_repository": "servo/servo", - "base_revision": "77fccacc1f1fdce10498d50173aafaa09d02879e", + "base_revision": "1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019", "repository": "Travis-Gilbert/servo", - "revision": "c535d2b639bde66570dbcf0f07c3fce009c01b9a", - "branch": "turvo/storage-engines-1.0", + "revision": "b70d4e64c0005d5dc2d5257c09f997dba235410a", + "branch": "theorem/v0.5.0", + "ahead_by": 20, + "behind_by": 0, + "rust_channel": "1.95.0", "patches": [ { "path": "0001-policy-preserving-http-interception.patch", diff --git a/patches/servo/upstream-base b/patches/servo/upstream-base new file mode 100644 index 0000000..e633fdb --- /dev/null +++ b/patches/servo/upstream-base @@ -0,0 +1,8 @@ +# Machine-readable lineage for the unified Theorem/Turvo Servo fork. +# +# The former v0.4.0 release line diverges from v0.5.0 at the merge base below. +# ADDENDUM-1 deliberately exercises the FORK.md rebase exception: the surviving +# commits were replayed onto the exact v0.5.0 tag and Turvo became pin owner. +previous_release_merge_base=b5675b1bc38498a26530b27e578122a8068af3b6 +upstream_ref=refs/tags/v0.5.0 +upstream_commit=1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019 diff --git a/plans/TURVO-1.0-COMPLETION/CONTINUITY.md b/plans/TURVO-1.0-COMPLETION/CONTINUITY.md index ecdb983..dfc15bb 100644 --- a/plans/TURVO-1.0-COMPLETION/CONTINUITY.md +++ b/plans/TURVO-1.0-COMPLETION/CONTINUITY.md @@ -1,6 +1,6 @@ # Continuity -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` Generation: `6` diff --git a/plans/TURVO-1.0-COMPLETION/disagreements.md b/plans/TURVO-1.0-COMPLETION/disagreements.md index 6f030ec..c1f1dbb 100644 --- a/plans/TURVO-1.0-COMPLETION/disagreements.md +++ b/plans/TURVO-1.0-COMPLETION/disagreements.md @@ -1,6 +1,6 @@ # Decisions and disagreements -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` These decisions resolve known design forks. A failed oracle reopens the named decision through its retraction path rather than weakening acceptance. diff --git a/plans/TURVO-1.0-COMPLETION/edges.md b/plans/TURVO-1.0-COMPLETION/edges.md index f462022..c74176e 100644 --- a/plans/TURVO-1.0-COMPLETION/edges.md +++ b/plans/TURVO-1.0-COMPLETION/edges.md @@ -1,6 +1,6 @@ # Dependency edges -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` | From | To | Condition | |---|---|---| diff --git a/plans/TURVO-1.0-COMPLETION/lessons.md b/plans/TURVO-1.0-COMPLETION/lessons.md index 8c9d74f..4dd027c 100644 --- a/plans/TURVO-1.0-COMPLETION/lessons.md +++ b/plans/TURVO-1.0-COMPLETION/lessons.md @@ -1,6 +1,6 @@ # Lessons and constraints -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` ## Current facts diff --git a/plans/TURVO-1.0-COMPLETION/manifest.md b/plans/TURVO-1.0-COMPLETION/manifest.md index d5074dc..a425ab5 100644 --- a/plans/TURVO-1.0-COMPLETION/manifest.md +++ b/plans/TURVO-1.0-COMPLETION/manifest.md @@ -1,6 +1,6 @@ # TURVO-1.0-COMPLETION completion board -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` ## Destination diff --git a/plans/TURVO-1.0-COMPLETION/nodes/D00.md b/plans/TURVO-1.0-COMPLETION/nodes/D00.md index 0b9cd39..7d2cd8e 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/D00.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/D00.md @@ -1,6 +1,6 @@ # D00: Seal source precedence and delivery authority -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/D01.md b/plans/TURVO-1.0-COMPLETION/nodes/D01.md index 95618d8..02afa35 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/D01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/D01.md @@ -1,6 +1,6 @@ # D01: Seal runtime repair strategy -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/E01.md b/plans/TURVO-1.0-COMPLETION/nodes/E01.md index c74a5d0..041bf11 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/E01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/E01.md @@ -1,6 +1,6 @@ # E01: Wait for a consumable Tauri opener revision -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `world` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/E02.md b/plans/TURVO-1.0-COMPLETION/nodes/E02.md index 42a4717..d53bbb5 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/E02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/E02.md @@ -1,6 +1,6 @@ # E02: Require a published-engine release graph -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `parked` - Controller: `world` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/P00.md b/plans/TURVO-1.0-COMPLETION/nodes/P00.md index 71dee2f..fb64c37 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/P00.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/P00.md @@ -1,6 +1,6 @@ # P00: Audit bootstrap and publish its initial tip -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/P01.md b/plans/TURVO-1.0-COMPLETION/nodes/P01.md index 8290bc3..4985e83 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/P01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/P01.md @@ -1,6 +1,6 @@ # P01: Resolve protocol, opener, and packaging seams -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V01.md b/plans/TURVO-1.0-COMPLETION/nodes/V01.md index 7703408..807782c 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V01.md @@ -1,6 +1,6 @@ # V01: Verify compile baseline at the published tip -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V02.md b/plans/TURVO-1.0-COMPLETION/nodes/V02.md index 34f2e1c..29aaf06 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V02.md @@ -1,6 +1,6 @@ # V02: Verify protocol routing and origin separation -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V02I.md b/plans/TURVO-1.0-COMPLETION/nodes/V02I.md index 5fb7eaa..7a89854 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V02I.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V02I.md @@ -1,6 +1,6 @@ # V02I: Verify actual IPC sender isolation -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V03.md b/plans/TURVO-1.0-COMPLETION/nodes/V03.md index e567b6c..90dc066 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V03.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V03.md @@ -1,6 +1,6 @@ # V03: Verify the upstream opener seam -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V04.md b/plans/TURVO-1.0-COMPLETION/nodes/V04.md index 072c4a7..dc3bcd2 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V04.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V04.md @@ -1,6 +1,6 @@ # V04: Verify runtime-managed window.open -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V05.md b/plans/TURVO-1.0-COMPLETION/nodes/V05.md index 11c2323..6ab4809 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V05.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V05.md @@ -1,6 +1,6 @@ # V05: Verify complete example behavior -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V06.md b/plans/TURVO-1.0-COMPLETION/nodes/V06.md index c278439..488ed65 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V06.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V06.md @@ -1,6 +1,6 @@ # V06: Verify module and package boundaries -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V07.md b/plans/TURVO-1.0-COMPLETION/nodes/V07.md index 2458a12..a203fdc 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V07.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V07.md @@ -1,6 +1,6 @@ # V07: Verify native behavior on Linux and macOS -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V08.md b/plans/TURVO-1.0-COMPLETION/nodes/V08.md index ec95e95..a6e87db 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V08.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V08.md @@ -1,6 +1,6 @@ # V08: Verify migration isolation and PR behavior -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V09.md b/plans/TURVO-1.0-COMPLETION/nodes/V09.md index 31aa557..b24bbc6 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V09.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V09.md @@ -1,6 +1,6 @@ # V09: Verify the published crate and two-edit consumer -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V10.md b/plans/TURVO-1.0-COMPLETION/nodes/V10.md index 2089539..5884b7d 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V10.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V10.md @@ -1,6 +1,6 @@ # V10: Verify Theorem uses one Servo revision -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V11.md b/plans/TURVO-1.0-COMPLETION/nodes/V11.md index 11326fd..86b8783 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V11.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V11.md @@ -1,6 +1,6 @@ # V11: Verify Turvo reaches fixpoint -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/VX1.md b/plans/TURVO-1.0-COMPLETION/nodes/VX1.md index 37b3bdf..d39e55c 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/VX1.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/VX1.md @@ -1,6 +1,6 @@ # VX1: Verify restored Windows acceptance -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W01.md b/plans/TURVO-1.0-COMPLETION/nodes/W01.md index 516f36b..6682c91 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W01.md @@ -1,6 +1,6 @@ # W01: Stabilize the pushed compile baseline -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W02.md b/plans/TURVO-1.0-COMPLETION/nodes/W02.md index e7e3a55..ff96e07 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W02.md @@ -1,6 +1,6 @@ # W02: Implement Windows app-protocol interception -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W02I.md b/plans/TURVO-1.0-COMPLETION/nodes/W02I.md index dbc8f97..0580eba 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W02I.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W02I.md @@ -1,6 +1,6 @@ # W02I: Complete authenticated IPC and ordinary app-origin policy -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `completed` - Controller: `agent` @@ -44,9 +44,9 @@ Bind each privileged call to its engine-authenticated initiating document, inclu - `cargo test -p turvo --lib --tests --locked` - `native IPC source-authentication suite` -- `cargo +1.94.0 test -p servo-net --test main --locked in the exact public engine checkout` +- `cargo +1.95.0 test -p servo-net --test main --locked in the exact public engine checkout` - `python3 scripts/check_integration.py` -- `cargo +1.94.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact` +- `cargo +1.95.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact` ## Discharge evidence diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W03.md b/plans/TURVO-1.0-COMPLETION/nodes/W03.md index 9c3004a..c17247d 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W03.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W03.md @@ -1,6 +1,6 @@ # W03: Create a runtime-neutral upstream new-window seam -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `frontier` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W04.md b/plans/TURVO-1.0-COMPLETION/nodes/W04.md index 394203f..918129c 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W04.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W04.md @@ -1,6 +1,6 @@ # W04: Integrate window.open through the accepted opener seam -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W05.md b/plans/TURVO-1.0-COMPLETION/nodes/W05.md index cab0bf9..ff06f95 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W05.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W05.md @@ -1,6 +1,6 @@ # W05: Complete API parity and self-reporting smoke probes -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W06.md b/plans/TURVO-1.0-COMPLETION/nodes/W06.md index ded9def..8a04194 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W06.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W06.md @@ -1,6 +1,6 @@ # W06: Extract runtime modules and implement turvo-build -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W07.md b/plans/TURVO-1.0-COMPLETION/nodes/W07.md index 726a6e6..cdfd399 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W07.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W07.md @@ -1,6 +1,6 @@ # W07: Build the Linux/macOS native smoke and DevTools lane -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W08.md b/plans/TURVO-1.0-COMPLETION/nodes/W08.md index 6ba9873..5aad797 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W08.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W08.md @@ -1,6 +1,6 @@ # W08: Demonstrate the monthly Servo migration lane -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W09.md b/plans/TURVO-1.0-COMPLETION/nodes/W09.md index db06b22..dd3440c 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W09.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W09.md @@ -1,6 +1,6 @@ # W09: Publish Turvo 0.1.0 and prove a clean consumer -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W10.md b/plans/TURVO-1.0-COMPLETION/nodes/W10.md index a81d19d..915db33 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W10.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W10.md @@ -1,6 +1,6 @@ # W10: Integrate Turvo into Theorem desktop and browser hosts -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W11.md b/plans/TURVO-1.0-COMPLETION/nodes/W11.md index 85b9a30..29d42fc 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W11.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W11.md @@ -1,6 +1,6 @@ # W11: Close documentation, follow-on plans, and acceptance drift -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/WX1.md b/plans/TURVO-1.0-COMPLETION/nodes/WX1.md index 312847c..655f876 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/WX1.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/WX1.md @@ -1,6 +1,6 @@ # WX1: Restore Windows native and packaging acceptance -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Status: `parked` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/plan-definition.json b/plans/TURVO-1.0-COMPLETION/plan-definition.json index d58f565..bfa9781 100644 --- a/plans/TURVO-1.0-COMPLETION/plan-definition.json +++ b/plans/TURVO-1.0-COMPLETION/plan-definition.json @@ -851,9 +851,9 @@ "proof_commands": [ "cargo test -p turvo --lib --tests --locked", "native IPC source-authentication suite", - "cargo +1.94.0 test -p servo-net --test main --locked in the exact public engine checkout", + "cargo +1.95.0 test -p servo-net --test main --locked in the exact public engine checkout", "python3 scripts/check_integration.py", - "cargo +1.94.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact" + "cargo +1.95.0 test -p servo-net --test main --locked filemanager_thread::test_filemanager -- --exact" ], "discharge_evidence": [ "Turvo be7bb189aaaff9b6e0ac79ae17d948d215d2c9bd pins public Servo 526e95cf47ba81485225660fe1a14dc000ffd4b7, requires Window-backed engine provenance for privileged IPC, and retains the full hostile-frame, navigation, CSP, asset, module, binary, channel, and event suite.", diff --git a/plans/TURVO-1.0-COMPLETION/projection.md b/plans/TURVO-1.0-COMPLETION/projection.md index 62b59c0..1ed4d3b 100644 --- a/plans/TURVO-1.0-COMPLETION/projection.md +++ b/plans/TURVO-1.0-COMPLETION/projection.md @@ -1,6 +1,6 @@ # Dependency projection -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` ```mermaid flowchart TD diff --git a/plans/TURVO-1.0-COMPLETION/replay.md b/plans/TURVO-1.0-COMPLETION/replay.md index e3ed0dc..081dde6 100644 --- a/plans/TURVO-1.0-COMPLETION/replay.md +++ b/plans/TURVO-1.0-COMPLETION/replay.md @@ -1,6 +1,6 @@ # Execution replay -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` This is a generated status replay. Durable proof lives in each node's discharge evidence and the referenced external artifacts. diff --git a/plans/TURVO-1.0-COMPLETION/validation.md b/plans/TURVO-1.0-COMPLETION/validation.md index 010f66a..5091ac6 100644 --- a/plans/TURVO-1.0-COMPLETION/validation.md +++ b/plans/TURVO-1.0-COMPLETION/validation.md @@ -1,6 +1,6 @@ # Board validation -Canonical SHA-256: `e6805a0db8f36fc01c742435d134de3216bccc5ae26e7a53634283614f91c765` +Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` - Canonical JSON parses. - Required fields, palettes, controllers, and statuses validate. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8770b1d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +components = ["clippy", "rustfmt"] From 5a59d0ea9f90bf5eb3312368c735adaf697fe164 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 13 Sep 2026 00:27:02 -0400 Subject: [PATCH 2/4] fix(ci): fetch Servo release tag from upstream --- .github/workflows/servo-integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/servo-integration.yml b/.github/workflows/servo-integration.yml index 8eb186c..1e791bf 100644 --- a/.github/workflows/servo-integration.yml +++ b/.github/workflows/servo-integration.yml @@ -44,7 +44,7 @@ jobs: - name: Read the versioned public engine pin id: engine shell: bash - run: jq -r '"repository=\(.repository)\nrevision=\(.revision)\nbranch=\(.branch)\nbase_revision=\(.base_revision)\nahead_by=\(.ahead_by)\nbehind_by=\(.behind_by)\nrust_channel=\(.rust_channel)"' patches/servo/integration.json >> "$GITHUB_OUTPUT" + run: jq -r '"upstream_repository=\(.upstream_repository)\nrepository=\(.repository)\nrevision=\(.revision)\nbranch=\(.branch)\nbase_revision=\(.base_revision)\nahead_by=\(.ahead_by)\nbehind_by=\(.behind_by)\nrust_channel=\(.rust_channel)"' patches/servo/integration.json >> "$GITHUB_OUTPUT" - name: Check out the exact public engine revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -68,7 +68,7 @@ jobs: run: | test "$(git rev-parse HEAD)" = "${{ steps.engine.outputs.revision }}" test "$(git ls-remote https://github.com/${{ steps.engine.outputs.repository }}.git refs/heads/${{ steps.engine.outputs.branch }} | cut -f1)" = "${{ steps.engine.outputs.revision }}" - git fetch origin refs/tags/v0.5.0:refs/tags/v0.5.0 + git fetch "https://github.com/${{ steps.engine.outputs.upstream_repository }}.git" refs/tags/v0.5.0:refs/tags/v0.5.0 test "$(git rev-parse refs/tags/v0.5.0^{commit})" = "${{ steps.engine.outputs.base_revision }}" git merge-base --is-ancestor "${{ steps.engine.outputs.base_revision }}" HEAD read -r behind_by ahead_by < <(git rev-list --left-right --count "${{ steps.engine.outputs.base_revision }}...HEAD") From c8b393ff9ba39359465dabbbb6021c22418e02d8 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 13 Sep 2026 00:42:36 -0400 Subject: [PATCH 3/4] fix(servo): align migration policy with addendum --- .github/workflows/servo-integration.yml | 9 ++-- AGENTS.md | 31 +++++++------ README.md | 22 ++++++---- .../records/003-theorem-desktop-servo-home.md | 39 +++++++++++++++++ plans/TURVO-1.0-COMPLETION/CONTINUITY.md | 4 +- plans/TURVO-1.0-COMPLETION/disagreements.md | 4 +- plans/TURVO-1.0-COMPLETION/edges.md | 2 +- plans/TURVO-1.0-COMPLETION/lessons.md | 4 +- plans/TURVO-1.0-COMPLETION/manifest.md | 12 +++--- plans/TURVO-1.0-COMPLETION/nodes/D00.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/D01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/E01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/E02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/P00.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/P01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V02I.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V03.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V04.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V05.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V06.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V07.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V08.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V09.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/V10.md | 13 +++--- plans/TURVO-1.0-COMPLETION/nodes/V11.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/VX1.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W01.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W02.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W02I.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W03.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W04.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W05.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W06.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W07.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W08.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W09.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/W10.md | 12 +++--- plans/TURVO-1.0-COMPLETION/nodes/W11.md | 2 +- plans/TURVO-1.0-COMPLETION/nodes/WX1.md | 2 +- .../TURVO-1.0-COMPLETION/plan-definition.json | 43 ++++++++++--------- plans/TURVO-1.0-COMPLETION/projection.md | 6 +-- plans/TURVO-1.0-COMPLETION/replay.md | 2 +- plans/TURVO-1.0-COMPLETION/validation.md | 2 +- 45 files changed, 158 insertions(+), 107 deletions(-) create mode 100644 docs/records/003-theorem-desktop-servo-home.md diff --git a/.github/workflows/servo-integration.yml b/.github/workflows/servo-integration.yml index 1e791bf..e95fa8a 100644 --- a/.github/workflows/servo-integration.yml +++ b/.github/workflows/servo-integration.yml @@ -2,7 +2,7 @@ name: Servo integration policy on: push: - branches: ['integration/**'] + branches: ['integration/**', next] paths: - Cargo.toml - Cargo.lock @@ -83,8 +83,9 @@ jobs: - name: Verify the ordered versioned patch stack shell: bash run: | - patch_check="$RUNNER_TEMP/pinned-servo-patch-check" - git clone pinned-servo "$patch_check" + patch_index="$RUNNER_TEMP/pinned-servo-patch-stack.index" + test ! -e "$patch_index" + GIT_INDEX_FILE="$patch_index" git -C pinned-servo read-tree HEAD for patch in \ 0007-sendable-web-resource-responders.patch \ 0006-align-jemalloc-consumer-graph.patch \ @@ -94,7 +95,7 @@ jobs: 0002-cancellation-feature-lockfile.patch \ 0001-policy-preserving-http-interception.patch do - git -C "$patch_check" apply --reverse "$GITHUB_WORKSPACE/patches/servo/$patch" + GIT_INDEX_FILE="$patch_index" git -C pinned-servo apply --cached --reverse "$GITHUB_WORKSPACE/patches/servo/$patch" done - name: Install Linux networking test prerequisites if: runner.os == 'Linux' diff --git a/AGENTS.md b/AGENTS.md index 5052277..59537a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,10 +74,11 @@ Tech Stack: Rust, Tauri 2, Servo, Tao, GitHub Actions ## Overview -Turvo is a desktop-only Tauri runtime that embeds a pinned Servo engine in -process. It aims to provide an Electron-class application shell without a -bundled Chromium runtime while keeping engine choice deterministic across -Linux, Windows, and macOS. +Turvo is the Servo integration home for the Theorem desktop. It owns the exact +Servo pin, migration lane, hosted engine proof, and desktop bundling path. GPUI +owns Theorem's native windows and chrome. Turvo's existing desktop-only Tauri +runtime is one consumer of the in-process Servo embedding, not the repository's +product boundary. Performance, memory, startup-time, and binary-size claims require benchmark receipts and must not be presented as established project facts. @@ -91,12 +92,12 @@ receipts and must not be presented as established project facts. | API parity probe | Invoke/events/window commands implemented, not locally launched | `examples/api` | | DevTools | Secure configuration implemented, native attachment pending | Record 001 A4 | | Cross-platform CI | Linux/macOS required for current integration; Windows explicitly deferred with failing security receipts retained | Record 002; graph O13/WX1 | -| Completion graph | W02I/V02I complete on `integration/servo-0.5-unix`; W03 and W05 are the next implementation frontier | `plans/TURVO-1.0-COMPLETION/CONTINUITY.md` | -| Public integration | Exact public Servo/Tauri pins adopted; ordinary assets/modules and worker/hostile-frame denials pass natively on Linux/macOS | CI run 33567283891; Record 002; `patches/servo` | +| Completion graph | The prior standalone completion graph retains historical receipts; the Theorem desktop-shell addendum governs the current cross-repository migration | Record 003; `plans/TURVO-1.0-COMPLETION/CONTINUITY.md` | +| Public integration | `next` pins the unified `Travis-Gilbert/servo:theorem/v0.5.0` fork at `b70d4e64`; promotion awaits required Linux/macOS CI | draft PR #3; Record 003; `patches/servo/FORK.md` | | Tauri opener proposal | Public opener seam adopted and compatibility green; actual Servo popup metadata and integration remain open | CI run 33357076684; `patches/tauri` | -| Monthly Servo lane | Defined, not demonstrated | `.github/workflows/servo-next.yml` | +| Monthly Servo lane | Active on `next`; draft migration PR opened | draft PR #3; `.github/workflows/servo-next.yml` | | crates.io release | Pending | Acceptance A7 in Record 001 | -| Theorem integration | Pending and separately owned | Acceptance A8 in Record 001 | +| Theorem integration | Pending after Turvo promotion; Turvo owns Servo integration and Theorem consumes it without a duplicate pin | Record 003 | ## Recent Decisions @@ -108,6 +109,8 @@ receipts and must not be presented as established project facts. | 2026-08-30 | Separate compile CI from native behavior proof | Successful compilation does not demonstrate rendering, IPC, origin security, or window behavior. | | 2026-08-30 | Relay monthly agent changes as a scoped patch through fresh jobs | The migration agent should not receive a GitHub token, and credentialed PR creation must not execute agent-modified code. | | 2026-08-30 | Proceed with public exact-revision Servo/Tauri integration without repeated confirmation; defer Windows | Explicit user correction; preserve Linux/macOS security checks, published-release gates, and Theorem-owned branches. See Record 002. | +| 2026-09-13 | Make Turvo the sole home of Theorem's Servo integration while GPUI retains native window and chrome ownership | Removes duplicate engine-pin authority; the Tauri runtime remains one consumer. See Record 003. | +| 2026-09-13 | Treat arbitrary third-party pages as supported scope | The Theorem desktop compatibility matrix requires remote sites; origin-boundary negative tests are mandatory. See Record 003. | ## Development Commands @@ -127,9 +130,9 @@ build graph. ## Next Step -Continue W03 on `integration/servo-0.5-unix`; read `CONTINUITY.md` and Record 002. -Public pinned Servo/Tauri patches are authorized. Complete the real Servo popup -metadata and runtime integration without fabricating Wry-native state. Linux/macOS -native behavior remains mandatory. Windows is O13/WX1; published-engine release -is E02. Neither deferred obligation is complete, and existing Theorem branches -stay intact. +Finish the `next` migration in draft PR #3 and require the Servo policy plus +ordinary Linux/macOS CI to pass before promotion to `main`. Then let Theorem +consume Turvo and remove its duplicate Servo pin authority. Read Record 003 +before the older standalone completion graph: its W03/W05 backlog remains, but +it does not override the current ownership, branch, or third-party-site scope. +Windows remains deferred under O13/WX1, and publication remains gated by E02. diff --git a/README.md b/README.md index 2ed99fe..cf919ca 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Turvo -Turvo is an experimental Tauri desktop runtime backed by Servo. It embeds one -version of the renderer in the application so Linux, Windows, and macOS do not -silently select different system webviews. +Turvo is the Servo integration home for the Theorem desktop. It owns the exact +engine pin, migration lane, hosted engine proof, and desktop bundling path. +GPUI owns Theorem's native windows and chrome; Turvo supplies the in-process +Servo embedding. The existing Tauri runtime is one consumer of that embedding, +not Turvo's product boundary. The project is aiming for an Electron-class application shell without bundling Chromium. Performance, memory, startup-time, and binary-size claims are @@ -10,10 +12,12 @@ deliberately deferred until Turvo has a reproducible benchmark suite. ## Current status -Active integration work is Linux/macOS-first on `integration/servo-0.5-unix`. -That branch pins public Servo/Tauri patches; Windows is explicitly deferred, -not verified. The published-engine release contract remains gated. See -[Record 002](docs/records/002-unix-public-integration.md) for the current scope. +Active integration work is Linux/macOS-first on `next`, through draft PR #3. +That lane pins `Travis-Gilbert/servo:theorem/v0.5.0` at an exact revision before +promotion to `main`; Windows is explicitly deferred, not verified. The +published-engine release contract remains gated. See +[Record 003](docs/records/003-theorem-desktop-servo-home.md) for the ownership +and scope change. Turvo is pre-release software. The repository currently contains: @@ -35,7 +39,9 @@ cross-origin and CSP checks. The public integration's ordinary `fetch()`/HEAD/static-module/dynamic-module tests pass on Linux/macOS; full native acceptance still awaits the sandbox-probe correction and exact-tip rerun. Source and automated review are not runtime proof. -Do not use this bootstrap in production or load untrusted remote pages/frames. +Do not use this pre-release bootstrap in production until its required +origin-boundary and third-party compatibility gates pass. Arbitrary remote +pages and frames are nevertheless part of the supported product scope. The [protocol audit](https://github.com/Travis-Gilbert/Turvo/blob/main/docs/research/protocol-origin-boundary.md) records the engine API limitations and required negative tests. diff --git a/docs/records/003-theorem-desktop-servo-home.md b/docs/records/003-theorem-desktop-servo-home.md new file mode 100644 index 0000000..c440ee7 --- /dev/null +++ b/docs/records/003-theorem-desktop-servo-home.md @@ -0,0 +1,39 @@ +# Record 003: Theorem desktop Servo ownership + +Date: 2026-09-13 + +Status: Accepted by `SPEC-THEOREM-DESKTOP-SHELL-SERVO-RR-1.0-ADDENDUM-1`. + +## Decision + +Turvo is the sole home of Theorem's Servo integration, exact engine pin, +migration lane, hosted engine validation, and desktop bundling path. Theorem +consumes Turvo. GPUI owns native application windows and chrome; Turvo's Tauri +runtime traits remain a supported consumer of the Servo embedding rather than +the repository's defining product boundary. + +The supported scope includes arbitrary third-party pages and frames. The +origin-boundary audit's remote, nested, opaque, sandboxed, and navigation-race +negative tests are required CI gates rather than advisory future work. + +## Migration + +The migration starts on Turvo's `next` branch. It pins +`Travis-Gilbert/servo:theorem/v0.5.0` at an exact revision descended from +upstream Servo `v0.5.0`, records the deliberate rebase in `patches/servo/FORK.md` +and `patches/servo/upstream-base`, and requires Linux and macOS CI before +promotion to `main`. + +The older `TURVO-1.0-COMPLETION` graph retains useful historical receipts and +the remaining runtime backlog. Any statement there that assigns the Servo pin +to Theorem, excludes third-party compatibility, or directs new work to +`integration/servo-0.5-unix` is superseded by this record. + +## Consequences + +- Exactly one repository declares the product Servo pin: Turvo. +- Theorem must remove its duplicate pin and fork ledger when it consumes Turvo. +- Linux and macOS hosted CI are release evidence; local builds are development + checks only. +- Windows and mobile remain outside this migration milestone; their existing + deferred obligations are not converted into passing receipts. diff --git a/plans/TURVO-1.0-COMPLETION/CONTINUITY.md b/plans/TURVO-1.0-COMPLETION/CONTINUITY.md index dfc15bb..b3a611b 100644 --- a/plans/TURVO-1.0-COMPLETION/CONTINUITY.md +++ b/plans/TURVO-1.0-COMPLETION/CONTINUITY.md @@ -1,8 +1,8 @@ # Continuity -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` -Generation: `6` +Generation: `7` ## Resume here diff --git a/plans/TURVO-1.0-COMPLETION/disagreements.md b/plans/TURVO-1.0-COMPLETION/disagreements.md index c1f1dbb..7c6fda5 100644 --- a/plans/TURVO-1.0-COMPLETION/disagreements.md +++ b/plans/TURVO-1.0-COMPLETION/disagreements.md @@ -1,6 +1,6 @@ # Decisions and disagreements -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` These decisions resolve known design forks. A failed oracle reopens the named decision through its retraction path rather than weakening acceptance. @@ -70,7 +70,7 @@ Retraction: Revise or remove the proposal before adoption; Turvo's released depe ## ADR09: How does the user's new authority change the parked graph? -Choice: Resume on integration/servo-0.5-unix with public exact-revision Servo/Tauri patches and required Linux/macOS native security checks. Carry Windows as O13/WX1/VX1 and keep published-engine release effects behind E02. Make reversible implementation decisions without another confirmation request. +Choice: The historical integration/servo-0.5-unix lane established the public exact-revision Servo/Tauri patches and required Linux/macOS native security checks. Record 003 supersedes that lane for current work: migrate the unified Theorem Servo fork on next, while retaining Windows as O13/WX1/VX1 and published-engine release effects behind E02. Reversibility: `reversible_with_cost` diff --git a/plans/TURVO-1.0-COMPLETION/edges.md b/plans/TURVO-1.0-COMPLETION/edges.md index c74176e..dc10d2a 100644 --- a/plans/TURVO-1.0-COMPLETION/edges.md +++ b/plans/TURVO-1.0-COMPLETION/edges.md @@ -1,6 +1,6 @@ # Dependency edges -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` | From | To | Condition | |---|---|---| diff --git a/plans/TURVO-1.0-COMPLETION/lessons.md b/plans/TURVO-1.0-COMPLETION/lessons.md index 4dd027c..7872efa 100644 --- a/plans/TURVO-1.0-COMPLETION/lessons.md +++ b/plans/TURVO-1.0-COMPLETION/lessons.md @@ -1,6 +1,6 @@ # Lessons and constraints -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` ## Current facts @@ -47,7 +47,7 @@ Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276 ## Explicit exclusions -- No arbitrary third-party web compatibility commitment. +- No system-webview or DOM-owned Theorem chrome; GPUI owns native windows and chrome while Turvo supplies Servo content surfaces. - No offscreen GPUI compositor implementation in the 0.1.0 windowed release; only its follow-on plan is required. - No performance, memory, startup, or binary-size claim before a reproducible benchmark receipt. - No unsafe fabrication of Tauri NewWindowOpener platform objects. diff --git a/plans/TURVO-1.0-COMPLETION/manifest.md b/plans/TURVO-1.0-COMPLETION/manifest.md index a425ab5..68fa245 100644 --- a/plans/TURVO-1.0-COMPLETION/manifest.md +++ b/plans/TURVO-1.0-COMPLETION/manifest.md @@ -1,26 +1,26 @@ # TURVO-1.0-COMPLETION completion board -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` ## Destination -Ship Turvo 0.1.0 as a public, deterministic, desktop-only Tauri runtime backed by an in-process pinned Servo engine, with native Linux, Windows, and macOS proof, secure local-versus-remote capability behavior, Firefox DevTools, runtime-managed window.open, automated Servo migration, a published two-edit consumer path, and an exact-revision Theorem consumer receipt. +Ship Turvo 0.1.0 as the public, deterministic Servo integration home for the Theorem desktop, with one exact engine pin, hosted migration and bundle lanes, native Linux, Windows, and macOS proof, secure local-versus-remote capability behavior, arbitrary third-party-site support, Firefox DevTools, runtime-managed window.open, a published Tauri consumer path, and an exact-revision Theorem consumer receipt. ## Active integration profile -Branch: `integration/servo-0.5-unix` +Branch: `next` Required: Linux, macOS. Deferred: Windows. The user explicitly said to skip Windows for now on 2026-08-30. Its failing native receipts remain evidence of unresolved work under O13/WX1/VX1. -The user authorized version-pinned public Servo/Tauri integration branches. Preserve main/next and Theorem-owned branches; test actual source revisions on Linux/macOS. +Turvo owns the exact product pin to Travis-Gilbert/servo:theorem/v0.5.0. Migrate on next, require Linux/macOS hosted proof, promote to main only when green, and remove Theorem's duplicate pin when it consumes Turvo. Release: E02 retains the published-engine/two-edit consumer requirement; VX1 retains deferred Windows proof. Neither can be discharged by integration-branch CI. ## Fixpoint -O01-O13 each carry their declared evidence. GitHub main and next exist; the exact source tip passes compile, lint, package, native smoke, DevTools, origin, IPC, events, plugin, tray, multi-window, and window.open gates on the required platforms; servo-next has opened a real migration PR; turvo 0.1.0 is published and consumed in a clean app; Theorem boots with its consumer-owned Servo patch; no benchmark claim appears without a benchmark receipt; no rewrite remains applicable. The current integration milestone is Linux/macOS; Windows and the published-engine release gate remain explicitly deferred, not discharged. +O01-O13 each carry their declared evidence. GitHub main and next exist; Turvo alone owns the exact Servo pin; the exact source tip passes compile, lint, package, native smoke, DevTools, origin, IPC, events, plugin, tray, multi-window, window.open, and third-party origin-boundary gates on the required platforms; servo-next has opened a real migration PR; turvo 0.1.0 is published and consumed in a clean app; Theorem consumes Turvo without a duplicate Servo pin; no benchmark claim appears without a benchmark receipt; no rewrite remains applicable. The current integration milestone is Linux/macOS; Windows and the published-engine release gate remain explicitly deferred, not discharged. ## Hard prerequisite @@ -34,7 +34,7 @@ The user requested bootstrap, graph computation, and execution, then explicitly ## Opening move -- 1. `W03`: V02I now proves the IPC boundary on Linux/macOS. Complete the already versioned runtime-neutral opener seam before examples depend on real popup behavior. +- 1. `W03`: Record 003's next-branch unified-fork migration and hosted Linux/macOS proof take precedence. Once that cross-repository gate is green, V02I permits this retained popup backlog to resume. ## Task board diff --git a/plans/TURVO-1.0-COMPLETION/nodes/D00.md b/plans/TURVO-1.0-COMPLETION/nodes/D00.md index 7d2cd8e..206b14b 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/D00.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/D00.md @@ -1,6 +1,6 @@ # D00: Seal source precedence and delivery authority -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/D01.md b/plans/TURVO-1.0-COMPLETION/nodes/D01.md index 02afa35..8564f35 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/D01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/D01.md @@ -1,6 +1,6 @@ # D01: Seal runtime repair strategy -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/E01.md b/plans/TURVO-1.0-COMPLETION/nodes/E01.md index 041bf11..ad5ae41 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/E01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/E01.md @@ -1,6 +1,6 @@ # E01: Wait for a consumable Tauri opener revision -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `world` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/E02.md b/plans/TURVO-1.0-COMPLETION/nodes/E02.md index d53bbb5..1790aaf 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/E02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/E02.md @@ -1,6 +1,6 @@ # E02: Require a published-engine release graph -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `parked` - Controller: `world` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/P00.md b/plans/TURVO-1.0-COMPLETION/nodes/P00.md index fb64c37..9c85502 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/P00.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/P00.md @@ -1,6 +1,6 @@ # P00: Audit bootstrap and publish its initial tip -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/P01.md b/plans/TURVO-1.0-COMPLETION/nodes/P01.md index 4985e83..29d1fb2 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/P01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/P01.md @@ -1,6 +1,6 @@ # P01: Resolve protocol, opener, and packaging seams -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V01.md b/plans/TURVO-1.0-COMPLETION/nodes/V01.md index 807782c..c117e5b 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V01.md @@ -1,6 +1,6 @@ # V01: Verify compile baseline at the published tip -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V02.md b/plans/TURVO-1.0-COMPLETION/nodes/V02.md index 29aaf06..6532fb3 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V02.md @@ -1,6 +1,6 @@ # V02: Verify protocol routing and origin separation -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V02I.md b/plans/TURVO-1.0-COMPLETION/nodes/V02I.md index 7a89854..090b1f5 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V02I.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V02I.md @@ -1,6 +1,6 @@ # V02I: Verify actual IPC sender isolation -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V03.md b/plans/TURVO-1.0-COMPLETION/nodes/V03.md index 90dc066..39f1e5e 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V03.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V03.md @@ -1,6 +1,6 @@ # V03: Verify the upstream opener seam -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V04.md b/plans/TURVO-1.0-COMPLETION/nodes/V04.md index dc3bcd2..65ae336 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V04.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V04.md @@ -1,6 +1,6 @@ # V04: Verify runtime-managed window.open -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V05.md b/plans/TURVO-1.0-COMPLETION/nodes/V05.md index 6ab4809..081b9f0 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V05.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V05.md @@ -1,6 +1,6 @@ # V05: Verify complete example behavior -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V06.md b/plans/TURVO-1.0-COMPLETION/nodes/V06.md index 488ed65..ed082fe 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V06.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V06.md @@ -1,6 +1,6 @@ # V06: Verify module and package boundaries -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V07.md b/plans/TURVO-1.0-COMPLETION/nodes/V07.md index a203fdc..f48bdac 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V07.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V07.md @@ -1,6 +1,6 @@ # V07: Verify native behavior on Linux and macOS -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V08.md b/plans/TURVO-1.0-COMPLETION/nodes/V08.md index a6e87db..fba072c 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V08.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V08.md @@ -1,6 +1,6 @@ # V08: Verify migration isolation and PR behavior -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V09.md b/plans/TURVO-1.0-COMPLETION/nodes/V09.md index b24bbc6..63c3bfa 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V09.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V09.md @@ -1,6 +1,6 @@ # V09: Verify the published crate and two-edit consumer -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V10.md b/plans/TURVO-1.0-COMPLETION/nodes/V10.md index 5884b7d..391a164 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V10.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V10.md @@ -1,6 +1,6 @@ -# V10: Verify Theorem uses one Servo revision +# V10: Verify Theorem uses Turvo's sole Servo pin -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` @@ -15,11 +15,11 @@ Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276 ## Gist -Prove desktop and browser hosts share the exact fork revision and Turvo has no private-fork dependency. +Prove Turvo is the only exact-pin authority, Theorem consumes its embedding, and GPUI still owns native windows and chrome. ## Scope -- read-only Theorem integration diff, dependency graphs, and native boot receipts +- read-only Turvo and Theorem integration diffs, cross-repository pin scan, dependency graph, and native boot receipt ## Consumes @@ -31,12 +31,13 @@ Prove desktop and browser hosts share the exact fork revision and Turvo has no p ## Blueprint -- Audit both dependency graphs and independently reproduce the native boots at the exact commit. +- Scan both repositories for pin declarations, audit the resolved dependency graph, and independently reproduce the native desktop boot at the exact commits. ## Proof commands +- `python3 scripts/check-servo-pin-unity.py` - `cargo tree -i servo` -- `native TheoremWeb and browser-host boot oracles` +- `native TheoremWeb desktop boot oracle` ## Discharge evidence diff --git a/plans/TURVO-1.0-COMPLETION/nodes/V11.md b/plans/TURVO-1.0-COMPLETION/nodes/V11.md index 86b8783..b19979b 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/V11.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/V11.md @@ -1,6 +1,6 @@ # V11: Verify Turvo reaches fixpoint -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/VX1.md b/plans/TURVO-1.0-COMPLETION/nodes/VX1.md index d39e55c..d9ecf15 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/VX1.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/VX1.md @@ -1,6 +1,6 @@ # VX1: Verify restored Windows acceptance -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `verifier` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W01.md b/plans/TURVO-1.0-COMPLETION/nodes/W01.md index 6682c91..04ed9d1 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W01.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W01.md @@ -1,6 +1,6 @@ # W01: Stabilize the pushed compile baseline -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W02.md b/plans/TURVO-1.0-COMPLETION/nodes/W02.md index ff96e07..a965bf7 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W02.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W02.md @@ -1,6 +1,6 @@ # W02: Implement Windows app-protocol interception -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W02I.md b/plans/TURVO-1.0-COMPLETION/nodes/W02I.md index 0580eba..a20a346 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W02I.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W02I.md @@ -1,6 +1,6 @@ # W02I: Complete authenticated IPC and ordinary app-origin policy -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `completed` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W03.md b/plans/TURVO-1.0-COMPLETION/nodes/W03.md index c17247d..7798be4 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W03.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W03.md @@ -1,6 +1,6 @@ # W03: Create a runtime-neutral upstream new-window seam -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `frontier` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W04.md b/plans/TURVO-1.0-COMPLETION/nodes/W04.md index 918129c..4f3c728 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W04.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W04.md @@ -1,6 +1,6 @@ # W04: Integrate window.open through the accepted opener seam -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W05.md b/plans/TURVO-1.0-COMPLETION/nodes/W05.md index ff06f95..b2cbcc0 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W05.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W05.md @@ -1,6 +1,6 @@ # W05: Complete API parity and self-reporting smoke probes -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W06.md b/plans/TURVO-1.0-COMPLETION/nodes/W06.md index 8a04194..9f0ecbf 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W06.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W06.md @@ -1,6 +1,6 @@ # W06: Extract runtime modules and implement turvo-build -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W07.md b/plans/TURVO-1.0-COMPLETION/nodes/W07.md index cdfd399..5784458 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W07.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W07.md @@ -1,6 +1,6 @@ # W07: Build the Linux/macOS native smoke and DevTools lane -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W08.md b/plans/TURVO-1.0-COMPLETION/nodes/W08.md index 5aad797..7d0965d 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W08.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W08.md @@ -1,6 +1,6 @@ # W08: Demonstrate the monthly Servo migration lane -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W09.md b/plans/TURVO-1.0-COMPLETION/nodes/W09.md index dd3440c..e5e9a88 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W09.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W09.md @@ -1,6 +1,6 @@ # W09: Publish Turvo 0.1.0 and prove a clean consumer -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W10.md b/plans/TURVO-1.0-COMPLETION/nodes/W10.md index 915db33..ea3855a 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W10.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W10.md @@ -1,6 +1,6 @@ -# W10: Integrate Turvo into Theorem desktop and browser hosts +# W10: Integrate Turvo into the Theorem desktop host -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` @@ -15,11 +15,11 @@ Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276 ## Gist -Make TheoremWeb desktop and apps/browser resolve one theorem Servo revision while Turvo remains public-fork independent. +Make TheoremWeb consume Turvo's Servo embedding while GPUI retains windows and chrome and Turvo remains the only exact-pin authority. ## Scope -- isolated Theorem worktree Turvo dependency, consumer Servo patch, apps/browser pin, and integration records +- isolated Theorem worktree Turvo dependency, duplicate Servo-pin removal, and integration records ## Consumes @@ -34,8 +34,8 @@ Make TheoremWeb desktop and apps/browser resolve one theorem Servo revision whil ## Blueprint -- Use a clean Theorem worktree and consumer-owned patch entries. -- Do not add the private Servo fork to Turvo or disturb unrelated Theorem work. +- Use a clean Theorem worktree and consume Turvo's public integration surface. +- Remove Theorem's duplicate Servo pin and fork ledger without disturbing unrelated Theorem work. ## Proof commands diff --git a/plans/TURVO-1.0-COMPLETION/nodes/W11.md b/plans/TURVO-1.0-COMPLETION/nodes/W11.md index 29d42fc..9d76f12 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/W11.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/W11.md @@ -1,6 +1,6 @@ # W11: Close documentation, follow-on plans, and acceptance drift -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `pending` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/nodes/WX1.md b/plans/TURVO-1.0-COMPLETION/nodes/WX1.md index 655f876..9f4f6ee 100644 --- a/plans/TURVO-1.0-COMPLETION/nodes/WX1.md +++ b/plans/TURVO-1.0-COMPLETION/nodes/WX1.md @@ -1,6 +1,6 @@ # WX1: Restore Windows native and packaging acceptance -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Status: `parked` - Controller: `agent` diff --git a/plans/TURVO-1.0-COMPLETION/plan-definition.json b/plans/TURVO-1.0-COMPLETION/plan-definition.json index bfa9781..10c9f58 100644 --- a/plans/TURVO-1.0-COMPLETION/plan-definition.json +++ b/plans/TURVO-1.0-COMPLETION/plan-definition.json @@ -1,15 +1,15 @@ { "schema": "theorem.portable-plan.v1", "plan_id": "TURVO-1.0-COMPLETION", - "generation": 6, + "generation": 7, "charted_from": { "binding": "portable", "reason": "The user requested compute-graph-plans and execute-graph-plans, but no callable Theorem plan, multihead, coordination, encode, or continuity surface is available." }, - "destination": "Ship Turvo 0.1.0 as a public, deterministic, desktop-only Tauri runtime backed by an in-process pinned Servo engine, with native Linux, Windows, and macOS proof, secure local-versus-remote capability behavior, Firefox DevTools, runtime-managed window.open, automated Servo migration, a published two-edit consumer path, and an exact-revision Theorem consumer receipt.", - "fixpoint": "O01-O13 each carry their declared evidence. GitHub main and next exist; the exact source tip passes compile, lint, package, native smoke, DevTools, origin, IPC, events, plugin, tray, multi-window, and window.open gates on the required platforms; servo-next has opened a real migration PR; turvo 0.1.0 is published and consumed in a clean app; Theorem boots with its consumer-owned Servo patch; no benchmark claim appears without a benchmark receipt; no rewrite remains applicable. The current integration milestone is Linux/macOS; Windows and the published-engine release gate remain explicitly deferred, not discharged.", + "destination": "Ship Turvo 0.1.0 as the public, deterministic Servo integration home for the Theorem desktop, with one exact engine pin, hosted migration and bundle lanes, native Linux, Windows, and macOS proof, secure local-versus-remote capability behavior, arbitrary third-party-site support, Firefox DevTools, runtime-managed window.open, a published Tauri consumer path, and an exact-revision Theorem consumer receipt.", + "fixpoint": "O01-O13 each carry their declared evidence. GitHub main and next exist; Turvo alone owns the exact Servo pin; the exact source tip passes compile, lint, package, native smoke, DevTools, origin, IPC, events, plugin, tray, multi-window, window.open, and third-party origin-boundary gates on the required platforms; servo-next has opened a real migration PR; turvo 0.1.0 is published and consumed in a clean app; Theorem consumes Turvo without a duplicate Servo pin; no benchmark claim appears without a benchmark receipt; no rewrite remains applicable. The current integration milestone is Linux/macOS; Windows and the published-engine release gate remain explicitly deferred, not discharged.", "execution_profile": { - "branch": "integration/servo-0.5-unix", + "branch": "next", "required_platforms": [ "Linux", "macOS" @@ -18,12 +18,12 @@ "Windows" ], "deferral_reason": "The user explicitly said to skip Windows for now on 2026-08-30. Its failing native receipts remain evidence of unresolved work under O13/WX1/VX1.", - "dependency_policy": "The user authorized version-pinned public Servo/Tauri integration branches. Preserve main/next and Theorem-owned branches; test actual source revisions on Linux/macOS.", + "dependency_policy": "Turvo owns the exact product pin to Travis-Gilbert/servo:theorem/v0.5.0. Migrate on next, require Linux/macOS hosted proof, promote to main only when green, and remove Theorem's duplicate pin when it consumes Turvo.", "release_gate": "E02 retains the published-engine/two-edit consumer requirement; VX1 retains deferred Windows proof. Neither can be discharged by integration-branch CI." }, "hard_prerequisite": "Never substitute source inspection, inherited upstream CI, metadata, or package listing for current-tip compilation or native behavior. Preserve unrelated repositories and credentials. The crates.io publish node is irreversible and remains held until every prerequisite obligation has a replayable receipt.", "scope_exclusions": [ - "No arbitrary third-party web compatibility commitment.", + "No system-webview or DOM-owned Theorem chrome; GPUI owns native windows and chrome while Turvo supplies Servo content surfaces.", "No offscreen GPUI compositor implementation in the 0.1.0 windowed release; only its follow-on plan is required.", "No performance, memory, startup, or binary-size claim before a reproducible benchmark receipt.", "No unsafe fabrication of Tauri NewWindowOpener platform objects.", @@ -93,8 +93,8 @@ { "id": "O11", "source": "Desired end state; acceptance 8", - "text": "TheoremWeb desktop boots on Turvo using a consumer-owned Travis-Gilbert/servo theorem branch via patch, and apps/browser pins the identical Servo revision without a private requirement in Turvo.", - "oracle": "Exact Theorem commit, resolved dependency graph, and native boot receipts for desktop and browser host." + "text": "TheoremWeb desktop consumes Turvo for the in-process Servo embedding, Turvo is the only repository that declares the exact Travis-Gilbert/servo theorem pin, and GPUI remains the native window and chrome owner.", + "oracle": "Exact Turvo and Theorem commits, a cross-repository pin-unity scan, resolved dependency graph, and native desktop boot receipt." }, { "id": "O12", @@ -335,7 +335,7 @@ { "id": "ADR09", "question": "How does the user's new authority change the parked graph?", - "choice": "Resume on integration/servo-0.5-unix with public exact-revision Servo/Tauri patches and required Linux/macOS native security checks. Carry Windows as O13/WX1/VX1 and keep published-engine release effects behind E02. Make reversible implementation decisions without another confirmation request.", + "choice": "The historical integration/servo-0.5-unix lane established the public exact-revision Servo/Tauri patches and required Linux/macOS native security checks. Record 003 supersedes that lane for current work: migrate the unified Theorem Servo fork on next, while retaining Windows as O13/WX1/VX1 and published-engine release effects behind E02.", "options_considered": [ "Continue waiting for upstream releases: no longer needed for the authorized development branch.", "Silently delete Windows/release obligations: would confuse deferred work with proof.", @@ -358,7 +358,7 @@ ], "scope_law": "Only one mutating actor owns an exact declared scope at a time. Verification scopes are read-only. Parallel work nodes have disjoint scopes; any newly discovered overlap serializes through a plan rewrite before editing.", "enforce_scope_overlap": true, - "scope_exclusions_note": "External Tauri and Theorem work uses separate clean worktrees and never mutates unrelated shared dirty checkouts.", + "scope_exclusions_note": "Record 003 governs current ownership and scope: Turvo is the Servo home, Theorem consumes it, GPUI owns windows and chrome, and arbitrary third-party sites are in scope. External work uses separate clean worktrees and never mutates unrelated shared dirty checkouts.", "palette": { "probe.research": { "controller": "agent", @@ -406,7 +406,7 @@ { "rank": 1, "node": "W03", - "why": "V02I now proves the IPC boundary on Linux/macOS. Complete the already versioned runtime-neutral opener seam before examples depend on real popup behavior.", + "why": "Record 003's next-branch unified-fork migration and hosted Linux/macOS proof take precedence. Once that cross-repository gate is green, V02I permits this retained popup backlog to resume.", "value_vector": { "success": { "value": "uncertain", @@ -1656,7 +1656,7 @@ }, { "id": "W10", - "label": "Integrate Turvo into Theorem desktop and browser hosts", + "label": "Integrate Turvo into the Theorem desktop host", "type": "work.external", "status": "pending", "controller": "agent", @@ -1664,12 +1664,12 @@ "V09" ], "scope": [ - "isolated Theorem worktree Turvo dependency, consumer Servo patch, apps/browser pin, and integration records" + "isolated Theorem worktree Turvo dependency, duplicate Servo-pin removal, and integration records" ], "obligations": [ "O11" ], - "gist": "Make TheoremWeb desktop and apps/browser resolve one theorem Servo revision while Turvo remains public-fork independent.", + "gist": "Make TheoremWeb consume Turvo's Servo embedding while GPUI retains windows and chrome and Turvo remains the only exact-pin authority.", "consumes": [ "V09 published Turvo", "Theorem current ownership and build instructions" @@ -1680,8 +1680,8 @@ "native boot receipts" ], "blueprint": [ - "Use a clean Theorem worktree and consumer-owned patch entries.", - "Do not add the private Servo fork to Turvo or disturb unrelated Theorem work." + "Use a clean Theorem worktree and consume Turvo's public integration surface.", + "Remove Theorem's duplicate Servo pin and fork ledger without disturbing unrelated Theorem work." ], "oracle_class": "exact-revision cross-repository native integration", "implementation_mode": "real_external_integration", @@ -1699,7 +1699,7 @@ }, { "id": "V10", - "label": "Verify Theorem uses one Servo revision", + "label": "Verify Theorem uses Turvo's sole Servo pin", "type": "verify.live", "status": "pending", "controller": "verifier", @@ -1707,12 +1707,12 @@ "W10" ], "scope": [ - "read-only Theorem integration diff, dependency graphs, and native boot receipts" + "read-only Turvo and Theorem integration diffs, cross-repository pin scan, dependency graph, and native boot receipt" ], "obligations": [ "O11" ], - "gist": "Prove desktop and browser hosts share the exact fork revision and Turvo has no private-fork dependency.", + "gist": "Prove Turvo is the only exact-pin authority, Theorem consumes its embedding, and GPUI still owns native windows and chrome.", "consumes": [ "W10 integration" ], @@ -1720,7 +1720,7 @@ "Theorem integration verification receipt" ], "blueprint": [ - "Audit both dependency graphs and independently reproduce the native boots at the exact commit." + "Scan both repositories for pin declarations, audit the resolved dependency graph, and independently reproduce the native desktop boot at the exact commits." ], "oracle_class": "independent cross-repository native verification", "implementation_mode": "independent_verification", @@ -1728,8 +1728,9 @@ "substitution_allowed": false, "live_oracle_required": true, "proof_commands": [ + "python3 scripts/check-servo-pin-unity.py", "cargo tree -i servo", - "native TheoremWeb and browser-host boot oracles" + "native TheoremWeb desktop boot oracle" ], "discharge_evidence": [], "retraction_path": "Return W10 to frontier with the mismatched revision or failed host." diff --git a/plans/TURVO-1.0-COMPLETION/projection.md b/plans/TURVO-1.0-COMPLETION/projection.md index 1ed4d3b..f9c2d83 100644 --- a/plans/TURVO-1.0-COMPLETION/projection.md +++ b/plans/TURVO-1.0-COMPLETION/projection.md @@ -1,6 +1,6 @@ # Dependency projection -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` ```mermaid flowchart TD @@ -32,8 +32,8 @@ flowchart TD E02["E02 Require a published-engine release graph"] W09["W09 Publish Turvo 0.1.0 and prove a clean consumer"] V09["V09 Verify the published crate and two-edit consumer"] - W10["W10 Integrate Turvo into Theorem desktop and browser hosts"] - V10["V10 Verify Theorem uses one Servo revision"] + W10["W10 Integrate Turvo into the Theorem desktop host"] + V10["V10 Verify Theorem uses Turvo's sole Servo pin"] W11["W11 Close documentation, follow-on plans, and acceptance drift"] V11["V11 Verify Turvo reaches fixpoint"] P00 --> D00 diff --git a/plans/TURVO-1.0-COMPLETION/replay.md b/plans/TURVO-1.0-COMPLETION/replay.md index 081dde6..89b6fe3 100644 --- a/plans/TURVO-1.0-COMPLETION/replay.md +++ b/plans/TURVO-1.0-COMPLETION/replay.md @@ -1,6 +1,6 @@ # Execution replay -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` This is a generated status replay. Durable proof lives in each node's discharge evidence and the referenced external artifacts. diff --git a/plans/TURVO-1.0-COMPLETION/validation.md b/plans/TURVO-1.0-COMPLETION/validation.md index 5091ac6..4a998c5 100644 --- a/plans/TURVO-1.0-COMPLETION/validation.md +++ b/plans/TURVO-1.0-COMPLETION/validation.md @@ -1,6 +1,6 @@ # Board validation -Canonical SHA-256: `92ef5216b6d77400fff93d0497b6dfb3def0f3b0949b148276e4990e1276c563` +Canonical SHA-256: `e80beb1aa16e530ef2571a9a7f011f4363ceedeb98fbf15e0fe9d11e89fba7f2` - Canonical JSON parses. - Required fields, palettes, controllers, and statuses validate. From 90cc0003dff3800ad01fabe571a75ad80491cd5b Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Mon, 14 Sep 2026 02:15:06 -0400 Subject: [PATCH 4/4] chore(servo): move the pin to the combined verified tip e92cdaa790 Turvo pinned b70d4e64c0, which is 36 commits behind the revision two independent verifications accepted. The last green run pair, 34738526924 and 34738526952, is therefore green at the wrong revision. The pin lives in seven places, not one, and all seven move together: the five [patch.crates-io] revisions, Cargo.lock, patches/servo/integration.json, patches/servo/README.md, patches/servo/FORK.md, AGENTS.md, and the patch stack the Servo integration policy job reverse-applies. Two new patch files, one per independently verified slice: 0008 b70d4e64c0..6ed6091e4e, 33 commits, the IndexedDB conformance slice accepted at 6ed6091e4e against the web-platform suite. 0009 6ed6091e4e..e92cdaa790, 3 commits, Web Locks, shared-worker teardown and named window proxies, accepted at e92cdaa790. 0005-storage-engine-factories.patch is deliberately NOT rewritten. The 36 commits rewrite components/shared/storage/indexeddb.rs by +232/-31, so reverse-applying the old seven-patch stack at the new tip fails on 0005 across five files. Folding those commits into 0005 would have made a patch whose subject says "storage engine factories" carry the whole IndexedDB series, and integration.json pins that file by digest while README.md describes it in prose. Adding one file per verified slice keeps the stack a decomposition. Verified locally before any push, all read-only against a clean clone of the fork at /Volumes/servo-build/servo-fork: * The nine patches, in the order read out of servo-integration.yml, reverse-apply at the revision read out of integration.json and produce tree aa41ca6aa70d446a791453c8ac3ce741d7acdcc8. Reversing the existing seven at the old pin produces the same tree, so the two new files add exactly the 36-commit delta and nothing else. * python3 scripts/check_integration.py passes in both modes, and cargo metadata --locked --no-deps returns 0. * All 112 changed Cargo.lock lines name the servo fork. No unrelated version moved. * Lineage against upstream v0.5.0 (1d44e5dd6a): ahead_by=56, behind_by=0, base is an ancestor, matching the new integration.json. * rust-toolchain.toml is channel 1.95.0 at both the old and the new Servo revision and in this repository, so rust_channel is unchanged. NOT verified here: cargo test -p turvo --lib --locked needs a full Servo link, and this machine has 12 GiB free against a build that takes 13. That check belongs to CI. BLOCKED ON ONE PUSH THAT IS NOT MINE TO MAKE. The policy job asserts that integration.json's revision is the tip of integration.json's branch. theorem/v0.5.0 is still at b5fead2675, which is a strict ancestor of e92cdaa790, 35 behind and 0 ahead. FORK.md rule 2 says to grow theorem/v0.5.0 by commits and not to retarget it silently, so this change keeps the branch name and needs that branch fast-forwarded on Travis-Gilbert/servo. The alternative, retargeting the pin to theorem/v0.5.0-indexeddb, needs no servo push but does need rule 2 amended, and it is two string fields away in integration.json. Co-Authored-By: Claude Opus 5 --- .github/workflows/servo-integration.yml | 2 + AGENTS.md | 2 +- Cargo.lock | 112 +- Cargo.toml | 10 +- .../0008-indexeddb-conformance-slice.patch | 10077 ++++++++++++++++ .../0009-web-locks-and-window-proxies.patch | 2839 +++++ patches/servo/FORK.md | 13 +- patches/servo/README.md | 15 +- patches/servo/integration.json | 12 +- 9 files changed, 13012 insertions(+), 70 deletions(-) create mode 100644 patches/servo/0008-indexeddb-conformance-slice.patch create mode 100644 patches/servo/0009-web-locks-and-window-proxies.patch diff --git a/.github/workflows/servo-integration.yml b/.github/workflows/servo-integration.yml index e95fa8a..6974076 100644 --- a/.github/workflows/servo-integration.yml +++ b/.github/workflows/servo-integration.yml @@ -87,6 +87,8 @@ jobs: test ! -e "$patch_index" GIT_INDEX_FILE="$patch_index" git -C pinned-servo read-tree HEAD for patch in \ + 0009-web-locks-and-window-proxies.patch \ + 0008-indexeddb-conformance-slice.patch \ 0007-sendable-web-resource-responders.patch \ 0006-align-jemalloc-consumer-graph.patch \ 0005-storage-engine-factories.patch \ diff --git a/AGENTS.md b/AGENTS.md index 59537a9..7eea80d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ receipts and must not be presented as established project facts. | DevTools | Secure configuration implemented, native attachment pending | Record 001 A4 | | Cross-platform CI | Linux/macOS required for current integration; Windows explicitly deferred with failing security receipts retained | Record 002; graph O13/WX1 | | Completion graph | The prior standalone completion graph retains historical receipts; the Theorem desktop-shell addendum governs the current cross-repository migration | Record 003; `plans/TURVO-1.0-COMPLETION/CONTINUITY.md` | -| Public integration | `next` pins the unified `Travis-Gilbert/servo:theorem/v0.5.0` fork at `b70d4e64`; promotion awaits required Linux/macOS CI | draft PR #3; Record 003; `patches/servo/FORK.md` | +| Public integration | `next` pins the unified `Travis-Gilbert/servo:theorem/v0.5.0` fork at `e92cdaa7`; promotion awaits required Linux/macOS CI | draft PR #3; Record 003; `patches/servo/FORK.md` | | Tauri opener proposal | Public opener seam adopted and compatibility green; actual Servo popup metadata and integration remain open | CI run 33357076684; `patches/tauri` | | Monthly Servo lane | Active on `next`; draft migration PR opened | draft PR #3; `.github/workflows/servo-next.yml` | | crates.io release | Pending | Acceptance A7 in Record 001 | diff --git a/Cargo.lock b/Cargo.lock index 4ddbe23..fcba9bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6676,7 +6676,7 @@ dependencies = [ [[package]] name = "servo" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "arboard", @@ -6740,7 +6740,7 @@ dependencies = [ [[package]] name = "servo-allocator" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "libc", "tikv-jemalloc-sys", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "servo-background-hang-monitor" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "backtrace", "crossbeam-channel", @@ -6766,7 +6766,7 @@ dependencies = [ [[package]] name = "servo-background-hang-monitor-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "serde", "servo-base", @@ -6775,7 +6775,7 @@ dependencies = [ [[package]] name = "servo-base" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "crossbeam-channel", @@ -6800,7 +6800,7 @@ dependencies = [ [[package]] name = "servo-canvas" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "bytemuck", "crossbeam-channel", @@ -6825,7 +6825,7 @@ dependencies = [ [[package]] name = "servo-canvas-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "crossbeam-channel", "euclid", @@ -6847,7 +6847,7 @@ dependencies = [ [[package]] name = "servo-config" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "num_enum", "serde", @@ -6860,7 +6860,7 @@ dependencies = [ [[package]] name = "servo-config-macro" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "proc-macro2", "quote", @@ -6871,7 +6871,7 @@ dependencies = [ [[package]] name = "servo-constellation" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "backtrace", @@ -6915,7 +6915,7 @@ dependencies = [ [[package]] name = "servo-constellation-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "base64 0.23.1", "content-security-policy", @@ -6950,7 +6950,7 @@ dependencies = [ [[package]] name = "servo-default-resources" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "servo-embedder-traits", ] @@ -6958,7 +6958,7 @@ dependencies = [ [[package]] name = "servo-deny-public-fields" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -6968,7 +6968,7 @@ dependencies = [ [[package]] name = "servo-devtools" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "atomic_refcell", "base64 0.23.1", @@ -6998,7 +6998,7 @@ dependencies = [ [[package]] name = "servo-devtools-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "http 1.5.0", "malloc_size_of_derive", @@ -7015,7 +7015,7 @@ dependencies = [ [[package]] name = "servo-dom-struct" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "prettyplease", "proc-macro2", @@ -7026,7 +7026,7 @@ dependencies = [ [[package]] name = "servo-embedder-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "bitflags 2.13.1", @@ -7060,7 +7060,7 @@ dependencies = [ [[package]] name = "servo-fonts" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "app_units", "atomic_refcell", @@ -7119,7 +7119,7 @@ dependencies = [ [[package]] name = "servo-fonts-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "atomic_refcell", "dwrote", @@ -7144,7 +7144,7 @@ dependencies = [ [[package]] name = "servo-geometry" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "app_units", "euclid", @@ -7157,7 +7157,7 @@ dependencies = [ [[package]] name = "servo-hyper-serde" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "cookie 0.18.2", "headers", @@ -7171,7 +7171,7 @@ dependencies = [ [[package]] name = "servo-jstraceable-derive" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -7181,7 +7181,7 @@ dependencies = [ [[package]] name = "servo-layout" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "app_units", @@ -7240,7 +7240,7 @@ dependencies = [ [[package]] name = "servo-layout-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "app_units", "atomic_refcell", @@ -7274,7 +7274,7 @@ dependencies = [ [[package]] name = "servo-malloc-size-of" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "app_units", "atomic_refcell", @@ -7317,7 +7317,7 @@ dependencies = [ [[package]] name = "servo-media" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "servo-base", "servo-media-audio", @@ -7330,7 +7330,7 @@ dependencies = [ [[package]] name = "servo-media-audio" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "byte-slice-cast", "euclid", @@ -7354,7 +7354,7 @@ dependencies = [ [[package]] name = "servo-media-derive" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "proc-macro2", "quote", @@ -7364,7 +7364,7 @@ dependencies = [ [[package]] name = "servo-media-dummy" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "servo-base", "servo-media", @@ -7378,7 +7378,7 @@ dependencies = [ [[package]] name = "servo-media-ohos" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "crossbeam-channel", "libc", @@ -7401,7 +7401,7 @@ dependencies = [ [[package]] name = "servo-media-player" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "malloc_size_of_derive", "serde", @@ -7414,7 +7414,7 @@ dependencies = [ [[package]] name = "servo-media-streams" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "malloc_size_of_derive", "servo-malloc-size-of", @@ -7424,7 +7424,7 @@ dependencies = [ [[package]] name = "servo-media-thread" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "euclid", "ipc-channel", @@ -7443,7 +7443,7 @@ dependencies = [ [[package]] name = "servo-media-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "malloc_size_of_derive", "servo-malloc-size-of", @@ -7452,7 +7452,7 @@ dependencies = [ [[package]] name = "servo-media-webrtc" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "log", "servo-media-streams", @@ -7462,7 +7462,7 @@ dependencies = [ [[package]] name = "servo-metrics" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "malloc_size_of_derive", "servo-base", @@ -7477,7 +7477,7 @@ dependencies = [ [[package]] name = "servo-net" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "async-compression", "async-recursion", @@ -7549,7 +7549,7 @@ dependencies = [ [[package]] name = "servo-net-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "content-security-policy", "cookie 0.18.2", @@ -7590,7 +7590,7 @@ dependencies = [ [[package]] name = "servo-paint" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "bitflags 2.13.1", "crossbeam-channel", @@ -7627,7 +7627,7 @@ dependencies = [ [[package]] name = "servo-paint-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "bitflags 2.13.1", "crossbeam-channel", @@ -7662,7 +7662,7 @@ dependencies = [ [[package]] name = "servo-pixels" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "euclid", "image", @@ -7677,7 +7677,7 @@ dependencies = [ [[package]] name = "servo-profile" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "libc", "log", @@ -7695,7 +7695,7 @@ dependencies = [ [[package]] name = "servo-profile-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "crossbeam-channel", "ipc-channel", @@ -7711,7 +7711,7 @@ dependencies = [ [[package]] name = "servo-script" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "aes", "aes-gcm", @@ -7854,7 +7854,7 @@ dependencies = [ [[package]] name = "servo-script-bindings" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "atomic_refcell", "bitflags 2.13.1", @@ -7894,7 +7894,7 @@ dependencies = [ [[package]] name = "servo-script-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "accesskit", "crossbeam-channel", @@ -7928,7 +7928,7 @@ dependencies = [ [[package]] name = "servo-storage" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "libc", "log", @@ -7953,7 +7953,7 @@ dependencies = [ [[package]] name = "servo-storage-traits" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "malloc_size_of_derive", "serde", @@ -7967,7 +7967,7 @@ dependencies = [ [[package]] name = "servo-timers" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "crossbeam-channel", "malloc_size_of_derive", @@ -7977,7 +7977,7 @@ dependencies = [ [[package]] name = "servo-tracing" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "proc-macro2", "quote", @@ -7987,7 +7987,7 @@ dependencies = [ [[package]] name = "servo-url" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "encoding_rs", "malloc_size_of_derive", @@ -8001,7 +8001,7 @@ dependencies = [ [[package]] name = "servo-wakelock" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "serde", "servo-embedder-traits", @@ -8010,7 +8010,7 @@ dependencies = [ [[package]] name = "servo-webgl" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "bitflags 2.13.1", "byteorder", @@ -8034,7 +8034,7 @@ dependencies = [ [[package]] name = "servo-webvtt" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "html5ever 0.39.0", "markup5ever 0.39.0", @@ -8044,7 +8044,7 @@ dependencies = [ [[package]] name = "servo-webxr-api" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "euclid", "ipc-channel", @@ -8060,7 +8060,7 @@ dependencies = [ [[package]] name = "servo-xpath" version = "0.5.0" -source = "git+https://github.com/Travis-Gilbert/servo?rev=b70d4e64c0005d5dc2d5257c09f997dba235410a#b70d4e64c0005d5dc2d5257c09f997dba235410a" +source = "git+https://github.com/Travis-Gilbert/servo?rev=e92cdaa790797479c1821c33470c64e0d166feb2#e92cdaa790797479c1821c33470c64e0d166feb2" dependencies = [ "log", "malloc_size_of_derive", diff --git a/Cargo.toml b/Cargo.toml index a2da420..16cf7a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,11 +25,11 @@ ureq = { version = "3.4", default-features = false } # Public development integration only. Registry release remains gated by E02; # published versions do not yet provide these runtime/origin contracts. [patch.crates-io] -servo = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } -servo-net-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } -servo-base = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } -servo-storage-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } -servo-url = { git = "https://github.com/Travis-Gilbert/servo", rev = "b70d4e64c0005d5dc2d5257c09f997dba235410a" } +servo = { git = "https://github.com/Travis-Gilbert/servo", rev = "e92cdaa790797479c1821c33470c64e0d166feb2" } +servo-net-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "e92cdaa790797479c1821c33470c64e0d166feb2" } +servo-base = { git = "https://github.com/Travis-Gilbert/servo", rev = "e92cdaa790797479c1821c33470c64e0d166feb2" } +servo-storage-traits = { git = "https://github.com/Travis-Gilbert/servo", rev = "e92cdaa790797479c1821c33470c64e0d166feb2" } +servo-url = { git = "https://github.com/Travis-Gilbert/servo", rev = "e92cdaa790797479c1821c33470c64e0d166feb2" } tauri = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } tauri-runtime = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } tauri-utils = { git = "https://github.com/Travis-Gilbert/tauri", rev = "e84733018d84c8004645e04cbc8fea8511ae36b1" } diff --git a/patches/servo/0008-indexeddb-conformance-slice.patch b/patches/servo/0008-indexeddb-conformance-slice.patch new file mode 100644 index 0000000..258d21e --- /dev/null +++ b/patches/servo/0008-indexeddb-conformance-slice.patch @@ -0,0 +1,10077 @@ +From 6ed6091e4efc5b0871625c9e723fefec5029a448 Mon Sep 17 00:00:00 2001 +From: Travis Gilbert <1travisgilbert@gmail.com> +Date: Sun, 13 Sep 2026 13:10:16 -0400 +Subject: [PATCH] feat(indexeddb): the conformance slice V06 verified at 6ed6091e4e + +Squashed range b70d4e64c0..6ed6091e4e (33 commits) on Travis-Gilbert/servo +theorem/v0.5.0-indexeddb. Commits, oldest first: + + b5fead2675 fix(embedder): restore the bluetooth-only channel import b70d4e64c0 dropped + 61dfb2e494 feat(indexeddb): implement the IDBIndex surface, cursor iteration and index records + c1fbab2736 fix(indexeddb): remove backend panics and make upgrade cleanup idempotent + d21ed144da feat(indexeddb): implement IDBCursor update and delete, and stop key conversion from aborting + f695d426dc feat(indexeddb): widen IDBRequest.source to the index and cursor surfaces + 3ca48373fd feat(indexeddb): implement IDBRecord and the getAllRecords surface + 997c5151a3 feat(indexeddb): accept IDBGetAllOptions and read the getAll family through one operation + 36ca1f5f98 feat(indexeddb): wait for connections to close before deleting a database + 21af22ab85 fix(indexeddb): build sequence key paths as arrays, rename stores in the backend, cap the key generator in integer space + af207c383e fix(indexeddb): revert upgrade renames before comparing schema, restore index handles on abort, and keep the key generator in integer space + 254b2b9fab feat(indexeddb): populate a new index from the records the store already holds + a6c3d4e8e4 fix(indexeddb): keep index creation out of the request surface and stop the key generator repeating itself + 117da183af fix(indexeddb): let an aborting transaction answer the requests it takes down + f8721d7529 feat(indexeddb): roll back the writes of an aborted readwrite transaction + ee7e89d3fc fix(indexeddb): fail the request instead of the process when a backend reply is lost + f66edded39 fix(indexeddb): return the same object every time keyPath is read + c266652e79 fix(indexeddb): look up key path identifiers as UTF-16, not Latin-1 bytes + 931dd04b19 fix(indexeddb): scope an index name to its object store, not to the database + f9b2853e9c fix(indexeddb): list a reused request once, and count its executions separately + f586fb6f36 fix(indexeddb): sort objectStoreNames on every path that reads it + ffb4bd4a6e fix(indexeddb): report the committed version from databases(), not one in flight + 038baac1a4 fix(indexeddb): answer a violated invariant in the manager instead of asserting on it + b9d4e3298c fix(indexeddb): report reply-channel failures instead of panicking + 358c0ab112 fix(indexeddb): log the debug-only assertions instead of compiling them away + 9ce367638c fix(indexeddb): handle the structural invariants instead of asserting them + d96d5fe0fc fix(indexeddb): turn the shape assertions into branches that report + 71db1f6b80 fix(indexeddb): correct two signatures the earlier three commits got wrong + c8e1d0553a fix(indexeddb): generate keys where the operation runs, not where it is queued + b1a900eaa9 fix(indexeddb): let the generated key fill one position of a sequence index key + e4a7ab3021 test(indexeddb): prove an embedder's engine is the one the manager opens + 7a728af94c fix(indexeddb): preserve failure semantics across hardening + d78c4c3599 test(indexeddb): update the sqlite engine assertions to the record return type + 6ed6091e4e test(indexeddb): rename the index update field in the traits test module + +--- + components/script/dom/event/event.rs | 15 +- + components/script/dom/indexeddb/idbcursor.rs | 499 +++++++++++++++++++++++++++++++++++++++++++++++++++---- + components/script/dom/indexeddb/idbdatabase.rs | 174 +++++++++++++++---- + components/script/dom/indexeddb/idbfactory.rs | 132 +++++++++------ + components/script/dom/indexeddb/idbindex.rs | 488 ++++++++++++++++++++++++++++++++++++++++++++++++++++- + components/script/dom/indexeddb/idbkeyrange.rs | 14 +- + components/script/dom/indexeddb/idbobjectstore.rs | 873 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- + components/script/dom/indexeddb/idbopendbrequest.rs | 53 ++++-- + components/script/dom/indexeddb/idbrecord.rs | 100 +++++++++++ + components/script/dom/indexeddb/idbrequest.rs | 1029 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- + components/script/dom/indexeddb/idbtransaction.rs | 355 ++++++++++++++++++++++++++++++++------- + components/script/dom/indexeddb/mod.rs | 1 + + components/script/indexeddb.rs | 398 +++++++++++++++++++++++++++++++++----------- + components/script_bindings/codegen/Bindings.conf | 8 +- + components/script_bindings/webidls/IDBCursor.webidl | 18 +- + components/script_bindings/webidls/IDBIndex.webidl | 24 +-- + components/script_bindings/webidls/IDBKeyRange.webidl | 6 +- + components/script_bindings/webidls/IDBObjectStore.webidl | 12 +- + components/script_bindings/webidls/IDBRecord.webidl | 16 ++ + components/script_bindings/webidls/IDBRequest.webidl | 3 +- + components/servo/servo.rs | 2 + + components/shared/storage/indexeddb.rs | 263 +++++++++++++++++++++++++---- + components/storage/indexeddb/engines/sqlite.rs | 1227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- + components/storage/indexeddb/engines/sqlite/create.rs | 47 ++++-- + components/storage/indexeddb/mod.rs | 944 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- + components/storage/tests/storage_thread.rs | 303 ++++++++++++++++++++++++++++++++- + 26 files changed, 5980 insertions(+), 1024 deletions(-) + +diff --git a/components/script/dom/event/event.rs b/components/script/dom/event/event.rs +index 0831801a8a..93ff3fa229 100644 +--- a/components/script/dom/event/event.rs ++++ b/components/script/dom/event/event.rs +@@ -452,7 +452,20 @@ impl Event { + .GetRootNode(&GetRootNodeOptions::empty()) + .is_shadow_including_inclusive_ancestor_of(parent) + }); +- if parent.is::() || root_is_shadow_inclusive_ancestor { ++ // A parent that is not a node was named by an object's own `get the parent` ++ // algorithm rather than reached through a tree, so there is no shadow tree for ++ // the target to be adjusted across and step 6.9.8 has nothing to adjust. ++ // IndexedDB is the one such chain here: an event fired at an `IDBRequest` ++ // travels to its transaction and then to its connection, and script reads the ++ // request back off `event.target` at each of them. Step 6.9.8 would instead ++ // hand those listeners the transaction and then the connection, and would make ++ // both at-target steps, so an event whose `bubbles` is false would reach them ++ // anyway. ++ let parent_is_outside_a_tree = !parent.is::(); ++ if parent.is::() || ++ root_is_shadow_inclusive_ancestor || ++ parent_is_outside_a_tree ++ { + // Step 6.9.6.1. If isActivationEvent is true, event’s bubbles attribute is true, activationTarget + // is null, and parent has activation behavior, then set activationTarget to parent. + if is_activation_event && +diff --git a/components/script/dom/indexeddb/idbcursor.rs b/components/script/dom/indexeddb/idbcursor.rs +index aa124ded8f..ce3f852bac 100644 +--- a/components/script/dom/indexeddb/idbcursor.rs ++++ b/components/script/dom/indexeddb/idbcursor.rs +@@ -8,34 +8,54 @@ use dom_struct::dom_struct; + use js::context::JSContext; + use js::jsapi::Heap; + use js::jsval::{JSVal, UndefinedValue}; +-use js::rust::MutableHandleValue; ++use js::rust::{HandleValue, MutableHandleValue}; + use script_bindings::cell::DomRefCell; + use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; + use storage_traits::indexeddb::{IndexedDBKeyRange, IndexedDBKeyType, IndexedDBRecord}; + ++use storage_traits::indexeddb::{ ++ AsyncOperation, AsyncReadOnlyOperation, KvsOperationContext, KvsOperationTarget, RecordsShape, ++}; ++ + use crate::dom::bindings::codegen::Bindings::IDBCursorBinding::{ + IDBCursorDirection, IDBCursorMethods, + }; ++use crate::dom::bindings::codegen::Bindings::IDBIndexBinding::IDBIndexMethods; ++use crate::dom::bindings::codegen::Bindings::IDBTransactionBinding::{ ++ IDBTransactionMethods, IDBTransactionMode, ++}; + use crate::dom::bindings::codegen::UnionTypes::IDBObjectStoreOrIDBIndex; +-use crate::dom::bindings::error::Error; ++use crate::dom::bindings::error::{Error, Fallible}; + use crate::dom::bindings::refcounted::Trusted; + use crate::dom::bindings::root::{Dom, DomRoot, MutNullableDom}; + use crate::dom::bindings::structuredclone; + use crate::dom::globalscope::GlobalScope; + use crate::dom::indexeddb::idbindex::IDBIndex; + use crate::dom::indexeddb::idbobjectstore::IDBObjectStore; +-use crate::dom::indexeddb::idbrequest::IDBRequest; ++use crate::dom::indexeddb::idbrequest::{IDBRequest, RecordsParam, RequestSource}; + use crate::dom::indexeddb::idbtransaction::IDBTransaction; +-use crate::indexeddb::key_type_to_jsval; ++use crate::indexeddb::{convert_value_to_key, key_type_to_jsval}; + + #[derive(JSTraceable, MallocSizeOf)] +-#[expect(unused)] + #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] + pub(crate) enum ObjectStoreOrIndex { + ObjectStore(Dom), + Index(Dom), + } + ++impl ObjectStoreOrIndex { ++ /// The object store the records come from, whichever surface the request addressed. ++ /// ++ /// An index request reads the object store's values, so the store is the one that knows ++ /// whether a stored value is missing the key it was generated under. ++ pub(crate) fn object_store(&self) -> DomRoot { ++ match self { ++ ObjectStoreOrIndex::ObjectStore(store) => store.as_rooted(), ++ ObjectStoreOrIndex::Index(index) => index.object_store(), ++ } ++ } ++} ++ + #[dom_struct] + pub(crate) struct IDBCursor { + reflector_: Reflector, +@@ -75,6 +95,11 @@ pub(crate) struct IDBCursor { + } + + impl IDBCursor { ++ /// The object store or index this cursor was opened on. ++ pub(crate) fn source(&self) -> &ObjectStoreOrIndex { ++ &self.source ++ } ++ + #[cfg_attr(crown, expect(crown::unrooted_must_root))] + pub(crate) fn new_inherited( + transaction: &IDBTransaction, +@@ -170,6 +195,166 @@ impl IDBCursor { + ObjectStoreOrIndex::Index(_) => self.object_store_position.borrow().clone(), + } + } ++ ++ /// ++ fn effective_object_store(&self) -> DomRoot { ++ match &self.source { ++ ObjectStoreOrIndex::ObjectStore(store) => store.as_rooted(), ++ ObjectStoreOrIndex::Index(index) => index.ObjectStore(), ++ } ++ } ++ ++ /// The backend must range over the same records the cursor was opened on, so an index ++ /// cursor keeps naming its index on every subsequent iteration, not only on the first. ++ fn operation_context(&self) -> KvsOperationContext { ++ match &self.source { ++ ObjectStoreOrIndex::ObjectStore(_) => KvsOperationContext::default(), ++ ObjectStoreOrIndex::Index(index) => KvsOperationContext { ++ target: KvsOperationTarget::Index { ++ name: index.Name().to_string(), ++ }, ++ index_updates: Vec::new(), ++ }, ++ } ++ } ++ ++ /// The preconditions `advance`, `continue` and `continuePrimaryKey` share before any ++ /// argument of their own is examined. ++ /// ++ /// Kept separate from the got value check because `continuePrimaryKey` interposes two ++ /// "InvalidAccessError" checks of its own between them, and the order the exceptions are ++ /// thrown in is observable. ++ fn check_transaction_and_source(&self) -> Fallible<()> { ++ // If this's transaction's state is not active, throw a "TransactionInactiveError" ++ // DOMException. ++ if !self.transaction.is_active() || !self.transaction.is_usable() { ++ return Err(Error::TransactionInactive(None)); ++ } ++ ++ // If this's source or effective object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ let store = self.effective_object_store(); ++ if !self.transaction.Db().object_store_exists(&store.get_name()) { ++ return Err(Error::InvalidState(Some( ++ "The cursor's effective object store has been deleted".to_owned(), ++ ))); ++ } ++ if let ObjectStoreOrIndex::Index(index) = &self.source { ++ if !store.has_index(&index.Name()) { ++ return Err(Error::InvalidState(Some( ++ "The cursor's source index has been deleted".to_owned(), ++ ))); ++ } ++ } ++ Ok(()) ++ } ++ ++ /// The preconditions `update` and `delete` share: steps 2 through 6 of both algorithms, ++ /// in the order their exceptions are observable in. ++ /// ++ /// Kept separate from `check_transaction_and_source` because the read-only check falls ++ /// between the inactive check and the deleted check, and the three iteration methods have ++ /// no read-only check at all. ++ fn check_writable(&self) -> Fallible<()> { ++ // Step 2. If transaction's state is not active, throw a "TransactionInactiveError" ++ // DOMException. ++ if !self.transaction.is_active() || !self.transaction.is_usable() { ++ return Err(Error::TransactionInactive(None)); ++ } ++ ++ // Step 3. If transaction is a read-only transaction, throw a "ReadOnlyError" ++ // DOMException. ++ if let IDBTransactionMode::Readonly = self.transaction.get_mode() { ++ return Err(Error::ReadOnly(None)); ++ } ++ ++ // Step 4. If this's source or effective object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ let store = self.effective_object_store(); ++ if !self.transaction.Db().object_store_exists(&store.get_name()) { ++ return Err(Error::InvalidState(Some( ++ "The cursor's effective object store has been deleted".to_owned(), ++ ))); ++ } ++ if let ObjectStoreOrIndex::Index(index) = &self.source && ++ !store.has_index(&index.Name()) ++ { ++ return Err(Error::InvalidState(Some( ++ "The cursor's source index has been deleted".to_owned(), ++ ))); ++ } ++ ++ // Step 5. If this's got value flag is false, throw an "InvalidStateError" DOMException. ++ self.check_got_value()?; ++ ++ // Step 6. If this's key only flag is true, throw an "InvalidStateError" DOMException. ++ if self.key_only { ++ return Err(Error::InvalidState(Some( ++ "A key-only cursor has no value to write".to_owned(), ++ ))); ++ } ++ ++ Ok(()) ++ } ++ ++ /// If this's got value flag is false, throw an "InvalidStateError" DOMException. ++ /// ++ /// The flag is unset while a previous iteration is outstanding, so this is what refuses a ++ /// second `continue` before the first one's success event has fired. ++ fn check_got_value(&self) -> Fallible<()> { ++ if !self.got_value.get() { ++ return Err(Error::InvalidState(Some( ++ "The cursor is already iterating".to_owned(), ++ ))); ++ } ++ Ok(()) ++ } ++ ++ /// The tail the three iteration methods share: unset the got value flag, reopen the ++ /// cursor's one request, and run another iterate operation against it. ++ fn run_iteration( ++ &self, ++ cx: &mut JSContext, ++ key: Option, ++ primary_key: Option, ++ count: Option, ++ ) -> Fallible<()> { ++ // Unset this's got value flag. ++ self.got_value.set(false); ++ ++ // Let request be this's request. Set request's done flag to false. ++ let request = self.request.get().ok_or(Error::InvalidState(Some( ++ "The cursor has no request".to_owned(), ++ )))?; ++ request.set_ready_state_pending(); ++ ++ let iteration_param = IterationParam { ++ cursor: Trusted::new(self), ++ key, ++ primary_key, ++ count, ++ }; ++ let key_range = self.range.clone(); ++ ++ // Run the steps to asynchronously execute a request with this as source and the steps ++ // to iterate a cursor as operation, reusing request. ++ IDBRequest::execute_async_with_context( ++ cx, ++ &self.effective_object_store(), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range, ++ count: None, ++ shape: RecordsShape::WithValues, ++ }) ++ }, ++ Some(request), ++ Some(RecordsParam::Cursor(iteration_param)), ++ ) ++ .map(|_| ()) ++ } + } + + impl IDBCursorMethods for IDBCursor { +@@ -191,7 +376,7 @@ impl IDBCursorMethods for IDBCursor { + } + + /// +- fn Key(&self, cx: &mut JSContext, mut value: MutableHandleValue) { ++ fn GetKey(&self, cx: &mut JSContext, mut value: MutableHandleValue) -> Fallible<()> { + // The key getter steps are to return the result of converting a key to a value with the cursor’s current key. + // + // NOTE: If key returns an object (e.g. a Date or Array), it returns the +@@ -201,20 +386,30 @@ impl IDBCursorMethods for IDBCursor { + // modify the contents of the database. + if let Some(cached) = &*self.cached_key.borrow() { + value.set(cached.get()); +- return; ++ return Ok(()); + } + + match self.key.borrow().as_ref() { +- Some(key) => key_type_to_jsval(cx, key, value.reborrow()), ++ Some(key) => key_type_to_jsval(cx, key, value.reborrow())?, + None => value.set(UndefinedValue()), + } + ++ // The `Heap` is stored before it is set: `Heap::set` registers the slot's own ++ // address with the GC store buffer, so the value has to be written where it ++ // will live rather than moved in afterwards. + *self.cached_key.borrow_mut() = Some(Heap::default()); +- self.cached_key.borrow().as_ref().unwrap().set(value.get()); ++ if let Some(cached) = self.cached_key.borrow().as_ref() { ++ cached.set(value.get()); ++ } ++ Ok(()) + } + + /// +- fn PrimaryKey(&self, cx: &mut JSContext, mut value: MutableHandleValue) { ++ fn GetPrimaryKey( ++ &self, ++ cx: &mut JSContext, ++ mut value: MutableHandleValue, ++ ) -> Fallible<()> { + // NOTE: If primaryKey returns an object (e.g. a Date or Array), + // it returns the same object instance every time it is inspected, + // until the cursor’s effective key is changed. This means that if the object is modified, +@@ -222,20 +417,19 @@ impl IDBCursorMethods for IDBCursor { + // However modifying such an object does not modify the contents of the database. + if let Some(cached) = &*self.cached_primary_key.borrow() { + value.set(cached.get()); +- return; ++ return Ok(()); + } + + match self.effective_key() { +- Some(effective_key) => key_type_to_jsval(cx, &effective_key, value.reborrow()), ++ Some(effective_key) => key_type_to_jsval(cx, &effective_key, value.reborrow())?, + None => value.set(UndefinedValue()), + } + + *self.cached_primary_key.borrow_mut() = Some(Heap::default()); +- self.cached_primary_key +- .borrow() +- .as_ref() +- .unwrap() +- .set(value.get()); ++ if let Some(cached) = self.cached_primary_key.borrow().as_ref() { ++ cached.set(value.get()); ++ } ++ Ok(()) + } + + /// +@@ -244,6 +438,219 @@ impl IDBCursorMethods for IDBCursor { + .get() + .expect("IDBCursor.request should be set when cursor is opened") + } ++ ++ /// ++ fn Advance(&self, cx: &mut JSContext, count: u32) -> Fallible<()> { ++ // Step 1. If count is 0 (zero), throw a TypeError. ++ if count == 0 { ++ return Err(Error::Type(c"count must not be zero".to_owned())); ++ } ++ ++ // Step 2. Let transaction be this's transaction. ++ // Step 3. If transaction's state is not active, throw a "TransactionInactiveError" ++ // DOMException. ++ // Step 4. If this's source or effective object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.check_transaction_and_source()?; ++ ++ // Step 5. If this's got value flag is false, throw an "InvalidStateError" DOMException. ++ self.check_got_value()?; ++ ++ // Step 6. Unset this's got value flag. ++ // Step 7. Let request be this's request. ++ // Step 8. Set request's done flag to false. ++ // Step 9. Let operation be an algorithm to run iterate a cursor with the current Realm ++ // record, this, and count. ++ // Step 10. Run asynchronously execute a request with this's source as source, operation ++ // as operation and request as request. ++ self.run_iteration(cx, None, None, Some(count)) ++ } ++ ++ /// ++ fn Continue(&self, cx: &mut JSContext, key: HandleValue) -> Fallible<()> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. If transaction's state is not active, throw a "TransactionInactiveError" ++ // DOMException. ++ // Step 3. If this's source or effective object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.check_transaction_and_source()?; ++ ++ // Step 4. If this's got value flag is false, throw an "InvalidStateError" DOMException. ++ self.check_got_value()?; ++ ++ // Step 5. If key is given, then: ++ let key = if key.is_undefined() { ++ None ++ } else { ++ // Step 5.1. Let r be the result of running the steps to convert a value to a key ++ // with key. Rethrow any exceptions. ++ // Step 5.2. If r is "invalid value" or "invalid type", throw a "DataError" ++ // DOMException. ++ // Step 5.3. Let key be r. ++ let key = convert_value_to_key(cx, key, None)?.into_result()?; ++ ++ // Step 5.4. If key is less than or equal to this's position and this's direction is ++ // "next" or "nextunique", or if key is greater than or equal to this's position and ++ // this's direction is "prev" or "prevunique", throw a "DataError" DOMException. ++ if let Some(position) = self.position.borrow().as_ref() { ++ let moves_backwards = match self.direction { ++ IDBCursorDirection::Next | IDBCursorDirection::Nextunique => &key <= position, ++ IDBCursorDirection::Prev | IDBCursorDirection::Prevunique => &key >= position, ++ }; ++ if moves_backwards { ++ return Err(Error::Data(Some( ++ "continue() must move the cursor in its own direction".to_owned(), ++ ))); ++ } ++ } ++ Some(key) ++ }; ++ ++ // Step 6. Unset this's got value flag. ++ // Step 7. Let request be this's request. ++ // Step 8. Set request's done flag to false. ++ // Step 9. Let operation be an algorithm to run iterate a cursor with the current Realm ++ // record, this, and key (if given). ++ // Step 10. Run asynchronously execute a request with this's source as source, operation ++ // as operation and request as request. ++ self.run_iteration(cx, key, None, None) ++ } ++ ++ /// ++ fn ContinuePrimaryKey( ++ &self, ++ cx: &mut JSContext, ++ key: HandleValue, ++ primary_key: HandleValue, ++ ) -> Fallible<()> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. If transaction's state is not active, throw a "TransactionInactiveError" ++ // DOMException. ++ // Step 3. If this's source or effective object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.check_transaction_and_source()?; ++ ++ // Step 4. If this's source is not an index, throw an "InvalidAccessError" DOMException. ++ if !matches!(self.source, ObjectStoreOrIndex::Index(_)) { ++ return Err(Error::InvalidAccess(Some( ++ "continuePrimaryKey() requires an index cursor".to_owned(), ++ ))); ++ } ++ ++ // Step 5. If this's direction is not "next" or "prev", throw an "InvalidAccessError" ++ // DOMException. ++ if !matches!( ++ self.direction, ++ IDBCursorDirection::Next | IDBCursorDirection::Prev ++ ) { ++ return Err(Error::InvalidAccess(Some( ++ "continuePrimaryKey() requires direction next or prev".to_owned(), ++ ))); ++ } ++ ++ // Step 6. If this's got value flag is false, throw an "InvalidStateError" DOMException. ++ self.check_got_value()?; ++ ++ // Step 7. Let r be the result of running the steps to convert a value to a key with key. ++ // Rethrow any exceptions. ++ // Step 8. If r is "invalid value" or "invalid type", throw a "DataError" DOMException. ++ // Step 9. Let key be r. ++ let key = convert_value_to_key(cx, key, None)?.into_result()?; ++ ++ // Step 10. Let r be the result of running the steps to convert a value to a key with ++ // primaryKey. Rethrow any exceptions. ++ // Step 11. If r is "invalid value" or "invalid type", throw a "DataError" DOMException. ++ // Step 12. Let primaryKey be r. ++ let primary_key = convert_value_to_key(cx, primary_key, None)?.into_result()?; ++ ++ // Step 13. If key is less than this's position and this's direction is "next", or if key ++ // is greater than this's position and this's direction is "prev", throw a "DataError" ++ // DOMException. ++ // ++ // Step 14. If key is equal to this's position and primaryKey is less than or equal to ++ // this's object store position and this's direction is "next", or if key is equal to ++ // this's position and primaryKey is greater than or equal to this's object store ++ // position and this's direction is "prev", throw a "DataError" DOMException. ++ // ++ // Step 14 is the reason the object store position has to survive iterate_cursor: it is ++ // the only record of where within a run of equal index keys the cursor stopped. ++ if let Some(position) = self.position.borrow().as_ref() { ++ let object_store_position = self.object_store_position.borrow(); ++ let refuses = match self.direction { ++ IDBCursorDirection::Next => { ++ &key < position || ++ (&key == position && ++ object_store_position ++ .as_ref() ++ .is_some_and(|current| &primary_key <= current)) ++ }, ++ IDBCursorDirection::Prev => { ++ &key > position || ++ (&key == position && ++ object_store_position ++ .as_ref() ++ .is_some_and(|current| &primary_key >= current)) ++ }, ++ // Refused at step 5 above. ++ IDBCursorDirection::Nextunique | IDBCursorDirection::Prevunique => false, ++ }; ++ if refuses { ++ return Err(Error::Data(Some( ++ "continuePrimaryKey() must move the cursor in its own direction".to_owned(), ++ ))); ++ } ++ } ++ ++ // Step 15. Unset this's got value flag. ++ // Step 16. Let request be this's request. ++ // Step 17. Set request's done flag to false. ++ // Step 18. Let operation be an algorithm to run iterate a cursor with the current Realm ++ // record, this, key and primaryKey. ++ // Step 19. Run asynchronously execute a request with this's source as source, operation ++ // as operation and request as request. ++ self.run_iteration(cx, Some(key), Some(primary_key), None) ++ } ++ ++ /// ++ fn Update(&self, cx: &mut JSContext, value: HandleValue) -> Fallible> { ++ // Steps 1 through 6. ++ self.check_writable()?; ++ ++ // Step 7. Let targetRealm be a user-agent defined Realm. ++ // Steps 8 through 11 are the effective object store's, because the clone, the key path ++ // check and the index records all need state that belongs to it. ++ let Some(effective_key) = self.effective_key() else { ++ return Err(Error::InvalidState(Some( ++ "The cursor has no effective key to update".to_owned(), ++ ))); ++ }; ++ self.effective_object_store().store_record_with_known_key( ++ cx, ++ RequestSource::Cursor(Dom::from_ref(self)), ++ value, ++ &effective_key, ++ ) ++ } ++ ++ /// ++ fn Delete(&self, cx: &mut JSContext) -> Fallible> { ++ // Steps 1 through 6, the same preconditions update() checks and in the same order. ++ self.check_writable()?; ++ ++ // Step 7. Let operation be an algorithm to run delete records from an object store with ++ // this's effective object store and this's effective key. ++ // Step 8. Return the result of running asynchronously execute a request. ++ let Some(effective_key) = self.effective_key() else { ++ return Err(Error::InvalidState(Some( ++ "The cursor has no effective key to delete".to_owned(), ++ ))); ++ }; ++ self.effective_object_store().delete_record_with_known_key( ++ cx, ++ RequestSource::Cursor(Dom::from_ref(self)), ++ &effective_key, ++ ) ++ } + } + + /// A struct containing parameters for +@@ -282,12 +689,25 @@ pub(crate) fn iterate_cursor( + let direction = cursor.direction; + + // Step 3. Assert: if primaryKey is given, source is an index and direction is "next" or "prev". ++ // ++ // A primary key only reaches here from continuePrimaryKey(), whose steps 4 and 5 already ++ // threw "InvalidAccessError" for any other source or direction. A primary key that ++ // arrives anyway would be read against the wrong shape of record, so the request fails ++ // rather than the process. + if primary_key.is_some() { +- assert!(matches!(source, ObjectStoreOrIndex::Index(..))); +- assert!(matches!( +- direction, +- IDBCursorDirection::Next | IDBCursorDirection::Prev +- )); ++ match (source, direction) { ++ ( ++ ObjectStoreOrIndex::Index(..), ++ IDBCursorDirection::Next | IDBCursorDirection::Prev, ++ ) => {}, ++ _ => { ++ warn!( ++ "iterate_cursor was given a primary key for a cursor that is not an index \ ++ cursor in direction next or prev." ++ ); ++ return Err(Error::InvalidAccess(None)); ++ }, ++ } + } + + // Step 4. Let records be the list of records in source. +@@ -300,7 +720,12 @@ pub(crate) fn iterate_cursor( + let mut position = cursor.position.borrow().clone(); + + // Step 7. Let object store position be cursor’s object store position. +- let object_store_position = cursor.object_store_position.borrow().clone(); ++ // ++ // NOTE: This is a local, exactly like position above. Steps 9.4, 10 and 11 rebind it and ++ // only step 11 writes it back to the cursor. Writing the cursor field inside the loop and ++ // then restoring this local at step 11 would discard every iteration's object store ++ // position, which is the cursor's effective key when the source is an index. ++ let mut object_store_position = cursor.object_store_position.borrow().clone(); + + // Step 8. If count is not given, let count be 1. + let mut count = count.unwrap_or(1); +@@ -478,9 +903,12 @@ pub(crate) fn iterate_cursor( + records + .iter() + .find(|&record| record.key == temp_record.key) +- .expect( +- "Record with key equal to temp record's key should exist in records", +- ) ++ // The search starts from a record that is already in `records`, so a ++ // reflexive comparison always finds at least that one. Key equality is ++ // not reflexive for a NaN number key, which script cannot produce but ++ // stored bytes can, so a corrupt record falls back to itself instead of ++ // killing the content process. ++ .unwrap_or(temp_record) + }), + }; + +@@ -509,7 +937,7 @@ pub(crate) fn iterate_cursor( + + // Step 9.4. If source is an index, let object store position be found record’s value. + if matches!(source, ObjectStoreOrIndex::Index(_)) { +- cursor.set_object_store_position(Some(found_record.primary_key.clone())); ++ object_store_position = Some(found_record.primary_key.clone()); + } + + // Step 9.5. Decrease count by 1. +@@ -517,8 +945,14 @@ pub(crate) fn iterate_cursor( + }, + } + } +- let found_record = +- found_record.expect("The while loop above guarantees found_record is defined"); ++ // Step 9 runs at least once: `count` is never `Some(0)`, because `advance()` rejects a ++ // zero count and the other two iteration methods pass `None`. An iteration that finds ++ // nothing has already returned at step 9.2.4, so reaching here means the loop bound a ++ // record. A cursor that somehow did not is a request that failed, not a crash. ++ let Some(found_record) = found_record else { ++ warn!("iterate_cursor reached step 10 without a found record."); ++ return Err(Error::Operation(None)); ++ }; + + // Step 10. Set cursor’s position to position. + cursor.set_position(position); +@@ -541,6 +975,13 @@ pub(crate) fn iterate_cursor( + .and_then(|data| { + structuredclone::read(cx, global, data, new_cursor_value.handle_mut()) + })?; ++ // A store that generates keys into an in-line key path does not store the key inside the ++ // value, so it goes back in before script sees the cursor's value. ++ source.object_store().inject_record_key_if_absent( ++ cx, ++ new_cursor_value.handle(), ++ &found_record.primary_key, ++ )?; + cursor.value.set(new_cursor_value.get()); + } + +diff --git a/components/script/dom/indexeddb/idbdatabase.rs b/components/script/dom/indexeddb/idbdatabase.rs +index 419374e358..aa3b61c706 100644 +--- a/components/script/dom/indexeddb/idbdatabase.rs ++++ b/components/script/dom/indexeddb/idbdatabase.rs +@@ -42,6 +42,9 @@ pub struct IDBDatabase { + object_store_names: DomRefCell>, + /// + upgrade_transaction: MutNullableDom, ++ /// Serial of the upgrade transaction most recently cleared successfully. This distinguishes ++ /// an idempotent repeated cleanup from an unrelated cleanup request after state was lost. ++ last_cleared_upgrade_transaction: Cell>, + + #[no_trace] + #[ignore_malloc_size_of = "Uuid"] +@@ -52,6 +55,40 @@ pub struct IDBDatabase { + close_pending: Cell, + } + ++#[derive(Clone, Copy, Debug, Eq, PartialEq)] ++pub(crate) enum UpgradeTransactionClear { ++ Cleared, ++ AlreadyCleared, ++} ++ ++#[derive(Clone, Copy, Debug, Eq, PartialEq)] ++pub(crate) enum UpgradeTransactionClearError { ++ Missing { ++ requested: u64, ++ last_cleared: Option, ++ }, ++ Mismatch { ++ requested: u64, ++ current: u64, ++ }, ++} ++ ++fn classify_upgrade_transaction_clear( ++ current: Option, ++ last_cleared: Option, ++ requested: u64, ++) -> Result { ++ match current { ++ Some(current) if current == requested => Ok(UpgradeTransactionClear::Cleared), ++ Some(current) => Err(UpgradeTransactionClearError::Mismatch { requested, current }), ++ None if last_cleared == Some(requested) => Ok(UpgradeTransactionClear::AlreadyCleared), ++ None => Err(UpgradeTransactionClearError::Missing { ++ requested, ++ last_cleared, ++ }), ++ } ++} ++ + impl IDBDatabase { + pub fn new_inherited( + name: DOMString, +@@ -68,6 +105,7 @@ impl IDBDatabase { + object_store_names.into_iter().map(Into::into).collect(), + ), + upgrade_transaction: Default::default(), ++ last_cleared_upgrade_transaction: Cell::new(None), + close_pending: Cell::new(false), + } + } +@@ -100,8 +138,14 @@ impl IDBDatabase { + self.name.clone() + } + ++ /// The connection's object store set, as `objectStoreNames` reports it. ++ /// ++ /// and ++ /// both sort, and an ++ /// upgrade transaction reads its names through here, so the sort belongs on this side rather ++ /// than on each caller. + pub fn object_stores(&self, cx: &mut JSContext) -> DomRoot { +- DOMStringList::new(cx, &self.global(), self.object_store_names.borrow().clone()) ++ DOMStringList::new_sorted(cx, &self.global(), &*self.object_store_names.borrow()) + } + + pub(crate) fn object_store_names_snapshot(&self) -> Vec { +@@ -145,18 +189,32 @@ impl IDBDatabase { + self.upgrade_transaction.set(Some(transaction)); + } + +- pub(crate) fn clear_upgrade_transaction(&self, transaction: &IDBTransaction) { ++ pub(crate) fn clear_upgrade_transaction( ++ &self, ++ transaction: &IDBTransaction, ++ ) -> Result { ++ let requested = transaction.get_serial_number(); + let current = self + .upgrade_transaction + .get() +- .expect("clear_upgrade_transaction called but no upgrade transaction is set"); +- +- debug_assert!( +- &*current == transaction, +- "clear_upgrade_transaction called with non-current transaction" ++ .map(|transaction| transaction.get_serial_number()); ++ let result = classify_upgrade_transaction_clear( ++ current, ++ self.last_cleared_upgrade_transaction.get(), ++ requested, + ); + +- self.upgrade_transaction.set(None); ++ match result { ++ Ok(UpgradeTransactionClear::Cleared) => { ++ self.upgrade_transaction.set(None); ++ self.last_cleared_upgrade_transaction.set(Some(requested)); ++ }, ++ Ok(UpgradeTransactionClear::AlreadyCleared) => {}, ++ Err(error) => { ++ warn!("Could not clear IndexedDB upgrade transaction: {error:?}"); ++ }, ++ } ++ result + } + + /// +@@ -177,6 +235,14 @@ impl IDBDatabase { + ); + } + ++ /// Whether this connection is . ++ /// ++ /// A connection is closed once its close pending flag is set, so this is the question ++ /// `open a database connection` step 10.7 asks before handing the connection back. ++ pub(crate) fn is_close_pending(&self) -> bool { ++ self.close_pending.get() ++ } ++ + /// + pub(crate) fn close_a_database_connection(&self, _forced: bool) { + // Step 1: Set connection’s close pending flag to true. +@@ -248,7 +314,7 @@ impl IDBDatabaseMethods for IDBDatabase { + // stores named in scope. + let durability = options.durability; + let scope = DOMStringList::new(cx, &self.global(), scope); +- let transaction = IDBTransaction::new(cx, &self.global(), self, mode, durability, &scope); ++ let transaction = IDBTransaction::new(cx, &self.global(), self, mode, durability, &scope)?; + + // Step 8. Set transaction’s cleanup event loop to the current event loop. + transaction.set_cleanup_event_loop(); +@@ -336,7 +402,6 @@ impl IDBDatabaseMethods for IDBDatabase { + IDBObjectStoreAbortState { + newly_created_during_transaction: true, + rollback_indexes_on_abort: vec![], +- key_generator_current_number: if auto_increment { Some(1_i64) } else { None }, + }, + &transaction, + ); +@@ -349,20 +414,24 @@ impl IDBDatabaseMethods for IDBDatabase { + }); + + let operation = AsyncSchemaOperation::CreateObjectStore { +- callback: transaction.create_abort_callback(), ++ callback: transaction.create_abort_callback()?, + key_path: key_paths, + auto_increment, + }; +- +- self.get_idb_thread() +- .send(IndexedDBThreadMsg::AsyncSchemaOperation { ++ transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { + origin: self.global().origin().immutable().clone(), + database_name: self.name.to_string(), + store_name: name.to_string(), + operation, + transaction_serial_number: transaction.get_serial_number(), + }) +- .unwrap(); ++ .map_err(|()| { ++ warn!("Could not send CreateObjectStore to the IndexedDB backend"); ++ Error::Operation(Some( ++ "Could not send the create object store operation".to_owned(), ++ )) ++ })?; + + self.object_store_names.borrow_mut().push(name); + transaction.register_object_store_handle(&object_store.get_name(), &object_store); +@@ -390,27 +459,37 @@ impl IDBDatabaseMethods for IDBDatabase { + return Err(Error::NotFound(None)); + } + +- // Step 5 +- self.object_store_names +- .borrow_mut() +- .retain(|store_name| *store_name != name); +- +- // Step 6 +- // FIXME:(arihant2math) Remove from index set ... +- +- // Step 7 ++ // Queue destruction before mutating the connection and handle metadata. If callback ++ // construction or transport fails, script receives the structured failure while its ++ // view still agrees with the backend. + let operation = AsyncSchemaOperation::DeleteObjectStore { +- callback: transaction.create_abort_callback(), ++ callback: transaction.create_abort_callback()?, + }; +- self.get_idb_thread() +- .send(IndexedDBThreadMsg::AsyncSchemaOperation { ++ transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { + origin: self.global().origin().immutable().clone(), + database_name: self.name.to_string(), + store_name: name.to_string(), + operation, + transaction_serial_number: transaction.get_serial_number(), + }) +- .unwrap(); ++ .map_err(|()| { ++ warn!("Could not send DeleteObjectStore to the IndexedDB backend"); ++ Error::Operation(Some( ++ "Could not send the delete object store operation".to_owned(), ++ )) ++ })?; ++ ++ // Step 5 ++ self.object_store_names ++ .borrow_mut() ++ .retain(|store_name| *store_name != name); ++ ++ // Step 6. If there is an object store handle associated with store and ++ // transaction, remove all entries from its index set. ++ if let Some(store) = transaction.object_store_handle(&name) { ++ store.clear_index_set(); ++ } + + Ok(()) + } +@@ -427,7 +506,7 @@ impl IDBDatabaseMethods for IDBDatabase { + + /// + fn ObjectStoreNames(&self, cx: &mut JSContext) -> DomRoot { +- DOMStringList::new_sorted(cx, &self.global(), &*self.object_store_names.borrow()) ++ self.object_stores(cx) + } + + /// +@@ -448,3 +527,40 @@ impl IDBDatabaseMethods for IDBDatabase { + // https://www.w3.org/TR/IndexedDB-3/#dom-idbdatabase-onversionchange + event_handler!(versionchange, GetOnversionchange, SetOnversionchange); + } ++ ++#[cfg(test)] ++mod tests { ++ use super::{ ++ UpgradeTransactionClear, UpgradeTransactionClearError, classify_upgrade_transaction_clear, ++ }; ++ ++ #[test] ++ fn upgrade_cleanup_is_idempotent_only_for_the_last_cleared_transaction() { ++ assert_eq!( ++ classify_upgrade_transaction_clear(None, Some(7), 7), ++ Ok(UpgradeTransactionClear::AlreadyCleared) ++ ); ++ assert_eq!( ++ classify_upgrade_transaction_clear(None, Some(7), 8), ++ Err(UpgradeTransactionClearError::Missing { ++ requested: 8, ++ last_cleared: Some(7), ++ }) ++ ); ++ } ++ ++ #[test] ++ fn upgrade_cleanup_rejects_a_different_current_transaction() { ++ assert_eq!( ++ classify_upgrade_transaction_clear(Some(9), Some(7), 8), ++ Err(UpgradeTransactionClearError::Mismatch { ++ requested: 8, ++ current: 9, ++ }) ++ ); ++ assert_eq!( ++ classify_upgrade_transaction_clear(Some(8), Some(7), 8), ++ Ok(UpgradeTransactionClear::Cleared) ++ ); ++ } ++} +diff --git a/components/script/dom/indexeddb/idbfactory.rs b/components/script/dom/indexeddb/idbfactory.rs +index 9872a4ad7a..86015682e9 100644 +--- a/components/script/dom/indexeddb/idbfactory.rs ++++ b/components/script/dom/indexeddb/idbfactory.rs +@@ -35,7 +35,7 @@ use crate::dom::globalscope::GlobalScope; + use crate::dom::indexeddb::idbopendbrequest::IDBOpenDBRequest; + use crate::dom::promise::Promise; + use crate::dom::types::IDBTransaction; +-use crate::indexeddb::{convert_value_to_key, map_backend_error_to_dom_error}; ++use crate::indexeddb::{convert_value_to_key, map_backend_error_to_dom_error, reply_lost}; + + /// A non-jstraceable string wrapper for use in `HashMapTracedValues`. + #[derive(Clone, Debug, Eq, Hash, MallocSizeOf, PartialEq)] +@@ -191,10 +191,14 @@ impl IDBFactory { + cleared += request.clear_transaction_if_matches(transaction) as usize; + } + +- debug_assert_eq!( +- cleared, 1, +- "A versionchange transaction should belong to exactly one IDBOpenDBRequest." +- ); ++ // Clearing is what this method is for, so a count other than one leaves nothing to ++ // undo and the release build has always carried on from here. ++ if cleared != 1 { ++ warn!( ++ "A versionchange transaction should belong to exactly one IDBOpenDBRequest, \ ++ but {cleared} were cleared." ++ ); ++ } + } + + pub fn new(cx: &mut JSContext, global: &GlobalScope) -> DomRoot { +@@ -202,9 +206,13 @@ impl IDBFactory { + } + + /// Setup the callback to the backend service, if this hasn't been done already. +- fn get_or_setup_callback(&self) -> GenericCallback { ++ /// ++ /// Returns `None` when the reply channel cannot be created. The factory has no way to ++ /// reach the storage thread without one, and the open request that asked for it reports ++ /// that as a DOMException rather than taking the content process down. ++ fn get_or_setup_callback(&self) -> Option> { + if let Some(cb) = self.callback.borrow().as_ref() { +- return cb.clone(); ++ return Some(cb.clone()); + } + + let global = self.global(); +@@ -225,27 +233,29 @@ impl IDBFactory { + let factory = response_listener.root(); + factory.handle_connection_message(cx, response) + })); +- }) +- .expect("Could not create open database callback"); ++ }); ++ let callback = match callback { ++ Ok(callback) => callback, ++ Err(error) => { ++ warn!("Could not create the IndexedDB open database callback: {error:?}"); ++ return None; ++ }, ++ }; + + *self.callback.borrow_mut() = Some(callback.clone()); + +- callback ++ Some(callback) + } + + fn get_request(&self, name: String, request_id: &Uuid) -> Option> { + let name = DBName(name); + let mut pending = self.connections.borrow_mut(); + let Some(entry) = pending.get_mut(&name) else { +- debug_assert!(false, "There should be a pending connection for {:?}", name); ++ warn!("There should be a pending connection for {name:?}."); + return None; + }; + let Some(request) = entry.get_mut(request_id) else { +- debug_assert!( +- false, +- "There should be a pending connection for {:?}", +- request_id +- ); ++ warn!("There should be a pending connection for {request_id:?}."); + return None; + }; + Some(request.as_rooted()) +@@ -265,10 +275,8 @@ impl IDBFactory { + object_store_names, + } => { + let Some(request) = self.get_request(name.clone(), &id) else { +- return debug_assert!( +- false, +- "There should be a request to handle ConnectionMsg::Connection." +- ); ++ warn!("There should be a request to handle ConnectionMsg::Connection."); ++ return; + }; + + // https://w3c.github.io/IndexedDB/#upgrade-transaction-steps +@@ -276,12 +284,22 @@ impl IDBFactory { + let connection = request.get_or_init_connection( + cx, + &self.global(), +- name, ++ name.clone(), + version, + object_store_names, + upgraded, + ); + ++ // step 10.7. If ++ // connection was closed, return a newly created "AbortError" DOMException. ++ // `close()` called from inside `upgradeneeded` leaves the upgrade transaction ++ // to commit on its own, so the transaction fires `complete` first and the open ++ // request only then reports that the connection it would have returned is gone. ++ if connection.is_close_pending() { ++ self.dispatch_error(cx, name, id, Error::Abort(None)); ++ return; ++ } ++ + // Step 2.2: Otherwise, + // set request’s result to result, + // set request’s done flag, +@@ -299,10 +317,8 @@ impl IDBFactory { + let global = self.global(); + + let Some(request) = self.get_request(name.clone(), &id) else { +- return debug_assert!( +- false, +- "There should be a request to handle ConnectionMsg::Upgrade." +- ); ++ warn!("There should be a request to handle ConnectionMsg::Upgrade."); ++ return; + }; + + let connection = request.get_or_init_connection( +@@ -337,15 +353,20 @@ impl IDBFactory { + } => { + let global = self.global(); + let Some(request) = self.get_request(name.clone(), &id) else { +- return debug_assert!( +- false, +- "There should be a request to handle ConnectionMsg::VersionChange." +- ); ++ warn!("There should be a request to handle ConnectionMsg::VersionChange."); ++ return; + }; +- let connection = request.connection(); +- + // Step 10.2: fire a version change event named versionchange at entry with db’s version and version. +- connection.dispatch_versionchange(cx, old_version, Some(version)); ++ // Note: a database delete carries a null `newVersion`, which arrives as `None`. ++ match request.pending_connection() { ++ Some(connection) => connection.dispatch_versionchange(cx, old_version, version), ++ // Without a connection there is no entry to fire the event at. The backend ++ // is waiting at step 10.3 and only moves on once it is told the event phase ++ // is over, so the message below is still sent. ++ None => { ++ warn!("ConnectionMsg::VersionChange arrived for a request with no connection.") ++ }, ++ } + + // Step 10.3: Wait for all of the events to be fired. + // Note: backend is at this step; sending a message to continue algo there. +@@ -370,10 +391,8 @@ impl IDBFactory { + old_version, + } => { + let Some(request) = self.get_request(name, &id) else { +- return debug_assert!( +- false, +- "There should be a request to handle ConnectionMsg::VersionChange." +- ); ++ warn!("There should be a request to handle ConnectionMsg::Blocked."); ++ return; + }; + + // Step 10.4: fire a version change event named blocked at request with db’s version and version. +@@ -407,14 +426,12 @@ impl IDBFactory { + let request = { + let mut pending = self.connections.borrow_mut(); + let Some(entry) = pending.get_mut(&name) else { +- return debug_assert!(false, "There should be a pending connection for {:?}", name); ++ warn!("There should be a pending connection for {name:?}."); ++ return; + }; + let Some(request) = entry.get_mut(&request_id) else { +- return debug_assert!( +- false, +- "There should be a pending connection for {:?}", +- request_id +- ); ++ warn!("There should be a pending connection for {request_id:?}."); ++ return; + }; + request.as_rooted() + }; +@@ -460,14 +477,19 @@ impl IDBFactory { + let global = self.global(); + let request_id = request.get_id(); + ++ // The callback is obtained before the request is recorded as pending, so a factory ++ // that cannot talk to the storage thread does not leave behind a connection entry ++ // that nothing will ever answer. ++ let Some(callback) = self.get_or_setup_callback() else { ++ return Err(()); ++ }; ++ + { + let mut pending = self.connections.borrow_mut(); + let outer = pending.entry(DBName(name.to_string())).or_default(); + outer.insert(request_id, Dom::from_ref(request)); + } + +- let callback = self.get_or_setup_callback(); +- + // Step 5: Run these steps in parallel: + // Step 5.1: Let result be the result of opening a database connection, + // with storageKey, name, version if given and undefined otherwise, and request. +@@ -503,7 +525,9 @@ impl IDBFactory { + .collect(); + let origin = global.origin().immutable().clone(); + let Ok(proxy_map) = self.obtain_a_local_storage_bottle_map(&global, origin.clone()) else { +- debug_assert!(false, "Failed to obtain a proxy map."); ++ // The AbortPendingUpgrades message carries the proxy map, so without one there is ++ // nothing to send and the release build has always returned here. ++ warn!("Failed to obtain a proxy map."); + return; + }; + if global +@@ -664,7 +688,9 @@ impl IDBFactoryMethods for IDBFactory { + .database_access_task_source() + .to_sendable(); + let callback = GenericCallback::new(global.time_profiler_chan().clone(), move |message| { +- let result: BackendResult> = message.unwrap(); ++ // The promise is the thing to reject when the storage process stops answering. ++ let result: BackendResult> = ++ message.unwrap_or_else(|error| Err(reply_lost(error))); + let Some(trusted_promise) = trusted_promise.take() else { + return error!("Callback for `DataBases` called twice."); + }; +@@ -692,8 +718,18 @@ impl IDBFactoryMethods for IDBFactory { + }, + } + })); +- }) +- .expect("Could not create databases callback"); ++ }); ++ let callback = match callback { ++ Ok(callback) => callback, ++ Err(error) => { ++ // Step 4 cannot start without a reply channel. `databases()` reports failure ++ // by rejecting its promise, which is also how the steps below report a ++ // backend error, so the rejection goes there rather than into a panic. ++ warn!("Could not create the IndexedDB databases callback: {error:?}"); ++ p.reject_error(cx, Error::Operation(None)); ++ return p; ++ }, ++ }; + + let get_operation = SyncOperation::GetDatabases(callback, storage_key); + if global +diff --git a/components/script/dom/indexeddb/idbindex.rs b/components/script/dom/indexeddb/idbindex.rs +index 2902378d07..b60b653279 100644 +--- a/components/script/dom/indexeddb/idbindex.rs ++++ b/components/script/dom/indexeddb/idbindex.rs +@@ -5,17 +5,35 @@ use dom_struct::dom_struct; + use js::context::JSContext; + use js::conversions::ToJSValConvertible; + use js::gc::MutableHandleValue; ++use js::jsapi::Heap; ++use js::jsval::JSVal; ++use js::rust::HandleValue; + use script_bindings::cell::DomRefCell; + use script_bindings::codegen::GenericBindings::IDBIndexBinding::IDBIndexMethods; ++use script_bindings::codegen::GenericBindings::IDBObjectStoreBinding::IDBGetAllOptions; + use script_bindings::codegen::GenericBindings::IDBTransactionBinding::IDBTransactionMode; + use script_bindings::error::{Error, ErrorResult}; + use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; + use script_bindings::str::DOMString; ++use storage_traits::indexeddb::{ ++ AsyncOperation, AsyncReadOnlyOperation, KvsOperationContext, KvsOperationTarget, RecordsShape, ++}; + ++use crate::dom::bindings::codegen::Bindings::IDBCursorBinding::IDBCursorDirection; ++use crate::dom::bindings::error::Fallible; ++use crate::dom::bindings::trace::RootedTraceableBox; ++use crate::dom::bindings::refcounted::Trusted; ++use crate::dom::bindings::reflector::DomGlobal; + use crate::dom::bindings::root::{Dom, DomRoot}; + use crate::dom::globalscope::GlobalScope; + use crate::dom::idbobjectstore::KeyPath; ++use crate::dom::indexeddb::idbcursor::{IDBCursor, IterationParam, ObjectStoreOrIndex}; ++use crate::dom::indexeddb::idbcursorwithvalue::IDBCursorWithValue; + use crate::dom::indexeddb::idbobjectstore::IDBObjectStore; ++use crate::dom::indexeddb::idbrequest::{ ++ GetAllKind, GetAllRequest, IDBRequest, RecordsParam, RequestSource, ++}; ++use crate::indexeddb::convert_value_to_key_range; + + #[dom_struct] + pub(crate) struct IDBIndex { +@@ -25,6 +43,17 @@ pub(crate) struct IDBIndex { + multi_entry: bool, + unique: bool, + key_path: KeyPath, ++ /// ++ /// An index created during the upgrade transaction leaves the store's index set when ++ /// the transaction aborts. One that existed before it gets its name back instead. ++ newly_created_during_transaction: bool, ++ /// The name this index carried when the upgrade transaction began, recorded on the ++ /// first rename so the abort has something to restore. ++ rollback_name: DomRefCell>, ++ /// `keyPath` converted to a value, kept so the attribute hands back the same object ++ /// every time it is read. An index's key path never changes, so this is written once. ++ #[ignore_malloc_size_of = "mozjs"] ++ cached_key_path: DomRefCell>>, + } + + impl IDBIndex { +@@ -34,6 +63,7 @@ impl IDBIndex { + multi_entry: bool, + unique: bool, + key_path: KeyPath, ++ newly_created_during_transaction: bool, + ) -> IDBIndex { + IDBIndex { + reflector_: Reflector::new(), +@@ -42,9 +72,30 @@ impl IDBIndex { + multi_entry, + unique, + key_path, ++ newly_created_during_transaction, ++ rollback_name: DomRefCell::new(None), ++ cached_key_path: DomRefCell::new(None), + } + } + ++ /// Whether an aborting upgrade transaction should drop this handle rather than ++ /// restore it. ++ pub(crate) fn was_newly_created_during_transaction(&self) -> bool { ++ self.newly_created_during_transaction ++ } ++ ++ /// ++ /// Step 6: if the index was not newly created during the transaction, set the ++ /// handle's name back to the index's name. Returns the name the handle now carries, ++ /// which is the key the store's index set has to file it under. ++ pub(crate) fn restore_name_after_abort(&self) -> DOMString { ++ if let Some(name) = self.rollback_name.borrow_mut().take() { ++ *self.name.borrow_mut() = name; ++ } ++ self.name.borrow().clone() ++ } ++ ++ #[allow(clippy::too_many_arguments)] + pub fn new( + cx: &mut JSContext, + global: &GlobalScope, +@@ -53,6 +104,7 @@ impl IDBIndex { + multi_entry: bool, + unique: bool, + key_path: KeyPath, ++ newly_created_during_transaction: bool, + ) -> DomRoot { + reflect_dom_object_with_cx( + Box::new(IDBIndex::new_inherited( +@@ -61,11 +113,156 @@ impl IDBIndex { + multi_entry, + unique, + key_path, ++ newly_created_during_transaction, + )), + global, + cx, + ) + } ++ ++ /// The object store this index belongs to. ++ pub(crate) fn object_store(&self) -> DomRoot { ++ self.object_store.as_rooted() ++ } ++ ++ /// The index's name, as the object store knows it when it builds index records. ++ pub(crate) fn index_name(&self) -> String { ++ self.name.borrow().to_string() ++ } ++ ++ /// The key path index keys are extracted with. ++ pub(crate) fn index_key_path(&self) -> &KeyPath { ++ &self.key_path ++ } ++ ++ /// Whether each element of an extracted array key becomes its own index record. ++ pub(crate) fn is_multi_entry(&self) -> bool { ++ self.multi_entry ++ } ++ ++ /// An index request addresses the same object store as its handle, so the transaction, the ++ /// store name on the wire and the request's source all come from the object store. What the ++ /// context adds is the index name, which is how the backend knows to range over index ++ /// records rather than object store records. ++ fn operation_context(&self) -> KvsOperationContext { ++ KvsOperationContext { ++ target: KvsOperationTarget::Index { ++ name: self.name.borrow().to_string(), ++ }, ++ index_updates: Vec::new(), ++ } ++ } ++ ++ /// ++ /// ++ /// Every request method begins by rejecting a deleted index or a deleted owning object ++ /// store with an "InvalidStateError" DOMException. ++ fn verify_not_deleted(&self) -> ErrorResult { ++ let transaction = self.object_store.transaction(); ++ if !self.object_store.has_index(&self.name.borrow()) || ++ !transaction ++ .get_db() ++ .object_store_exists(&self.object_store.get_name()) ++ { ++ return Err(Error::InvalidState(Some( ++ "Index or its object store has been deleted".to_owned(), ++ ))); ++ } ++ Ok(()) ++ } ++ ++ /// Rejects a request made against an inactive transaction with a ++ /// "TransactionInactiveError" DOMException. ++ fn check_transaction_active(&self) -> Fallible<()> { ++ let transaction = self.object_store.transaction(); ++ if !transaction.is_active() || !transaction.is_usable() { ++ return Err(Error::TransactionInactive(None)); ++ } ++ Ok(()) ++ } ++ ++ /// ++ /// ++ fn open_cursor( ++ &self, ++ cx: &mut JSContext, ++ query: HandleValue, ++ direction: IDBCursorDirection, ++ key_only: bool, ++ ) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ let transaction = self.object_store.transaction(); ++ ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction is not active, throw a "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Step 5. Let range be the result of running the steps to convert a value to a key range ++ // with query. Rethrow any exceptions. ++ let range = convert_value_to_key_range(cx, query, Some(false))?; ++ ++ // Step 6. Let cursor be a new cursor with transaction set to transaction, an undefined ++ // position, direction set to direction, got value flag unset, undefined key and value, ++ // source set to index, range set to range, and key only flag set to key only. ++ // ++ // The cursor's source being the index is what makes its effective key the object store ++ // position rather than the position, which is the distinction iterate_cursor turns on. ++ let cursor = if key_only { ++ IDBCursor::new( ++ cx, ++ &self.global(), ++ &transaction, ++ direction, ++ false, ++ ObjectStoreOrIndex::Index(Dom::from_ref(self)), ++ range.clone(), ++ key_only, ++ ) ++ } else { ++ DomRoot::upcast(IDBCursorWithValue::new( ++ cx, ++ &self.global(), ++ &transaction, ++ direction, ++ false, ++ ObjectStoreOrIndex::Index(Dom::from_ref(self)), ++ range.clone(), ++ key_only, ++ )) ++ }; ++ ++ // Step 7. Run the steps to asynchronously execute a request and return the IDBRequest ++ // created by these steps, with this as source and the steps to iterate a cursor as ++ // operation. ++ let iteration_param = IterationParam { ++ cursor: Trusted::new(&cursor), ++ key: None, ++ primary_key: None, ++ count: None, ++ }; ++ ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: range, ++ count: None, ++ shape: RecordsShape::WithValues, ++ }) ++ }, ++ None, ++ Some(RecordsParam::Cursor(iteration_param)), ++ ) ++ .inspect(|request| cursor.set_request(request)) ++ } + } + + impl IDBIndexMethods for IDBIndex { +@@ -121,8 +318,18 @@ impl IDBIndexMethods for IDBIndex { + ))); + } + +- // Step 9: Set index’s name to name. +- self.object_store.rename_index(&stored_name, &name); ++ // Queue the backend rename before changing either local name. If callback creation or ++ // transport fails, the index remains consistently named on both sides. ++ self.object_store.rename_index(&stored_name, &name)?; ++ ++ // Step 9: Set index’s name to name. An aborting upgrade transaction has to put the ++ // first name back, not the name of whatever rename happened to be last. ++ { ++ let mut rollback_name = self.rollback_name.borrow_mut(); ++ if rollback_name.is_none() { ++ *rollback_name = Some(stored_name.clone()); ++ } ++ } + + // Step 10: Set this’s name to name. + *stored_name = name; +@@ -145,14 +352,285 @@ impl IDBIndexMethods for IDBIndex { + } + + /// +- fn KeyPath(&self, cx: &mut JSContext, retval: MutableHandleValue) { ++ fn KeyPath(&self, cx: &mut JSContext, mut retval: MutableHandleValue) { ++ // A sequence key path converts to a fresh Array on every call, so converting on ++ // each read would hand script a different object each time it looked. The value is ++ // converted once and kept; `idbindex_keyPath.any.js` asserts both halves of that, ++ // that one index answers with the same object and that two index handles onto the ++ // same index answer with different ones. ++ if let Some(cached) = self.cached_key_path.borrow().as_ref() { ++ retval.set(cached.get()); ++ return; ++ } ++ + match &self.key_path { + KeyPath::String(string) => { +- string.safe_to_jsval(cx, retval); ++ string.safe_to_jsval(cx, retval.reborrow()); + }, + KeyPath::StringSequence(sequence) => { +- sequence.safe_to_jsval(cx, retval); ++ sequence.safe_to_jsval(cx, retval.reborrow()); + }, + } ++ ++ // The `Heap` is stored before it is set: `Heap::set` registers the slot's own ++ // address with the GC store buffer, so the value has to be written where it will ++ // live rather than moved in afterwards. ++ *self.cached_key_path.borrow_mut() = Some(Heap::default()); ++ if let Some(cached) = self.cached_key_path.borrow().as_ref() { ++ cached.set(retval.get()); ++ } ++ } ++ ++ /// ++ fn Get(&self, cx: &mut JSContext, query: HandleValue) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Step 5. Let range be the result of converting a value to a key range with query and ++ // true. Rethrow any exceptions. ++ let serialized_query = convert_value_to_key_range(cx, query, Some(true)); ++ ++ // Step 6. Let operation be an algorithm to run retrieve a referenced value from an index ++ // with the current Realm record, index, and range. ++ // Step 7. Return the result (an IDBRequest) of running asynchronously execute a request ++ // with this and operation. ++ serialized_query.and_then(|q| { ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetItem { ++ callback, ++ key_range: q, ++ }) ++ }, ++ None, ++ None, ++ ) ++ }) ++ } ++ ++ /// ++ fn GetKey(&self, cx: &mut JSContext, query: HandleValue) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Step 5. Let range be the result of converting a value to a key range with query and ++ // true. Rethrow any exceptions. ++ let serialized_query = convert_value_to_key_range(cx, query, Some(true)); ++ ++ // Step 6. Let operation be an algorithm to run retrieve a value from an index with ++ // index and range. ++ // Step 7. Return the result (an IDBRequest) of running asynchronously execute a request ++ // with this and operation. ++ serialized_query.and_then(|q| { ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetKey { ++ callback, ++ key_range: q, ++ }) ++ }, ++ None, ++ None, ++ ) ++ }) ++ } ++ ++ /// ++ fn GetAll( ++ &self, ++ cx: &mut JSContext, ++ query_or_options: HandleValue, ++ count: Option, ++ ) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::resolve(cx, GetAllKind::Values, query_or_options, count)?; ++ ++ // Steps 10 to 13. Run retrieve multiple records from an index as the operation of an ++ // asynchronously executed request. ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::Values.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) ++ } ++ ++ /// ++ fn GetAllRecords( ++ &self, ++ cx: &mut JSContext, ++ options: RootedTraceableBox, ++ ) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::from_options(cx, GetAllKind::Records, &options)?; ++ ++ // Steps 10 to 13. Run retrieve multiple records from an index as the operation of an ++ // asynchronously executed request. ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::Records.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) ++ } ++ ++ /// ++ fn GetAllKeys( ++ &self, ++ cx: &mut JSContext, ++ query_or_options: HandleValue, ++ count: Option, ++ ) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::resolve(cx, GetAllKind::PrimaryKeys, query_or_options, count)?; ++ ++ // Steps 10 to 13. Run retrieve multiple records from an index as the operation of an ++ // asynchronously executed request. ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::PrimaryKeys.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) ++ } ++ ++ /// ++ fn Count(&self, cx: &mut JSContext, query: HandleValue) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let index be this's index. ++ // Step 3. If index or index's object store has been deleted, throw an ++ // "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Step 5. Let range be the result of converting a value to a key range with query. ++ // Rethrow any exceptions. ++ let serialized_query = convert_value_to_key_range(cx, query, None); ++ ++ // Step 6. Let operation be an algorithm to run count the records in a range with index ++ // and range. ++ // Step 7. Return the result (an IDBRequest) of running asynchronously execute a request ++ // with this and operation. ++ serialized_query.and_then(|q| { ++ IDBRequest::execute_async_from_source( ++ cx, ++ &self.object_store, ++ RequestSource::Index(Dom::from_ref(self)), ++ self.operation_context(), ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Count { ++ callback, ++ key_range: q, ++ }) ++ }, ++ None, ++ None, ++ ) ++ }) ++ } ++ ++ /// ++ fn OpenCursor( ++ &self, ++ cx: &mut JSContext, ++ query: HandleValue, ++ direction: IDBCursorDirection, ++ ) -> Fallible> { ++ self.open_cursor(cx, query, direction, false) ++ } ++ ++ /// ++ fn OpenKeyCursor( ++ &self, ++ cx: &mut JSContext, ++ query: HandleValue, ++ direction: IDBCursorDirection, ++ ) -> Fallible> { ++ self.open_cursor(cx, query, direction, true) + } + } +diff --git a/components/script/dom/indexeddb/idbkeyrange.rs b/components/script/dom/indexeddb/idbkeyrange.rs +index 07e71f42dc..5de492d444 100644 +--- a/components/script/dom/indexeddb/idbkeyrange.rs ++++ b/components/script/dom/indexeddb/idbkeyrange.rs +@@ -45,16 +45,18 @@ impl IDBKeyRange { + + impl IDBKeyRangeMethods for IDBKeyRange { + /// +- fn Lower(&self, cx: &mut JSContext, answer: MutableHandleValue) { +- if let Some(lower) = self.inner.lower.as_ref() { +- key_type_to_jsval(cx, lower, answer); ++ fn GetLower(&self, cx: &mut JSContext, answer: MutableHandleValue) -> Fallible<()> { ++ match self.inner.lower.as_ref() { ++ Some(lower) => key_type_to_jsval(cx, lower, answer), ++ None => Ok(()), + } + } + + /// +- fn Upper(&self, cx: &mut JSContext, answer: MutableHandleValue) { +- if let Some(upper) = self.inner.upper.as_ref() { +- key_type_to_jsval(cx, upper, answer); ++ fn GetUpper(&self, cx: &mut JSContext, answer: MutableHandleValue) -> Fallible<()> { ++ match self.inner.upper.as_ref() { ++ Some(upper) => key_type_to_jsval(cx, upper, answer), ++ None => Ok(()), + } + } + +diff --git a/components/script/dom/indexeddb/idbobjectstore.rs b/components/script/dom/indexeddb/idbobjectstore.rs +index 3ebc0e180a..11af206080 100644 +--- a/components/script/dom/indexeddb/idbobjectstore.rs ++++ b/components/script/dom/indexeddb/idbobjectstore.rs +@@ -1,29 +1,34 @@ + /* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +-use std::cell::Cell; + use std::collections::HashMap; + + use dom_struct::dom_struct; + use js::context::JSContext; + use js::conversions::ToJSValConvertible; + use js::gc::MutableHandleValue; +-use js::jsval::NullValue; ++use js::jsapi::Heap; ++use js::jsval::{JSVal, NullValue}; + use js::rust::HandleValue; ++use js::rust::wrappers2::JS_ClearPendingException; + use script_bindings::cell::DomRefCell; + use script_bindings::codegen::GenericBindings::IDBObjectStoreBinding::IDBIndexParameters; + use script_bindings::codegen::GenericUnionTypes::StringOrStringSequence; + use script_bindings::error::ErrorResult; + use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; +-use servo_base::generic_channel::{GenericSend, GenericSender}; + use storage_traits::indexeddb::{ + self, AsyncOperation, AsyncReadOnlyOperation, AsyncReadWriteOperation, AsyncSchemaOperation, +- IndexedDBKeyType, IndexedDBThreadMsg, ++ BackfillIndexResult, IndexBackfillEntry, IndexedDBKeyRange, IndexedDBKeyType, ++ IndexedDBRecord, IndexedDBThreadMsg, KvsIndexUpdate, KvsOperationContext, KvsOperationTarget, ++ RecordKeyPlacement, ++ RecordsShape, + }; + + use crate::dom::bindings::codegen::Bindings::IDBCursorBinding::IDBCursorDirection; + use crate::dom::bindings::codegen::Bindings::IDBDatabaseBinding::IDBObjectStoreParameters; +-use crate::dom::bindings::codegen::Bindings::IDBObjectStoreBinding::IDBObjectStoreMethods; ++use crate::dom::bindings::codegen::Bindings::IDBObjectStoreBinding::{ ++ IDBGetAllOptions, IDBObjectStoreMethods, ++}; + use crate::dom::bindings::codegen::Bindings::IDBTransactionBinding::{ + IDBTransactionMethods, IDBTransactionMode, + }; +@@ -35,12 +40,15 @@ use crate::dom::bindings::reflector::DomGlobal; + use crate::dom::bindings::root::{Dom, DomRoot}; + use crate::dom::bindings::str::DOMString; + use crate::dom::bindings::structuredclone; ++use crate::dom::bindings::trace::RootedTraceableBox; + use crate::dom::domstringlist::DOMStringList; + use crate::dom::globalscope::GlobalScope; + use crate::dom::indexeddb::idbcursor::{IDBCursor, IterationParam, ObjectStoreOrIndex}; + use crate::dom::indexeddb::idbcursorwithvalue::IDBCursorWithValue; + use crate::dom::indexeddb::idbindex::IDBIndex; +-use crate::dom::indexeddb::idbrequest::IDBRequest; ++use crate::dom::indexeddb::idbrequest::{ ++ BackfillHalf, GetAllKind, GetAllRequest, IDBRequest, RecordsParam, RequestSource, ++}; + use crate::dom::indexeddb::idbtransaction::IDBTransaction; + use crate::indexeddb::{ + ExtractionResult, can_inject_key_into_value, convert_value_to_key, convert_value_to_key_range, +@@ -90,7 +98,23 @@ struct IDBObjectStoreRollbackState { + rollback_name: Option, + #[no_trace] + rollback_indexes: Vec, +- key_generator_current_number: Option, ++} ++ ++/// How far an index's key path reaches into the store's key path, for a record whose key the ++/// engine has not generated yet. ++/// ++/// `Untouched` is the ordinary case: the value carries everything the index needs. The other ++/// three say the engine has to finish the key, or that the record earns no entry at all. ++enum RecordKeyReach { ++ /// The index's key path does not name the store's key path. ++ Untouched, ++ /// The index's key is the record's key. ++ WholeKey, ++ /// The index's key is a sequence, with the record's key at each `None`. ++ InSequence(Vec>), ++ /// The index's key path reaches the store's key path, but another component of the sequence ++ /// did not evaluate to a valid key, so the record earns no entry in this index. ++ Dropped, + } + + #[dom_struct] +@@ -102,7 +126,10 @@ pub struct IDBObjectStore { + abort_state_on_abort: DomRefCell>, + transaction: Dom, + has_key_generator: bool, +- key_generator_current_number: Cell>, ++ /// `keyPath` converted to a value, kept so the attribute hands back the same object ++ /// every time it is read. A store's key path never changes, so this is written once. ++ #[ignore_malloc_size_of = "mozjs"] ++ cached_key_path: DomRefCell>>, + + // We store the db name in the object store to address backend operations + // that are keyed by (origin, database name, object store name). +@@ -112,7 +139,6 @@ pub struct IDBObjectStore { + pub(crate) struct IDBObjectStoreAbortState { + pub(crate) newly_created_during_transaction: bool, + pub(crate) rollback_indexes_on_abort: Vec, +- pub(crate) key_generator_current_number: Option, + } + + impl IDBObjectStore { +@@ -136,13 +162,7 @@ impl IDBObjectStore { + let IDBObjectStoreAbortState { + newly_created_during_transaction, + rollback_indexes_on_abort, +- key_generator_current_number, + } = abort_state; +- let key_generator_current_number = if has_key_generator { +- Some(key_generator_current_number.unwrap_or(1)) +- } else { +- None +- }; + + IDBObjectStore { + reflector_: Reflector::new(), +@@ -153,11 +173,10 @@ impl IDBObjectStore { + newly_created_during_transaction, + rollback_name: None, + rollback_indexes: rollback_indexes_on_abort, +- key_generator_current_number, + })), + transaction: Dom::from_ref(transaction), + has_key_generator, +- key_generator_current_number: Cell::new(key_generator_current_number), ++ cached_key_path: DomRefCell::new(None), + db_name, + } + } +@@ -204,34 +223,55 @@ impl IDBObjectStore { + + // Step 5.2. Set handle’s index set to the set of indexes that reference + // its object store. ++ // Step 6. For each index handle handle associated with transaction, if handle’s ++ // index was not newly created during transaction, set handle’s name to its ++ // index’s name. ++ // ++ // The handles script is already holding have to be the ones that come back, so a ++ // surviving index keeps its `IDBIndex` object and only gets its name restored. An ++ // index created during the transaction leaves the set, and one deleted during it ++ // has no handle left, so it is rebuilt from the metadata the store started with. ++ let handles = self ++ .index_set ++ .borrow() ++ .values() ++ .map(|index| index.as_rooted()) ++ .collect::>(); + self.index_set.borrow_mut().clear(); ++ for handle in handles { ++ if handle.was_newly_created_during_transaction() { ++ continue; ++ } ++ let name = handle.restore_name_after_abort(); ++ self.index_set ++ .borrow_mut() ++ .insert(name, Dom::from_ref(&*handle)); ++ } + for index in abort_state.rollback_indexes { ++ let name: DOMString = index.name.clone().into(); ++ if self.index_set.borrow().contains_key(&name) { ++ continue; ++ } + self.add_index( + cx, +- index.name.clone().into(), ++ name, + &IDBIndexParameters { + multiEntry: index.multi_entry, + unique: index.unique, + }, + index.key_path.clone().into(), ++ false, + ); + } + +- // Restore key generator state for existing object store handles. +- if self.has_key_generator && !abort_state.newly_created_during_transaction { +- self.key_generator_current_number +- .set(abort_state.key_generator_current_number); +- } ++ // The key generator is not restored here. It is durable state that lives in the engine, ++ // and `abort a transaction` rolls it back there. + } + + pub(crate) fn transaction(&self) -> DomRoot { + self.transaction.as_rooted() + } + +- fn get_idb_thread(&self) -> GenericSender { +- self.global().storage_threads().sender() +- } +- + /// + fn clone_value_in_target_realm( + &self, +@@ -240,7 +280,13 @@ impl IDBObjectStore { + clone: MutableHandleValue<'_>, + ) -> Fallible<()> { + // Step 1. Assert: transaction's state is active. +- debug_assert!(self.transaction.is_active()); ++ // ++ // Step 2 below makes the transaction inactive and step 5 makes it active again, which ++ // is what the clone needs; a transaction that was already inactive is simply left ++ // active afterwards, and the release build has always run the clone either way. ++ if !self.transaction.is_active() { ++ warn!("Cloning a value for an object store whose transaction is not active."); ++ } + + // Step 2. Set transaction's state to inactive. + // +@@ -269,52 +315,99 @@ impl IDBObjectStore { + self.has_key_generator + } + +- /// +- fn generate_key_for_put(&self) -> Fallible<(IndexedDBKeyType, i64)> { +- // Step 1. Let generator be store's key generator. +- let Some(current_number) = self.key_generator_current_number.get() else { +- return Err(Error::Data(None)); ++ /// Where this index's key path reaches the store's key path, for a record whose key the ++ /// engine has not generated yet. ++ /// ++ /// Only a `String` key path can belong to a store with a key generator; `createObjectStore` ++ /// refuses `autoIncrement` beside a sequence key path or an empty one. An index has no such ++ /// restriction, so it reaches the store's key path either by naming it outright or by listing ++ /// it among the components of a sequence, and `idbobjectstore_createIndex.any.js` builds both. ++ #[expect(unsafe_code)] ++ fn record_key_reach( ++ &self, ++ cx: &mut JSContext, ++ value: HandleValue, ++ index_key_path: &KeyPath, ++ ) -> Fallible { ++ let Some(KeyPath::String(store_path)) = self.key_path.as_ref() else { ++ return Ok(RecordKeyReach::Untouched); + }; +- // Step 2. Let key be generator's current number. +- let key = current_number as f64; +- // Step 3. If key is greater than 2^53 (9007199254740992), then return failure. +- if key > 9_007_199_254_740_992.0 { +- return Err(Error::Constraint(None)); ++ let KeyPath::StringSequence(components) = index_key_path else { ++ return Ok(match index_key_path { ++ KeyPath::String(index_path) if index_path == store_path => { ++ RecordKeyReach::WholeKey ++ }, ++ _ => RecordKeyReach::Untouched, ++ }); ++ }; ++ if !components.iter().any(|component| component == store_path) { ++ return Ok(RecordKeyReach::Untouched); + } +- // Step 4. Increase generator's current number by 1. +- let next_current_number = current_number +- .checked_add(1) +- .ok_or(Error::Constraint(None))?; +- // Step 5. Return key. +- Ok((IndexedDBKeyType::Number(key), next_current_number)) ++ // A sequence key path evaluates one component at a time and fails as a whole if any one ++ // of them fails, so the components that are not the record's key are extracted here one ++ // by one and a single failure takes the record out of this index. `createIndex` refuses ++ // `multiEntry` beside a sequence key path, so every component is a plain key. ++ let mut extracted = Vec::with_capacity(components.len()); ++ for component in components { ++ if component == store_path { ++ extracted.push(None); ++ continue; ++ } ++ let result = extract_key(cx, value, &KeyPath::String(component.clone()), Some(false)); ++ match result { ++ Ok(ExtractionResult::Key(key)) => extracted.push(Some(key)), ++ Ok(ExtractionResult::Invalid | ExtractionResult::Failure) => { ++ return Ok(RecordKeyReach::Dropped); ++ }, ++ // An exception thrown while evaluating one component takes the record out of the ++ // index the same way a failure does, and the pending exception has to go with it. ++ Err(_) => { ++ unsafe { JS_ClearPendingException(cx) }; ++ return Ok(RecordKeyReach::Dropped); ++ }, ++ } ++ } ++ Ok(RecordKeyReach::InSequence(extracted)) + } + +- /// +- fn possibly_update_the_key_generator(&self, key: &IndexedDBKeyType) -> Option { +- // Step 1. If the type of key is not number, abort these steps. +- let IndexedDBKeyType::Number(number) = key else { +- return None; ++ /// Put the record's key back into a value the store never wrote it into. ++ /// ++ /// A store with a key generator and an in-line key path has its keys generated in the engine, ++ /// where there is no JavaScript context to inject one with, so the stored value does not carry ++ /// its key. Every path that turns a stored value back into a JavaScript value runs this first. ++ /// ++ /// Records written before the generator moved to the engine do carry their key. The extraction ++ /// below is what tells the two apart, so nothing has to be migrated: a value that answers its ++ /// own key path is handed back exactly as it was stored. ++ #[expect(unsafe_code)] ++ pub(crate) fn inject_record_key_if_absent( ++ &self, ++ cx: &mut JSContext, ++ value: HandleValue, ++ key: &IndexedDBKeyType, ++ ) -> Fallible<()> { ++ let Some(KeyPath::String(key_path)) = self.key_path.as_ref() else { ++ return Ok(()); + }; +- +- // Step 2. Let value be the value of key. +- let mut value = *number; +- // Step 3. Set value to the minimum of value and 2^53 (9007199254740992). +- value = value.min(9_007_199_254_740_992.0); +- // Step 4. Set value to the largest integer not greater than value. +- value = value.floor(); +- // Step 5. Let generator be store's key generator. +- let current_number = self.key_generator_current_number.get()?; +- // Step 6. If value is greater than or equal to generator's current number, +- // then set generator's current number to value + 1. +- if value < current_number as f64 { +- return None; +- } +- +- let next = value + 1.0; +- if next > i64::MAX as f64 { +- return Some(i64::MAX); ++ let key_path = key_path.clone(); ++ match extract_key(cx, value, &KeyPath::String(key_path.clone()), None) { ++ // The value has no key at its key path, so this record was stored without one. ++ Ok(ExtractionResult::Failure) => {}, ++ // The value answers its key path already, or answers it with something that is not a ++ // key. Either way the stored value is what script asked for. ++ Ok(_) => return Ok(()), ++ // A getter on the key path threw. The record is handed back unchanged rather than ++ // failing the read, and the pending exception has to go with it or the next ++ // JavaScript call on this context would inherit it. ++ Err(_) => { ++ unsafe { JS_ClearPendingException(cx) }; ++ return Ok(()); ++ }, + } +- Some(next as i64) ++ // A false answer means the value cannot hold a key at this path, which leaves it as it ++ // was stored. `put` has already refused the values that could not take one. ++ inject_key_into_value(cx, value, key, &key_path)?; ++ Ok(()) + } + + /// +@@ -361,6 +454,302 @@ impl IDBObjectStore { + Ok(()) + } + ++ /// ++ /// Step 12's operation, first half: read back every record the store already holds. ++ /// ++ /// A new index has to hold a record for each of them, and the index key comes out of the ++ /// JavaScript value the key path is evaluated against. Only the script thread holds that ++ /// value, so the records make a round trip: out through this read, back through ++ /// [`Self::finish_index_backfill`] as index keys. ++ /// ++ /// `store_name` is the name the backend knows the store by. It is passed in rather than ++ /// read from the handle, because the round trip outlives the call that started it and a ++ /// rename placed in the same upgrade transaction changes the handle's name while the ++ /// backend is still holding the old one. ++ pub(crate) fn start_index_backfill( ++ &self, ++ cx: &mut JSContext, ++ store_name: &str, ++ index_name: &str, ++ key_path: &indexeddb::KeyPath, ++ multi_entry: bool, ++ ) -> Fallible<()> { ++ IDBRequest::execute_backfill_operation::, _>( ++ cx, ++ self, ++ store_name, ++ BackfillHalf::Read, ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: IndexedDBKeyRange::default(), ++ count: None, ++ shape: RecordsShape::WithValues, ++ }) ++ }, ++ Some(RecordsParam::IndexBackfill { ++ store_name: store_name.to_owned(), ++ index_name: index_name.to_owned(), ++ key_path: key_path.clone(), ++ multi_entry, ++ }), ++ )?; ++ Ok(()) ++ } ++ ++ /// ++ /// Step 12's operation, second half: evaluate the index's key path against each record and ++ /// send the extracted keys out as the write that populates the index. ++ /// ++ /// The uniqueness check stays in the backend, which is where the index records land and the ++ /// only place that can see a collision with a row that arrived some other way. ++ #[expect(unsafe_code)] ++ pub(crate) fn finish_index_backfill( ++ &self, ++ cx: &mut JSContext, ++ store_name: &str, ++ index_name: &str, ++ key_path: &indexeddb::KeyPath, ++ multi_entry: bool, ++ records: Vec, ++ ) -> Fallible<()> { ++ // The key path and the multiEntry flag arrive with the records rather than being read ++ // back off the index set, because a `deleteIndex` placed after the `createIndex` has ++ // already taken the index off this handle by the time they come back. The write still ++ // belongs ahead of that delete on the wire, and the backend drops the records with the ++ // index when the delete reaches it. ++ let key_path = KeyPath::from(key_path.clone()); ++ let global = self.global(); ++ let mut entries = Vec::with_capacity(records.len()); ++ for record in records { ++ rooted!(&in(cx) let mut value = NullValue()); ++ let data = postcard::from_bytes(&record.value).map_err(|_| Error::Data(None))?; ++ structuredclone::read(cx, &global, data, value.handle_mut())?; ++ // A record of a store that generates keys into an in-line key path was stored ++ // without its key. The index is built over the value script would be handed, so the ++ // key goes back in first, and an index on the store's own key path finds it. ++ self.inject_record_key_if_absent(cx, value.handle(), &record.primary_key)?; ++ // Step 6 of `store a record into an object store`, run here against a value the ++ // store is already holding rather than one being written. ++ let extracted = match extract_key(cx, value.handle(), &key_path, Some(multi_entry)) { ++ Ok(extracted) => extracted, ++ // An exception thrown while extracting an index key takes the record out of ++ // the index rather than failing the operation. The pending exception has to go ++ // with it, or the next JavaScript call on this context would inherit it. ++ Err(_) => { ++ unsafe { JS_ClearPendingException(cx) }; ++ continue; ++ }, ++ }; ++ let keys = match extracted { ++ ExtractionResult::Key(IndexedDBKeyType::Array(elements)) if multi_entry => elements, ++ ExtractionResult::Key(key) => vec![key], ++ ExtractionResult::Invalid | ExtractionResult::Failure => continue, ++ }; ++ entries.push(IndexBackfillEntry { ++ primary_key: record.primary_key, ++ keys, ++ }); ++ } ++ ++ IDBRequest::execute_backfill_operation::( ++ cx, ++ self, ++ store_name, ++ BackfillHalf::Write, ++ |callback| { ++ AsyncOperation::ReadWrite(AsyncReadWriteOperation::BackfillIndex { ++ callback, ++ index_name: index_name.to_owned(), ++ entries, ++ }) ++ }, ++ None, ++ )?; ++ Ok(()) ++ } ++ ++ /// The index records this store's declared indexes produce for `value`, one update per index. ++ /// ++ /// This is steps 6.1 through 6.4 of "store a record into an object store". Only the script ++ /// thread can run them: an index key is extracted by evaluating a key path against the ++ /// JavaScript value, which never crosses to the storage thread. The backend receives the ++ /// extracted keys and writes the index records from those alone. ++ /// ++ /// An index whose key path does not evaluate against the value contributes nothing, which is ++ /// what makes an index sparse. A multiEntry index contributes one record per distinct element ++ /// of its extracted array key; every other index contributes one record. Uniqueness is left ++ /// to the backend, which is the only place that can see the records already stored. ++ /// ++ /// `key_comes_from_the_generator` says the engine has not chosen this record's key yet, so ++ /// the value does not carry it. An index that reaches the store's key path indexes the record ++ /// under that key, so extracting it from this value fails. Those indexes carry a ++ /// `RecordKeyPlacement` rather than a key, and the engine fills the hole once it has ++ /// generated one. `record_key_reach` is what tells the cases apart. ++ #[expect(unsafe_code)] ++ fn extract_index_updates( ++ &self, ++ cx: &mut JSContext, ++ value: HandleValue, ++ key_comes_from_the_generator: bool, ++ ) -> Fallible> { ++ // Extraction runs JavaScript getters, which can reenter this object store, so the index ++ // set is snapshotted here rather than held borrowed across any of it. ++ let indexes = self ++ .index_set ++ .borrow() ++ .values() ++ .map(|index| { ++ ( ++ index.index_name(), ++ index.index_key_path().clone(), ++ index.is_multi_entry(), ++ ) ++ }) ++ .collect::>(); ++ let mut updates = Vec::with_capacity(indexes.len()); ++ for (index_name, key_path, multi_entry) in indexes { ++ // An index that reaches the store's key path indexes the record under a key that is ++ // not in this value, so it is answered before extraction rather than by it. ++ let reach = if key_comes_from_the_generator { ++ self.record_key_reach(cx, value, &key_path)? ++ } else { ++ RecordKeyReach::Untouched ++ }; ++ let placement = match reach { ++ // Step 6.2. A component that did not evaluate to a valid key takes the record out ++ // of this index, the same as any other extraction failure. ++ RecordKeyReach::Dropped => continue, ++ RecordKeyReach::WholeKey => Some(RecordKeyPlacement::WholeKey), ++ RecordKeyReach::InSequence(components) => { ++ Some(RecordKeyPlacement::InSequence(components)) ++ }, ++ RecordKeyReach::Untouched => None, ++ }; ++ if let Some(placement) = placement { ++ updates.push(KvsIndexUpdate { ++ index_name, ++ keys: Vec::new(), ++ record_key_placement: Some(placement), ++ }); ++ continue; ++ } ++ // Step 6.1. Let index key be the result of extracting a key from a value using a key ++ // path with value, index's key path, and index's multiEntry flag. ++ let extracted = match extract_key(cx, value, &key_path, Some(multi_entry)) { ++ Ok(extracted) => extracted, ++ // Step 6.2. An exception thrown while extracting an index key takes the record ++ // out of that index rather than failing the put, so it is discarded here. The ++ // pending exception has to go with it, or the next JavaScript call on this ++ // context would inherit it. ++ Err(_) => { ++ unsafe { JS_ClearPendingException(cx) }; ++ continue; ++ }, ++ }; ++ let keys = match extracted { ++ // Step 6.4. A multiEntry index whose key is an array key stores one record per ++ // element; `convert_value_to_multientry_key` has already dropped the duplicates. ++ ExtractionResult::Key(IndexedDBKeyType::Array(elements)) if multi_entry => elements, ++ // Step 6.3. Otherwise the whole extracted key is the one index key. ++ ExtractionResult::Key(key) => vec![key], ++ // Step 6.2. Invalid or failure leaves the record out of this index. ++ ExtractionResult::Invalid | ExtractionResult::Failure => continue, ++ }; ++ updates.push(KvsIndexUpdate { ++ index_name, ++ keys, ++ record_key_placement: None, ++ }); ++ } ++ Ok(updates) ++ } ++ ++ /// ++ /// ++ /// The cursor write path. `IDBCursor.update` has already decided the key: it is the ++ /// cursor's effective key, and a record is already stored under it. None of the key ++ /// generator, out-of-line key, or key injection machinery in `put` applies, which is why ++ /// this is a sibling of `put` rather than another flag through it. What does apply is the ++ /// clone, the in-line key path equality check, and index record extraction. ++ pub(crate) fn store_record_with_known_key( ++ &self, ++ cx: &mut JSContext, ++ source: RequestSource, ++ value: HandleValue, ++ key: &IndexedDBKeyType, ++ ) -> Fallible> { ++ // update() Step 8. Let clone be a clone of value in targetRealm during transaction. ++ rooted!(&in(cx) let mut cloned_js_value = NullValue()); ++ self.clone_value_in_target_realm(cx, value, cloned_js_value.handle_mut())?; ++ ++ // update() Step 9. If the effective object store uses in-line keys, then the key ++ // extracted from the clone has to equal the key the record is stored under. A record ++ // cannot be moved by rewriting its own key. ++ if let Some(key_path) = self.key_path.as_ref() { ++ match extract_key(cx, cloned_js_value.handle(), key_path, None)? { ++ ExtractionResult::Key(extracted_key) if &extracted_key == key => {}, ++ _ => return Err(Error::Data(None)), ++ } ++ } ++ ++ let cloned_value = structuredclone::write(cx, cloned_js_value.handle(), None)?; ++ let Ok(serialized_value) = postcard::to_stdvec(&cloned_value) else { ++ return Err(Error::InvalidState(None)); ++ }; ++ ++ // Storing a record also rebuilds its index records, so they are extracted from the ++ // finished clone and travel with the operation, exactly as they do for `put`. ++ // The cursor is positioned on a record that already has a key, so no index key waits on ++ // the generator here. ++ let index_updates = self.extract_index_updates(cx, cloned_js_value.handle(), false)?; ++ IDBRequest::execute_async_from_source( ++ cx, ++ self, ++ source, ++ KvsOperationContext { ++ target: KvsOperationTarget::ObjectStore, ++ index_updates, ++ }, ++ |callback| { ++ AsyncOperation::ReadWrite(AsyncReadWriteOperation::PutItem { ++ callback, ++ key: Some(key.clone()), ++ value: serialized_value, ++ should_overwrite: true, ++ }) ++ }, ++ None, ++ None, ++ ) ++ } ++ ++ /// ++ /// ++ /// The cursor delete path. The range is the one effective key the cursor is positioned on, ++ /// so there is no query value to convert and nothing left to reject. ++ pub(crate) fn delete_record_with_known_key( ++ &self, ++ cx: &mut JSContext, ++ source: RequestSource, ++ key: &IndexedDBKeyType, ++ ) -> Fallible> { ++ IDBRequest::execute_async_from_source( ++ cx, ++ self, ++ source, ++ KvsOperationContext::default(), ++ |callback| { ++ AsyncOperation::ReadWrite(AsyncReadWriteOperation::RemoveItem { ++ callback, ++ key_range: IndexedDBKeyRange::only(key.clone()), ++ }) ++ }, ++ None, ++ None, ++ ) ++ } ++ + /// + fn put( + &self, +@@ -392,7 +781,8 @@ impl IDBObjectStore { + + // Step 8. If key was given, then: + let mut serialized_key = None; +- let mut key_generator_current_number_for_put = None; ++ // Whether the engine is the one that decides this record's key. ++ let mut key_comes_from_the_generator = false; + + if !key.is_undefined() { + // Step 8.1. Let r be the result of converting a value to a key with key. +@@ -402,7 +792,9 @@ impl IDBObjectStore { + // "DataError" DOMException. + // Handled by `into_result()` above. + // Step 8.3. Let key be r. +- key_generator_current_number_for_put = self.possibly_update_the_key_generator(&key); ++ // ++ // `possibly update the key generator` is not run here. It runs in the engine when ++ // this key arrives, against the durable generator rather than against a copy of it. + serialized_key = Some(key); + } + +@@ -422,8 +814,6 @@ impl IDBObjectStore { + ExtractionResult::Invalid => return Err(Error::Data(None)), + // Step 11.3. If kpk is not failure, let key be kpk. + ExtractionResult::Key(kpk) => { +- key_generator_current_number_for_put = +- self.possibly_update_the_key_generator(&kpk); + serialized_key = Some(kpk); + }, + // Step 11.4. Otherwise (kpk is failure): +@@ -443,19 +833,22 @@ impl IDBObjectStore { + return Err(Error::Data(None)); + } + +- // Prepares the generated key and injected clone here so Step 12 can +- // pass the final key/value pair to the storage backend. +- let (generated_key, next_current_number) = self.generate_key_for_put()?; +- if !inject_key_into_value( +- cx, +- cloned_js_value.handle(), +- &generated_key, +- key_path, +- )? { +- return Err(Error::Data(None)); +- } +- serialized_key = Some(generated_key); +- key_generator_current_number_for_put = Some(next_current_number); ++ // `generate a key` runs in the engine, and the key is therefore not ++ // injected into the clone here. ++ // ++ // It has to run there. The generator is durable state, and ++ // requires ++ // that an insertion refused by a constraint leave it alone. Only the ++ // engine knows whether the requests queued ahead of this one kept their ++ // keys, and it knows it too late to help here: in ++ // `request-event-ordering-small-values` the refusal is answered six ++ // requests after this one is queued. ++ // ++ // The key is injected on the way back out instead, by ++ // `inject_record_key_if_absent`, which every path that turns a stored ++ // value back into a JavaScript value runs. ++ serialized_key = None; ++ key_comes_from_the_generator = true; + }, + } + +@@ -468,26 +861,30 @@ impl IDBObjectStore { + }; + // Step 12. Let operation be an algorithm to run store a record into an object store with + // store, clone, key, and no-overwrite flag. +- let request = IDBRequest::execute_async( ++ // ++ // Storing a record also builds its index records, so the index keys are extracted from ++ // the finished clone, after any generated key has been injected into it, and travel with ++ // the operation. ++ let index_updates = ++ self.extract_index_updates(cx, cloned_js_value.handle(), key_comes_from_the_generator)?; ++ let request = IDBRequest::execute_async_with_context( + cx, + self, ++ KvsOperationContext { ++ target: KvsOperationTarget::ObjectStore, ++ index_updates, ++ }, + |callback| { + AsyncOperation::ReadWrite(AsyncReadWriteOperation::PutItem { + callback, + key: serialized_key, + value: serialized_value, + should_overwrite: !no_overwrite, +- key_generator_current_number: key_generator_current_number_for_put, + }) + }, + None, + None, + )?; +- // Keep the in-memory key generator in sync with the queued put request. +- if let Some(next_key_generator_current_number) = key_generator_current_number_for_put { +- self.key_generator_current_number +- .set(Some(next_key_generator_current_number)); +- } + // Step 13. Return the result (an IDBRequest) of running asynchronously execute a request + // with handle and operation. + Ok(request) +@@ -566,10 +963,12 @@ impl IDBObjectStore { + AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { + callback, + key_range: range, ++ count: None, ++ shape: RecordsShape::WithValues, + }) + }, + None, +- Some(iteration_param), ++ Some(RecordsParam::Cursor(iteration_param)), + ) + .inspect(|request| cursor.set_request(request)) + } +@@ -580,6 +979,7 @@ impl IDBObjectStore { + name: DOMString, + options: &IDBIndexParameters, + key_path: KeyPath, ++ newly_created_during_transaction: bool, + ) -> DomRoot { + let index = IDBIndex::new( + cx, +@@ -589,6 +989,7 @@ impl IDBObjectStore { + options.multiEntry, + options.unique, + key_path, ++ newly_created_during_transaction, + ); + self.index_set + .borrow_mut() +@@ -596,42 +997,56 @@ impl IDBObjectStore { + index + } + ++ /// ++ /// Step 6: remove every entry from the handle's index set. An aborted upgrade puts ++ /// them back through `restore_metadata_after_abort`. ++ pub(crate) fn clear_index_set(&self) { ++ self.index_set.borrow_mut().clear(); ++ } ++ + pub(crate) fn has_index(&self, name: &DOMString) -> bool { + self.index_set.borrow().contains_key(name) + } + + /// The caller must ensure that the original index exists. +- pub(crate) fn rename_index(&self, name: &DOMString, new_name: &DOMString) { ++ pub(crate) fn rename_index(&self, name: &DOMString, new_name: &DOMString) -> ErrorResult { ++ let index = self ++ .index_set ++ .borrow() ++ .get(name) ++ .map(|index| index.as_rooted()) ++ .ok_or_else(|| { ++ warn!("rename_index called for an index that is not in the index set"); ++ Error::InvalidState(Some( ++ "The index to rename is no longer in its object store".to_owned(), ++ )) ++ })?; + let operation = AsyncSchemaOperation::RenameIndex { +- callback: self.transaction.create_abort_callback(), ++ callback: self.transaction.create_abort_callback()?, + index_name: name.to_string(), + new_name: new_name.to_string(), + }; +- +- if self +- .get_idb_thread() +- .send(IndexedDBThreadMsg::AsyncSchemaOperation { ++ self.transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { + origin: self.global().origin().immutable().clone(), + database_name: self.db_name.to_string(), + store_name: self.name.borrow().clone().into(), + operation, + transaction_serial_number: self.transaction.get_serial_number(), + }) +- .is_err() +- { +- warn!("Could not send AsyncSchemaOperation"); ++ .map_err(|()| { ++ warn!("Could not send RenameIndex to the IndexedDB backend"); ++ Error::Operation(Some("Could not send the rename index operation".to_owned())) ++ })?; ++ ++ // We also need to update the key in the index set. ++ if self.index_set.borrow_mut().remove(name).is_none() { ++ warn!("The index disappeared while its backend rename was being queued"); + } +- +- // We also need to update the key in the index set +- let index = self +- .index_set +- .borrow_mut() +- .remove(name) +- .expect("Earlier steps of the algorithm checked that the index exists") +- .as_rooted(); + self.index_set + .borrow_mut() + .insert(new_name.clone(), Dom::from_ref(&index)); ++ Ok(()) + } + } + +@@ -780,76 +1195,110 @@ impl IDBObjectStoreMethods for IDBObjectStore { + fn GetAll( + &self, + cx: &mut JSContext, +- query: HandleValue, ++ query_or_options: HandleValue, + count: Option, + ) -> Fallible> { +- // Step 1. Let transaction be this’s transaction. ++ // Step 1. Let transaction be this's transaction. + // Step 2. Let store be this's object store. + // Step 3. If store has been deleted, throw an "InvalidStateError" DOMException. + self.verify_not_deleted()?; + +- // Step 4. If transaction’s state is not active, then throw a "TransactionInactiveError" DOMException. ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. + self.check_transaction_active()?; + +- // Step 5. Let range be the result of converting a value to a key range with query and true. Rethrow any exceptions. +- let serialized_query = convert_value_to_key_range(cx, query, None); ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::resolve(cx, GetAllKind::Values, query_or_options, count)?; + +- // Step 6. Run the steps to asynchronously execute a request and return the IDBRequest created by these steps. +- // The steps are run with this object store handle as source and the steps to retrieve a key from an object +- // store as operation, using store and range. +- serialized_query.and_then(|q| { +- IDBRequest::execute_async( +- cx, +- self, +- |callback| { +- AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetAllItems { +- callback, +- key_range: q, +- count, +- }) +- }, +- None, +- None, +- ) +- }) ++ // Step 10 to 13. Run retrieve multiple records from an object store as the operation of ++ // an asynchronously executed request. ++ IDBRequest::execute_async( ++ cx, ++ self, ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::Values.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) ++ } ++ ++ /// ++ fn GetAllRecords( ++ &self, ++ cx: &mut JSContext, ++ options: RootedTraceableBox, ++ ) -> Fallible> { ++ // Step 1. Let transaction be this's transaction. ++ // Step 2. Let store be this's object store. ++ // Step 3. If store has been deleted, throw an "InvalidStateError" DOMException. ++ self.verify_not_deleted()?; ++ ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. ++ self.check_transaction_active()?; ++ ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::from_options(cx, GetAllKind::Records, &options)?; ++ ++ // Step 10 to 13. Run retrieve multiple records from an object store as the operation of ++ // an asynchronously executed request. ++ IDBRequest::execute_async( ++ cx, ++ self, ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::Records.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) + } + + /// + fn GetAllKeys( + &self, + cx: &mut JSContext, +- query: HandleValue, ++ query_or_options: HandleValue, + count: Option, + ) -> Fallible> { +- // Step 1. Let transaction be this’s transaction. ++ // Step 1. Let transaction be this's transaction. + // Step 2. Let store be this's object store. + // Step 3. If store has been deleted, throw an "InvalidStateError" DOMException. + self.verify_not_deleted()?; + +- // Step 4. If transaction’s state is not active, then throw a "TransactionInactiveError" DOMException. ++ // Step 4. If transaction's state is not active, then throw a ++ // "TransactionInactiveError" DOMException. + self.check_transaction_active()?; + +- // Step 5. Let range be the result of converting a value to a key range with query and true. Rethrow any exceptions. +- let serialized_query = convert_value_to_key_range(cx, query, None); ++ // Steps 6 to 9. Resolve the range, the direction and the count the read may apply. ++ let request = GetAllRequest::resolve(cx, GetAllKind::PrimaryKeys, query_or_options, count)?; + +- // Step 6. Run the steps to asynchronously execute a request and return the IDBRequest created by these steps. +- // The steps are run with this object store handle as source and the steps to retrieve a key from an object +- // store as operation, using store and range. +- serialized_query.and_then(|q| { +- IDBRequest::execute_async( +- cx, +- self, +- |callback| { +- AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetAllKeys { +- callback, +- key_range: q, +- count, +- }) +- }, +- None, +- None, +- ) +- }) ++ // Step 10 to 13. Run retrieve multiple records from an object store as the operation of ++ // an asynchronously executed request. ++ IDBRequest::execute_async( ++ cx, ++ self, ++ |callback| { ++ AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { ++ callback, ++ key_range: request.key_range, ++ count: request.count, ++ shape: GetAllKind::PrimaryKeys.shape(), ++ }) ++ }, ++ None, ++ Some(request.records_param), ++ ) + } + + /// +@@ -939,13 +1388,37 @@ impl IDBObjectStoreMethods for IDBObjectStore { + } + + let old_name = self.name.borrow().clone(); ++ ++ // Step 9. Set store’s name to name. ++ // The store also has to be renamed in the backend, which keys a store's rows and ++ // every later request against it by name. Without this the rename lived only on ++ // the handle, and the first request issued after the upgrade transaction ++ // committed failed against a store the backend still held under the old name. ++ let operation = AsyncSchemaOperation::RenameObjectStore { ++ callback: self.transaction.create_abort_callback()?, ++ new_name: name.to_string(), ++ }; ++ self.transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { ++ origin: self.global().origin().immutable().clone(), ++ database_name: self.db_name.to_string(), ++ store_name: old_name.to_string(), ++ operation, ++ transaction_serial_number: self.transaction.get_serial_number(), ++ }) ++ .map_err(|()| { ++ warn!("Could not send RenameObjectStore to the IndexedDB backend"); ++ Error::Operation(Some( ++ "Could not send the rename object store operation".to_owned(), ++ )) ++ })?; ++ + if let Some(abort_state) = self.abort_state_on_abort.borrow_mut().as_mut() && + abort_state.rollback_name.is_none() + { + abort_state.rollback_name = Some(old_name.clone()); + } + +- // Step 9. Set store’s name to name. + transaction + .Db() + .rename_object_store_name(&old_name, name.clone()); +@@ -957,11 +1430,29 @@ impl IDBObjectStoreMethods for IDBObjectStore { + + /// + fn KeyPath(&self, cx: &mut JSContext, mut ret_val: MutableHandleValue) { ++ // A sequence key path converts to a fresh Array on every call, so converting on ++ // each read would hand script a different object each time it looked. The value is ++ // converted once and kept; `idbobjectstore_keyPath.any.js` asserts both halves of ++ // that, that one store answers with the same object and that two store handles onto ++ // the same store answer with different ones. ++ if let Some(cached) = self.cached_key_path.borrow().as_ref() { ++ ret_val.set(cached.get()); ++ return; ++ } ++ + match &self.key_path { +- Some(KeyPath::String(path)) => path.safe_to_jsval(cx, ret_val), +- Some(KeyPath::StringSequence(paths)) => paths.safe_to_jsval(cx, ret_val), ++ Some(KeyPath::String(path)) => path.safe_to_jsval(cx, ret_val.reborrow()), ++ Some(KeyPath::StringSequence(paths)) => paths.safe_to_jsval(cx, ret_val.reborrow()), + None => ret_val.set(NullValue()), + } ++ ++ // The `Heap` is stored before it is set: `Heap::set` registers the slot's own ++ // address with the GC store buffer, so the value has to be written where it will ++ // live rather than moved in afterwards. ++ *self.cached_key_path.borrow_mut() = Some(Heap::default()); ++ if let Some(cached) = self.cached_key_path.borrow().as_ref() { ++ cached.set(ret_val.get()); ++ } + } + + /// +@@ -1022,30 +1513,53 @@ impl IDBObjectStoreMethods for IDBObjectStore { + // Step 11. Let index be a new index in store. + // Set index’s name to name and key path to keyPath. If unique is set, set index’s unique flag. + // If multiEntry is set, set index’s multiEntry flag. ++ let stored_key_path: indexeddb::KeyPath = key_path.clone().into(); + let operation = AsyncSchemaOperation::CreateIndex { +- callback: self.transaction.create_abort_callback(), ++ callback: self.transaction.create_abort_callback()?, + index_name: name.to_string(), +- key_path: key_path.clone().into(), ++ key_path: stored_key_path.clone(), + unique: options.unique, + multi_entry: options.multiEntry, + }; + +- if self +- .get_idb_thread() +- .send(IndexedDBThreadMsg::AsyncSchemaOperation { ++ self.transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { + origin: self.global().origin().immutable().clone(), + database_name: self.db_name.to_string(), + store_name: self.name.borrow().clone().into(), + operation, + transaction_serial_number: self.transaction.get_serial_number(), + }) +- .is_err() +- { +- return Err(Error::Operation(None)); +- } ++ .map_err(|()| { ++ warn!("Could not send CreateIndex to the IndexedDB backend"); ++ Error::Operation(Some("Could not send the create index operation".to_owned())) ++ })?; + + // Step 12. Add index to this object store handle's index set. +- let index = self.add_index(cx, name, options, key_path); ++ let index = self.add_index(cx, name.clone(), options, key_path, true); ++ ++ // Step 11's operation: the index has to hold a record for every record the store ++ // already has, and the index keys come out of the stored JavaScript values, so the ++ // records make a round trip through the script thread. The read goes out here, in the ++ // place in the outbound queue this `createIndex` occupies, and the transaction then ++ // holds everything script places after it: without the hold a later request would ++ // reach the backend first and be ordered ahead of the index records, so the index ++ // would read as empty right after it was made. ++ // ++ // The store name is captured now rather than when the round trip finishes. It names ++ // the store the backend has, and a rename placed later in this same transaction is ++ // held behind the round trip, so the backend still knows the store by this name when ++ // the backfill's write lands. ++ let store_name = self.name.borrow().to_string(); ++ let index_name = name.to_string(); ++ self.start_index_backfill( ++ cx, ++ &store_name, ++ &index_name, ++ &stored_key_path, ++ index.is_multi_entry(), ++ )?; ++ self.transaction.hold_outbound_after_backfill(); + + // Step 13. Return a new index handle associated with index and this object store handle. + Ok(index) +@@ -1066,26 +1580,27 @@ impl IDBObjectStoreMethods for IDBObjectStore { + if !self.index_set.borrow().contains_key(&name) { + return Err(Error::NotFound(None)); + } +- // Step 7. Remove index from this object store handle's index set. +- self.index_set.borrow_mut().retain(|n, _| n != &name); + // Step 8. Destroy index. + let operation = AsyncSchemaOperation::DeleteIndex { +- callback: self.transaction.create_abort_callback(), ++ callback: self.transaction.create_abort_callback()?, + index_name: name.to_string(), + }; +- if self +- .get_idb_thread() +- .send(IndexedDBThreadMsg::AsyncSchemaOperation { ++ self.transaction ++ .send_or_hold(IndexedDBThreadMsg::AsyncSchemaOperation { + origin: self.global().origin().immutable().clone(), + database_name: self.db_name.to_string(), + store_name: self.name.borrow().clone().into(), + operation, + transaction_serial_number: self.transaction.get_serial_number(), + }) +- .is_err() +- { +- return Err(Error::Operation(None)); +- } ++ .map_err(|()| { ++ warn!("Could not send DeleteIndex to the IndexedDB backend"); ++ Error::Operation(Some("Could not send the delete index operation".to_owned())) ++ })?; ++ ++ // Step 7. Remove index from this object store handle's index set only once the backend ++ // operation is guaranteed to be queued. ++ self.index_set.borrow_mut().retain(|n, _| n != &name); + Ok(()) + } + +diff --git a/components/script/dom/indexeddb/idbopendbrequest.rs b/components/script/dom/indexeddb/idbopendbrequest.rs +index c55e6576ca..54c47cee55 100644 +--- a/components/script/dom/indexeddb/idbopendbrequest.rs ++++ b/components/script/dom/indexeddb/idbopendbrequest.rs +@@ -12,7 +12,7 @@ use script_bindings::reflector::reflect_dom_object_with_cx; + use servo_base::generic_channel::GenericSend; + use servo_url::origin::ImmutableOrigin; + use storage_traits::client_storage::StorageProxyMap; +-use storage_traits::indexeddb::{BackendResult, IndexedDBThreadMsg, SyncOperation}; ++use storage_traits::indexeddb::{DeleteDatabaseMsg, IndexedDBThreadMsg, SyncOperation}; + use stylo_atoms::Atom; + use uuid::Uuid; + +@@ -30,7 +30,7 @@ use crate::dom::indexeddb::idbdatabase::IDBDatabase; + use crate::dom::indexeddb::idbrequest::IDBRequest; + use crate::dom::indexeddb::idbtransaction::IDBTransaction; + use crate::dom::indexeddb::idbversionchangeevent::IDBVersionChangeEvent; +-use crate::indexeddb::map_backend_error_to_dom_error; ++use crate::indexeddb::{map_backend_error_to_dom_error, reply_lost}; + use crate::realms::enter_auto_realm; + + #[derive(Clone)] +@@ -41,7 +41,21 @@ struct OpenRequestListener { + impl OpenRequestListener { + /// The continuation of the parallel steps of + /// +- fn handle_delete_db(&self, cx: &mut JSContext, result: BackendResult) { ++ fn handle_delete_db(&self, cx: &mut JSContext, message: DeleteDatabaseMsg) { ++ let result = match message { ++ // ++ // Step 8: fire a version change event named blocked at request with db's ++ // version and null. The request is not done, so the delete continues. ++ DeleteDatabaseMsg::Blocked { old_version } => { ++ let open_request = self.open_request.root(); ++ let mut realm = enter_auto_realm(cx, &*open_request); ++ let cx = &mut realm.current_realm(); ++ open_request.dispatch_blocked(cx, old_version, None); ++ return; ++ }, ++ DeleteDatabaseMsg::Done(result) => result, ++ }; ++ + // Step 4.1: Let result be the result of deleting a database, with storageKey, name, and request. + // Note: done with the `result` argument. + +@@ -127,12 +141,6 @@ impl IDBOpenDBRequest { + self.id + } + +- pub(crate) fn connection(&self) -> DomRoot { +- self.pending_connection +- .get() +- .expect("A connection should exist for the db.") +- } +- + pub(crate) fn get_or_init_connection( + &self, + cx: &mut JSContext, +@@ -143,7 +151,12 @@ impl IDBOpenDBRequest { + upgraded: bool, + ) -> DomRoot { + self.pending_connection.or_init(|| { +- debug_assert!(!upgraded, "A connection should exist for the upgraded db."); ++ // An upgraded database was opened by an earlier ConnectionMsg::Upgrade, which ++ // initialised the connection. Creating one here anyway is what the release build ++ // has always done, and it is still a usable connection for this request. ++ if upgraded { ++ warn!("A connection should exist for the upgraded db."); ++ } + IDBDatabase::new( + cx, + global, +@@ -249,10 +262,24 @@ impl IDBOpenDBRequest { + let callback = GenericCallback::new(global.time_profiler_chan().clone(), move |message| { + let response_listener = response_listener.clone(); + task_source.queue(task!(request_callback: move |cx| { +- response_listener.handle_delete_db(cx, message.unwrap()); ++ // A delete whose answer was lost is a delete that failed as far as script ++ // can tell, and the request is there to say so. ++ let message = message.unwrap_or_else(|error| { ++ DeleteDatabaseMsg::Done(Err(reply_lost(error))) ++ }); ++ response_listener.handle_delete_db(cx, message); + })) +- }) +- .expect("Could not create delete database callback"); ++ }); ++ let callback = match callback { ++ Ok(callback) => callback, ++ Err(error) => { ++ // Without a reply channel the delete cannot be asked for at all, which is the ++ // same failure as a send the storage thread never took; `deleteDatabase()` ++ // turns this `Err` into an "UnknownError" DOMException. ++ warn!("Could not create the IndexedDB delete database callback: {error:?}"); ++ return Err(()); ++ }, ++ }; + + let delete_operation = + SyncOperation::DeleteDatabase(callback, storage_key, name, proxy_map, self.get_id()); +diff --git a/components/script/dom/indexeddb/idbrecord.rs b/components/script/dom/indexeddb/idbrecord.rs +new file mode 100644 +index 0000000000..8f452b45b8 +--- /dev/null ++++ b/components/script/dom/indexeddb/idbrecord.rs +@@ -0,0 +1,100 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use js::jsapi::Heap; ++use js::jsval::{JSVal, UndefinedValue}; ++use js::rust::MutableHandleValue; ++use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; ++use storage_traits::indexeddb::IndexedDBRecord; ++ ++use crate::dom::bindings::codegen::Bindings::IDBRecordBinding::IDBRecordMethods; ++use crate::dom::bindings::error::{Error, Fallible}; ++use crate::dom::bindings::root::DomRoot; ++use crate::dom::bindings::structuredclone; ++use crate::dom::globalscope::GlobalScope; ++use crate::dom::indexeddb::idbobjectstore::IDBObjectStore; ++use crate::indexeddb::key_type_to_jsval; ++ ++/// ++/// ++/// One entry of a `getAllRecords` result. The three attributes are converted once, when the ++/// record is built, rather than on each read: the spec says each getter returns the same object ++/// every time it is inspected, and converting eagerly is also what lets the getters be ++/// infallible, because the allocation that can fail has already happened where a failed request ++/// can still report it. ++#[dom_struct] ++pub(crate) struct IDBRecord { ++ reflector_: Reflector, ++ /// ++ #[ignore_malloc_size_of = "mozjs"] ++ key: Heap, ++ /// ++ #[ignore_malloc_size_of = "mozjs"] ++ primary_key: Heap, ++ /// ++ #[ignore_malloc_size_of = "mozjs"] ++ value: Heap, ++} ++ ++impl IDBRecord { ++ fn new_inherited() -> IDBRecord { ++ IDBRecord { ++ reflector_: Reflector::new(), ++ key: Heap::default(), ++ primary_key: Heap::default(), ++ value: Heap::default(), ++ } ++ } ++ ++ /// Projects one stored record into the `IDBRecord` a `getAllRecords` result array holds. ++ /// ++ /// The reflector is allocated before any slot is written, because `Heap::set` registers the ++ /// slot's own address with the GC store buffer: a value written into a `Heap` that is later ++ /// moved leaves the buffer pointing at freed memory. ++ pub(crate) fn new( ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ store: &IDBObjectStore, ++ record: IndexedDBRecord, ++ ) -> Fallible> { ++ let this = reflect_dom_object_with_cx(Box::new(IDBRecord::new_inherited()), global, cx); ++ ++ rooted!(&in(cx) let mut key = UndefinedValue()); ++ key_type_to_jsval(cx, &record.key, key.handle_mut())?; ++ this.key.set(key.get()); ++ ++ rooted!(&in(cx) let mut primary_key = UndefinedValue()); ++ key_type_to_jsval(cx, &record.primary_key, primary_key.handle_mut())?; ++ this.primary_key.set(primary_key.get()); ++ ++ rooted!(&in(cx) let mut value = UndefinedValue()); ++ let data = postcard::from_bytes(&record.value).map_err(|_| Error::Data(None))?; ++ structuredclone::read(cx, global, data, value.handle_mut())?; ++ // A store that generates keys into an in-line key path does not store the key inside the ++ // value. `record.value` is the stored bytes, so the key goes back in here. ++ store.inject_record_key_if_absent(cx, value.handle(), &record.primary_key)?; ++ this.value.set(value.get()); ++ ++ Ok(this) ++ } ++} ++ ++impl IDBRecordMethods for IDBRecord { ++ /// ++ fn Key(&self, mut retval: MutableHandleValue) { ++ retval.set(self.key.get()); ++ } ++ ++ /// ++ fn PrimaryKey(&self, mut retval: MutableHandleValue) { ++ retval.set(self.primary_key.get()); ++ } ++ ++ /// ++ fn Value(&self, mut retval: MutableHandleValue) { ++ retval.set(self.value.get()); ++ } ++} +diff --git a/components/script/dom/indexeddb/idbrequest.rs b/components/script/dom/indexeddb/idbrequest.rs +index bdc25fd283..bb5e39cfd6 100644 +--- a/components/script/dom/indexeddb/idbrequest.rs ++++ b/components/script/dom/indexeddb/idbrequest.rs +@@ -6,56 +6,327 @@ use std::cell::Cell; + + use dom_struct::dom_struct; + use js::context::JSContext; +-use js::conversions::ToJSValConvertible; ++use js::conversions::{ConversionResult as JsConversionResult, ToJSValConvertible}; + use js::jsapi::Heap; +-use js::jsval::{DoubleValue, JSVal, ObjectValue, UndefinedValue}; ++use js::jsval::{DoubleValue, JSVal, NullValue, ObjectValue, UndefinedValue}; + use js::rust::HandleValue; + use profile_traits::generic_callback::GenericCallback; ++use script_bindings::cell::DomRefCell; + use script_bindings::reflector::{DomObject, reflect_dom_object_with_cx}; + use serde::{Deserialize, Serialize}; + use servo_base::generic_channel::GenericSend; + use storage_traits::indexeddb::{ +- AsyncOperation, AsyncReadOnlyOperation, BackendError, BackendResult, IndexedDBKeyType, +- IndexedDBRecord, IndexedDBThreadMsg, IndexedDBTxnMode, KvsOperationContext, KvsOperationTarget, +- PutItemResult, SyncOperation, ++ AsyncOperation, AsyncReadOnlyOperation, BackendError, BackendResult, BackfillIndexResult, ++ IndexedDBKeyRange, IndexedDBKeyType, IndexedDBRecord, IndexedDBThreadMsg, IndexedDBTxnMode, ++ KeyPath, KvsOperationContext, PutItemResult, RecordsShape, SyncOperation, + }; + use stylo_atoms::Atom; + ++use crate::dom::bindings::codegen::Bindings::IDBCursorBinding::IDBCursorDirection; ++use crate::dom::bindings::codegen::Bindings::IDBObjectStoreBinding::IDBGetAllOptions; + use crate::dom::bindings::codegen::Bindings::IDBRequestBinding::{ + IDBRequestMethods, IDBRequestReadyState, + }; + use crate::dom::bindings::codegen::Bindings::IDBTransactionBinding::IDBTransactionMode; ++use crate::dom::bindings::codegen::UnionTypes::IDBObjectStoreOrIDBIndexOrIDBCursor; + use crate::dom::bindings::error::{Error, Fallible, create_dom_exception}; + use crate::dom::bindings::inheritance::Castable; + use crate::dom::bindings::refcounted::Trusted; + use crate::dom::bindings::reflector::DomGlobal; +-use crate::dom::bindings::root::{DomRoot, MutNullableDom}; ++use crate::dom::bindings::root::{Dom, DomRoot, MutNullableDom}; + use crate::dom::bindings::structuredclone; + use crate::dom::domexception::DOMException; + use crate::dom::event::{Event, EventBubbles, EventCancelable}; + use crate::dom::eventtarget::EventTarget; + use crate::dom::globalscope::GlobalScope; +-use crate::dom::indexeddb::idbcursor::{IterationParam, iterate_cursor}; ++use crate::dom::indexeddb::idbcursor::{IDBCursor, IterationParam, iterate_cursor}; + use crate::dom::indexeddb::idbcursorwithvalue::IDBCursorWithValue; ++use crate::dom::indexeddb::idbindex::IDBIndex; + use crate::dom::indexeddb::idbobjectstore::IDBObjectStore; ++use crate::dom::indexeddb::idbrecord::IDBRecord; + use crate::dom::indexeddb::idbtransaction::IDBTransaction; +-use crate::indexeddb::key_type_to_jsval; ++use crate::indexeddb::{ ++ convert_value_to_key_range, is_potentially_valid_key_range, key_type_to_jsval, reply_lost, ++}; + use crate::realms::enter_auto_realm; + ++/// ++/// ++/// Every request reaches the backend through an object store, because that is what names the ++/// records on the wire, but the source the spec exposes is whichever surface the method was ++/// called on. `IDBIndex`'s eight operations and `IDBCursor`'s `update` and `delete` all travel ++/// over an object store while reporting the index or the cursor as their source. ++#[derive(JSTraceable, MallocSizeOf)] ++#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] ++pub(crate) enum RequestSource { ++ ObjectStore(Dom), ++ Index(Dom), ++ Cursor(Dom), ++} ++ ++impl RequestSource { ++ /// The object store whose records the request reads. ++ /// ++ /// An index request and a cursor request both answer with the object store's values, and the ++ /// store is what knows whether a stored value is missing the key it was generated under. ++ pub(crate) fn object_store(&self) -> DomRoot { ++ match self { ++ RequestSource::ObjectStore(store) => store.as_rooted(), ++ RequestSource::Index(index) => index.object_store(), ++ RequestSource::Cursor(cursor) => cursor.source().object_store(), ++ } ++ } ++} ++ ++/// Which projection of each record a `getAll`-family request answers with. ++/// ++/// One backend read serves all three methods; only the shape of the answer differs. ++#[derive(Clone, Copy)] ++pub(crate) enum GetAllKind { ++ /// `getAll`: the stored value of each record. ++ Values, ++ /// `getAllKeys`: the object store key of each record. ++ PrimaryKeys, ++ /// `getAllRecords`: an `IDBRecord` per record. ++ Records, ++} ++ ++impl GetAllKind { ++ /// `getAllKeys` never reads a stored value, and over a store of large values that is the ++ /// difference between shipping a list of keys and shipping a copy of the database. ++ pub(crate) fn shape(self) -> RecordsShape { ++ match self { ++ GetAllKind::PrimaryKeys => RecordsShape::KeysOnly, ++ GetAllKind::Values | GetAllKind::Records => RecordsShape::WithValues, ++ } ++ } ++} ++ ++/// Whether a request may be held back by the transaction's outbound hold. ++#[derive(Clone, Copy)] ++enum OutboundHold { ++ /// The ordinary case: the hold, when it is set, takes this request. ++ Respect, ++ /// The request the hold is waiting for. Holding it would stall the transaction on itself. ++ Bypass, ++} ++ ++/// Whether the answer to a request is reported to script. ++#[derive(Clone, Copy, PartialEq)] ++enum RequestVisibility { ++ /// The ordinary case: the answer arrives as a `success` or an `error` event fired at the ++ /// request, and an unhandled error event's default action aborts the transaction. ++ Script, ++ /// A half of a `create index` backfill. ++ /// ++ /// processes index ++ /// creation as an asynchronous request inside the upgrade transaction but never hands ++ /// script a request for it, so no event is fired at either half. A failure runs ++ /// `abort a transaction` with the error directly, which is how a unique index over records ++ /// that already share a key takes the upgrade transaction down. ++ Internal(BackfillHalf), ++} ++ ++/// Which half of a `create index` backfill a request carries. ++/// ++/// The two halves sit on opposite sides of the transaction's outbound hold, so which half a ++/// request is decides both where it goes in the queue and what its answer releases. ++#[derive(Clone, Copy, PartialEq)] ++pub(crate) enum BackfillHalf { ++ /// The read that collects the records the new index has to cover. It takes its ordinary ++ /// place in the outbound queue, because the records it has to see are the ones every ++ /// message ahead of it leaves behind. ++ Read, ++ /// The write that stores the keys extracted from those records. It bypasses the hold, ++ /// because the hold is what it is keeping the rest of the transaction waiting for, and its ++ /// answer is the point at which creating the index has either succeeded or failed. Only ++ /// then may the messages behind it go out: a request script placed after a `createIndex` ++ /// that fails is one the abort answers, not one the backend should ever see. ++ Write, ++} ++ ++/// What the DOM should make of a `Vec` answer. ++/// ++/// Cursor iteration and the `getAll` family read the same records and so share one wire ++/// payload. This is what tells them apart, and it carries the part of each algorithm the ++/// backend was not told about. ++#[derive(Clone)] ++pub(crate) enum RecordsParam { ++ /// Move a cursor onto the next record the iteration selects. ++ Cursor(IterationParam), ++ /// Project the records the way `kind` names. ++ GetAll { ++ kind: GetAllKind, ++ direction: IDBCursorDirection, ++ count: Option, ++ }, ++ /// Extract `index_name`'s keys from the records and send them back out as the write that ++ /// populates a newly created index. ++ /// ++ /// This request is not script visible. `create index` needs the index's key path evaluated ++ /// against every stored value, and only the script thread can do that, so the read comes ++ /// here and the keys go out again. ++ /// ++ /// Everything the second half needs is carried across the round trip rather than read back ++ /// off the object store when the records arrive, because the upgrade transaction goes on ++ /// running while they travel. `store_name` is the name the backend knows the store by, and ++ /// a rename placed after the `createIndex` is still held behind this round trip. The key ++ /// path and the multiEntry flag belong to the index the `createIndex` created, and a ++ /// `deleteIndex` placed after it has already taken that index off the store handle. The key ++ /// path is the storage thread's own, because a `RequestListener` travels to the task queue ++ /// and the DOM's `KeyPath` holds `DOMString`, which does not cross threads. ++ IndexBackfill { ++ store_name: String, ++ index_name: String, ++ key_path: KeyPath, ++ multi_entry: bool, ++ }, ++} ++ ++/// A resolved `getAll`, `getAllKeys` or `getAllRecords` request. ++/// ++/// ++pub(crate) struct GetAllRequest { ++ /// The range the backend reads. ++ pub(crate) key_range: IndexedDBKeyRange, ++ /// The count the backend may apply during the read, which is not always the count the ++ /// request asked for. ++ pub(crate) count: Option, ++ /// The projection the DOM applies to the answer. ++ pub(crate) records_param: RecordsParam, ++} ++ ++impl GetAllRequest { ++ /// Splits a resolved request into what the DOM applies after the read and what the backend ++ /// may apply during it. ++ /// ++ /// A count of zero is not a limit. The spec reads it as infinity, and a `LIMIT 0` would ++ /// answer with nothing at all. Of the counts that do limit, only `next` may be pushed down: ++ /// the backend answers in ascending key order with no duplicate filtering, so for every ++ /// other direction a count applied during the read would already have truncated the wrong ++ /// end of the range, or dropped records that the unique filter was going to remove anyway. ++ fn new( ++ kind: GetAllKind, ++ key_range: IndexedDBKeyRange, ++ direction: IDBCursorDirection, ++ count: Option, ++ ) -> Self { ++ let count = count.filter(|count| *count > 0); ++ let backend_count = match direction { ++ IDBCursorDirection::Next => count, ++ _ => None, ++ }; ++ Self { ++ key_range, ++ count: backend_count, ++ records_param: RecordsParam::GetAll { ++ kind, ++ direction, ++ count, ++ }, ++ } ++ } ++ ++ /// `getAllRecords(options)`, whose single argument needs no disambiguation. ++ pub(crate) fn from_options( ++ cx: &mut JSContext, ++ kind: GetAllKind, ++ options: &IDBGetAllOptions, ++ ) -> Fallible { ++ let key_range = convert_value_to_key_range(cx, options.query.handle(), None)?; ++ Ok(Self::new(kind, key_range, options.direction, options.count)) ++ } ++ ++ /// `getAll(queryOrOptions, count)` and `getAllKeys(queryOrOptions, count)`, whose first ++ /// argument is either a query or an `IDBGetAllOptions`. ++ pub(crate) fn resolve( ++ cx: &mut JSContext, ++ kind: GetAllKind, ++ query_or_options: HandleValue, ++ count: Option, ++ ) -> Fallible { ++ // Step 8. If running is a potentially valid key range with queryOrOptions is true, the ++ // argument is the query and the direction is "next". ++ if is_potentially_valid_key_range(cx, query_or_options)? { ++ let key_range = convert_value_to_key_range(cx, query_or_options, None)?; ++ return Ok(Self::new(kind, key_range, IDBCursorDirection::Next, count)); ++ } ++ ++ // Step 9. Otherwise the argument is an IDBGetAllOptions, and the dictionary replaces ++ // the positional count whether or not it carries one of its own. ++ let options = match IDBGetAllOptions::new(cx, query_or_options) { ++ Ok(JsConversionResult::Success(options)) => options, ++ Ok(JsConversionResult::Failure(error)) => { ++ return Err(Error::Type(error.into_owned())); ++ }, ++ Err(()) => return Err(Error::JSFailed), ++ }; ++ Self::from_options(cx, kind, &options) ++ } ++} ++ ++/// Applies the direction and count that `getAllRecords` resolves after the read. ++/// ++/// The backend answers in ascending index key order and then ascending primary key order, for ++/// every direction. Unique filtering therefore keeps the first record of each key, which is the ++/// one with the lowest primary key, and it has to run before the reversal rather than after. ++/// Count is last, because it limits the records the direction chose and not the ones the range ++/// matched. ++fn project_records( ++ direction: IDBCursorDirection, ++ count: Option, ++ mut records: Vec, ++) -> Vec { ++ if matches!( ++ direction, ++ IDBCursorDirection::Nextunique | IDBCursorDirection::Prevunique ++ ) { ++ let mut previous: Option = None; ++ records.retain(|record| { ++ if previous.as_ref() == Some(&record.key) { ++ return false; ++ } ++ previous = Some(record.key.clone()); ++ true ++ }); ++ } ++ ++ if matches!( ++ direction, ++ IDBCursorDirection::Prev | IDBCursorDirection::Prevunique ++ ) { ++ records.reverse(); ++ } ++ ++ if let Some(count) = count { ++ records.truncate(count as usize); ++ } ++ ++ records ++} ++ + #[derive(Clone)] + struct RequestListener { + request: Trusted, +- iteration_param: Option, ++ transaction: Trusted, ++ records_param: Option, + request_id: u64, ++ visibility: RequestVisibility, + } + + pub enum IdbResult { + Key(IndexedDBKeyType), + Keys(Vec), +- Value(Vec), ++ /// The one record a `get` answers with, key included. ++ /// ++ /// The key rides along because a store that generates keys into an in-line key path does not ++ /// write the key into the value; the key is generated in the engine and injected here. ++ Record(IndexedDBRecord), + Values(Vec>), + Count(u64), +- Iterate(Vec), ++ /// The records a range covers. Which of the four algorithms that read records this answer ++ /// belongs to is carried by the request's `RecordsParam`, not by the wire. ++ Records(Vec), + Error(Error), + None, + } +@@ -72,9 +343,9 @@ impl From> for IdbResult { + } + } + +-impl From> for IdbResult { +- fn from(value: Vec) -> Self { +- IdbResult::Value(value) ++impl From for IdbResult { ++ fn from(value: IndexedDBRecord) -> Self { ++ IdbResult::Record(value) + } + } + +@@ -89,13 +360,36 @@ impl From for IdbResult { + match value { + PutItemResult::Key(key) => Self::Key(key), + PutItemResult::CannotOverwrite => Self::Error(Error::Constraint(None)), ++ PutItemResult::IndexConstraintViolated(index_name) => { ++ Self::Error(Error::Constraint(Some(format!( ++ "Unique index \"{index_name}\" already holds that key" ++ )))) ++ }, ++ PutItemResult::KeyGeneratorExhausted => Self::Error(Error::Constraint(Some( ++ "The object store's key generator has reached its maximum value".into(), ++ ))), + } + } + } + + impl From> for IdbResult { + fn from(value: Vec) -> Self { +- Self::Iterate(value) ++ Self::Records(value) ++ } ++} ++ ++impl From for IdbResult { ++ fn from(value: BackfillIndexResult) -> Self { ++ match value { ++ BackfillIndexResult::Done => Self::None, ++ // : a unique ++ // index that cannot hold the store's records aborts the upgrade transaction with ++ // this error. The request carrying it is not script visible, so the abort is run ++ // directly rather than as an error event's default action. ++ BackfillIndexResult::UniqueConstraintViolated => Self::Error(Error::Constraint(Some( ++ "A unique index cannot be created over records that already share a key".into(), ++ ))), ++ } + } + } + +@@ -153,11 +447,30 @@ impl RequestListener { + fn handle_async_request_finished(&self, cx: &mut JSContext, result: BackendResult) { + let request = self.request.root(); + let global = request.global(); ++ let transaction = self.transaction.root(); ++ ++ // Completion bookkeeping belongs to the transaction that issued the backend request, ++ // not to the request's mutable script-facing association. Keep processing with the ++ // retained transaction if that association was unexpectedly cleared or replaced, or the ++ // pending count and RequestHandled frontier would never advance. ++ match request.transaction.get() { ++ Some(request_transaction) if &*request_transaction == &*transaction => {}, ++ Some(_) => warn!( ++ "An IndexedDB reply arrived for a request associated with a different transaction." ++ ), ++ None => warn!("An IndexedDB reply arrived for a request with no transaction."), ++ } ++ ++ // step 5 already answered this ++ // request with an `AbortError`, which is what "abort the steps to asynchronously ++ // execute a request" leaves behind: the answer that has just arrived is the one those ++ // steps were told to stop producing, so it is dropped rather than fired at a request ++ // that is already done. The transaction still counts the request as gone. ++ if request.is_settled_by_abort() { ++ transaction.request_finished(); ++ return; ++ } + +- let transaction = request +- .transaction +- .get() +- .expect("Request unexpectedly has no transaction"); + // Substep 1: Set the result of request to result. + request.set_ready_state_done(); + +@@ -167,23 +480,54 @@ impl RequestListener { + + if let Ok(data) = result { + match data { +- IdbResult::Key(key) => key_type_to_jsval(cx, &key, answer.handle_mut()), ++ IdbResult::Key(key) => { ++ // A failed key conversion is the same kind of event as a failed ++ // structured clone below: the request rejects rather than the ++ // process dying. ++ if let Err(e) = key_type_to_jsval(cx, &key, answer.handle_mut()) { ++ warn!("Error converting an IndexedDB key to a value"); ++ self.handle_async_request_error(&global, cx, request, e); ++ return; ++ } ++ }, + IdbResult::Keys(keys) => { + rooted!(&in(cx) let mut array = vec![JSVal::default(); keys.len()]); + for (i, key) in keys.into_iter().enumerate() { +- key_type_to_jsval(cx, &key, array.handle_mut_at(i)); ++ if let Err(e) = key_type_to_jsval(cx, &key, array.handle_mut_at(i)) { ++ warn!("Error converting an IndexedDB key to a value"); ++ self.handle_async_request_error(&global, cx, request, e); ++ return; ++ } + } + array.safe_to_jsval(cx, answer.handle_mut()); + }, +- IdbResult::Value(serialized_data) => { +- let result = postcard::from_bytes(&serialized_data) ++ IdbResult::Record(record) => { ++ // The store is rooted and the borrow released before anything below can ++ // reenter script, because injecting a key runs `CreateDataProperty`. ++ let store = request ++ .source ++ .borrow() ++ .as_ref() ++ .map(|source| source.object_store()); ++ let result = postcard::from_bytes(&record.value) + .map_err(|_| Error::Data(None)) + .and_then(|data| { + structuredclone::read(cx, &global, data, answer.handle_mut()) ++ }) ++ .map(|_| ()) ++ .and_then(|()| match &store { ++ // A store that generates keys into an in-line key path does not write ++ // the key into the value, so `get` puts it back. ++ Some(store) => store.inject_record_key_if_absent( ++ cx, ++ answer.handle(), ++ &record.primary_key, ++ ), ++ None => Ok(()), + }); + if let Err(e) = result { +- warn!("Error reading structuredclone data"); +- Self::handle_async_request_error(&global, cx, request, e, self.request_id); ++ warn!("Error reading the stored record"); ++ self.handle_async_request_error(&global, cx, request, e); + return; + }; + }, +@@ -197,13 +541,7 @@ impl RequestListener { + }); + if let Err(e) = result { + warn!("Error reading structuredclone data"); +- Self::handle_async_request_error( +- &global, +- cx, +- request, +- e, +- self.request_id, +- ); ++ self.handle_async_request_error(&global, cx, request, e); + return; + }; + } +@@ -212,49 +550,185 @@ impl RequestListener { + IdbResult::Count(count) => { + answer.handle_mut().set(DoubleValue(count as f64)); + }, +- IdbResult::Iterate(records) => { +- let param = self.iteration_param.as_ref().expect( +- "iteration_param must be provided by IDBRequest::execute_async for Iterate", +- ); +- let cursor = match iterate_cursor(&global, cx, param, records) { +- Ok(cursor) => cursor, +- Err(e) => { +- warn!("Error reading structuredclone data"); +- Self::handle_async_request_error( ++ IdbResult::Records(records) => match self.records_param.as_ref() { ++ Some(RecordsParam::Cursor(param)) => { ++ let cursor = match iterate_cursor(&global, cx, param, records) { ++ Ok(cursor) => cursor, ++ Err(e) => { ++ warn!("Error reading the cursor's record"); ++ self.handle_async_request_error(&global, cx, request, e); ++ return; ++ }, ++ }; ++ match cursor { ++ Some(cursor) => match cursor.downcast::() { ++ Some(cursor_with_value) => { ++ answer.handle_mut().set(ObjectValue( ++ *cursor_with_value.reflector().get_jsobject(), ++ )); ++ }, ++ None => { ++ answer ++ .handle_mut() ++ .set(ObjectValue(*cursor.reflector().get_jsobject())); ++ }, ++ }, ++ // ++ // Step 6: no record was found, so the cursor is exhausted and ++ // the request's result is null rather than undefined. ++ None => answer.handle_mut().set(NullValue()), ++ } ++ }, ++ Some(RecordsParam::GetAll { ++ kind, ++ direction, ++ count, ++ }) => { ++ // The store is rooted and the borrow released before the loop, because ++ // injecting a key runs `CreateDataProperty`, which can reenter script. ++ let store = request ++ .source ++ .borrow() ++ .as_ref() ++ .map(|source| source.object_store()); ++ let records = project_records(*direction, *count, records); ++ rooted!(&in(cx) let mut array = vec![JSVal::default(); records.len()]); ++ for (i, record) in records.into_iter().enumerate() { ++ let element = match kind { ++ GetAllKind::Values => (|| { ++ let data = postcard::from_bytes(&record.value) ++ .map_err(|_| Error::Data(None))?; ++ structuredclone::read( ++ cx, ++ &global, ++ data, ++ array.handle_mut_at(i), ++ )?; ++ // The deserialized message ports belong to the value, which ++ // is now rooted in the array. Nothing here owns them. ++ // A store that generates keys into an in-line key path does ++ // not write the key into the value, so it goes back in here. ++ if let Some(store) = &store { ++ store.inject_record_key_if_absent( ++ cx, ++ array.handle_at(i), ++ &record.primary_key, ++ )?; ++ } ++ Ok(()) ++ })(), ++ GetAllKind::PrimaryKeys => key_type_to_jsval( ++ cx, ++ &record.primary_key, ++ array.handle_mut_at(i), ++ ), ++ // A `getAllRecords` request always has a source, so a reply that ++ // arrives without one cannot be projected into records. ++ GetAllKind::Records => match &store { ++ Some(store) => IDBRecord::new(cx, &global, store, record) ++ .map(|idb_record| { ++ array.handle_mut_at(i).set(ObjectValue( ++ *idb_record.reflector().get_jsobject(), ++ )); ++ }), ++ None => Err(Error::InvalidState(None)), ++ }, ++ }; ++ if let Err(e) = element { ++ warn!("Error building a getAll result"); ++ self.handle_async_request_error(&global, cx, request, e); ++ return; ++ } ++ } ++ array.safe_to_jsval(cx, answer.handle_mut()); ++ }, ++ Some(RecordsParam::IndexBackfill { ++ store_name, ++ index_name, ++ key_path, ++ multi_entry, ++ }) => { ++ // A backfill request is not script visible, so no event is fired at it ++ // and nothing below opens the activity window `fire a success event` ++ // step 6 opens. The continuation places the backfill write against the ++ // transaction, so it opens that window here; the internal tail closes ++ // it again. ++ if transaction.is_inactive() { ++ transaction.set_active_flag(true); ++ } ++ let store = match &*request.source.borrow() { ++ Some(RequestSource::ObjectStore(store)) => Some(store.as_rooted()), ++ _ => None, ++ }; ++ // A backfill read is always issued from the object store that owns the ++ // index, so anything else here is a protocol error. The transaction ++ // aborts, and the messages the hold is carrying go with it. ++ let Some(store) = store else { ++ warn!("An index backfill answered a request with no object store"); ++ self.handle_async_request_error( + &global, + cx, + request, +- e, +- self.request_id, ++ Error::InvalidState(None), + ); + return; +- }, +- }; +- if let Some(cursor) = cursor { +- match cursor.downcast::() { +- Some(cursor_with_value) => { +- answer.handle_mut().set(ObjectValue( +- *cursor_with_value.reflector().get_jsobject(), +- )); +- }, +- None => { +- answer +- .handle_mut() +- .set(ObjectValue(*cursor.reflector().get_jsobject())); +- }, ++ }; ++ if let Err(e) = store.finish_index_backfill( ++ cx, ++ store_name, ++ index_name, ++ key_path, ++ *multi_entry, ++ records, ++ ) { ++ warn!("Error populating a new index from the store's records"); ++ self.handle_async_request_error(&global, cx, request, e); ++ return; + } +- } ++ }, ++ // The pairing is asserted where the operation is sent, so reaching here ++ // means the backend answered with records for a request that reads none. ++ // The request rejects; it is not a reason to end the content process. ++ None => { ++ warn!("IndexedDB answered with records for a request that reads none"); ++ self.handle_async_request_error( ++ &global, ++ cx, ++ request, ++ Error::InvalidState(None), ++ ); ++ return; ++ }, + }, + IdbResult::None => { + // no-op + }, + IdbResult::Error(error) => { + // Substep 2 +- Self::handle_async_request_error(&global, cx, request, error, self.request_id); ++ self.handle_async_request_error(&global, cx, request, error); + return; + }, + } + ++ if let RequestVisibility::Internal(half) = self.visibility { ++ // keeps the ++ // backfill inside the upgrade transaction without exposing a request for it, ++ // so there is no result to set and no event to fire. The activity window the ++ // continuation above opened is closed here, the way step 8 of ++ // `fire a success event` would have closed it. ++ if half == BackfillHalf::Write { ++ // Creating the index has landed, so everything script placed behind it can ++ // follow, up to the next `createIndex` that queued itself here. ++ transaction.resume_after_backfill(); ++ } ++ if transaction.is_active() { ++ transaction.set_active_flag(false); ++ } ++ transaction.request_finished(); ++ Self::send_request_handled(cx, &transaction, self.request_id); ++ return; ++ } ++ + // Substep 3.1: Set the result of request to answer. + request.set_result(answer.handle()); + +@@ -304,29 +778,21 @@ impl RequestListener { + } else { + // FIXME:(arihant2math) dispatch correct error + // Substep 2 +- Self::handle_async_request_error( +- &global, +- cx, +- request, +- Error::Data(None), +- self.request_id, +- ); ++ self.handle_async_request_error(&global, cx, request, Error::Data(None)); + } + } + + // https://www.w3.org/TR/IndexedDB-3/#async-execute-request + // Implements Step 5.4.2 + fn handle_async_request_error( ++ &self, + global: &GlobalScope, + cx: &mut JSContext, + request: DomRoot, + error: Error, +- request_id: u64, + ) { +- let transaction = request +- .transaction +- .get() +- .expect("Request has no transaction"); ++ let request_id = self.request_id; ++ let transaction = self.transaction.root(); + // Substep 1: Set the result of request to undefined. + rooted!(&in(cx) let undefined = UndefinedValue()); + request.set_result(undefined.handle()); +@@ -334,6 +800,22 @@ impl RequestListener { + // Substep 2: Set the error of request to result. + request.set_error(cx, Some(error.clone())); + ++ if matches!(self.visibility, RequestVisibility::Internal(_)) { ++ // : creating an ++ // index can only fail after the method has returned, and the algorithm answers that ++ // by running `abort a transaction` with the error. Script holds no request for the ++ // backfill, so firing an error event here would instead surface the failure at the ++ // transaction and the connection, where the algorithm never puts it. ++ if transaction.is_active() { ++ transaction.set_active_flag(false); ++ } ++ transaction.initiate_abort(cx, error); ++ transaction.request_backend_abort(); ++ transaction.request_finished(); ++ Self::send_request_handled(cx, &transaction, request_id); ++ return; ++ } ++ + // https://w3c.github.io/IndexedDB/#fire-error-event + // Step 1: Let event be the result of creating an event using Event. + // Step 2: Set event’s type attribute to "error". +@@ -397,9 +879,22 @@ pub struct IDBRequest { + #[ignore_malloc_size_of = "mozjs"] + result: Heap, + error: MutNullableDom, +- source: MutNullableDom, ++ source: DomRefCell>, + transaction: MutNullableDom, + ready_state: Cell, ++ /// Whether `abort a transaction` has taken this request over. ++ /// ++ /// step 5 answers every request the ++ /// transaction still owes an answer to, so the answer the backend is still going to send ++ /// for it stops being the answer. The flag is set while the abort runs, which is before ++ /// either answer can be delivered, and it is what tells the late one apart. ++ settled_by_abort: Cell, ++ /// Whether script holds this request. ++ /// ++ /// runs its backfill as ++ /// requests the algorithm never exposes, so an abort has nobody to fire their error events ++ /// at. ++ script_visible: Cell, + } + + impl IDBRequest { +@@ -412,6 +907,8 @@ impl IDBRequest { + source: Default::default(), + transaction: Default::default(), + ready_state: Cell::new(IDBRequestReadyState::Pending), ++ settled_by_abort: Cell::new(false), ++ script_visible: Cell::new(true), + } + } + +@@ -419,14 +916,23 @@ impl IDBRequest { + reflect_dom_object_with_cx(Box::new(IDBRequest::new_inherited()), global, cx) + } + +- pub fn set_source(&self, source: Option<&IDBObjectStore>) { +- self.source.set(source); ++ pub(crate) fn set_source(&self, source: RequestSource) { ++ *self.source.borrow_mut() = Some(source); + } + + pub fn set_ready_state_done(&self) { + self.ready_state.set(IDBRequestReadyState::Done); + } + ++ /// Reopens a completed request so it can carry the result of another operation. ++ /// ++ /// Cursor iteration is the only caller: `advance`, `continue` and `continuePrimaryKey` all ++ /// say "set request's done flag to false" and then run a fresh iterate operation against the ++ /// same `IDBRequest` object, because the cursor holds exactly one request for its lifetime. ++ pub fn set_ready_state_pending(&self) { ++ self.ready_state.set(IDBRequestReadyState::Pending); ++ } ++ + pub fn set_result(&self, result: HandleValue) { + self.result.set(result.get()); + } +@@ -453,6 +959,66 @@ impl IDBRequest { + self.ready_state.get() == IDBRequestReadyState::Done + } + ++ /// See [`Self::script_visible`]. ++ pub(crate) fn hide_from_script(&self) { ++ self.script_visible.set(false); ++ } ++ ++ /// See [`Self::settled_by_abort`]. ++ pub(crate) fn is_settled_by_abort(&self) -> bool { ++ self.settled_by_abort.get() ++ } ++ ++ /// Whether an aborting transaction still owes this request an answer. ++ pub(crate) fn is_awaiting_answer(&self) -> bool { ++ self.script_visible.get() && !self.is_done() && !self.settled_by_abort.get() ++ } ++ ++ /// step 5, for one request. ++ /// ++ /// The transaction is aborting, so the answer to this request is that the transaction took ++ /// it down, whether the backend was about to answer it or, for a request the outbound hold ++ /// was still carrying, was never going to hear about it at all. The request is settled ++ /// synchronously so a reply already on its way is recognised as stale when it lands, and the ++ /// event the abort owes script is queued as a database task. ++ /// ++ /// Step 5.4 is `fire an event`, not `fire an error event`: the transaction is already ++ /// finished, so neither the activity window nor the unhandled-error abort that the latter ++ /// carries has anything left to act on. ++ pub(crate) fn settle_by_abort(&self) { ++ if self.settled_by_abort.get() { ++ return; ++ } ++ self.settled_by_abort.set(true); ++ let this = Trusted::new(self); ++ self.global() ++ .task_manager() ++ .database_access_task_source() ++ .queue(task!(idb_request_aborted: move |cx| { ++ let request = this.root(); ++ let global = request.global(); ++ let mut realm = enter_auto_realm(cx, &*request); ++ let cx: &mut JSContext = &mut realm; ++ // Step 5.1. Set request's done flag to true. ++ request.set_ready_state_done(); ++ // Step 5.2. Set request's result to undefined. ++ rooted!(&in(cx) let undefined = UndefinedValue()); ++ request.set_result(undefined.handle()); ++ // Step 5.3. Set request's error to a newly created "AbortError" DOMException. ++ request.set_error(cx, Some(Error::Abort(None))); ++ // Step 5.4. Fire an event named error at request with its bubbles and ++ // cancelable attributes initialized to true. ++ let event = Event::new( ++ cx, ++ &global, ++ Atom::from("error"), ++ EventBubbles::Bubbles, ++ EventCancelable::Cancelable, ++ ); ++ event.fire(cx, request.upcast()); ++ })); ++ } ++ + pub(crate) fn transaction(&self) -> Option> { + self.transaction.get() + } +@@ -460,17 +1026,199 @@ impl IDBRequest { + // https://www.w3.org/TR/IndexedDB-3/#asynchronously-execute-a-request + pub fn execute_async( + cx: &mut JSContext, +- source: &IDBObjectStore, ++ store: &IDBObjectStore, + operation_fn: F, + request: Option>, +- iteration_param: Option, ++ records_param: Option, ++ ) -> Fallible> ++ where ++ T: Into + for<'a> Deserialize<'a> + Serialize + Send + Sync + 'static, ++ F: FnOnce(GenericCallback>) -> AsyncOperation, ++ { ++ Self::execute_async_with_context( ++ cx, ++ store, ++ KvsOperationContext::default(), ++ operation_fn, ++ request, ++ records_param, ++ ) ++ } ++ ++ /// `asynchronously execute a request` where the request's source is not the object store ++ /// that carries the transaction and the store name. ++ /// ++ /// Only `IDBIndex` and `IDBCursor` need this. Everything the backend is told still derives ++ /// from `store`; `source` is the DOM surface the method was called on, and the two are not ++ /// the same thing. ++ pub(crate) fn execute_async_from_source( ++ cx: &mut JSContext, ++ store: &IDBObjectStore, ++ source: RequestSource, ++ context: KvsOperationContext, ++ operation_fn: F, ++ request: Option>, ++ records_param: Option, ++ ) -> Fallible> ++ where ++ T: Into + for<'a> Deserialize<'a> + Serialize + Send + Sync + 'static, ++ F: FnOnce(GenericCallback>) -> AsyncOperation, ++ { ++ Self::execute_async_inner( ++ cx, ++ store, ++ String::from(store.get_name()), ++ Some(source), ++ context, ++ operation_fn, ++ request, ++ records_param, ++ OutboundHold::Respect, ++ RequestVisibility::Script, ++ ) ++ } ++ ++ /// Asynchronously execute a request, naming which of the object store's surfaces the ++ /// request addresses. ++ /// ++ /// `store` is always the owning object store, because the transaction, the store name on ++ /// the wire and the request's source all derive from it. `context` is what distinguishes an ++ /// `IDBIndex` request from an `IDBObjectStore` one: the backend reads index records when ++ /// `context.target` is `KvsOperationTarget::Index` and object store records otherwise. The ++ /// six read operations are deliberately target agnostic, so an index needs no new operation ++ /// variants, only the context that selects which records they range over. ++ pub fn execute_async_with_context( ++ cx: &mut JSContext, ++ store: &IDBObjectStore, ++ context: KvsOperationContext, ++ operation_fn: F, ++ request: Option>, ++ records_param: Option, ++ ) -> Fallible> ++ where ++ T: Into + for<'a> Deserialize<'a> + Serialize + Send + Sync + 'static, ++ F: FnOnce(GenericCallback>) -> AsyncOperation, ++ { ++ Self::execute_async_inner( ++ cx, ++ store, ++ String::from(store.get_name()), ++ None, ++ context, ++ operation_fn, ++ request, ++ records_param, ++ OutboundHold::Respect, ++ RequestVisibility::Script, ++ ) ++ } ++ ++ /// `asynchronously execute a request` for one half of a `create index` backfill. ++ /// ++ /// Neither half is script visible, so neither fires an event and a failure aborts the ++ /// upgrade transaction outright. `store_name` is the name the backend knows the store by, ++ /// which is the name it had when `createIndex` ran: a rename placed afterwards is still ++ /// waiting behind this round trip. ++ /// ++ /// `half` decides where the request sits relative to the transaction's outbound hold, so ++ /// the two travel together rather than being chosen separately at each call site. ++ pub(crate) fn execute_backfill_operation( ++ cx: &mut JSContext, ++ store: &IDBObjectStore, ++ store_name: &str, ++ half: BackfillHalf, ++ operation_fn: F, ++ records_param: Option, ++ ) -> Fallible> ++ where ++ T: Into + for<'a> Deserialize<'a> + Serialize + Send + Sync + 'static, ++ F: FnOnce(GenericCallback>) -> AsyncOperation, ++ { ++ let hold = match half { ++ BackfillHalf::Read => OutboundHold::Respect, ++ BackfillHalf::Write => OutboundHold::Bypass, ++ }; ++ Self::execute_async_inner( ++ cx, ++ store, ++ store_name.to_owned(), ++ None, ++ KvsOperationContext::default(), ++ operation_fn, ++ None, ++ records_param, ++ hold, ++ RequestVisibility::Internal(half), ++ ) ++ } ++ ++ /// `asynchronously execute a request` for an operation that has already failed. ++ /// ++ /// runs the operation in ++ /// parallel and reports its failure by firing an error event at the request, so an operation ++ /// that cannot even start still answers asynchronously rather than throwing out of the method ++ /// that created the request. `store a record into an object store` fails this way when the ++ /// store's key generator can no longer produce a key. ++ pub(crate) fn execute_async_failure( ++ cx: &mut JSContext, ++ store: &IDBObjectStore, ++ error: Error, ++ ) -> Fallible> { ++ // Step 1. Let transaction be the transaction associated with source. ++ let transaction = store.transaction(); ++ let global = transaction.global(); ++ // Step 2. Assert: transaction is active. ++ if !transaction.is_active() || !transaction.is_usable() { ++ return Err(Error::TransactionInactive(None)); ++ } ++ ++ let request_id = transaction.allocate_request_id(); ++ // Step 3. Let request be a new request with source as source. ++ let request = IDBRequest::new(cx, &global); ++ request.set_source(RequestSource::ObjectStore(Dom::from_ref(store))); ++ request.set_transaction(&transaction); ++ // Step 4. Add request to the end of transaction's request list. ++ transaction.add_request(&request); ++ ++ // Step 5. The answer is already known, so the returning task is queued here instead of ++ // by a backend reply. It still reports the request id as handled, which is what keeps ++ // the transaction's commit bookkeeping in step with the requests script placed. ++ let listener = RequestListener { ++ request: Trusted::new(&request), ++ transaction: Trusted::new(&transaction), ++ records_param: None, ++ request_id, ++ visibility: RequestVisibility::Script, ++ }; ++ global.task_manager().database_access_task_source().queue( ++ task!(idb_request_failed: move |cx| { ++ listener.handle_async_request_finished(cx, Ok(IdbResult::Error(error))); ++ }), ++ ); ++ ++ // Step 6. Return request. ++ Ok(request) ++ } ++ ++ #[allow(clippy::too_many_arguments)] ++ fn execute_async_inner( ++ cx: &mut JSContext, ++ store: &IDBObjectStore, ++ store_name: String, ++ source: Option, ++ context: KvsOperationContext, ++ operation_fn: F, ++ request: Option>, ++ records_param: Option, ++ hold: OutboundHold, ++ visibility: RequestVisibility, + ) -> Fallible> + where + T: Into + for<'a> Deserialize<'a> + Serialize + Send + Sync + 'static, + F: FnOnce(GenericCallback>) -> AsyncOperation, + { + // Step 1: Let transaction be the transaction associated with source. +- let transaction = source.transaction(); ++ let transaction = store.transaction(); + let global = transaction.global(); + // Step 2: Assert: transaction is active. + if !transaction.is_active() || !transaction.is_usable() { +@@ -482,10 +1230,15 @@ impl IDBRequest { + // Step 3: If request was not given, let request be a new request with source as source. + let request = request.unwrap_or_else(|| { + let new_request = IDBRequest::new(cx, &global); +- new_request.set_source(Some(source)); ++ new_request.set_source( ++ source.unwrap_or_else(|| RequestSource::ObjectStore(Dom::from_ref(store))), ++ ); + new_request.set_transaction(&transaction); + new_request + }); ++ if matches!(visibility, RequestVisibility::Internal(_)) { ++ request.hide_from_script(); ++ } + + // Step 4: Add request to the end of transaction’s request list. + transaction.add_request(&request); +@@ -500,8 +1253,10 @@ impl IDBRequest { + + let response_listener = RequestListener { + request: Trusted::new(&request), +- iteration_param: iteration_param.clone(), ++ transaction: Trusted::new(&transaction), ++ records_param: records_param.clone(), + request_id, ++ visibility, + }; + + let task_source = global +@@ -509,12 +1264,23 @@ impl IDBRequest { + .database_access_task_source() + .to_sendable(); + ++ // Step 4 has already added the request to the transaction's request list, so it has to ++ // be answered even if the operation cannot be started. Both are kept out of the closure ++ // below, which consumes the originals. ++ let unstarted_listener = response_listener.clone(); ++ let unstarted_task_source = task_source.clone(); ++ + let closure = move |message: Result, ipc_channel::IpcError>| { + let response_listener = response_listener.clone(); ++ // In multiprocess mode this runs on the router thread with a reply that crossed a ++ // process boundary, so it can arrive as a transport error. That is one request's ++ // failure, and it has a request to fail; panicking here would instead take down ++ // every page in the content process. ++ let result = message.unwrap_or_else(|error| Err(reply_lost(error))); + task_source.queue(task!(request_callback: move |cx| { + response_listener.handle_async_request_finished( + cx, +- message.expect("Could not unwrap message").inspect_err(|e| { ++ result.inspect_err(|e| { + if let BackendError::DbErr(e) = e { + error!("Error in IndexedDB operation: {}", e); + } +@@ -522,44 +1288,71 @@ impl IDBRequest { + ); + })); + }; +- let callback = GenericCallback::new(global.time_profiler_chan().clone(), closure) +- .expect("Could not create callback"); ++ let callback = match GenericCallback::new(global.time_profiler_chan().clone(), closure) { ++ Ok(callback) => callback, ++ Err(error) => { ++ // No reply channel means no backend reply will ever arrive for a request the ++ // transaction is already counting, and a request that never settles wedges the ++ // transaction's commit bookkeeping. `asynchronously execute a request` reports ++ // an operation that cannot start by answering the request with an error, which ++ // is what `execute_async_failure` does for an already-failed operation. ++ warn!("Could not create an IndexedDB request callback: {error:?}"); ++ unstarted_task_source.queue(task!(idb_request_unstarted: move |cx| { ++ unstarted_listener.handle_async_request_finished( ++ cx, ++ Ok(IdbResult::Error(Error::Operation(None))), ++ ); ++ })); ++ return Ok(request); ++ }, ++ }; + let operation = operation_fn(callback); + +- if matches!( ++ // Every record-reading request answers with `Vec`, so the parameter ++ // is the only thing that says which algorithm the answer belongs to. Pairing it with ++ // the operation here is what lets the result handler treat a missing one as a protocol ++ // error rather than guess. ++ // ++ // A mispaired parameter is a wiring mistake in the caller rather than anything script ++ // can provoke, and the result handler already reports a missing one as a protocol ++ // error, so say so and let it. ++ let iterates = matches!( + operation, + AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { .. }) +- ) { +- assert!( +- iteration_param.is_some(), +- "iteration_param must be provided for Iterate" +- ); +- } else { +- assert!( +- iteration_param.is_none(), +- "iteration_param should not be provided for operation other than Iterate" +- ); ++ ); ++ match (iterates, records_param.is_some()) { ++ (true, false) => warn!( ++ "Iterate must carry the RecordsParam that names the algorithm reading it" ++ ), ++ (false, true) => warn!( ++ "records_param should not be provided for an operation that reads no records" ++ ), ++ _ => {}, + } + + // Start is a backend database task (spec). Script does not model it with a + // separate queued task, backend scheduling decides when requests begin. +- transaction +- .global() +- .storage_threads() +- .send(IndexedDBThreadMsg::Async( +- global.origin().immutable().clone(), +- String::from(transaction.get_db_name()), +- String::from(source.get_name()), +- KvsOperationContext { +- target: KvsOperationTarget::ObjectStore, +- index_updates: Vec::new(), +- }, +- transaction.get_serial_number(), +- request_id, +- transaction_mode, +- operation, +- )) +- .unwrap(); ++ let message = IndexedDBThreadMsg::Async( ++ global.origin().immutable().clone(), ++ String::from(transaction.get_db_name()), ++ store_name, ++ context, ++ transaction.get_serial_number(), ++ request_id, ++ transaction_mode, ++ operation, ++ ); ++ let sent = match hold { ++ OutboundHold::Respect => transaction.send_or_hold(message), ++ OutboundHold::Bypass => transaction ++ .global() ++ .storage_threads() ++ .send(message) ++ .map_err(|_| ()), ++ }; ++ if sent.is_err() { ++ warn!("Could not send an IndexedDB request to the storage backend"); ++ } + + // Step 6 + Ok(request) +@@ -599,8 +1392,20 @@ impl IDBRequestMethods for IDBRequest { + } + + /// +- fn GetSource(&self) -> Option> { +- self.source.get() ++ fn GetSource(&self) -> Option { ++ // A cursor's reflector is the `IDBCursorWithValue` object when the cursor has one, so ++ // rooting it as an `IDBCursor` still hands script back the object it already holds. ++ self.source.borrow().as_ref().map(|source| match source { ++ RequestSource::ObjectStore(store) => { ++ IDBObjectStoreOrIDBIndexOrIDBCursor::IDBObjectStore(store.as_rooted()) ++ }, ++ RequestSource::Index(index) => { ++ IDBObjectStoreOrIDBIndexOrIDBCursor::IDBIndex(index.as_rooted()) ++ }, ++ RequestSource::Cursor(cursor) => { ++ IDBObjectStoreOrIDBIndexOrIDBCursor::IDBCursor(cursor.as_rooted()) ++ }, ++ }) + } + + /// +diff --git a/components/script/dom/indexeddb/idbtransaction.rs b/components/script/dom/indexeddb/idbtransaction.rs +index 9f0ce71290..d950883b9e 100644 +--- a/components/script/dom/indexeddb/idbtransaction.rs ++++ b/components/script/dom/indexeddb/idbtransaction.rs +@@ -3,7 +3,7 @@ + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + + use std::cell::Cell; +-use std::collections::{HashMap, HashSet}; ++use std::collections::{HashMap, HashSet, VecDeque}; + + use dom_struct::dom_struct; + use js::context::JSContext; +@@ -42,7 +42,7 @@ use crate::dom::globalscope::GlobalScope; + use crate::dom::indexeddb::idbdatabase::IDBDatabase; + use crate::dom::indexeddb::idbobjectstore::{IDBObjectStore, IDBObjectStoreAbortState}; + use crate::dom::indexeddb::idbrequest::IDBRequest; +-use crate::indexeddb::map_backend_error_to_dom_error; ++use crate::indexeddb::{map_backend_error_to_dom_error, reply_lost}; + + #[dom_struct] + pub struct IDBTransaction { +@@ -83,11 +83,36 @@ pub struct IDBTransaction { + next_unhandled_request_id: Cell, + handled_pending: DomRefCell>, + ++ /// Outbound messages this transaction is holding back, in the order script placed them. ++ /// ++ /// `createIndex` populates its index over a round trip: the records go to the script ++ /// thread, which is the only place the index's key path can be evaluated against a stored ++ /// value, and the extracted keys come back as a write. Anything script places between the ++ /// two would otherwise reach the backend first and be ordered ahead of the index records, ++ /// so the index would read as empty right after it was made, and a `put` that belongs ++ /// before the backfill would be missing from it. ++ #[no_trace] ++ #[ignore_malloc_size_of = "thread messages carry IPC callbacks with no size to report"] ++ held_outbound: DomRefCell>, ++ /// Whether `held_outbound` is taking messages. It stays set while a backfill is in flight ++ /// and while any later backfill is still queued behind it. ++ outbound_held: Cell, ++ + // An unique identifier, used to commit and revert this transaction + // FIXME:(rasviitanen) Replace this with a channel + serial_number: u64, + } + ++/// One entry in a transaction's outbound hold. ++pub(crate) enum HeldOutbound { ++ /// A message waiting for the hold to lift. ++ Message(IndexedDBThreadMsg), ++ /// The end of a `createIndex` backfill's read. Draining stops here: the read ahead of it ++ /// has gone out, and everything behind it keeps waiting until that backfill's write is on ++ /// its way, because the write has to be ordered ahead of them. ++ Barrier, ++} ++ + impl IDBTransaction { + fn new_inherited( + connection: &IDBDatabase, +@@ -123,11 +148,18 @@ impl IDBTransaction { + next_request_id: Cell::new(0), + next_unhandled_request_id: Cell::new(0), + handled_pending: Default::default(), ++ held_outbound: Default::default(), ++ outbound_held: Cell::new(false), + serial_number, + } + } + + /// Does a blocking call to create a backend transaction and get its id. ++ /// ++ /// Fails when the backend cannot be reached, which `IDBDatabase.transaction()` turns into ++ /// a thrown exception. That is the one place an IndexedDB failure has nowhere else to go: ++ /// every other operation already owns a request to fail, and this one is what the request ++ /// would have belonged to. + pub fn new( + cx: &mut JSContext, + global: &GlobalScope, +@@ -135,10 +167,10 @@ impl IDBTransaction { + mode: IDBTransactionMode, + durability: IDBTransactionDurability, + scope: &DOMStringList, +- ) -> DomRoot { ++ ) -> Fallible> { + let serial_number = +- IDBTransaction::create_transaction(global, connection.get_name(), mode, scope); +- IDBTransaction::new_with_serial( ++ IDBTransaction::create_transaction(global, connection.get_name(), mode, scope)?; ++ Ok(IDBTransaction::new_with_serial( + cx, + global, + connection, +@@ -146,7 +178,7 @@ impl IDBTransaction { + durability, + scope, + serial_number, +- ) ++ )) + } + + pub(crate) fn new_with_serial( +@@ -171,12 +203,17 @@ impl IDBTransaction { + ) + } + ++ /// Ask the backend to open a transaction and block until it answers with the identity. ++ /// ++ /// Each failure below is the storage thread being gone or not answering, which script can ++ /// do nothing about. What it can do is see an exception rather than a dead tab, which is ++ /// what these used to be: three panics on a path every `transaction()` call takes. + fn create_transaction( + global: &GlobalScope, + db_name: DOMString, + mode: IDBTransactionMode, + scope: &DOMStringList, +- ) -> u64 { ++ ) -> Fallible { + let backend_mode = match mode { + IDBTransactionMode::Readonly => IndexedDBTxnMode::Readonly, + IDBTransactionMode::Readwrite => IndexedDBTxnMode::Readwrite, +@@ -186,20 +223,34 @@ impl IDBTransaction { + .filter_map(|i| scope.Item(i)) + .map(String::from) + .collect(); +- let (sender, receiver) = channel(global.time_profiler_chan().clone()).unwrap(); ++ let Some((sender, receiver)) = channel(global.time_profiler_chan().clone()) else { ++ return Err(Error::Operation(Some( ++ "IndexedDB could not open a channel to the storage backend".to_owned(), ++ ))); ++ }; + +- global ++ let operation = SyncOperation::CreateTransaction { ++ sender, ++ origin: global.origin().immutable().clone(), ++ db_name: String::from(db_name), ++ mode: backend_mode, ++ scope, ++ }; ++ if let Err(error) = global + .storage_threads() +- .send(IndexedDBThreadMsg::Sync(SyncOperation::CreateTransaction { +- sender, +- origin: global.origin().immutable().clone(), +- db_name: String::from(db_name), +- mode: backend_mode, +- scope, +- })) +- .expect("Failed to send IndexedDBThreadMsg::Sync"); ++ .send(IndexedDBThreadMsg::Sync(operation)) ++ { ++ return Err(Error::Operation(Some(format!( ++ "IndexedDB could not reach the storage backend: {error:?}" ++ )))); ++ } + +- receiver.recv().unwrap().expect("CreateTransaction failed") ++ match receiver.recv() { ++ Ok(result) => result.map_err(map_backend_error_to_dom_error), ++ Err(error) => Err(map_backend_error_to_dom_error(BackendError::ReplyLost( ++ format!("{error:?}"), ++ ))), ++ } + } + + /// +@@ -235,8 +286,14 @@ impl IDBTransaction { + self.committing.get() + } + ++ /// ++ /// Step 6: when a transaction is committed or aborted, its state is set to finished. ++ /// sets that state at step 6, ++ /// before the task that fires the `abort` event, so the state has to follow the ++ /// initiation rather than the task. `finalize_abort` and `finalize_commit` still read ++ /// the field, because they are what queues those tasks. + pub(crate) fn is_finished(&self) -> bool { +- self.finished.get() ++ self.finished.get() || self.abort_initiated.get() + } + + pub(crate) fn set_cleanup_event_loop(&self) { +@@ -276,6 +333,15 @@ impl IDBTransaction { + .insert(name.to_string(), Dom::from_ref(store)); + } + ++ /// The object store handle associated with `name` and this transaction, if script has ++ /// already asked for one. ++ pub(crate) fn object_store_handle(&self, name: &DOMString) -> Option> { ++ self.store_handles ++ .borrow() ++ .get(&name.to_string()) ++ .map(|store| DomRoot::from_ref(&**store)) ++ } ++ + pub(crate) fn rename_object_store_handle_cache( + &self, + old_name: &DOMString, +@@ -323,8 +389,14 @@ impl IDBTransaction { + let task_source = task_source.clone(); + task_source.queue(task!(handle_commit_result: move |cx| { + let this = this.root(); +- let message = message.expect("Could not unwrap message"); +- match message.result { ++ // A commit whose answer was lost is not a commit that happened. Firing ++ // `complete` at it would tell script that writes landed which may not ++ // have, so it takes the same path as a commit the backend refused. ++ let result = match message { ++ Ok(message) => message.result, ++ Err(error) => Err(reply_lost(error)), ++ }; ++ match result { + Ok(()) => { + this.finalize_commit(); + } +@@ -339,8 +411,17 @@ impl IDBTransaction { + // Backend commit/rollback is not yet atomic. + })); + }, +- ) +- .expect("Could not create callback"); ++ ); ++ let callback = match callback { ++ Ok(callback) => callback, ++ Err(error) => { ++ // A commit that cannot be initiated is reported by returning false, which is ++ // what the failed send below does; `maybe_commit` answers that by aborting the ++ // transaction rather than leaving it waiting for a `complete` that cannot come. ++ warn!("Could not create the IndexedDB commit callback: {error:?}"); ++ return false; ++ }, ++ }; + + let commit_operation = SyncOperation::Commit( + callback, +@@ -456,10 +537,19 @@ impl IDBTransaction { + } + } + ++ /// step 4: add the ++ /// request to the end of this transaction's request list. ++ /// ++ /// The list holds requests, not executions. A cursor's `continue()` runs a second operation ++ /// against the request script already holds, and a request that is already listed moves to ++ /// the end rather than appearing twice: the list is walked once per request when an abort ++ /// answers everything still outstanding, and a request listed twice would be settled twice. + pub fn add_request(&self, request: &IDBRequest) { +- self.requests.borrow_mut().push(Dom::from_ref(request)); +- // Increase the number of outstanding requests so that we can detect when +- // the transaction is allowed to finish. ++ let mut requests = self.requests.borrow_mut(); ++ requests.retain(|listed| *listed != request); ++ requests.push(Dom::from_ref(request)); ++ // The count, unlike the list, counts executions: a reused request owes one answer per ++ // operation run against it, and each of those answers calls `request_finished`. + self.pending_request_count + .set(self.pending_request_count.get() + 1); + } +@@ -499,6 +589,9 @@ impl IDBTransaction { + self.restore_associated_object_store_handles_after_abort(cx); + } + self.abort_initiated.set(true); ++ // An aborted transaction answers every outstanding request from the abort itself, so ++ // anything a `createIndex` backfill was holding back has nowhere left to land. ++ self.discard_held_outbound(); + // https://w3c.github.io/IndexedDB/#transaction-concept + // A transaction has a error which is set if the transaction is aborted. + // NOTE: Implementors need to keep in mind that the value "null" is considered an error, as it is set from abort() +@@ -507,6 +600,27 @@ impl IDBTransaction { + { + self.error.set(Some(&exception)); + } ++ self.abort_pending_requests(); ++ } ++ ++ /// step 5. ++ /// ++ /// Every request the transaction still owes an answer to is answered by the abort instead: ++ /// the answer the backend is still going to send is discarded, and a request the outbound ++ /// hold was carrying, which `discard_held_outbound` has just thrown away, would otherwise ++ /// never be answered at all. The error events are queued here, so they fire ahead of the ++ /// transaction's own `abort` event, which is still waiting on a round trip to the backend. ++ fn abort_pending_requests(&self) { ++ let pending: Vec> = self ++ .requests ++ .borrow() ++ .iter() ++ .filter(|request| request.is_awaiting_answer()) ++ .map(|request| request.as_rooted()) ++ .collect(); ++ for request in pending { ++ request.settle_by_abort(); ++ } + } + + pub(crate) fn request_backend_abort(&self) { +@@ -520,6 +634,10 @@ impl IDBTransaction { + .task_manager() + .dom_manipulation_task_source() + .to_sendable(); ++ // Kept out of the closure below, which consumes the originals, so the abort can still ++ // be finalized if the backend can never be told about it. ++ let unsent_this = Trusted::new(self); ++ let unsent_task_source = task_source.clone(); + let callback = GenericCallback::new( + global.time_profiler_chan().clone(), + move |message: Result| { +@@ -527,12 +645,31 @@ impl IDBTransaction { + let task_source = task_source.clone(); + task_source.queue(task!(handle_abort_result: move || { + let this = this.root(); +- let _ = message.expect("Could not unwrap message"); ++ // The abort is finalized whichever way the reply went. The backend was ++ // told to abort, and a transaction that never finalizes stays wedged ++ // with its requests unanswered, so a lost answer is worth reporting and ++ // not worth stopping for. ++ if let Err(error) = message { ++ warn!("Lost the backend's answer to an abort: {error}"); ++ } + this.finalize_abort(); + })); + }, +- ) +- .expect("Could not create callback"); ++ ); ++ let callback = match callback { ++ Ok(callback) => callback, ++ Err(error) => { ++ // The abort message carries this callback, so without it the backend is never ++ // told and the reply that would have run `finalize_abort` can never arrive. ++ // For the same reason the callback finalizes on a lost answer, finalize here: ++ // a transaction that never finalizes stays wedged with its requests unanswered. ++ warn!("Could not create the IndexedDB abort callback: {error:?}"); ++ unsent_task_source.queue(task!(finalize_unsent_abort: move || { ++ unsent_this.root().finalize_abort(); ++ })); ++ return; ++ }, ++ }; + let operation = SyncOperation::Abort( + callback, + global.origin().immutable().clone(), +@@ -576,14 +713,21 @@ impl IDBTransaction { + // https://w3c.github.io/IndexedDB/#abort-an-upgrade-transaction + this.db.set_version(old_version); + } +- this.db.clear_upgrade_transaction(&this); ++ // Abort finalization is already the structured failure path. A missing or ++ // mismatched connection slot is diagnosed by the identity-aware cleanup; ++ // importantly, it never clears another transaction. ++ let _ = this.db.clear_upgrade_transaction(&this); + } + let global = this.global(); + let event = Event::new( + cx, + &global, + Atom::from("abort"), +- EventBubbles::DoesNotBubble, ++ // step 6.2 fires ++ // this one with its bubbles attribute initialized to true, which is how a ++ // connection's `onabort` hears about a transaction it did not listen to ++ // directly. ++ EventBubbles::Bubbles, + EventCancelable::NotCancelable, + ); + event.fire(cx, this.upcast()); +@@ -630,16 +774,30 @@ impl IDBTransaction { + let this = this.root(); + this.committing.set(false); + this.commit_started.set(false); +- this.version_change_old_version.set(None); +- this.version_change_old_object_store_names +- .borrow_mut() +- .take(); + if this.mode == IDBTransactionMode::Versionchange { + // https://w3c.github.io/IndexedDB/#commit-transaction + // Step 5.1: If transaction is an upgrade transaction, then set transaction’s connection’s + // associated database’s upgrade transaction to null. +- this.db.clear_upgrade_transaction(&this); ++ if let Err(error) = this.db.clear_upgrade_transaction(&this) { ++ // A versionchange transaction cannot report successful completion while ++ // its connection has lost or replaced the transaction being completed. ++ // Convert the invariant failure into the normal structured abort path. ++ this.initiate_abort( ++ cx, ++ Error::Operation(Some(format!( ++ "Could not clear the IndexedDB upgrade transaction: {error:?}" ++ ))), ++ ); ++ this.request_backend_abort(); ++ return; ++ } + } ++ // Keep the rollback snapshots intact until fallible upgrade cleanup succeeds. ++ // If cleanup fails, the abort path above still needs both to restore DOM state. ++ this.version_change_old_version.set(None); ++ this.version_change_old_object_store_names ++ .borrow_mut() ++ .take(); + // https://w3c.github.io/IndexedDB/#commit-transaction + // Step 5.2: Set transaction’s state to finished. + this.finished.set(true); +@@ -685,14 +843,81 @@ impl IDBTransaction { + self.global().storage_threads().sender() + } + ++ /// Send a message to the storage backend, or hold it if a `createIndex` backfill is still ++ /// running. ++ /// ++ /// Every request and schema operation this transaction places goes through here, because ++ /// the backend runs one transaction's operations in the order they arrive and the backfill's ++ /// write has to land ahead of anything script placed after the `createIndex` that started ++ /// it. ++ pub(crate) fn send_or_hold(&self, message: IndexedDBThreadMsg) -> Result<(), ()> { ++ if self.outbound_held.get() { ++ self.held_outbound ++ .borrow_mut() ++ .push_back(HeldOutbound::Message(message)); ++ return Ok(()); ++ } ++ self.get_idb_thread().send(message).map_err(|_| ()) ++ } ++ ++ /// Hold everything script places from here on, for a `createIndex` backfill whose read has ++ /// just been placed. ++ /// ++ /// A second `createIndex` while an earlier backfill is still running leaves its read in the ++ /// queue and marks the queue behind it, so the drain stops there and that backfill takes ++ /// the hold in turn. ++ pub(crate) fn hold_outbound_after_backfill(&self) { ++ if self.outbound_held.get() { ++ self.held_outbound ++ .borrow_mut() ++ .push_back(HeldOutbound::Barrier); ++ return; ++ } ++ self.outbound_held.set(true); ++ } ++ ++ /// A backfill's write has been sent. Release the messages held behind it, stopping at the ++ /// next queued backfill's read. ++ pub(crate) fn resume_after_backfill(&self) { ++ loop { ++ let entry = self.held_outbound.borrow_mut().pop_front(); ++ match entry { ++ Some(HeldOutbound::Message(message)) => { ++ if self.get_idb_thread().send(message).is_err() { ++ warn!("Could not send a held IndexedDB message"); ++ } ++ }, ++ // The read just sent belongs to the next backfill, which owns the hold now. ++ Some(HeldOutbound::Barrier) => return, ++ None => { ++ self.outbound_held.set(false); ++ return; ++ }, ++ } ++ } ++ } ++ ++ /// Drop everything the hold is carrying without sending it. ++ /// ++ /// An aborted transaction answers every outstanding request from the abort itself, so the ++ /// held messages have nowhere to land. ++ pub(crate) fn discard_held_outbound(&self) { ++ self.held_outbound.borrow_mut().clear(); ++ self.outbound_held.set(false); ++ } ++ + fn object_store_parameters( + &self, + object_store_name: &DOMString, +- ) -> Option<(IDBObjectStoreParameters, Vec, Option)> { ++ ) -> Option<(IDBObjectStoreParameters, Vec)> { + let global = self.global(); + let idb_sender = global.storage_threads().sender(); +- let (sender, receiver) = +- channel(global.time_profiler_chan().clone()).expect("failed to create channel"); ++ // A store whose parameters cannot even be asked for reads the same way to the caller as ++ // a store the backend could not find, which the `?`s below already report as `None`. ++ let Some((sender, receiver)) = channel(global.time_profiler_chan().clone()) else { ++ warn!("Could not create a channel to read IndexedDB object store parameters."); ++ return None; ++ }; + + let origin = global.origin().immutable().clone(); + let db_name = String::from(self.db.get_name()); +@@ -702,12 +927,11 @@ impl IDBTransaction { + + let _ = idb_sender.send(IndexedDBThreadMsg::Sync(operation)); + +- // First unwrap for ipc +- // Second unwrap will never happen unless this db gets manually deleted somehow ++ // A lost reply and a store the backend could not find are both `None` here, which the ++ // caller reads as the store not being there. The comments this replaces described ++ // unwraps that no longer exist. + let object_store = receiver.recv().ok()?.ok()?; + +- // First unwrap for ipc +- // Second unwrap will never happen unless this db gets manually deleted somehow + let key_path = object_store.key_path.map(|key_path| match key_path { + KeyPath::String(string) => StringOrStringSequence::String(string.into()), + KeyPath::Sequence(seq) => { +@@ -720,11 +944,15 @@ impl IDBTransaction { + keyPath: key_path, + }, + object_store.indexes, +- object_store.key_generator_current_number, + )) + } + +- pub(crate) fn create_abort_callback(&self) -> GenericCallback { ++ /// The callback an `AsyncSchemaOperation` carries to report why it failed. ++ /// ++ /// Returns a structured failure when the reply channel cannot be created. The schema ++ /// operation cannot be sent without it, so callers must propagate the failure before ++ /// changing their local handle state. ++ pub(crate) fn create_abort_callback(&self) -> Fallible> { + let trusted_transaction = Trusted::new(self); + let task_source = self + .global() +@@ -734,18 +962,32 @@ impl IDBTransaction { + GenericCallback::new( + self.global().time_profiler_chan().clone(), + move |error: Result| { +- let Ok(error) = error else { +- return; ++ let error = match error { ++ Ok(error) => map_backend_error_to_dom_error(error), ++ // The backend reports schema failure through this callback; it does not ++ // independently abort the DOM transaction. If the reply is lost, preserve ++ // that failure semantic with a generic operation error. ++ Err(error) => { ++ warn!("Lost the reason an IndexedDB schema operation failed: {error}"); ++ Error::Operation(Some( ++ "The IndexedDB schema operation failure reply was lost".to_owned(), ++ )) ++ }, + }; + let trusted_transaction = trusted_transaction.clone(); + task_source.queue(task!(delete_failed: move |cx| { + let transaction = trusted_transaction.root(); +- transaction.initiate_abort(cx, map_backend_error_to_dom_error(error)); ++ transaction.initiate_abort(cx, error); + transaction.request_backend_abort(); + })); + }, + ) +- .expect("Could not create GenericCallback") ++ .map_err(|error| { ++ warn!("Could not create an IndexedDB schema operation abort callback: {error:?}"); ++ Error::Operation(Some( ++ "Could not create the IndexedDB schema operation callback".to_owned(), ++ )) ++ }) + } + } + +@@ -791,24 +1033,21 @@ impl IDBTransactionMethods for IDBTransaction { + &self.global(), + self.db.get_name(), + name.clone(), +- parameters.as_ref().map(|(params, _, _)| params), ++ parameters.as_ref().map(|(params, _)| params), + IDBObjectStoreAbortState { + newly_created_during_transaction: false, + rollback_indexes_on_abort: if self.mode == IDBTransactionMode::Versionchange { + parameters + .as_ref() +- .map(|(_, indexes, _)| indexes.clone()) ++ .map(|(_, indexes)| indexes.clone()) + .unwrap_or_default() + } else { + Vec::new() + }, +- key_generator_current_number: parameters +- .as_ref() +- .and_then(|(_, _, key_generator_current_number)| *key_generator_current_number), + }, + self, + ); +- if let Some(indexes) = parameters.map(|(_, indexes, _)| indexes) { ++ if let Some(indexes) = parameters.map(|(_, indexes)| indexes) { + for index in indexes { + store.add_index( + cx, +@@ -818,6 +1057,7 @@ impl IDBTransactionMethods for IDBTransaction { + unique: index.unique, + }, + index.key_path.into(), ++ false, + ); + } + } +@@ -842,7 +1082,10 @@ impl IDBTransactionMethods for IDBTransaction { + + /// + fn Abort(&self, cx: &mut JSContext) -> Fallible<()> { +- if self.finished.get() || self.committing.get() { ++ // Step 1. If this's state is committing or finished, throw an "InvalidStateError" ++ // DOMException. An abort that has been initiated already finished the state, so a ++ // second abort() throws rather than running the algorithm twice. ++ if self.finished.get() || self.abort_initiated.get() || self.committing.get() { + return Err(Error::InvalidState(None)); + } + self.active.set(false); +diff --git a/components/script/dom/indexeddb/mod.rs b/components/script/dom/indexeddb/mod.rs +index 22fc0692bc..267ca3ee4e 100644 +--- a/components/script/dom/indexeddb/mod.rs ++++ b/components/script/dom/indexeddb/mod.rs +@@ -10,6 +10,7 @@ pub(crate) mod idbindex; + pub(crate) mod idbkeyrange; + pub(crate) mod idbobjectstore; + pub(crate) mod idbopendbrequest; ++pub(crate) mod idbrecord; + pub(crate) mod idbrequest; + pub(crate) mod idbtransaction; + pub(crate) mod idbversionchangeevent; +diff --git a/components/script/indexeddb.rs b/components/script/indexeddb.rs +index 21748734f9..73260a600b 100644 +--- a/components/script/indexeddb.rs ++++ b/components/script/indexeddb.rs +@@ -10,14 +10,15 @@ use js::context::JSContext; + use js::conversions::{ToJSValConvertible, jsstr_to_string}; + use js::jsapi::{ + ClippedTime, IsArrayBufferObject, IsDetachedArrayBufferObject, JS_GetArrayBufferViewBuffer, +- JS_GetStringLength, JS_IsArrayBufferViewObject, NewArrayObject1, PropertyKey, ++ JS_GetStringLength, JS_IsArrayBufferViewObject, JSPROP_ENUMERATE, NewArrayObject1, PropertyKey, + }; + use js::jsval::{DoubleValue, ObjectValue, UndefinedValue}; + use js::rust::wrappers2::{ +- GetArrayLength, IsArrayObject, JS_HasOwnPropertyById, JS_IndexToId, JS_IsIdentifier, +- JS_NewObject, NewDateObject, ObjectIsDate, SameValue, ++ GetArrayLength, IsArrayObject, JS_DefinePropertyById2, JS_GetPropertyById, ++ JS_HasOwnPropertyById, JS_IndexToId, JS_IsIdentifier, JS_NewObject, JS_StringToId, ++ NewDateObject, ObjectIsDate, SameValue, + }; +-use js::rust::{HandleValue, MutableHandleValue}; ++use js::rust::{HandleId, HandleObject, HandleValue, MutableHandleId, MutableHandleValue}; + use js::typedarray::{ArrayBuffer, ArrayBufferView, CreateWith}; + use storage_traits::indexeddb::{BackendError, IndexedDBKeyRange, IndexedDBKeyType}; + +@@ -27,21 +28,27 @@ use crate::dom::bindings::codegen::UnionTypes::StringOrStringSequence as StrOrSt + use crate::dom::bindings::conversions::{ + get_property_jsval, root_from_handlevalue, root_from_object, + }; +-use crate::dom::bindings::error::Error; ++use crate::dom::bindings::error::{Error, Fallible}; + use crate::dom::bindings::str::DOMString; +-use crate::dom::bindings::utils::{define_dictionary_property, has_own_property}; ++use crate::dom::bindings::utils::define_dictionary_property; + use crate::dom::blob::Blob; + use crate::dom::file::File; + use crate::dom::idbkeyrange::IDBKeyRange; + use crate::dom::idbobjectstore::KeyPath; + +-// https://www.w3.org/TR/IndexedDB-3/#convert-key-to-value ++/// ++/// ++/// The spec asserts that each conversion step is not an abrupt completion. Those ++/// assertions hold for the algorithm, not for the allocator: `NewDateObject`, ++/// `ArrayBuffer::create` and `NewArrayObject1` all return null under memory pressure. ++/// Reporting that as `Error::JSFailed` rejects the request the conversion belongs to ++/// rather than killing the content process along with every other page in it. + #[expect(unsafe_code)] + pub fn key_type_to_jsval( + cx: &mut JSContext, + key: &IndexedDBKeyType, + mut result: MutableHandleValue, +-) { ++) -> Fallible<()> { + // Step 1. Let type be key’s type. + // Step 2. Let value be key’s value. + // Step 3. Switch on type: +@@ -58,10 +65,9 @@ pub fn key_type_to_jsval( + let date = NewDateObject(cx, ClippedTime { t: *d }); + + // Step 3.2. Assert: date is not an abrupt completion. +- assert!( +- !date.is_null(), +- "Failed to convert IndexedDB date key into a Date" +- ); ++ if date.is_null() { ++ return Err(Error::JSFailed); ++ } + + // Step 3.3. Return date. + date.safe_to_jsval(cx, result); +@@ -74,21 +80,18 @@ pub fn key_type_to_jsval( + // Step 3.2. Let buffer be the result of executing the ECMAScript + // ArrayBuffer constructor with len. + rooted!(&in(cx) let mut buffer = ptr::null_mut::()); +- assert!( +- ArrayBuffer::create(cx.raw_cx(), CreateWith::Length(len), buffer.handle_mut()) +- .is_ok(), +- "Failed to convert IndexedDB binary key into an ArrayBuffer" +- ); ++ ArrayBuffer::create(cx.raw_cx(), CreateWith::Length(len), buffer.handle_mut()) ++ .map_err(|()| Error::JSFailed)?; + + // Step 3.3. Assert: buffer is not an abrupt completion. + + // Step 3.4. Set the entries in buffer’s [[ArrayBufferData]] internal slot to the + // entries in value. +- let mut array_buffer = ArrayBuffer::from(buffer.get()) +- .expect("ArrayBuffer::create should create an ArrayBuffer object"); ++ let mut array_buffer = ++ ArrayBuffer::from(buffer.get()).map_err(|()| Error::JSFailed)?; + array_buffer + .as_mut_slice_safe(cx.no_gc()) +- .expect("Can't be detached") ++ .ok_or(Error::JSFailed)? + .copy_from_slice(b); + + // Step 3.5. Return buffer. +@@ -101,10 +104,9 @@ pub fn key_type_to_jsval( + rooted!(&in(cx) let array = NewArrayObject1(cx.raw_cx(), 0)); + + // Step 3.2. Assert: array is not an abrupt completion. +- assert!( +- !array.get().is_null(), +- "Failed to convert IndexedDB array key into an Array" +- ); ++ if array.get().is_null() { ++ return Err(Error::JSFailed); ++ } + + // Step 3.3. Let len be value’s size. + let len = a.len(); +@@ -117,27 +119,20 @@ pub fn key_type_to_jsval( + // Step 3.5.1. Let entry be the result of converting a key to a value with + // value[index]. + rooted!(&in(cx) let mut entry = UndefinedValue()); +- key_type_to_jsval(cx, &a[index], entry.handle_mut()); ++ key_type_to_jsval(cx, &a[index], entry.handle_mut())?; + + // Step 3.5.2. Let status be CreateDataProperty(array, index, entry). +- let index_property = CString::new(index.to_string()); +- assert!( +- index_property.is_ok(), +- "Failed to convert IndexedDB array index to CString" +- ); +- let index_property = index_property.unwrap(); +- let status = define_dictionary_property( ++ let index_property = ++ CString::new(index.to_string()).map_err(|_| Error::JSFailed)?; ++ ++ // Step 3.5.3. Assert: status is true. ++ define_dictionary_property( + cx, + array.handle(), + index_property.as_c_str(), + entry.handle(), +- ); +- +- // Step 3.5.3. Assert: status is true. +- assert!( +- status.is_ok(), +- "CreateDataProperty on a fresh JS array should not fail" +- ); ++ ) ++ .map_err(|()| Error::JSFailed)?; + + // Step 3.5.4. Increase index by 1. + index += 1; +@@ -147,6 +142,8 @@ pub fn key_type_to_jsval( + result.set(ObjectValue(array.get())); + }, + } ++ ++ Ok(()) + } + + /// +@@ -209,16 +206,28 @@ pub(crate) fn is_valid_key_path( + } + } + ++/// The result of converting a value to a key. ++/// ++/// The two failures read alike at every ordinary call site, which throws a "DataError" for ++/// either. They are kept apart because `getAll` and `getAllKeys` tell their first argument ++/// apart by exactly this distinction: a value of a key type that happens not to convert (a NaN ++/// `Date`, a detached buffer, an array holding a non-key) is still a query and still throws, ++/// while a value of no key type at all is an `IDBGetAllOptions` dictionary. + pub(crate) enum ConversionResult { + Valid(IndexedDBKeyType), +- Invalid, ++ /// A value whose type is a key type, carrying something that is not a key. ++ InvalidValue, ++ /// A value whose type is not a key type at all. ++ InvalidType, + } + + impl ConversionResult { + pub fn into_result(self) -> Result { + match self { + ConversionResult::Valid(key) => Ok(key), +- ConversionResult::Invalid => Err(Error::Data(None)), ++ ConversionResult::InvalidValue | ConversionResult::InvalidType => { ++ Err(Error::Data(None)) ++ }, + } + } + } +@@ -240,7 +249,7 @@ pub fn convert_value_to_key( + return Err(Error::JSFailed); + } + if same { +- return Ok(ConversionResult::Invalid); ++ return Ok(ConversionResult::InvalidValue); + } + } + +@@ -250,7 +259,7 @@ pub fn convert_value_to_key( + if input.is_number() { + // 3.1. If input is NaN then return "invalid value". + if input.to_number().is_nan() { +- return Ok(ConversionResult::Invalid); ++ return Ok(ConversionResult::InvalidValue); + } + // 3.2. Otherwise, return a new key with type number and value input. + return Ok(ConversionResult::Valid(IndexedDBKeyType::Number( +@@ -261,7 +270,7 @@ pub fn convert_value_to_key( + // If Type(input) is String: + if input.is_string() { + // 3.1. Return a new key with type string and value input. +- let string_ptr = std::ptr::NonNull::new(input.to_string()).unwrap(); ++ let string_ptr = std::ptr::NonNull::new(input.to_string()).ok_or(Error::JSFailed)?; + let key = unsafe { jsstr_to_string(cx, string_ptr) }; + return Ok(ConversionResult::Valid(IndexedDBKeyType::String(key))); + } +@@ -283,7 +292,7 @@ pub fn convert_value_to_key( + } + // 3.2. If ms is NaN then return "invalid value". + if ms.is_nan() { +- return Ok(ConversionResult::Invalid); ++ return Ok(ConversionResult::InvalidValue); + } + // 3.3. Otherwise, return a new key with type date and value ms. + return Ok(ConversionResult::Valid(IndexedDBKeyType::Date(ms))); +@@ -308,7 +317,7 @@ pub fn convert_value_to_key( + }; + // 3.1. If input is detached then return "invalid value". + if is_detached { +- return Ok(ConversionResult::Invalid); ++ return Ok(ConversionResult::InvalidValue); + } + // 3.2. Let bytes be the result of getting a copy of the bytes held + // by the buffer source input. +@@ -320,7 +329,7 @@ pub fn convert_value_to_key( + ArrayBufferView::from(*object).map_err(|()| Error::JSFailed)?; + array_buffer_view.to_vec() + } +- .expect("Already checked for detached buffers"); ++ .ok_or(Error::JSFailed)?; + // 3.3. Return a new key with type binary and value bytes. + return Ok(ConversionResult::Valid(IndexedDBKeyType::Binary(bytes))); + } +@@ -355,7 +364,7 @@ pub fn convert_value_to_key( + } + // 3.5.2. If hop is false, return "invalid value". + if !hop { +- return Ok(ConversionResult::Invalid); ++ return Ok(ConversionResult::InvalidValue); + } + // 3.5.3. Let entry be ? Get(input, index). + rooted!(&in(cx) let mut entry = UndefinedValue()); +@@ -375,7 +384,9 @@ pub fn convert_value_to_key( + ConversionResult::Valid(key) => key, + // 3.5.6. If key is "invalid value" or "invalid type" + // abort these steps and return "invalid value". +- ConversionResult::Invalid => return Ok(ConversionResult::Invalid), ++ ConversionResult::InvalidValue | ConversionResult::InvalidType => { ++ return Ok(ConversionResult::InvalidValue); ++ }, + }; + // 3.5.7. Append key to keys. + keys.push(key); +@@ -389,7 +400,118 @@ pub fn convert_value_to_key( + } + + // Otherwise, return "invalid type". +- Ok(ConversionResult::Invalid) ++ Ok(ConversionResult::InvalidType) ++} ++ ++/// ++/// ++/// This differs from converting a value to a key in exactly one way, and the difference is the ++/// point of a multiEntry index: an array element that is a hole, or that is not a valid key, is ++/// skipped instead of invalidating the whole result, and an element equal to one already taken is ++/// dropped. A record whose indexed array holds one unusable element still gets index records for ++/// the rest. ++#[expect(unsafe_code)] ++pub fn convert_value_to_multientry_key( ++ cx: &mut JSContext, ++ input: HandleValue, ++) -> Result { ++ // Step 1. If input is an Array exotic object, then: ++ if input.is_object() { ++ rooted!(&in(cx) let object = input.to_object()); ++ let mut is_array = false; ++ if unsafe { !IsArrayObject(cx, input, &mut is_array) } { ++ return Err(Error::JSFailed); ++ } ++ if is_array { ++ // Step 1.1. Let len be ? ToLength( ? Get(input, "length")). ++ let mut len = 0; ++ if unsafe { !GetArrayLength(cx, object.handle(), &mut len) } { ++ return Err(Error::JSFailed); ++ } ++ // Step 1.2. Let seen be a new set containing only input. ++ let seen = vec![input]; ++ // Step 1.3. Let keys be a new empty list. ++ let mut keys: Vec = vec![]; ++ // Step 1.4. Let index be 0. ++ let mut index: u32 = 0; ++ // Step 1.5. While index is less than len: ++ while index < len { ++ rooted!(&in(cx) let mut id: PropertyKey); ++ if unsafe { !JS_IndexToId(cx, index, id.handle_mut()) } { ++ return Err(Error::JSFailed); ++ } ++ rooted!(&in(cx) let mut entry = UndefinedValue()); ++ // Step 1.5.1. Let entry be Get(input, index). ++ // Step 1.5.2. If entry is not an abrupt completion, then: ++ if unsafe { ++ js::rust::wrappers2::JS_GetPropertyById( ++ cx, ++ object.handle(), ++ id.handle(), ++ entry.handle_mut(), ++ ) ++ } { ++ // Step 1.5.2.1. Let key be the result of converting a value to a key with ++ // entry and seen. ++ // Step 1.5.2.2. If key is not invalid or an abrupt completion, and there is ++ // no item in keys equal to key, then append key to keys. ++ if let Ok(ConversionResult::Valid(key)) = ++ convert_value_to_key(cx, entry.handle(), Some(seen.clone())) ++ { ++ if !keys.contains(&key) { ++ keys.push(key); ++ } ++ } ++ } ++ // Step 1.5.3. Increase index by 1. ++ index += 1; ++ } ++ // Step 1.6. Return a new array key with value keys. ++ return Ok(ConversionResult::Valid(IndexedDBKeyType::Array(keys))); ++ } ++ } ++ ++ // Step 2. Otherwise, return the result of converting a value to a key with input. ++ convert_value_to_key(cx, input, None) ++} ++ ++/// ++/// ++/// This is how `getAll` and `getAllKeys` tell a query from an `IDBGetAllOptions` dictionary. ++/// The question is about the value's type, not about the value: a NaN `Date`, a detached ++/// buffer and an array holding a non-key are all potentially valid key ranges, so they reach ++/// `convert a value to a key range` and throw a "DataError" there, instead of quietly being ++/// read as a dictionary with default members. ++/// ++/// The spec's step list answers false for `undefined` and `null`, which is a spec bug: both ++/// convert to an unbounded key range, so both satisfy the definition this algorithm is named ++/// after, and reading them as a dictionary would discard the positional `count` that ++/// `getAll(undefined, 10)` passes. Web platform tests require the count to survive. ++#[expect(unsafe_code)] ++pub(crate) fn is_potentially_valid_key_range( ++ cx: &mut JSContext, ++ value: HandleValue, ++) -> Result { ++ // Step 1. If value is a key range, return true. ++ if value.is_object() { ++ rooted!(&in(cx) let object = value.to_object()); ++ if unsafe { root_from_object::(cx, object.get()).is_ok() } { ++ return Ok(true); ++ } ++ } ++ ++ // Not a spec step. See the note above. ++ if value.get().is_undefined() || value.get().is_null() { ++ return Ok(true); ++ } ++ ++ // Step 2. Let key be the result of converting a value to a key with value. ++ // Step 3. If key is "invalid type" return false. ++ // Step 4. Else return true. ++ Ok(!matches!( ++ convert_value_to_key(cx, value, None)?, ++ ConversionResult::InvalidType ++ )) + } + + /// +@@ -436,6 +558,24 @@ pub fn convert_value_to_key_range( + Ok(IndexedDBKeyRange::only(key)) + } + ++/// The backend error that stands in for an answer which never arrived. ++/// ++/// A reply from the storage thread is delivered as `Result`. In ++/// single-process mode it is always `Ok`; in multiprocess mode the storage thread is another ++/// process, so the reply can be lost to a closed channel or fail to deserialize. Every caller ++/// is a callback that already has a request, promise or transaction to fail, and those run on ++/// the router thread, where a panic ends the whole content process rather than the one ++/// operation that went wrong. ++pub(crate) fn reply_lost(error: ipc_channel::IpcError) -> BackendError { ++ BackendError::ReplyLost(error.to_string()) ++} ++ ++/// The DOM error a request carries when the backend answered with one. ++/// ++/// Every backend failure except a quota overrun lands on `OperationError`. The spec names ++/// `UnknownError` for an implementation failure it has no specific error for, but Servo's ++/// `DOMErrorName` has no such name, and inventing one here would change the exception ++/// vocabulary of every API in the engine rather than of IndexedDB. + pub(crate) fn map_backend_error_to_dom_error(error: BackendError) -> Error { + match error { + BackendError::QuotaExceeded => Error::QuotaExceeded { +@@ -443,9 +583,13 @@ pub(crate) fn map_backend_error_to_dom_error(error: BackendError) -> Error { + requested: None, + }, + BackendError::DbErr(details) => { +- Error::Operation(Some(format!("IndexedDB open failed: {details}"))) ++ Error::Operation(Some(format!("IndexedDB operation failed: {details}"))) + }, +- other => Error::Operation(Some(format!("IndexedDB open failed: {other:?}"))), ++ // The backend never got to fail: its answer did not survive the trip back. ++ BackendError::ReplyLost(details) => Error::Operation(Some(format!( ++ "IndexedDB lost the storage backend's answer: {details}" ++ ))), ++ other => Error::Operation(Some(format!("IndexedDB operation failed: {other:?}"))), + } + } + +@@ -456,6 +600,73 @@ pub(crate) enum EvaluationResult { + Failure, + } + ++/// The property key for one key path identifier. ++/// ++/// The `const char*` JSAPI overloads read their bytes as Latin-1: `js::Atomize` in ++/// `js/src/vm/JSAtomUtils.cpp` casts the pointer straight to `Latin1Char`. Handing one a ++/// UTF-8 `CString` therefore names a different property whenever the identifier is not ++/// ASCII, so the key path `my.køi` asked `{my: {køi: 5}}` for `køi`, found nothing, and ++/// every store or index with a non-ASCII key path answered `DataError` for every value it ++/// was given. A key path identifier is any ECMAScript `IdentifierName`, so the lookup goes ++/// through UTF-16 and the three operations below take the key rather than a byte string. ++#[expect(unsafe_code)] ++fn key_path_identifier_key( ++ cx: &mut JSContext, ++ identifier: &str, ++ id: MutableHandleId, ++) -> Result<(), Error> { ++ rooted!(&in(cx) let mut value = UndefinedValue()); ++ identifier.safe_to_jsval(cx, value.handle_mut()); ++ rooted!(&in(cx) let string = value.to_string()); ++ ++ if unsafe { !JS_StringToId(cx, string.handle(), id) } { ++ return Err(Error::JSFailed); ++ } ++ Ok(()) ++} ++ ++/// `HasOwnProperty(object, id)`. ++#[expect(unsafe_code)] ++fn has_own_property_by_id( ++ cx: &mut JSContext, ++ object: HandleObject, ++ id: HandleId, ++) -> Result { ++ let mut found = false; ++ if unsafe { !JS_HasOwnPropertyById(cx, object, id, &mut found) } { ++ return Err(Error::JSFailed); ++ } ++ Ok(found) ++} ++ ++/// `Get(object, id)`. ++#[expect(unsafe_code)] ++fn get_property_by_id( ++ cx: &mut JSContext, ++ object: HandleObject, ++ id: HandleId, ++ rval: MutableHandleValue, ++) -> Result<(), Error> { ++ if unsafe { !JS_GetPropertyById(cx, object, id, rval) } { ++ return Err(Error::JSFailed); ++ } ++ Ok(()) ++} ++ ++/// `CreateDataProperty(object, id, value)`, enumerable as the algorithm requires. ++#[expect(unsafe_code)] ++fn define_property_by_id( ++ cx: &mut JSContext, ++ object: HandleObject, ++ id: HandleId, ++ value: HandleValue, ++) -> Result<(), Error> { ++ if unsafe { !JS_DefinePropertyById2(cx, object, id, value, JSPROP_ENUMERATE as u32) } { ++ return Err(Error::JSFailed); ++ } ++ Ok(()) ++} ++ + /// + #[expect(unsafe_code)] + pub(crate) fn evaluate_key_path_on_value( +@@ -468,7 +679,16 @@ pub(crate) fn evaluate_key_path_on_value( + // Step 1. If keyPath is a list of strings, then: + KeyPath::StringSequence(key_path) => { + // Step 1.1. Let result be a new Array object created as if by the expression []. +- rooted!(&in(cx) let mut result = unsafe { JS_NewObject(cx, ptr::null()) }); ++ // Note: it must be an Array and not a plain object. The caller runs `convert a ++ // value to a key` on this result, and a plain object is not a valid key, so a ++ // store or index with a sequence key path answered DataError for every value. ++ rooted!(&in(cx) let mut result = unsafe { NewArrayObject1(cx.raw_cx(), 0) }); ++ ++ // The allocator can fail where the algorithm asserts it cannot. Rejecting the ++ // request beats killing the content process. ++ if result.get().is_null() { ++ return Err(Error::JSFailed); ++ } + + // Step 1.2. Let i be 0. + // Step 1.3. For each item in keyPath: +@@ -490,7 +710,8 @@ pub(crate) fn evaluate_key_path_on_value( + // Step 1.3.4. Let p be ! ToString(i). + // Step 1.3.5. Let status be CreateDataProperty(result, p, key). + // Step 1.3.6. Assert: status is true. +- let i_cstr = std::ffi::CString::new(i.to_string()).unwrap(); ++ let i_cstr = ++ std::ffi::CString::new(i.to_string()).map_err(|_| Error::JSFailed)?; + define_dictionary_property(cx, result.handle(), i_cstr.as_c_str(), key.handle()) + .map_err(|_| Error::JSFailed)?; + +@@ -609,12 +830,11 @@ pub(crate) fn evaluate_key_path_on_value( + } + + rooted!(&in(cx) let object = current_value.to_object()); +- let identifier_name = +- CString::new(identifier).expect("Failed to convert str to CString"); ++ rooted!(&in(cx) let mut id: PropertyKey); ++ key_path_identifier_key(cx, identifier, id.handle_mut())?; + + // Let hop be ! HasOwnProperty(value, identifier). +- let hop = has_own_property(cx, object.handle(), identifier_name.as_c_str()) +- .map_err(|_| Error::JSFailed)?; ++ let hop = has_own_property_by_id(cx, object.handle(), id.handle())?; + + // If hop is false, return failure. + if !hop { +@@ -622,12 +842,7 @@ pub(crate) fn evaluate_key_path_on_value( + } + + // Let value be ! Get(value, identifier). +- get_property_jsval( +- cx, +- object.handle(), +- identifier_name.as_c_str(), +- current_value.handle_mut(), +- )?; ++ get_property_by_id(cx, object.handle(), id.handle(), current_value.handle_mut())?; + + // If value is undefined, return failure. + if current_value.get().is_undefined() { +@@ -679,12 +894,11 @@ pub(crate) fn can_inject_key_into_value( + } + + rooted!(&in(cx) let current_object = current_value.to_object()); +- let identifier_name = +- CString::new(identifier).expect("Failed to convert key path identifier to CString"); ++ rooted!(&in(cx) let mut id: PropertyKey); ++ key_path_identifier_key(cx, identifier, id.handle_mut())?; + + // Step 3.2. Let hop be ? HasOwnProperty(value, identifier). +- let hop = has_own_property(cx, current_object.handle(), identifier_name.as_c_str()) +- .map_err(|_| Error::JSFailed)?; ++ let hop = has_own_property_by_id(cx, current_object.handle(), id.handle())?; + + // Step 3.3. If hop is false, set value to a new Object created as if by the expression + // ({}). +@@ -695,10 +909,10 @@ pub(crate) fn can_inject_key_into_value( + } + + // Step 3.4. Set value to ? Get(value, identifier). +- get_property_jsval( ++ get_property_by_id( + cx, + current_object.handle(), +- identifier_name.as_c_str(), ++ id.handle(), + current_value.handle_mut(), + )?; + } +@@ -737,12 +951,11 @@ pub(crate) fn inject_key_into_value( + } + + rooted!(&in(cx) let current_object = current_value.to_object()); +- let identifier_name = +- CString::new(identifier).expect("Failed to convert key path identifier to CString"); ++ rooted!(&in(cx) let mut id: PropertyKey); ++ key_path_identifier_key(cx, identifier, id.handle_mut())?; + + // Step 4.2 Let hop be ! HasOwnProperty(value, identifier). +- let hop = has_own_property(cx, current_object.handle(), identifier_name.as_c_str()) +- .map_err(|_| Error::JSFailed)?; ++ let hop = has_own_property_by_id(cx, current_object.handle(), id.handle())?; + + // Step 4.3 If hop is false, then: + if !hop { +@@ -752,22 +965,16 @@ pub(crate) fn inject_key_into_value( + o.safe_to_jsval(cx, o_value.handle_mut()); + + // Step 4.3.2 Let status be CreateDataProperty(value, identifier, o). +- define_dictionary_property( +- cx, +- current_object.handle(), +- identifier_name.as_c_str(), +- o_value.handle(), +- ) +- .map_err(|_| Error::JSFailed)?; ++ define_property_by_id(cx, current_object.handle(), id.handle(), o_value.handle())?; + + // Step 4.3.3 Assert: status is true. + } + + // Step 4.3 Let value be ! Get(value, identifier). +- get_property_jsval( ++ get_property_by_id( + cx, + current_object.handle(), +- identifier_name.as_c_str(), ++ id.handle(), + current_value.handle_mut(), + )?; + +@@ -779,23 +986,23 @@ pub(crate) fn inject_key_into_value( + + // Step 6. Let keyValue be the result of converting a key to a value with key. + rooted!(&in(cx) let mut key_value = UndefinedValue()); +- key_type_to_jsval(cx, key, key_value.handle_mut()); ++ key_type_to_jsval(cx, key, key_value.handle_mut())?; + + // `current_value` is the parent object where `last` will be defined. + if !current_value.is_object() { + return Ok(false); + } + rooted!(&in(cx) let parent_object = current_value.to_object()); +- let last_name = CString::new(last).expect("Failed to convert final key path identifier"); ++ rooted!(&in(cx) let mut last_id: PropertyKey); ++ key_path_identifier_key(cx, last, last_id.handle_mut())?; + + // Step 7. Let status be CreateDataProperty(value, last, keyValue). +- define_dictionary_property( ++ define_property_by_id( + cx, + parent_object.handle(), +- last_name.as_c_str(), ++ last_id.handle(), + key_value.handle(), +- ) +- .map_err(|_| Error::JSFailed)?; ++ )?; + + // Step 8. Assert: status is true. + // The JS_DefineProperty success check above enforces this assertion. +@@ -824,14 +1031,19 @@ pub(crate) fn extract_key( + // multiEntry flag is unset, and the result of running the steps to convert a value to a + // multiEntry key with r otherwise. Rethrow any exceptions. + let key = match multi_entry { +- Some(true) => { +- // TODO: implement convert_value_to_multientry_key +- unimplemented!("multiEntry keys are not yet supported"); ++ Some(true) => match convert_value_to_multientry_key(cx, r.handle())? { ++ ConversionResult::Valid(key) => key, ++ // Step 4. If key is invalid, return invalid. ++ ConversionResult::InvalidValue | ConversionResult::InvalidType => { ++ return Ok(ExtractionResult::Invalid); ++ }, + }, + _ => match convert_value_to_key(cx, r.handle(), None)? { + ConversionResult::Valid(key) => key, + // Step 4. If key is invalid, return invalid. +- ConversionResult::Invalid => return Ok(ExtractionResult::Invalid), ++ ConversionResult::InvalidValue | ConversionResult::InvalidType => { ++ return Ok(ExtractionResult::Invalid); ++ }, + }, + }; + +diff --git a/components/script_bindings/codegen/Bindings.conf b/components/script_bindings/codegen/Bindings.conf +index 15b225d193..cac582107a 100644 +--- a/components/script_bindings/codegen/Bindings.conf ++++ b/components/script_bindings/codegen/Bindings.conf +@@ -797,7 +797,7 @@ DOMInterfaces = { + }, + + 'IDBCursor': { +- 'cx': ['Key', 'PrimaryKey'] ++ 'cx': ['GetKey', 'GetPrimaryKey', 'Advance', 'Continue', 'ContinuePrimaryKey', 'Update', 'Delete'] + }, + + 'IDBCursorWithValue': { +@@ -813,15 +813,15 @@ DOMInterfaces = { + }, + + 'IDBIndex': { +- 'cx': ['KeyPath'], ++ 'cx': ['KeyPath', 'Get', 'GetKey', 'GetAll', 'GetAllKeys', 'GetAllRecords', 'Count', 'OpenCursor', 'OpenKeyCursor'], + }, + + 'IDBKeyRange': { +- 'cx': ['Lower', 'Upper', 'Only', 'LowerBound', 'UpperBound', 'Bound', 'Includes'] ++ 'cx': ['GetLower', 'GetUpper', 'Only', 'LowerBound', 'UpperBound', 'Bound', 'Includes'] + }, + + 'IDBObjectStore': { +- 'cx': ['CreateIndex', 'IndexNames', 'Put', 'Add', 'Delete', 'Clear', 'Get', 'GetKey', 'GetAll', 'GetAllKeys', 'Count', 'OpenCursor', 'OpenKeyCursor', 'KeyPath'], ++ 'cx': ['CreateIndex', 'IndexNames', 'Put', 'Add', 'Delete', 'Clear', 'Get', 'GetKey', 'GetAll', 'GetAllKeys', 'GetAllRecords', 'Count', 'OpenCursor', 'OpenKeyCursor', 'KeyPath'], + }, + + 'IDBRequest': { +diff --git a/components/script_bindings/webidls/IDBCursor.webidl b/components/script_bindings/webidls/IDBCursor.webidl +index 93ec491e78..5016371b53 100644 +--- a/components/script_bindings/webidls/IDBCursor.webidl ++++ b/components/script_bindings/webidls/IDBCursor.webidl +@@ -12,16 +12,20 @@ + interface IDBCursor { + readonly attribute (IDBObjectStore or IDBIndex) source; + readonly attribute IDBCursorDirection direction; +- readonly attribute any key; +- readonly attribute any primaryKey; ++ // Converting a key to a value allocates, so both getters can report an allocation ++ // failure instead of taking the content process down with them. ++ [Throws] readonly attribute any key; ++ [Throws] readonly attribute any primaryKey; + [SameObject] readonly attribute IDBRequest request; + +- // undefined advance([EnforceRange] unsigned long count); +- // undefined continue(optional any key); +- // undefined continuePrimaryKey(any key, any primaryKey); ++ [Throws] undefined advance([EnforceRange] unsigned long count); ++ [Throws] undefined continue(optional any key); ++ [Throws] undefined continuePrimaryKey(any key, any primaryKey); + +- // [NewObject] IDBRequest update(any value); +- // [NewObject] IDBRequest delete(); ++ // The cursor's write path. Both run against the cursor's effective key through ++ // IDBObjectStore, which owns the key path, clone and index extraction they need. ++ [NewObject, Throws] IDBRequest update(any value); ++ [NewObject, Throws] IDBRequest delete(); + }; + + enum IDBCursorDirection { +diff --git a/components/script_bindings/webidls/IDBIndex.webidl b/components/script_bindings/webidls/IDBIndex.webidl +index 8985569d8b..fcc5233746 100644 +--- a/components/script_bindings/webidls/IDBIndex.webidl ++++ b/components/script_bindings/webidls/IDBIndex.webidl +@@ -15,17 +15,17 @@ interface IDBIndex { + readonly attribute boolean multiEntry; + readonly attribute boolean unique; + +- // [NewObject] IDBRequest get(any query); +- // [NewObject] IDBRequest getKey(any query); +- // [NewObject] IDBRequest getAll(optional any queryOrOptions, +- // optional [EnforceRange] unsigned long count); +- // [NewObject] IDBRequest getAllKeys(optional any queryOrOptions, +- // optional [EnforceRange] unsigned long count); +- // [NewObject] IDBRequest getAllRecords(optional IDBGetAllOptions options = {}); +- // [NewObject] IDBRequest count(optional any query); ++ [NewObject, Throws] IDBRequest get(any query); ++ [NewObject, Throws] IDBRequest getKey(any query); ++ [NewObject, Throws] IDBRequest getAll(optional any queryOrOptions, ++ optional [EnforceRange] unsigned long count); ++ [NewObject, Throws] IDBRequest getAllKeys(optional any queryOrOptions, ++ optional [EnforceRange] unsigned long count); ++ [NewObject, Throws] IDBRequest count(optional any query); ++ [NewObject, Throws] IDBRequest getAllRecords(optional IDBGetAllOptions options = {}); + +- // [NewObject] IDBRequest openCursor(optional any query, +- // optional IDBCursorDirection direction = "next"); +- // [NewObject] IDBRequest openKeyCursor(optional any query, +- // optional IDBCursorDirection direction = "next"); ++ [NewObject, Throws] IDBRequest openCursor(optional any query, ++ optional IDBCursorDirection direction = "next"); ++ [NewObject, Throws] IDBRequest openKeyCursor(optional any query, ++ optional IDBCursorDirection direction = "next"); + }; +diff --git a/components/script_bindings/webidls/IDBKeyRange.webidl b/components/script_bindings/webidls/IDBKeyRange.webidl +index 7627f55928..b3ccd04428 100644 +--- a/components/script_bindings/webidls/IDBKeyRange.webidl ++++ b/components/script_bindings/webidls/IDBKeyRange.webidl +@@ -10,8 +10,10 @@ + // https://w3c.github.io/IndexedDB/#keyrange + [Pref="dom_indexeddb_enabled", Exposed=(Window,Worker)] + interface IDBKeyRange { +- readonly attribute any lower; +- readonly attribute any upper; ++ // Converting a key to a value allocates, so both getters can report an allocation ++ // failure instead of taking the content process down with them. ++ [Throws] readonly attribute any lower; ++ [Throws] readonly attribute any upper; + readonly attribute boolean lowerOpen; + readonly attribute boolean upperOpen; + +diff --git a/components/script_bindings/webidls/IDBObjectStore.webidl b/components/script_bindings/webidls/IDBObjectStore.webidl +index a9c0f41c25..7e8f86ca40 100644 +--- a/components/script_bindings/webidls/IDBObjectStore.webidl ++++ b/components/script_bindings/webidls/IDBObjectStore.webidl +@@ -22,11 +22,12 @@ interface IDBObjectStore { + [NewObject, Throws] IDBRequest clear(); + [NewObject, Throws] IDBRequest get(any query); + [NewObject, Throws] IDBRequest getKey(any query); +- [NewObject, Throws] IDBRequest getAll(optional any query, ++ [NewObject, Throws] IDBRequest getAll(optional any queryOrOptions, + optional [EnforceRange] unsigned long count); +- [NewObject, Throws] IDBRequest getAllKeys(optional any query, ++ [NewObject, Throws] IDBRequest getAllKeys(optional any queryOrOptions, + optional [EnforceRange] unsigned long count); + [NewObject, Throws] IDBRequest count(optional any query); ++ [NewObject, Throws] IDBRequest getAllRecords(optional IDBGetAllOptions options = {}); + + [NewObject, Throws] IDBRequest openCursor(optional any query, + optional IDBCursorDirection direction = "next"); +@@ -41,6 +42,13 @@ interface IDBObjectStore { + [Throws] undefined deleteIndex(DOMString name); + }; + ++// https://w3c.github.io/IndexedDB/#dictdef-idbgetalloptions ++dictionary IDBGetAllOptions { ++ any query = null; ++ [EnforceRange] unsigned long count; ++ IDBCursorDirection direction = "next"; ++}; ++ + // https://w3c.github.io/IndexedDB/#dictdef-idbindexparameters + dictionary IDBIndexParameters { + boolean unique = false; +diff --git a/components/script_bindings/webidls/IDBRecord.webidl b/components/script_bindings/webidls/IDBRecord.webidl +new file mode 100644 +index 0000000000..01427bf576 +--- /dev/null ++++ b/components/script_bindings/webidls/IDBRecord.webidl +@@ -0,0 +1,16 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++/* ++ * The origin of this IDL file is ++ * https://w3c.github.io/IndexedDB/#idbrecord ++ * ++ */ ++ ++// https://w3c.github.io/IndexedDB/#idbrecord ++[Pref="dom_indexeddb_enabled", Exposed=(Window,Worker)] ++interface IDBRecord { ++ readonly attribute any key; ++ readonly attribute any primaryKey; ++ readonly attribute any value; ++}; +diff --git a/components/script_bindings/webidls/IDBRequest.webidl b/components/script_bindings/webidls/IDBRequest.webidl +index 0e09cd6827..f1714ada45 100644 +--- a/components/script_bindings/webidls/IDBRequest.webidl ++++ b/components/script_bindings/webidls/IDBRequest.webidl +@@ -12,8 +12,7 @@ + interface IDBRequest : EventTarget { + [Throws] readonly attribute any result; + [Throws] readonly attribute DOMException? error; +- // readonly attribute (IDBObjectStore or IDBIndex or IDBCursor)? source; +- readonly attribute IDBObjectStore? source; ++ readonly attribute (IDBObjectStore or IDBIndex or IDBCursor)? source; + readonly attribute IDBTransaction? transaction; + readonly attribute IDBRequestReadyState readyState; + +diff --git a/components/servo/servo.rs b/components/servo/servo.rs +index 6d3eacc093..215e7c82ce 100644 +--- a/components/servo/servo.rs ++++ b/components/servo/servo.rs +@@ -43,6 +43,8 @@ use rustc_hash::FxHashMap; + use script::{JSEngineSetup, ServiceWorkerManager}; + use servo_background_hang_monitor::HangMonitorRegister; + use servo_base::generic_channel::{GenericCallback, RoutedReceiver}; ++#[cfg(feature = "bluetooth")] ++use servo_base::generic_channel::GenericSender; + pub use servo_base::id::WebViewId; + use servo_base::id::{EMBEDDER_PIPELINE_NAMESPACE_ID, PipelineNamespace}; + #[cfg(feature = "bluetooth")] +diff --git a/components/shared/storage/indexeddb.rs b/components/shared/storage/indexeddb.rs +index c8f9ccd7cd..4f987101b4 100644 +--- a/components/shared/storage/indexeddb.rs ++++ b/components/shared/storage/indexeddb.rs +@@ -36,6 +36,13 @@ pub enum BackendError { + QuotaExceeded, + /// The transaction was aborted + Abort, ++ /// The backend's answer never arrived intact. ++ /// ++ /// In multiprocess mode the storage thread runs in another process, so a reply can be lost ++ /// to a closed channel or fail to deserialize. Nothing went wrong in the backend, so this ++ /// is not a [`DbError`]; what failed is the round trip. The string is the transport's own ++ /// account of it, kept because that is the only description of the failure that exists. ++ ReplyLost(String), + + DbErr(DbError), + } +@@ -53,6 +60,7 @@ impl Display for BackendError { + BackendError::StoreNotFound => write!(f, "StoreNotFound"), + BackendError::QuotaExceeded => write!(f, "QuotaExceeded"), + BackendError::Abort => write!(f, "Abort"), ++ BackendError::ReplyLost(err) => write!(f, "ReplyLost({err})"), + BackendError::DbErr(err) => write!(f, "{err}"), + } + } +@@ -103,17 +111,45 @@ pub enum KvsOperationTarget { + /// + /// `keys` contains one entry per index record to write. A multi-entry index is flattened by the + /// script layer before it reaches the backend; an empty vector means extraction produced no index +-/// record. The backend retains responsibility for uniqueness checks using its index schema. ++/// record, unless `record_key_placement` is set. The backend retains responsibility for ++/// uniqueness checks using its index schema. + #[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] + pub struct KvsIndexUpdate { + pub index_name: String, + pub keys: Vec, ++ /// Where the record's own key belongs in this index's key, when the engine is what generates ++ /// it. `keys` is empty while this is set, and the engine fills it in. ++ pub record_key_placement: Option, ++} ++ ++/// Where the record's own key belongs inside an index key the engine has to finish building. ++/// ++/// A store that generates keys into an in-line key path does not write the key into the value it ++/// stores, because only the engine knows whether the insertion survived and therefore what the ++/// key is. An index whose key path reaches that same key path cannot be extracted from such a ++/// value, so the script layer names the hole and the engine fills it before the uniqueness check. ++/// That ordering is what lets `createIndex('by_id', 'id', { unique: true })` on an `autoIncrement` ++/// store still refuse a duplicate. ++#[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] ++pub enum RecordKeyPlacement { ++ /// The index key is the record's key itself. ++ WholeKey, ++ /// The index key is a sequence, and every `None` here is the record's key. The `Some` ++ /// components were extracted from the value, in the index key path's own order. ++ InSequence(Vec>), + } + + #[derive(MallocSizeOf)] + pub struct KvsTransaction { + pub mode: IndexedDBTxnMode, + pub requests: VecDeque, ++ /// Which DOM transaction these requests belong to. ++ /// ++ /// A transaction reaches the engine as a series of batches rather than as one call, ++ /// because script may place further requests while an earlier batch is still running. The ++ /// engine needs the number to attribute the writes of every batch to one transaction, so ++ /// that [`KvsEngine::rollback_transaction`] can undo all of them together. ++ pub serial_number: u64, + } + + /// The backend contract used by Servo's IndexedDB transaction scheduler. +@@ -138,13 +174,14 @@ pub trait KvsEngine: MallocSizeOf + Send { + on_complete: Box, + ); + +- fn key_generator_current_number(&self, store_name: &str) -> Option; ++ fn key_generator_current_number(&self, store_name: &str) ++ -> BackendResult>; + fn set_key_generator_current_number( + &self, + store_name: &str, + current_number: i64, + ) -> BackendResult<()>; +- fn key_path(&self, store_name: &str) -> Option; ++ fn key_path(&self, store_name: &str) -> BackendResult>; + fn object_store_names(&self) -> BackendResult>; + fn indexes(&self, store_name: &str) -> BackendResult>; + +@@ -158,8 +195,39 @@ pub trait KvsEngine: MallocSizeOf + Send { + ) -> BackendResult; + fn delete_index(&self, store_name: &str, index_name: String) -> BackendResult<()>; + ++ /// Rename a store without touching anything it holds. Reverting an aborted upgrade ++ /// needs this: deleting the renamed store and recreating it under its old name would ++ /// throw away the records the abort is supposed to be preserving. ++ fn rename_store(&self, store_name: &str, new_name: &str) -> BackendResult<()>; ++ ++ /// Rename an index without touching its records, for the same reason. ++ fn rename_index( ++ &self, ++ store_name: &str, ++ index_name: &str, ++ new_name: &str, ++ ) -> BackendResult<()>; ++ + fn version(&self) -> BackendResult; + fn set_version(&self, version: u64) -> BackendResult<()>; ++ ++ /// ++ /// ++ /// > When a transaction is aborted the implementation must undo (roll back) any changes ++ /// > that were made to the database during that transaction. ++ /// ++ /// The scheduler calls this once per aborted transaction, after the last batch it had in ++ /// flight has finished, so an engine may assume no writes of its own are still running. ++ /// Answering `Ok` for a transaction that wrote nothing is correct: a readonly transaction ++ /// and a transaction whose requests all failed both reach here. ++ fn rollback_transaction(&self, serial_number: u64) -> BackendResult<()>; ++ ++ /// Release whatever [`KvsEngine::rollback_transaction`] would have needed. ++ /// ++ /// The transaction ended without aborting, so its writes stand and the engine may discard ++ /// the means of undoing them. Like the rollback, this is called once, after the ++ /// transaction's last batch. ++ fn commit_transaction(&self, serial_number: u64) -> BackendResult<()>; + } + + impl KvsEngine for Box +@@ -191,7 +259,8 @@ where + (**self).process_transaction(transaction, on_complete) + } + +- fn key_generator_current_number(&self, store_name: &str) -> Option { ++ fn key_generator_current_number(&self, store_name: &str) ++ -> BackendResult> { + (**self).key_generator_current_number(store_name) + } + +@@ -203,7 +272,7 @@ where + (**self).set_key_generator_current_number(store_name, current_number) + } + +- fn key_path(&self, store_name: &str) -> Option { ++ fn key_path(&self, store_name: &str) -> BackendResult> { + (**self).key_path(store_name) + } + +@@ -230,6 +299,19 @@ where + (**self).delete_index(store_name, index_name) + } + ++ fn rename_store(&self, store_name: &str, new_name: &str) -> BackendResult<()> { ++ (**self).rename_store(store_name, new_name) ++ } ++ ++ fn rename_index( ++ &self, ++ store_name: &str, ++ index_name: &str, ++ new_name: &str, ++ ) -> BackendResult<()> { ++ (**self).rename_index(store_name, index_name, new_name) ++ } ++ + fn version(&self) -> BackendResult { + (**self).version() + } +@@ -237,6 +319,14 @@ where + fn set_version(&self, version: u64) -> BackendResult<()> { + (**self).set_version(version) + } ++ ++ fn rollback_transaction(&self, serial_number: u64) -> BackendResult<()> { ++ (**self).rollback_transaction(serial_number) ++ } ++ ++ fn commit_transaction(&self, serial_number: u64) -> BackendResult<()> { ++ (**self).commit_transaction(serial_number) ++ } + } + + pub trait IndexedDbEngineFactory: Send + Sync { +@@ -428,12 +518,15 @@ impl IndexedDBKeyRange { + } + + pub fn is_singleton(&self) -> bool { +- self.lower.is_some() && self.lower == self.upper && !self.lower_open && !self.upper_open ++ self.as_singleton().is_some() + } + ++ /// The bound reference and the singleton proof are produced by the same `?`, so ++ /// there is no place left to test one condition and unwrap a different one. + pub fn as_singleton(&self) -> Option<&IndexedDBKeyType> { +- if self.is_singleton() { +- return Some(self.lower.as_ref().unwrap()); ++ let lower = self.lower.as_ref()?; ++ if self.lower == self.upper && !self.lower_open && !self.upper_open { ++ return Some(lower); + } + None + } +@@ -447,6 +540,19 @@ pub struct IndexedDBRecord { + pub value: Vec, + } + ++/// How much of each record a request reads. ++/// ++/// `getAllKeys` never looks at a stored value, and a store of large values makes that the ++/// difference between shipping a list of keys and shipping a copy of the database. The shape ++/// travels with the operation so the engine can leave those bytes in the database. ++#[derive(Clone, Copy, Debug, Deserialize, Eq, MallocSizeOf, PartialEq, Serialize)] ++pub enum RecordsShape { ++ /// Keys and primary keys only. Every record's `value` is empty. ++ KeysOnly, ++ /// Keys, primary keys and stored values. ++ WithValues, ++} ++ + #[derive(Clone, Debug, Deserialize, MallocSizeOf, Serialize)] + pub struct IndexedDBIndex { + pub name: String, +@@ -468,6 +574,37 @@ pub struct IndexedDBObjectStore { + pub enum PutItemResult { + Key(IndexedDBKeyType), + CannotOverwrite, ++ /// A unique index already holds one of the record's index keys for a different primary key. ++ /// Carries the index name so the request can report which index refused it. ++ IndexConstraintViolated(String), ++ /// The store's key generator can no longer produce a key. ++ /// ++ /// returns failure once the generator's ++ /// current number passes 2^53, which an explicit key is allowed to do. The record is not ++ /// stored and the request rejects with a "ConstraintError". ++ KeyGeneratorExhausted, ++} ++ ++/// One record's index keys, prepared for the backfill a newly created index needs. ++/// ++/// An index's keys come out of the JavaScript value the key path is evaluated against, and the ++/// backend holds only structured-clone bytes, so `create index` reads the store's records back ++/// to the script thread and sends the extracted keys out again as one of these per record. A ++/// multi-entry index arrives flattened, the same way `KvsIndexUpdate` does; an empty `keys` ++/// means the record has no place in the index. ++#[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] ++pub struct IndexBackfillEntry { ++ pub primary_key: IndexedDBKeyType, ++ pub keys: Vec, ++} ++ ++#[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] ++pub enum BackfillIndexResult { ++ /// Every entry was written. ++ Done, ++ /// The index is unique and two of the store's records extract the same index key, so ++ /// `create index` has to abort the upgrade transaction. ++ UniqueConstraintViolated, + } + + #[derive(Debug, Deserialize, MallocSizeOf, Serialize)] +@@ -477,29 +614,33 @@ pub enum AsyncReadOnlyOperation { + callback: GenericCallback>>, + key_range: IndexedDBKeyRange, + }, ++ /// The one record a key range covers, key included. ++ /// ++ /// The key travels with the value because a store with a key generator and an in-line key ++ /// path does not write the key into the value; the key is generated in the engine, where ++ /// there is no JavaScript to inject it with, so `get` injects it on the way back out. ++ /// `primary_key` is the object store key in both cases, which is the one that gets injected. + GetItem { +- callback: GenericCallback>>>, +- key_range: IndexedDBKeyRange, +- }, +- +- GetAllKeys { +- callback: GenericCallback>>, ++ callback: GenericCallback>>, + key_range: IndexedDBKeyRange, +- count: Option, +- }, +- GetAllItems { +- callback: GenericCallback>>>, +- key_range: IndexedDBKeyRange, +- count: Option, + }, + + Count { + callback: GenericCallback>, + key_range: IndexedDBKeyRange, + }, ++ /// The records a key range covers, in ascending key order and then, for an index ++ /// request, ascending primary key order. ++ /// ++ /// Cursor iteration and the whole `getAll` family read through this one operation. ++ /// Direction is not applied here; the DOM applies it, the way `IDBCursor` already does, ++ /// because a count pushed down beside a descending direction would truncate the wrong end ++ /// of the range. + Iterate { + callback: GenericCallback>>, + key_range: IndexedDBKeyRange, ++ count: Option, ++ shape: RecordsShape, + }, + } + +@@ -508,8 +649,6 @@ impl AsyncReadOnlyOperation { + let _ = match self { + Self::GetKey { callback, .. } => callback.send(Err(error)), + Self::GetItem { callback, .. } => callback.send(Err(error)), +- Self::GetAllKeys { callback, .. } => callback.send(Err(error)), +- Self::GetAllItems { callback, .. } => callback.send(Err(error)), + Self::Count { callback, .. } => callback.send(Err(error)), + Self::Iterate { callback, .. } => callback.send(Err(error)), + }; +@@ -521,11 +660,17 @@ pub enum AsyncReadWriteOperation { + /// Sets the value of the given key in the associated idb data + PutItem { + callback: GenericCallback>, ++ /// `None` when the store's key generator has to produce the key. ++ /// ++ /// Both of the key generator's spec operations run in the engine, because the generator ++ /// they read is the durable one. `generate a key` runs for a `None` key and ++ /// `possibly update the key generator` runs for an explicit numeric one. Neither can be ++ /// decided here. A script-side mirror advances when a request is queued and the durable ++ /// generator advances when that request succeeds, so the two disagree for as long as a ++ /// put is in flight and they stay apart for good once one fails. + key: Option, + value: Vec, + should_overwrite: bool, +- /// New object store key generator current number to persist if the put succeeds. +- key_generator_current_number: Option, + }, + + /// Removes the key/value pair for the given key in the associated idb data +@@ -535,6 +680,16 @@ pub enum AsyncReadWriteOperation { + }, + /// Clears all key/value pairs in the associated idb data + Clear(GenericCallback>), ++ /// Write the index records a newly created index needs for the store's existing records. ++ /// ++ /// step 12 runs this as ++ /// part of the upgrade transaction. The keys arrive already extracted because the key path ++ /// is evaluated against a JavaScript value, which only the script thread holds. ++ BackfillIndex { ++ callback: GenericCallback>, ++ index_name: String, ++ entries: Vec, ++ }, + } + + impl AsyncReadWriteOperation { +@@ -543,6 +698,7 @@ impl AsyncReadWriteOperation { + Self::PutItem { callback, .. } => callback.send(Err(error)), + Self::RemoveItem { callback, .. } => callback.send(Err(error)), + Self::Clear(callback) => callback.send(Err(error)), ++ Self::BackfillIndex { callback, .. } => callback.send(Err(error)), + }; + } + } +@@ -578,6 +734,12 @@ pub enum AsyncSchemaOperation { + DeleteObjectStore { + callback: GenericCallback, + }, ++ /// Rename an existing object store in the database. ++ /// The store being renamed is the one named by the message's `store_name`. ++ RenameObjectStore { ++ callback: GenericCallback, ++ new_name: String, ++ }, + } + + impl AsyncSchemaOperation { +@@ -592,6 +754,9 @@ impl AsyncSchemaOperation { + AsyncSchemaOperation::DeleteObjectStore { callback, .. } => { + let _ = callback.send(error); + }, ++ AsyncSchemaOperation::RenameObjectStore { callback, .. } => { ++ let _ = callback.send(error); ++ }, + }; + } + } +@@ -656,7 +821,9 @@ pub enum ConnectionMsg { + id: Uuid, + /// The name of the connection. + name: String, +- version: u64, ++ /// The version the requesting connection is upgrading to, or `None` when the ++ /// request is a database delete, whose `newVersion` is null. ++ version: Option, + old_version: u64, + }, + /// A `blocked` event should be fired for a connection. +@@ -676,6 +843,18 @@ pub enum ConnectionMsg { + TxnMaybeCommit { db_name: String, txn: u64 }, + } + ++/// ++/// A delete request owns a private callback rather than a connection, so the events ++/// that fire at the request travel on this channel instead of `ConnectionMsg`. ++#[derive(Debug, Deserialize, MallocSizeOf, Serialize)] ++pub enum DeleteDatabaseMsg { ++ /// Step 8: connections to the database are still open, so a `blocked` event should ++ /// be fired at the request with the database's version and null. ++ Blocked { old_version: u64 }, ++ /// Step 12: the request is finished, carrying the version that was deleted. ++ Done(BackendResult), ++} ++ + #[derive(Clone, Debug, Deserialize, MallocSizeOf, Serialize)] + pub struct TxnCompleteMsg { + pub origin: ImmutableOrigin, +@@ -789,7 +968,7 @@ pub enum SyncOperation { + + /// Deletes the database + DeleteDatabase( +- GenericCallback>, ++ GenericCallback, + ImmutableOrigin, + // Database name. + String, +@@ -900,8 +1079,8 @@ mod test { + on_complete(); + } + +- fn key_generator_current_number(&self, _store_name: &str) -> Option { +- None ++ fn key_generator_current_number(&self, _store_name: &str) -> BackendResult> { ++ Ok(None) + } + + fn set_key_generator_current_number( +@@ -912,8 +1091,8 @@ mod test { + Ok(()) + } + +- fn key_path(&self, _store_name: &str) -> Option { +- None ++ fn key_path(&self, _store_name: &str) -> BackendResult> { ++ Ok(None) + } + + fn object_store_names(&self) -> BackendResult> { +@@ -939,6 +1118,19 @@ mod test { + Ok(()) + } + ++ fn rename_store(&self, _store_name: &str, _new_name: &str) -> BackendResult<()> { ++ Ok(()) ++ } ++ ++ fn rename_index( ++ &self, ++ _store_name: &str, ++ _index_name: &str, ++ _new_name: &str, ++ ) -> BackendResult<()> { ++ Ok(()) ++ } ++ + fn version(&self) -> BackendResult { + Ok(0) + } +@@ -946,6 +1138,14 @@ mod test { + fn set_version(&self, _version: u64) -> BackendResult<()> { + Ok(()) + } ++ ++ fn rollback_transaction(&self, _serial_number: u64) -> BackendResult<()> { ++ Ok(()) ++ } ++ ++ fn commit_transaction(&self, _serial_number: u64) -> BackendResult<()> { ++ Ok(()) ++ } + } + + #[test] +@@ -976,6 +1176,7 @@ mod test { + index_updates: vec![KvsIndexUpdate { + index_name: "by-tag".to_owned(), + keys: vec![IndexedDBKeyType::String("rust".to_owned())], ++ record_key_placement: None, + }], + }; + let callback = GenericCallback::new(ProfilerChan(None), |_| {}).unwrap(); +@@ -984,6 +1185,7 @@ mod test { + engine.process_transaction( + KvsTransaction { + mode: IndexedDBTxnMode::Readwrite, ++ serial_number: 0, + requests: VecDeque::from([KvsOperation { + store_name: "documents".to_owned(), + context: expected.clone(), +@@ -992,7 +1194,6 @@ mod test { + key: Some(IndexedDBKeyType::Number(1.0)), + value: vec![1, 2, 3], + should_overwrite: true, +- key_generator_current_number: None, + }), + }]), + }, +diff --git a/components/storage/indexeddb/engines/sqlite.rs b/components/storage/indexeddb/engines/sqlite.rs +index 7924d61063..ca2ca7eb66 100644 +--- a/components/storage/indexeddb/engines/sqlite.rs ++++ b/components/storage/indexeddb/engines/sqlite.rs +@@ -6,15 +6,18 @@ use std::sync::Arc; + + use log::{info, warn}; + use malloc_size_of::{MallocSizeOf, MallocSizeOfOps}; +-use rusqlite::{Connection, Error, OptionalExtension, params}; ++use rusqlite::types::Value; ++use rusqlite::{Connection, Error, OptionalExtension, params, params_from_iter}; + use sea_query::{Condition, Expr, ExprTrait, IntoCondition, SqliteQueryBuilder}; + use sea_query_rusqlite::RusqliteBinder; + use servo_base::threadpool::ThreadPool; + use storage_traits::indexeddb::{ + AsyncOperation, AsyncReadOnlyOperation, AsyncReadWriteOperation, AsyncSchemaOperation, +- BackendError, BackendResult, CreateObjectResult, IndexedDBDescription, IndexedDBIndex, +- IndexedDBKeyRange, IndexedDBKeyType, IndexedDBRecord, IndexedDBTxnMode, KeyPath, KvsEngine, +- KvsTransaction, PutItemResult, ++ BackendError, BackendResult, BackfillIndexResult, CreateObjectResult, IndexBackfillEntry, ++ IndexedDBDescription, IndexedDBIndex, IndexedDBKeyRange, IndexedDBKeyType, IndexedDBRecord, ++ IndexedDBTxnMode, KeyPath, KvsEngine, KvsIndexUpdate, KvsOperationTarget, KvsTransaction, ++ RecordKeyPlacement, ++ PutItemResult, RecordsShape, + }; + + use crate::shared::{DB_INIT_PRAGMAS, DB_PRAGMAS, is_sqlite_disk_full_error}; +@@ -26,6 +29,33 @@ mod object_data_model; + mod object_store_index_model; + mod object_store_model; + ++/// Bytes already in the database that do not decode are corrupt storage, not a ++/// programming error. Reporting them as a `rusqlite::Error` lets the request reject ++/// through the path every other SQL failure already takes, instead of killing the ++/// storage thread and every other database it is serving. ++fn corrupt_storage(what: &str) -> Error { ++ Error::FromSqlConversionFailure( ++ 0, ++ rusqlite::types::Type::Blob, ++ Box::new(std::io::Error::new( ++ std::io::ErrorKind::InvalidData, ++ format!("stored IndexedDB {what} is not decodable"), ++ )), ++ ) ++} ++ ++fn decode_key(bytes: &[u8]) -> Result { ++ encoding::deserialize(bytes).ok_or_else(|| corrupt_storage("key")) ++} ++ ++fn decode_key_path(bytes: &[u8]) -> Result { ++ postcard::from_bytes(bytes).map_err(|_| corrupt_storage("key path")) ++} ++ ++fn encode_key_path(key_path: &KeyPath) -> Result, Error> { ++ postcard::to_stdvec(key_path).map_err(|error| Error::ToSqlConversionFailure(Box::new(error))) ++} ++ + fn backend_error_from_sqlite_error(error: Error) -> BackendError { + if is_sqlite_disk_full_error(&error) { + BackendError::QuotaExceeded +@@ -68,6 +98,118 @@ fn range_to_query(range: IndexedDBKeyRange) -> Condition { + condition + } + ++/// One record from whichever surface a request addressed. ++struct SourceRecord { ++ /// The key the request's range applied to, which is the index key for an index request. ++ key: Vec, ++ /// The object store key, which an index cursor reports as its `primaryKey`. ++ primary_key: Vec, ++ data: Vec, ++} ++ ++/// Index records live in `unique_index_data` when the index is unique and in `index_data` ++/// otherwise. The two tables differ only in their primary key, which is what makes the unique ++/// one reject a second primary key for the same index key. ++fn index_table(index: &object_store_index_model::Model) -> &'static str { ++ if index.unique_index { ++ "unique_index_data" ++ } else { ++ "index_data" ++ } ++} ++ ++/// Append `range` to `sql` as a comparison over `column`, pushing its bound values onto `values`. ++/// ++/// [`range_to_query`] does the same thing through sea_query, but only ever over `object_data.key`. ++/// The index tables need the identical comparison over their `value` column, and the join those ++/// statements carry reads more clearly hand written than through a query builder. ++fn append_range_predicate( ++ sql: &mut String, ++ values: &mut Vec, ++ column: &str, ++ range: &IndexedDBKeyRange, ++) { ++ if let Some(singleton) = range.as_singleton() { ++ sql.push_str(&format!(" AND {column} = ?")); ++ values.push(Value::Blob(encoding::serialize(singleton))); ++ return; ++ } ++ if let Some(lower) = range.lower.as_ref() { ++ let operator = if range.lower_open { ">" } else { ">=" }; ++ sql.push_str(&format!(" AND {column} {operator} ?")); ++ values.push(Value::Blob(encoding::serialize(lower))); ++ } ++ if let Some(upper) = range.upper.as_ref() { ++ let operator = if range.upper_open { "<" } else { "<=" }; ++ sql.push_str(&format!(" AND {column} {operator} ?")); ++ values.push(Value::Blob(encoding::serialize(upper))); ++ } ++} ++ ++/// One table a readwrite transaction may change, described well enough to undo a change to it. ++/// ++/// The three record tables are all `WITHOUT ROWID`, so an undo statement has to name a row by ++/// its declared primary key rather than by a rowid the table does not have. ++struct UndoLoggedTable { ++ name: &'static str, ++ /// Every column, in the order an `INSERT` lists them. ++ columns: &'static [&'static str], ++ /// The primary key columns, which are what a row is found by afterwards. ++ key_columns: &'static [&'static str], ++} ++ ++/// The tables whose contents belong to a readwrite transaction. ++/// ++/// `object_store` is deliberately absent. Its `auto_increment` column carries the key ++/// generator's current number, and the scheduler already snapshots that when the transaction is ++/// registered and writes it back when the transaction aborts; logging it here as well would ++/// revert it twice. ++const UNDO_LOGGED_TABLES: [UndoLoggedTable; 3] = [ ++ UndoLoggedTable { ++ name: "object_data", ++ columns: &["object_store_id", "key", "data"], ++ key_columns: &["object_store_id", "key"], ++ }, ++ UndoLoggedTable { ++ name: "index_data", ++ columns: &[ ++ "index_id", ++ "value", ++ "object_data_key", ++ "object_store_id", ++ "value_locale", ++ ], ++ key_columns: &["index_id", "value", "object_data_key"], ++ }, ++ UndoLoggedTable { ++ name: "unique_index_data", ++ columns: &[ ++ "index_id", ++ "value", ++ "object_store_id", ++ "object_data_key", ++ "value_locale", ++ ], ++ key_columns: &["index_id", "value"], ++ }, ++]; ++ ++/// Build the SQL expression a trigger body concatenates to name one row of `table`. ++/// ++/// `row` is `new` or `old`, whichever alias holds the row the undo statement has to find. ++fn undo_where_clause(table: &UndoLoggedTable, row: &str) -> String { ++ table ++ .key_columns ++ .iter() ++ .enumerate() ++ .map(|(position, column)| { ++ let separator = if position == 0 { " WHERE " } else { " AND " }; ++ format!("'{separator}{column}=' || quote({row}.{column})") ++ }) ++ .collect::>() ++ .join(" || ") ++} ++ + pub struct SqliteEngine { + db_path: PathBuf, + connection: Connection, +@@ -87,6 +229,142 @@ impl SqliteEngine { + ) + } + ++ /// Widen `object_store_index`'s uniqueness from the name alone to the pair the ++ /// specification scopes a name by. ++ /// ++ /// A database created before [`create::object_store_index_table`] carried the table-level ++ /// constraint has `unique` on the `name` column, which refuses a second object store an ++ /// index name the first store already uses. That constraint lives in an implicit index ++ /// SQLite will not let `drop index` remove and `alter table` cannot rewrite, so the only ++ /// way out is the rebuild SQLite documents: create the replacement under a temporary name, ++ /// copy the rows across, drop the original, and rename the replacement into its place. ++ /// ++ /// The replacement is created first and renamed last so that no `references` clause is ever ++ /// rewritten: `index_data` and `unique_index_data` both point at `object_store_index`, and ++ /// renaming that table out of the way instead would move those references with it. ++ fn scope_index_names_to_their_store(connection: &Connection) -> Result<(), Error> { ++ // The old constraint is exactly a unique index whose only column is `name`. Asking the ++ // schema what indexes exist answers that without depending on how the original ++ // `create table` text happened to be spelled or spaced. ++ let unscoped: bool = connection.query_row( ++ "SELECT EXISTS ( ++ SELECT 1 FROM pragma_index_list(?) AS idx ++ WHERE idx.\"unique\" = 1 ++ AND (SELECT count(*) FROM pragma_index_info(idx.name)) = 1 ++ AND (SELECT col.name FROM pragma_index_info(idx.name) AS col) = 'name' ++ )", ++ [create::OBJECT_STORE_INDEX], ++ |row| row.get(0), ++ )?; ++ if !unscoped { ++ return Ok(()); ++ } ++ ++ info!( ++ "Rebuilding {} to scope index names to their object store", ++ create::OBJECT_STORE_INDEX ++ ); ++ let scoped = format!("{}_scoped", create::OBJECT_STORE_INDEX); ++ let columns = create::OBJECT_STORE_INDEX_COLUMNS; ++ let table = create::OBJECT_STORE_INDEX; ++ connection.execute_batch(&format!( ++ "begin; ++ {create_scoped} ++ insert into {scoped} ({columns}) select {columns} from {table}; ++ drop table {table}; ++ alter table {scoped} rename to {table}; ++ commit;", ++ create_scoped = create::object_store_index_table(&scoped), ++ ))?; ++ Ok(()) ++ } ++ ++ /// The table holding the statements that would undo the writes of each live transaction. ++ /// ++ /// It is created outside [`Self::init_db`] because that returns early for a database that ++ /// already exists, and a database written by an earlier build has every other table but not ++ /// this one. ++ fn create_undo_log(connection: &Connection) -> Result<(), Error> { ++ connection.execute( ++ "CREATE TABLE IF NOT EXISTS undo_log ( ++ seq INTEGER PRIMARY KEY AUTOINCREMENT, ++ transaction_serial INTEGER NOT NULL, ++ statement TEXT NOT NULL ++ )", ++ [], ++ )?; ++ // A transaction that was live when the process died left its undo statements behind. ++ // They describe a state the database no longer has any transaction waiting to return ++ // to, and replaying them later against whatever a new transaction reuses the number for ++ // would corrupt it, so the log starts empty. ++ connection.execute("DELETE FROM undo_log", [])?; ++ Ok(()) ++ } ++ ++ /// Record, for the duration of this connection, how to undo every row `serial_number` ++ /// writes. ++ /// ++ /// This is SQLite's own undo/redo recipe: a trigger per table per statement kind writes the ++ /// SQL text that would put the row back, and [`Self::rollback_transaction`] runs those ++ /// statements in reverse. `quote()` renders a blob, a NULL and a string the way SQL reads ++ /// them back, which is what lets the undo travel as text. ++ /// ++ /// The triggers are `TEMP`, so they belong to this connection alone and end with it. The ++ /// scheduler's own connection therefore never has them, and the writes it makes while ++ /// reverting are not themselves logged. ++ fn install_undo_log_triggers(connection: &Connection, serial_number: u64) -> Result<(), Error> { ++ // Conflict resolution inside `INSERT OR REPLACE` deletes the row it replaces, and that ++ // deletion only reaches a delete trigger when recursive triggers are on. The index ++ // tables are written that way, so without this an index record could be replaced with ++ // no record of what it held. ++ connection.execute_batch("PRAGMA recursive_triggers = ON;")?; ++ ++ let serial = i64::from_ne_bytes(serial_number.to_ne_bytes()); ++ for table in &UNDO_LOGGED_TABLES { ++ let name = table.name; ++ let columns = table.columns.join(","); ++ let restored_values = table ++ .columns ++ .iter() ++ .enumerate() ++ .map(|(position, column)| { ++ let separator = if position == 0 { "" } else { "," }; ++ format!("'{separator}' || quote(old.{column})") ++ }) ++ .collect::>() ++ .join(" || "); ++ let restored_assignments = table ++ .columns ++ .iter() ++ .enumerate() ++ .map(|(position, column)| { ++ let separator = if position == 0 { " SET " } else { "," }; ++ format!("'{separator}{column}=' || quote(old.{column})") ++ }) ++ .collect::>() ++ .join(" || "); ++ // An insert and an update are both undone against the row as it now stands, so ++ // both find it by the `new` alias. A delete has no row left to find. ++ let find_row = undo_where_clause(table, "new"); ++ ++ connection.execute_batch(&format!( ++ "CREATE TEMP TRIGGER undo_{name}_insert AFTER INSERT ON {name} BEGIN ++ INSERT INTO undo_log (transaction_serial, statement) ++ VALUES ({serial}, 'DELETE FROM {name}' || {find_row}); ++ END; ++ CREATE TEMP TRIGGER undo_{name}_delete AFTER DELETE ON {name} BEGIN ++ INSERT INTO undo_log (transaction_serial, statement) ++ VALUES ({serial}, 'INSERT INTO {name} ({columns}) VALUES (' || {restored_values} || ')'); ++ END; ++ CREATE TEMP TRIGGER undo_{name}_update AFTER UPDATE ON {name} BEGIN ++ INSERT INTO undo_log (transaction_serial, statement) ++ VALUES ({serial}, 'UPDATE {name}' || {restored_assignments} || {find_row}); ++ END;" ++ ))?; ++ } ++ Ok(()) ++ } ++ + // TODO: intake dual pools + pub fn new( + path: PathBuf, +@@ -96,6 +374,8 @@ impl SqliteEngine { + ) -> Result { + let db_path = path.join("indexeddb.sqlite"); + let connection = Self::init_db(&db_path, db_info)?; ++ Self::scope_index_names_to_their_store(&connection)?; ++ Self::create_undo_log(&connection)?; + + for stmt in DB_PRAGMAS { + // TODO: Handle errors properly +@@ -167,71 +447,347 @@ impl SqliteEngine { + Self::get(connection, store, key_range).map(|opt| opt.map(|model| model.key)) + } + +- fn get_item( ++ /// The one record a key range covers, key included. ++ /// ++ /// `get` already reads the whole row. Dropping the key here is what kept it from the DOM, ++ /// which needs it to inject a generated in-line key into the value on the way out. An object ++ /// store record's key and primary key are the same key. ++ fn get_record( + connection: &Connection, + store: object_store_model::Model, + key_range: IndexedDBKeyRange, +- ) -> Result>, Error> { +- Self::get(connection, store, key_range).map(|opt| opt.map(|model| model.data)) ++ ) -> Result, Error> { ++ Self::get(connection, store, key_range).map(|opt| { ++ opt.map(|model| SourceRecord { ++ key: model.key.clone(), ++ primary_key: model.key, ++ data: model.data, ++ }) ++ }) + } + +- fn get_all( ++ /// The records of an object store that a key range covers, in ascending key order. ++ /// ++ /// An object store record's key and primary key are the same key, which is what makes the ++ /// answer the same shape as an index request's. ++ fn object_store_records( + connection: &Connection, + store: object_store_model::Model, + key_range: IndexedDBKeyRange, + count: Option, +- ) -> Result, Error> { ++ shape: RecordsShape, ++ ) -> Result, Error> { + let query = range_to_query(key_range); + let mut sql_query = sea_query::Query::select(); + sql_query + .from(object_data_model::Column::Table) +- .columns(vec![ +- object_data_model::Column::ObjectStoreId, +- object_data_model::Column::Key, +- object_data_model::Column::Data, +- ]) +- .and_where(query.and(Expr::col(object_data_model::Column::ObjectStoreId).is(store.id))); +- if let Some(count) = count { ++ .column(object_data_model::Column::Key); ++ // A key-only request pays for every stored byte it selects and then discards. ++ if shape == RecordsShape::WithValues { ++ sql_query.column(object_data_model::Column::Data); ++ } ++ sql_query ++ .and_where(query.and(Expr::col(object_data_model::Column::ObjectStoreId).is(store.id))) ++ // Every operation reaching here (getAll, getAllKeys, getAllRecords, cursor ++ // iteration) is defined in key order, and a LIMIT without an ORDER BY truncates ++ // an unspecified subset rather than the first `count` records. ++ .order_by(object_data_model::Column::Key, sea_query::Order::Asc); ++ // "If count is not given or is 0 (zero), let count be infinity." ++ // ++ // A `LIMIT 0` answers with nothing, which is the opposite of what a zero count asks for. ++ if let Some(count) = count.filter(|count| *count > 0) { + sql_query.limit(count as u64); + } + let (sql, values) = sql_query.build_rusqlite(SqliteQueryBuilder); + let mut stmt = connection.prepare(&sql)?; +- let models = stmt ++ let records = stmt + .query_and_then(&*values.as_params(), |row| { +- object_data_model::Model::try_from(row) ++ let key: Vec = row.get(0)?; ++ Ok::(SourceRecord { ++ key: key.clone(), ++ primary_key: key, ++ data: match shape { ++ RecordsShape::WithValues => row.get(1)?, ++ RecordsShape::KeysOnly => Vec::new(), ++ }, ++ }) + })? + .collect::, _>>()?; +- Ok(models) ++ Ok(records) + } + +- fn get_all_keys( ++ /// Look up one declared index of a store by name. ++ fn index_by_name( + connection: &Connection, +- store: object_store_model::Model, ++ store_id: i32, ++ index_name: &str, ++ ) -> Result, Error> { ++ connection ++ .prepare("SELECT * FROM object_store_index WHERE object_store_id = ? AND name = ?") ++ .and_then(|mut stmt| { ++ stmt.query_row(params![store_id, index_name], |row| { ++ object_store_index_model::Model::try_from(row) ++ }) ++ .optional() ++ }) ++ } ++ ++ /// The records an index request ranges over, in index key order and then primary key order. ++ /// ++ /// The index key is what the request's key range applies to and the object store key rides ++ /// along beside it. Keeping the pair distinct is the whole point: an index cursor's `key` is ++ /// the index key and its `primaryKey` is the object store key, and the two coincide only for ++ /// an object store request. An index that no longer exists yields no records rather than an ++ /// error, because the transaction that deleted it has already invalidated the request. ++ fn index_records( ++ connection: &Connection, ++ store: &object_store_model::Model, ++ index_name: &str, + key_range: IndexedDBKeyRange, + count: Option, +- ) -> Result>, Error> { +- Self::get_all(connection, store, key_range, count) +- .map(|models| models.into_iter().map(|m| m.key).collect()) ++ shape: RecordsShape, ++ ) -> Result, Error> { ++ let Some(index) = Self::index_by_name(connection, store.id, index_name)? else { ++ return Ok(Vec::new()); ++ }; ++ let table = index_table(&index); ++ // The join stays even for a key-only request: it is what drops an index record whose ++ // object store row is already gone. Only the selected value changes. ++ let value_column = match shape { ++ RecordsShape::WithValues => ", o.data", ++ RecordsShape::KeysOnly => "", ++ }; ++ let mut sql = format!( ++ "SELECT i.value, i.object_data_key{value_column} FROM {table} i \ ++ JOIN object_data o \ ++ ON o.object_store_id = i.object_store_id AND o.key = i.object_data_key \ ++ WHERE i.index_id = ? AND i.object_store_id = ?" ++ ); ++ let mut values = vec![ ++ Value::Integer(index.id as i64), ++ Value::Integer(store.id as i64), ++ ]; ++ append_range_predicate(&mut sql, &mut values, "i.value", &key_range); ++ sql.push_str(" ORDER BY i.value ASC, i.object_data_key ASC"); ++ // A zero count is infinity, not an empty answer. See `get_all`. ++ if let Some(count) = count.filter(|count| *count > 0) { ++ sql.push_str(" LIMIT ?"); ++ values.push(Value::Integer(count as i64)); ++ } ++ let mut stmt = connection.prepare(&sql)?; ++ let records = stmt ++ .query_and_then(params_from_iter(values), |row| { ++ Ok::(SourceRecord { ++ key: row.get(0)?, ++ primary_key: row.get(1)?, ++ data: match shape { ++ RecordsShape::WithValues => row.get(2)?, ++ RecordsShape::KeysOnly => Vec::new(), ++ }, ++ }) ++ })? ++ .collect::, _>>()?; ++ Ok(records) + } + +- fn get_all_items( ++ /// How many index records the range covers, counted without loading any stored value. ++ fn index_count( + connection: &Connection, +- store: object_store_model::Model, ++ store: &object_store_model::Model, ++ index_name: &str, + key_range: IndexedDBKeyRange, +- count: Option, +- ) -> Result>, Error> { +- Self::get_all(connection, store, key_range, count) +- .map(|models| models.into_iter().map(|m| m.data).collect()) ++ ) -> Result { ++ let Some(index) = Self::index_by_name(connection, store.id, index_name)? else { ++ return Ok(0); ++ }; ++ let table = index_table(&index); ++ let mut sql = format!( ++ "SELECT COUNT(*) FROM {table} i WHERE i.index_id = ? AND i.object_store_id = ?" ++ ); ++ let mut values = vec![ ++ Value::Integer(index.id as i64), ++ Value::Integer(store.id as i64), ++ ]; ++ append_range_predicate(&mut sql, &mut values, "i.value", &key_range); ++ let count: i64 = ++ connection ++ .prepare(&sql)? ++ .query_row(params_from_iter(values), |row| row.get(0))?; ++ Ok(count as u64) ++ } ++ ++ /// Drop every index record pointing at any of `primary_keys`. ++ fn delete_index_records( ++ connection: &Connection, ++ store_id: i32, ++ primary_keys: &[Vec], ++ ) -> Result<(), Error> { ++ for key in primary_keys { ++ for table in ["index_data", "unique_index_data"] { ++ connection.execute( ++ &format!( ++ "DELETE FROM {table} WHERE object_store_id = ? AND object_data_key = ?" ++ ), ++ params![store_id, key], ++ )?; ++ } ++ } ++ Ok(()) ++ } ++ ++ /// The name of the first unique index `index_updates` would collide on, if any. ++ /// ++ /// This runs before the value is stored. A unique index rejects the whole put, so finding the ++ /// collision after writing `object_data` would leave the store holding a record the ++ /// transaction is about to be told never landed. A key already held by this same primary key ++ /// is not a collision: that is the record being overwritten. ++ fn unique_index_conflict( ++ connection: &Connection, ++ store_id: i32, ++ primary_key: &[u8], ++ index_updates: &[KvsIndexUpdate], ++ ) -> Result, Error> { ++ for update in index_updates { ++ let Some(index) = Self::index_by_name(connection, store_id, &update.index_name)? else { ++ continue; ++ }; ++ if !index.unique_index { ++ continue; ++ } ++ for key in &update.keys { ++ let value = encoding::serialize(key); ++ let holder: Option> = connection ++ .prepare( ++ "SELECT object_data_key FROM unique_index_data WHERE index_id = ? AND value = ?", ++ ) ++ .and_then(|mut stmt| { ++ stmt.query_row(params![index.id, value], |row| row.get(0)) ++ .optional() ++ })?; ++ if holder.is_some_and(|held| held != primary_key) { ++ return Ok(Some(update.index_name.clone())); ++ } ++ } ++ } ++ Ok(None) ++ } ++ ++ /// Replace the index records that point at `primary_key`. ++ /// ++ /// A put rewrites the whole record, so every index key extracted from the old value stops ++ /// being true the moment the new one lands. Deleting this primary key's records first and ++ /// inserting the freshly extracted keys afterwards is what keeps the index tables agreeing ++ /// with `object_data`. The keys themselves come from the script thread, which owns the ++ /// JavaScript value the key path is evaluated against; a multiEntry index arrives here as one ++ /// update carrying several keys. ++ fn replace_index_records( ++ connection: &Connection, ++ store_id: i32, ++ primary_key: &[u8], ++ index_updates: &[KvsIndexUpdate], ++ ) -> Result<(), Error> { ++ Self::delete_index_records(connection, store_id, &[primary_key.to_vec()])?; ++ for update in index_updates { ++ let Some(index) = Self::index_by_name(connection, store_id, &update.index_name)? else { ++ continue; ++ }; ++ let table = index_table(&index); ++ for key in &update.keys { ++ let value = encoding::serialize(key); ++ connection.execute( ++ &format!( ++ "INSERT OR REPLACE INTO {table} \ ++ (index_id, value, object_store_id, object_data_key) VALUES (?, ?, ?, ?)" ++ ), ++ params![index.id, value, store_id, primary_key], ++ )?; ++ } ++ } ++ Ok(()) + } + +- #[expect(clippy::type_complexity)] +- fn get_all_records( ++ /// Write the index records a newly created index needs for the records the store already ++ /// holds. ++ /// ++ /// step 12. The keys are ++ /// extracted on the script thread, so all this does is insert them and answer whether a ++ /// unique index found two records under one key. The index row was inserted by the schema ++ /// operation that ran ahead of this one, so it holds no records yet and the only collisions ++ /// possible are between entries in this call; the read below still goes to the table, so a ++ /// row that arrived some other way is caught too. ++ fn backfill_index( + connection: &Connection, + store: object_store_model::Model, +- key_range: IndexedDBKeyRange, +- ) -> Result, Vec)>, Error> { +- Self::get_all(connection, store, key_range, None) +- .map(|models| models.into_iter().map(|m| (m.key, m.data)).collect()) ++ index_name: &str, ++ entries: &[IndexBackfillEntry], ++ ) -> Result { ++ let Some(index) = Self::index_by_name(connection, store.id, index_name)? else { ++ // The transaction that created the index has already deleted it again. There is ++ // nothing to populate and nothing to refuse. ++ return Ok(BackfillIndexResult::Done); ++ }; ++ let table = index_table(&index); ++ for entry in entries { ++ let primary_key = encoding::serialize(&entry.primary_key); ++ for key in &entry.keys { ++ let value = encoding::serialize(key); ++ if index.unique_index { ++ let holder: Option> = connection ++ .prepare( ++ "SELECT object_data_key FROM unique_index_data \ ++ WHERE index_id = ? AND value = ?", ++ ) ++ .and_then(|mut stmt| { ++ stmt.query_row(params![index.id, value], |row| row.get(0)) ++ .optional() ++ })?; ++ if holder.is_some_and(|held| held != primary_key) { ++ return Ok(BackfillIndexResult::UniqueConstraintViolated); ++ } ++ } ++ connection.execute( ++ &format!( ++ "INSERT OR REPLACE INTO {table} \ ++ (index_id, value, object_store_id, object_data_key) VALUES (?, ?, ?, ?)" ++ ), ++ params![index.id, value, store.id, primary_key], ++ )?; ++ } ++ } ++ Ok(BackfillIndexResult::Done) ++ } ++ ++ /// ++ /// ++ /// `auto_increment` holds 0 for a store with no key generator, which is the step that aborts ++ /// before any of this runs. The answer is the generator's new current number, or `None` when ++ /// the generator does not move. ++ fn possibly_updated_key_generator(auto_increment: i64, key: &IndexedDBKeyType) -> Option { ++ if auto_increment == 0 { ++ return None; ++ } ++ // Step 1. If the type of key is not number, abort these steps. ++ let IndexedDBKeyType::Number(number) = key else { ++ return None; ++ }; ++ // Step 2. Let value be the value of key. ++ // Step 3. Set value to the minimum of value and 2^53 (9007199254740992). ++ // Step 4. Set value to the largest integer not greater than value. ++ // Spelled as an associated call because `sea_query`'s prelude is in scope here and ++ // supplies its own `min` for `&f64`, which builds a SQL expression rather than a number. ++ let value = f64::min(*number, 9_007_199_254_740_992.0).floor(); ++ // Step 5. Let generator be store's key generator. ++ // Step 6. If value is greater than or equal to generator's current number, then set ++ // generator's current number to value + 1. ++ if value < auto_increment as f64 { ++ return None; ++ } ++ // The clamp above leaves value at 2^53 or below, and an f64 holds every integer up to ++ // 2^53 exactly, so the increment moves into integer space here. Adding 1 in f64 would ++ // land on 2^53 + 1, which is not representable and rounds straight back down to 2^53. A ++ // generator an explicit key had maxed out would then come back one short of the value ++ // that makes the next `generate a key` fail, and it would keep handing out 2^53. ++ Some(value as i64 + 1) + } + + fn put_item( +@@ -241,6 +797,7 @@ impl SqliteEngine { + value: Vec, + should_overwrite: bool, + key_generator_current_number: Option, ++ index_updates: &[KvsIndexUpdate], + ) -> Result { + let no_overwrite = !should_overwrite; + let serialized_key: Vec = encoding::serialize(&key); +@@ -252,6 +809,11 @@ impl SqliteEngine { + }) + .optional() + })?; ++ if let Some(index_name) = ++ Self::unique_index_conflict(connection, store.id, &serialized_key, index_updates)? ++ { ++ return Ok(PutItemResult::IndexConstraintViolated(index_name)); ++ } + if existing_item.is_some() { + if no_overwrite { + return Ok(PutItemResult::CannotOverwrite); +@@ -268,6 +830,7 @@ impl SqliteEngine { + params![store.id, serialized_key, value], + )?; + } ++ Self::replace_index_records(connection, store.id, &serialized_key, index_updates)?; + if let Some(next_key_generator_current_number) = key_generator_current_number { + connection.execute( + "UPDATE object_store SET auto_increment = ? WHERE id = ?", +@@ -282,6 +845,19 @@ impl SqliteEngine { + store: object_store_model::Model, + key_range: IndexedDBKeyRange, + ) -> Result<(), Error> { ++ // The index records name their primary keys, so the set has to be read before the rows ++ // that define it are gone. ++ let removed = Self::object_store_records( ++ connection, ++ store.clone(), ++ key_range.clone(), ++ None, ++ RecordsShape::KeysOnly, ++ )? ++ .into_iter() ++ .map(|record| record.key) ++ .collect::>(); ++ Self::delete_index_records(connection, store.id, &removed)?; + let query = range_to_query(key_range); + let (sql, values) = sea_query::Query::delete() + .from_table(object_data_model::Column::Table) +@@ -292,6 +868,12 @@ impl SqliteEngine { + } + + fn clear(connection: &Connection, store: object_store_model::Model) -> Result<(), Error> { ++ for table in ["index_data", "unique_index_data"] { ++ connection.execute( ++ &format!("DELETE FROM {table} WHERE object_store_id = ?"), ++ params![store.id], ++ )?; ++ } + connection.execute( + "DELETE FROM object_data WHERE object_store_id = ?", + params![store.id], +@@ -331,7 +913,7 @@ impl SqliteEngine { + "INSERT INTO object_store (name, key_path, auto_increment) VALUES (?, ?, ?)", + params![ + store_name.to_string(), +- key_path.map(|v| postcard::to_stdvec(&v).unwrap()), ++ key_path.as_ref().map(encode_key_path).transpose()?, + auto_increment as i32 + ], + )?; +@@ -372,6 +954,26 @@ impl SqliteEngine { + } + } + ++ /// ++ /// Step 9. Set store's name to name. ++ /// Every row that belongs to the store keys off the store's id, so nothing below the ++ /// `object_store` row moves with it. ++ fn rename_store( ++ connection: &Connection, ++ store_name: &str, ++ new_name: &str, ++ ) -> Result<(), Error> { ++ let object_store = Self::object_store_by_name(connection, store_name)?; ++ let rows_affected = connection.execute( ++ "UPDATE object_store SET name = ? WHERE id = ?", ++ params![new_name, object_store.id], ++ )?; ++ if rows_affected == 0 { ++ return Err(Error::QueryReturnedNoRows); ++ } ++ Ok(()) ++ } ++ + fn create_index( + connection: &Connection, + store_name: &str, +@@ -401,7 +1003,7 @@ impl SqliteEngine { + params![ + object_store.id, + index_name, +- postcard::to_stdvec(&key_path).unwrap(), ++ encode_key_path(&key_path)?, + unique, + multi_entry, + ], +@@ -437,10 +1039,17 @@ impl SqliteEngine { + let object_store = connection.query_row( + "SELECT * FROM object_store WHERE name = ?", + params![store_name.to_string()], +- |row| Ok(object_store_model::Model::try_from(row).unwrap()), ++ |row| object_store_model::Model::try_from(row), + )?; + +- // Delete the index if it exists ++ // Delete the index's records before the row that gives them their index_id. ++ if let Some(index) = Self::index_by_name(connection, object_store.id, &index_name)? { ++ let table = index_table(&index); ++ connection.execute( ++ &format!("DELETE FROM {table} WHERE index_id = ?"), ++ params![index.id], ++ )?; ++ } + let _ = connection.execute( + "DELETE FROM object_store_index WHERE name = ? AND object_store_id = ?", + params![index_name, object_store.id], +@@ -498,6 +1107,8 @@ impl KvsEngine for SqliteEngine { + self.write_pool.clone() + }; + let path = self.db_path.clone(); ++ let serial_number = transaction.serial_number; ++ let undo_logged = transaction.mode == IndexedDBTxnMode::Readwrite; + spawning_pool.spawn(move || { + let connection = match Connection::open(path) { + Ok(connection) => connection, +@@ -511,6 +1122,24 @@ impl KvsEngine for SqliteEngine { + return; + }, + }; ++ // Only a readwrite transaction is undone row by row. An upgrade transaction is ++ // reverted by rebuilding the schema it started from, which gives stores it ++ // recreates new identifiers, so rows carrying the old ones have nothing to go back ++ // to; reverting the records an upgrade wrote is a separate piece of work. A ++ // readonly transaction writes nothing to undo. ++ if undo_logged { ++ if let Err(error) = Self::install_undo_log_triggers(&connection, serial_number) { ++ // Without the triggers the transaction would look like it could be aborted ++ // and then silently keep its writes, so it is refused instead. ++ for request in transaction.requests { ++ request ++ .operation ++ .notify_error(BackendError::DbErr(format!("{error:?}"))); ++ } ++ on_complete(); ++ return; ++ } ++ } + for request in transaction.requests { + // The pinned SQLite implementation has schema support for indexes but no index + // request methods or index-record maintenance. Preserve its behavior by handling +@@ -551,16 +1180,32 @@ impl KvsEngine for SqliteEngine { + }, + }; + ++ // The target says which of the store's surfaces the request addressed. The six ++ // read operations are deliberately target agnostic on the wire, so an index needs ++ // no operation variants of its own, only this context to select which records ++ // they range over. ++ let context = request.context; + match request.operation { + AsyncOperation::ReadWrite(AsyncReadWriteOperation::PutItem { + callback, + key, + value, + should_overwrite, +- key_generator_current_number, + }) => { ++ // Both of the key generator's operations run here, against the durable ++ // generator. A store that has no key generator holds 0 in this column; ++ // every generator starts at 1 and only ever grows, so the one column ++ // carries both the flag and the generator's current number. + let (key, key_generator_current_number) = match key { +- Some(key) => (key, key_generator_current_number), ++ // ++ Some(key) => { ++ let next = Self::possibly_updated_key_generator( ++ object_store.auto_increment, ++ &key, ++ ); ++ (key, next) ++ }, ++ // + None => { + if object_store.auto_increment == 0 { + if let Err(error) = callback.send(Err(BackendError::DbErr( +@@ -570,24 +1215,50 @@ impl KvsEngine for SqliteEngine { + } + continue; + } +- let Some(next_key_generator_current_number) = +- object_store.auto_increment.checked_add(1) +- else { +- if let Err(error) = callback.send(Err(BackendError::DbErr( +- "Key generator overflow".to_string(), +- ))) { +- warn!( +- "Failed to send PutItem key generator overflow error: {error:?}" +- ); +- } ++ // Step 3. If key is greater than 2^53 (9007199254740992), then ++ // return failure. An explicit key is allowed to push the ++ // generator one past that maximum, and this is what makes the ++ // next generated key fail instead of repeating 2^53 forever. ++ if object_store.auto_increment > 9_007_199_254_740_992 { ++ let _ = ++ callback.send(Ok(PutItemResult::KeyGeneratorExhausted)); + continue; +- }; ++ } + ( + IndexedDBKeyType::Number(object_store.auto_increment as f64), +- Some(next_key_generator_current_number), ++ // Step 4. Increase the generator's current number by 1. The ++ // check above leaves it at 2^53 or below, so this cannot ++ // overflow an i64. ++ Some(object_store.auto_increment + 1), + ) + }, + }; ++ // An index whose key path reaches the store's key path indexes the ++ // record under the key the record is stored under. Script cannot extract ++ // that key from a value it was never injected into, so it names the hole ++ // and the key is filled in here, ahead of the uniqueness check `put_item` ++ // runs. ++ let mut index_updates = context.index_updates; ++ for update in &mut index_updates { ++ update.keys = match update.record_key_placement.take() { ++ None => continue, ++ // The index is on the store's own key path. ++ Some(RecordKeyPlacement::WholeKey) => vec![key.clone()], ++ // The index is on a sequence that lists the store's key path, so ++ // the generated key fills each hole the script layer left. ++ Some(RecordKeyPlacement::InSequence(components)) => { ++ vec![IndexedDBKeyType::Array( ++ components ++ .into_iter() ++ .map(|component| match component { ++ Some(component) => component, ++ None => key.clone(), ++ }) ++ .collect(), ++ )] ++ }, ++ }; ++ } + let _ = callback.send( + Self::put_item( + &connection, +@@ -596,6 +1267,7 @@ impl KvsEngine for SqliteEngine { + value, + should_overwrite, + key_generator_current_number, ++ &index_updates, + ) + .map_err(|e| BackendError::DbErr(format!("{:?}", e))), + ); +@@ -604,33 +1276,45 @@ impl KvsEngine for SqliteEngine { + callback, + key_range, + }) => { +- let _ = callback.send( +- Self::get_item(&connection, object_store, key_range) +- .map_err(|e| BackendError::DbErr(format!("{:?}", e))), +- ); +- }, +- AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetAllKeys { +- callback, +- key_range, +- count, +- }) => { +- let _ = callback.send( +- Self::get_all_keys(&connection, object_store, key_range, count) +- .map(|keys| { +- keys.into_iter() +- .map(|k| encoding::deserialize(&k).unwrap()) +- .collect() ++ // The key rides back with the value. A store with a key generator and an ++ // in-line key path does not write the key into the value, because the key ++ // is generated here and there is no JavaScript here to inject it with, so ++ // the DOM injects it when it deserializes. ++ let result = match &context.target { ++ KvsOperationTarget::Index { name } => Self::index_records( ++ &connection, ++ &object_store, ++ name, ++ key_range, ++ Some(1), ++ RecordsShape::WithValues, ++ ) ++ .map(|records| records.into_iter().next()), ++ KvsOperationTarget::ObjectStore => { ++ Self::get_record(&connection, object_store, key_range) ++ }, ++ } ++ .and_then(|record| { ++ record ++ .map(|record| { ++ Ok(IndexedDBRecord { ++ key: decode_key(&record.key)?, ++ primary_key: decode_key(&record.primary_key)?, ++ value: record.data, ++ }) + }) +- .map_err(|e| BackendError::DbErr(format!("{:?}", e))), +- ); ++ .transpose() ++ }); ++ let _ = callback ++ .send(result.map_err(|e| BackendError::DbErr(format!("{:?}", e)))); + }, +- AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetAllItems { ++ AsyncOperation::ReadWrite(AsyncReadWriteOperation::BackfillIndex { + callback, +- key_range, +- count, ++ index_name, ++ entries, + }) => { + let _ = callback.send( +- Self::get_all_items(&connection, object_store, key_range, count) ++ Self::backfill_index(&connection, object_store, &index_name, &entries) + .map_err(|e| BackendError::DbErr(format!("{:?}", e))), + ); + }, +@@ -647,27 +1331,60 @@ impl KvsEngine for SqliteEngine { + callback, + key_range, + }) => { +- let _ = callback.send( +- Self::count(&connection, object_store, key_range) +- .map(|r| r as u64) +- .map_err(|e| BackendError::DbErr(format!("{:?}", e))), +- ); ++ let result = match &context.target { ++ KvsOperationTarget::Index { name } => { ++ Self::index_count(&connection, &object_store, name, key_range) ++ }, ++ KvsOperationTarget::ObjectStore => { ++ Self::count(&connection, object_store, key_range).map(|r| r as u64) ++ }, ++ }; ++ let _ = callback ++ .send(result.map_err(|e| BackendError::DbErr(format!("{:?}", e)))); + }, + AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { + callback, + key_range, ++ count, ++ shape, + }) => { ++ // An object store record's key and primary key are the same key. An ++ // index record's are not, and keeping them apart here is what lets a ++ // cursor report the index key while continuing from the object store ++ // position, and `getAllKeys` on an index answer with primary keys. ++ // ++ // Direction is not applied here: the DOM applies it, the way IDBCursor ++ // already does. See ADR12. ++ let result = match &context.target { ++ KvsOperationTarget::Index { name } => Self::index_records( ++ &connection, ++ &object_store, ++ name, ++ key_range, ++ count, ++ shape, ++ ), ++ KvsOperationTarget::ObjectStore => Self::object_store_records( ++ &connection, ++ object_store, ++ key_range, ++ count, ++ shape, ++ ), ++ }; + let _ = callback.send( +- Self::get_all_records(&connection, object_store, key_range) +- .map(|records| { ++ result ++ .and_then(|records: Vec| { + records + .into_iter() +- .map(|(key, data)| IndexedDBRecord { +- key: encoding::deserialize(&key).unwrap(), +- primary_key: encoding::deserialize(&key).unwrap(), +- value: data, ++ .map(|record| { ++ Ok(IndexedDBRecord { ++ key: decode_key(&record.key)?, ++ primary_key: decode_key(&record.primary_key)?, ++ value: record.data, ++ }) + }) +- .collect() ++ .collect::, Error>>() + }) + .map_err(|e| BackendError::DbErr(format!("{:?}", e))), + ); +@@ -682,9 +1399,27 @@ impl KvsEngine for SqliteEngine { + callback, + key_range, + }) => { ++ // An index request answers with the primary key the index record points ++ // at, which is what `IDBIndex.getKey` is defined to return. ++ let result = match &context.target { ++ KvsOperationTarget::Index { name } => Self::index_records( ++ &connection, ++ &object_store, ++ name, ++ key_range, ++ Some(1), ++ RecordsShape::KeysOnly, ++ ) ++ .map(|records| { ++ records.into_iter().next().map(|record| record.primary_key) ++ }), ++ KvsOperationTarget::ObjectStore => { ++ Self::get_key(&connection, object_store, key_range) ++ }, ++ }; + let _ = callback.send( +- Self::get_key(&connection, object_store, key_range) +- .map(|key| key.map(|k| encoding::deserialize(&k).unwrap())) ++ result ++ .and_then(|key| key.as_deref().map(decode_key).transpose()) + .map_err(|e| BackendError::DbErr(format!("{:?}", e))), + ); + }, +@@ -706,8 +1441,19 @@ impl KvsEngine for SqliteEngine { + let _ = callback.send(BackendError::DbErr(format!("{error:?}"))); + } + }, +- AsyncOperation::Schema(AsyncSchemaOperation::CreateObjectStore { .. }) => { +- unreachable!("Should be handled above"); ++ AsyncOperation::Schema(AsyncSchemaOperation::CreateObjectStore { ++ callback, .. ++ }) => { ++ // The pre-pass above handles this and continues, because the store ++ // does not exist yet and so cannot survive the lookup every other ++ // operation needs. Reaching here would mean the two patterns have ++ // drifted apart. Report it rather than killing a storage thread that ++ // is serving every other database in the process. ++ let _ = callback.send(BackendError::DbErr( ++ "CreateObjectStore reached the main dispatch; the pre-pass above \ ++ should have handled it" ++ .to_owned(), ++ )); + }, + AsyncOperation::Schema(AsyncSchemaOperation::DeleteIndex { index_name, callback }) => { + if let Err(error) = Self::delete_index(&connection, &request.store_name, index_name) { +@@ -719,6 +1465,11 @@ impl KvsEngine for SqliteEngine { + let _ = callback.send(BackendError::DbErr(format!("{error:?}"))); + } + }, ++ AsyncOperation::Schema(AsyncSchemaOperation::RenameObjectStore { new_name, callback }) => { ++ if let Err(error) = Self::rename_store(&connection, &request.store_name, &new_name) { ++ let _ = callback.send(BackendError::DbErr(format!("{error:?}"))); ++ } ++ }, + AsyncOperation::Schema(AsyncSchemaOperation::RenameIndex { index_name, new_name, callback }) => { + if let Err(error) = Self::rename_index( + &connection, +@@ -735,18 +1486,21 @@ impl KvsEngine for SqliteEngine { + }); + } + +- fn key_generator_current_number(&self, store_name: &str) -> Option { +- self.connection +- .prepare("SELECT * FROM object_store WHERE name = ?") +- .and_then(|mut stmt| { +- stmt.query_row(params![store_name.to_string()], |r| { +- let object_store = object_store_model::Model::try_from(r).unwrap(); +- Ok(object_store.auto_increment) +- }) ++ fn key_generator_current_number(&self, store_name: &str) -> BackendResult> { ++ let load = || -> Result, Error> { ++ let mut stmt = self ++ .connection ++ .prepare("SELECT * FROM object_store WHERE name = ?")?; ++ stmt.query_row(params![store_name.to_string()], |row| { ++ Ok(object_store_model::Model::try_from(row)?.auto_increment) + }) + .optional() +- .unwrap() +- .and_then(|current_number| (current_number != 0).then_some(current_number)) ++ }; ++ // Zero is the stored representation of "this store has no key generator". A failed ++ // read is a different answer and now reaches the caller as one. ++ Ok(load() ++ .map_err(backend_error_from_sqlite_error)? ++ .and_then(|current_number| (current_number != 0).then_some(current_number))) + } + + fn set_key_generator_current_number( +@@ -778,21 +1532,25 @@ impl KvsEngine for SqliteEngine { + update().map_err(backend_error_from_sqlite_error) + } + +- fn key_path(&self, store_name: &str) -> Option { +- self.connection +- .prepare("SELECT * FROM object_store WHERE name = ?") +- .and_then(|mut stmt| { +- stmt.query_row(params![store_name.to_string()], |r| { +- let object_store = object_store_model::Model::try_from(r).unwrap(); +- Ok(object_store +- .key_path +- .map(|key_path| postcard::from_bytes(&key_path).unwrap())) +- }) ++ /// `Ok(None)` still conflates "no such store" with "this store has no key path". ++ /// That conflation is the one the old `TODO: Wrong, same issues as has_key_generator` ++ /// named and it is unchanged here; what changed is that a failed read is no longer a ++ /// third thing hiding inside it. ++ fn key_path(&self, store_name: &str) -> BackendResult> { ++ let load = || -> Result, Error> { ++ let mut stmt = self ++ .connection ++ .prepare("SELECT * FROM object_store WHERE name = ?")?; ++ stmt.query_row(params![store_name.to_string()], |row| { ++ object_store_model::Model::try_from(row)? ++ .key_path ++ .map(|key_path| decode_key_path(&key_path)) ++ .transpose() + }) + .optional() +- .unwrap() +- // TODO: Wrong, same issues as has_key_generator +- .unwrap_or_default() ++ .map(Option::flatten) ++ }; ++ load().map_err(backend_error_from_sqlite_error) + } + + fn object_store_names(&self) -> BackendResult> { +@@ -820,7 +1578,7 @@ impl KvsEngine for SqliteEngine { + let model = object_store_index_model::Model::try_from(row)?; + Ok(IndexedDBIndex { + name: model.name, +- key_path: postcard::from_bytes(&model.key_path).unwrap(), ++ key_path: decode_key_path(&model.key_path)?, + unique: model.unique_index, + multi_entry: model.multi_entry_index, + }) +@@ -836,6 +1594,21 @@ impl KvsEngine for SqliteEngine { + .map_err(backend_error_from_sqlite_error) + } + ++ fn rename_store(&self, store_name: &str, new_name: &str) -> BackendResult<()> { ++ Self::rename_store(&self.connection, store_name, new_name) ++ .map_err(backend_error_from_sqlite_error) ++ } ++ ++ fn rename_index( ++ &self, ++ store_name: &str, ++ index_name: &str, ++ new_name: &str, ++ ) -> BackendResult<()> { ++ Self::rename_index(&self.connection, store_name, index_name, new_name) ++ .map_err(backend_error_from_sqlite_error) ++ } ++ + fn version(&self) -> BackendResult { + self.connection + .query_row("SELECT version FROM database LIMIT 1", [], |row| row.get(0)) +@@ -856,6 +1629,61 @@ impl KvsEngine for SqliteEngine { + }; + update().map_err(backend_error_from_sqlite_error) + } ++ ++ fn rollback_transaction(&self, serial_number: u64) -> BackendResult<()> { ++ let serial = i64::from_ne_bytes(serial_number.to_ne_bytes()); ++ let replay = || -> Result<(), Error> { ++ // The statements undo one write each, so they have to run against the database the ++ // write after them has already been taken back out of: newest first. ++ let statements = self ++ .connection ++ .prepare( ++ "SELECT statement FROM undo_log \ ++ WHERE transaction_serial = ? ORDER BY seq DESC", ++ ) ++ .and_then(|mut stmt| { ++ stmt.query_map(params![serial], |row| row.get::<_, String>(0))? ++ .collect::, Error>>() ++ })?; ++ for statement in statements { ++ self.connection.execute_batch(&statement)?; ++ } ++ self.connection.execute( ++ "DELETE FROM undo_log WHERE transaction_serial = ?", ++ params![serial], ++ )?; ++ Ok(()) ++ }; ++ ++ // A half-applied undo is a state no transaction ever wrote, so the replay either lands ++ // whole or leaves the database as the abort found it and says why. ++ self.connection ++ .execute_batch("BEGIN IMMEDIATE") ++ .map_err(backend_error_from_sqlite_error)?; ++ match replay() { ++ Ok(()) => self ++ .connection ++ .execute_batch("COMMIT") ++ .map_err(backend_error_from_sqlite_error), ++ Err(error) => { ++ if let Err(rollback_error) = self.connection.execute_batch("ROLLBACK") { ++ warn!("Failed to roll back a failed undo replay: {rollback_error:?}"); ++ } ++ Err(backend_error_from_sqlite_error(error)) ++ }, ++ } ++ } ++ ++ fn commit_transaction(&self, serial_number: u64) -> BackendResult<()> { ++ let serial = i64::from_ne_bytes(serial_number.to_ne_bytes()); ++ self.connection ++ .execute( ++ "DELETE FROM undo_log WHERE transaction_serial = ?", ++ params![serial], ++ ) ++ .map(|_| ()) ++ .map_err(backend_error_from_sqlite_error) ++ } + } + + fn get_db_status(connection: &Connection, op: i32) -> Result { +@@ -903,7 +1731,7 @@ mod tests { + use storage_traits::indexeddb::{ + AsyncOperation, AsyncReadOnlyOperation, AsyncReadWriteOperation, CreateObjectResult, + IndexedDBDescription, IndexedDBKeyRange, IndexedDBKeyType, IndexedDBTxnMode, KeyPath, +- KvsEngine, KvsOperation, KvsTransaction, PutItemResult, ++ KvsEngine, KvsOperation, KvsTransaction, PutItemResult, RecordsShape, + }; + use url::Host; + +@@ -1027,7 +1855,7 @@ mod tests { + let create_result = result.unwrap(); + assert_eq!(create_result, CreateObjectResult::AlreadyExists); + // Ensure store was not overwritten +- assert!(db.key_generator_current_number(store_name).is_some()); ++ assert_eq!(db.key_generator_current_number(store_name), Ok(Some(1))); + } + + #[test] +@@ -1098,7 +1926,7 @@ mod tests { + assert!(result.is_ok()); + assert_eq!( + db.key_path(store_name), +- Some(KeyPath::String("test".to_string())) ++ Ok(Some(KeyPath::String("test".to_string()))) + ); + } + +@@ -1156,6 +1984,7 @@ mod tests { + vec![1, 2, 3], + true, + None, ++ &[], + ) + .expect("Failed to insert item"); + +@@ -1232,6 +2061,7 @@ mod tests { + db.process_transaction( + KvsTransaction { + mode: IndexedDBTxnMode::Readwrite, ++ serial_number: 0, + requests: VecDeque::from(vec![ + KvsOperation { + store_name: store_name.to_owned(), +@@ -1241,7 +2071,6 @@ mod tests { + key: Some(IndexedDBKeyType::Number(1.0)), + value: vec![1, 2, 3], + should_overwrite: false, +- key_generator_current_number: None, + }), + }, + KvsOperation { +@@ -1252,7 +2081,6 @@ mod tests { + key: Some(IndexedDBKeyType::String("2.0".to_string())), + value: vec![4, 5, 6], + should_overwrite: false, +- key_generator_current_number: None, + }), + }, + KvsOperation { +@@ -1266,7 +2094,6 @@ mod tests { + ])), + value: vec![7, 8, 9], + should_overwrite: false, +- key_generator_current_number: None, + }), + }, + // Try to put a duplicate key without overwrite +@@ -1278,7 +2105,6 @@ mod tests { + key: Some(IndexedDBKeyType::Number(1.0)), + value: vec![10, 11, 12], + should_overwrite: false, +- key_generator_current_number: None, + }), + }, + KvsOperation { +@@ -1289,7 +2115,6 @@ mod tests { + key: Some(IndexedDBKeyType::Number(1.0)), + value: vec![13, 14, 15], + should_overwrite: true, +- key_generator_current_number: None, + }), + }, + KvsOperation { +@@ -1311,13 +2136,14 @@ mod tests { + KvsOperation { + store_name: store_name.to_owned(), + context: Default::default(), +- operation: AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetAllItems { ++ operation: AsyncOperation::ReadOnly(AsyncReadOnlyOperation::Iterate { + callback: get_callback(get_all_items.0), + key_range: IndexedDBKeyRange::lower_bound( + IndexedDBKeyType::Number(0.0), + false, + ), + count: None, ++ shape: RecordsShape::WithValues, + }), + }, + KvsOperation { +@@ -1361,12 +2187,19 @@ mod tests { + PutItemResult::Key(IndexedDBKeyType::Number(1.0)) + ); + let get_result = get_item_some.1.recv().unwrap(); +- let value = get_result.unwrap(); ++ let value = get_result.unwrap().map(|record| record.value); + assert_eq!(value, Some(vec![13, 14, 15])); + let get_result = get_item_none.1.recv().unwrap(); +- let value = get_result.unwrap(); ++ let value = get_result.unwrap().map(|record| record.value); + assert_eq!(value, None); +- let all_items = get_all_items.1.recv().unwrap().unwrap(); ++ let all_items: Vec> = get_all_items ++ .1 ++ .recv() ++ .unwrap() ++ .unwrap() ++ .into_iter() ++ .map(|record| record.value) ++ .collect(); + assert_eq!(all_items.len(), 3); + // Check that all three items are present + assert!(all_items.contains(&vec![13, 14, 15])); +@@ -1412,6 +2245,7 @@ mod tests { + vec![key as u8], + false, + None, ++ &[], + ) + .expect("Failed to seed object store"); + } +@@ -1428,14 +2262,20 @@ mod tests { + ) + .expect("Failed to delete key range"); + +- SqliteEngine::get_all_keys(&db.connection, store, IndexedDBKeyRange::default(), None) +- .expect("Failed to read remaining keys") +- .into_iter() +- .map(|raw_key| match encoding::deserialize(&raw_key).unwrap() { +- IndexedDBKeyType::Number(number) => number as i32, +- other => panic!("Expected numeric key, got {other:?}"), +- }) +- .collect() ++ SqliteEngine::object_store_records( ++ &db.connection, ++ store, ++ IndexedDBKeyRange::default(), ++ None, ++ RecordsShape::KeysOnly, ++ ) ++ .expect("Failed to read remaining keys") ++ .into_iter() ++ .map(|record| match encoding::deserialize(&record.key).unwrap() { ++ IndexedDBKeyType::Number(number) => number as i32, ++ other => panic!("Expected numeric key, got {other:?}"), ++ }) ++ .collect() + } + + assert_eq!( +@@ -1455,4 +2295,119 @@ mod tests { + vec![1, 2, 3, 8, 9, 10] + ); + } ++ ++ /// ++ /// ++ /// > When a transaction is aborted the implementation must undo (roll back) any changes ++ /// > that were made to the database during that transaction. ++ /// ++ /// One transaction lays down two records and commits. A second one overwrites one of them, ++ /// adds a record of its own and removes the other, then aborts. All three of those are ++ /// taken back, which is the difference between `count()` reading 1 and reading 0 in ++ /// `idb-explicit-commit`. ++ #[test] ++ fn test_rollback_transaction_undoes_a_readwrite_transaction() { ++ fn ignored_callback() -> GenericCallback ++ where ++ T: for<'de> Deserialize<'de> + Serialize + Send + Sync, ++ { ++ GenericCallback::new(ProfilerChan(None), |_| {}).expect("Could not construct callback") ++ } ++ ++ fn run(db: &SqliteEngine, serial_number: u64, requests: Vec) { ++ let (done_tx, done_rx) = std::sync::mpsc::channel(); ++ db.process_transaction( ++ KvsTransaction { ++ mode: IndexedDBTxnMode::Readwrite, ++ serial_number, ++ requests: VecDeque::from(requests), ++ }, ++ Box::new(move || { ++ let _ = done_tx.send(()); ++ }), ++ ); ++ done_rx.recv().unwrap(); ++ } ++ ++ fn put(store_name: &str, key: f64, value: Vec) -> KvsOperation { ++ KvsOperation { ++ store_name: store_name.to_owned(), ++ context: Default::default(), ++ operation: AsyncOperation::ReadWrite(AsyncReadWriteOperation::PutItem { ++ callback: ignored_callback(), ++ key: Some(IndexedDBKeyType::Number(key)), ++ value, ++ should_overwrite: true, ++ }), ++ } ++ } ++ ++ let (_temp_dir, path, created, _proxy_map, _handle) = create_db("test_db".to_string()); ++ let db = SqliteEngine::new( ++ path, ++ created, ++ &IndexedDBDescription { ++ name: "test_db".to_string(), ++ origin: test_origin(), ++ }, ++ get_pool(), ++ ) ++ .unwrap(); ++ let store_name = "test_store"; ++ db.create_store(store_name, None, false) ++ .expect("Failed to create store"); ++ ++ run( ++ &db, ++ 1, ++ vec![ ++ put(store_name, 1.0, vec![1, 2, 3]), ++ put(store_name, 3.0, vec![7, 8, 9]), ++ ], ++ ); ++ db.commit_transaction(1).expect("Failed to commit"); ++ ++ run( ++ &db, ++ 2, ++ vec![ ++ put(store_name, 1.0, vec![9, 9, 9]), ++ put(store_name, 2.0, vec![4, 5, 6]), ++ KvsOperation { ++ store_name: store_name.to_owned(), ++ context: Default::default(), ++ operation: AsyncOperation::ReadWrite(AsyncReadWriteOperation::RemoveItem { ++ callback: ignored_callback(), ++ key_range: IndexedDBKeyRange::only(IndexedDBKeyType::Number(3.0)), ++ }), ++ }, ++ ], ++ ); ++ db.rollback_transaction(2).expect("Failed to roll back"); ++ ++ let read = |key: f64| { ++ let (tx, rx) = generic_channel::channel().unwrap(); ++ let callback = GenericCallback::new(ProfilerChan(None), move |result| { ++ assert!(tx.send(result.unwrap()).is_ok()); ++ }) ++ .expect("Could not construct callback"); ++ run( ++ &db, ++ 3, ++ vec![KvsOperation { ++ store_name: store_name.to_owned(), ++ context: Default::default(), ++ operation: AsyncOperation::ReadOnly(AsyncReadOnlyOperation::GetItem { ++ callback, ++ key_range: IndexedDBKeyRange::only(IndexedDBKeyType::Number(key)), ++ }), ++ }], ++ ); ++ rx.recv().unwrap().unwrap().map(|record| record.value) ++ }; ++ ++ assert_eq!(read(1.0), Some(vec![1, 2, 3]), "an overwrite was undone"); ++ assert_eq!(read(2.0), None, "an added record was undone"); ++ assert_eq!(read(3.0), Some(vec![7, 8, 9]), "a removal was undone"); ++ } + } +diff --git a/components/storage/indexeddb/engines/sqlite/create.rs b/components/storage/indexeddb/engines/sqlite/create.rs +index c841b98b14..8edc29d059 100644 +--- a/components/storage/indexeddb/engines/sqlite/create.rs ++++ b/components/storage/indexeddb/engines/sqlite/create.rs +@@ -5,6 +5,39 @@ + // Adapted from: + // https://github.com/mozilla-firefox/firefox/blob/ee102e926521b3e460293b0aea6b54b1a03f6f74/dom/indexedDB/DBSchema.cpp#L78 + ++/// The name the `object_store_index` table has in a database that is done migrating. ++pub(crate) const OBJECT_STORE_INDEX: &str = "object_store_index"; ++ ++/// The columns copied when the table is rebuilt, in the order this schema declares them. ++pub(crate) const OBJECT_STORE_INDEX_COLUMNS: &str = ++ "id, object_store_id, name, key_path, unique_index, multi_entry_index"; ++ ++/// The `object_store_index` table, under whichever name the caller needs. ++/// ++/// A rebuild has to create the replacement under a temporary name before it can take the real ++/// one, so the shape is written once here rather than once per caller. ++pub(crate) fn object_store_index_table(table: &str) -> String { ++ format!( ++ r#" ++create table {table} ( ++ id integer not null ++ primary key autoincrement, ++ object_store_id integer not null ++ references object_store, ++ name varchar not null, ++ key_path varbinary_blob not null, ++ unique_index boolean not null, ++ multi_entry_index boolean not null, ++ -- An index name is scoped to its object store, so two stores in one database may each have ++ -- an index of the same name. The Firefox schema this file is adapted from spells that as a ++ -- table-level `UNIQUE (object_store_id, name)`; writing `unique` on the column alone makes ++ -- the name unique across the whole database, so the second store's index is refused with a ++ -- constraint violation the specification has no error for. ++ unique (object_store_id, name) ++);"# ++ ) ++} ++ + pub(crate) fn create_tables(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> { + const DATABASE: &str = r#" + create table database ( +@@ -37,19 +70,7 @@ create table object_data ( + ) WITHOUT ROWID;"#; + conn.execute(OBJECT_DATA, [])?; + +- const OBJECT_STORE_INDEX: &str = r#" +-create table object_store_index ( +- id integer not null +- primary key autoincrement, +- object_store_id integer not null +- references object_store, +- name varchar not null +- unique, +- key_path varbinary_blob not null, +- unique_index boolean not null, +- multi_entry_index boolean not null +-);"#; +- conn.execute(OBJECT_STORE_INDEX, [])?; ++ conn.execute(&object_store_index_table(OBJECT_STORE_INDEX), [])?; + + const INDEX_DATA: &str = r#" + CREATE TABLE index_data ( +diff --git a/components/storage/indexeddb/mod.rs b/components/storage/indexeddb/mod.rs +index 8fb95aa7f6..7e68612e45 100644 +--- a/components/storage/indexeddb/mod.rs ++++ b/components/storage/indexeddb/mod.rs +@@ -25,9 +25,10 @@ use servo_base::threadpool::ThreadPool; + use servo_url::origin::ImmutableOrigin; + use storage_traits::client_storage::StorageProxyMap; + use storage_traits::indexeddb::{ +- AsyncOperation, BackendError, BackendResult, ConnectionMsg, CreateObjectResult, DatabaseInfo, +- DbResult, IndexedDBDescription, IndexedDBIndex, IndexedDBObjectStore, IndexedDBThreadMsg, +- IndexedDBTxnMode, IndexedDbEngineFactory, KeyPath, KvsEngine, KvsOperation, ++ AsyncOperation, AsyncSchemaOperation, BackendError, BackendResult, ConnectionMsg, ++ CreateObjectResult, DatabaseInfo, ++ DbResult, DeleteDatabaseMsg, IndexedDBDescription, IndexedDBIndex, IndexedDBObjectStore, ++ IndexedDBThreadMsg, IndexedDBTxnMode, IndexedDbEngineFactory, KeyPath, KvsEngine, KvsOperation, + KvsOperationContext, KvsTransaction, SyncOperation, TxnCompleteMsg, + }; + use uuid::Uuid; +@@ -54,7 +55,12 @@ impl IndexedDBThreadFactory for GenericSender { + + let manager_sender = chan.clone(); + +- thread::Builder::new() ++ // A storage thread factory cannot report failure: the trait returns `Self`, and all ++ // four storage threads are built by infallible calls. Aborting the process is the ++ // worse of the two answers available here. Every send on the returned channel fails ++ // once the receiving thread is absent, and the send sites already report that, so a ++ // browser that loses IndexedDB keeps running rather than dying with it. ++ if let Err(error) = thread::Builder::new() + .name("IndexedDBManager".to_owned()) + .spawn(move || { + mem_profiler_chan.run_with_memory_reporting( +@@ -64,7 +70,11 @@ impl IndexedDBThreadFactory for GenericSender { + IndexedDBThreadMsg::CollectMemoryReport, + ); + }) +- .expect("Thread spawning failed"); ++ { ++ error!( ++ "Failed to spawn the IndexedDB manager thread: {error}. IndexedDB is unavailable." ++ ); ++ } + + chan + } +@@ -152,16 +162,25 @@ impl IndexedDBEnvironment { + } + } + +- fn register_transaction(&mut self, txn: u64, mode: IndexedDBTxnMode, scope: Vec) { ++ fn register_transaction( ++ &mut self, ++ txn: u64, ++ mode: IndexedDBTxnMode, ++ scope: Vec, ++ ) -> DbResult<()> { + if self.txn_info.contains_key(&txn) { +- return; ++ return Ok(()); + } + let scope: HashSet = scope.into_iter().collect(); ++ // A readwrite transaction snapshots the key generator of every store in its ++ // scope so that an abort can revert it. A failed read is not an absent ++ // snapshot: registering the transaction without one would leave the generator ++ // silently un-revertable, so the registration fails instead. + let scope: Vec = scope + .into_iter() + .map(|store_name| { + let key_generator_snapshot = if mode == IndexedDBTxnMode::Readwrite { +- self.key_generator_current_number(&store_name) ++ self.key_generator_current_number(&store_name)? + .map(|current_number| KeyGeneratorSnapshot { + store_name: store_name.clone(), + current_number, +@@ -169,12 +188,12 @@ impl IndexedDBEnvironment { + } else { + None + }; +- TxnScopeStore { ++ Ok(TxnScopeStore { + name: store_name, + key_generator_snapshot, +- } ++ }) + }) +- .collect(); ++ .collect::>>()?; + let created_seq = self.next_created_seq; + self.next_created_seq += 1; + self.txn_info.insert( +@@ -191,7 +210,9 @@ impl IndexedDBEnvironment { + .or_insert_with(|| KvsTransaction { + requests: VecDeque::new(), + mode, ++ serial_number: txn, + }); ++ Ok(()) + } + + fn scopes_overlap(a: &TxnInfo, b: &TxnInfo) -> bool { +@@ -291,6 +312,7 @@ impl IndexedDBEnvironment { + .insert(KvsTransaction { + requests: VecDeque::new(), + mode: mode.clone(), ++ serial_number, + }) + .requests + .push_back(KvsOperation { +@@ -420,7 +442,11 @@ impl IndexedDBEnvironment { + + let manager_sender = self.manager_sender.clone(); + self.engine.process_transaction( +- KvsTransaction { mode, requests }, ++ KvsTransaction { ++ mode, ++ requests, ++ serial_number: txn, ++ }, + Box::new(move || { + // Notify the manager thread when the engine finishes so it can: + // - clear running_readonly / running_readwrite +@@ -486,6 +512,14 @@ impl IndexedDBEnvironment { + } + } + ++ /// Whether `txn` is a readwrite transaction, which is the only kind whose writes the engine ++ /// records an undo for. ++ fn is_readwrite(&self, txn: u64) -> bool { ++ self.txn_info ++ .get(&txn) ++ .is_some_and(|info| info.mode == IndexedDBTxnMode::Readwrite) ++ } ++ + fn can_commit_now(&self, txn: u64) -> bool { + self.can_start_by_spec(txn) && self.can_notify_txn_maybe_commit(txn) + } +@@ -523,6 +557,15 @@ impl IndexedDBEnvironment { + } + + fn finish_transaction(&mut self, txn: u64) { ++ // The transaction's writes stand, so what would have undone them is no longer needed. ++ // An aborted transaction reaches here too, after `abort_transaction` has already ++ // replayed and discarded them, and this then has nothing left to discard. ++ if self.is_readwrite(txn) { ++ if let Err(error) = self.engine.commit_transaction(txn) { ++ error!("Failed to release the undo record of transaction {txn}: {error:?}"); ++ } ++ } ++ + if let Some(info) = self.txn_info.get_mut(&txn) { + info.live = false; + } +@@ -542,6 +585,21 @@ impl IndexedDBEnvironment { + } + + fn abort_transaction(&mut self, txn: u64) { ++ // ++ // > When a transaction is aborted the implementation must undo (roll back) any changes ++ // > that were made to the database during that transaction. ++ // ++ // `handle_abort` holds the abort back until the transaction has no batch in flight, so ++ // everything the engine is going to write for it has been written by the time this ++ // runs. A failure here leaves writes standing that script has already been told were ++ // taken back, which is worth saying out loud rather than asserting about in debug ++ // builds alone. ++ if self.is_readwrite(txn) { ++ if let Err(error) = self.engine.rollback_transaction(txn) { ++ error!("Failed to roll back transaction {txn}: {error:?}"); ++ } ++ } ++ + let key_generator_snapshots = self + .txn_info + .get(&txn) +@@ -561,8 +619,10 @@ impl IndexedDBEnvironment { + // Likewise, if a transaction is aborted, the current number of the + // key generator for each object store in the transaction’s scope is + // reverted to the value it had before the transaction was started. +- let res = self.restore_key_generators_after_abort(&key_generator_snapshots); +- debug_assert!(res.is_ok(), "Restoring key generators should not fail."); ++ if let Err(error) = self.restore_key_generators_after_abort(&key_generator_snapshots) ++ { ++ error!("Failed to restore key generators after a transaction abort: {error}"); ++ } + + // Keep scheduling metadata until script reports TransactionFinished. + // https://w3c.github.io/IndexedDB/#transaction-lifetime +@@ -580,8 +640,10 @@ impl IndexedDBEnvironment { + self.pending_commit_callbacks.remove(&txn); + } + +- fn key_generator_current_number(&self, store_name: &str) -> Option { +- self.engine.key_generator_current_number(store_name) ++ fn key_generator_current_number(&self, store_name: &str) -> DbResult> { ++ self.engine ++ .key_generator_current_number(store_name) ++ .map_err(|err| format!("{err:?}")) + } + + fn set_key_generator_current_number( +@@ -611,9 +673,9 @@ impl IndexedDBEnvironment { + /// + fn object_store(&self, store_name: &str) -> DbResult { + // A key generator has a current number. +- let key_generator_current_number = self.key_generator_current_number(store_name); ++ let key_generator_current_number = self.key_generator_current_number(store_name)?; + Ok(IndexedDBObjectStore { +- key_path: self.key_path(store_name), ++ key_path: self.key_path(store_name)?, + has_key_generator: key_generator_current_number.is_some(), + key_generator_current_number, + indexes: self.indexes(store_name)?, +@@ -628,8 +690,10 @@ impl IndexedDBEnvironment { + .collect() + } + +- fn key_path(&self, store_name: &str) -> Option { +- self.engine.key_path(store_name) ++ fn key_path(&self, store_name: &str) -> DbResult> { ++ self.engine ++ .key_path(store_name) ++ .map_err(|err| format!("{err:?}")) + } + + fn object_store_names(&self) -> DbResult> { +@@ -663,6 +727,18 @@ impl IndexedDBEnvironment { + .map_err(|err| format!("{err:?}")) + } + ++ fn rename_object_store(&self, store_name: &str, new_name: &str) -> DbResult<()> { ++ self.engine ++ .rename_store(store_name, new_name) ++ .map_err(|err| format!("{err:?}")) ++ } ++ ++ fn rename_index(&self, store_name: &str, index_name: &str, new_name: &str) -> DbResult<()> { ++ self.engine ++ .rename_index(store_name, index_name, new_name) ++ .map_err(|err| format!("{err:?}")) ++ } ++ + fn create_object_store( + &mut self, + store_name: &str, +@@ -788,7 +864,10 @@ impl IndexedDBEnvironment { + /// This only aborts the transaction if one was previously queued by adding an abort + /// callback to [`Self::pending_abort_callbacks`]. + /// +- /// TODO: implement the abort algorithm and rollback for the engine. ++ /// The rollback itself is [`Self::abort_transaction`], below. An upgrade transaction is ++ /// still reverted by rebuilding the schema it started from rather than row by row, so the ++ /// records an upgrade wrote to a store that already existed are the one thing this does ++ /// not take back. + fn abort(&mut self, origin: &ImmutableOrigin, database_name: &str, transaction: u64) -> bool { + let message = || TxnCompleteMsg { + origin: origin.clone(), +@@ -869,17 +948,25 @@ enum OpenRequest { + }, + Delete { + /// The callback used to send a result to script. +- sender: GenericCallback>, ++ sender: GenericCallback, + + _origin: ImmutableOrigin, + + /// The name of the database. +- /// Note: will be used when the full spec is implemented. + db_name: String, + + /// + processed: bool, + ++ /// This request is pending on these connections to close. ++ pending_close: HashSet, ++ ++ /// This request is pending on these connections to fire a versionchange event. ++ /// Note: this starts as equal to `pending_close`, but when all events have fired, ++ /// not all connections need to have closed, in which case the `blocked` event ++ /// is fired at this request. ++ pending_versionchange: HashSet, ++ + id: Uuid, + + /// +@@ -906,6 +993,8 @@ impl OpenRequest { + _origin: _, + db_name: _, + processed: _, ++ pending_close: _, ++ pending_versionchange: _, + proxy_map: _, + id, + } => id, +@@ -931,6 +1020,8 @@ impl OpenRequest { + _origin: _, + db_name: _, + processed: _, ++ pending_close: _, ++ pending_versionchange: _, + proxy_map: _, + id: _, + } => false, +@@ -962,9 +1053,11 @@ impl OpenRequest { + _origin: _, + db_name: _, + processed, ++ pending_close, ++ pending_versionchange, + id: _, + proxy_map: _, +- } => !processed, ++ } => !processed || !pending_close.is_empty() || !pending_versionchange.is_empty(), + } + } + +@@ -999,10 +1092,15 @@ impl OpenRequest { + _origin: _, + db_name: _, + processed: _, ++ pending_close: _, ++ pending_versionchange: _, + id: _, + proxy_map: _, + } => { +- if sender.send(Err(BackendError::DbNotFound)).is_err() { ++ if sender ++ .send(DeleteDatabaseMsg::Done(Err(BackendError::DbNotFound))) ++ .is_err() ++ { + error!("Failed to send result of database delete to script."); + }; + None +@@ -1011,12 +1109,34 @@ impl OpenRequest { + } + } + ++/// A schema rename applied during an upgrade transaction. ++/// ++/// The revert in `restore_object_stores` matches stores and indexes by name, so a rename ++/// reads there as "one thing gone, another appeared" and it would delete the renamed ++/// store and recreate it empty. Replaying these in reverse before that comparison runs ++/// puts the names back first, so the comparison sees nothing changed and the records stay ++/// where they are. ++#[derive(Clone, MallocSizeOf)] ++enum SchemaRename { ++ ObjectStore { ++ from: String, ++ to: String, ++ }, ++ Index { ++ store: String, ++ from: String, ++ to: String, ++ }, ++} ++ + #[derive(Clone, MallocSizeOf)] + struct VersionUpgrade { + old: u64, + new: u64, + transaction: u64, + object_stores: Vec, ++ /// Renames applied by this upgrade transaction, in the order they were applied. ++ schema_renames: Vec, + } + + /// +@@ -1049,6 +1169,32 @@ struct IndexedDBManager { + } + + impl IndexedDBManager { ++ /// The version `databases()` may report for one database: the version its last committed ++ /// upgrade left, not one an upgrade transaction still in flight has already written. ++ /// ++ /// step 8 sets the database's version ++ /// before the transaction carrying the change commits, and ++ /// [`Self::revert_aborted_upgrade`] puts it back if that transaction aborts. So while an ++ /// upgrade runs, the stored version is a value no other connection may see yet, and ++ /// runs in parallel with it. ++ /// ++ /// This is what keeps a database being created invisible as well as one being migrated: a ++ /// creation upgrade reports the 0 it came from, and step 4.3.4 skips a version of 0. ++ fn committed_version(&self, description: &IndexedDBDescription, live: u64) -> u64 { ++ self.connection_queues ++ .get(description) ++ .and_then(|queue| { ++ queue.iter().find_map(|request| match request { ++ OpenRequest::Open { ++ pending_upgrade: Some(upgrade), ++ .. ++ } => Some(upgrade.old), ++ _ => None, ++ }) ++ }) ++ .unwrap_or(live) ++ } ++ + fn new( + port: GenericReceiver, + manager_sender: GenericSender, +@@ -1161,6 +1307,13 @@ impl IndexedDBManager { + operation, + transaction_serial_number, + } => { ++ self.record_schema_rename( ++ &origin, ++ &database_name, ++ &store_name, ++ &operation, ++ transaction_serial_number, ++ ); + if let Some(database) = + self.get_database_mut(origin.clone(), database_name.clone()) + { +@@ -1252,11 +1405,21 @@ impl IndexedDBManager { + }; + + if committed { ++ // The version bump is already durable by the time this arrives, so a missing ++ // queue or request is not a benign no-op: it is an open request that will ++ // never be answered. Say so, rather than returning into a debug-only ++ // assertion that release builds do not have. + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return error!( ++ "Upgrade of {:?} committed with no connection queue to report it to.", ++ key.name ++ ); + }; + let Some(front) = queue.front() else { +- return debug_assert!(false, "A pending open request should exist."); ++ return error!( ++ "Upgrade of {:?} committed with no open request to report it to.", ++ key.name ++ ); + }; + let OpenRequest::Open { + pending_upgrade: Some(pending_upgrade), +@@ -1311,10 +1474,10 @@ impl IndexedDBManager { + + let (request_id, proxy_map) = { + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return warn!("No connection queue for the aborted upgrade transaction."); + }; + let Some(front) = queue.front() else { +- return debug_assert!(false, "A pending open request should exist."); ++ return warn!("No open request for the aborted upgrade transaction."); + }; + let OpenRequest::Open { + pending_upgrade: Some(pending_upgrade), +@@ -1342,10 +1505,10 @@ impl IndexedDBManager { + let Some(queue) = self.connection_queues.get_mut(&key) else { + return; + }; +- if queue.is_empty() { ++ let Some(front) = queue.front() else { + return; +- } +- queue.front().expect("Queue is not empty.").is_open() ++ }; ++ front.is_open() + }; + + if is_open { +@@ -1357,10 +1520,10 @@ impl IndexedDBManager { + let was_pruned = self.maybe_remove_front_from_queue(&key); + + if !was_pruned { +- // Note: requests to delete a database are, at this point in the implementation, +- // done in one step; so we can continue on to the next request. +- // Request to open a connection consists of multiple async steps, so we must break if +- // it is still pending. ++ // Note: both kinds of request consist of multiple async steps, so we must ++ // break if the front one is still pending. A delete waits for open ++ // connections to fire `versionchange` and then close; an open waits for ++ // the same two conditions and then for its upgrade transaction. + break; + } + } +@@ -1370,15 +1533,12 @@ impl IndexedDBManager { + fn maybe_remove_front_from_queue(&mut self, key: &IndexedDBDescription) -> bool { + let (is_empty, was_pruned) = { + let Some(queue) = self.connection_queues.get_mut(key) else { +- debug_assert!(false, "A connection queue should exist."); ++ warn!("No connection queue to prune for {:?}.", key.name); + return false; + }; + let mut pruned = false; +- let front_is_pending = queue.front().map(|record| record.is_pending()); +- if let Some(is_pending) = front_is_pending && +- !is_pending +- { +- queue.pop_front().expect("Queue has a non-pending item."); ++ if queue.front().is_some_and(|record| !record.is_pending()) { ++ queue.pop_front(); + pruned = true + } + (queue.is_empty(), pruned) +@@ -1403,6 +1563,81 @@ impl IndexedDBManager { + } + } + ++ /// Append a rename to the pending upgrade's log so an abort can undo it. ++ /// ++ /// A rename that reaches here without a matching pending upgrade cannot be reverted, ++ /// but it also cannot be aborted: `IDBObjectStore.name` and `IDBIndex.name` refuse ++ /// outside an upgrade transaction, so there is nothing to record. ++ fn record_schema_rename( ++ &mut self, ++ origin: &ImmutableOrigin, ++ database_name: &str, ++ store_name: &str, ++ operation: &AsyncSchemaOperation, ++ transaction_serial_number: u64, ++ ) { ++ let rename = match operation { ++ AsyncSchemaOperation::RenameObjectStore { new_name, .. } => { ++ SchemaRename::ObjectStore { ++ from: store_name.to_owned(), ++ to: new_name.clone(), ++ } ++ }, ++ AsyncSchemaOperation::RenameIndex { ++ index_name, ++ new_name, ++ .. ++ } => SchemaRename::Index { ++ store: store_name.to_owned(), ++ from: index_name.clone(), ++ to: new_name.clone(), ++ }, ++ _ => return, ++ }; ++ ++ let key = IndexedDBDescription { ++ origin: origin.clone(), ++ name: database_name.to_owned(), ++ }; ++ let Some(queue) = self.connection_queues.get_mut(&key) else { ++ return; ++ }; ++ let Some(OpenRequest::Open { ++ pending_upgrade: Some(pending_upgrade), ++ .. ++ }) = queue.front_mut() ++ else { ++ return; ++ }; ++ if pending_upgrade.transaction != transaction_serial_number { ++ return; ++ } ++ pending_upgrade.schema_renames.push(rename); ++ } ++ ++ /// Undo the upgrade's renames, newest first, so the name comparison in ++ /// `restore_object_stores` sees the schema it expects. ++ fn revert_schema_renames(&mut self, key: &IndexedDBDescription, upgrade: &VersionUpgrade) { ++ let Some(db) = self.databases.get_mut(key) else { ++ return; ++ }; ++ for rename in upgrade.schema_renames.iter().rev() { ++ let result = match rename { ++ SchemaRename::ObjectStore { from, to } => db.rename_object_store(to, from), ++ SchemaRename::Index { store, from, to } => db.rename_index(store, to, from), ++ }; ++ if let Err(error) = result { ++ // The name comparison below will fall back to deleting and recreating, ++ // which loses this store's records. Say which rename failed rather than ++ // letting the loss look like a correct revert. ++ error!( ++ "Failed to undo a rename while reverting the aborted upgrade of {:?}: {error}", ++ key.name ++ ); ++ } ++ } ++ } ++ + /// Revert the backing database state after aborting an upgrade transaction. + /// + /// +@@ -1413,44 +1648,59 @@ impl IndexedDBManager { + /// that first upgrade must roll back to the pre-creation state by deleting the + /// placeholder backing store entirely. + /// ++ /// The snapshot is borrowed rather than consumed, and every step is individually ++ /// idempotent: deleting a database that is already gone, setting a version that is ++ /// already `old`, and restoring object stores that already match are all no-ops. ++ /// A caller whose revert failed therefore still holds everything a second attempt ++ /// needs, and a partially applied revert converges when it is re-run. ++ /// + /// Related: + fn revert_aborted_upgrade( + &mut self, + key: &IndexedDBDescription, + upgrade: &VersionUpgrade, + proxy_map: &StorageProxyMap, +- ) { ++ ) -> DbResult<()> { ++ // An upgrade from version zero is what created the database, so reverting it ++ // deletes the database rather than winding a version back. + if upgrade.old == 0 { +- if let Some(db) = self.databases.remove(key) { +- // Note: ensure db is dropped before deleting directory, +- // to get around windows file locks. +- drop(db); +- let response = proxy_map +- .handle +- .delete_database(proxy_map.bottle_id, key.name.clone()) +- .recv(); +- if response.is_err() { +- error!("Failed to communicate with client storage."); +- return; +- } +- if response.unwrap().is_err() { +- error!("Failed to delete database {:?}", key.name); +- } +- } +- return; ++ let Some(db) = self.databases.remove(key) else { ++ // Already removed, by an earlier attempt at this same revert. ++ return Ok(()); ++ }; ++ // Note: ensure db is dropped before deleting directory, ++ // to get around windows file locks. ++ // ++ // Dropping first is what the file locks require, so a failed delete leaves ++ // the directory on disk with no entry in `self.databases`. That residual is ++ // unchanged here; what changed is that it is now reported instead of logged ++ // and swallowed, so the caller knows the revert did not complete. ++ drop(db); ++ let response = proxy_map ++ .handle ++ .delete_database(proxy_map.bottle_id, key.name.clone()) ++ .recv() ++ .map_err(|_| "Failed to communicate with client storage".to_string())?; ++ return response ++ .map_err(|error| format!("Failed to delete database {:?}: {error:?}", key.name)); + } + ++ // Renames first: `restore_object_stores` compares name sets, so a store still ++ // carrying its new name reads there as a store to delete and a store to create. ++ self.revert_schema_renames(key, upgrade); ++ + let Some(db) = self.databases.get_mut(key) else { +- return debug_assert!(false, "Db should have been created"); ++ return Err(format!( ++ "No open database to revert the aborted upgrade of {:?}", ++ key.name ++ )); + }; +- let res = db.set_version(upgrade.old); +- debug_assert!(res.is_ok(), "Setting a db version should not fail."); ++ db.set_version(upgrade.old)?; + + // Step 4. Set connection’s object store set to the set of object stores + // in database if database previously existed, or the empty set if + // database was newly created. +- let res = db.restore_object_stores(&upgrade.object_stores); +- debug_assert!(res.is_ok(), "Restoring object stores should not fail."); ++ db.restore_object_stores(&upgrade.object_stores) + } + + /// Aborting the current upgrade for an origin. +@@ -1465,24 +1715,34 @@ impl IndexedDBManager { + let key = IndexedDBDescription { name, origin }; + let upgrade = { + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!( +- false, +- "There should be a connection queue for the aborted upgrade." ++ return warn!( ++ "No connection queue for the aborted upgrade of {:?}.", ++ key.name + ); + }; +- let Some(open_request) = queue.pop_front() else { +- return debug_assert!(false, "There should be an open request to upgrade."); ++ // The identity check reads the front and only then pops it. Popping first ++ // and checking afterwards discards another connection's open request in ++ // release builds, where the failed assertion is not there to stop it. ++ let Some(front) = queue.front() else { ++ warn!("No open request to abort for the upgrade of {:?}.", key.name); ++ return; + }; +- if open_request.get_id() != id { +- return debug_assert!( +- false, +- "Open request to abort should be at the head of the queue." +- ); ++ if front.get_id() != id { ++ warn!("The open request to abort is not at the head of the connection queue."); ++ return; + } ++ let Some(open_request) = queue.pop_front() else { ++ return; ++ }; + open_request.abort() + }; +- if let Some(upgrade) = upgrade { +- self.revert_aborted_upgrade(&key, &upgrade, proxy_map); ++ if let Some(upgrade) = upgrade && ++ let Err(error) = self.revert_aborted_upgrade(&key, &upgrade, proxy_map) ++ { ++ error!( ++ "Failed to revert the aborted upgrade of {:?}: {error}", ++ key.name ++ ); + } + + self.remove_connection(&key, &id); +@@ -1500,7 +1760,10 @@ impl IndexedDBManager { + proxy_map: StorageProxyMap, + ) { + for (name, ids) in pending_upgrades.into_iter() { +- let mut upgrade_to_revert: Option = None; ++ // Every aborted request that carried an upgrade contributes a revert. ++ // Keeping only the first one left the remaining version bumps applied with ++ // nothing left in the queue to undo them. ++ let mut upgrades_to_revert: Vec = Vec::new(); + let key = IndexedDBDescription { + name: name.clone(), + origin: origin.clone(), +@@ -1508,32 +1771,37 @@ impl IndexedDBManager { + for id in ids.iter() { + self.remove_connection(&key, id); + } +- { +- let is_empty = { +- let Some(queue) = self.connection_queues.get_mut(&key) else { +- continue; +- }; +- queue.retain_mut(|open_request| { +- if ids.contains(&open_request.get_id()) { +- let upgrade = open_request.abort(); +- if upgrade_to_revert.is_none() && +- let Some(upgrade) = upgrade +- { +- upgrade_to_revert = Some(upgrade); +- } +- false +- } else { +- true +- } +- }); +- queue.is_empty() ++ let is_empty = { ++ let Some(queue) = self.connection_queues.get_mut(&key) else { ++ continue; + }; +- if is_empty { +- self.connection_queues.remove(&key); ++ queue.retain_mut(|open_request| { ++ if ids.contains(&open_request.get_id()) { ++ if let Some(upgrade) = open_request.abort() { ++ upgrades_to_revert.push(upgrade); ++ } ++ false ++ } else { ++ true ++ } ++ }); ++ queue.is_empty() ++ }; ++ if is_empty { ++ self.connection_queues.remove(&key); ++ } ++ for upgrade in &upgrades_to_revert { ++ if let Err(error) = self.revert_aborted_upgrade(&key, upgrade, &proxy_map) { ++ error!( ++ "Failed to revert the aborted upgrade of {:?}: {error}", ++ key.name ++ ); + } + } +- if let Some(upgrade) = upgrade_to_revert { +- self.revert_aborted_upgrade(&key, &upgrade, &proxy_map); ++ if !is_empty { ++ // Requests queued behind the aborted ones are now at the front of a ++ // queue that nothing else is going to advance. ++ self.advance_connection_queue(key); + } + } + } +@@ -1608,12 +1876,12 @@ impl IndexedDBManager { + /// + /// To upgrade a database with connection (a connection), + /// a new version, and a request, run these steps: +- fn upgrade_database(&mut self, key: IndexedDBDescription, new_version: u64) { ++ fn upgrade_database(&mut self, key: IndexedDBDescription, new_version: u64) -> DbResult<()> { + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return Err("No connection queue for the database being upgraded".to_string()); + }; + let Some(open_request) = queue.front_mut() else { +- return debug_assert!(false, "An open request should be in the queue."); ++ return Err("No open request at the front of the connection queue".to_string()); + }; + let OpenRequest::Open { + sender, +@@ -1627,52 +1895,83 @@ impl IndexedDBManager { + proxy_map: _, + } = open_request + else { +- return; ++ return Ok(()); + }; + + // Step 1: Let db be connection’s database. +- let db = self +- .databases +- .get_mut(&key) +- .expect("Db should have been opened."); ++ let Some(db) = self.databases.get_mut(&key) else { ++ return Err("The database being upgraded is not open".to_string()); ++ }; + + // Step 2: Let transaction be a new upgrade transaction with connection used as connection. + let transaction = self.serial_number_counter; + self.serial_number_counter += 1; + +- // Step 3: Set transaction’s scope to connection’s object store set. +- let scope = db +- .object_store_names() +- .expect("Fetching object store names should not fail."); +- +- // Step 4: Set db’s upgrade transaction to transaction. +- // Backend tracks the active upgrade transaction in `pending_upgrade` below. +- db.register_transaction(transaction, IndexedDBTxnMode::Versionchange, scope.clone()); +- +- // Step 5: Set transaction’s state to inactive. +- // Step 6: Start transaction. +- // Backend transactions are started by the scheduler when requests are queued; +- // newly created upgrade transactions are therefore initially inactive. +- +- // Step 7: Let old version be db’s version. +- let old_version = db.version().expect("DB should have a version."); +- let object_stores = db +- .object_stores() +- .expect("Fetching object stores should not fail."); +- +- // Step 8: Set db’s version to version. This change is considered part of the +- // transaction, and so if the transaction is aborted, this change is reverted. +- db.set_version(new_version) +- .expect("Setting the version should not fail"); ++ // Steps 3 through 8. The upgrade transaction and the pending-upgrade record are ++ // what the revert path reads, so the one durable write here is bracketed by ++ // both: it happens after the record that says how to undo it is installed, and ++ // the failure tail below removes the record and the registration together. ++ let mut scope = Vec::new(); ++ let outcome = (|| -> DbResult { ++ // Step 3: Set transaction’s scope to connection’s object store set. ++ scope = db.object_store_names()?; ++ ++ // Step 4: Set db’s upgrade transaction to transaction. ++ // Backend tracks the active upgrade transaction in `pending_upgrade` below. ++ db.register_transaction( ++ transaction, ++ IndexedDBTxnMode::Versionchange, ++ scope.clone(), ++ )?; ++ ++ // Step 5: Set transaction’s state to inactive. ++ // Step 6: Start transaction. ++ // Backend transactions are started by the scheduler when requests are queued; ++ // newly created upgrade transactions are therefore initially inactive. ++ ++ // Step 7: Let old version be db’s version. ++ let old_version = db.version().map_err(|err| format!("{err:?}"))?; ++ let object_stores = db.object_stores()?; ++ ++ // Step 8: Set db’s version to version. This change is considered part of the ++ // transaction, and so if the transaction is aborted, this change is reverted. ++ let _ = pending_upgrade.insert(VersionUpgrade { ++ old: old_version, ++ new: new_version, ++ transaction, ++ object_stores, ++ schema_renames: Vec::new(), ++ }); ++ db.set_version(new_version)?; ++ Ok(old_version) ++ })(); ++ ++ let old_version = match outcome { ++ Ok(old_version) => old_version, ++ Err(error) => { ++ // Nothing durable survived, so neither does the bookkeeping: the pending ++ // record goes away with the registration it describes, and the request is ++ // marked processed so the connection queue does not stay blocked behind a ++ // request that has already been answered. ++ *pending_upgrade = None; ++ *processed = true; ++ db.finish_transaction(transaction); ++ if sender ++ .send(ConnectionMsg::DatabaseError { ++ name: db_name.clone(), ++ id: *id, ++ error: BackendError::DbErr(error.clone()), ++ }) ++ .is_err() ++ { ++ error!("Couldn't queue task for indexeddb upgrade failure."); ++ } ++ return Err(error); ++ }, ++ }; + + // Step 9: Set request’s processed flag to true. + *processed = true; +- let _ = pending_upgrade.insert(VersionUpgrade { +- old: old_version, +- new: new_version, +- transaction, +- object_stores, +- }); + + // Step 10: Queue a database task to run these steps. + if sender +@@ -1691,6 +1990,7 @@ impl IndexedDBManager { + + // Step 11: Wait for transaction to finish. + // Queue progression remains blocked while `pending_upgrade` is set. ++ Ok(()) + } + + /// +@@ -1705,12 +2005,65 @@ impl IndexedDBManager { + name: name.clone(), + origin, + }; ++ // ++ // A delete request waits on the same two conditions as an open request, but its ++ // `blocked` event fires at the request itself rather than at a connection. ++ // `Some(true)` means every condition is met and the delete can run. ++ let delete_can_finish = { ++ let Some(queue) = self.connection_queues.get_mut(&key) else { ++ return warn!("A connection queue should exist."); ++ }; ++ let Some(open_request) = queue.front_mut() else { ++ return warn!("An open request should be in the queue."); ++ }; ++ match open_request { ++ OpenRequest::Delete { ++ sender, ++ pending_versionchange, ++ pending_close, ++ .. ++ } => { ++ pending_versionchange.remove(&from_id); ++ ++ // Step 7: Wait for all of the events to be fired. ++ if !pending_versionchange.is_empty() { ++ Some(false) ++ } else if !pending_close.is_empty() { ++ // Step 8: If any of the connections in openConnections are still ++ // not closed, queue a database task to fire a version change ++ // event named blocked at request with db's version and null. ++ if sender ++ .send(DeleteDatabaseMsg::Blocked { old_version }) ++ .is_err() ++ { ++ debug!("Script went away during pending database delete."); ++ } ++ // Step 9: Wait until all connections in openConnections are ++ // closed. The algorithm continues in `close_database`. ++ Some(false) ++ } else { ++ Some(true) ++ } ++ }, ++ OpenRequest::Open { .. } => None, ++ } ++ }; ++ if let Some(can_finish) = delete_can_finish { ++ if can_finish { ++ self.finish_delete_database(key.clone()); ++ if self.maybe_remove_front_from_queue(&key) { ++ self.advance_connection_queue(key); ++ } ++ } ++ return; ++ } ++ + let (can_upgrade, version) = { + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return warn!("A connection queue should exist."); + }; + let Some(open_request) = queue.front_mut() else { +- return debug_assert!(false, "An open request should be in the queue."); ++ return warn!("An open request should be in the queue."); + }; + let OpenRequest::Open { + sender, +@@ -1724,15 +2077,13 @@ impl IndexedDBManager { + proxy_map: _, + } = open_request + else { +- return debug_assert!( +- false, +- "An request to open a connection should be in the queue." +- ); ++ return warn!("An request to open a connection should be in the queue."); + }; +- debug_assert!( +- pending_versionchange.contains(&from_id), +- "The open request should be pending on the versionchange event for the connection sending the message." +- ); ++ if !pending_versionchange.contains(&from_id) { ++ warn!( ++ "A versionchange acknowledgement arrived from a connection the open request was not waiting on." ++ ); ++ } + + pending_versionchange.remove(&from_id); + +@@ -1742,9 +2093,8 @@ impl IndexedDBManager { + } + + let Some(version) = *version else { +- return debug_assert!( +- false, +- "An upgrade version should have been determined by now." ++ return warn!( ++ "No upgrade version was determined before the versionchange events completed." + ); + }; + +@@ -1771,7 +2121,9 @@ impl IndexedDBManager { + // Note: if we still need to wait, the algorithm will continue in the handling of the close message. + if can_upgrade { + // Step 10.6: Run upgrade a database using connection, version and request. +- self.upgrade_database(key.clone(), version); ++ // A failed upgrade has already answered the request with a database error, ++ // so the prune below removes it and the queue keeps moving. ++ let _ = self.upgrade_database(key.clone(), version); + + let was_pruned = self.maybe_remove_front_from_queue(&key); + if was_pruned { +@@ -1784,10 +2136,10 @@ impl IndexedDBManager { + /// The part where the open request is ready for processing. + fn open_database(&mut self, key: IndexedDBDescription) { + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return warn!("No connection queue for the database being opened."); + }; + let Some(open_request) = queue.front_mut() else { +- return debug_assert!(false, "An open request should be in the queue."); ++ return warn!("No open request at the front of the connection queue."); + }; + let OpenRequest::Open { + sender, +@@ -1801,9 +2153,8 @@ impl IndexedDBManager { + proxy_map, + } = open_request + else { +- return debug_assert!( +- false, +- "An request to open a connection should be in the queue." ++ return warn!( ++ "The entry at the front of the connection queue is not an open request." + ); + }; + +@@ -1910,10 +2261,7 @@ impl IndexedDBManager { + }; + + let Some(version) = *version else { +- return debug_assert!( +- false, +- "An upgrade version should have been determined by now." +- ); ++ return warn!("No upgrade version was determined while opening the database."); + }; + + // Step 7: If db’s version is greater than version, +@@ -1958,7 +2306,7 @@ impl IndexedDBManager { + .send(ConnectionMsg::VersionChange { + name: db_name.clone(), + id: *id_to_close, +- version, ++ version: Some(version), + old_version: db_version, + }) + .is_err() +@@ -1974,7 +2322,14 @@ impl IndexedDBManager { + } + + // Step 10.6: Run upgrade a database using connection, version and request. +- self.upgrade_database(key, version); ++ // The success path leaves the request pending until the upgrade transaction ++ // finishes. A failure has already answered it, so the queue is advanced here ++ // instead of waiting for a completion that will never arrive. ++ if self.upgrade_database(key.clone(), version).is_err() && ++ self.maybe_remove_front_from_queue(&key) ++ { ++ self.advance_connection_queue(key); ++ } + return; + } + +@@ -2006,13 +2361,15 @@ impl IndexedDBManager { + key: IndexedDBDescription, + id: Uuid, + proxy_map: StorageProxyMap, +- sender: GenericCallback>, ++ sender: GenericCallback, + ) { + let open_request = OpenRequest::Delete { + sender, + _origin: key.origin.clone(), + db_name: key.name.clone(), + processed: false, ++ pending_close: Default::default(), ++ pending_versionchange: Default::default(), + proxy_map, + id, + }; +@@ -2034,49 +2391,120 @@ impl IndexedDBManager { + } + + /// ++ /// Steps 4 through 9: notify every open connection and wait for them to close. + fn delete_database(&mut self, key: IndexedDBDescription) { ++ // Step 4: Let db be the database named name in storageKey, if one exists. ++ // Otherwise, return 0 (zero). ++ // Note: a database that is not open has no connections, so steps 5 through 9 ++ // are vacuous and the delete runs straight through. ++ let Some(db) = self.databases.get(&key) else { ++ return self.finish_delete_database(key); ++ }; ++ let db_version = match db.version() { ++ Ok(version) => version, ++ Err(error) => return self.fail_delete_database(&key, error), ++ }; ++ ++ // Step 5: Let openConnections be the set of all connections associated with db. ++ // Step 6: For each entry of openConnections that does not have its close pending ++ // flag set to true, queue a database task to fire a version change event named ++ // versionchange at entry with db's version and null. ++ let mut pending: HashSet = HashSet::new(); ++ if let Some(connections) = self.connections.get(&key) { ++ for (connection_id, connection) in connections.iter() { ++ if connection.close_pending { ++ continue; ++ } ++ if connection ++ .sender ++ .send(ConnectionMsg::VersionChange { ++ name: key.name.clone(), ++ id: *connection_id, ++ version: None, ++ old_version: db_version, ++ }) ++ .is_err() ++ { ++ error!("Failed to send ConnectionMsg::VersionChange to script."); ++ } ++ pending.insert(*connection_id); ++ } ++ } ++ ++ if pending.is_empty() { ++ return self.finish_delete_database(key); ++ } ++ ++ // Step 7: Wait for all of the events to be fired. ++ // Step 9: Wait until all connections in openConnections are closed. ++ // Note: the algorithm continues in `handle_version_change_done` once every event ++ // has fired, and in `close_database` once every connection has closed. ++ let Some(queue) = self.connection_queues.get_mut(&key) else { ++ return warn!("A connection queue should exist while deleting a database."); ++ }; ++ let Some(OpenRequest::Delete { ++ pending_close, ++ pending_versionchange, ++ .. ++ }) = queue.front_mut() ++ else { ++ return warn!("A request to delete a database should be in the queue."); ++ }; ++ *pending_close = pending.clone(); ++ *pending_versionchange = pending; ++ } ++ ++ /// ++ /// Answer the request at the front of the queue with a backend error. ++ fn fail_delete_database(&mut self, key: &IndexedDBDescription, error: BackendError) { ++ let Some(queue) = self.connection_queues.get_mut(key) else { ++ return warn!("A connection queue should exist while deleting a database."); ++ }; ++ let Some(OpenRequest::Delete { ++ sender, processed, .. ++ }) = queue.front_mut() ++ else { ++ return warn!("A request to delete a database should be in the queue."); ++ }; ++ *processed = true; ++ if sender.send(DeleteDatabaseMsg::Done(Err(error))).is_err() { ++ debug!("Script went away during pending database delete."); ++ } ++ } ++ ++ /// ++ /// Steps 10 through 12: every connection has closed, so the database can be deleted. ++ fn finish_delete_database(&mut self, key: IndexedDBDescription) { ++ // Note: the database is removed from the open set here rather than at step 4, ++ // because connections kept using it while the request waited for them to close. ++ let db = self.databases.remove(&key); ++ + let Some(queue) = self.connection_queues.get_mut(&key) else { +- return debug_assert!(false, "A connection queue should exist."); ++ return warn!("A connection queue should exist while deleting a database."); + }; + let Some(open_request) = queue.front_mut() else { +- return debug_assert!(false, "An open request should be in the queue."); ++ return warn!("A request to delete a database should be in the queue."); + }; + let OpenRequest::Delete { + sender, +- _origin: _, + db_name, + processed, +- id: _, + proxy_map, ++ .. + } = open_request + else { +- return debug_assert!( +- false, +- "An request to open a connection should be in the queue." +- ); ++ return warn!("A request to delete a database should be in the queue."); + }; + +- // Step 4: Let db be the database named name in storageKey, if one exists. Otherwise, return 0 (zero). +- let version = if let Some(db) = self.databases.remove(&key) { +- // Step 5: Let openConnections be the set of all connections associated with db. +- // Step6: For each entry of openConnections that does not have its close pending flag set to true, +- // queue a database task to fire a version change event named versionchange +- // at entry with db’s version and null. +- // Step 7: Wait for all of the events to be fired. +- // Step 8: If any of the connections in openConnections are still not closed, +- // queue a database task to fire a version change event +- // named blocked at request with db’s version and null. +- // Step 9: Wait until all connections in openConnections are closed. +- // TODO: implement connections. +- +- // Step 10: Let version be db’s version. ++ let version = if let Some(db) = db { ++ // Step 10: Let version be db's version. + let res = db.version(); + let Ok(version) = res else { + *processed = true; + if sender +- .send(BackendResult::Err(BackendError::DbErr( ++ .send(DeleteDatabaseMsg::Done(Err(BackendError::DbErr( + res.unwrap_err().to_string(), +- ))) ++ )))) + .is_err() + { + debug!("Script went away during pending database delete."); +@@ -2096,10 +2524,11 @@ impl IndexedDBManager { + .delete_database(proxy_map.bottle_id, db_name.clone()) + .recv() + else { ++ *processed = true; + if sender +- .send(BackendResult::Err(BackendError::DbErr( ++ .send(DeleteDatabaseMsg::Done(Err(BackendError::DbErr( + "Failed to communicate with client storage.".to_string(), +- ))) ++ )))) + .is_err() + { + debug!("Script went away during pending database delete."); +@@ -2107,10 +2536,11 @@ impl IndexedDBManager { + return; + }; + if let Err(err) = response { ++ *processed = true; + if sender +- .send(BackendResult::Err(BackendError::DbErr(format!( ++ .send(DeleteDatabaseMsg::Done(Err(BackendError::DbErr(format!( + "Client storage error: {err:?}" +- )))) ++ ))))) + .is_err() + { + debug!("Script went away during pending database delete."); +@@ -2123,11 +2553,10 @@ impl IndexedDBManager { + }; + + // step 12: Return version. +- if sender.send(BackendResult::Ok(version)).is_err() { ++ *processed = true; ++ if sender.send(DeleteDatabaseMsg::Done(Ok(version))).is_err() { + debug!("Script went away during pending database delete."); + } +- +- *processed = true; + } + + /// +@@ -2149,7 +2578,7 @@ impl IndexedDBManager { + // + // in the case that an open request is waiting for connections to close. + let key = IndexedDBDescription { origin, name }; +- let (can_upgrade, version) = { ++ let (can_upgrade, version, delete_can_finish) = { + self.remove_connection(&key, &id); + + let Some(queue) = self.connection_queues.get_mut(&key) else { +@@ -2158,31 +2587,55 @@ impl IndexedDBManager { + let Some(open_request) = queue.front_mut() else { + return; + }; +- if let OpenRequest::Open { +- sender: _, +- db_name: _, +- version, +- id: _, +- processed: _, +- pending_upgrade, +- pending_versionchange, +- pending_close, +- proxy_map: _, +- } = open_request +- { +- pending_close.remove(&id); +- ( +- // Note: need to exclude requests that have already started upgrading. +- pending_close.is_empty() && +- pending_versionchange.is_empty() && +- !pending_upgrade.is_some(), +- *version, +- ) +- } else { +- (false, None) ++ match open_request { ++ OpenRequest::Open { ++ sender: _, ++ db_name: _, ++ version, ++ id: _, ++ processed: _, ++ pending_upgrade, ++ pending_versionchange, ++ pending_close, ++ proxy_map: _, ++ } => { ++ pending_close.remove(&id); ++ ( ++ // Note: need to exclude requests that have already started upgrading. ++ pending_close.is_empty() && ++ pending_versionchange.is_empty() && ++ !pending_upgrade.is_some(), ++ *version, ++ false, ++ ) ++ }, ++ // ++ // Step 9: Wait until all connections in openConnections are closed. ++ // Note: the versionchange events must also have all fired, because a ++ // connection may close before the event queued at step 6 reaches it. ++ OpenRequest::Delete { ++ pending_close, ++ pending_versionchange, ++ .. ++ } => { ++ pending_close.remove(&id); ++ ( ++ false, ++ None, ++ pending_close.is_empty() && pending_versionchange.is_empty(), ++ ) ++ }, + } + }; + ++ if delete_can_finish { ++ self.finish_delete_database(key.clone()); ++ if self.maybe_remove_front_from_queue(&key) { ++ self.advance_connection_queue(key); ++ } ++ return; ++ } ++ + // + // Step 10.3: Wait for all of the events to be fired. + // Step 10.5: Wait until all connections in openConnections are closed. +@@ -2191,12 +2644,13 @@ impl IndexedDBManager { + if can_upgrade { + // Step 10.6: Run upgrade a database using connection, version and request. + let Some(version) = version else { +- return debug_assert!( +- false, +- "An upgrade version should have been determined by now." ++ return warn!( ++ "No upgrade version was determined before the last connection closed." + ); + }; +- self.upgrade_database(key.clone(), version); ++ // A failed upgrade has already answered the request with a database error, ++ // so the prune below removes it and the queue keeps moving. ++ let _ = self.upgrade_database(key.clone(), version); + + let was_pruned = self.maybe_remove_front_from_queue(&key); + if was_pruned { +@@ -2224,6 +2678,7 @@ impl IndexedDBManager { + .filter_map(|(description, info)| { + // Step 4.3: For each db of databases: + if let Ok(version) = info.version() { ++ let version = self.committed_version(description, version); + // Step 4.3.4: If db’s version is 0, then continue. + if version == 0 { + None +@@ -2278,18 +2733,16 @@ impl IndexedDBManager { + self.start_delete_database(idb_description, id, proxy_map, callback); + }, + SyncOperation::GetObjectStore(sender, origin, db_name, store_name) => { +- // FIXME:(arihant2math) Should we error out more aggressively here? +- let result = self.get_database(origin, db_name).map(|db| { +- let key_generator_current_number = db.key_generator_current_number(&store_name); +- IndexedDBObjectStore { +- key_path: db.key_path(&store_name), +- has_key_generator: key_generator_current_number.is_some(), +- key_generator_current_number, +- indexes: db.indexes(&store_name).unwrap_or_default(), +- name: store_name, +- } +- }); +- let _ = sender.send(result.ok_or(BackendError::DbNotFound)); ++ // `object_store` is the same construction the upgrade path uses, and it ++ // now reports a failed metadata read instead of returning a store whose ++ // key path and key generator are indistinguishable from absent ones. ++ let result = match self.get_database(origin, db_name) { ++ Some(db) => db ++ .object_store(&store_name) ++ .map_err(BackendError::DbErr), ++ None => Err(BackendError::DbNotFound), ++ }; ++ let _ = sender.send(result); + }, + SyncOperation::Commit(callback, origin, db_name, txn) => { + // https://w3c.github.io/IndexedDB/#commit-a-transaction +@@ -2408,9 +2861,15 @@ impl IndexedDBManager { + if let Some(db) = self.databases.get_mut(&key) { + let transaction_id = self.serial_number_counter; + self.serial_number_counter += 1; +- db.register_transaction(transaction_id, mode, scope); +- db.schedule_transactions(origin, &db_name); +- let _ = sender.send(Ok(transaction_id)); ++ match db.register_transaction(transaction_id, mode, scope) { ++ Ok(()) => { ++ db.schedule_transactions(origin, &db_name); ++ let _ = sender.send(Ok(transaction_id)); ++ }, ++ Err(error) => { ++ let _ = sender.send(Err(BackendError::DbErr(error))); ++ }, ++ } + } else { + let _ = sender.send(Err(BackendError::DbNotFound)); + } +@@ -2442,7 +2901,7 @@ impl IndexedDBManager { + self.handle_version_change_done(name, id, old_version, origin); + }, + SyncOperation::Exit(_) => { +- unreachable!("We must've already broken out of event loop."); ++ warn!("Received an Exit message after the event loop should have ended."); + }, + } + } +@@ -2581,14 +3040,15 @@ mod tests { + let mut env = IndexedDBEnvironment::new(engine, sender); + + env.create_object_store("books", None, true).unwrap(); +- assert_eq!(env.key_generator_current_number("books"), Some(1)); ++ assert_eq!(env.key_generator_current_number("books"), Ok(Some(1))); + +- env.register_transaction(1, IndexedDBTxnMode::Readwrite, vec!["books".to_string()]); ++ env.register_transaction(1, IndexedDBTxnMode::Readwrite, vec!["books".to_string()]) ++ .unwrap(); + env.set_key_generator_current_number("books", 345680) + .unwrap(); + + env.abort_transaction(1); + +- assert_eq!(env.key_generator_current_number("books"), Some(1)); ++ assert_eq!(env.key_generator_current_number("books"), Ok(Some(1))); + } + } +diff --git a/components/storage/tests/storage_thread.rs b/components/storage/tests/storage_thread.rs +index 3f01398f86..e53346549e 100644 +--- a/components/storage/tests/storage_thread.rs ++++ b/components/storage/tests/storage_thread.rs +@@ -4,9 +4,13 @@ + + use std::collections::BTreeMap; + use std::path::PathBuf; ++use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + ++use malloc_size_of::{MallocSizeOf, MallocSizeOfOps}; + use profile::mem as profile_mem; ++use profile::time as profile_time; ++use profile_traits::generic_callback::GenericCallback as ProfiledCallback; + use servo_base::generic_channel::{self, GenericCallback, GenericSend}; + use servo_base::id::{BrowsingContextId, Index, PipelineNamespaceId, TEST_WEBVIEW_ID, WebViewId}; + use servo_url::ServoUrl; +@@ -14,12 +18,18 @@ use storage_traits::cache_storage::{ + CacheStorageEngine, CacheStorageEngineFactory, CacheStorageError, CacheStorageThreadMessage, + CacheStorageThreadResponse, + }; +-use storage_traits::client_storage::{ClientStorageThreadMessage, StorageProxyMap}; +-use storage_traits::indexeddb::{IndexedDBThreadMsg, SyncOperation}; ++use storage_traits::client_storage::{ ++ ClientStorageThreadMessage, StorageIdentifier, StorageProxyMap, StorageType, ++}; ++use storage_traits::indexeddb::{ ++ BackendResult, ConnectionMsg, CreateObjectResult, IndexedDBDescription, IndexedDBIndex, ++ IndexedDBThreadMsg, IndexedDbEngineFactory, KeyPath, KvsEngine, KvsTransaction, SyncOperation, ++}; + use storage_traits::webstorage_thread::{ + WebStorageEngine, WebStorageEngineFactory, WebStorageThreadMsg, WebStorageType, + }; + use storage_traits::{StorageEngines, StorageThreads}; ++use uuid::Uuid; + + fn shutdown_storage_group(threads: &StorageThreads) { + let (client_sender, client_receiver) = generic_channel::channel().unwrap(); +@@ -340,3 +350,292 @@ fn test_storage_engine_factory_is_selected_end_to_end() { + shutdown_storage_group(&private_storage_threads); + shutdown_storage_group(&public_storage_threads); + } ++ ++/// A version no fresh SQLite database reports, so the assertion below cannot pass by ++/// accident if the fallback engine is ever selected instead of the supplied one. ++const CUSTOM_ENGINE_VERSION: u64 = 7; ++const CUSTOM_ENGINE_STORE: &str = "store-only-the-custom-engine-has"; ++ ++/// Names of the [`KvsEngine`] methods the manager called, in call order. ++type EngineCalls = Arc>>; ++/// One `open`, as (database name, origin, whether client storage reported it created). ++type FactoryOpen = (String, String, bool); ++ ++/// A `KvsEngine` that stores nothing and reports what it was asked. ++/// ++/// Its answers are deliberately values SQLite could not produce on a database that has ++/// just been created: a version of 7 and a store that was never created. ++struct RecordingIdbEngine { ++ calls: EngineCalls, ++} ++ ++impl RecordingIdbEngine { ++ fn record(&self, call: &'static str) { ++ self.calls.lock().unwrap().push(call); ++ } ++} ++ ++impl MallocSizeOf for RecordingIdbEngine { ++ fn size_of(&self, _ops: &mut MallocSizeOfOps) -> usize { ++ 0 ++ } ++} ++ ++impl KvsEngine for RecordingIdbEngine { ++ fn create_store( ++ &self, ++ _store_name: &str, ++ _key_path: Option, ++ _auto_increment: bool, ++ ) -> BackendResult { ++ self.record("create_store"); ++ Ok(CreateObjectResult::Created) ++ } ++ ++ fn delete_store(&self, _store_name: &str) -> BackendResult<()> { ++ self.record("delete_store"); ++ Ok(()) ++ } ++ ++ fn close_store(&self, _store_name: &str) -> BackendResult<()> { ++ self.record("close_store"); ++ Ok(()) ++ } ++ ++ fn process_transaction( ++ &self, ++ _transaction: KvsTransaction, ++ on_complete: Box, ++ ) { ++ self.record("process_transaction"); ++ on_complete(); ++ } ++ ++ fn key_generator_current_number(&self, _store_name: &str) -> BackendResult> { ++ self.record("key_generator_current_number"); ++ Ok(None) ++ } ++ ++ fn set_key_generator_current_number( ++ &self, ++ _store_name: &str, ++ _current_number: i64, ++ ) -> BackendResult<()> { ++ self.record("set_key_generator_current_number"); ++ Ok(()) ++ } ++ ++ fn key_path(&self, _store_name: &str) -> BackendResult> { ++ self.record("key_path"); ++ Ok(None) ++ } ++ ++ fn object_store_names(&self) -> BackendResult> { ++ self.record("object_store_names"); ++ Ok(vec![CUSTOM_ENGINE_STORE.to_owned()]) ++ } ++ ++ fn indexes(&self, _store_name: &str) -> BackendResult> { ++ self.record("indexes"); ++ Ok(Vec::new()) ++ } ++ ++ fn create_index( ++ &self, ++ _store_name: &str, ++ _index_name: String, ++ _key_path: KeyPath, ++ _unique: bool, ++ _multi_entry: bool, ++ ) -> BackendResult { ++ self.record("create_index"); ++ Ok(CreateObjectResult::Created) ++ } ++ ++ fn delete_index(&self, _store_name: &str, _index_name: String) -> BackendResult<()> { ++ self.record("delete_index"); ++ Ok(()) ++ } ++ ++ fn rename_store(&self, _store_name: &str, _new_name: &str) -> BackendResult<()> { ++ self.record("rename_store"); ++ Ok(()) ++ } ++ ++ fn rename_index( ++ &self, ++ _store_name: &str, ++ _index_name: &str, ++ _new_name: &str, ++ ) -> BackendResult<()> { ++ self.record("rename_index"); ++ Ok(()) ++ } ++ ++ fn version(&self) -> BackendResult { ++ self.record("version"); ++ Ok(CUSTOM_ENGINE_VERSION) ++ } ++ ++ fn set_version(&self, _version: u64) -> BackendResult<()> { ++ self.record("set_version"); ++ Ok(()) ++ } ++ ++ fn rollback_transaction(&self, _serial_number: u64) -> BackendResult<()> { ++ self.record("rollback_transaction"); ++ Ok(()) ++ } ++ ++ fn commit_transaction(&self, _serial_number: u64) -> BackendResult<()> { ++ self.record("commit_transaction"); ++ Ok(()) ++ } ++} ++ ++struct RecordingIdbFactory { ++ opens: Arc>>, ++ calls: EngineCalls, ++} ++ ++impl IndexedDbEngineFactory for RecordingIdbFactory { ++ fn open( ++ &self, ++ _path: PathBuf, ++ created: bool, ++ description: &IndexedDBDescription, ++ ) -> BackendResult> { ++ self.opens.lock().unwrap().push(( ++ description.name.clone(), ++ description.origin.ascii_serialization(), ++ created, ++ )); ++ Ok(Box::new(RecordingIdbEngine { ++ calls: self.calls.clone(), ++ })) ++ } ++} ++ ++/// An embedder-supplied IndexedDB engine is the one the manager opens and answers from. ++/// ++/// `test_storage_engine_factory_is_selected_end_to_end` proves the `StorageEngines` seam for ++/// the cache and web storage threads and leaves `indexeddb` as `None`, so until this test ++/// nothing showed that a non-SQLite `KvsEngine` reaches the IndexedDB manager at all. The WPT ++/// corpus cannot show it either: every WPT run takes the `SqliteIndexedDbEngineFactory` ++/// fallback, which is what `None` selects. ++/// ++/// The discriminator is in the values, not in the call log alone. Both fields asserted on the ++/// connection reply are read back off the supplied engine by the manager, and neither is a ++/// value SQLite could produce for a database that has just been created: SQLite would report ++/// version 0 and no object stores. ++#[test] ++fn test_indexeddb_engine_factory_serves_the_connection() { ++ let mem_profiler_chan = profile_mem::Profiler::create(); ++ let config_dir = tempfile::tempdir().unwrap(); ++ let opens: Arc>> = Arc::new(Mutex::new(Vec::new())); ++ let calls: EngineCalls = Arc::new(Mutex::new(Vec::new())); ++ let engines = StorageEngines { ++ indexeddb: Some(Arc::new(RecordingIdbFactory { ++ opens: opens.clone(), ++ calls: calls.clone(), ++ })), ++ ..Default::default() ++ }; ++ let (private_storage_threads, public_storage_threads) = storage::new_storage_threads( ++ mem_profiler_chan, ++ Some(config_dir.path().to_path_buf()), ++ false, ++ engines, ++ ); ++ ++ // The manager asks client storage for the database's directory before it reaches the ++ // engine factory, and that lookup is a foreign key into the registry, so the bottle has to ++ // be a real one rather than an invented id. ++ let origin = ServoUrl::parse("https://example.com").unwrap().origin(); ++ let proxy = public_storage_threads ++ .client_storage_handle() ++ .obtain_a_storage_bottle_map( ++ StorageType::Local, ++ Some(TEST_WEBVIEW_ID), ++ StorageIdentifier::IndexedDB, ++ origin.clone(), ++ ) ++ .recv() ++ .expect("no reply to obtain_a_storage_bottle_map") ++ .expect("failed to obtain a storage bottle map"); ++ // `SyncOperation::OpenDatabase` carries `profile_traits`' callback, which is a different ++ // type from the `servo_base` one the cache storage message above uses and has no blocking ++ // constructor, so the reply is handed back over a plain channel. ++ let (reply_sender, receiver) = mpsc::channel(); ++ let callback = ++ ProfiledCallback::new(profile_time::Profiler::create(&None, None), move |reply| { ++ let _ = reply_sender.send(reply.expect("the open reply did not survive the channel")); ++ }) ++ .expect("failed to build the open callback"); ++ GenericSend::send( ++ &public_storage_threads, ++ IndexedDBThreadMsg::Sync(SyncOperation::OpenDatabase( ++ callback, ++ origin, ++ "engine-selection-db".to_owned(), ++ Some(CUSTOM_ENGINE_VERSION), ++ Uuid::new_v4(), ++ proxy, ++ )), ++ ) ++ .expect("failed to send OpenDatabase"); ++ ++ match receiver.recv().expect("no reply to OpenDatabase") { ++ ConnectionMsg::Connection { ++ name, ++ version, ++ upgraded, ++ object_store_names, ++ .. ++ } => { ++ assert_eq!(name, "engine-selection-db"); ++ assert_eq!( ++ version, CUSTOM_ENGINE_VERSION, ++ "the connection should carry the supplied engine's version, not SQLite's 0" ++ ); ++ assert!( ++ !upgraded, ++ "the requested version equals the engine's, so nothing should have been upgraded" ++ ); ++ assert_eq!( ++ object_store_names, ++ vec![CUSTOM_ENGINE_STORE.to_owned()], ++ "the connection's scope should come from the supplied engine" ++ ); ++ }, ++ other => panic!("expected a connection served by the supplied engine, got {other:?}"), ++ } ++ ++ { ++ let opens = opens.lock().unwrap(); ++ assert_eq!( ++ opens.len(), ++ 1, ++ "the manager should have opened the supplied engine exactly once, got {opens:?}" ++ ); ++ assert_eq!(opens[0].0, "engine-selection-db"); ++ assert_eq!(opens[0].1, "https://example.com"); ++ assert!( ++ opens[0].2, ++ "a database that did not exist yet should reach the factory as created" ++ ); ++ ++ let calls = calls.lock().unwrap(); ++ assert!( ++ calls.contains(&"version"), ++ "the manager should have read the version off the supplied engine, got {calls:?}" ++ ); ++ assert!( ++ calls.contains(&"object_store_names"), ++ "the manager should have read the scope off the supplied engine, got {calls:?}" ++ ); ++ } ++ ++ shutdown_storage_group(&public_storage_threads); ++ shutdown_storage_group(&private_storage_threads); ++} diff --git a/patches/servo/0009-web-locks-and-window-proxies.patch b/patches/servo/0009-web-locks-and-window-proxies.patch new file mode 100644 index 0000000..d2400ae --- /dev/null +++ b/patches/servo/0009-web-locks-and-window-proxies.patch @@ -0,0 +1,2839 @@ +From e92cdaa790797479c1821c33470c64e0d166feb2 Mon Sep 17 00:00:00 2001 +From: Travis Gilbert <1travisgilbert@gmail.com> +Date: Sun, 13 Sep 2026 16:26:45 -0400 +Subject: [PATCH] feat(script): Web Locks, shared-worker teardown and named window proxies + +Squashed range 6ed6091e4e..e92cdaa790 (3 commits) on Travis-Gilbert/servo +theorem/v0.5.0-indexeddb. Commits, oldest first: + + fec9c84f8f fix(script): end shared workers when their owner set empties + b3f6ddc61c feat(locks): implement the Web Locks API + e92cdaa790 feat(windowproxy): find named browsing contexts through the constellation + +--- + components/config/prefs.rs | 3 + + components/constellation/browsingcontext.rs | 5 + + components/constellation/constellation.rs | 230 +++++++++++++++++++++++++-- + components/constellation/lib.rs | 1 + + components/constellation/tracing.rs | 4 + + components/constellation/web_locks.rs | 283 +++++++++++++++++++++++++++++++++ + components/script/dom/abort/abortsignal.rs | 8 + + components/script/dom/document/document.rs | 3 + + components/script/dom/globalscope/globalscope.rs | 31 +++- + components/script/dom/html/htmliframeelement.rs | 1 + + components/script/dom/lockmanager/lock.rs | 53 +++++++ + components/script/dom/lockmanager/lockmanager.rs | 609 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + components/script/dom/lockmanager/mod.rs | 8 + + components/script/dom/mod.rs | 2 + + components/script/dom/navigator/navigator.rs | 8 + + components/script/dom/window/window.rs | 6 + + components/script/dom/window/windowproxy.rs | 136 ++++++++++++++-- + components/script/dom/workers/sharedworker.rs | 67 +++++++- + components/script/dom/workers/worker.rs | 6 + + components/script/dom/workers/workernavigator.rs | 8 + + components/script/links.rs | 20 ++- + components/script/navigation.rs | 4 + + components/script/script_thread.rs | 42 ++++- + components/script/script_window_proxies.rs | 14 +- + components/script_bindings/codegen/Bindings.conf | 8 +- + components/script_bindings/webidls/LockManager.webidl | 47 ++++++ + components/shared/constellation/from_script_message.rs | 120 ++++++++++++++ + components/shared/script/lib.rs | 6 + + tests/wpt/include.ini | 2 + + tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-noopener.html.ini | 7 - + tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-scoped-to-browsing-context-group.html.ini | 3 - + tests/wpt/meta/html/browsers/windows/browsing-context-names/duplicate-name-order.html.ini | 3 - + tests/wpt/meta/html/browsers/windows/targeting-cross-origin-nested-browsing-contexts.html.ini | 4 - + tests/wpt/meta/web-locks/acquire.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/bfcache/contention.https.window.js.ini | 50 ++++++ + tests/wpt/meta/web-locks/clientids.https.html.ini | 3 + + tests/wpt/meta/web-locks/held.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/idlharness.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/ifAvailable.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/lock-attributes.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/mode-exclusive.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/mode-mixed.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/mode-shared.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/partitioned-web-locks.tentative.https.html.ini | 6 + + tests/wpt/meta/web-locks/query-empty.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/resource-names.https.any.js.ini | 41 +++++ + tests/wpt/meta/web-locks/secure-context.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/signal.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/steal.https.any.js.ini | 2 + + tests/wpt/meta/web-locks/storage-buckets.tentative.https.any.js.ini | 14 ++ + 50 files changed, 1823 insertions(+), 67 deletions(-) + +diff --git a/components/config/prefs.rs b/components/config/prefs.rs +index 59e8bdc73b..f8bb5b9c24 100644 +--- a/components/config/prefs.rs ++++ b/components/config/prefs.rs +@@ -202,6 +202,8 @@ pub struct Preferences { + pub dom_script_asynch: bool, + // feature: Storage API | #43976 | Web/API/Storage_API + pub dom_storage_manager_api_enabled: bool, ++ // Web Locks API (Web/API/Web_Locks_API); fork feature, no upstream tracking issue is pinned. ++ pub dom_web_locks_enabled: bool, + // feature: ServiceWorker | #36538 | Web/API/Service_Worker_API + pub dom_serviceworker_enabled: bool, + pub dom_serviceworker_timeout_seconds: i64, +@@ -463,6 +465,7 @@ impl Preferences { + dom_sanitizer_enabled: false, + dom_script_asynch: true, + dom_storage_manager_api_enabled: false, ++ dom_web_locks_enabled: true, + dom_serviceworker_enabled: false, + dom_serviceworker_timeout_seconds: 60, + dom_sharedworker_enabled: true, +diff --git a/components/constellation/browsingcontext.rs b/components/constellation/browsingcontext.rs +index cd99a81897..dce591afd4 100644 +--- a/components/constellation/browsingcontext.rs ++++ b/components/constellation/browsingcontext.rs +@@ -45,6 +45,10 @@ pub struct BrowsingContext { + /// The browsing context id. + pub id: BrowsingContextId, + ++ /// The target name of this browsing context, used by named lookups. ++ /// ++ pub name: String, ++ + /// The top-level browsing context ancestor + pub webview_id: WebViewId, + +@@ -93,6 +97,7 @@ impl BrowsingContext { + BrowsingContext { + bc_group_id, + id, ++ name: String::new(), + webview_id, + viewport_details, + is_private, +diff --git a/components/constellation/constellation.rs b/components/constellation/constellation.rs +index c8ec87882c..15404ad341 100644 +--- a/components/constellation/constellation.rs ++++ b/components/constellation/constellation.rs +@@ -162,8 +162,9 @@ use servo_config::{opts, pref}; + use servo_constellation_traits::{ + AuxiliaryWebViewCreationRequest, AuxiliaryWebViewCreationResponse, ConstellationInterest, + DocumentState, EmbedderToConstellationMessage, IFrameLoadInfo, IFrameLoadInfoWithData, +- IFrameSizeMsg, LoadData, LogEntry, MessagePortMsg, NavigationHistoryBehavior, PaintMetricEvent, +- PortMessageTask, PortTransferInfo, RemoteFocusOperation, SWManagerSenders, ++ IFrameSizeMsg, LoadData, LoadOrigin, LogEntry, MessagePortMsg, NamedBrowsingContextInfo, ++ NavigationHistoryBehavior, PaintMetricEvent, PortMessageTask, PortTransferInfo, ++ RemoteFocusOperation, SWManagerSenders, + ScreenshotReadinessResponse, ScriptToConstellationMessage, ScrollStateUpdate, + ServiceWorkerAlgorithm, ServiceWorkerManagerFactory, ServiceWorkerMsg, + StructuredSerializedData, TargetSnapshotParams, TraversalDirection, UserContentManagerAction, +@@ -193,6 +194,7 @@ use crate::pipeline::Pipeline; + use crate::process_manager::ProcessManager; + use crate::serviceworker::ServiceWorkerUnprivilegedContent; + use crate::session_history::{NeedsToReload, SessionHistoryChange, SessionHistoryDiff}; ++use crate::web_locks::WebLockRegistry; + + struct PendingApprovalNavigation { + load_data: LoadData, +@@ -385,6 +387,10 @@ pub struct Constellation { + /// A map of origin to sender to a Service worker manager. + sw_managers: HashMap>, + ++ /// Every origin's Web Locks queue and held set. ++ /// ++ web_locks: WebLockRegistry, ++ + /// A channel for the constellation to send messages to the + /// time profiler thread. + pub(crate) time_profiler_chan: time::ProfilerChan, +@@ -519,6 +525,11 @@ pub struct Constellation { + /// yet known to the constellation. + pending_viewport_changes: HashMap, + ++ /// Target names set by script for browsing contexts this constellation has not ++ /// created yet. An auxiliary's name arrives before its first session history ++ /// change commits, so it is kept here until `new_browsing_context` runs. ++ pending_browsing_context_names: HashMap, ++ + /// Pending screenshot readiness requests. These are collected until the screenshot is + /// ready to take place, at which point the Constellation informs the renderer that it + /// can start the process of taking the screenshot. +@@ -701,6 +712,7 @@ where + private_storage_threads: state.private_storage_threads, + system_font_service: state.system_font_service, + sw_managers: Default::default(), ++ web_locks: Default::default(), + browsing_context_group_set: Default::default(), + browsing_context_group_next_id: Default::default(), + message_ports: Default::default(), +@@ -747,6 +759,7 @@ where + broken_image_icon_data, + )), + pending_viewport_changes: Default::default(), ++ pending_browsing_context_names: Default::default(), + screenshot_readiness_requests: Vec::new(), + user_contents_for_manager_id: Default::default(), + }; +@@ -1053,12 +1066,24 @@ where + .get(&webview_id) + .and_then(|webview| webview.user_content_manager_id); + ++ let browsing_context_name = self ++ .browsing_contexts ++ .get(&browsing_context_id) ++ .map(|browsing_context| browsing_context.name.clone()) ++ .or_else(|| { ++ self.pending_browsing_context_names ++ .get(&browsing_context_id) ++ .cloned() ++ }) ++ .unwrap_or_default(); ++ + let new_pipeline_info = NewPipelineInfo { + parent_info: parent_pipeline_id, + new_pipeline_id, + browsing_context_id, + webview_id, + opener, ++ browsing_context_name, + load_data, + viewport_details: initial_viewport_details, + user_content_manager_id, +@@ -1106,6 +1131,113 @@ where + } + } + ++ /// ++ fn find_browsing_context_by_name( ++ &self, ++ source_id: BrowsingContextId, ++ name: &str, ++ ) -> Option { ++ let source = self.browsing_contexts.get(&source_id)?; ++ let info = |browsing_context: &BrowsingContext| NamedBrowsingContextInfo { ++ browsing_context_id: browsing_context.id, ++ webview_id: browsing_context.webview_id, ++ pipeline_id: browsing_context.pipeline_id, ++ }; ++ ++ // Step 3 and 4. Search the source's own subtree first, then the whole tree ++ // of its top-level traversable. ++ let top_level_id = BrowsingContextId::from(source.webview_id); ++ for subtree_id in [source_id, top_level_id] { ++ if let Some(found) = self ++ .all_descendant_browsing_contexts_iter(subtree_id) ++ .find(|browsing_context| browsing_context.name == name) ++ { ++ return Some(info(found)); ++ } ++ } ++ ++ // Step 5 to 7. Search the other top-level browsing contexts of the group, ++ // in creation order, skipping contexts the source is not familiar with. ++ let group = self.browsing_context_group_set.get(&source.bc_group_id)?; ++ let mut others: Vec = group ++ .top_level_browsing_context_set ++ .iter() ++ .copied() ++ .filter(|webview_id| *webview_id != source.webview_id) ++ .collect(); ++ others.sort(); ++ others ++ .into_iter() ++ .flat_map(|webview_id| { ++ self.all_descendant_browsing_contexts_iter(BrowsingContextId::from(webview_id)) ++ }) ++ .find(|browsing_context| { ++ browsing_context.name == name && self.is_familiar_with(source, browsing_context) ++ }) ++ .map(info) ++ } ++ ++ /// The origin of a browsing context's active document as far as the constellation ++ /// can tell: the origin of the pipeline's URL, or for `about:blank` the origin of ++ /// the script that created the pipeline. ++ fn browsing_context_origin( ++ &self, ++ browsing_context: &BrowsingContext, ++ ) -> Option { ++ let pipeline = self.pipelines.get(&browsing_context.pipeline_id)?; ++ if pipeline.url.as_str() == "about:blank" && ++ let LoadOrigin::Script(origin) = &pipeline.load_data.load_origin ++ { ++ return Some(origin.immutable().clone()); ++ } ++ Some(pipeline.url.origin()) ++ } ++ ++ /// ++ fn is_familiar_with(&self, a: &BrowsingContext, b: &BrowsingContext) -> bool { ++ // Step 1. A's active document's origin is same origin with B's. ++ let a_origin = self.browsing_context_origin(a); ++ if let Some(a_origin) = &a_origin && ++ self.browsing_context_origin(b).as_ref() == Some(a_origin) ++ { ++ return true; ++ } ++ // Step 2. A's top-level browsing context is B. ++ if BrowsingContextId::from(a.webview_id) == b.id { ++ return true; ++ } ++ // Step 3. B is an auxiliary browsing context and A is familiar with its opener. ++ if b.parent_pipeline_id.is_none() && ++ let Some(opener_id) = self ++ .pipelines ++ .get(&b.pipeline_id) ++ .and_then(|pipeline| pipeline.opener) && ++ let Some(opener) = self.browsing_contexts.get(&opener_id) && ++ self.is_familiar_with(a, opener) ++ { ++ return true; ++ } ++ // Step 4. An ancestor browsing context of B has the same origin as A's ++ // active document. ++ if let Some(a_origin) = a_origin { ++ let mut parent_pipeline_id = b.parent_pipeline_id; ++ while let Some(pipeline_id) = parent_pipeline_id { ++ let Some(ancestor) = self ++ .pipelines ++ .get(&pipeline_id) ++ .and_then(|pipeline| self.browsing_contexts.get(&pipeline.browsing_context_id)) ++ else { ++ break; ++ }; ++ if self.browsing_context_origin(ancestor) == Some(a_origin.clone()) { ++ return true; ++ } ++ parent_pipeline_id = ancestor.parent_pipeline_id; ++ } ++ } ++ false ++ } ++ + /// Enumerate the specified browsing context's ancestor pipelines up to + /// the top-level pipeline. + fn ancestor_pipelines_of_browsing_context_iter( +@@ -1185,7 +1317,7 @@ where + .pending_viewport_changes + .remove(&browsing_context_id) + .unwrap_or(viewport_details); +- let browsing_context = BrowsingContext::new( ++ let mut browsing_context = BrowsingContext::new( + bc_group_id, + browsing_context_id, + webview_id, +@@ -1196,6 +1328,12 @@ where + inherited_secure_context, + throttled, + ); ++ if let Some(name) = self ++ .pending_browsing_context_names ++ .remove(&browsing_context_id) ++ { ++ browsing_context.name = name; ++ } + self.browsing_contexts + .insert(browsing_context_id, browsing_context); + +@@ -1865,6 +2003,9 @@ where + ScriptToConstellationMessage::ScriptNewIFrame(load_info) => { + self.handle_script_new_iframe(load_info); + }, ++ ScriptToConstellationMessage::WebLock(message) => { ++ self.web_locks.handle_message(source_pipeline_id, message); ++ }, + ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info) => { + self.handle_script_new_auxiliary(load_info); + }, +@@ -2022,6 +2163,47 @@ where + ); + } + }, ++ ScriptToConstellationMessage::SetBrowsingContextName(browsing_context_id, name) => { ++ match self.browsing_contexts.get_mut(&browsing_context_id) { ++ Some(browsing_context) => browsing_context.name = name, ++ None => { ++ self.pending_browsing_context_names ++ .insert(browsing_context_id, name); ++ }, ++ } ++ }, ++ ScriptToConstellationMessage::FindBrowsingContextByName( ++ browsing_context_id, ++ name, ++ response_sender, ++ ) => { ++ let result = self.find_browsing_context_by_name(browsing_context_id, &name); ++ if let Err(e) = response_sender.send(result) { ++ warn!("Sending reply to find browsing context by name failed ({e:?})."); ++ } ++ }, ++ ScriptToConstellationMessage::LoadUrlInBrowsingContext( ++ browsing_context_id, ++ load_data, ++ history_handling, ++ ) => { ++ let Some((webview_id, pipeline_id)) = ++ self.browsing_contexts ++ .get(&browsing_context_id) ++ .map(|browsing_context| { ++ (browsing_context.webview_id, browsing_context.pipeline_id) ++ }) ++ else { ++ return warn!("{browsing_context_id}: Load in unknown browsing context"); ++ }; ++ self.schedule_navigation( ++ webview_id, ++ pipeline_id, ++ load_data, ++ history_handling, ++ TargetSnapshotParams::default(), ++ ); ++ }, + ScriptToConstellationMessage::GetDocumentOrigin(pipeline_id, response_sender) => { + self.send_message_to_pipeline( + pipeline_id, +@@ -3043,6 +3225,9 @@ where + !set.is_empty() + }); + ++ // Release any Web Locks the pipeline's globals did not release themselves. ++ self.web_locks.pipeline_exited(pipeline_id); ++ + // Now that the Script and Constellation parts of Servo no longer have a reference to + // this pipeline, tell `Paint` that it has shut down. This is delayed until the + // last moment. +@@ -3694,6 +3879,7 @@ where + load_data, + opener_webview_id, + opener_pipeline_id, ++ noopener, + response_sender, + } = load_info; + +@@ -3745,7 +3931,7 @@ where + new_pipeline_id, + new_browsing_context_id, + new_webview_id, +- Some(opener_browsing_context_id), ++ (!noopener).then_some(opener_browsing_context_id), + script_sender, + self.paint_proxy.clone(), + is_opener_throttled, +@@ -3768,16 +3954,30 @@ where + ), + ); + +- // https://html.spec.whatwg.org/multipage/#bcg-append +- let Some(opener) = self.browsing_contexts.get(&opener_browsing_context_id) else { +- return warn!("Trying to append an unknown auxiliary to a browsing context group"); +- }; +- let Some(bc_group) = self.browsing_context_group_set.get_mut(&opener.bc_group_id) else { +- return warn!("Trying to add a top-level to an unknown group."); +- }; +- bc_group +- .top_level_browsing_context_set +- .insert(new_webview_id); ++ if noopener { ++ // https://html.spec.whatwg.org/multipage/#creating-a-new-top-level-traversable ++ // With noopener the new traversable gets a browsing context group of its ++ // own, so named lookups from the opener's group cannot reach it. ++ let mut new_bc_group: BrowsingContextGroup = Default::default(); ++ let new_bc_group_id = self.next_browsing_context_group_id(); ++ new_bc_group ++ .top_level_browsing_context_set ++ .insert(new_webview_id); ++ self.browsing_context_group_set ++ .insert(new_bc_group_id, new_bc_group); ++ } else { ++ // https://html.spec.whatwg.org/multipage/#bcg-append ++ let Some(opener) = self.browsing_contexts.get(&opener_browsing_context_id) else { ++ return warn!("Trying to append an unknown auxiliary to a browsing context group"); ++ }; ++ let Some(bc_group) = self.browsing_context_group_set.get_mut(&opener.bc_group_id) ++ else { ++ return warn!("Trying to add a top-level to an unknown group."); ++ }; ++ bc_group ++ .top_level_browsing_context_set ++ .insert(new_webview_id); ++ } + + self.add_pending_change(SessionHistoryChange { + webview_id: new_webview_id, +@@ -5642,6 +5842,8 @@ where + exit_mode: ExitPipelineMode, + ) -> Option { + debug!("{}: Closing", browsing_context_id); ++ self.pending_browsing_context_names ++ .remove(&browsing_context_id); + + self.close_browsing_context_children( + browsing_context_id, +diff --git a/components/constellation/lib.rs b/components/constellation/lib.rs +index 9aad13e48c..3af7250285 100644 +--- a/components/constellation/lib.rs ++++ b/components/constellation/lib.rs +@@ -19,6 +19,7 @@ mod process_manager; + mod sandboxing; + mod serviceworker; + mod session_history; ++mod web_locks; + + pub use crate::constellation::{Constellation, InitialConstellationState}; + pub use crate::embedder::ConstellationToEmbedderMsg; +diff --git a/components/constellation/tracing.rs b/components/constellation/tracing.rs +index 36807c0e0e..7e179b8c60 100644 +--- a/components/constellation/tracing.rs ++++ b/components/constellation/tracing.rs +@@ -125,6 +125,7 @@ mod from_script { + fn log_target(&self) -> &'static str { + match self { + Self::ServiceWorkerAlgorithm(..) => target!("ServiceWorkerAlgorithm"), ++ Self::WebLock(..) => target!("WebLock"), + Self::CompleteMessagePortTransfer(..) => target!("CompleteMessagePortTransfer"), + Self::MessagePortTransferResult(..) => target!("MessagePortTransferResult"), + Self::NewMessagePort(..) => target!("NewMessagePort"), +@@ -156,6 +157,9 @@ mod from_script { + Self::GetBrowsingContextInfo(..) => target!("GetBrowsingContextInfo"), + Self::GetDocumentOrigin(..) => target!("GetDocumentOrigin"), + Self::GetChildBrowsingContextId(..) => target!("GetChildBrowsingContextId"), ++ Self::SetBrowsingContextName(..) => target!("SetBrowsingContextName"), ++ Self::FindBrowsingContextByName(..) => target!("FindBrowsingContextByName"), ++ Self::LoadUrlInBrowsingContext(..) => target!("LoadUrlInBrowsingContext"), + Self::LoadComplete => target!("LoadComplete"), + Self::LoadUrl(..) => target!("LoadUrl"), + Self::AbortLoadUrl => target!("AbortLoadUrl"), +diff --git a/components/constellation/web_locks.rs b/components/constellation/web_locks.rs +new file mode 100644 +index 0000000000..009673090c +--- /dev/null ++++ b/components/constellation/web_locks.rs +@@ -0,0 +1,283 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++//! The Web Locks registry: one lock request queue and one held lock set per ++//! origin, owned by the constellation so that every agent of an origin ++//! (documents in any pipeline, dedicated and shared workers) sees the same ++//! state. ++//! ++//! ++ ++use std::collections::HashMap; ++ ++use log::warn; ++use servo_base::generic_channel::GenericCallback; ++use servo_base::id::PipelineId; ++use servo_constellation_traits::{WebLockInfo, WebLockMessage, WebLockMode, WebLockResponse}; ++use servo_url::ImmutableOrigin; ++ ++/// The identity of a lock request within its origin: the requesting client ++/// plus the id that client allocated for the request. ++#[derive(Clone, Debug, Eq, PartialEq)] ++struct RequestKey { ++ client_id: String, ++ request_id: u64, ++} ++ ++/// A lock request that has not been granted yet, or a held lock. ++/// and ++/// ++#[derive(Debug)] ++struct LockEntry { ++ key: RequestKey, ++ /// The pipeline the request arrived from, when the requesting agent dies ++ /// with it. Used as a backstop to release locks when a pipeline exits ++ /// without sending `ClientGone`. None for a shared worker, which outlives ++ /// the document that created it. ++ pipeline_id: Option, ++ name: String, ++ mode: WebLockMode, ++ result_handler: GenericCallback, ++} ++ ++impl LockEntry { ++ fn info(&self) -> WebLockInfo { ++ WebLockInfo { ++ name: self.name.clone(), ++ mode: self.mode, ++ client_id: self.key.client_id.clone(), ++ } ++ } ++ ++ fn reply(&self, response: WebLockResponse) { ++ if let Err(error) = self.result_handler.send(response) { ++ warn!("Failed to deliver a Web Locks response: {error:?}"); ++ } ++ } ++} ++ ++/// ++#[derive(Debug, Default)] ++struct OriginLocks { ++ /// ++ queue: Vec, ++ /// ++ held: Vec, ++} ++ ++impl OriginLocks { ++ fn is_empty(&self) -> bool { ++ self.queue.is_empty() && self.held.is_empty() ++ } ++ ++ /// ++ fn is_grantable(&self, index: usize) -> bool { ++ let request = &self.queue[index]; ++ let earlier = &self.queue[..index]; ++ match request.mode { ++ WebLockMode::Exclusive => { ++ !self.held.iter().any(|lock| lock.name == request.name) && ++ !earlier.iter().any(|other| other.name == request.name) ++ }, ++ WebLockMode::Shared => { ++ !self ++ .held ++ .iter() ++ .any(|lock| lock.mode == WebLockMode::Exclusive && lock.name == request.name) && ++ !earlier.iter().any(|other| { ++ other.mode == WebLockMode::Exclusive && other.name == request.name ++ }) ++ }, ++ } ++ } ++ ++ /// ++ fn process_queue(&mut self) { ++ let mut index = 0; ++ while index < self.queue.len() { ++ if self.is_grantable(index) { ++ let request = self.queue.remove(index); ++ request.reply(WebLockResponse::Granted { ++ request_id: request.key.request_id, ++ }); ++ self.held.push(request); ++ } else { ++ index += 1; ++ } ++ } ++ } ++ ++ /// Remove every held lock matching `predicate`, leaving queued requests alone. ++ fn remove_held_where(&mut self, predicate: impl Fn(&LockEntry) -> bool) -> Vec { ++ let mut removed = Vec::new(); ++ let mut index = 0; ++ while index < self.held.len() { ++ if predicate(&self.held[index]) { ++ removed.push(self.held.remove(index)); ++ } else { ++ index += 1; ++ } ++ } ++ removed ++ } ++ ++ /// Remove every held lock and queued request matching `predicate`. ++ fn remove_where(&mut self, predicate: impl Fn(&LockEntry) -> bool) -> Vec { ++ let removed = self.remove_held_where(&predicate); ++ self.queue.retain(|entry| !predicate(entry)); ++ removed ++ } ++} ++ ++/// Every origin's lock manager, keyed by origin. ++#[derive(Debug, Default)] ++pub(crate) struct WebLockRegistry { ++ origins: HashMap, ++} ++ ++impl WebLockRegistry { ++ pub(crate) fn handle_message(&mut self, pipeline_id: PipelineId, message: WebLockMessage) { ++ match message { ++ WebLockMessage::Request { ++ origin, ++ client_id, ++ request_id, ++ name, ++ mode, ++ if_available, ++ steal, ++ pipeline_bound, ++ result_handler, ++ } => { ++ let entry = LockEntry { ++ key: RequestKey { ++ client_id, ++ request_id, ++ }, ++ pipeline_id: pipeline_bound.then_some(pipeline_id), ++ name, ++ mode, ++ result_handler, ++ }; ++ self.request(origin, entry, if_available, steal); ++ }, ++ WebLockMessage::Abort { ++ origin, ++ client_id, ++ request_id, ++ } => { ++ let key = RequestKey { ++ client_id, ++ request_id, ++ }; ++ self.with_origin(origin, |locks| { ++ // ++ // Only a request still in the queue can be aborted; a lock that ++ // was granted in the meantime is released by the client. ++ locks.queue.retain(|entry| entry.key != key); ++ locks.process_queue(); ++ }); ++ }, ++ WebLockMessage::Release { ++ origin, ++ client_id, ++ request_id, ++ } => { ++ let key = RequestKey { ++ client_id, ++ request_id, ++ }; ++ self.with_origin(origin, |locks| { ++ // ++ locks.held.retain(|entry| entry.key != key); ++ locks.process_queue(); ++ }); ++ }, ++ WebLockMessage::Query { ++ origin, ++ request_id, ++ result_handler, ++ } => { ++ // ++ let (held, pending) = match self.origins.get(&origin) { ++ Some(locks) => ( ++ locks.held.iter().map(LockEntry::info).collect(), ++ locks.queue.iter().map(LockEntry::info).collect(), ++ ), ++ None => (Vec::new(), Vec::new()), ++ }; ++ if let Err(error) = result_handler.send(WebLockResponse::Snapshot { ++ request_id, ++ held, ++ pending, ++ }) { ++ warn!("Failed to deliver a Web Locks snapshot: {error:?}"); ++ } ++ }, ++ WebLockMessage::ClientGone { origin, client_id } => { ++ self.with_origin(origin, |locks| { ++ locks.remove_where(|entry| entry.key.client_id == client_id); ++ locks.process_queue(); ++ }); ++ }, ++ } ++ } ++ ++ /// Release every lock and drop every request that arrived from `pipeline_id`. ++ /// This is the backstop for a pipeline that exits without its globals ++ /// sending `ClientGone`, for example after a script thread panic. ++ pub(crate) fn pipeline_exited(&mut self, pipeline_id: PipelineId) { ++ for locks in self.origins.values_mut() { ++ locks.remove_where(|entry| entry.pipeline_id == Some(pipeline_id)); ++ locks.process_queue(); ++ } ++ self.origins.retain(|_, locks| !locks.is_empty()); ++ } ++ ++ /// ++ fn request(&mut self, origin: ImmutableOrigin, entry: LockEntry, if_available: bool, steal: bool) { ++ let locks = self.origins.entry(origin).or_default(); ++ if steal { ++ // Step 5.1. For each lock of held with the same name: remove it and ++ // reject its waiting promise with an "AbortError" DOMException. ++ // Queued requests with that name stay queued. ++ let name = entry.name.clone(); ++ for stolen in locks.remove_held_where(|held| held.name == name) { ++ stolen.reply(WebLockResponse::Stolen { ++ request_id: stolen.key.request_id, ++ }); ++ } ++ // Step 5.2. Prepend request in queue. ++ locks.queue.insert(0, entry); ++ } else if if_available { ++ // Step 6. If ifAvailable is true and request is not grantable, invoke ++ // the callback with null. Grantability is tested as if request were ++ // at the end of the queue. ++ locks.queue.push(entry); ++ let index = locks.queue.len() - 1; ++ if !locks.is_grantable(index) { ++ let request = locks.queue.remove(index); ++ request.reply(WebLockResponse::Unavailable { ++ request_id: request.key.request_id, ++ }); ++ return; ++ } ++ } else { ++ // Step 7. Enqueue request in queue. ++ locks.queue.push(entry); ++ } ++ // Step 8. Process the lock request queue for origin. ++ locks.process_queue(); ++ } ++ ++ fn with_origin(&mut self, origin: ImmutableOrigin, operation: impl FnOnce(&mut OriginLocks)) { ++ let Some(locks) = self.origins.get_mut(&origin) else { ++ return; ++ }; ++ operation(locks); ++ if locks.is_empty() { ++ self.origins.remove(&origin); ++ } ++ } ++} +diff --git a/components/script/dom/abort/abortsignal.rs b/components/script/dom/abort/abortsignal.rs +index 32f6931882..927695ae56 100644 +--- a/components/script/dom/abort/abortsignal.rs ++++ b/components/script/dom/abort/abortsignal.rs +@@ -29,6 +29,7 @@ use crate::dom::bindings::root::{Dom, DomRoot}; + use crate::dom::bindings::str::DOMString; + use crate::dom::eventtarget::EventTarget; + use crate::dom::globalscope::GlobalScope; ++use crate::dom::lockmanager::WebLockAbortRequest; + use crate::dom::readablestream::PipeTo; + use crate::fetch::{DeferredFetchRecordId, FetchContext}; + use crate::realms::enter_auto_realm; +@@ -53,6 +54,8 @@ pub(crate) enum AbortAlgorithm { + ), + /// + FetchLater(#[no_trace] DeferredFetchRecordId), ++ /// ++ WebLockRequest(WebLockAbortRequest), + } + + #[derive(Clone, JSTraceable, MallocSizeOf)] +@@ -202,6 +205,11 @@ impl AbortSignal { + .deferred_fetch_record_for_id(deferred_fetch_record_id) + .abort(); + }, ++ AbortAlgorithm::WebLockRequest(request) => { ++ rooted!(&in(cx) let mut reason = UndefinedValue()); ++ reason.set(self.abort_reason.get()); ++ request.run(cx, reason.handle()); ++ }, + AbortAlgorithm::DomEventListener(removable_listener) => { + removable_listener.event_target.remove_event_listener( + removable_listener.ty.clone(), +diff --git a/components/script/dom/document/document.rs b/components/script/dom/document/document.rs +index 3edadbd4a3..7c9aac11fe 100644 +--- a/components/script/dom/document/document.rs ++++ b/components/script/dom/document/document.rs +@@ -973,6 +973,9 @@ impl Document { + if activity != DocumentActivity::FullyActive { + self.window().suspend(cx); + media.suspend(&client_context_id); ++ // : a document that is ++ // no longer fully active releases its held locks and drops its requests. ++ self.window().as_global_scope().release_web_locks(); + return; + } + +diff --git a/components/script/dom/globalscope/globalscope.rs b/components/script/dom/globalscope/globalscope.rs +index 5caf0c4615..e1416969e2 100644 +--- a/components/script/dom/globalscope/globalscope.rs ++++ b/components/script/dom/globalscope/globalscope.rs +@@ -69,7 +69,7 @@ use servo_config::pref; + use servo_constellation_traits::{ + BlobData, BlobImpl, BroadcastChannelMsg, ConstellationInterest, FileBlob, MessagePortImpl, + MessagePortMsg, PortMessageTask, ScriptToConstellationChan, ScriptToConstellationMessage, +- ScriptToConstellationSender, ++ ScriptToConstellationSender, WebLockMessage, + }; + use servo_url::{ImmutableOrigin, MutableOrigin, ServoUrl}; + use storage_traits::StorageThreads; +@@ -222,6 +222,11 @@ pub(crate) struct GlobalScope { + /// The broadcast channels state this global, if it is managing any. + broadcast_channel_state: DomRefCell, + ++ /// The Web Locks client id of this global, once its `LockManager` exists. ++ /// Used to release the client's locks when the global is destroyed. ++ /// ++ web_lock_client_id: DomRefCell>, ++ + /// Tracks the number of active listeners per constellation interest category. + /// When the count transitions from 0 to 1, a RegisterInterest message is sent. + /// When it transitions from 1 to 0, an UnregisterInterest message is sent. +@@ -794,6 +799,7 @@ impl GlobalScope { + Self { + message_port_state: DomRefCell::new(MessagePortState::UnManaged), + broadcast_channel_state: DomRefCell::new(BroadcastChannelState::UnManaged), ++ web_lock_client_id: DomRefCell::new(None), + constellation_interest_counts: RefCell::new(HashMap::new()), + blob_state: Default::default(), + eventtarget: EventTarget::new_inherited(), +@@ -1039,6 +1045,7 @@ impl GlobalScope { + pub(crate) fn remove_web_messaging_and_dedicated_workers_infra(&self) { + self.remove_message_ports_router(); + self.remove_broadcast_channel_router(); ++ self.release_web_locks(); + + // Drop each ref to a worker explicitly now, + // which will send a shutdown signal, +@@ -1049,6 +1056,28 @@ impl GlobalScope { + .for_each(drop); + } + ++ /// Record the client id of this global's `LockManager`. ++ pub(crate) fn register_web_lock_client(&self, client_id: String) { ++ *self.web_lock_client_id.borrow_mut() = Some(client_id); ++ } ++ ++ /// Tell the constellation that this global's Web Locks client is gone, ++ /// which releases its held locks and drops its pending requests. ++ /// ++ /// Tell the constellation this global no longer holds or waits for any web lock. ++ /// Idempotent: the client id is kept so a document that leaves the fully active ++ /// state and later returns keeps its `LockManager` identity. ++ pub(crate) fn release_web_locks(&self) { ++ if let Some(client_id) = self.web_lock_client_id.borrow().as_ref() { ++ let _ = self.script_to_constellation_chan().send( ++ ScriptToConstellationMessage::WebLock(WebLockMessage::ClientGone { ++ origin: self.origin().immutable().clone(), ++ client_id: client_id.clone(), ++ }), ++ ); ++ } ++ } ++ + /// Update our state to un-managed, + /// and tell the constellation to drop the sender to our message-port router. + fn remove_message_ports_router(&self) { +diff --git a/components/script/dom/html/htmliframeelement.rs b/components/script/dom/html/htmliframeelement.rs +index bc38869453..2e88a8c0b9 100644 +--- a/components/script/dom/html/htmliframeelement.rs ++++ b/components/script/dom/html/htmliframeelement.rs +@@ -304,6 +304,7 @@ impl HTMLIFrameElement { + browsing_context_id, + webview_id, + opener: None, ++ browsing_context_name: String::new(), + load_data, + viewport_details, + user_content_manager_id: None, +diff --git a/components/script/dom/lockmanager/lock.rs b/components/script/dom/lockmanager/lock.rs +new file mode 100644 +index 0000000000..bd4430da95 +--- /dev/null ++++ b/components/script/dom/lockmanager/lock.rs +@@ -0,0 +1,53 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; ++ ++use crate::dom::bindings::codegen::Bindings::LockManagerBinding::{LockMethods, LockMode}; ++use crate::dom::bindings::root::DomRoot; ++use crate::dom::bindings::str::DOMString; ++use crate::dom::globalscope::GlobalScope; ++ ++/// ++#[dom_struct] ++pub(crate) struct Lock { ++ reflector_: Reflector, ++ /// ++ name: DOMString, ++ /// ++ mode: LockMode, ++} ++ ++impl Lock { ++ fn new_inherited(name: DOMString, mode: LockMode) -> Lock { ++ Lock { ++ reflector_: Reflector::new(), ++ name, ++ mode, ++ } ++ } ++ ++ pub(crate) fn new( ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ name: DOMString, ++ mode: LockMode, ++ ) -> DomRoot { ++ reflect_dom_object_with_cx(Box::new(Lock::new_inherited(name, mode)), global, cx) ++ } ++} ++ ++impl LockMethods for Lock { ++ /// ++ fn Name(&self) -> DOMString { ++ self.name.clone() ++ } ++ ++ /// ++ fn Mode(&self) -> LockMode { ++ self.mode ++ } ++} +diff --git a/components/script/dom/lockmanager/lockmanager.rs b/components/script/dom/lockmanager/lockmanager.rs +new file mode 100644 +index 0000000000..065cb5c453 +--- /dev/null ++++ b/components/script/dom/lockmanager/lockmanager.rs +@@ -0,0 +1,609 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++use std::cell::Cell; ++use std::rc::Rc; ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use js::jsval::UndefinedValue; ++use js::realm::CurrentRealm; ++use js::rust::HandleValue; ++use js::rust::wrappers2::{JS_ClearPendingException, JS_GetPendingException}; ++use script_bindings::cell::DomRefCell; ++use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; ++use servo_base::generic_channel::GenericCallback; ++use servo_constellation_traits::{ ++ ScriptToConstellationMessage, WebLockInfo, WebLockMessage, WebLockMode, WebLockResponse, ++}; ++use uuid::Uuid; ++ ++use crate::dom::abortsignal::AbortAlgorithm; ++use crate::dom::bindings::callback::ExceptionHandling; ++use crate::dom::bindings::codegen::Bindings::AbortSignalBinding::AbortSignalMethods; ++use crate::dom::bindings::codegen::Bindings::LockManagerBinding::{ ++ LockGrantedCallback, LockInfo, LockManagerMethods, LockManagerSnapshot, LockMode, LockOptions, ++}; ++use crate::dom::bindings::codegen::Bindings::WindowBinding::WindowMethods; ++use crate::dom::bindings::error::Error; ++use crate::dom::bindings::inheritance::Castable; ++use crate::dom::bindings::refcounted::Trusted; ++use crate::dom::bindings::reflector::DomGlobal; ++use crate::dom::bindings::root::{Dom, DomRoot}; ++use crate::dom::bindings::str::DOMString; ++use crate::dom::bindings::trace::HashMapTracedValues; ++use crate::dom::globalscope::GlobalScope; ++use crate::dom::lockmanager::lock::Lock; ++use crate::dom::promise::Promise; ++use crate::dom::promisenativehandler::{Callback, PromiseNativeHandler}; ++use crate::dom::sharedworkerglobalscope::SharedWorkerGlobalScope; ++use crate::dom::window::Window; ++use crate::realms::enter_auto_realm; ++ ++/// A request this client sent to the constellation that has not been ++/// granted, refused, or aborted yet. ++/// ++#[derive(JSTraceable, MallocSizeOf)] ++struct PendingRequest { ++ /// The promise `request()` returned. ++ #[conditional_malloc_size_of] ++ promise: Rc, ++ /// ++ #[conditional_malloc_size_of] ++ callback: Rc, ++ name: DOMString, ++ mode: LockMode, ++} ++ ++/// A lock granted to this client whose waiting promise has not settled. ++/// ++#[derive(JSTraceable, MallocSizeOf)] ++struct HeldLock { ++ /// ++ #[conditional_malloc_size_of] ++ promise: Rc, ++} ++ ++/// The abort algorithm `request()` adds to its `signal`. ++/// ++#[derive(Clone, JSTraceable, MallocSizeOf)] ++#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] ++pub(crate) struct WebLockAbortRequest { ++ manager: Dom, ++ request_id: u64, ++} ++ ++impl WebLockAbortRequest { ++ /// Abort the request and reject its promise with the signal's abort reason. ++ pub(crate) fn run(&self, cx: &mut CurrentRealm, reason: HandleValue) { ++ self.manager.abort_request(cx, self.request_id, reason); ++ } ++} ++ ++/// ++#[dom_struct] ++pub(crate) struct LockManager { ++ reflector_: Reflector, ++ /// The id of this client's environment settings object, reported by ++ /// `query()` as `clientId`. ++ /// ++ client_id: String, ++ /// The next request id to allocate. Ids are unique per client. ++ next_request_id: Cell, ++ #[ignore_malloc_size_of = "promises and callbacks"] ++ pending: DomRefCell>, ++ #[ignore_malloc_size_of = "promises"] ++ held: DomRefCell>, ++ /// `query()` promises awaiting a snapshot. ++ #[ignore_malloc_size_of = "promises"] ++ queries: DomRefCell>>, ++ /// Handler of constellation responses, created on first use. ++ #[no_trace] ++ result_handler: DomRefCell>>, ++} ++ ++impl LockManager { ++ fn new_inherited() -> LockManager { ++ LockManager { ++ reflector_: Reflector::new(), ++ client_id: Uuid::new_v4().simple().to_string(), ++ next_request_id: Cell::new(0), ++ pending: DomRefCell::new(HashMapTracedValues::new()), ++ held: DomRefCell::new(HashMapTracedValues::new()), ++ queries: DomRefCell::new(HashMapTracedValues::new()), ++ result_handler: DomRefCell::new(None), ++ } ++ } ++ ++ pub(crate) fn new(cx: &mut JSContext, global: &GlobalScope) -> DomRoot { ++ let manager = reflect_dom_object_with_cx(Box::new(LockManager::new_inherited()), global, cx); ++ global.register_web_lock_client(manager.client_id.clone()); ++ manager ++ } ++ ++ fn allocate_request_id(&self) -> u64 { ++ let id = self.next_request_id.get(); ++ self.next_request_id.set(id + 1); ++ id ++ } ++ ++ /// Whether this's relevant global object is a `Window` whose associated ++ /// `Document` is fully active, or is not a `Window` at all. ++ /// Whether the relevant document is fully active. A window whose browsing ++ /// context was discarded (for example a removed iframe) keeps its activity ++ /// flag until the pipeline exits, so that state is checked here as well. ++ fn is_fully_active(&self) -> bool { ++ self.global().downcast::().is_none_or(|window| { ++ window.is_alive() && ++ window.undiscarded_window_proxy().is_some() && ++ window.Document().is_fully_active() ++ }) ++ } ++ ++ fn send(&self, message: WebLockMessage) -> bool { ++ self.global() ++ .script_to_constellation_chan() ++ .send(ScriptToConstellationMessage::WebLock(message)) ++ .is_ok() ++ } ++ ++ /// Set up the callback the constellation replies through, if this hasn't been done already. ++ fn get_or_setup_result_handler(&self) -> GenericCallback { ++ if let Some(handler) = self.result_handler.borrow().as_ref() { ++ return handler.clone(); ++ } ++ ++ let manager = Trusted::new(self); ++ let task_source = self ++ .global() ++ .task_manager() ++ .dom_manipulation_task_source() ++ .to_sendable(); ++ let handler = GenericCallback::new(move |message| { ++ let manager = manager.clone(); ++ let response = match message { ++ Ok(response) => response, ++ Err(error) => { ++ return error!("Error receiving a Web Locks response: {error:?}"); ++ }, ++ }; ++ task_source.queue(task!(web_lock_response: move |cx| { ++ let manager = manager.root(); ++ manager.handle_response(cx, response); ++ })); ++ }) ++ .expect("Could not create a Web Locks callback"); ++ ++ *self.result_handler.borrow_mut() = Some(handler.clone()); ++ handler ++ } ++ ++ /// ++ fn request_with_options( ++ &self, ++ realm: &mut CurrentRealm, ++ name: DOMString, ++ options: &LockOptions, ++ callback: Rc, ++ ) -> Rc { ++ let global = self.global(); ++ let promise = Promise::new_in_realm(realm); ++ ++ // If this's relevant global object's associated Document is not fully ++ // active, return a promise rejected with an "InvalidStateError" DOMException. ++ if !self.is_fully_active() { ++ promise.reject_error(realm, Error::InvalidState(None)); ++ return promise; ++ } ++ ++ // Step 1. Let environment be this's relevant settings object. ++ // Step 2. Let origin be environment's origin. ++ let origin = global.origin().immutable().clone(); ++ ++ // Step 3. If origin is an opaque origin, then return a promise rejected ++ // with a "SecurityError" DOMException. ++ if !origin.is_tuple() { ++ promise.reject_error(realm, Error::Security(None)); ++ return promise; ++ } ++ ++ // Step 4. Let mode be options["mode"]. ++ let mode = options.mode; ++ ++ // Step 5. If name starts with U+002D HYPHEN-MINUS (-), then return a ++ // promise rejected with a "NotSupportedError" DOMException. ++ if name.str().starts_with('-') { ++ promise.reject_error( ++ realm, ++ Error::NotSupported(Some("Lock names cannot start with '-'.".to_string())), ++ ); ++ return promise; ++ } ++ ++ // Step 6. If both options["steal"] and options["ifAvailable"] are true, ++ // then return a promise rejected with a "NotSupportedError" DOMException. ++ if options.steal && options.ifAvailable { ++ promise.reject_error( ++ realm, ++ Error::NotSupported(Some( ++ "The 'steal' and 'ifAvailable' options cannot be used together.".to_string(), ++ )), ++ ); ++ return promise; ++ } ++ ++ // Step 7. If options["steal"] is true and mode is not "exclusive", then ++ // return a promise rejected with a "NotSupportedError" DOMException. ++ if options.steal && mode != LockMode::Exclusive { ++ promise.reject_error( ++ realm, ++ Error::NotSupported(Some( ++ "The 'steal' option can only be used with exclusive locks.".to_string(), ++ )), ++ ); ++ return promise; ++ } ++ ++ // Step 8. If options["signal"] exists, and either of options["steal"] or ++ // options["ifAvailable"] is true, then return a promise rejected with a ++ // "NotSupportedError" DOMException. ++ if options.signal.is_some() && (options.steal || options.ifAvailable) { ++ promise.reject_error( ++ realm, ++ Error::NotSupported(Some( ++ "The 'signal' option cannot be used with 'steal' or 'ifAvailable'." ++ .to_string(), ++ )), ++ ); ++ return promise; ++ } ++ ++ // Step 9. If options["signal"] exists and is aborted, then return a ++ // promise rejected with options["signal"]'s abort reason. ++ if let Some(signal) = options.signal.as_ref().filter(|signal| signal.aborted()) { ++ rooted!(&in(realm) let mut reason = UndefinedValue()); ++ signal.Reason(reason.handle_mut()); ++ promise.reject(realm, reason.handle()); ++ return promise; ++ } ++ ++ // Step 10. Let promise be a new promise. ++ // Step 11. Let request be the result of running request a lock with ++ // promise, the current agent, environment's id, origin, name, mode, ++ // options["steal"], options["ifAvailable"], and callback. ++ // Note: the queue and held set live in the constellation. ++ let request_id = self.allocate_request_id(); ++ let result_handler = self.get_or_setup_result_handler(); ++ self.pending.borrow_mut().insert( ++ request_id, ++ PendingRequest { ++ promise: promise.clone(), ++ callback, ++ name: name.clone(), ++ mode, ++ }, ++ ); ++ let sent = self.send(WebLockMessage::Request { ++ origin, ++ client_id: self.client_id.clone(), ++ request_id, ++ name: name.to_string(), ++ mode: to_web_lock_mode(mode), ++ if_available: options.ifAvailable, ++ steal: options.steal, ++ pipeline_bound: self.global().downcast::().is_none(), ++ result_handler, ++ }); ++ if !sent { ++ self.pending.borrow_mut().remove(&request_id); ++ promise.reject_error( ++ realm, ++ Error::Type(c"Failed to send the lock request to the constellation".to_owned()), ++ ); ++ return promise; ++ } ++ ++ // Step 12. If options["signal"] exists, then add the following abort ++ // steps to options["signal"]: abort the request request, and reject ++ // promise with options["signal"]'s abort reason. ++ if let Some(signal) = options.signal.as_ref() { ++ signal.add(&AbortAlgorithm::WebLockRequest(WebLockAbortRequest { ++ manager: Dom::from_ref(self), ++ request_id, ++ })); ++ } ++ ++ // Step 13. Return promise. ++ promise ++ } ++ ++ /// ++ fn abort_request(&self, cx: &mut CurrentRealm, request_id: u64, reason: HandleValue) { ++ // A request that was granted or refused in the meantime is no longer ++ // pending, and aborting it has no effect. ++ let Some(request) = self.pending.borrow_mut().remove(&request_id) else { ++ return; ++ }; ++ // Step 1. Remove request from queue, and process the lock request queue. ++ self.send(WebLockMessage::Abort { ++ origin: self.global().origin().immutable().clone(), ++ client_id: self.client_id.clone(), ++ request_id, ++ }); ++ // Step 2. Reject promise with signal's abort reason. ++ request.promise.reject(cx, reason); ++ } ++ ++ fn handle_response(&self, cx: &mut JSContext, response: WebLockResponse) { ++ match response { ++ WebLockResponse::Granted { request_id } => self.handle_granted(cx, request_id), ++ WebLockResponse::Unavailable { request_id } => self.handle_unavailable(cx, request_id), ++ WebLockResponse::Stolen { request_id } => self.handle_stolen(cx, request_id), ++ WebLockResponse::Snapshot { ++ request_id, ++ held, ++ pending, ++ } => self.handle_snapshot(cx, request_id, held, pending), ++ } ++ } ++ ++ /// The task queued by step 1.5 of ++ /// . ++ fn handle_granted(&self, cx: &mut JSContext, request_id: u64) { ++ let Some(request) = self.pending.borrow_mut().remove(&request_id) else { ++ // The request was aborted after the constellation granted it, so ++ // the lock it holds is released at once and the callback never runs. ++ self.send(WebLockMessage::Release { ++ origin: self.global().origin().immutable().clone(), ++ client_id: self.client_id.clone(), ++ request_id, ++ }); ++ return; ++ }; ++ let global = self.global(); ++ ++ // Step 1.5.1. Let waiting be a new promise. ++ // Step 1.5.2. Let lock be a new lock with agent, clientId, mode, name, ++ // waiting promise waiting, and released promise promise. ++ self.held.borrow_mut().insert( ++ request_id, ++ HeldLock { ++ promise: request.promise, ++ }, ++ ); ++ let lock = Lock::new(cx, &global, request.name, request.mode); ++ ++ // Step 1.5.3. Let r be the result of invoking callback with a new Lock ++ // object associated with lock as the only argument. If an exception ++ // was thrown, reject waiting with the exception; otherwise resolve ++ // waiting with r. ++ let waiting = self.invoke_callback(cx, &global, &request.callback, Some(&lock)); ++ ++ // Step 1.5.4. Upon fulfillment or rejection of waiting, release the ++ // lock and settle lock's released promise the same way. ++ self.react_to_waiting_promise(cx, &global, &waiting, request_id); ++ } ++ ++ /// Step 6 of : the ++ /// request had `ifAvailable` and was not grantable. ++ fn handle_unavailable(&self, cx: &mut JSContext, request_id: u64) { ++ let Some(request) = self.pending.borrow_mut().remove(&request_id) else { ++ return; ++ }; ++ let global = self.global(); ++ // Step 6.1.1. Let r be the result of invoking callback with null as ++ // the only argument. If an exception was thrown, reject promise with ++ // the exception; otherwise resolve promise with r. ++ let result = self.invoke_callback(cx, &global, &request.callback, None); ++ request.promise.resolve_native(cx, &result); ++ } ++ ++ /// Step 5.1 of : another ++ /// client stole this lock, which rejects its waiting promise with an ++ /// "AbortError" DOMException and so rejects its released promise. ++ fn handle_stolen(&self, cx: &mut JSContext, request_id: u64) { ++ let Some(held) = self.held.borrow_mut().remove(&request_id) else { ++ return; ++ }; ++ held.promise.reject_error( ++ cx, ++ Error::Abort(Some("The lock was stolen by another request.".to_string())), ++ ); ++ } ++ ++ /// Step 5.2 of . ++ fn handle_snapshot( ++ &self, ++ cx: &mut JSContext, ++ request_id: u64, ++ held: Vec, ++ pending: Vec, ++ ) { ++ let Some(promise) = self.queries.borrow_mut().remove(&request_id) else { ++ return; ++ }; ++ let snapshot = LockManagerSnapshot { ++ held: Some(held.into_iter().map(to_lock_info).collect()), ++ pending: Some(pending.into_iter().map(to_lock_info).collect()), ++ }; ++ promise.resolve_native(cx, &snapshot); ++ } ++ ++ /// Invoke the request's callback and return the promise its result was ++ /// resolved with, or a promise rejected with the exception it threw. ++ fn invoke_callback( ++ &self, ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ callback: &LockGrantedCallback, ++ lock: Option<&Lock>, ++ ) -> Rc { ++ match callback.Call__(cx, lock, ExceptionHandling::Rethrow) { ++ Ok(promise) => promise, ++ Err(_) => { ++ rooted!(&in(cx) let mut exception = UndefinedValue()); ++ #[expect(unsafe_code)] ++ unsafe { ++ assert!(JS_GetPendingException(cx, exception.handle_mut())); ++ JS_ClearPendingException(cx); ++ } ++ Promise::new_rejected(cx, global, exception.get()) ++ }, ++ } ++ } ++ ++ fn react_to_waiting_promise( ++ &self, ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ waiting: &Rc, ++ request_id: u64, ++ ) { ++ rooted!(&in(cx) let mut fulfillment_handler = Some(WaitingPromiseSettledHandler { ++ manager: Dom::from_ref(self), ++ request_id, ++ fulfilled: true, ++ })); ++ rooted!(&in(cx) let mut rejection_handler = Some(WaitingPromiseSettledHandler { ++ manager: Dom::from_ref(self), ++ request_id, ++ fulfilled: false, ++ })); ++ let handler = PromiseNativeHandler::new( ++ cx, ++ global, ++ fulfillment_handler.take().map(|h| Box::new(h) as Box<_>), ++ rejection_handler.take().map(|h| Box::new(h) as Box<_>), ++ ); ++ let mut realm = enter_auto_realm(cx, global); ++ let cx = &mut realm.current_realm(); ++ waiting.append_native_handler(cx, &handler); ++ } ++ ++ /// , followed by ++ /// settling the lock's released promise. ++ fn waiting_promise_settled(&self, cx: &mut CurrentRealm, request_id: u64, fulfilled: bool, value: HandleValue) { ++ // A stolen lock was already released and its promise rejected. ++ let Some(held) = self.held.borrow_mut().remove(&request_id) else { ++ return; ++ }; ++ self.send(WebLockMessage::Release { ++ origin: self.global().origin().immutable().clone(), ++ client_id: self.client_id.clone(), ++ request_id, ++ }); ++ if fulfilled { ++ held.promise.resolve_native(cx, &value); ++ } else { ++ held.promise.reject_native(cx, &value); ++ } ++ } ++} ++ ++impl LockManagerMethods for LockManager { ++ /// ++ fn Request( ++ &self, ++ realm: &mut CurrentRealm, ++ name: DOMString, ++ callback: Rc, ++ ) -> Rc { ++ self.request_with_options(realm, name, &LockOptions::empty(), callback) ++ } ++ ++ /// ++ fn Request_( ++ &self, ++ realm: &mut CurrentRealm, ++ name: DOMString, ++ options: &LockOptions, ++ callback: Rc, ++ ) -> Rc { ++ self.request_with_options(realm, name, options, callback) ++ } ++ ++ /// ++ fn Query(&self, realm: &mut CurrentRealm) -> Rc { ++ let global = self.global(); ++ // Step 3. Let promise be a new promise. ++ let promise = Promise::new_in_realm(realm); ++ ++ // If this's relevant global object's associated Document is not fully ++ // active, return a promise rejected with an "InvalidStateError" DOMException. ++ if !self.is_fully_active() { ++ promise.reject_error(realm, Error::InvalidState(None)); ++ return promise; ++ } ++ ++ // Step 1. Let origin be environment's origin. ++ // Step 2. If origin is an opaque origin, then return a promise rejected ++ // with a "SecurityError" DOMException. ++ let origin = global.origin().immutable().clone(); ++ if !origin.is_tuple() { ++ promise.reject_error(realm, Error::Security(None)); ++ return promise; ++ } ++ ++ // Step 4. Run these steps in parallel: snapshot the lock state and ++ // resolve promise with it. ++ let request_id = self.allocate_request_id(); ++ let result_handler = self.get_or_setup_result_handler(); ++ self.queries.borrow_mut().insert(request_id, promise.clone()); ++ let sent = self.send(WebLockMessage::Query { ++ origin, ++ request_id, ++ result_handler, ++ }); ++ if !sent { ++ self.queries.borrow_mut().remove(&request_id); ++ promise.reject_error( ++ realm, ++ Error::Type(c"Failed to send the lock query to the constellation".to_owned()), ++ ); ++ } ++ ++ // Step 5. Return promise. ++ promise ++ } ++} ++ ++/// The fulfillment and rejection handlers of a lock's waiting promise. ++#[derive(Clone, JSTraceable, MallocSizeOf)] ++#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] ++struct WaitingPromiseSettledHandler { ++ manager: Dom, ++ request_id: u64, ++ fulfilled: bool, ++} ++ ++impl js::gc::Rootable for WaitingPromiseSettledHandler {} ++ ++impl Callback for WaitingPromiseSettledHandler { ++ fn callback(&self, cx: &mut CurrentRealm, value: HandleValue) { ++ self.manager ++ .waiting_promise_settled(cx, self.request_id, self.fulfilled, value); ++ } ++} ++ ++fn to_web_lock_mode(mode: LockMode) -> WebLockMode { ++ match mode { ++ LockMode::Shared => WebLockMode::Shared, ++ LockMode::Exclusive => WebLockMode::Exclusive, ++ } ++} ++ ++fn from_web_lock_mode(mode: WebLockMode) -> LockMode { ++ match mode { ++ WebLockMode::Shared => LockMode::Shared, ++ WebLockMode::Exclusive => LockMode::Exclusive, ++ } ++} ++ ++fn to_lock_info(info: WebLockInfo) -> LockInfo { ++ LockInfo { ++ name: Some(DOMString::from(info.name)), ++ mode: Some(from_web_lock_mode(info.mode)), ++ clientId: Some(DOMString::from(info.client_id)), ++ } ++} +diff --git a/components/script/dom/lockmanager/mod.rs b/components/script/dom/lockmanager/mod.rs +new file mode 100644 +index 0000000000..c5177a738a +--- /dev/null ++++ b/components/script/dom/lockmanager/mod.rs +@@ -0,0 +1,8 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++pub(crate) mod lock; ++#[expect(clippy::module_inception, reason = "The interface name is LockManager")] ++pub(crate) mod lockmanager; ++pub(crate) use lockmanager::{LockManager, WebLockAbortRequest}; +diff --git a/components/script/dom/mod.rs b/components/script/dom/mod.rs +index abe3bd24b9..0e4fa1f631 100644 +--- a/components/script/dom/mod.rs ++++ b/components/script/dom/mod.rs +@@ -283,6 +283,8 @@ pub(crate) mod indexeddb; + pub(crate) use self::indexeddb::*; + pub(crate) mod intersectionobserver; + pub(crate) use self::intersectionobserver::*; ++pub(crate) mod lockmanager; ++pub(crate) use self::lockmanager::*; + pub(crate) mod media; + pub(crate) use self::media::*; + pub(crate) mod mimetype; +diff --git a/components/script/dom/navigator/navigator.rs b/components/script/dom/navigator/navigator.rs +index 0109df563a..00236b4645 100644 +--- a/components/script/dom/navigator/navigator.rs ++++ b/components/script/dom/navigator/navigator.rs +@@ -49,6 +49,7 @@ use crate::dom::csp::{GlobalCspReporting, Violation}; + use crate::dom::gamepad::Gamepad; + use crate::dom::geolocation::Geolocation; + use crate::dom::globalscope::GlobalScope; ++use crate::dom::lockmanager::LockManager; + use crate::dom::mediadevices::MediaDevices; + use crate::dom::mediasession::MediaSession; + use crate::dom::mimetypearray::MimeTypeArray; +@@ -130,6 +131,7 @@ pub(crate) struct Navigator { + mediasession: MutNullableDom, + clipboard: MutNullableDom, + storage: MutNullableDom, ++ locks: MutNullableDom, + #[cfg(feature = "webgpu")] + gpu: MutNullableDom, + /// +@@ -159,6 +161,7 @@ impl Navigator { + mediasession: Default::default(), + clipboard: Default::default(), + storage: Default::default(), ++ locks: Default::default(), + #[cfg(feature = "webgpu")] + gpu: Default::default(), + #[cfg(feature = "gamepad")] +@@ -521,6 +524,11 @@ impl NavigatorMethods for Navigator { + .or_init(|| StorageManager::new(cx, &self.global())) + } + ++ /// ++ fn Locks(&self, cx: &mut JSContext) -> DomRoot { ++ self.locks.or_init(|| LockManager::new(cx, &self.global())) ++ } ++ + /// + fn SendBeacon( + &self, +diff --git a/components/script/dom/window/window.rs b/components/script/dom/window/window.rs +index fdd149d256..72920262e8 100644 +--- a/components/script/dom/window/window.rs ++++ b/components/script/dom/window/window.rs +@@ -179,6 +179,7 @@ use crate::dom::scrolling_box::{ScrollingBox, ScrollingBoxSource}; + use crate::dom::selection::Selection; + use crate::dom::serviceworker::cachestorage::CacheStorage; + use crate::dom::shadowroot::ShadowRoot; ++use crate::dom::sharedworker::SharedWorker; + use crate::dom::storage::Storage; + #[cfg(feature = "bluetooth")] + use crate::dom::testrunner::TestRunner; +@@ -532,6 +533,7 @@ impl Window { + pub(crate) fn clear_js_runtime_for_script_deallocation(&self) { + self.as_global_scope() + .remove_web_messaging_and_dedicated_workers_infra(); ++ SharedWorker::document_discarded(self.pipeline_id()); + unsafe { + *self.js_runtime.borrow_for_script_deallocation() = None; + self.window_proxy.set(None); +@@ -2473,6 +2475,10 @@ impl Window { + self.as_global_scope() + .remove_web_messaging_and_dedicated_workers_infra(); + ++ // This document is discarded: leave the owner set of every shared worker it ++ // created, terminating the ones left without an owner. ++ SharedWorker::document_discarded(self.pipeline_id()); ++ + // Clean up any active promises + // https://github.com/servo/servo/issues/15318 + self.Document().teardown_custom_element_registry(); +diff --git a/components/script/dom/window/windowproxy.rs b/components/script/dom/window/windowproxy.rs +index 5cbd24ae3c..714049b271 100644 +--- a/components/script/dom/window/windowproxy.rs ++++ b/components/script/dom/window/windowproxy.rs +@@ -146,8 +146,9 @@ impl WindowProxy { + parent: Option<&WindowProxy>, + opener: Option, + creator: CreatorBrowsingContextInfo, ++ name: DOMString, + ) -> WindowProxy { +- let name = frame_element.map_or(DOMString::new(), |e| { ++ let name = frame_element.map_or(name, |e| { + e.get_string_attribute(&local_name!("name")) + }); + WindowProxy { +@@ -180,6 +181,7 @@ impl WindowProxy { + parent: Option<&WindowProxy>, + opener: Option, + creator: CreatorBrowsingContextInfo, ++ name: DOMString, + ) -> DomRoot { + unsafe { + let handler = window.windowproxy_handler(); +@@ -209,6 +211,7 @@ impl WindowProxy { + parent, + opener, + creator, ++ name, + )); + + // The window proxy owns the browsing context. +@@ -231,7 +234,11 @@ impl WindowProxy { + window_proxy + .reflector + .init_reflector::(js_proxy.get()); +- DomRoot::from_ref(&*Box::into_raw(window_proxy)) ++ let window_proxy = DomRoot::from_ref(&*Box::into_raw(window_proxy)); ++ if !window_proxy.name.borrow().is_empty() { ++ window_proxy.notify_constellation_of_name(window); ++ } ++ window_proxy + } + } + +@@ -257,6 +264,7 @@ impl WindowProxy { + parent, + opener, + creator, ++ DOMString::new(), + )); + + // Create a new dissimilar-origin window. +@@ -336,6 +344,15 @@ impl WindowProxy { + SandboxingFlagSet::empty() + }; + ++ // Step 5. Let targetName be the empty string. ++ // Step 6. If name is not an ASCII case-insensitive match for "_blank", ++ // then set targetName to name. ++ let target_name = if name.str().eq_ignore_ascii_case("_blank") { ++ DOMString::new() ++ } else { ++ name ++ }; ++ + let blank_url = ServoUrl::parse("about:blank").ok().unwrap(); + let load_data = LoadData::new( + LoadOrigin::Script(document.origin().snapshot()), +@@ -355,6 +372,7 @@ impl WindowProxy { + load_data: load_data.clone(), + opener_webview_id: window.webview_id(), + opener_pipeline_id: self.currently_active.get().unwrap(), ++ noopener, + response_sender, + }; + let constellation_msg = ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info); +@@ -368,6 +386,7 @@ impl WindowProxy { + browsing_context_id: new_browsing_context_id, + webview_id: response.new_webview_id, + opener: Some(self.browsing_context_id), ++ browsing_context_name: target_name.to_string(), + load_data, + viewport_details: window.viewport_details(), + user_content_manager_id: response.user_content_manager_id, +@@ -384,11 +403,11 @@ impl WindowProxy { + script_thread.spawn_pipeline(cx, new_pipeline_info); + }); + ++ // Step 7 and 8. The new traversable was created with targetName as its ++ // browsing context's name: the `WindowProxy` took it from the pipeline ++ // info when the script thread loaded the initial about:blank document. + let new_window_proxy = ScriptThread::find_document(response.new_pipeline_id) + .and_then(|doc| doc.browsing_context())?; +- if name.to_lowercase() != "_blank" { +- new_window_proxy.set_name(name); +- } + if noopener { + new_window_proxy.disown(); + } else { +@@ -540,9 +559,10 @@ impl WindowProxy { + }; + // TODO Step 15.2, Set up browsing context features for targetNavigable's + // active browsing context given tokenizedFeatures. +- let target_document = match chosen.document() { +- Some(target_document) => target_document, +- None => return Ok(None), ++ let Some(target_document) = chosen.document() else { ++ // The chosen browsing context's active document lives in another script ++ // thread, so it is navigated through the constellation. ++ return self.open_in_remote_browsing_context(&chosen, url, noreferrer, noopener); + }; + let has_trustworthy_ancestor_origin = if new { + target_document.has_trustworthy_ancestor_or_current_origin() +@@ -620,6 +640,77 @@ impl WindowProxy { + Ok(target_document.browsing_context()) + } + ++ /// Step 15.5 of the window open steps for a browsing context found by name whose ++ /// active document is owned by another script thread. ++ /// ++ fn open_in_remote_browsing_context( ++ &self, ++ chosen: &WindowProxy, ++ url: USVString, ++ noreferrer: bool, ++ noopener: bool, ++ ) -> Fallible>> { ++ if !url.is_empty() { ++ let existing_document = self ++ .currently_active ++ .get() ++ .and_then(ScriptThread::find_document) ++ .unwrap(); ++ let url = match existing_document.url().join(&url) { ++ Ok(url) => url, ++ Err(_) => return Err(Error::Syntax(None)), ++ }; ++ let referrer = if noreferrer { ++ Referrer::NoReferrer ++ } else { ++ existing_document.global().get_referrer() ++ }; ++ chosen.navigate_from_other_thread( ++ &existing_document, ++ url, ++ referrer, ++ NavigationHistoryBehavior::Push, ++ ); ++ } ++ // Step 17. ++ if noopener { ++ return Ok(None); ++ } ++ // Step 18. ++ Ok(Some(DomRoot::from_ref(chosen))) ++ } ++ ++ /// Navigate this browsing context, whose active document is owned by another ++ /// script thread, on behalf of `source_document`. The constellation resolves the ++ /// current pipeline and routes the load like a script-initiated `LoadUrl`. ++ pub(crate) fn navigate_from_other_thread( ++ &self, ++ source_document: &Document, ++ url: ServoUrl, ++ referrer: Referrer, ++ history_handling: NavigationHistoryBehavior, ++ ) { ++ let load_data = LoadData::new( ++ LoadOrigin::Script(source_document.origin().snapshot()), ++ url, ++ None, ++ Some(source_document.window().pipeline_id()), ++ referrer, ++ source_document.get_referrer_policy(), ++ None, ++ None, ++ false, ++ SandboxingFlagSet::empty(), ++ ); ++ source_document.window().send_to_constellation( ++ ScriptToConstellationMessage::LoadUrlInBrowsingContext( ++ self.browsing_context_id, ++ load_data, ++ history_handling, ++ ), ++ ); ++ } ++ + // https://html.spec.whatwg.org/multipage/#the-rules-for-choosing-a-browsing-context-given-a-browsing-context-name + pub(crate) fn choose_browsing_context( + &self, +@@ -648,11 +739,18 @@ impl WindowProxy { + true, + ), + _ => { +- // Step 6. +- // TODO: expand the search to all 'familiar' bc, +- // including auxiliaries familiar by way of their opener. +- // See https://html.spec.whatwg.org/multipage/#familiar-with +- match ScriptThread::find_window_proxy_by_name(&name) { ++ // Step 7. Find a navigable by target name. The constellation searches ++ // this context's tree and then the familiar contexts of its browsing ++ // context group, including ones owned by other script threads. ++ let global = self ++ .currently_active ++ .get() ++ .and_then(ScriptThread::find_document) ++ .map(|document| document.global()); ++ let found = global.as_deref().and_then(|global| { ++ ScriptThread::find_window_proxy_by_name(cx, self, global, &name) ++ }); ++ match found { + Some(proxy) => (Some(proxy), false), + None => ( + self.create_auxiliary_browsing_context(cx, name, noopener), +@@ -820,6 +918,18 @@ impl WindowProxy { + + pub(crate) fn set_name(&self, name: DOMString) { + *self.name.borrow_mut() = name; ++ if let Some(document) = self.currently_active.get().and_then(ScriptThread::find_document) { ++ self.notify_constellation_of_name(document.window()); ++ } ++ } ++ ++ /// Tell the constellation this browsing context's target name, so that named ++ /// lookups from any script thread can find it. ++ fn notify_constellation_of_name(&self, window: &Window) { ++ window.send_to_constellation(ScriptToConstellationMessage::SetBrowsingContextName( ++ self.browsing_context_id, ++ self.name.borrow().to_string(), ++ )); + } + } + +diff --git a/components/script/dom/workers/sharedworker.rs b/components/script/dom/workers/sharedworker.rs +index 79cbe78ad4..adae1aea63 100644 +--- a/components/script/dom/workers/sharedworker.rs ++++ b/components/script/dom/workers/sharedworker.rs +@@ -15,6 +15,7 @@ use net_traits::pub_domains::reg_suffix; + use net_traits::request::{CredentialsMode, Referrer}; + use script_bindings::reflector::reflect_dom_object_with_proto; + use servo_base::generic_channel; ++use servo_base::id::PipelineId; + use servo_constellation_traits::{MessagePortImpl, WorkerScriptLoadOrigin}; + use servo_url::{Host, ImmutableOrigin, ServoUrl}; + use uuid::Uuid; +@@ -138,6 +139,17 @@ enum SharedWorkerRegistryState { + struct SharedWorkerRegistryEntry { + key: SharedWorkerKey, + state: SharedWorkerRegistryState, ++ /// The pipelines of the documents that own the worker. ++ /// ++ owners: Vec, ++} ++ ++impl SharedWorkerRegistryEntry { ++ fn add_owner(&mut self, owner: PipelineId) { ++ if !self.owners.contains(&owner) { ++ self.owners.push(owner); ++ } ++ } + } + + // A `SharedWorkerGlobalScope` object has associated constructor origin (an origin), constructor URL (a URL record), and credentials (a credentials mode), and extended lifetime (a boolean). +@@ -150,7 +162,7 @@ struct SharedWorkerRegistration { + worker_is_secure_context: bool, + closing: Arc, + sender: Sender, +- _control_sender: Sender, ++ control_sender: Sender, + } + + // A user agent has an associated shared worker manager which is the result of starting a new parallel queue. +@@ -194,7 +206,7 @@ fn find_matching_shared_worker( + + /// + /// +-fn find_or_claim_shared_worker(key: SharedWorkerKey) -> SharedWorkerClaimResult { ++fn find_or_claim_shared_worker(key: SharedWorkerKey, owner: PipelineId) -> SharedWorkerClaimResult { + let (workers, ready) = &*SHARED_WORKERS; + let mut workers = workers.lock().expect("SharedWorker registry poisoned"); + +@@ -204,10 +216,14 @@ fn find_or_claim_shared_worker(key: SharedWorkerKey) -> SharedWorkerClaimResult + workers.push(SharedWorkerRegistryEntry { + key, + state: SharedWorkerRegistryState::Creating { waiters: 0 }, ++ owners: vec![owner], + }); + return SharedWorkerClaimResult::Claimed; + }; + ++ // Step 11.5.8. Append the relevant owner to add given outsideSettings to ++ // workerGlobalScope's owner set. ++ workers[index].add_owner(owner); + match &mut workers[index].state { + SharedWorkerRegistryState::Creating { waiters } => *waiters += 1, + SharedWorkerRegistryState::Created(registration) => { +@@ -223,6 +239,7 @@ fn find_or_claim_shared_worker(key: SharedWorkerKey) -> SharedWorkerClaimResult + return SharedWorkerClaimResult::Failed; + }; + ++ workers[index].add_owner(owner); + match &mut workers[index].state { + SharedWorkerRegistryState::Creating { .. } => {}, + SharedWorkerRegistryState::Created(registration) => { +@@ -314,6 +331,45 @@ fn send_connect_to_created_worker( + } + + impl SharedWorker { ++ /// Remove the document of `pipeline_id` from every shared worker's owner set ++ /// and terminate the workers left without an owner: a worker whose owner set ++ /// is empty is not a permissible worker unless its lifetime is extended. ++ /// ++ /// ++ pub(crate) fn document_discarded(pipeline_id: PipelineId) { ++ let (workers, ready) = &*SHARED_WORKERS; ++ let mut workers = workers.lock().expect("SharedWorker registry poisoned"); ++ let old_len = workers.len(); ++ workers.retain_mut(|entry| { ++ entry.owners.retain(|owner| *owner != pipeline_id); ++ if !entry.owners.is_empty() { ++ return true; ++ } ++ match &entry.state { ++ SharedWorkerRegistryState::Created(registration) ++ if !registration.extended_lifetime => ++ { ++ // Step 1. Set the worker's WorkerGlobalScope object's closing flag to true. ++ registration.closing.store(true, Ordering::SeqCst); ++ // Wake the worker's event loop so it observes the flag and tears ++ // its global down, which releases what the global holds. ++ if registration ++ .control_sender ++ .send(SharedWorkerControlMsg::Exit) ++ .is_err() ++ { ++ warn!("Couldn't send an exit message to a shared worker."); ++ } ++ false ++ }, ++ _ => true, ++ } ++ }); ++ if workers.len() != old_len { ++ ready.notify_all(); ++ } ++ } ++ + pub(crate) fn unregister_shared_worker(id: Uuid) { + let (workers, ready) = &*SHARED_WORKERS; + let mut workers = workers.lock().expect("SharedWorker registry poisoned"); +@@ -482,7 +538,8 @@ impl SharedWorkerMethods for SharedWorker { + // Servo also atomically records a Creating entry here when no matching + // scope exists, so another same-key constructor cannot race into the + // Step 11.6 fresh-worker path. +- let shared_worker = find_or_claim_shared_worker(shared_worker_key.clone()); ++ let shared_worker = ++ find_or_claim_shared_worker(shared_worker_key.clone(), global.pipeline_id()); + + match shared_worker { + SharedWorkerClaimResult::Created(registration) => { +@@ -523,7 +580,7 @@ impl SharedWorkerMethods for SharedWorker { + if send_connect_to_created_worker(®istration, inside_port_impl) { + SharedWorker::queue_simple_error(global, worker_addr); + } +- // TODO Step 11.5.8. Append the relevant owner to add given outsideSettings to workerGlobalScope's owner set. ++ // Step 11.5.8 ran inside find_or_claim_shared_worker, under the registry lock. + return Ok(worker); + }, + SharedWorkerClaimResult::Failed => { +@@ -649,7 +706,7 @@ impl SharedWorkerMethods for SharedWorker { + worker_is_secure_context, + closing, + sender, +- _control_sender: control_sender, ++ control_sender, + }; + + if !transition_creating_to_created(&shared_worker_key, registration.clone()) { +diff --git a/components/script/dom/workers/worker.rs b/components/script/dom/workers/worker.rs +index 519238b62a..47ece9365f 100644 +--- a/components/script/dom/workers/worker.rs ++++ b/components/script/dom/workers/worker.rs +@@ -328,6 +328,12 @@ impl WorkerMethods for Worker { + if let Some(cx) = self.context_for_interrupt.borrow().as_ref() { + cx.request_interrupt_callback() + } ++ ++ // An idle worker is blocked in its event loop select and only observes the ++ // closing flag once a message arrives. Wake it so the loop exits and the ++ // global's teardown (which releases its web locks) runs now rather than at ++ // parent teardown. ++ let _ = self.sender.send(DedicatedWorkerScriptMsg::WakeUp); + } + + // https://html.spec.whatwg.org/multipage/#handler-worker-onmessage +diff --git a/components/script/dom/workers/workernavigator.rs b/components/script/dom/workers/workernavigator.rs +index 2cc59fa2da..a367edfd8f 100644 +--- a/components/script/dom/workers/workernavigator.rs ++++ b/components/script/dom/workers/workernavigator.rs +@@ -13,6 +13,7 @@ use crate::dom::bindings::reflector::DomGlobal; + use crate::dom::bindings::root::{DomRoot, MutNullableDom}; + use crate::dom::bindings::str::DOMString; + use crate::dom::bindings::utils::to_frozen_array; ++use crate::dom::lockmanager::LockManager; + use crate::dom::navigator::hardware_concurrency; + use crate::dom::navigatorinfo; + use crate::dom::permissions::Permissions; +@@ -27,6 +28,7 @@ pub(crate) struct WorkerNavigator { + reflector_: Reflector, + permissions: MutNullableDom, + storage: MutNullableDom, ++ locks: MutNullableDom, + #[cfg(feature = "webgpu")] + gpu: MutNullableDom, + } +@@ -37,6 +39,7 @@ impl WorkerNavigator { + reflector_: Reflector::new(), + permissions: Default::default(), + storage: Default::default(), ++ locks: Default::default(), + #[cfg(feature = "webgpu")] + gpu: Default::default(), + } +@@ -125,6 +128,11 @@ impl WorkerNavigatorMethods for WorkerNavigator { + .or_init(|| StorageManager::new(cx, &self.global())) + } + ++ /// ++ fn Locks(&self, cx: &mut JSContext) -> DomRoot { ++ self.locks.or_init(|| LockManager::new(cx, &self.global())) ++ } ++ + // https://gpuweb.github.io/gpuweb/#dom-navigator-gpu + #[cfg(feature = "webgpu")] + fn Gpu(&self, cx: &mut JSContext) -> DomRoot { +diff --git a/components/script/links.rs b/components/script/links.rs +index 0056c3506d..c28d5edf51 100644 +--- a/components/script/links.rs ++++ b/components/script/links.rs +@@ -504,5 +504,23 @@ pub(crate) fn follow_hyperlink( + .task_manager() + .dom_manipulation_task_source() + .queue(task); +- }; ++ } else { ++ // The target's active document lives in another script thread, so the ++ // navigation is routed through the constellation. ++ let mut href = subject ++ .get_attribute_string_value(&local_name!("href")) ++ .unwrap(); ++ if let Some(suffix) = hyperlink_suffix { ++ href.push_str(&suffix); ++ } ++ let Ok(url) = document.encoding_parse_a_url(&href) else { ++ return; ++ }; ++ let referrer = if relations.contains(LinkRelations::NO_REFERRER) { ++ Referrer::NoReferrer ++ } else { ++ window.as_global_scope().get_referrer() ++ }; ++ chosen.navigate_from_other_thread(&document, url, referrer, history_handling); ++ } + } +diff --git a/components/script/navigation.rs b/components/script/navigation.rs +index 233e20a642..bed7e0d9d4 100644 +--- a/components/script/navigation.rs ++++ b/components/script/navigation.rs +@@ -155,6 +155,9 @@ pub(crate) struct InProgressLoad { + /// The opener, if this is an auxiliary. + #[no_trace] + pub(crate) opener: Option, ++ /// The browsing context's target name, for a `WindowProxy` this script ++ /// thread has yet to create. ++ pub(crate) browsing_context_name: String, + /// The current window size associated with this pipeline. + #[no_trace] + pub(crate) viewport_details: ViewportDetails, +@@ -196,6 +199,7 @@ impl InProgressLoad { + webview_id: new_pipeline_info.webview_id, + parent_info: new_pipeline_info.parent_info, + opener: new_pipeline_info.opener, ++ browsing_context_name: new_pipeline_info.browsing_context_name, + viewport_details: new_pipeline_info.viewport_details, + activity: DocumentActivity::FullyActive, + throttled: false, +diff --git a/components/script/script_thread.rs b/components/script/script_thread.rs +index e22807b148..fa8013b835 100644 +--- a/components/script/script_thread.rs ++++ b/components/script/script_thread.rs +@@ -770,9 +770,45 @@ impl ScriptThread { + with_script_thread(|script_thread| script_thread.window_proxies.clone()) + } + +- pub(crate) fn find_window_proxy_by_name(name: &DOMString) -> Option> { ++ /// Find the browsing context with the given target name that `source` is familiar ++ /// with. The constellation answers, so contexts owned by other script threads are ++ /// found as well; one of those is materialized as a dissimilar-origin window proxy. ++ /// The reply is awaited synchronously: the constellation answers from its own ++ /// state without contacting any script thread, so this cannot deadlock. ++ /// ++ pub(crate) fn find_window_proxy_by_name( ++ cx: &mut JSContext, ++ source: &WindowProxy, ++ global_to_clone: &GlobalScope, ++ name: &DOMString, ++ ) -> Option> { + with_script_thread(|script_thread| { +- script_thread.window_proxies.find_window_proxy_by_name(name) ++ let (result_sender, result_receiver) = generic_channel::channel()?; ++ let msg = ScriptToConstellationMessage::FindBrowsingContextByName( ++ source.browsing_context_id(), ++ name.to_string(), ++ result_sender, ++ ); ++ script_thread ++ .senders ++ .pipeline_to_constellation_sender ++ .send((source.webview_id(), global_to_clone.pipeline_id(), msg)) ++ .ok()?; ++ let found = result_receiver.recv().ok()??; ++ if let Some(window_proxy) = script_thread ++ .window_proxies ++ .find_window_proxy(found.browsing_context_id) ++ { ++ return Some(window_proxy); ++ } ++ script_thread.window_proxies.remote_window_proxy( ++ cx, ++ &script_thread.senders, ++ global_to_clone, ++ found.webview_id, ++ found.pipeline_id, ++ None, ++ ) + }) + } + +@@ -3132,6 +3168,7 @@ impl ScriptThread { + // is no need to pass along existing opener information that + // will be discarded. + None, ++ DOMString::new(), + ); + } + +@@ -3680,6 +3717,7 @@ impl ScriptThread { + incomplete.webview_id, + incomplete.parent_info, + incomplete.opener, ++ DOMString::from(incomplete.browsing_context_name.clone()), + ); + if window_proxy.parent().is_some() { + // https://html.spec.whatwg.org/multipage/#navigating-across-documents:delaying-load-events-mode-2 +diff --git a/components/script/script_window_proxies.rs b/components/script/script_window_proxies.rs +index e5d5fb16be..c5eb6252d8 100644 +--- a/components/script/script_window_proxies.rs ++++ b/components/script/script_window_proxies.rs +@@ -33,18 +33,6 @@ impl ScriptWindowProxies { + .map(|context| DomRoot::from_ref(&**context)) + } + +- pub(crate) fn find_window_proxy_by_name( +- &self, +- name: &DOMString, +- ) -> Option> { +- for (_, proxy) in self.map.borrow().iter() { +- if proxy.get_name() == *name { +- return Some(DomRoot::from_ref(&**proxy)); +- } +- } +- None +- } +- + pub(crate) fn insert(&self, id: BrowsingContextId, proxy: &WindowProxy) { + self.map.borrow_mut().insert(id, Dom::from_ref(proxy)); + } +@@ -115,6 +103,7 @@ impl ScriptWindowProxies { + webview_id: WebViewId, + parent_info: Option, + opener: Option, ++ name: DOMString, + ) -> DomRoot { + if let Some(window_proxy) = self.find_window_proxy(browsing_context_id) { + // Note: we do not set the window to be the currently-active one, +@@ -155,6 +144,7 @@ impl ScriptWindowProxies { + parent_browsing_context.as_deref(), + opener, + creator, ++ name, + ); + self.insert(browsing_context_id, &window_proxy); + window_proxy +diff --git a/components/script_bindings/codegen/Bindings.conf b/components/script_bindings/codegen/Bindings.conf +index cac582107a..efee8e5498 100644 +--- a/components/script_bindings/codegen/Bindings.conf ++++ b/components/script_bindings/codegen/Bindings.conf +@@ -848,6 +848,10 @@ DOMInterfaces = { + 'cx': ['Phases'] + }, + ++'LockManager': { ++ 'realm': ['Request', 'Request_', 'Query'], ++}, ++ + 'Location': { + 'cx': ['Assign', 'Reload', 'Replace'], + 'implicitCxSetters': True, +@@ -908,7 +912,7 @@ DOMInterfaces = { + + 'Navigator': { + 'cx': ['Bluetooth', 'Credentials', 'Clipboard', 'Geolocation', 'Gpu', 'MimeTypes', 'Languages', 'SendBeacon', 'ServiceWorker', 'Servo', +- 'Storage', 'Plugins', 'UserActivation', 'WakeLock', 'Xr', 'MediaDevices', 'MediaSession', 'Permissions', 'GetGamepads'], ++ 'Storage', 'Plugins', 'UserActivation', 'WakeLock', 'Xr', 'MediaDevices', 'MediaSession', 'Permissions', 'GetGamepads', 'Locks'], + }, + + 'CredentialsContainer': { +@@ -1405,7 +1409,7 @@ DOMInterfaces = { + }, + + 'WorkerNavigator': { +- 'cx': ['Gpu', 'Languages', 'Storage', 'Permissions'], ++ 'cx': ['Gpu', 'Languages', 'Storage', 'Permissions', 'Locks'], + }, + + 'Worklet': { +diff --git a/components/script_bindings/webidls/LockManager.webidl b/components/script_bindings/webidls/LockManager.webidl +new file mode 100644 +index 0000000000..5b4f593411 +--- /dev/null ++++ b/components/script_bindings/webidls/LockManager.webidl +@@ -0,0 +1,47 @@ ++/* This Source Code Form is subject to the terms of the Mozilla Public ++ * License, v. 2.0. If a copy of the MPL was not distributed with this ++ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ ++ ++// https://w3c.github.io/web-locks/ ++ ++[SecureContext] ++interface mixin NavigatorLocks { ++ [SameObject, Pref="dom_web_locks_enabled"] readonly attribute LockManager locks; ++}; ++Navigator includes NavigatorLocks; ++WorkerNavigator includes NavigatorLocks; ++ ++[SecureContext, Exposed=(Window,Worker), Pref="dom_web_locks_enabled"] ++interface LockManager { ++ Promise request(DOMString name, LockGrantedCallback callback); ++ Promise request(DOMString name, LockOptions options, LockGrantedCallback callback); ++ Promise query(); ++}; ++ ++callback LockGrantedCallback = Promise (Lock? lock); ++ ++enum LockMode { "shared", "exclusive" }; ++ ++dictionary LockOptions { ++ LockMode mode = "exclusive"; ++ boolean ifAvailable = false; ++ boolean steal = false; ++ AbortSignal signal; ++}; ++ ++dictionary LockManagerSnapshot { ++ sequence held; ++ sequence pending; ++}; ++ ++dictionary LockInfo { ++ DOMString name; ++ LockMode mode; ++ DOMString clientId; ++}; ++ ++[SecureContext, Exposed=(Window,Worker), Pref="dom_web_locks_enabled"] ++interface Lock { ++ readonly attribute DOMString name; ++ readonly attribute LockMode mode; ++}; +diff --git a/components/shared/constellation/from_script_message.rs b/components/shared/constellation/from_script_message.rs +index b1ca67d649..9a3a5193f8 100644 +--- a/components/shared/constellation/from_script_message.rs ++++ b/components/shared/constellation/from_script_message.rs +@@ -441,6 +441,93 @@ pub enum DocumentState { + Pending, + } + ++/// ++#[derive(Clone, Copy, Debug, Deserialize, Eq, MallocSizeOf, PartialEq, Serialize)] ++pub enum WebLockMode { ++ /// ++ Shared, ++ /// ++ Exclusive, ++} ++ ++/// One entry of a lock manager snapshot. ++/// ++#[derive(Clone, Debug, Deserialize, MallocSizeOf, Serialize)] ++pub struct WebLockInfo { ++ /// ++ pub name: String, ++ /// ++ pub mode: WebLockMode, ++ /// ++ pub client_id: String, ++} ++ ++/// A message from a `LockManager` to the constellation's per-origin lock registry. ++/// Requests and locks are identified by the pair of the client id (one per ++/// environment settings object) and a request id the client allocates. ++/// ++#[derive(Debug, Deserialize, Serialize)] ++pub enum WebLockMessage { ++ /// ++ Request { ++ origin: ImmutableOrigin, ++ client_id: String, ++ request_id: u64, ++ name: String, ++ mode: WebLockMode, ++ if_available: bool, ++ steal: bool, ++ /// Whether the requesting agent dies with the pipeline the request ++ /// arrived from. False for shared workers, which outlive the document ++ /// that created them, so only their own teardown may release their locks. ++ pipeline_bound: bool, ++ result_handler: GenericCallback, ++ }, ++ /// ++ Abort { ++ origin: ImmutableOrigin, ++ client_id: String, ++ request_id: u64, ++ }, ++ /// ++ Release { ++ origin: ImmutableOrigin, ++ client_id: String, ++ request_id: u64, ++ }, ++ /// ++ Query { ++ origin: ImmutableOrigin, ++ request_id: u64, ++ result_handler: GenericCallback, ++ }, ++ /// The client's environment is being destroyed: release its held locks ++ /// and drop its pending requests. ++ /// ++ ClientGone { ++ origin: ImmutableOrigin, ++ client_id: String, ++ }, ++} ++ ++/// The constellation's reply to a [`WebLockMessage`], routed back to the ++/// `LockManager` that allocated `request_id`. ++#[derive(Debug, Deserialize, Serialize)] ++pub enum WebLockResponse { ++ /// The request became grantable and is now a held lock. ++ Granted { request_id: u64 }, ++ /// The request was made with `ifAvailable` and was not grantable. ++ Unavailable { request_id: u64 }, ++ /// A held lock was removed by a `steal` request from another client. ++ Stolen { request_id: u64 }, ++ /// ++ Snapshot { ++ request_id: u64, ++ held: Vec, ++ pending: Vec, ++ }, ++} ++ + /// This trait allows creating a `ServiceWorkerManager` without depending on the `script` + /// crate. + pub trait ServiceWorkerManagerFactory { +@@ -457,10 +544,25 @@ pub struct AuxiliaryWebViewCreationRequest { + pub opener_webview_id: WebViewId, + /// The pipeline opener browsing context. + pub opener_pipeline_id: PipelineId, ++ /// Whether the new top-level traversable is created with no opener, in which ++ /// case it gets a browsing context group of its own. ++ /// ++ pub noopener: bool, + /// Sender for the constellation’s response to our request. + pub response_sender: GenericSender>, + } + ++/// The constellation's answer to a named browsing context lookup. ++#[derive(Debug, Deserialize, Serialize)] ++pub struct NamedBrowsingContextInfo { ++ /// The browsing context whose target name matched. ++ pub browsing_context_id: BrowsingContextId, ++ /// The top-level ancestor of that browsing context. ++ pub webview_id: WebViewId, ++ /// The pipeline of that browsing context's current session history entry. ++ pub pipeline_id: PipelineId, ++} ++ + /// Constellation’s response to auxiliary browsing context creation requests. + #[derive(Debug, Deserialize, Serialize)] + pub struct AuxiliaryWebViewCreationResponse { +@@ -610,6 +712,8 @@ pub enum ConstellationInterest { + #[derive(Deserialize, IntoStaticStr, Serialize)] + pub enum ScriptToConstellationMessage { + ServiceWorkerAlgorithm(ServiceWorkerAlgorithm), ++ /// A Web Locks operation for the constellation's per-origin lock registry. ++ WebLock(WebLockMessage), + /// Request to complete the transfer of a set of ports to a router. + CompleteMessagePortTransfer(MessagePortRouterId, Vec), + /// The results of attempting to complete the transfer of a batch of ports. +@@ -699,6 +803,22 @@ pub enum ScriptToConstellationMessage { + FocusRemoteBrowsingContext(BrowsingContextId, RemoteFocusOperation), + /// Get the top-level browsing context info for a given browsing context. + GetTopForBrowsingContext(BrowsingContextId, GenericSender>), ++ /// Record the target name of a browsing context, so that named lookups can be ++ /// answered for every script thread. ++ /// ++ SetBrowsingContextName(BrowsingContextId, String), ++ /// Find a browsing context by target name, starting from the given browsing ++ /// context: its subtree, then its whole tree, then the other top-level browsing ++ /// contexts of its group that it is familiar with. ++ /// ++ FindBrowsingContextByName( ++ BrowsingContextId, ++ String, ++ GenericSender>, ++ ), ++ /// Navigate a browsing context whose active document lives in another script ++ /// thread, as when a link or `window.open` targets it by name. ++ LoadUrlInBrowsingContext(BrowsingContextId, LoadData, NavigationHistoryBehavior), + /// Get the browsing context id of the browsing context in which pipeline is + /// embedded and the parent pipeline id of that browsing context. + GetBrowsingContextInfo( +diff --git a/components/shared/script/lib.rs b/components/shared/script/lib.rs +index fb261c869a..de3283c086 100644 +--- a/components/shared/script/lib.rs ++++ b/components/shared/script/lib.rs +@@ -73,6 +73,12 @@ pub struct NewPipelineInfo { + pub webview_id: WebViewId, + /// Id of the opener, if any + pub opener: Option, ++ /// The browsing context's target name. A script thread that meets this ++ /// browsing context for the first time creates its `WindowProxy` with the ++ /// name the constellation already holds, so a top-level navigation into a ++ /// new event loop does not lose it. ++ /// ++ pub browsing_context_name: String, + /// Network request data which will be initiated by the script thread. + pub load_data: LoadData, + /// Initial [`ViewportDetails`] for this layout. +diff --git a/tests/wpt/include.ini b/tests/wpt/include.ini +index 52df12db83..23b52e1613 100644 +--- a/tests/wpt/include.ini ++++ b/tests/wpt/include.ini +@@ -334,6 +334,8 @@ skip: true + skip: true + [interfaces] + skip: false ++[web-locks] ++ skip: false + [webaudio] + skip: false + [WebCryptoAPI] +diff --git a/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-noopener.html.ini b/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-noopener.html.ini +deleted file mode 100644 +index 6907354e04..0000000000 +--- a/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-noopener.html.ini ++++ /dev/null +@@ -1,7 +0,0 @@ +-[named-lookup-noopener.html] +- expected: TIMEOUT +- [Two noopener window.open() calls create separate windows] +- expected: TIMEOUT +- +- [Two rel=noopener clicks create separate windows] +- expected: NOTRUN +diff --git a/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-scoped-to-browsing-context-group.html.ini b/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-scoped-to-browsing-context-group.html.ini +deleted file mode 100644 +index d5ad70e64c..0000000000 +--- a/tests/wpt/meta/html/browsers/windows/auxiliary-browsing-contexts/named-lookup-scoped-to-browsing-context-group.html.ini ++++ /dev/null +@@ -1,3 +0,0 @@ +-[named-lookup-scoped-to-browsing-context-group.html] +- [named lookup scoped to browsing context group] +- expected: FAIL +diff --git a/tests/wpt/meta/html/browsers/windows/browsing-context-names/duplicate-name-order.html.ini b/tests/wpt/meta/html/browsers/windows/browsing-context-names/duplicate-name-order.html.ini +deleted file mode 100644 +index cb51164ac9..0000000000 +--- a/tests/wpt/meta/html/browsers/windows/browsing-context-names/duplicate-name-order.html.ini ++++ /dev/null +@@ -1,3 +0,0 @@ +-[duplicate-name-order.html] +- [Duplicate name lookup order] +- expected: FAIL +diff --git a/tests/wpt/meta/html/browsers/windows/targeting-cross-origin-nested-browsing-contexts.html.ini b/tests/wpt/meta/html/browsers/windows/targeting-cross-origin-nested-browsing-contexts.html.ini +deleted file mode 100644 +index 602d82d9c1..0000000000 +--- a/tests/wpt/meta/html/browsers/windows/targeting-cross-origin-nested-browsing-contexts.html.ini ++++ /dev/null +@@ -1,4 +0,0 @@ +-[targeting-cross-origin-nested-browsing-contexts.html] +- expected: TIMEOUT +- [Targeting nested browsing contexts] +- expected: TIMEOUT +diff --git a/tests/wpt/meta/web-locks/acquire.https.any.js.ini b/tests/wpt/meta/web-locks/acquire.https.any.js.ini +new file mode 100644 +index 0000000000..2980b3c91c +--- /dev/null ++++ b/tests/wpt/meta/web-locks/acquire.https.any.js.ini +@@ -0,0 +1,2 @@ ++[acquire.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/bfcache/contention.https.window.js.ini b/tests/wpt/meta/web-locks/bfcache/contention.https.window.js.ini +new file mode 100644 +index 0000000000..69b050cfa1 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/bfcache/contention.https.window.js.ini +@@ -0,0 +1,50 @@ ++[contention.https.window.html?context=document&contention=query] ++ [A held lock on the main thread should cause eviction on navigator.locks.query()] ++ expected: FAIL ++ ++[contention.https.window.html?context=document&contention=request] ++ [A held lock on the main thread should cause eviction on navigator.locks.request()] ++ expected: FAIL ++ ++[contention.https.window.html?context=document&contention=request-if-available] ++ [A held lock on the main thread should cause eviction on navigator.locks.request() with ifAvailable: true] ++ expected: FAIL ++ ++[contention.https.window.html?context=nested-worker&contention=query] ++ [A held lock on a nested worker should cause eviction on navigator.locks.query()] ++ expected: FAIL ++ ++[contention.https.window.html?context=nested-worker&contention=request] ++ expected: TIMEOUT ++ [A held lock on a nested worker should cause eviction on navigator.locks.request()] ++ expected: TIMEOUT ++ ++[contention.https.window.html?context=nested-worker&contention=request-if-available] ++ [A held lock on a nested worker should cause eviction on navigator.locks.request() with ifAvailable: true] ++ expected: FAIL ++ ++[contention.https.window.html?context=shared-worker&contention=query] ++ [A held lock on a shared worker should cause eviction on navigator.locks.query()] ++ expected: FAIL ++ ++[contention.https.window.html?context=shared-worker&contention=request] ++ expected: TIMEOUT ++ [A held lock on a shared worker should cause eviction on navigator.locks.request()] ++ expected: TIMEOUT ++ ++[contention.https.window.html?context=shared-worker&contention=request-if-available] ++ [A held lock on a shared worker should cause eviction on navigator.locks.request() with ifAvailable: true] ++ expected: FAIL ++ ++[contention.https.window.html?context=worker&contention=query] ++ [A held lock on a worker should cause eviction on navigator.locks.query()] ++ expected: FAIL ++ ++[contention.https.window.html?context=worker&contention=request] ++ expected: TIMEOUT ++ [A held lock on a worker should cause eviction on navigator.locks.request()] ++ expected: TIMEOUT ++ ++[contention.https.window.html?context=worker&contention=request-if-available] ++ [A held lock on a worker should cause eviction on navigator.locks.request() with ifAvailable: true] ++ expected: FAIL +diff --git a/tests/wpt/meta/web-locks/clientids.https.html.ini b/tests/wpt/meta/web-locks/clientids.https.html.ini +new file mode 100644 +index 0000000000..b8c12771e1 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/clientids.https.html.ini +@@ -0,0 +1,3 @@ ++[clientids.https.html] ++ [Client IDs match between Locks API and Service Workers] ++ expected: FAIL +diff --git a/tests/wpt/meta/web-locks/held.https.any.js.ini b/tests/wpt/meta/web-locks/held.https.any.js.ini +new file mode 100644 +index 0000000000..dde55f4318 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/held.https.any.js.ini +@@ -0,0 +1,2 @@ ++[held.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/idlharness.https.any.js.ini b/tests/wpt/meta/web-locks/idlharness.https.any.js.ini +new file mode 100644 +index 0000000000..7a1c5661da +--- /dev/null ++++ b/tests/wpt/meta/web-locks/idlharness.https.any.js.ini +@@ -0,0 +1,2 @@ ++[idlharness.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/ifAvailable.https.any.js.ini b/tests/wpt/meta/web-locks/ifAvailable.https.any.js.ini +new file mode 100644 +index 0000000000..e850533e95 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/ifAvailable.https.any.js.ini +@@ -0,0 +1,2 @@ ++[ifAvailable.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/lock-attributes.https.any.js.ini b/tests/wpt/meta/web-locks/lock-attributes.https.any.js.ini +new file mode 100644 +index 0000000000..f5ad52be70 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/lock-attributes.https.any.js.ini +@@ -0,0 +1,2 @@ ++[lock-attributes.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/mode-exclusive.https.any.js.ini b/tests/wpt/meta/web-locks/mode-exclusive.https.any.js.ini +new file mode 100644 +index 0000000000..99d3636877 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/mode-exclusive.https.any.js.ini +@@ -0,0 +1,2 @@ ++[mode-exclusive.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/mode-mixed.https.any.js.ini b/tests/wpt/meta/web-locks/mode-mixed.https.any.js.ini +new file mode 100644 +index 0000000000..d67be3ea3c +--- /dev/null ++++ b/tests/wpt/meta/web-locks/mode-mixed.https.any.js.ini +@@ -0,0 +1,2 @@ ++[mode-mixed.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/mode-shared.https.any.js.ini b/tests/wpt/meta/web-locks/mode-shared.https.any.js.ini +new file mode 100644 +index 0000000000..0ee21e65a5 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/mode-shared.https.any.js.ini +@@ -0,0 +1,2 @@ ++[mode-shared.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/partitioned-web-locks.tentative.https.html.ini b/tests/wpt/meta/web-locks/partitioned-web-locks.tentative.https.html.ini +new file mode 100644 +index 0000000000..865466733f +--- /dev/null ++++ b/tests/wpt/meta/web-locks/partitioned-web-locks.tentative.https.html.ini +@@ -0,0 +1,6 @@ ++[partitioned-web-locks.tentative.https.html] ++ [WebLocks of an iframe under a 3rd-party site are partitioned] ++ expected: FAIL ++ ++ [WebLocks of a nested iframe with a cross-site ancestor are partitioned] ++ expected: FAIL +diff --git a/tests/wpt/meta/web-locks/query-empty.https.any.js.ini b/tests/wpt/meta/web-locks/query-empty.https.any.js.ini +new file mode 100644 +index 0000000000..58bac38d9c +--- /dev/null ++++ b/tests/wpt/meta/web-locks/query-empty.https.any.js.ini +@@ -0,0 +1,2 @@ ++[query-empty.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/resource-names.https.any.js.ini b/tests/wpt/meta/web-locks/resource-names.https.any.js.ini +new file mode 100644 +index 0000000000..1fe7b35466 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/resource-names.https.any.js.ini +@@ -0,0 +1,41 @@ ++[resource-names.https.any.html] ++ [DOMString: 0xD800] ++ expected: FAIL ++ ++ [DOMString: 0xDC00] ++ expected: FAIL ++ ++ [DOMString: 0xDC00 0xD800] ++ expected: FAIL ++ ++ [Resource names that are not valid UTF-16 are not mangled] ++ expected: FAIL ++ ++[resource-names.https.any.serviceworker.html] ++ expected: ERROR ++ ++[resource-names.https.any.sharedworker.html] ++ [DOMString: 0xD800] ++ expected: FAIL ++ ++ [DOMString: 0xDC00] ++ expected: FAIL ++ ++ [DOMString: 0xDC00 0xD800] ++ expected: FAIL ++ ++ [Resource names that are not valid UTF-16 are not mangled] ++ expected: FAIL ++ ++[resource-names.https.any.worker.html] ++ [DOMString: 0xD800] ++ expected: FAIL ++ ++ [DOMString: 0xDC00] ++ expected: FAIL ++ ++ [DOMString: 0xDC00 0xD800] ++ expected: FAIL ++ ++ [Resource names that are not valid UTF-16 are not mangled] ++ expected: FAIL +diff --git a/tests/wpt/meta/web-locks/secure-context.https.any.js.ini b/tests/wpt/meta/web-locks/secure-context.https.any.js.ini +new file mode 100644 +index 0000000000..a53c75fba9 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/secure-context.https.any.js.ini +@@ -0,0 +1,2 @@ ++[secure-context.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/signal.https.any.js.ini b/tests/wpt/meta/web-locks/signal.https.any.js.ini +new file mode 100644 +index 0000000000..9f09db9c74 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/signal.https.any.js.ini +@@ -0,0 +1,2 @@ ++[signal.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/steal.https.any.js.ini b/tests/wpt/meta/web-locks/steal.https.any.js.ini +new file mode 100644 +index 0000000000..f2361a576e +--- /dev/null ++++ b/tests/wpt/meta/web-locks/steal.https.any.js.ini +@@ -0,0 +1,2 @@ ++[steal.https.any.serviceworker.html] ++ expected: ERROR +diff --git a/tests/wpt/meta/web-locks/storage-buckets.tentative.https.any.js.ini b/tests/wpt/meta/web-locks/storage-buckets.tentative.https.any.js.ini +new file mode 100644 +index 0000000000..191f8b5aa4 +--- /dev/null ++++ b/tests/wpt/meta/web-locks/storage-buckets.tentative.https.any.js.ini +@@ -0,0 +1,14 @@ ++[storage-buckets.tentative.https.any.html] ++ [Storage buckets have independent locks] ++ expected: FAIL ++ ++[storage-buckets.tentative.https.any.serviceworker.html] ++ expected: ERROR ++ ++[storage-buckets.tentative.https.any.sharedworker.html] ++ [Storage buckets have independent locks] ++ expected: FAIL ++ ++[storage-buckets.tentative.https.any.worker.html] ++ [Storage buckets have independent locks] ++ expected: FAIL diff --git a/patches/servo/FORK.md b/patches/servo/FORK.md index cdaf56f..d8ec49e 100644 --- a/patches/servo/FORK.md +++ b/patches/servo/FORK.md @@ -12,8 +12,8 @@ Tauri the application window system. | Upstream tag | `refs/tags/v0.5.0` = `1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019` | | Previous-release merge base | `b5675b1bc38498a26530b27e578122a8068af3b6` | | Fork branch | `Travis-Gilbert/servo:theorem/v0.5.0` | -| Current pin | `b70d4e64c0005d5dc2d5257c09f997dba235410a` | -| Relationship | `ahead_by=20`, `behind_by=0` against upstream `v0.5.0` | +| Current pin | `e92cdaa790797479c1821c33470c64e0d166feb2` | +| Relationship | `ahead_by=56`, `behind_by=0` against upstream `v0.5.0` | | Rust channel | `1.95.0`, identical to upstream `v0.5.0` and Turvo's `rust-toolchain.toml` | | Recorded in | `integration.json` and `upstream-base` | | Enforced by | `.github/workflows/servo-integration.yml` | @@ -43,9 +43,14 @@ invalidate receipts bound to the old SHA. | `e2a2d5e575` | Make web-resource responders sendable | Local embedder seam | | `65d71b0bfe` | Escape keyword-named Promise wrapper methods | Candidate upstream generator fix | | `4182b51681` through `b70d4e64c0` | Repair and verify the v0.5 carry against current module, media, sandbox, and lifecycle APIs | Rebase adaptation | +| `b5fead2675` through `6ed6091e4e` | IndexedDB index records, cursor iteration, the getAll family, `IDBRecord`, transaction rollback, and reported backend failures in place of panics | Local engine work, accepted independently at `6ed6091e4e` against the IndexedDB web-platform suite | +| `fec9c84f8f` through `e92cdaa790` | Web Locks, shared-worker teardown, and named browsing-context lookup through the constellation | Local engine work, accepted independently at `e92cdaa790` against the Web Locks and window-proxy suites | -The versioned patch files retain Turvo's seven original engine commits for -digest and reverse-application checks. The branch is authoritative when those +The versioned patch files retain Turvo's seven original engine commits plus +one squashed file per independently verified slice, nine in all, for digest +and reverse-application checks. Reversing the nine in order at the current pin +reconstructs the same tree reversing the seven produced at the previous pin, +which is what makes the two additions a decomposition rather than a rewrite. The branch is authoritative when those patch artifacts and the exact pin disagree. ## Rules diff --git a/patches/servo/README.md b/patches/servo/README.md index d3dfc92..3bff966 100644 --- a/patches/servo/README.md +++ b/patches/servo/README.md @@ -21,9 +21,20 @@ to resolve in that workspace without two crates claiming `links = "jemalloc"`. The seventh patch makes the public web-resource response handle `Send`, so a bounded Turvo interceptor can finish Servo-owned responses from its worker thread without an unsafe wrapper or a duplicate response path. +The eighth patch is the IndexedDB conformance slice: index records and cursor +iteration, the getAll family and `IDBRecord`, transaction rollback, key +generation where the operation runs, and the removal of the backend's panics +in favour of reported failures. It squashes thirty-three commits and is the +exact range an independent verifier accepted at `6ed6091e4e`. +The ninth patch adds the Web Locks API, ends shared workers when their owner +set empties, and finds named browsing contexts through the constellation. It +squashes three commits and is the range the same verifier accepted at +`e92cdaa790`. The current public revision is -`b70d4e64c0005d5dc2d5257c09f997dba235410a`, 20 commits ahead of and zero -commits behind upstream `v0.5.0`. +`e92cdaa790797479c1821c33470c64e0d166feb2`, 56 commits ahead of and zero +commits behind upstream `v0.5.0`. The two newest patch files each cover one +independently verified slice, so the nine files still reverse-apply in order +to the same tree the seven produced at the previous pin. The request interceptor replaces only HTTP transport, after request policy selection and before normal response processing. CSP, CORS/preflight, redirects, diff --git a/patches/servo/integration.json b/patches/servo/integration.json index cc49d86..a01a727 100644 --- a/patches/servo/integration.json +++ b/patches/servo/integration.json @@ -2,9 +2,9 @@ "upstream_repository": "servo/servo", "base_revision": "1d44e5dd6a8b64c02f9dbf7fcbdf4ebdd0740019", "repository": "Travis-Gilbert/servo", - "revision": "b70d4e64c0005d5dc2d5257c09f997dba235410a", + "revision": "e92cdaa790797479c1821c33470c64e0d166feb2", "branch": "theorem/v0.5.0", - "ahead_by": 20, + "ahead_by": 56, "behind_by": 0, "rust_channel": "1.95.0", "patches": [ @@ -35,6 +35,14 @@ { "path": "0007-sendable-web-resource-responders.patch", "sha256": "6e77b01e86c7a17174cfa48210ba2e9bdd7d3f535df40945d30377ff968e6762" + }, + { + "path": "0008-indexeddb-conformance-slice.patch", + "sha256": "e7a0ba9fc708400410fc590e24b2036f404703169d756a4b1f987072ccd64918" + }, + { + "path": "0009-web-locks-and-window-proxies.patch", + "sha256": "ad8f67fa87395a54316e1ff3b7bcfc454b9fda8dd9b575f8d2ba26272b846cea" } ] }