diff --git a/.gitattributes b/.gitattributes index 72fdad6e73a4..cc7849849765 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,27 +40,21 @@ # these carry real CR and CRLF bytes as the terminal emitted them, and line-ending # normalisation on a Windows checkout would rewrite the evidence the fixture exists to be. /src/main/runtime/__fixtures__/*.txt -text +/src/main/daemon/__fixtures__/pty-transcripts/*.txt -text # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf # Generated method->params catalog: compared byte-for-byte by # verify:rpc-params-catalog, so a CRLF checkout would fail the gate. /src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf -# Mobile web bundle source. Every text byte here is hashed into an asset digest and -# from there into buildId, so a CRLF checkout produces a different bundle id for the -# same commit (91af2897 vs 9d78435e). The PNG is -text because it must not be touched. -/src/mobile-web/index.html text eol=lf -/src/mobile-web/src/*.ts text eol=lf -/src/mobile-web/src/*.css text eol=lf -/src/mobile-web/src/*.png -text -# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are -# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different -# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it. +# Mobile web page source. Every text byte here is hashed into an asset digest and from +# there into buildId, so a CRLF Windows checkout would ship a different bundle id for +# identical source (91af2897 vs 9d78435e, measured on the bundle this replaced). /mobile/src/** text eol=lf /mobile/app/** text eol=lf /mobile/web-entry/** text eol=lf # The blanket pin above would mark a future binary as text; exempt the asset types an -# RN page actually carries, the same way src/mobile-web exempts its PNG. +# RN page actually carries. /mobile/src/**/*.png -text /mobile/src/**/*.jpg -text /mobile/src/**/*.jpeg -text diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 30c98070f312..6aecef6e1524 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -50,8 +50,9 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false - name: Setup requested Node.js id: requested-node @@ -59,8 +60,31 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false + + # PR-local stores compete with reusable build caches for the repository quota. + - name: Resolve pnpm download store + id: pnpm-store + if: github.event_name == 'pull_request' + shell: bash + env: + LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} + run: | + test -n "$LOCKFILE_HASH" + cache_path="$(pnpm store path --silent)" + test -n "$cache_path" + printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" + + # Match setup-node's key and path so existing default-branch stores remain reusable. + - name: Restore pnpm download store without saving + if: github.event_name == 'pull_request' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} - name: Validate native runtime shell: bash diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml new file mode 100644 index 000000000000..058d31a064c7 --- /dev/null +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -0,0 +1,32 @@ +name: Prepare baseline Git compatibility binary +description: Restore or build the pinned Linux Git binary for the compatibility contract. + +runs: + using: composite + steps: + # The default-branch warmer shares this key across PRs; a PR save is private to its merge ref. + # Cache eviction remains possible, so keep the checksum-verified cold build below. + - name: Cache baseline Git build + uses: actions/cache@v5 + with: + path: ~/.cache/orca-git-compat/git-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + + # Finish the CPU-heavy build before any timed compatibility lanes start. + - name: Build the baseline Git binary + shell: bash + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$HOME/.cache/orca-git-compat/git-2.25.5" + if [ -x "$source/git" ]; then + exit 0 + fi + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + # Object files are no longer needed after linking the cached binary. + find "$source" -name '*.o' -delete diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..681fe94fbb84 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Why only ripgrep: the bundled rg ships in every artifact and to SSH remotes, and a bump is a + # one-line pin change (the SSH cache keys on the binary's hash). Other dependencies stay manual. + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + allow: + - dependency-name: '@vscode/ripgrep-universal' + open-pull-requests-limit: 1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d7a73a6b88a..326de25e1531 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,8 @@ ## Linked Issue - + + Fixes # diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml new file mode 100644 index 000000000000..8452bbfb2532 --- /dev/null +++ b/.github/workflows/bun-profile-tests.yml @@ -0,0 +1,140 @@ +name: Bun profile persistence + +on: + pull_request: + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bun-profile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_run: ${{ steps.scope.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Detect Bun build and test inputs + id: scope + shell: bash + run: | + # Compare the tested merge with its base, retaining both sides of renames. + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then + node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes" + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + + persistence: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} + - name: Build the native Windows process reader + if: runner.os == 'Windows' + run: node config/scripts/build-windows-process-tree-relay-addon.mjs + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + - uses: actions/setup-node@v6 + if: runner.arch == 'X64' + with: + node-version: '18' + - name: Verify Node 18 loads and hands off to bundled Bun + if: runner.arch == 'X64' + run: | + node out/orcad/orcad.js --orcad-smoke-load-check + node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 + + linux_glibc_floor: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + container: ubuntu:20.04 + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Install Ubuntu 20.04 prerequisites + run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Trust the checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: ./.github/actions/install-node-dependencies + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + + linux_musl: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Verify native Alpine artifact and persistence + run: | + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' + set -eu + apk add --no-cache bash git libstdc++ python3 make g++ + git config --global --add safe.directory /work + npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:orcad + pnpm test:bun:profile --artifact + BUN_QUALIFICATION diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml new file mode 100644 index 000000000000..9066dd266a36 --- /dev/null +++ b/.github/workflows/ci-cache-warmup.yml @@ -0,0 +1,65 @@ +name: Warm shared CI caches + +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-git-compatibility/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'config/tsconfig*.json' + - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/rebuild-native-deps.mjs' + - 'config/patches/node-pty@1.1.0.patch' + - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'native/windows-registry/**' + pull_request: + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/prepare-git-compatibility/**' + - 'config/scripts/ci-cache-warmup-workflow.test.mjs' + +permissions: + contents: read + +concurrency: + group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Default-branch caches can be restored by every PR; PR caches cannot. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + + - uses: ./.github/actions/prepare-git-compatibility + + - name: Cache TypeScript incremental state + id: typecheck-cache + uses: actions/cache@v5 + with: + path: config/*.tsbuildinfo + key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} + restore-keys: | + tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + + - name: Refresh TypeScript state for this commit + if: steps.typecheck-cache.outputs.cache-hit != 'true' + run: pnpm run typecheck diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 62e5ebb14264..5f594852eb5b 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -55,6 +55,8 @@ jobs: CLOUD_SQL_INSTANCE: ${{ inputs.environment == 'production' && 'orca-cloud-auth-db' || 'orca-cloud-staging-auth-db' }} VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360 VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17 + # SHOW max_connections on the live instance, 2026-09-16; no flag is set. + VERIFIED_DEFAULT_MAX_CONNECTIONS: '500' TF_BACKEND: ${{ inputs.environment == 'production' && 'backend/production.hcl' || 'backend/staging.hcl' }} TF_VARS: ${{ inputs.environment == 'production' && 'environments/production.tfvars' || 'environments/staging.tfvars' }} TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.environment == 'production' && vars.PRODUCTION_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER || vars.STAGING_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER }} @@ -73,6 +75,7 @@ jobs: case "${TARGET_ENVIRONMENT}:${TARGET_CELL_IDS}" in staging:staging-gce-c4) ;; production:production-gce-c27,production-gce-c28,production-gce-c29) ;; + production:production-gce-c30) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac [[ "${TARGET_IMAGE}" =~ ^us-central1-docker\.pkg\.dev/${GCP_PROJECT_ID}/orca-cloud/relay@sha256:[0-9a-f]{64}$ ]] @@ -118,13 +121,13 @@ jobs: live_max="${live_flag}" live_source=explicit-flag else - # The verified production database uses Cloud SQL's 400-connection - # default for this exact shape; fail closed if its shape changes. + # No flag: the ceiling is the tier default measured for this exact + # shape; fail closed if the shape changes. test "$(jq -er '.settings.tier' <<< "${instance}")" = \ "${VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER}" test "$(jq -er '.databaseVersion' <<< "${instance}")" = \ "${VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION}" - live_max=400 + live_max="${VERIFIED_DEFAULT_MAX_CONNECTIONS}" live_source=verified-shape-default fi test "${live_max}" = "${checked_max}" @@ -166,6 +169,38 @@ jobs: done echo "file=${file}" >> "${GITHUB_OUTPUT}" + - id: live-images + name: Plan every non-target cell at the image it serves + shell: bash + run: | + set -euo pipefail + # The URL map target pulls every cell's template into the plan, and same-cap rolls + # leave committed images behind the served ones; read only templates out of state. + cells="${RUNNER_TEMP}/relay-committed-cells.json" + live="${RUNNER_TEMP}/relay-live-cell-templates.json" + overlay="${RUNNER_TEMP}/relay-asia-live-images.tfvars.json" + committed_plan="${RUNNER_TEMP}/relay-committed-cells.tfplan" + mapfile -t targets < "${{ steps.targets.outputs.file }}" + # Not console: it evaluates every output against state, where a new cell has no MIG yet. + terraform -chdir=infra/terraform plan -input=false -refresh=false -lock=false \ + -var-file="${TF_VARS}" "${targets[@]}" -out="${committed_plan}" > /dev/null + terraform -chdir=infra/terraform show -json "${committed_plan}" \ + | jq -ce '.variables.relay_gce_cells.value | objects' > "${cells}" + terraform -chdir=infra/terraform show -json | jq -ce '[ + .values.root_module.resources[]? + | select(.mode == "managed" and + .type == "google_compute_instance_template" and .name == "relay_gce_cell") + | { index, metadata_startup_script: .values.metadata_startup_script } + ]' > "${live}" + summary="$(node dev/scripts/relay-live-cell-image-overlay.mjs \ + --cells-json "${cells}" --live-templates-json "${live}" \ + --cell-ids "${TARGET_CELL_IDS}" --output "${overlay}")" + echo "file=${overlay}" >> "${GITHUB_OUTPUT}" + { + echo "### Live images held for non-target cells" + echo "- Cells whose committed image differs from the served one: $(jq -r '.drifted | join(", ")' <<< "${summary}")" + } >> "${GITHUB_STEP_SUMMARY}" + - name: Create and validate the saved topology plan id: plan shell: bash @@ -175,7 +210,7 @@ jobs: plan_json="${RUNNER_TEMP}/relay-asia-topology.json" mapfile -t targets < "${{ steps.targets.outputs.file }}" terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \ - -var-file="${TF_VARS}" \ + -var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \ "${targets[@]}" -out="${plan}" terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}" committed="${RUNNER_TEMP}/relay-committed-asia-topology.json" @@ -221,7 +256,7 @@ jobs: plan="${RUNNER_TEMP}/relay-asia-topology-readback.tfplan" plan_json="${RUNNER_TEMP}/relay-asia-topology-readback.json" terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \ - -var-file="${TF_VARS}" \ + -var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \ "${targets[@]}" -out="${plan}" terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}" result="$(node dev/scripts/validate-relay-asia-topology-plan.mjs \ diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 617c852d304e..78b1099744e4 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -57,9 +57,14 @@ jobs: GATE_OVERRIDE_REASON: ${{ inputs.gate-override-reason }} GATE_OVERRIDE_CONFIRMATION: ${{ inputs.gate-override-confirmation }} OUTPUT_DIRECTORY: ${{ github.workspace }}/relay-monitor-evidence - # ~800 controls over 2 min is ~7 re-dials/s per cell, well under the director's - # 5 x 80 in-flight assign cap. A cell on an older image ignores it and drains at once. - DRAIN_PACE_WINDOW_MS: '120000' + # Each drained host re-dials /v1/assign and is re-placed through the director's + # single-slot sticky lane, which clears roughly 8 hosts/s fleet-wide. A 2,100-host + # cell over 2 min (~18/s) overran it on 2026-09-23 and browned out assign for every + # region for ~5 min. 5 min is the cell's maximum window (DRAIN_PACE_WINDOW_MAX_MS); a + # ~2,700-host cell still arrives at ~9/s, so expect a short tail, not zero rejects. + # The transition wait below is the 15-min migration lease plus this window. A cell on + # an older image drains at once. + DRAIN_PACE_WINDOW_MS: '300000' steps: - name: Require exact reusable-workflow configuration working-directory: . @@ -78,7 +83,8 @@ jobs: [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] [[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] - [[ "${WAVE_INDEX}" =~ ^[0-3]$ ]] + # cell_1..cell_10 in the calling wave; the chain is static, so this range is too. + [[ "${WAVE_INDEX}" =~ ^[0-9]$ ]] # The caller validated this too; re-check here so a malformed override # can never reach a mutation through this reusable workflow. if test -n "${GATE_OVERRIDE_REASON}${GATE_OVERRIDE_CONFIRMATION}"; then @@ -272,7 +278,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29) + c27|c28|c29|c30) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 @@ -537,9 +543,12 @@ jobs: - name: Reversibly isolate and drain only the selected cell if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }} + id: drain env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }} run: | + # Opens the window the report-only shadow health gate below judges this cell over. + echo "drain-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" echo "MUTATION_STARTED=true" >> "${GITHUB_ENV}" # A cell isolated by a failed canary is already migration-only, so # isolate is a no-op there that does not advance the selector; the @@ -553,6 +562,10 @@ jobs: if test "${ENTRY_ADMISSION}" = migration-only; then jq -e '.changed == false' <<< "${ISOLATE_RESULT}" >/dev/null fi + # The director re-places hosts off a cell only when the isolate stamped it, so a + # script too old to ask for the stamp produces today's behaviour and the canary + # reads as "the fix did nothing" with nothing to tell that from a wrong premise. + jq -e '.rollIsolated == true' <<< "${ISOLATE_RESULT}" >/dev/null ISOLATE_GENERATION="$(jq -er '.generation' <<< "${ISOLATE_RESULT}")" echo "SELECTOR_GENERATION_AFTER_ISOLATE=${ISOLATE_GENERATION}" >> "${GITHUB_ENV}" node dev/scripts/prepare-relay-production-capacity-canary.mjs \ @@ -566,7 +579,7 @@ jobs: --unobserved-bound "${EXPECTED_UNOBSERVED_BOUND}" \ --heartbeat either --admission migration-only --draining required \ --activity restart-safe --expected-image-digests "${PREDECESSOR_IMAGE_DIGEST}" \ - --timeout-ms 1020000 + --timeout-ms 1200000 - id: capacity-auth if: ${{ inputs.mode != 'verify' }} @@ -590,6 +603,10 @@ jobs: # Zero resource changes prove the prior run's apply completed and no # restart will follow, keeping the incarnation check honest. Root # outputs may lag a targeted apply, so judge resource_changes only. + # The cell backend service is deliberately not targeted: the capacity role has no + # compute.backendServices.update, so naming it fails the apply after the MIG has + # rolled. Its drain and log_config settings ship in a root apply by a principal + # that holds the permission. terraform -chdir=infra/terraform plan \ -var-file=environments/production.tfvars \ -var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \ @@ -617,7 +634,7 @@ jobs: | select(.change.actions | any(. != "no-op" and . != "read")) | .address] | join(","))' echo 'requiring reviewed rollback-image drift' - terraform -chdir=infra/terraform show -json \ + RESUME_REVIEW="$(terraform -chdir=infra/terraform show -json \ "${RUNNER_TEMP}/relay-same-cap-resume.tfplan" \ | node dev/scripts/validate-relay-capacity-plan.mjs \ --mode same-cap-cell --cell-id "${TARGET_CELL_ID}" \ @@ -629,14 +646,16 @@ jobs: --rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ --rehome-audience https://relay.onorca.dev/v1/admin/host-drain \ --regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \ - "${POOL_ARGUMENTS[@]}" \ - | jq -e '.changes == 2' >/dev/null + "${POOL_ARGUMENTS[@]}")" + echo "${RESUME_REVIEW}" + jq -e '.changes == 2' <<< "${RESUME_REVIEW}" >/dev/null fi gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ --project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900 - name: Apply only the selected same-cap template and MIG if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }} + id: apply shell: bash env: CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }} @@ -666,6 +685,10 @@ jobs: --regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \ "${POOL_ARGUMENTS[@]}")" echo "${PLAN_REVIEW}" + # Stamped before the apply, not after it: the new container announces its listener while + # the MIG is still converging, so a bound taken at the end of this step is already past + # the announcement the shadow gate looks for. + echo "apply-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" terraform -chdir=infra/terraform apply -auto-approve \ "${RUNNER_TEMP}/relay-same-cap.tfplan" gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ @@ -684,6 +707,9 @@ jobs: gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ --project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900 fi + # Recorded for the operator comparing verdicts; the gate's boot search opens at the + # apply-started-at stamp above, not here. + echo "apply-completed-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" - id: post-auth if: ${{ inputs.mode != 'verify' }} @@ -697,6 +723,7 @@ jobs: - name: Verify new incarnation, exact image, protocol, and durable safety if: ${{ inputs.mode != 'verify' }} + id: verify-target env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | @@ -748,6 +775,7 @@ jobs: --expected-general-cells "${ISOLATED_GENERAL_CELLS}" \ --expected-control-generation "${EXPECTED_REHOME_GENERATION}" \ | jq -e '.control.enabled == false' >/dev/null + echo "verify-ended-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" - name: Prove exact per-host trust and idempotent no-neighbor behavior if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }} @@ -794,6 +822,63 @@ jobs: --expected-control-generation "${EXPECTED_REHOME_GENERATION}" \ | jq -e '.control.enabled == false' >/dev/null + # Report only: this evaluates the oracles an operator reads by hand after a cell and records + # what it would have called, so its verdicts can be compared with the operator's over a full + # roll before it is ever allowed to block. Two independent guarantees keep it inert: the + # script exits 0 on every verdict, and continue-on-error keeps even a crash off the job's + # outcome. The failsafe below therefore cannot fire on anything this step observes. + # + # It runs after the restore, not before it, for two reasons: the cell goes back into + # admission on exactly today's schedule rather than waiting out a minute of log reads, and + # the window it judges has closed by then, so Cloud Logging's ingestion lag is behind it. + # These are fleet oracles anyway; when this does gate, what it gates is the next cell. + - name: Shadow health gate (report only) + id: shadow-gate + if: ${{ inputs.mode != 'verify' }} + continue-on-error: true + # continue-on-error bounds this step's contribution to the job outcome, not its clock, + # and its reads are serialised. A timed-out step is a failed step, which continue-on-error + # absorbs; without this bound a Logging 429 storm or an expired credential makes every + # read cost its full retry budget and can push the job past timeout-minutes, and a + # cancelled job takes the whole wave with it. A healthy gate is already minutes of + # serial reads, so both bounds sit above that: the script settles at seven minutes and + # reaching this eight is the pathological case. Eight on top of a ~14-minute cell still + # leaves the job's 75 minutes intact. + timeout-minutes: 8 + env: + DRAIN_STARTED_AT: ${{ steps.drain.outputs.drain-started-at }} + APPLY_STARTED_AT: ${{ steps.apply.outputs.apply-started-at }} + APPLY_COMPLETED_AT: ${{ steps.apply.outputs.apply-completed-at }} + VERIFY_ENDED_AT: ${{ steps.verify-target.outputs.verify-ended-at }} + SHADOW_GATE_DIRECTORY: ${{ runner.temp }}/relay-same-cap-shadow-gate + SHADOW_GATE_NAME: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json + run: | + mkdir -p "${SHADOW_GATE_DIRECTORY}" + node dev/scripts/relay-same-cap-shadow-gate.mjs \ + --cell-id "${TARGET_CELL_ID}" \ + --cell-host "${TARGET_HOSTNAME}.relay.onorca.dev" \ + --project-id "${GCP_PROJECT_ID}" \ + --director-service orca-cloud-relay \ + --drain-started-at "${DRAIN_STARTED_AT}" \ + --apply-started-at "${APPLY_STARTED_AT}" \ + --apply-completed-at "${APPLY_COMPLETED_AT}" \ + --verify-ended-at "${VERIFY_ENDED_AT}" \ + --summary-file "${GITHUB_STEP_SUMMARY}" \ + --output-file "${SHADOW_GATE_DIRECTORY}/${SHADOW_GATE_NAME}" + + - name: Publish the shadow health gate verdict + if: ${{ inputs.mode != 'verify' }} + continue-on-error: true + # One small JSON file; a retrying upload must not spend the wave's remaining minutes either. + timeout-minutes: 2 + uses: actions/upload-artifact@v4 + with: + name: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json + path: ${{ runner.temp }}/relay-same-cap-shadow-gate + if-no-files-found: warn + retention-days: 14 + overwrite: true + - id: cleanup-auth if: ${{ failure() && inputs.mode != 'verify' }} uses: google-github-actions/auth@v2 @@ -815,6 +900,9 @@ jobs: --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode isolate)" echo "${ISOLATE_RESULT}" + # Same reason as the isolate step above: a failed wave leaves this cell isolated + # deliberately, and the stamp is what lets its hosts leave. + jq -e '.rollIsolated == true' <<< "${ISOLATE_RESULT}" >/dev/null # The isolate result carries the authoritative post-isolate generation; # fixed offsets are wrong whenever an earlier isolate was a no-op. FAILSAFE_GENERATION="$(jq -er '.generation' <<< "${ISOLATE_RESULT}")" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index 421fa080072e..cd24792cc8cd 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -10,7 +10,7 @@ on: type: choice options: [verify, canary-apply, batch-apply, rollback] cell-ids: - description: Ordered comma-separated serving cells; one canary or two to four batch cells + description: Ordered comma-separated serving cells; one canary or two to ten batch cells required: true type: string target-image-digest: @@ -313,6 +313,144 @@ jobs: wave-index: '3' secrets: inherit + cell_5: + if: ${{ needs.cell_4.result == 'success' && fromJSON(needs.gate.outputs.cells)[4] != null }} + needs: [gate, cell_4] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[4] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '4' + secrets: inherit + + cell_6: + if: ${{ needs.cell_5.result == 'success' && fromJSON(needs.gate.outputs.cells)[5] != null }} + needs: [gate, cell_5] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[5] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '5' + secrets: inherit + + cell_7: + if: ${{ needs.cell_6.result == 'success' && fromJSON(needs.gate.outputs.cells)[6] != null }} + needs: [gate, cell_6] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[6] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '6' + secrets: inherit + + cell_8: + if: ${{ needs.cell_7.result == 'success' && fromJSON(needs.gate.outputs.cells)[7] != null }} + needs: [gate, cell_7] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[7] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '7' + secrets: inherit + + cell_9: + if: ${{ needs.cell_8.result == 'success' && fromJSON(needs.gate.outputs.cells)[8] != null }} + needs: [gate, cell_8] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[8] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '8' + secrets: inherit + + cell_10: + if: ${{ needs.cell_9.result == 'success' && fromJSON(needs.gate.outputs.cells)[9] != null }} + needs: [gate, cell_9] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[9] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '9' + secrets: inherit + seal_canary: if: ${{ inputs.mode == 'canary-apply' }} needs: [gate, cell_1] @@ -360,6 +498,12 @@ jobs: - cell_2 - cell_3 - cell_4 + - cell_5 + - cell_6 + - cell_7 + - cell_8 + - cell_9 + - cell_10 - seal_canary runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 10 diff --git a/.github/workflows/cloud-operate-relay-asia-admission.yml b/.github/workflows/cloud-operate-relay-asia-admission.yml index 7abae2f8646b..2c7c289da268 100644 --- a/.github/workflows/cloud-operate-relay-asia-admission.yml +++ b/.github/workflows/cloud-operate-relay-asia-admission.yml @@ -39,11 +39,11 @@ on: required: false type: string evidence-run-id: - description: Successful staging or C27 evidence workflow run ID; required for production promotion + description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30 takes none and proves itself by its own canary required: false type: string evidence-run-attempt: - description: Exact evidence workflow run attempt; required for production promotion + description: Exact evidence workflow run attempt; required with an evidence run ID required: false type: string confirmation: @@ -104,6 +104,8 @@ jobs: test -n "${DEPLOY_SERVICE_ACCOUNT}" [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] evidence_kind=none + canary_cell=none + artifact_name=none if test "${OPERATION_MODE}" = inspect; then test -z "${EXPECTED_SELECTOR_GENERATION}" test -z "${SELECTOR_ATTEMPT_ID}" @@ -143,28 +145,42 @@ jobs: esac fi if test "${TARGET_ENVIRONMENT}:${OPERATION_MODE}" = production:promote; then - [[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]] - [[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]] case "${TARGET_CELL_IDS}" in production-gce-c27) - test "${#SELECTOR_ATTEMPT_ID}" -le 119 evidence_kind=staging artifact_name="relay-asia-staging-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" + canary_cell=production-gce-c27 ;; production-gce-c28,production-gce-c29) evidence_kind=c27 artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" ;; + production-gce-c30) + # No earlier proof binds C30's generation; its own canary below rolls it back on failure. + canary_cell=production-gce-c30 + ;; *) echo "production promotion wave is not reviewed" >&2; exit 1 ;; esac - else + fi + if test "${evidence_kind}" = none; then test -z "${EVIDENCE_RUN_ID}" test -z "${EVIDENCE_RUN_ATTEMPT}" - artifact_name=none + else + [[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]] + [[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]] + fi + canary=false + if test "${canary_cell}" != none; then + canary=true + # Leaves room for the rollback attempt's -rollback suffix within 128 characters. + test "${#SELECTOR_ATTEMPT_ID}" -le 119 fi { echo "evidence_kind=${evidence_kind}" echo "artifact_name=${artifact_name}" + echo "canary=${canary}" + echo "canary_cell=${canary_cell}" + echo "canary_hostname=${canary_cell##*-}" } >> "${GITHUB_OUTPUT}" - uses: actions/setup-node@v4 @@ -174,14 +190,14 @@ jobs: - uses: pnpm/action-setup@v4 with: package_json_file: cloud/package.json - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + if: ${{ steps.inputs.outputs.canary == 'true' }} - - name: Install exact C27 canary dependencies - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Install exact canary dependencies + if: ${{ steps.inputs.outputs.canary == 'true' }} run: pnpm install --frozen-lockfile - - name: Build the C27 canary Relay contract - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Build the canary Relay contract + if: ${{ steps.inputs.outputs.canary == 'true' }} run: pnpm --filter @orca-cloud/relay-contract build - name: Download immutable rollout evidence @@ -249,7 +265,7 @@ jobs: with: bucket: ${{ inputs.environment == 'production' && 'onorca-cloud-terraform-state' || 'onorca-cloud-staging-terraform-state' }} object: ${{ inputs.environment == 'production' && 'terraform/state/cloud-sql-rollout/production.lock' || 'terraform/state/cloud-sql-rollout/staging.lock' }} - if: ${{ inputs.mode == 'configure' || (inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27') }} + if: ${{ inputs.mode == 'configure' || steps.inputs.outputs.canary == 'true' }} - uses: hashicorp/setup-terraform@v3 if: ${{ inputs.mode == 'configure' }} @@ -292,33 +308,45 @@ jobs: fi } >> "${GITHUB_STEP_SUMMARY}" - - name: Verify C27 state and start the timed canary - id: c27-start - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Verify the canary cell state and start the timed canary + id: canary-start + if: ${{ steps.inputs.outputs.canary == 'true' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} shell: bash run: | set -euo pipefail + # C30's launch cells were checked general by the promotion; verify reads only C30's digest. + case "${CANARY_CELL}" in + production-gce-c27) + verify_cells=production-gce-c27,production-gce-c28,production-gce-c29 + expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}' + ;; + production-gce-c30) + verify_cells=production-gce-c30 + expected_states='{"production-gce-c30":"general"}' + ;; + *) exit 1 ;; + esac result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode verify \ - --cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \ + --cell-ids "${verify_cells}" \ --expected-generation "${{ steps.admission-operation.outputs.generation }}" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general - test "$(jq -r '.states["production-gce-c28"]' <<< "${result}")" = migration-only - test "$(jq -r '.states["production-gce-c29"]' <<< "${result}")" = migration-only + test "$(jq -cS '.states' <<< "${result}")" = "$(jq -cS '.' <<< "${expected_states}")" + echo "verify_cells=${verify_cells}" >> "${GITHUB_OUTPUT}" echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" - - name: Run a real five-minute C27 control and splice canary - id: c27-load - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Run a real five-minute canary control and splice + id: canary-load + if: ${{ steps.inputs.outputs.canary == 'true' }} shell: bash run: | set -euo pipefail - log="${RUNNER_TEMP}/relay-asia-c27-load.jsonl" - report="${RUNNER_TEMP}/relay-asia-c27-load.json" + log="${RUNNER_TEMP}/relay-asia-canary-load.jsonl" + report="${RUNNER_TEMP}/relay-asia-canary-load.json" node dev/scripts/load-relay-controls.mjs \ --director-origin "${DIRECTOR_ORIGIN}" \ --auth-origin "${AUTH_ORIGIN}" \ @@ -335,31 +363,34 @@ jobs: echo "ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" - name: Collect regional, Relay SQL, and Cloud SQL canary evidence - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + if: ${{ steps.inputs.outputs.canary == 'true' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} - CANARY_STARTED_AT: ${{ steps.c27-start.outputs.started_at }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} + CANARY_STARTED_AT: ${{ steps.canary-start.outputs.started_at }} + CANARY_VERIFY_CELLS: ${{ steps.canary-start.outputs.verify_cells }} shell: bash run: | set -euo pipefail result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode verify \ - --cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \ + --cell-ids "${CANARY_VERIFY_CELLS}" \ --expected-generation "${{ steps.admission-operation.outputs.generation }}" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general - ended_at="${{ steps.c27-load.outputs.ended_at }}" + test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = general + ended_at="${{ steps.canary-load.outputs.ended_at }}" sleep 60 output="${RUNNER_TEMP}/relay-asia-output-evidence" - logs="${RUNNER_TEMP}/relay-asia-c27-runtime-metrics.json" + logs="${RUNNER_TEMP}/relay-asia-canary-runtime-metrics.json" mkdir -p "${output}" gcloud logging read \ "timestamp>=\"${CANARY_STARTED_AT}\" AND timestamp<=\"${ended_at}\" AND jsonPayload.event=\"orca_relay_runtime_metrics\"" \ --project "${GCP_PROJECT_ID}" \ --limit 20000 \ --format json > "${logs}" - node dev/scripts/relay-asia-rollout-evidence.mjs create-c27 \ + node dev/scripts/relay-asia-rollout-evidence.mjs create-canary \ + --cell-id "${CANARY_CELL}" \ --repository "${GITHUB_REPOSITORY}" \ --run-id "${GITHUB_RUN_ID}" \ --run-attempt "${GITHUB_RUN_ATTEMPT}" \ @@ -368,18 +399,18 @@ jobs: --selector-generation "${{ steps.admission-operation.outputs.generation }}" \ --started-at "${CANARY_STARTED_AT}" \ --ended-at "${ended_at}" \ - --load-report "${RUNNER_TEMP}/relay-asia-c27-load.json" \ + --load-report "${RUNNER_TEMP}/relay-asia-canary-load.json" \ --logs-json "${logs}" \ --output "${output}/evidence.json" jq -r '.metrics | to_entries[] | "- \(.key): \(.value)"' \ "${output}/evidence.json" >> "${GITHUB_STEP_SUMMARY}" - - name: Upload immutable C27 canary evidence - id: c27-evidence-upload - if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }} + - name: Upload immutable canary evidence + id: canary-evidence-upload + if: ${{ steps.inputs.outputs.canary == 'true' }} uses: actions/upload-artifact@v4 with: - name: relay-asia-c27-canary-${{ github.run_id }}-${{ github.run_attempt }} + name: relay-asia-${{ steps.inputs.outputs.canary_hostname }}-canary-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/relay-asia-output-evidence/evidence.json if-no-files-found: error retention-days: 7 @@ -393,17 +424,18 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Return an unproven C27 canary to migration-only - if: ${{ always() && inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' && steps.admission-operation.outcome != 'skipped' && steps.c27-evidence-upload.outcome != 'success' }} + - name: Return an unproven canary cell to migration-only + if: ${{ always() && steps.inputs.outputs.canary == 'true' && steps.admission-operation.outcome != 'skipped' && steps.canary-evidence-upload.outcome != 'success' }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }} shell: bash run: | set -euo pipefail promoted="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode recover-promotion \ - --cell-ids production-gce-c27 \ + --cell-ids "${CANARY_CELL}" \ --expected-generation "${EXPECTED_SELECTOR_GENERATION}" \ --attempt-id "${SELECTOR_ATTEMPT_ID}" \ --image-digest "${IMAGE_DIGEST}")" @@ -412,11 +444,11 @@ jobs: result="$(node dev/scripts/operate-relay-asia-admission.mjs \ --environment production \ --mode rollback \ - --cell-ids production-gce-c27 \ + --cell-ids "${CANARY_CELL}" \ --expected-generation "${promoted_generation}" \ --attempt-id "${SELECTOR_ATTEMPT_ID}-rollback" \ --image-digest "${IMAGE_DIGEST}")" - test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = migration-only + test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = migration-only - name: Require registered migration-only cells before director configuration if: ${{ inputs.mode == 'configure' }} diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index a34552b898fe..b16132d9ba59 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -320,7 +320,7 @@ jobs: echo '### Regional rehome control' jq -r '"- mode: `\(.mode)`\n- generation: `\(.control.generation)`\n- enabled: `\(.control.enabled)`"' \ "${RUNNER_TEMP}/relay-rehome-control.json" - jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`"' \ + jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`\n- host not arrived (24h): `\(.hostNotArrivedLast24Hours // "not reported")`\n- oldest active age (ms): `\(.oldestActiveAgeMs // "none")`"' \ "${RUNNER_TEMP}/relay-rehome-inventory.json" } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index 5e24cae76cc7..02f2d1822ec3 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -32,15 +32,22 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - - name: Scan Cloud history with Gitleaks + # Scan every ancestor of HEAD without downloading unrelated branch/tag histories. + - name: Fetch complete scan history + working-directory: . + run: | + git fetch --no-tags --unshallow origin "$GITHUB_SHA" + test "$(git rev-parse --is-shallow-repository)" = false + + - name: Scan Cloud history reachable from HEAD with Gitleaks run: >- docker run --rm --volume "${GITHUB_WORKSPACE}:/repo:ro" zricethezav/gitleaks@sha256:cdbb7c955abce02001a9f6c9f602fb195b7fadc1e812065883f695d1eeaba854 git /repo --config /repo/cloud/.gitleaks.toml - --log-opts="--all -- cloud :(glob).github/workflows/cloud-*.yml .github/actions/cloud-sql-rollout-lease" + --log-opts="HEAD -- cloud :(glob).github/workflows/cloud-*.yml .github/actions/cloud-sql-rollout-lease" - name: Scan the single-commit Cloud snapshot with TruffleHog run: >- diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index ac9c1bd4ba40..584986086b91 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -57,10 +57,10 @@ jobs: uses: actions/cache@v4 with: path: dist/win-unpacked - # mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from - # the mobile install and, once Phase C flips the bundle, from the page trees below; a - # mobile-only change must miss this cache, not reuse a stale installer. src/** and - # config/** already cover src/mobile-web and the two bundle builders. + # mobile/ is in the key because beforePack requires out/mobile-web, whose bytes are the + # page built from the mobile install and the page trees below; a mobile-only change must + # miss this cache, not reuse a stale installer. config/** already covers the builder, the + # verifier and the manifest writer. key: >- win-unpacked-${{ hashFiles( 'src/**', diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml index 1602bae956a9..6b49177ed560 100644 --- a/.github/workflows/git-command-termination-runtime.yml +++ b/.github/workflows/git-command-termination-runtime.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: git-command-termination-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-exit: runs-on: windows-latest diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 11cfa866c676..0b80662d7166 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -66,10 +66,10 @@ jobs: - name: Run golden E2E tests on Linux if: runner.os == 'Linux' run: | - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -80,10 +80,10 @@ jobs: - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -104,7 +104,7 @@ jobs: pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { - pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } pnpm run --if-present test:e2e:source-control-golden diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index c437db7f21a5..cf0ee59a8425 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -12,7 +12,7 @@ permissions: jobs: apply-os-label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Apply OS label from issue form diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 17100c788b8f..cdb5a59b1549 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -18,6 +18,14 @@ on: required: false default: true type: boolean + shell: + description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.' + required: false + default: native + type: choice + options: + - native + - ota jobs: android-build: @@ -68,7 +76,14 @@ jobs: run: npx expo prebuild --platform android --no-install - name: Build Android release APK - run: cd android && ./gradlew assembleRelease + env: + # The one build-time constant that decides whether this binary mounts the web page or + # the native screens. A tag push and a schedule carry no inputs, so both read native. + EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }} + run: | + set -euo pipefail + echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL" + cd android && ./gradlew assembleRelease - name: Upload APK artifact uses: actions/upload-artifact@v7 diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index dd9a265d0c85..975b0618c7ff 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -22,6 +22,14 @@ on: description: 'Optional TestFlight external tester changelog' required: false type: string + shell: + description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.' + required: false + default: native + type: choice + options: + - native + - ota jobs: ios-build: @@ -141,7 +149,13 @@ jobs: # Keep fastlane non-interactive and quiet about analytics in CI. FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_HIDE_CHANGELOG: '1' - run: bundle exec fastlane ios build_and_upload + # The one build-time constant that decides whether this binary mounts the web page or + # the native screens. A tag push and a schedule carry no inputs, so both read native. + EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }} + run: | + set -euo pipefail + echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL" + bundle exec fastlane ios build_and_upload - name: Upload .ipa artifact if: always() diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index aae50234bf94..8382bb73b5d0 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review paths: - 'mobile/**' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. @@ -38,6 +37,9 @@ on: - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' - '.github/workflows/mobile-ios-release.yml' + - 'config/scripts/mobile-release-check-scope*' + - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/mobile-recording-pin-checkout.test.mjs' # Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that # commit only stops being reachable when the branch squash-merges. The pull_request run cannot # see that; this one is where the pin guard finds it. @@ -73,6 +75,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 2 - uses: ./.github/actions/install-node-dependencies with: @@ -80,10 +84,23 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + - name: Detect Ruby release inputs + id: ruby-scope + shell: bash + working-directory: . + run: | + # Keep deletions when release files move into an application directory. + if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still # resolves — before a release run finds out. - name: Setup Ruby and fastlane + if: steps.ruby-scope.outputs.should_run != 'false' uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' @@ -113,9 +130,11 @@ jobs: run: pnpm test - name: Test iOS release version resolution + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/ios_release_version_test.rb - name: Test TestFlight lane arguments + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/fastfile_testflight_arguments_test.rb # Why: nothing else in CI loads the Fastfile, so a syntax error, a broken @@ -124,6 +143,7 @@ jobs: # loads and lists, so it needs no App Store Connect credentials and makes # no network calls to Apple. - name: Smoke-check the Fastfile + if: steps.ruby-scope.outputs.should_run != 'false' env: FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_OPT_OUT_USAGE: '1' @@ -152,6 +172,8 @@ jobs: # answer at all rather than reporting a pass it has no evidence for -- and the pinned tree # below has to be checkable out. fetch-depth: 0 + # Ancestry needs commits; the pinned worktree fetches its historical blobs on demand. + filter: blob:none - uses: ./.github/actions/install-node-dependencies with: diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml index 2a23ac58988c..7d36e7fd1b13 100644 --- a/.github/workflows/packaged-browser-e2e.yml +++ b/.github/workflows/packaged-browser-e2e.yml @@ -39,7 +39,11 @@ jobs: root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' package=root/'orca-ide_1.4.188_amd64.deb' expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==' - assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected + digest=hashlib.sha512() + with package.open('rb') as archive: + for block in iter(lambda: archive.read(1024*1024), b''): + digest.update(block) + assert base64.b64encode(digest.digest()).decode()==expected extracted=root/'extracted' subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True) executable=extracted/'opt'/'Orca'/'orca-ide' diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 9f9df4f9b0b5..592bc75bb5e8 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -9,6 +9,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml index 837c38baf9a8..310467374994 100644 --- a/.github/workflows/pi-provider-runtime.yml +++ b/.github/workflows/pi-provider-runtime.yml @@ -7,6 +7,9 @@ on: - '.github/workflows/pi-provider-runtime.yml' permissions: contents: read +concurrency: + group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index a884aedc6361..340c6c7b70d1 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review concurrency: group: pr-test-loc-${{ github.event.pull_request.number }} @@ -19,7 +18,8 @@ permissions: jobs: loc: name: test vs non-test LoC - runs-on: ubuntu-latest + # API calls and a small Node script fit the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4c8e3210718c..d2dd482858ef 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,4 +1,5 @@ name: PR Checks +run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' on: pull_request: @@ -23,25 +24,32 @@ jobs: # Per-job outputs also skip git-compat/xterm/packaging/shell when those # inputs are unchanged; empty diffs fail closed and run everything. code_paths: - name: detect code-relevant changes - runs-on: ubuntu-latest + name: detect changes and check repository guards + # Reuse one lightweight checkout for detection and the always-required guards. + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + actions: read outputs: should_run: ${{ steps.filter.outputs.should_run }} changed_count: ${{ steps.filter.outputs.changed_count }} - native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + reused_run_id: ${{ steps.readiness.outputs.run_id }} + # A proven success masks required work only; advisory routing still uses the full diff. + native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} - mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} - static_analysis: ${{ steps.filter.outputs.static_analysis }} - typecheck: ${{ steps.filter.outputs.typecheck }} - git_compatibility: ${{ steps.filter.outputs.git_compatibility }} - codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }} - xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }} - shell_contracts: ${{ steps.filter.outputs.shell_contracts }} - test: ${{ steps.filter.outputs.test }} - orcad_browser: ${{ steps.filter.outputs.orcad_browser }} - cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }} - managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }} - package: ${{ steps.filter.outputs.package }} + mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} + static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} + typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }} + git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }} + codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }} + xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }} + shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }} + test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }} + orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }} + cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }} + managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} + package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} @@ -55,8 +63,37 @@ jobs: # few this job actually reads on demand. fetch-depth: 0 filter: blob:none + sparse-checkout: | + /package.json + /README.md + /docs/readme/ + /.github/scripts/check-root-directory-entries.mjs + /config/scripts/check-readme-local-links.mjs + /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-e2e-source-routing.mjs + /config/scripts/pr-ready-check-reuse.mjs + sparse-checkout-cone-mode: false persist-credentials: false + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + + # The full Git index retains link targets outside the sparse working tree. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + + # Readiness changes eligibility for advisory tests, not the already-tested source. + - name: Find identical successful required checks + id: readiness + if: github.event.action == 'ready_for_review' + env: + GH_TOKEN: ${{ github.token }} + PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: node config/scripts/pr-ready-check-reuse.mjs + - name: Classify changed paths id: filter env: @@ -271,33 +308,6 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements - root_directory_guard: - name: root directory guard - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Reject new root-level files and folders - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" - - # Why here: the READMEs embed media owned by docs/site and resources/onboarding, - # and the classifier skips static_analysis for docs-only diffs. This job runs - # on every PR and needs no install. - - name: Check README local links - run: node config/scripts/check-readme-local-links.mjs - fork_ownership_guard: name: fork ownership guard runs-on: ubuntu-latest @@ -318,11 +328,11 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/check-fork-ownership.mjs "$BASE_SHA" "$HEAD_SHA" - typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' - runs-on: ubuntu-latest + # Typechecking uses no native runtime, so it can use the free public ARM runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -360,39 +370,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies - # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the - # same binary on every PR for minutes of runner time. The key carries the version - # because that is the only input; the sha256 assertion below still guards the - # tarball on the miss path that actually builds. Only this PR's own later pushes - # can restore it — GitHub scopes a cache written from a pull_request run to that - # ref — so a first push always takes the build path below. - - name: Cache baseline Git build - uses: actions/cache@v5 - with: - path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - - # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below - # spend their wall clock waiting on container starts, not on Git. Sharing a runner - # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so - # the build has to finish before anything timed starts. - - name: Build the baseline Git binary - run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then - exit 0 - fi - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - # Why: the linked binaries are what the next run needs; the objects that - # produced them are most of the tree and would bloat the cache entry. - find "$source" -name '*.o' -delete + - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix run: | @@ -646,8 +624,9 @@ jobs: test: needs: [code_paths, test_native_cache] + # Honor cancellation while allowing the optional native-cache primer to skip. if: >- - always() && + !cancelled() && needs.code_paths.outputs.test == 'true' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml @@ -693,9 +672,8 @@ jobs: src/main/orcad/external-chromium-browser-process.integration.test.ts # Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test` - # matrix would pay for both on every shard to run two files. Dark through Phase C: this proves - # `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still - # builds the Phase A bootstrap via build:mobile-web. + # matrix would pay for both on every shard to run two files. It builds the same bundle the + # package job ships, and adds the render and census checks packaging does not run. mobile_web_app: name: mobile web app bundle needs: [code_paths] @@ -742,26 +720,28 @@ jobs: run: pnpm exec playwright install --with-deps webkit - name: Build and verify the app bundle - run: pnpm run build:mobile-web:app + run: pnpm run build:mobile-web # The bundling tests skip themselves where mobile dependencies are absent, which is how they # stay green in the sharded `test` job. This is the job that installs them, so here a missing # install has to fail rather than skip everything the job exists to run. + # Why a prefix and not a file list: the list this replaces had gone stale twice without + # anyone noticing, because a census whose closure block skips without the env flag below is + # green in the sharded `test` job whether or not it ever runs here. The prefix is the same + # one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all + # it takes to have it run. Quoted because these are vitest filename filters, matched as + # substrings against the discovered files, and the shell must not touch them. + # + # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes + # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport + # set; adding rows to that set is a decision about this job's runtime. - name: Builder, override census and render checks env: ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' run: | pnpm exec vitest run --config config/vitest.config.ts \ - config/scripts/build-mobile-web-app-bundle.test.mjs \ - config/scripts/mobile-web-app-web-overrides.test.mjs \ - config/scripts/mobile-web-app-render.test.mjs \ - config/scripts/mobile-web-app-drawer-render.test.mjs \ - config/scripts/mobile-web-app-agent-history-render.test.mjs \ - config/scripts/mobile-web-app-tasks-render.test.mjs \ - config/scripts/mobile-web-app-tasks-external-links.test.mjs \ - config/scripts/mobile-web-app-files-external-links.test.mjs \ - config/scripts/mobile-web-app-files-render.test.mjs \ - config/scripts/mobile-web-app-page-closure-families.test.mjs + 'config/scripts/mobile-web-app-' \ + 'config/scripts/build-mobile-web-app-bundle.test.mjs' cross-version-wire: name: cross-version wire compatibility @@ -804,6 +784,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 @@ -863,6 +844,12 @@ jobs: # build resolves React Native and Expo from mobile/node_modules. - uses: ./.github/actions/install-mobile-dependencies + # The checkout must pass without depending on a historical baseline that leaks. + - name: Verify Linux daemon shutdown descendant cleanup + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: node config/scripts/run-daemon-shutdown-descendants-docker.mjs + # Why --no-file-parallelism: every file here launches a full Electron stack twice, and each # probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other # past those deadlines; serial, every probe owns the runner. @@ -898,7 +885,7 @@ jobs: # Why here and not inside "Build package inputs": this job assembles packaging inputs step by # step instead of calling build:release, and electron-builder's beforePack guard hard-fails - # without out/mobile-web. + # without out/mobile-web. This is the real page, ~8 MB, not a bootstrap document. - name: Build mobile web bundle run: pnpm run build:mobile-web @@ -977,11 +964,10 @@ jobs: uses: ./.github/workflows/terminal-ime-e2e.yml verify: - if: always() + if: ${{ !cancelled() }} needs: - code_paths - static_analysis - - root_directory_guard - fork_ownership_guard - typecheck - git_compatibility @@ -994,7 +980,9 @@ jobs: - cross-version-wire - managed_hook_node18 - package - runs-on: ubuntu-latest + # Evaluating job results only needs the lightweight container runner. + runs-on: ubuntu-slim + timeout-minutes: 5 steps: # Why: e2e is deliberately absent from needs. The suite is currently red on @@ -1011,7 +999,6 @@ jobs: SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} FORK_OWNERSHIP_GUARD: ${{ needs.fork_ownership_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} @@ -1039,9 +1026,6 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi # Why here too: the ownership guard is ungated, so a docs-only PR still # runs it, and a docs-only path that never reads its result would let an # undeclared manifest change through the one check that exists to catch it. diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 67bb6eb707c2..ad710f49921b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -107,6 +107,10 @@ jobs: with: ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 + # Why: version math recovers unpublished tags; checkout's default + # fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and + # `git push` overwrites the existing tag. + fetch-tags: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -843,6 +847,16 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + - name: Restore draft-release scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- \ + config/scripts/create-draft-release.mjs \ + config/scripts/assert-github-release-is-draft.mjs + - name: Create draft release with bounded generated notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -884,7 +898,8 @@ jobs: git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- \ tests/e2e/golden-source-control-open-diff.spec.ts \ - tests/e2e/golden-terminal-file-link.spec.ts + tests/e2e/golden-terminal-file-link.spec.ts \ + tests/e2e/golden-worktree-create-switch.spec.ts - name: Install native build tools if: runner.os == 'Linux' @@ -1089,13 +1104,13 @@ jobs: ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux-x64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder - os: ubuntu-24.04-arm platform: linux-arm64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder @@ -1132,6 +1147,18 @@ jobs: # actions directory from the commit this workflow file itself came from. # Not Windows-only: every platform now consumes install-mobile-dependencies, so # any of them can be the one whose cut ref predates the action. + - name: Restore draft-publish scripts from the workflow ref + # Why: this job checks out the release tag, so a cut from an older SHA + # still has electron-builder releaseType:release and no re-draft helper. + # The workflow YAML is from main; restore the scripts it invokes. + shell: bash + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + - name: Restore composite actions from the workflow ref shell: bash env: @@ -1198,6 +1225,16 @@ jobs: restore-keys: | electron-builder-${{ matrix.platform }}- + # PRs cache tools separately from Electron; identical paths preserve their cache version. + - name: Seed shared Linux packaging downloads + if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main' + uses: actions/cache@v5 + with: + path: ~/.cache/electron-builder + key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + # The release cache above restores the same tools, so this only needs to save on a miss. + lookup-only: true + # Why: pnpm install triggers electron's postinstall, which downloads the # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on @@ -2093,36 +2130,21 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify release remains draft after artifact upload - # Why: the build matrix must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this platform leg and leave the diagnostic monitor artifact behind. + # Why: electron-builder `--publish always` can create a public release + # as soon as this platform uploads. Re-draft immediately, then fail, so + # /releases/latest never keeps serving a missing Windows exe. + # Why bash: the Windows matrix defaults to pwsh, which does not expand + # "$TAG" into argv. shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.cut.outputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}" - # Why post-publish for Linux: electron-builder packs and uploads in a - # single `--publish always` invocation, so there is no cheap insertion - # point between pack and upload without splitting those steps. Running - # verify last still blocks the bad release: the binary is uploaded to the - # draft, but a failed matrix job blocks `publish-release` from flipping - # the release from draft → published, so users never see it. A human then - # deletes the draft and re-cuts. + # Why post-pack for Linux: electron-builder packs and uploads in one + # `--publish always` invocation. The previous step re-drafts if that + # upload flipped the GitHub release public; this telemetry check still + # blocks `publish-release` from undrafting a bad binary. # # Why this guards against: a misconfigured CI run where # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index da8ae4609bc6..55cc679fb5ba 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -41,6 +41,14 @@ jobs: with: ref: refs/tags/${{ inputs.tag }} + - name: Restore draft-publish scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -146,6 +154,21 @@ jobs: # Kill only its child and require both PTY and watch recovery before packaging. node config/scripts/relay-watcher-fault-harness.mjs + - name: Abort if the parent release-cut run was cancelled + # Why: this workflow is dispatched separately, so cancelling release-cut + # does not stop mac `--publish always`. A cancelled parent left v1.4.206 + # public with only a partial mac upload. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PARENT_RUN: ${{ inputs.release_run_id }} + run: | + set -euo pipefail + conclusion="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PARENT_RUN" --jq '.conclusion // empty')" + if [[ "$conclusion" == "cancelled" || "$conclusion" == "failure" || "$conclusion" == "timed_out" ]]; then + echo "::error::Parent release-cut run $PARENT_RUN is $conclusion; refusing to publish mac artifacts." + exit 1 + fi + - name: Publish release artifacts (macOS) uses: nick-fields/retry@v4 with: @@ -157,7 +180,7 @@ jobs: timeout_minutes: 70 max_attempts: 2 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} @@ -167,28 +190,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Verify release remains draft after artifact upload - # Why: the macOS build must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this job so release-cut never publishes the release. + # Why: re-draft immediately if electron-builder flipped the GitHub + # release public, then fail. Checking without restoring leaves + # /releases/latest serving a missing Windows exe. shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the mac artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "${{ inputs.tag }}" # Why post-publish for macOS: electron-builder packs and uploads in a # single `--publish always` invocation, so there is no cheap insertion diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 8014145939ba..e6ab98f0d0d2 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -16,7 +16,7 @@ concurrency: jobs: enforce: if: github.repository == 'stablyai/orca' - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Enforce release policy diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index bd6be26bd272..168280f9ac2f 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -49,7 +49,7 @@ jobs: run: >- xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 - pnpm run test:e2e -- + pnpm run test:e2e tests/e2e/terminal-ime-exact-byte.spec.ts --workers=1 diff --git a/.github/workflows/terminal-perf.yml b/.github/workflows/terminal-perf.yml index 72a0fec8992f..83ada43d2cf9 100644 --- a/.github/workflows/terminal-perf.yml +++ b/.github/workflows/terminal-perf.yml @@ -55,6 +55,7 @@ jobs: timeout-minutes: 45 env: NODE_OPTIONS: --max-old-space-size=4096 + ORCA_BACKGROUND_LAUNCH: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' ORCA_E2E_TERMINAL_PERF_REPORT_PATH: ${{ inputs.report_path || 'test-results/terminal-scale-perf-report.json' }} @@ -128,7 +129,7 @@ jobs: if [ -n "${ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES:-}" ]; then export ORCA_E2E_OPENCODE_SCALE_HIDDEN_PRESSURE_PANES="$ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES" fi - xvfb-run --auto-servernum env SKIP_BUILD=1 pnpm run test:e2e:terminal-perf:scale:report -- "${args[@]}" + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_TERMINAL_PERF_XVFB=1 pnpm run test:e2e:terminal-perf:scale:report -- "${args[@]}" - name: Upload terminal perf report if: always() diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318a..73748238f5f3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -33,9 +33,6 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' - cache-dependency-path: | - pnpm-lock.yaml - cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index 91ac8fcf2262..e0d7b80d0473 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -71,8 +71,8 @@ jobs: with: path: dist/orca-windows-setup.exe # The mobile page trees are in the key because beforePack builds the mobile web bundle - # into the installer; src/** and config/** already cover src/mobile-web and the two - # bundle builders. A mobile-only change must miss this cache, not reuse a stale exe. + # into the installer; config/** already covers the builder, the verifier and the manifest + # writer. A mobile-only change must miss this cache, not reuse a stale exe. key: >- crash-survival-installer-${{ hashFiles( 'src/**', diff --git a/.gitignore b/.gitignore index 83c255ff012d..0965f297aea8 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,8 @@ docs/** !docs/audits/plugin-uninstall-log-retirement/** !docs/readme/ !docs/readme/** +!docs/bug-reproductions/ +!docs/bug-reproductions/** !docs/STYLEGUIDE.md !docs/fork-upstreaming.md !docs/audits/ @@ -207,3 +209,6 @@ tests/e2e/.cross-version-checkouts/ # Generated by config/scripts/sync-anti-slop-plugin.mjs from the pinned oxlint-plugin-anti-slop .anti-slop-plugin/ + +# Generated by the CI unit-test sequencer (and by reproducing a shard locally). +ci-shards/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 4acd967af0ee..353994ed0f16 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -143,7 +143,26 @@ { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } + }, + { + "files": ["src/shared/**/*.ts"], + "rules": { + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code." + } + ] } }, { @@ -155,7 +174,7 @@ } }, { - "files": ["**/*.ts"], + "files": ["**/*.ts", "**/*.mts", "**/*.cts"], "rules": { "max-lines": ["error", { "max": 500, "skipBlankLines": true, "skipComments": true }] } @@ -173,7 +192,16 @@ } }, { - "files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"], + "files": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.mts", + "**/*.test.cts", + "**/*.spec.mts", + "**/*.spec.cts" + ], "rules": { "max-lines": ["error", { "max": 1000, "skipBlankLines": true, "skipComments": true }] } diff --git a/AGENTS.md b/AGENTS.md index 9394332b38c8..71039c14d7a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,9 +216,10 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. -- **Windows terminal shells**: `--shell` picks the shell a terminal *is*; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md). +- **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md). - **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one. +- **Ripgrep**: Orca bundles `rg` for every platform, WSL, and SSH remotes. Spawn it through `spawnBundledRipgrep` (main) or `resolveRelayRipgrepCommand` (relay), never a bare `'rg'` — Windows resolves a bare name in the spawn cwd before PATH. Don't add git/readdir fallbacks locally; the relay's chain exists only for hosts an upload never reached. - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). - **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. Before changing the per-PTY job or debugging `windows-msys-job.win32.test.ts`, read [`docs/reference/windows-msys-job-breakaway.md`](./docs/reference/windows-msys-job-breakaway.md). - **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md). diff --git a/README.md b/README.md index 04f5e309da38..98efc301c2f9 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   @@ -238,9 +240,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan to join the Orca community WeChat group 8. Group 8 may be full; if so, scan the Group 9 QR code instead. +- **WeChat:** Scan to join the Orca community WeChat group 10. - WeChat group 8 QR code for the Orca community  WeChat group 9 QR code for the Orca community + WeChat group 10 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/cloud/apps/push/src/device-registration-delete-race.test.ts b/cloud/apps/push/src/device-registration-delete-race.test.ts index 2fe51e30516c..2e2bdbdc7fab 100644 --- a/cloud/apps/push/src/device-registration-delete-race.test.ts +++ b/cloud/apps/push/src/device-registration-delete-race.test.ts @@ -25,6 +25,8 @@ it.skipIf(!databaseUrl)( query: database.query.bind(database), close: database.close.bind(database), lockQuotaScope: database.lockQuotaScope.bind(database), + tryLockScope: database.tryLockScope.bind(database), + tryLockSharedScope: database.tryLockSharedScope.bind(database), transaction: (run) => database.transaction((tx) => run({ @@ -32,6 +34,8 @@ it.skipIf(!databaseUrl)( close: tx.close.bind(tx), transaction: tx.transaction.bind(tx), lockQuotaScope: tx.lockQuotaScope.bind(tx), + tryLockScope: tx.tryLockScope.bind(tx), + tryLockSharedScope: tx.tryLockSharedScope.bind(tx), query: async (sql, params) => { const rows = await tx.query(sql, params) if (pause && sql.startsWith('SELECT registration_id FROM push_devices')) { diff --git a/cloud/apps/push/src/durable-push-claim-mixed-revision.test.ts b/cloud/apps/push/src/durable-push-claim-mixed-revision.test.ts new file mode 100644 index 000000000000..829b9affa0b7 --- /dev/null +++ b/cloud/apps/push/src/durable-push-claim-mixed-revision.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, it } from 'vitest' +import type { PushDatabase } from './push-database.js' +import { DELIVERY_LEASE_MS, DurablePushStore } from './durable-push-store.js' +import { + cleanupDurablePushFixtures, + durablePushTestDatabaseUrl, + fixture, + notification, + pauseAfter, + within +} from './durable-push-store.test-fixture.js' + +afterEach(cleanupDurablePushFixtures) + +const LEASE_SQL = 'UPDATE push_delivery_batches SET lease_token' + +// The previous revision's claim statements, in order, with a hook between its candidate scan and re-read. +function previousRevisionClaim( + db: PushDatabase, + clock: () => number, + afterCandidate: () => Promise +): Promise { + return db.transaction(async (tx) => { + await tx.lockQuotaScope('push-worker-claim') + const now = clock() + const [candidate] = await tx.query( + `SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?) + ORDER BY due_at, created_at, batch_id LIMIT 1`, + [now, now, now, now] + ) + await afterCandidate() + if (!candidate) return null + await tx.lockQuotaScope(`push-events:${String(candidate.host_fingerprint)}`) + const [row] = await tx.query('SELECT * FROM push_delivery_batches WHERE batch_id = ?', [ + candidate.batch_id + ]) + if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null + await tx.query( + 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', + ['previous', now + DELIVERY_LEASE_MS, row.batch_id] + ) + return 'previous' + }) +} + +it.skipIf(!durablePushTestDatabaseUrl)( + 'keeps a previous-revision claim from re-leasing a delivery this revision leased', + async () => { + const { db, store, clock } = await fixture({ ownDatabase: true }) + await store.accept('host', 'phone', notification(1)) + const paused = pauseAfter(db, LEASE_SQL) + const current = new DurablePushStore(paused.wrapped, clock).claim() + await paused.atCandidate + let committed!: () => void + const currentCommitted = new Promise((resolve) => (committed = resolve)) + const previous = previousRevisionClaim(db, clock, () => currentCommitted) + // Room for the previous claim to scan while this revision's lease is still uncommitted. + await new Promise((resolve) => setTimeout(resolve, 200)) + paused.release() + const leased = await current + committed() + expect(await previous).toBeNull() + const [row] = await db.query('SELECT lease_token FROM push_delivery_batches') + expect(leased?.lease).toBeTruthy() + expect(row?.lease_token).toBe(leased?.lease) + } +) + +it.skipIf(!durablePushTestDatabaseUrl)( + 'yields to a previous-revision claim that already holds the claim lock', + async () => { + const { db, store, clock } = await fixture({ ownDatabase: true }) + await store.accept('host', 'phone', notification(1)) + let scanned!: () => void + const atCandidate = new Promise((resolve) => (scanned = resolve)) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + const previous = previousRevisionClaim(db, clock, async () => { + scanned() + await released + }) + await atCandidate + try { + expect(await within(store.claim(), 2_000)).toBeNull() + } finally { + release() + } + expect(await previous).toBe('previous') + expect(await store.claim()).toBeNull() + } +) diff --git a/cloud/apps/push/src/durable-push-claim.test.ts b/cloud/apps/push/src/durable-push-claim.test.ts new file mode 100644 index 000000000000..f43588bd2c3d --- /dev/null +++ b/cloud/apps/push/src/durable-push-claim.test.ts @@ -0,0 +1,221 @@ +import { afterEach, expect, it } from 'vitest' +import type { PushDatabase } from './push-database.js' +import { + DELIVERY_LEASE_MS, + DurablePushStore, + PRUNE_BATCH_ROWS, + PRUNE_MAX_BATCHES +} from './durable-push-store.js' +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { + CANDIDATE_SQL, + cleanupDurablePushFixtures, + DEVICE_HEAD_SQL, + durablePushTestDatabaseUrl, + fixture, + notification, + pauseAfter, + within +} from './durable-push-store.test-fixture.js' + +afterEach(cleanupDurablePushFixtures) + +async function batchCount(db: PushDatabase): Promise { + const [row] = await db.query('SELECT COUNT(*) AS total FROM push_delivery_batches') + return Number(row?.total ?? 0) +} + +it('leases exactly one delivery per device across concurrent claimers', async () => { + const { db, store, clock, advance } = await fixture() + for (let seq = 1; seq <= 5; seq++) { + await store.accept('host', 'phone', notification(seq)) + advance(1) + } + const claims = await Promise.all( + Array.from({ length: 8 }, () => new DurablePushStore(db, clock).claim()) + ) + const leased = claims.filter((claim) => claim !== null) + expect(leased).toHaveLength(1) + expect(leased[0]?.notification.notificationSeq).toBe(1) +}) + +it.skipIf(!durablePushTestDatabaseUrl)( + 'does not lease the row behind a head another claimer holds', + async () => { + const { db, store, clock, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + advance(1) + await store.accept('host', 'phone', notification(2)) + const paused = pauseAfter(db) + const first = new DurablePushStore(paused.wrapped, clock).claim() + await paused.atCandidate + try { + expect(await within(store.claim(), 2_000)).toBeNull() + } finally { + paused.release() + } + expect((await first)?.notification.notificationSeq).toBe(1) + expect(await store.claim()).toBeNull() + } +) + +it.skipIf(!durablePushTestDatabaseUrl)( + 'claims another device while one claim transaction is still open', + async () => { + const { db, store, clock, advance } = await fixture() + await store.accept('host', 'phone-a', notification(1)) + advance(1) + await store.accept('host', 'phone-b', notification(2)) + const paused = pauseAfter(db) + const first = new DurablePushStore(paused.wrapped, clock).claim() + await paused.atCandidate + try { + expect((await within(store.claim(), 2_000))?.registrationId).toBe('phone-b') + } finally { + paused.release() + } + expect((await first)?.registrationId).toBe('phone-a') + } +) + +it.skipIf(!durablePushTestDatabaseUrl)( + 'keeps a device exclusive when an earlier-sorting row lands mid-claim', + async () => { + const { db, store, clock } = await fixture() + await store.accept('host', 'phone', notification(1)) + const paused = pauseAfter(db, DEVICE_HEAD_SQL) + const first = new DurablePushStore(paused.wrapped, clock).claim() + await paused.atCandidate + try { + // Another instance with a slower clock queues a row that now sorts first. + await db.query( + `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + VALUES ('skewed', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`, + [JSON.stringify(notification(2)), clock() - 10, clock() + 60_000, clock() - 10] + ) + expect(await within(store.claim(), 2_000)).toBeNull() + } finally { + paused.release() + } + expect((await first)?.notification.notificationSeq).toBe(1) + } +) + +// Only the TTL term keeps the candidate scan off an unpruned backlog; expires_at is not indexed first. +it('never scans rows due before the notification TTL window', async () => { + const { db, store, clock } = await fixture() + const stale = clock() - PUSH_LIMITS.notificationTtlSeconds * 1000 - 1 + const insert = `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + VALUES (?, 'host', ?, 'alert', ?, 'pending', ?, ?, 0, 0, ?)` + // One unpruned backlog row, and one whose expires_at alone would admit it. + await db.query(insert, ['backlog', 'phone-a', JSON.stringify(notification(1)), stale, stale, stale]) + await db.query(insert, ['past-ttl', 'phone-b', JSON.stringify(notification(2)), stale, clock() + 60_000, stale]) + await store.accept('host', 'phone-c', notification(3)) + const scanned: unknown[] = [] + const capture: PushDatabase = { + dialect: db.dialect, + query: (sql, params) => db.query(sql, params), + close: () => db.close(), + lockQuotaScope: (key) => db.lockQuotaScope(key), + tryLockScope: (key) => db.tryLockScope(key), + tryLockSharedScope: (key) => db.tryLockSharedScope(key), + transaction: (run) => + db.transaction((tx) => + run({ + ...tx, + dialect: tx.dialect, + close: () => tx.close(), + transaction: (inner) => tx.transaction(inner), + lockQuotaScope: (key) => tx.lockQuotaScope(key), + tryLockScope: (key) => tx.tryLockScope(key), + tryLockSharedScope: (key) => tx.tryLockSharedScope(key), + query: async (sql, params) => { + const rows = await tx.query(sql, params) + if (sql.startsWith(CANDIDATE_SQL)) scanned.push(...rows.map((row) => row.batch_id)) + return rows + } + }) + ) + } + expect((await new DurablePushStore(capture, clock).claim())?.registrationId).toBe('phone-c') + expect(scanned).toHaveLength(1) + expect(scanned).not.toContain('past-ttl') +}) + +it('keeps claim, exclusion and prune correct without the queue index', async () => { + const { db, store, clock, advance } = await fixture() + await db.query('DROP INDEX push_batches_pending_device') + await store.accept('host', 'phone', notification(1)) + advance(1) + await store.accept('host', 'phone', notification(2)) + await store.accept('host', 'phone-2', notification(3)) + const claims = await Promise.all( + Array.from({ length: 4 }, () => new DurablePushStore(db, clock).claim()) + ) + const leased = claims.filter((claim) => claim !== null) + expect(leased.map((claim) => claim.notification.notificationSeq).sort()).toEqual([1, 3]) + for (const claim of leased) await store.finish(claim) + expect((await store.claim())?.notification.notificationSeq).toBe(2) + advance(10 * 60_000) + expect(await store.prune()).toBe(1) + expect(await batchCount(db)).toBe(0) +}) + +it('deletes a batch once it is finished, and keeps it only for a retry', async () => { + const { db, store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + advance(1) + await store.accept('host', 'phone-2', notification(2)) + const retried = (await store.claim())! + await store.finish(retried, 1000) + expect(await store.pendingCount(retried.registrationId)).toBe(1) + const sent = (await store.claim())! + await store.finish(sent) + expect(await batchCount(db)).toBe(1) + advance(1000) + const retry = (await store.claim())! + expect(retry.id).toBe(retried.id) + await store.finish(retry, 10 * 60_000) + expect(await batchCount(db)).toBe(0) +}) + +it('frees a device held by a crashed worker once its lease lapses', async () => { + const { db, store, clock, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + advance(1) + await store.accept('host', 'phone', notification(2)) + const crashed = (await new DurablePushStore(db, clock).claim())! + advance(DELIVERY_LEASE_MS - 1) + expect(await store.claim()).toBeNull() + advance(1) + const reclaimed = (await store.claim())! + expect(reclaimed.id).toBe(crashed.id) + expect(reclaimed.attempts).toBe(2) + await store.finish(reclaimed) + expect((await store.claim())?.notification.notificationSeq).toBe(2) +}) + +it('prunes a large backlog in bounded calls without touching live or leased work', async () => { + const { db, store, clock, advance } = await fixture() + const perCall = PRUNE_BATCH_ROWS * PRUNE_MAX_BATCHES + const now = clock() + await db.query( + `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ?) + SELECT 'old-' || n, 'host', 'phone-' || (n % 50), 'alert', '{}', 'done', ?, ?, 0, 1, ? FROM seq`, + [perCall + 500, now - 1, now - 1, now - 1] + ) + await db.query( + `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + VALUES ('leased', 'host', 'phone-x', 'alert', ?, 'pending', ?, ?, ?, 1, ?)`, + [JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1] + ) + await store.accept('host', 'phone-live', notification(1)) + expect(await store.prune()).toBe(perCall) + expect(await store.prune()).toBe(500) + expect(await store.prune()).toBe(0) + advance(1000) + expect(await store.prune()).toBe(1) + expect(await batchCount(db)).toBe(1) + expect(await store.pendingCount('phone-live')).toBe(1) +}) diff --git a/cloud/apps/push/src/durable-push-schema.ts b/cloud/apps/push/src/durable-push-schema.ts index 770c2a1fbced..9f29b14453ce 100644 --- a/cloud/apps/push/src/durable-push-schema.ts +++ b/cloud/apps/push/src/durable-push-schema.ts @@ -42,4 +42,5 @@ CREATE INDEX IF NOT EXISTS push_recipients_retention ON push_event_recipients(cr CREATE INDEX IF NOT EXISTS push_batches_expiry ON push_delivery_batches(expires_at); CREATE INDEX IF NOT EXISTS push_batches_due ON push_delivery_batches(state, due_at); CREATE INDEX IF NOT EXISTS push_batches_registration ON push_delivery_batches(registration_id, state); +CREATE INDEX IF NOT EXISTS push_batches_pending_device ON push_delivery_batches(registration_id, due_at, created_at, batch_id) WHERE state = 'pending'; ` diff --git a/cloud/apps/push/src/durable-push-store.test-fixture.ts b/cloud/apps/push/src/durable-push-store.test-fixture.ts new file mode 100644 index 000000000000..417931878d1c --- /dev/null +++ b/cloud/apps/push/src/durable-push-store.test-fixture.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import type { PushNotification } from '@orca-cloud/push-contract' +import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js' +import { DurablePushStore } from './durable-push-store.js' + +export const durablePushTestDatabaseUrl = + process.env.ORCA_PUSH_DURABLE_TEST_POSTGRES_URL ?? process.env.ORCA_PUSH_TEST_DATABASE_URL + +const cleanups: (() => Promise)[] = [] +export async function cleanupDurablePushFixtures(): Promise { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) +} +export const notification = (seq: number, kind: 'alert' | 'dismiss' = 'alert'): PushNotification => ({ + notificationId: `notification-${seq}`, + notificationEpoch: 'epoch', + notificationSeq: seq, + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + kind +}) +// ownDatabase: advisory locks are database-wide, so a test that holds a global key must not share one. +export async function fixture({ ownDatabase = false } = {}) { + const databaseUrl = durablePushTestDatabaseUrl + if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') + throw new Error('isolated_postgres_port_required') + let db: PushDatabase + if (databaseUrl) { + const admin = new pg.Client({ connectionString: databaseUrl }) + await admin.connect() + const name = `durable_${randomUUID().replaceAll('-', '')}` + let scoped: PushDatabase | undefined + cleanups.push(async () => { + try { + await scoped?.close() + } finally { + try { + await admin.query( + ownDatabase ? `DROP DATABASE IF EXISTS ${name}` : `DROP SCHEMA IF EXISTS ${name} CASCADE` + ) + } finally { + await admin.end() + } + } + }) + const url = new URL(databaseUrl) + if (ownDatabase) { + await admin.query(`CREATE DATABASE ${name}`) + url.pathname = `/${name}` + } else { + await admin.query(`CREATE SCHEMA ${name}`) + url.searchParams.set('options', `-c search_path=${name}`) + } + db = scoped = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '', poolMax: 4 }) + } else { + db = await openInMemoryPushDatabase() + cleanups.push(() => db.close()) + } + let now = 1_000_000 + const clock = () => now + return { + db, + store: new DurablePushStore(db, clock), + clock, + advance: (ms: number) => { + now += ms + } + } +} + +export const CANDIDATE_SQL = "SELECT * FROM push_delivery_batches WHERE state = 'pending'" +export const DEVICE_HEAD_SQL = 'SELECT (SELECT batch_id' + +// Parks the first claim transaction right after the matching statement, locks still held. +export function pauseAfter(database: PushDatabase, prefix = CANDIDATE_SQL) { + let reached!: () => void + const atCandidate = new Promise((resolve) => (reached = resolve)) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let paused = false + const wrapped: PushDatabase = { + dialect: database.dialect, + query: (sql, params) => database.query(sql, params), + close: () => database.close(), + lockQuotaScope: (key) => database.lockQuotaScope(key), + tryLockScope: (key) => database.tryLockScope(key), + tryLockSharedScope: (key) => database.tryLockSharedScope(key), + transaction: (run) => + database.transaction((tx) => + run({ + ...tx, + dialect: tx.dialect, + close: () => tx.close(), + transaction: (inner) => tx.transaction(inner), + lockQuotaScope: (key) => tx.lockQuotaScope(key), + tryLockScope: (key) => tx.tryLockScope(key), + tryLockSharedScope: (key) => tx.tryLockSharedScope(key), + query: async (sql, params) => { + const rows = await tx.query(sql, params) + if (!paused && sql.startsWith(prefix)) { + paused = true + reached() + await released + } + return rows + } + }) + ) + } + return { wrapped, atCandidate, release } +} + +export async function within(operation: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + operation, + new Promise((_, reject) => (timer = setTimeout(() => reject(new Error('claim_blocked')), ms))) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/cloud/apps/push/src/durable-push-store.test.ts b/cloud/apps/push/src/durable-push-store.test.ts index 136c08322110..acdc11dd44d9 100644 --- a/cloud/apps/push/src/durable-push-store.test.ts +++ b/cloud/apps/push/src/durable-push-store.test.ts @@ -1,65 +1,14 @@ -import { randomUUID } from 'node:crypto' -import pg from 'pg' import { afterEach, describe, expect, it } from 'vitest' -import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js' +import { openPushDatabase } from './push-database.js' import { DurablePushStore, DELIVERY_LEASE_MS } from './durable-push-store.js' -import type { PushNotification } from '@orca-cloud/push-contract' +import { + cleanupDurablePushFixtures, + durablePushTestDatabaseUrl, + fixture, + notification +} from './durable-push-store.test-fixture.js' -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) -}) -const notification = (seq: number, kind: 'alert' | 'dismiss' = 'alert'): PushNotification => ({ - notificationId: `notification-${seq}`, - notificationEpoch: 'epoch', - notificationSeq: seq, - source: 'agent-task-complete', - agentState: 'finished', - title: 'Done', - body: '', - kind -}) -async function fixture() { - const databaseUrl = - process.env.ORCA_PUSH_DURABLE_TEST_POSTGRES_URL ?? process.env.ORCA_PUSH_TEST_DATABASE_URL - if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') - throw new Error('isolated_postgres_port_required') - let db: PushDatabase - if (databaseUrl) { - const admin = new pg.Client({ connectionString: databaseUrl }) - await admin.connect() - const schema = `durable_${randomUUID().replaceAll('-', '')}` - let scoped: PushDatabase | undefined - cleanups.push(async () => { - try { - await scoped?.close() - } finally { - try { - await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - } finally { - await admin.end() - } - } - }) - await admin.query(`CREATE SCHEMA ${schema}`) - const url = new URL(databaseUrl) - url.searchParams.set('options', `-c search_path=${schema}`) - db = scoped = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '', poolMax: 4 }) - } else { - db = await openInMemoryPushDatabase() - cleanups.push(() => db.close()) - } - let now = 1_000_000 - const clock = () => now - return { - db, - store: new DurablePushStore(db, clock), - clock, - advance: (ms: number) => { - now += ms - } - } -} +afterEach(cleanupDurablePushFixtures) describe('durable push acceptance', () => { it('counts a logical event once across phones and separates the 300/15min dismissal budget', async () => { @@ -164,8 +113,7 @@ describe('durable push acceptance', () => { const { db, store } = await fixture() await db.query('ALTER TABLE push_delivery_batches RENAME TO push_delivery_batches_unavailable') try { - const databaseUrl = - process.env.ORCA_PUSH_DURABLE_TEST_POSTGRES_URL ?? process.env.ORCA_PUSH_TEST_DATABASE_URL + const databaseUrl = durablePushTestDatabaseUrl if (databaseUrl) { const concurrent = await openPushDatabase({ databaseUrl, dataDir: '' }) try { @@ -267,14 +215,11 @@ it('fences late renew and finish after an expired claim is dismissed', async () [stale.id] ) )[0] - const cancelled = await read() - expect(cancelled).toMatchObject({ state: 'dismissed', payload_json: '{}' }) + expect(await read()).toBeUndefined() await store.renew(stale) - expect(await read()).toEqual(cancelled) await store.finish(stale, 1000) - expect(await read()).toEqual(cancelled) await store.finish(stale) - expect(await read()).toEqual(cancelled) + expect(await read()).toBeUndefined() const dismissal = (await store.claim())! expect(dismissal.notification.kind).toBe('dismiss') await store.finish(dismissal) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index 3003cc6d384a..61fed2350184 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -5,6 +5,12 @@ import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract' import type { PushDatabase, SqlRow } from './push-database.js' const RETENTION_MS = 24 * 60 * 60_000 +// accept() caps expires_at at due_at + TTL, so older due_at is expired: scans skip an unpruned backlog. +const TTL_MS = PUSH_LIMITS.notificationTtlSeconds * 1000 +// Covers one claimer per worker drain skipping a device another drain holds. +const CLAIM_CANDIDATE_ATTEMPTS = 4 +export const PRUNE_BATCH_ROWS = 2_000 +export const PRUNE_MAX_BATCHES = 50 export const DELIVERY_LEASE_MS = 30_000 export type QueuedPushDelivery = { id: string @@ -19,7 +25,9 @@ export type QueuedPushDelivery = { export class DurablePushStore { constructor( private readonly database: PushDatabase, - private readonly now = Date.now + private readonly now = Date.now, + // Claim, finish and prune traffic; accept() and renew() stay on the request-path database. + private readonly background = database ) {} async accept( @@ -90,39 +98,61 @@ export class DurablePushStore { } async claim(): Promise { - return this.database.transaction(async (tx) => { - await tx.lockQuotaScope('push-worker-claim') + return this.background.transaction(async (tx) => { + // The previous revision's claim holds this key exclusively; sharing it makes that claim snapshot + // only after our leases commit. Drop one release after every worker runs this revision. + if (!(await tx.tryLockSharedScope('push-worker-claim'))) return null const now = this.now() - const params = [now, now, now, now] - const predicate = - "state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)" - let [row] = await tx.query( - `SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY due_at, created_at, batch_id LIMIT 1`, - params - ) - if (!row) return null - await tx.lockQuotaScope(`push-events:${String(row.host_fingerprint)}`) - ;[row] = await tx.query('SELECT * FROM push_delivery_batches WHERE batch_id = ?', [ - row.batch_id - ]) - if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null - const notification = parsePushDeliveryPayload(String(row.payload_json)) - if (await isDismissedAlert(tx, String(row.host_fingerprint), notification)) { - await tx.query( - "UPDATE push_delivery_batches SET state = 'dismissed', payload_json = '{}' WHERE batch_id = ?", - [row.batch_id] + // SQLite already serializes the whole transaction. + const lockRow = tx.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : '' + const skipped: string[] = [] + for (let attempt = 0; attempt < CLAIM_CANDIDATE_ATTEMPTS; attempt++) { + const excluded = skipped.map(() => ' AND registration_id <> ?').join('') + const [row] = await tx.query( + `SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND due_at > ?${excluded} + AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.state = 'pending' AND busy.lease_until > 0 AND busy.lease_until > ?) + ORDER BY due_at, created_at, batch_id LIMIT 1${lockRow}`, + [now, now, now, now - TTL_MS, ...skipped, now] ) - return null + if (!row) return null + if (await this.ownsDeviceHead(tx, row, now)) return await this.lease(tx, row, now) + skipped.push(String(row.registration_id)) } - const lease = randomUUID() - await tx.query( - 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', - [lease, now + DELIVERY_LEASE_MS, row.batch_id] - ) - return this.delivery(row, lease) + return null }) } + // The row lock alone lets a second claimer skip this device's head and lease the row behind it. + // Under the device lock, a fresh read sees every committed lease and every uncommitted head. + private async ownsDeviceHead(tx: PushDatabase, row: SqlRow, now: number): Promise { + const registrationId = String(row.registration_id) + if (!(await tx.tryLockScope(`push-device:${registrationId}`))) return false + const [device] = await tx.query( + `SELECT (SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND expires_at > ? AND due_at > ? ORDER BY due_at, created_at, batch_id LIMIT 1) AS head, + EXISTS (SELECT 1 FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND lease_until > 0 AND lease_until > ?) AS busy`, + [registrationId, now, now - TTL_MS, registrationId, now] + ) + return device?.head === row.batch_id && !Number(device?.busy) + } + + private async lease( + tx: PushDatabase, + row: SqlRow, + now: number + ): Promise { + const notification = parsePushDeliveryPayload(String(row.payload_json)) + if (await isDismissedAlert(tx, String(row.host_fingerprint), notification)) { + await tx.query('DELETE FROM push_delivery_batches WHERE batch_id = ?', [row.batch_id]) + return null + } + const lease = randomUUID() + await tx.query( + 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', + [lease, now + DELIVERY_LEASE_MS, row.batch_id] + ) + return this.delivery(row, lease) + } + private delivery(row: SqlRow, lease: string): QueuedPushDelivery { return { id: String(row.batch_id), @@ -135,6 +165,7 @@ export class DurablePushStore { } } + // Ungated: a keyed one-row write must not queue behind claims, or a lease can lapse mid-send. async renew(delivery: QueuedPushDelivery): Promise { await this.database.query( "UPDATE push_delivery_batches SET lease_until = ? WHERE batch_id = ? AND lease_token = ? AND state = 'pending'", @@ -142,24 +173,21 @@ export class DurablePushStore { ) } - async finish( - delivery: QueuedPushDelivery, - retryAfterMs?: number, - outcome = 'done' - ): Promise { + // A finished batch is deleted: accept() dedups on push_event_recipients, never on this row. + async finish(delivery: QueuedPushDelivery, retryAfterMs?: number): Promise { const now = this.now() const retryAt = retryAfterMs === undefined ? Infinity : now + Math.max(1000, retryAfterMs) - const retry = retryAt < delivery.expiresAt - await this.database.query( - `UPDATE push_delivery_batches SET state = ?, payload_json = ?, due_at = ?, lease_until = 0, lease_token = NULL + if (retryAt >= delivery.expiresAt) { + await this.background.query( + "DELETE FROM push_delivery_batches WHERE batch_id = ? AND lease_token = ? AND state = 'pending'", + [delivery.id, delivery.lease] + ) + return + } + await this.background.query( + `UPDATE push_delivery_batches SET payload_json = ?, due_at = ?, lease_until = 0, lease_token = NULL WHERE batch_id = ? AND lease_token = ? AND state = 'pending'`, - [ - retry ? 'pending' : retryAfterMs !== undefined ? 'expired' : outcome, - retry ? JSON.stringify(delivery.notification) : '{}', - retry ? retryAt : now, - delivery.id, - delivery.lease - ] + [JSON.stringify(delivery.notification), retryAt, delivery.id, delivery.lease] ) } @@ -171,24 +199,44 @@ export class DurablePushStore { return Number(row?.total ?? 0) } + // Bounded per call and per statement, so it drains any backlog on its own without holding locks. async prune(): Promise { const now = this.now() - await this.database.query( - "UPDATE push_delivery_batches SET state = 'expired', payload_json = '{}' WHERE expires_at <= ? AND state = 'pending'", - [now] + // Also clears terminal rows older revisions kept, since each carries a past expires_at. + let deleted = await this.deleteInBatches( + 'push_delivery_batches', + 'batch_id', + 'expires_at <= ? AND lease_until <= ?', + [now, now] ) - await this.database.query('DELETE FROM push_dismissed_events WHERE created_at < ?', [ - now - RETENTION_MS - ]) - await this.database.query('DELETE FROM push_delivery_batches WHERE expires_at < ?', [ - now - RETENTION_MS - ]) - await this.database.query('DELETE FROM push_event_recipients WHERE created_at < ?', [ - now - RETENTION_MS - ]) - const [result] = await this.database.query('DELETE FROM push_events WHERE created_at < ?', [ - now - RETENTION_MS - ]) - return Number(result?.changes ?? 0) + // A day, not the 15-minute quota window: a host replaying an event after that must stay deduped. + for (const [table, key] of [ + ['push_dismissed_events', 'host_fingerprint, notification_epoch, notification_id'], + ['push_event_recipients', 'event_id, registration_id'], + ['push_events', 'event_id'] + ] as const) { + deleted += await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS]) + } + return deleted + } + + private async deleteInBatches( + table: string, + key: string, + where: string, + params: unknown[] + ): Promise { + const lockRows = this.background.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : '' + let total = 0 + for (let batch = 0; batch < PRUNE_MAX_BATCHES; batch++) { + const [result] = await this.background.query( + `DELETE FROM ${table} WHERE (${key}) IN (SELECT ${key} FROM ${table} WHERE ${where} LIMIT ${PRUNE_BATCH_ROWS}${lockRows})`, + params + ) + const changes = Number(result?.changes ?? 0) + total += changes + if (changes < PRUNE_BATCH_ROWS) break + } + return total } } diff --git a/cloud/apps/push/src/durable-push-worker.ts b/cloud/apps/push/src/durable-push-worker.ts index 4efe329bfdac..64020d3846ef 100644 --- a/cloud/apps/push/src/durable-push-worker.ts +++ b/cloud/apps/push/src/durable-push-worker.ts @@ -71,7 +71,7 @@ export class DurablePushWorker { Math.min(30_000, 1000 * 2 ** Math.min(queued.attempts, 5)) ) : undefined - await this.store.finish(queued, retryAfterMs, outcome.status) + await this.store.finish(queued, retryAfterMs) } catch { await this.store.finish(queued, 5000) } finally { diff --git a/cloud/apps/push/src/push-background-database.test.ts b/cloud/apps/push/src/push-background-database.test.ts new file mode 100644 index 000000000000..994746d99cef --- /dev/null +++ b/cloud/apps/push/src/push-background-database.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'vitest' +import type { PushDatabase } from './push-database.js' +import { reserveRequestConnection } from './push-background-database.js' +import { createPushServer } from './push-server.js' +import { testPushConfig } from './push-server-harness.test-fixture.js' + +function concurrencyProbe() { + let active = 0 + let peak = 0 + const hold = async (value: T): Promise => { + peak = Math.max(peak, ++active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active-- + return value + } + const database: PushDatabase = { + dialect: 'postgres', + query: () => hold([]), + transaction: (operation) => hold(undefined).then(() => operation(database)), + lockQuotaScope: async () => undefined, + tryLockScope: async () => true, + tryLockSharedScope: async () => true, + close: async () => undefined + } + return { database, peak: () => peak } +} + +it.each([ + [2, 1], + [10, 9], + [1, 1] +])('lets background work hold at most poolMax - 1 of %i connections', async (poolMax, cap) => { + const probe = concurrencyProbe() + const background = reserveRequestConnection(probe.database, poolMax) + await Promise.all( + Array.from({ length: 12 }, (_, index) => + index % 2 ? background.query('SELECT 1') : background.transaction(async () => undefined) + ) + ) + expect(probe.peak()).toBe(cap) +}) + +it('caps the server worker at poolMax - 1 connections', async () => { + const probe = concurrencyProbe() + const server = createPushServer({ ...testPushConfig(), databasePoolMax: 3 }, probe.database, { + fcmAccessToken: async () => 'token', + fcmTransport: async () => ({ status: 200, body: '{}' }) + }) + try { + await server.worker.runDue() + } finally { + await server.worker.stop() + } + expect(probe.peak()).toBe(2) +}) diff --git a/cloud/apps/push/src/push-background-database.ts b/cloud/apps/push/src/push-background-database.ts new file mode 100644 index 000000000000..2ae6c0bdbaf2 --- /dev/null +++ b/cloud/apps/push/src/push-background-database.ts @@ -0,0 +1,28 @@ +import type { PushDatabase } from './push-database.js' + +// Caps worker and prune traffic at poolMax - 1 connections so request-path lookups always find one. +export function reserveRequestConnection(database: PushDatabase, poolMax: number): PushDatabase { + const capacity = Math.max(1, poolMax - 1) + let active = 0 + const waiting: (() => void)[] = [] + const admit = async (run: () => Promise): Promise => { + if (active >= capacity) await new Promise((resolve) => waiting.push(resolve)) + else active++ + try { + return await run() + } finally { + const next = waiting.shift() + if (next) next() + else active-- + } + } + return { + dialect: database.dialect, + query: (sql, params) => admit(() => database.query(sql, params)), + transaction: (operation) => admit(() => database.transaction(operation)), + lockQuotaScope: (key) => database.lockQuotaScope(key), + tryLockScope: (key) => database.tryLockScope(key), + tryLockSharedScope: (key) => database.tryLockSharedScope(key), + close: () => database.close() + } +} diff --git a/cloud/apps/push/src/push-database.ts b/cloud/apps/push/src/push-database.ts index 019a76a7cc4d..9957a7c28b52 100644 --- a/cloud/apps/push/src/push-database.ts +++ b/cloud/apps/push/src/push-database.ts @@ -22,6 +22,10 @@ export interface PushDatabase { // Serializes every transaction that reads then writes the same identity's // quota rows. Must be called inside a transaction; it releases at commit. lockQuotaScope(key: string): Promise + // Non-blocking variant: false means another transaction holds the scope. + tryLockScope(key: string): Promise + // Shared try-lock: holders of one key coexist, and an exclusive holder excludes them all. + tryLockSharedScope(key: string): Promise close(): Promise } @@ -55,6 +59,14 @@ class SqliteTransaction implements PushDatabase { // transaction, so there is nothing narrower left to take. async lockQuotaScope(): Promise {} + async tryLockScope(): Promise { + return true + } + + async tryLockSharedScope(): Promise { + return true + } + async close(): Promise {} } @@ -73,15 +85,17 @@ class SqliteDatabase extends SqliteTransaction { let release!: () => void this.tail = new Promise((resolve) => (release = resolve)) await previous - this.database.exec('BEGIN IMMEDIATE') - const transaction = new SqliteTransaction(this.database) try { - const result = await operation(transaction) - this.database.exec('COMMIT') - return result - } catch (error) { - this.database.exec('ROLLBACK') - throw error + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } } finally { release() } @@ -113,6 +127,21 @@ class PostgresTransaction implements PushDatabase { await this.query('SELECT pg_advisory_xact_lock(hashtext(?::text))', [key]) } + async tryLockScope(key: string): Promise { + const [row] = await this.query('SELECT pg_try_advisory_xact_lock(hashtext(?::text)) AS locked', [ + key + ]) + return row?.locked === true + } + + async tryLockSharedScope(key: string): Promise { + const [row] = await this.query( + 'SELECT pg_try_advisory_xact_lock_shared(hashtext(?::text)) AS locked', + [key] + ) + return row?.locked === true + } + async close(): Promise {} } @@ -182,6 +211,14 @@ class PostgresDatabase implements PushDatabase { throw new Error('lock_quota_scope_requires_transaction') } + async tryLockScope(): Promise { + throw new Error('lock_quota_scope_requires_transaction') + } + + async tryLockSharedScope(): Promise { + throw new Error('lock_quota_scope_requires_transaction') + } + async close(): Promise { await this.pool.end() } diff --git a/cloud/apps/push/src/push-queued-dismissal.ts b/cloud/apps/push/src/push-queued-dismissal.ts index 3ca5e8c7e66e..e1b48bcac53a 100644 --- a/cloud/apps/push/src/push-queued-dismissal.ts +++ b/cloud/apps/push/src/push-queued-dismissal.ts @@ -35,9 +35,11 @@ export async function reconcileQueuedDismissal( queued.notificationSeq > notification.notificationSeq ) continue + // The lease guard re-evaluates after a concurrent claim commits; that alert then goes out and + // this dismissal is delivered after it. await tx.query( - 'UPDATE push_delivery_batches SET payload_json = ?, state = ? WHERE batch_id = ?', - ['{}', 'dismissed', delivery.batch_id] + "DELETE FROM push_delivery_batches WHERE batch_id = ? AND state = 'pending' AND lease_until <= ?", + [delivery.batch_id, now] ) } return false diff --git a/cloud/apps/push/src/push-schema-boot-lock.test.ts b/cloud/apps/push/src/push-schema-boot-lock.test.ts new file mode 100644 index 000000000000..1c08fab56d11 --- /dev/null +++ b/cloud/apps/push/src/push-schema-boot-lock.test.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { afterEach, expect, it, vi } from 'vitest' +import { openPushDatabase } from './push-database.js' +import { durablePushTestDatabaseUrl } from './durable-push-store.test-fixture.js' + +const QUEUE_INDEXES = ['push_batches_pending_device'] +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +// Push applies its schema with retryLockTimeout, so a lock timeout is retried, never deferred: +// the new revision either boots with every index or does not boot at all. +it.skipIf(!durablePushTestDatabaseUrl)( + 'creates the queue index after waiting out a writer that holds the table', + async () => { + const admin = new pg.Client({ connectionString: durablePushTestDatabaseUrl }) + await admin.connect() + const schema = `boot_${randomUUID().replaceAll('-', '')}` + cleanups.push(async () => { + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await admin.end() + }) + await admin.query(`CREATE SCHEMA ${schema}`) + const url = new URL(durablePushTestDatabaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + await (await openPushDatabase({ databaseUrl: url.toString(), dataDir: '' })).close() + await admin.query( + `DROP INDEX ${QUEUE_INDEXES.map((name) => `${schema}.${name}`).join(', ')}` + ) + + const writer = new pg.Client({ connectionString: url.toString() }) + await writer.connect() + cleanups.push(() => writer.end()) + await writer.query('BEGIN') + await writer.query('LOCK TABLE push_delivery_batches IN ROW EXCLUSIVE MODE') + const warnings: string[] = [] + vi.spyOn(console, 'warn').mockImplementation((line: string) => warnings.push(line)) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const held = setTimeout(() => void writer.query('COMMIT'), 2_500) + cleanups.push(async () => clearTimeout(held)) + + const booted = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '' }) + await booted.close() + const { rows } = await admin.query( + 'SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND indexname = ANY($2) ORDER BY 1', + [schema, QUEUE_INDEXES] + ) + expect(rows.map((row) => row.indexname)).toEqual(QUEUE_INDEXES) + const events = warnings.map((line) => JSON.parse(line).event) + expect(events).toContain('orca_push_postgres_schema_retry') + expect(events).not.toContain('orca_push_postgres_schema_object_deferred') + } +) diff --git a/cloud/apps/push/src/push-server-auth.test.ts b/cloud/apps/push/src/push-server-auth.test.ts index a66a0c3bbe61..37f66f90e90c 100644 --- a/cloud/apps/push/src/push-server-auth.test.ts +++ b/cloud/apps/push/src/push-server-auth.test.ts @@ -32,6 +32,8 @@ describe('push gateway authentication and device routes', () => { }, transaction: async (operation) => await operation(unreachable), lockQuotaScope: async () => undefined, + tryLockScope: async () => true, + tryLockSharedScope: async () => true, close: async () => undefined } const broken = createPushServer(testPushConfig(), unreachable, { diff --git a/cloud/apps/push/src/push-server.ts b/cloud/apps/push/src/push-server.ts index 911f78901555..0aee3765570a 100644 --- a/cloud/apps/push/src/push-server.ts +++ b/cloud/apps/push/src/push-server.ts @@ -24,6 +24,7 @@ import { PushHostSessionStore } from './host-session-store.js' import type { PushDatabase } from './push-database.js' import { PushDispatcher } from './push-dispatcher.js' import { PushObservability } from './push-observability.js' +import { reserveRequestConnection } from './push-background-database.js' import { createPushReadiness } from './push-readiness.js' import { PushRequestDrain } from './push-request-drain.js' @@ -60,7 +61,11 @@ export function createPushServer( const challenges = new PushHostChallengeStore(database, config.publicUrl, now) const sessions = new PushHostSessionStore(database, now) const devices = new PushDeviceRegistryStore(database, now) - const deliveryStore = new DurablePushStore(database, now) + const deliveryStore = new DurablePushStore( + database, + now, + reserveRequestConnection(database, config.databasePoolMax) + ) const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null) const dispatcher = new PushDispatcher({ devices, diff --git a/cloud/apps/push/src/sqlite-transaction-queue.test.ts b/cloud/apps/push/src/sqlite-transaction-queue.test.ts new file mode 100644 index 000000000000..1ee5e8b3d52a --- /dev/null +++ b/cloud/apps/push/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openPushDatabase } from './push-database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-push.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts b/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts index 587143ed7e09..41f8ee49620c 100644 --- a/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts +++ b/cloud/apps/relay-ops/src/incident-live-preflight-cli.test.ts @@ -242,9 +242,16 @@ describe('relay incident live preflight', () => { await expect(runIncidentLivePreflight( ['--state-file', agedState(235 * 60_000 + 1), '--wave-index', '3'], deps )).rejects.toThrow('monitor evidence is incomplete or stale') - // The wave index is a strict single-use 0-3 argument. + // The last cell of a ten-cell same-cap batch: 10min + 9 * 75min exactly. await expect(runIncidentLivePreflight( - ['--state-file', stateFile(), '--wave-index', '4'], deps + ['--state-file', agedState(685 * 60_000), '--wave-index', '9'], deps + )).resolves.toBeUndefined() + await expect(runIncidentLivePreflight( + ['--state-file', agedState(685 * 60_000 + 1), '--wave-index', '9'], deps + )).rejects.toThrow('monitor evidence is incomplete or stale') + // The wave index is a strict single-use 0-9 argument. + await expect(runIncidentLivePreflight( + ['--state-file', stateFile(), '--wave-index', '10'], deps )).rejects.toThrow('usage:') await expect(runIncidentLivePreflight( ['--state-file', stateFile(), '--wave-index', ''], deps diff --git a/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts b/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts index 5183286e3024..99ea42e318e6 100644 --- a/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts +++ b/cloud/apps/relay-ops/src/incident-live-preflight-cli.ts @@ -30,7 +30,9 @@ const FRESHNESS_RETRY_INTERVAL_MS = 15_000 const MONITOR_EVIDENCE_MAX_AGE_MS = 10 * 60_000 // Matches the same-cap cell job timeout-minutes; bounds each predecessor wave. const WAVE_PREDECESSOR_TIMEOUT_MS = 75 * 60_000 -const WAVE_INDEX_PATTERN = /^[0-3]$/ +// Widest any wave chain declares (same-cap's cell_1..cell_10); each job workflow +// pins its own narrower range. +const WAVE_INDEX_PATTERN = /^[0-9]$/ // 2 for a general cell's isolate-and-restore wave, 0 for a migration-only cell's no-op pair. const SELECTOR_WAVE_DELTA_PATTERN = /^[02]$/ diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index 0dcd3a68f9ad..cb3a47f7f010 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -2,7 +2,7 @@ import { AssignmentRequestSchema, IdleRegionalRehomeRequestSchema, type IdleRegionalRehomeRequest, - type IdleRegionalRehomeOutcome, + type IdleRegionalRehomeResult, type RegionCorrectionResponse, isRelayCellConnectionHardCap, RELAY_ADMISSION_BUDGETS, @@ -79,7 +79,7 @@ export function createRelayApp( idleRehome?: (input: IdleRegionalRehomeRequest & { cohortPercent: number directorSafety: RegionalRehomeSafetySnapshot - }) => Promise<{ outcome: IdleRegionalRehomeOutcome }> + }) => Promise drainHost?: (input: { attemptId: string userId: string @@ -1614,7 +1614,15 @@ const AdminAdmissionSelectorApplySchema = z attemptId: AdmissionSelectorAttemptIdSchema, expectedGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), expectedMembershipSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), - membership: AdmissionSelectorMembershipSchema + membership: AdmissionSelectorMembershipSchema, + // Optional, so an older caller reaching an updated director is unchanged: + // the cell goes unmarked and its hosts stay pinned, today's behaviour. The + // other direction is NOT ignored — the schema below is .strict(), so an + // updated caller reaching an older director is a 400. That fails closed, + // before the isolate step sets MUTATION_STARTED and before anything is + // written, but it is a deploy ordering constraint: the director ships + // first, then any workflow run that uses the updated script. + rollIsolatedCells: z.array(CellIdSchema).max(256).optional() }) .strict() .refine( diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts index 02c21f76ada5..491dde21962d 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts @@ -16,6 +16,7 @@ function stubStore(overrides: Partial = {}) { completeReadyEvacuations: method('completeReadyEvacuations'), completeReadyRegionalRehomes: method('completeReadyRegionalRehomes'), abortExpiredEvacuations: method('abortExpiredEvacuations'), + abortUnarrivedRegionalRehomes: method('abortUnarrivedRegionalRehomes'), abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'), reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'), releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'), @@ -42,6 +43,7 @@ describe('assignment cleanup steps', () => { 'refreshRegionalRehomeLeases', 'completeReadyEvacuations', 'abortExpiredEvacuations', + 'abortUnarrivedRegionalRehomes', 'abortExpiredRegionalRehomes', 'reapRegionalRehomeAttempts', 'releaseExpiredActivityLeases', @@ -55,13 +57,13 @@ describe('assignment cleanup steps', () => { ) }) - it('covers all ten sweeps exactly once per run', async () => { + it('covers all eleven sweeps exactly once per run', async () => { const { store, calls } = stubStore() await runAssignmentCleanup(store) - expect(calls).toHaveLength(10) - expect(new Set(calls).size).toBe(10) - expect(assignmentCleanupSteps(store)).toHaveLength(10) + expect(calls).toHaveLength(11) + expect(new Set(calls).size).toBe(11) + expect(assignmentCleanupSteps(store)).toHaveLength(11) }) }) diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.ts b/cloud/apps/relay/src/assignment-cleanup-steps.ts index 0b6547d2ae41..b6fee0fdaa55 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.ts @@ -1,9 +1,9 @@ import { runRelayBackgroundOperation } from './relay-background-operation.js' -// The ten periodic assignment sweeps the director runs every 30s. Each step +// The eleven periodic assignment sweeps the director runs every 30s. Each step // re-derives its state from the database and is idempotent, so they carry no // intra-tick ordering dependency — which is what makes per-step isolation -// sound: one failing sweep costs one tick of itself, never the other nine. +// sound: one failing sweep costs one tick of itself, never the other ten. // (A single poisoned rehome row once silenced the whole chained form // fleet-wide.) Sweep failures are logged, never fed into the rehome worker's // dispatch-failure budget: a sweep exception is not a dispatch failure and @@ -13,6 +13,7 @@ export type AssignmentCleanupStore = { completeReadyEvacuations(): Promise completeReadyRegionalRehomes(): Promise abortExpiredEvacuations(): Promise + abortUnarrivedRegionalRehomes(): Promise abortExpiredRegionalRehomes(): Promise reapRegionalRehomeAttempts(): Promise releaseExpiredActivityLeases(): Promise @@ -29,6 +30,7 @@ export function assignmentCleanupSteps( ['complete-ready-evacuations', () => assignments.completeReadyEvacuations()], ['complete-ready-regional-rehomes', () => assignments.completeReadyRegionalRehomes()], ['abort-expired-evacuations', () => assignments.abortExpiredEvacuations()], + ['abort-unarrived-regional-rehomes', () => assignments.abortUnarrivedRegionalRehomes()], ['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()], ['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()], ['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()], diff --git a/cloud/apps/relay/src/assignment-connection-headroom.test.ts b/cloud/apps/relay/src/assignment-connection-headroom.test.ts index 19f5d46aa336..21e1d6aa110b 100644 --- a/cloud/apps/relay/src/assignment-connection-headroom.test.ts +++ b/cloud/apps/relay/src/assignment-connection-headroom.test.ts @@ -448,34 +448,48 @@ describe('relay assignment connection headroom', () => { }) }) - it.each(['existing-only', 'migration-only'] as const)( - 'keeps a zero-activity assignment pinned on a %s cell without connection headroom', - async (admission) => { - const { database, store, source } = await setupHeadroomReassignment() - const identity = { - userId: `pinned-${admission}-user`, - relayHostId: `pinned${admission.replace('-', '')}` - } - await store.setCellAdmissionState(source.id, admission) - await database.query( - `INSERT INTO relay_assignments - (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, - last_activity_at, reserved_controls, reserved_splices, reserved_invites, - pending_installs, pending_confirmations, migration_leases) - VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)`, - [identity.userId, identity.relayHostId, source.id, 7, 10_000, 100] - ) + it('keeps a zero-activity assignment pinned on an existing-only cell', async () => { + const { database, store, source } = await setupHeadroomReassignment() + const identity = { userId: 'pinned-existing-only-user', relayHostId: 'pinnedexistingonl' } + await store.setCellAdmissionState(source.id, 'existing-only') + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, + last_activity_at, reserved_controls, reserved_splices, reserved_invites, + pending_installs, pending_confirmations, migration_leases) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)`, + [identity.userId, identity.relayHostId, source.id, 7, 10_000, 100] + ) - await expect(store.assign(identity)).rejects.toThrow( - 'relay_connection_headroom_exhausted' - ) - expect(await store.resolve(identity)).toMatchObject({ - cellId: source.id, - assignmentEpoch: 7 - }) - expect(await database.query(`SELECT * FROM relay_assignment_activity_leases`)).toEqual([]) - } - ) + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + expect(await store.resolve(identity)).toMatchObject({ + cellId: source.id, + assignmentEpoch: 7 + }) + expect(await database.query(`SELECT * FROM relay_assignment_activity_leases`)).toEqual([]) + }) + + it('keeps a zero-activity assignment pinned on an unstamped migration-only cell', async () => { + // Why: an evacuation target is deliberately migration-only and deliberately + // full. Without the roll's isolate stamp it must keep the hosts it holds. + const { database, store, source } = await setupHeadroomReassignment() + const identity = { userId: 'pinned-migration-only-user', relayHostId: 'pinnedmigrationonly' } + await store.setCellAdmissionState(source.id, 'migration-only') + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, + last_activity_at, reserved_controls, reserved_splices, reserved_invites, + pending_installs, pending_confirmations, migration_leases) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)`, + [identity.userId, identity.relayHostId, source.id, 7, 10_000, 100] + ) + + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + expect(await store.resolve(identity)).toMatchObject({ + cellId: source.id, + assignmentEpoch: 7 + }) + }) it('renames a pending reservation exactly once on control activation', async () => { const { database, store } = await setup(449) diff --git a/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts b/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts index afc35f7e6014..0e55a22a3dff 100644 --- a/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts +++ b/cloud/apps/relay/src/assignment-inventory-snapshot.test.ts @@ -71,6 +71,7 @@ describe('assignment inventory snapshot', () => { targetRegistered: 0, completedLast24Hours: 0, abortedLast24Hours: 0, + hostNotArrivedLast24Hours: 0, oldestActiveAgeMs: null }) diff --git a/cloud/apps/relay/src/assignment-inventory-snapshot.ts b/cloud/apps/relay/src/assignment-inventory-snapshot.ts index 652fbdf21842..9e12610492fa 100644 --- a/cloud/apps/relay/src/assignment-inventory-snapshot.ts +++ b/cloud/apps/relay/src/assignment-inventory-snapshot.ts @@ -1,5 +1,6 @@ import { RELAY_DEFAULT_REGION } from '@orca-cloud/relay-contract' import type { RelayDatabase, SqlRow } from './database.js' +import { REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS } from './regional-rehome-abort-reason.js' export type CellInventorySnapshotRow = { cellId: string @@ -22,6 +23,7 @@ export type AssignmentInventorySnapshot = { targetRegistered: number completedLast24Hours: number abortedLast24Hours: number + hostNotArrivedLast24Hours: number oldestActiveAgeMs: number | null } } @@ -79,6 +81,10 @@ export async function readAssignmentInventorySnapshot( AS completed_last_24_hours, COALESCE(SUM(CASE WHEN attempt.aborted_at >= ? THEN 1 ELSE 0 END), 0) AS aborted_last_24_hours, + COALESCE(SUM(CASE WHEN attempt.aborted_at >= ? + AND attempt.abort_reason = 'host_not_arrived' + THEN 1 ELSE 0 END), 0) + AS host_not_arrived_last_24_hours, MIN(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL THEN attempt.created_at END) AS oldest_active_at FROM relay_region_rehome_attempts attempt @@ -86,7 +92,11 @@ export async function readAssignmentInventorySnapshot( ON migration.user_id = attempt.user_id AND migration.relay_host_id = attempt.relay_host_id AND migration.assignment_epoch = attempt.assignment_epoch`, - [now - 24 * 60 * 60_000, now - 24 * 60 * 60_000] + [ + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS, + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS, + now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS + ] ) )[0] const oldestActiveAt = optionalInteger(regionalRehomeRow, 'oldest_active_at') @@ -117,6 +127,10 @@ export async function readAssignmentInventorySnapshot( targetRegistered: asInteger(regionalRehomeRow, 'target_registered'), completedLast24Hours: asInteger(regionalRehomeRow, 'completed_last_24_hours'), abortedLast24Hours: asInteger(regionalRehomeRow, 'aborted_last_24_hours'), + hostNotArrivedLast24Hours: asInteger( + regionalRehomeRow, + 'host_not_arrived_last_24_hours' + ), oldestActiveAgeMs: oldestActiveAt === null ? null : now - oldestActiveAt } } @@ -147,6 +161,7 @@ export function formatAssignmentInventorySnapshot( ` targetRegistered=${snapshot.regionalRehomes.targetRegistered}` + ` completedLast24Hours=${snapshot.regionalRehomes.completedLast24Hours}` + ` abortedLast24Hours=${snapshot.regionalRehomes.abortedLast24Hours}` + + ` hostNotArrivedLast24Hours=${snapshot.regionalRehomes.hostNotArrivedLast24Hours}` + ` oldestActiveAgeMs=${snapshot.regionalRehomes.oldestActiveAgeMs ?? 'none'}` ) return lines diff --git a/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts b/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts new file mode 100644 index 000000000000..678c6350303a --- /dev/null +++ b/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts @@ -0,0 +1,364 @@ +import { createHash } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { + encodeMembership, + type CellAdmissionMembership, + type CellAdmissionState +} from './cell-admission-selector.js' +import type { RelayCellConfig } from './config.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +const USER_PREFIX = 'isolated-replacement-postgres' +const NOW = 100 +const CAPPED = { + capacityRequests: 1_000, + connectionHardCap: 600, + connectionUnobservedBound: 50 +} as const +const ISOLATED: RelayCellConfig = { + id: 'isolated-replacement-source', + url: 'https://isolated-replacement-source.example.com', + region: 'us-central1', + ...CAPPED +} +const TARGETS: RelayCellConfig[] = [ + { + id: 'isolated-replacement-target-a', + url: 'https://isolated-replacement-target-a.example.com', + region: 'us-central1', + ...CAPPED + }, + { + id: 'isolated-replacement-target-b', + url: 'https://isolated-replacement-target-b.example.com', + region: 'us-central1', + ...CAPPED + } +] +const CELLS = [ISOLATED, ...TARGETS] +const HOST_COUNT = 50 + +function hostIdentity(index: number): { userId: string; relayHostId: string } { + return { + userId: `${USER_PREFIX}-${index}`, + // relay host ids are fixed-width opaque ids. + relayHostId: `isolatedhost${String(index).padStart(4, '0')}` + } +} + +describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => { + const databases: RelayDatabase[] = [] + let stores: RelayAssignmentStore[] = [] + + async function reservedRequests(cellId: string): Promise { + const rows = await databases[0]!.query( + `SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, + [cellId] + ) + return Number(rows[0]!['reserved_requests']) + } + + async function heartbeatAll(): Promise { + for (const [index, cell] of CELLS.entries()) { + await stores[0]!.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: `1111111${index}-1111-4111-8111-111111111111`, + startedAt: 50, + ready: true, + observedRequests: 0, + region: cell.region, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + } + } + + // The real isolate path: one selector apply, generation CAS and all, naming + // the cells it stamps. Nothing else in the fleet may write the stamp. + async function applySelector( + states: Record, + rollIsolatedCells?: string[] + ): Promise { + const current = await stores[0]!.inspectCellAdmissionSelector() + // Built from relay_cells, not from the selector's own membership: the apply + // requires exact coverage of the fleet, and this database is shared. + const fleet = await databases[0]!.query( + `SELECT cell_id FROM relay_cells ORDER BY cell_id ASC` + ) + const membership: CellAdmissionMembership = { + existingOnly: [], + migrationOnly: [], + general: [] + } + for (const row of fleet) { + const cellId = String(row['cell_id']) + const state = + states[cellId] ?? + (current.selector.membership.existingOnly.includes(cellId) + ? 'existing-only' + : current.selector.membership.migrationOnly.includes(cellId) + ? 'migration-only' + : 'general') + if (state === 'existing-only') membership.existingOnly.push(cellId) + else if (state === 'migration-only') membership.migrationOnly.push(cellId) + else membership.general.push(cellId) + } + await stores[0]!.applyCellAdmissionSelector({ + attemptId: `isolated-${current.selector.generation}-${Object.keys(states).join('-')}`, + expectedGeneration: current.selector.generation, + ...(current.selector.generation === 0 + ? { + expectedMembershipSha256: createHash('sha256') + .update(encodeMembership(current.selector.membership)) + .digest('hex') + } + : {}), + membership, + ...(rollIsolatedCells ? { rollIsolatedCells } : {}) + }) + } + + async function rollIsolatedAt(cellId: string): Promise { + const rows = await databases[0]!.query( + `SELECT roll_isolated_at FROM relay_cell_admission WHERE cell_id = ?`, + [cellId] + ) + const value = rows[0]?.['roll_isolated_at'] + return value === undefined || value === null ? null : Number(value) + } + + // Every case starts from the whole fleet general; a case that leaves a cell + // isolated would otherwise starve the next one of placement candidates. + async function resetFleet(): Promise { + await deleteHostRows() + await applySelector(Object.fromEntries(CELLS.map((cell) => [cell.id, 'general']))) + } + + // The selector is fleet-wide and this database is shared with every other + // Postgres file in the project, all of which write admission through the + // generation-0 helpers. Advancing the generation and leaving it advanced + // would fail every one of them with admission_selector_boundary_active, so + // this file puts the boundary back exactly as it found it. + async function resetSelectorBoundary(): Promise { + await databases[0]!.query( + `UPDATE relay_admission_selectors SET generation = 0, attempt_id = NULL + WHERE selector_id = 'general'` + ) + await databases[0]!.query( + `DELETE FROM relay_admission_selector_intents WHERE attempt_id LIKE 'isolated-%'` + ) + // Rewrites membership_json from the live fleet, which generation 0 allows. + await stores[0]!.reconcileCells([], false) + } + + async function deleteHostRows(): Promise { + for (const table of [ + 'relay_control_connection_reservations', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignments' + ]) { + await databases[0]!.query(`DELETE FROM ${table} WHERE user_id LIKE '${USER_PREFIX}-%'`) + } + } + + beforeAll(async () => { + // Four connections so the concurrent dials below really contend on the + // fleet-wide relay_cells lock rather than queueing in one client. + for (let index = 0; index < 4; index += 1) { + databases.push(await openRelayDatabase({ databaseUrl, dataDir: '' })) + } + stores = databases.map( + (database) => + new RelayAssignmentStore(database, () => NOW, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + ) + await deleteHostRows() + // Registers the cell rows without touching admission, so this works at any + // selector generation the shared database happens to be sitting at. + await stores[0]!.reconcileCells(CELLS, false) + await resetSelectorBoundary() + await heartbeatAll() + }) + + afterAll(async () => { + if (databases[0]) { + await deleteHostRows() + // Order matters: drop the cells first, then rebuild the selector's + // membership from what is left, or it names rows that no longer exist and + // every later read fails admission_selector_membership_drift. + for (const cell of CELLS) { + for (const table of [ + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_connection_limits', + 'relay_cell_runtime', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await databases[0].query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) + } + } + await resetSelectorBoundary() + } + for (const connection of databases) await connection.close() + }) + + it('stamps only the named cell on isolate and clears it on restore', async () => { + await resetFleet() + expect(await rollIsolatedAt(ISOLATED.id)).toBeNull() + + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + const stamped = await rollIsolatedAt(ISOLATED.id) + expect(stamped).toBe(NOW) + // Cells the isolate did not name stay unmarked even when parked in the same + // apply: that is every non-roll flow, and its hosts must keep their pin. + await applySelector({ [TARGETS[0]!.id]: 'migration-only' }) + expect(await rollIsolatedAt(TARGETS[0]!.id)).toBeNull() + + // A failed wave re-isolates rather than restoring; the stamp survives, and + // does not move, so the hosts left behind stay eligible. + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + expect(await rollIsolatedAt(ISOLATED.id)).toBe(stamped) + + // Restore writes 'general', and the same statement clears the stamp. + await applySelector({ [ISOLATED.id]: 'general' }) + expect(await rollIsolatedAt(ISOLATED.id)).toBeNull() + }, 30_000) + + it('ignores a stamp older than the roll it is supposed to describe', async () => { + // A failed wave keeps its stamp on purpose and can sit for hours; past the + // bound the cell stops shedding hosts one dial at a time. + await resetFleet() + const identity = hostIdentity(902) + const first = await stores[0]!.assign(identity, 'us-central1') + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + await databases[0]!.query( + `UPDATE relay_cell_admission SET roll_isolated_at = ? WHERE cell_id = ?`, + [NOW - (2 * 60 * 60_000 + 1), ISOLATED.id] + ) + + expect(await stores[0]!.assign(identity, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }, 30_000) + + it('re-places every host off an isolated cell without leaking a reservation', async () => { + await resetFleet() + const identities = Array.from({ length: HOST_COUNT }, (_, index) => hostIdentity(index)) + const sourceBaseline = await reservedRequests(ISOLATED.id) + const targetBaseline = + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + + // Everyone lands on the cell about to be isolated. + await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' }) + const first = new Map() + for (const identity of identities) { + const grant = await stores[0]!.assign(identity, 'us-central1') + expect(grant.cellId).toBe(ISOLATED.id) + first.set(identity.relayHostId, grant) + } + await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' }) + + // The isolate step. The state and the stamp are written by one UPDATE under + // the fleet-wide relay_cells lock, so there is no torn state to race against. + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + expect(await rollIsolatedAt(ISOLATED.id)).not.toBeNull() + + const grants = await Promise.all( + identities.map( + async (identity, index) => + await stores[1 + (index % (stores.length - 1))]!.assign(identity, 'us-central1') + ) + ) + + for (const [index, grant] of grants.entries()) { + const identity = identities[index]! + expect(grant.cellId).not.toBe(ISOLATED.id) + expect(TARGETS.map(({ id }) => id)).toContain(grant.cellId) + // Exactly once: a double bump would mean two transactions both moved it. + expect(grant.assignmentEpoch).toBe(first.get(identity.relayHostId)!.assignmentEpoch + 1) + } + + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline) + expect( + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + ).toBe(targetBaseline + HOST_COUNT) + + const rows = await databases[0]!.query( + `SELECT COUNT(*) AS count FROM relay_assignments + WHERE user_id LIKE '${USER_PREFIX}-%' AND cell_id = ?`, + [ISOLATED.id] + ) + expect(Number(rows[0]!['count'])).toBe(0) + }, 60_000) + + it('lets exactly one of a host’s racing dials win the re-placement', async () => { + await resetFleet() + const identity = hostIdentity(900) + await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' }) + const first = await stores[0]!.assign(identity, 'us-central1') + expect(first.cellId).toBe(ISOLATED.id) + await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' }) + const targetBaseline = + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + const sourceBefore = await reservedRequests(ISOLATED.id) + + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + + // Two dials from the same host, on separate connections, through the same + // sticky lane. The per-assignment row lock is what must serialise them. + const raced = await Promise.allSettled([ + stores[1]!.assign(identity, 'us-central1'), + stores[2]!.assign(identity, 'us-central1') + ]) + const granted = raced.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ) + expect(granted.length).toBeGreaterThan(0) + + // However many dials were granted, only one re-placement may have + // committed: the epoch advances by exactly one and the reservation moves + // exactly one unit. + const settled = await stores[0]!.resolve(identity) + expect(settled?.assignmentEpoch).toBe(first.assignmentEpoch + 1) + expect(settled?.cellId).not.toBe(ISOLATED.id) + for (const grant of granted) expect(grant.cellId).toBe(settled?.cellId) + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore - 1) + expect( + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + ).toBe(targetBaseline + 1) + }, 30_000) + + it('grants no host the isolated cell after the admission flip commits', async () => { + await resetFleet() + const identity = hostIdentity(901) + const first = await stores[0]!.assign(identity, 'us-central1') + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + + for (let dial = 0; dial < 5; dial += 1) { + const grant = await stores[1 + (dial % (stores.length - 1))]!.assign( + identity, + 'us-central1' + ) + expect(grant.cellId).not.toBe(ISOLATED.id) + } + // Only the first dial re-places; the rest are ordinary sticky re-grants. + expect((await stores[0]!.resolve(identity))?.assignmentEpoch).toBe( + first.assignmentEpoch + 1 + ) + }, 30_000) +}) diff --git a/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts b/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts new file mode 100644 index 000000000000..cc1ae6a26bb0 --- /dev/null +++ b/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts @@ -0,0 +1,609 @@ +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore, RelayHomeCellUnavailableError } from './assignment-store.js' +import { + encodeMembership, + type CellAdmissionMembership, + type CellAdmissionState +} from './cell-admission-selector.js' +import type { RelayCellConfig } from './config.js' +import { + openInMemoryRelayDatabase, + type RelayDatabase, + type RelayLockOptions, + type RelayTransactionOptions, + type SqlRow +} from './database.js' + +// `migration-only` alone says nothing about a roll: it is the admission class +// (cloud/docs/orca-relay-operations.md:229-233) that evacuation targets, Asia +// `--mode rollback`, a failed wave's re-isolate and newly registered cells all +// occupy durably while holding hosts. The same-cap roll's isolate step is the +// only writer of the roll stamp, via `rollIsolatedCells` on the selector apply +// (cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs isolate mode); +// restore moves the cell to 'general', which clears the stamp in the same +// statement that writes the state. +const HEARTBEAT_TTL_MS = 45_000 +const START_MS = 100 +const IDENTITY = { userId: 'user-a', relayHostId: 'host000000000001' } + +// A connection-limited cell is what makes deadCellRequiresCommittedFence true, +// which is the branch that answers 503 relay_home_cell_unavailable. +const CAPPED = { + capacityRequests: 1_000, + connectionHardCap: 600, + connectionUnobservedBound: 50 +} as const +const CELLS: RelayCellConfig[] = [ + { id: 'us-c1', url: 'https://us-c1.example.com', region: 'us-central1', ...CAPPED }, + { id: 'us-c2', url: 'https://us-c2.example.com', region: 'us-central1', ...CAPPED }, + { id: 'asia-c1', url: 'https://asia-c1.example.com', region: 'asia-east2', ...CAPPED } +] + +const databases: RelayDatabase[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +// Counts the reads this change adds, so the general hot path can be held to a +// single admission lookup and no migration lookup at all. +class QueryCountingDatabase implements RelayDatabase { + readonly sql: string[] = [] + // Fails the first statement containing this fragment, so a test can roll a + // transaction back at a chosen point. + failOnce: string | undefined + + constructor(private readonly delegate: RelayDatabase) {} + + get dialect(): 'sqlite' | 'postgres' | undefined { + return this.delegate.dialect + } + + count(fragment: string): number { + return this.sql.filter((statement) => statement.includes(fragment)).length + } + + record(sql: string): void { + this.sql.push(sql) + if (this.failOnce !== undefined && sql.includes(this.failOnce)) { + this.failOnce = undefined + throw new Error('injected_placement_write_failure') + } + } + + async query(sql: string, params?: unknown[]): Promise { + this.record(sql) + return await this.delegate.query(sql, params) + } + + async queryLocked( + sql: string, + params?: unknown[], + options?: RelayLockOptions + ): Promise { + this.record(sql) + return await this.delegate.queryLocked(sql, params, options) + } + + async transaction( + operation: (transaction: RelayDatabase) => Promise, + options?: RelayTransactionOptions + ): Promise { + return await this.delegate.transaction( + async (inner) => await operation(this.recording(inner)), + options + ) + } + + async close(): Promise { + await this.delegate.close() + } + + // A nested transaction shares this one's tape, so a whole assign() is + // measured as a unit. + private recording(inner: RelayDatabase): RelayDatabase { + return { + dialect: inner.dialect, + query: async (sql, params) => { + this.record(sql) + return await inner.query(sql, params) + }, + queryLocked: async (sql, params, options) => { + this.record(sql) + return await inner.queryLocked(sql, params, options) + }, + transaction: async (operation, options) => await inner.transaction(operation, options), + close: async () => await inner.close() + } + } +} + +interface Harness { + store: RelayAssignmentStore + database: RelayDatabase + counter: QueryCountingDatabase + heartbeat: (cell: RelayCellConfig, at?: number) => Promise + setNow: (value: number) => void + /** The real isolate path: one selector apply that names the cell it stamps. */ + isolateForRoll: (cellId: string) => Promise + /** The real restore path: back to 'general', which clears the stamp. */ + restore: (cellId: string) => Promise + /** An admission move with no stamp — every flow that is not a same-cap roll. */ + park: (cellId: string, state: CellAdmissionState) => Promise + rollIsolatedAt: (cellId: string) => Promise +} + +async function setup(cells: RelayCellConfig[] = CELLS): Promise { + const inner = await openInMemoryRelayDatabase() + databases.push(inner) + const counter = new QueryCountingDatabase(inner) + let now = START_MS + const store = new RelayAssignmentStore(counter, () => now, { + requireLiveCells: true, + heartbeatTtlMs: HEARTBEAT_TTL_MS + }) + await store.reconcileCells(cells, true) + const heartbeat = async (cell: RelayCellConfig, at?: number): Promise => { + const previous = now + if (at !== undefined) now = at + try { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: `1111111${cells.indexOf(cell)}-1111-4111-8111-111111111111`, + startedAt: 50, + ready: true, + observedRequests: 0, + region: cell.region, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: cell.connectionHardCap ?? 600, + connectionUnobservedBound: cell.connectionUnobservedBound ?? 50 + }) + } finally { + if (at !== undefined) now = previous + } + } + for (const cell of cells) await heartbeat(cell) + + const applySelector = async ( + states: Record, + rollIsolatedCells?: string[] + ): Promise => { + const current = await store.inspectCellAdmissionSelector() + const membership: CellAdmissionMembership = { + existingOnly: [], + migrationOnly: [], + general: [] + } + for (const cell of cells) { + const state = + states[cell.id] ?? + (current.selector.membership.migrationOnly.includes(cell.id) + ? 'migration-only' + : current.selector.membership.existingOnly.includes(cell.id) + ? 'existing-only' + : 'general') + if (state === 'migration-only') membership.migrationOnly.push(cell.id) + else if (state === 'existing-only') membership.existingOnly.push(cell.id) + else membership.general.push(cell.id) + } + await store.applyCellAdmissionSelector({ + attemptId: `attempt-${current.selector.generation}-${Object.keys(states).join('-')}`, + expectedGeneration: current.selector.generation, + ...(current.selector.generation === 0 + ? { + expectedMembershipSha256: createHash('sha256') + .update(encodeMembership(current.selector.membership)) + .digest('hex') + } + : {}), + membership, + ...(rollIsolatedCells ? { rollIsolatedCells } : {}) + }) + } + + return { + store, + database: inner, + counter, + heartbeat, + setNow: (value: number) => (now = value), + isolateForRoll: async (cellId) => + await applySelector({ [cellId]: 'migration-only' }, [cellId]), + restore: async (cellId) => await applySelector({ [cellId]: 'general' }), + park: async (cellId, state) => await applySelector({ [cellId]: state }), + rollIsolatedAt: async (cellId) => { + const rows = await inner.query( + `SELECT roll_isolated_at FROM relay_cell_admission WHERE cell_id = ?`, + [cellId] + ) + const value = rows[0]?.['roll_isolated_at'] + return value === undefined || value === null ? null : Number(value) + } + } +} + +async function insertMigration( + database: RelayDatabase, + input: { + sourceCellId: string + targetCellId: string + assignmentEpoch: number + leases: number + settled?: 'completed' | 'aborted' + } +): Promise { + await database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, + assignment_epoch, source_request_units, target_reserved_units, expires_at, + target_registered_at, completed_at, aborted_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 1, ?, NULL, ?, ?, ?, ?)`, + [ + IDENTITY.userId, + IDENTITY.relayHostId, + input.sourceCellId, + input.targetCellId, + input.assignmentEpoch - 1, + input.assignmentEpoch, + START_MS + 900_000, + input.settled === 'completed' ? START_MS : null, + input.settled === 'aborted' ? START_MS : null, + START_MS, + START_MS + ] + ) + await database.query( + `UPDATE relay_assignments SET migration_leases = ? + WHERE user_id = ? AND relay_host_id = ?`, + [input.leases, IDENTITY.userId, IDENTITY.relayHostId] + ) +} + +type ConsoleWarnSpy = { mock: { calls: unknown[][] } } + +function jsonEvents(warn: ConsoleWarnSpy, event: string): unknown[] { + return warn.mock.calls + .map((call) => (typeof call[0] === 'string' ? call[0] : '')) + .filter((line) => line.includes(`"${event}"`)) + .map((line) => JSON.parse(line) as unknown) +} + +describe('re-placing a host off a cell isolated for a roll', () => { + it('leaves a host on a live general cell untouched, at one added admission read', async () => { + const { store, counter } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + + counter.sql.length = 0 + const second = await store.assign(IDENTITY, 'us-central1') + + // Identical, not merely same-celled: the clock has not moved, so every + // field including the lease deadline must match. + expect(second).toEqual(first) + // One row read answers both the stranded rule and the roll-stamp check. + expect(counter.count('relay_cell_admission')).toBe(1) + expect(counter.count('relay_assignment_migrations')).toBe(0) + // No placement: the sticky lane never reaches the fleet-wide inventory. + expect(counter.count('ORDER BY cell_id ASC')).toBe(0) + }) + + it('keeps a host pinned to a migration-only cell with no roll stamp', async () => { + // The guard for every non-roll flow that parks a loaded cell: an Asia + // `--mode rollback`, an evacuation target awaiting promotion, a failed + // wave's re-isolate. Moving these hosts would undo the operator's intent. + const { store, park, rollIsolatedAt } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await park(first.cellId, 'migration-only') + + expect(await rollIsolatedAt(first.cellId)).toBeNull() + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('re-places a host once the roll stamp is set', async () => { + const { store, counter, isolateForRoll, rollIsolatedAt } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + expect(await rollIsolatedAt(first.cellId)).toBe(START_MS) + counter.sql.length = 0 + const moved = await store.assign(IDENTITY, 'us-central1') + + expect(moved.cellId).not.toBe(first.cellId) + expect(moved.assignmentEpoch).toBe(first.assignmentEpoch + 1) + expect(counter.count('relay_assignment_migrations')).toBeGreaterThan(0) + + // Durable: the next dial does not bounce back to the isolated cell. + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: moved.cellId, + assignmentEpoch: moved.assignmentEpoch + }) + }) + + it.each([ + { label: 'just inside the bound', age: 2 * 60 * 60_000 - 1, moves: true }, + { label: 'just outside the bound', age: 2 * 60 * 60_000 + 1, moves: false } + ])('treats a stamp $label as $moves', async ({ age, moves }) => { + // Why the bound exists: a roll isolates and restores inside ~15 minutes, so + // an older stamp is a failed wave waiting on an operator, or an orphan left + // by a director rollback whose restore predates the clearing clause. Both + // mean a possibly healthy cell, and the safe answer is the pre-existing one. + const { store, heartbeat, isolateForRoll, setNow } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + const later = START_MS + age + setNow(later) + for (const cell of CELLS) await heartbeat(cell, later) + const grant = await store.assign(IDENTITY, 'us-central1') + + if (moves) { + expect(grant.cellId).not.toBe(first.cellId) + expect(grant.assignmentEpoch).toBe(first.assignmentEpoch + 1) + } else { + expect(grant.cellId).toBe(first.cellId) + expect(grant.assignmentEpoch).toBe(first.assignmentEpoch) + } + }) + + it('stops re-placing once restore clears the stamp', async () => { + const { store, isolateForRoll, restore, rollIsolatedAt } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + await restore(first.cellId) + + expect(await rollIsolatedAt(first.cellId)).toBeNull() + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('keeps a host pinned to an existing-only cell that still serves it', async () => { + // Why: existing-only cells serve the hosts they already hold (PR #194). Only + // assignmentStrandedOnUnservedCell may release that pin, and only on proof + // the cell stopped serving this host. + const { store, database, heartbeat, park, setNow } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await park(first.cellId, 'existing-only') + await database.query( + `INSERT INTO relay_assignment_activity_leases + (user_id, relay_host_id, activity_id, activity_kind, cell_id, + request_units, expires_at, updated_at) + VALUES (?, ?, 'control:live-1', 'control', ?, 1, ?, ?)`, + [IDENTITY.userId, IDENTITY.relayHostId, first.cellId, START_MS + 600_000, START_MS] + ) + + // Past the stranded rule's minimum grant age, which outlives the heartbeat TTL. + setNow(START_MS + 61_000) + for (const cell of CELLS) await heartbeat(cell, START_MS + 61_000) + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('keeps a host pinned on an unstamped cell whose migration has completed', async () => { + // The terminal state of a successful evacuation: the host's row points at + // the target, the migration is completed, and the cell waits migration-only + // for a separate promote dispatch. Re-placing here undoes the evacuation. + const { store, database, park } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await insertMigration(database, { + sourceCellId: 'us-c2', + targetCellId: first.cellId, + assignmentEpoch: first.assignmentEpoch, + leases: 0, + settled: 'completed' + }) + await park(first.cellId, 'migration-only') + + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('re-places a stamped cell whose migration has completed', async () => { + const { store, database, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await insertMigration(database, { + sourceCellId: 'us-c2', + targetCellId: first.cellId, + assignmentEpoch: first.assignmentEpoch, + leases: 0, + settled: 'completed' + }) + await isolateForRoll(first.cellId) + + expect((await store.assign(IDENTITY, 'us-central1')).cellId).not.toBe(first.cellId) + }) + + it('keeps the pin while a migration lease is outstanding', async () => { + const { store, database, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await insertMigration(database, { + sourceCellId: first.cellId, + targetCellId: 'us-c2', + assignmentEpoch: first.assignmentEpoch, + leases: 1 + }) + await isolateForRoll(first.cellId) + + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('keeps the pin while a migration row is open but its lease has lapsed', async () => { + // Why: the durable relay_assignment_migrations row outlives the 15-minute + // lease the counter tracks, and rollBackStalledRegionalRehomes refuses to + // unwind it while the source is not general. Re-placing would strand it. + const { store, database, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await insertMigration(database, { + sourceCellId: first.cellId, + targetCellId: 'us-c2', + assignmentEpoch: first.assignmentEpoch, + leases: 0 + }) + await isolateForRoll(first.cellId) + + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('never re-places across a region boundary, and says so', async () => { + // Both US cells parked and only Asia general: ordinary placement would spill + // to asia-east2 through `preferred[0] ?? candidates[0]`. This path refuses. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, isolateForRoll, park } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + const other = CELLS.find((cell) => cell.region === 'us-central1' && cell.id !== first.cellId)! + await park(other.id, 'migration-only') + await isolateForRoll(first.cellId) + + warn.mockClear() + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + expect(jsonEvents(warn, 'orca_relay_sticky_replacement_deferred')).toEqual([ + { + event: 'orca_relay_sticky_replacement_deferred', + reason: 'no_same_region_headroom', + cellId: first.cellId, + region: 'us-central1' + } + ]) + expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([]) + }) + + it('keeps a re-placed host in its own region', async () => { + const { store, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'asia-east2') + expect(first.region).toBe('asia-east2') + await isolateForRoll(first.cellId) + + // asia-c1 is the only Asia cell, so the only in-region candidate is gone. + expect(await store.assign(IDENTITY, 'asia-east2')).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + }) + + it('takes the dead-cell path when a stamped cell stops heartbeating', async () => { + // Why: the fence is the only proof that a cell we cannot reach has stopped + // serving the sockets it still holds. A stamp does not make it reachable. + const { store, heartbeat, isolateForRoll, setNow } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + const stale = START_MS + HEARTBEAT_TTL_MS + 1_000 + setNow(stale) + for (const cell of CELLS) { + if (cell.id !== first.cellId) await heartbeat(cell, stale) + } + + await expect(store.assign(IDENTITY, 'us-central1')).rejects.toBeInstanceOf( + RelayHomeCellUnavailableError + ) + }) + + it('does not demand a committed fence while the stamped cell is live', async () => { + const { store, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + const moved = await store.assign(IDENTITY, 'us-central1').catch((error: unknown) => error) + expect(moved).not.toBeInstanceOf(RelayHomeCellUnavailableError) + expect(moved).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 }) + }) + + it('leaves the source cell activity leases in place', async () => { + // Contrast with the fence and stranded paths, which delete them: an isolated + // cell is alive and still owns drainable work behind those leases. + const { store, database, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await database.query( + `INSERT INTO relay_assignment_activity_leases + (user_id, relay_host_id, activity_id, activity_kind, cell_id, + request_units, expires_at, updated_at) + VALUES (?, ?, 'splice:keep-1', 'splice', ?, 1, ?, ?)`, + [IDENTITY.userId, IDENTITY.relayHostId, first.cellId, START_MS + 600_000, START_MS] + ) + await isolateForRoll(first.cellId) + + await store.assign(IDENTITY, 'us-central1') + expect( + await database.query( + `SELECT activity_id FROM relay_assignment_activity_leases + WHERE user_id = ? AND relay_host_id = ? AND activity_id = 'splice:keep-1'`, + [IDENTITY.userId, IDENTITY.relayHostId] + ) + ).toHaveLength(1) + }) + + it('emits nothing when the placement rolls back after the decision', async () => { + // Why: the decision and the writes share one transaction. A line already on + // stdout cannot be rolled back with it, so an event written where it is + // decided would have the canary counting moves that never happened. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, counter, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + warn.mockClear() + // The first write after the event is decided. + counter.failOnce = 'INSERT INTO relay_assignments' + await expect(store.assign(IDENTITY, 'us-central1')).rejects.toThrow( + 'injected_placement_write_failure' + ) + + expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([]) + // The precondition for reading that absence: the move really was rolled + // back, so the event would have been a lie rather than merely early. + expect(await store.resolve(IDENTITY)).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + + // Control: the same dial with nothing injected does emit, so the assertion + // above is measuring the rollback and not a broken harness. + warn.mockClear() + const moved = await store.assign(IDENTITY, 'us-central1') + expect(moved.cellId).not.toBe(first.cellId) + expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toHaveLength(1) + }) + + it('logs one event naming both cells, the admission state and the region', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + + warn.mockClear() + const moved = await store.assign(IDENTITY, 'us-central1') + + expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([ + { + event: 'orca_relay_sticky_replaced_off_isolated_cell', + fromCellId: first.cellId, + fromRegion: 'us-central1', + admissionState: 'migration-only', + toCellId: moved.cellId, + region: moved.region + } + ]) + }) +}) diff --git a/cloud/apps/relay/src/assignment-store-lock-order.test.ts b/cloud/apps/relay/src/assignment-store-lock-order.test.ts index 61baedfb1e27..ea04e016a43f 100644 --- a/cloud/apps/relay/src/assignment-store-lock-order.test.ts +++ b/cloud/apps/relay/src/assignment-store-lock-order.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { RelayAssignmentStore } from './assignment-store.js' import type { RelayDatabase, RelayLockOptions, SqlRow } from './database.js' +import { openInMemoryRelayDatabase } from './database.js' const identity = { userId: 'user-a', relayHostId: 'host000000000001' } const activityId = 'splice:connection-1' @@ -484,3 +485,196 @@ describe('RelayAssignmentStore activity lock order', () => { ]) }) }) + +const TARGET_ROW_LOCK = 'FOR UPDATE OF cell, admission NOWAIT' + +type RecordedStatement = { sql: string; locked: boolean; options?: RelayLockOptions } + +// Reports Postgres (or no dialect) so the store emits its real lock clause, +// and strips that clause before SQLite runs the statement. +function recordAsPostgres( + database: RelayDatabase, + statements: RecordedStatement[], + dialect: 'postgres' | 'omitted' +): RelayDatabase { + const decorate = (delegate: RelayDatabase): RelayDatabase => ({ + ...(dialect === 'postgres' ? { dialect } : {}), + query: async (sql, params) => { + statements.push({ sql, locked: false }) + return await delegate.query(sql, params) + }, + queryLocked: async (sql, params, options) => { + statements.push({ sql, locked: true, options }) + return await delegate.queryLocked(sql.replace(TARGET_ROW_LOCK, ''), params, options) + }, + transaction: async (operation, options) => + await delegate.transaction(async (transaction) => await operation(decorate(transaction)), options), + close: async () => await delegate.close() + }) + return decorate(database) +} + +async function idleRehomeCommitStatements(dialect: 'postgres' | 'omitted' = 'postgres'): Promise<{ + outcome: string + statements: RecordedStatement[] +}> { + const sqlite = await openInMemoryRelayDatabase() + const statements: RecordedStatement[] = [] + let recording = false + const recorded = recordAsPostgres(sqlite, statements, dialect) + let now = 100_000_000 + const plain = new RelayAssignmentStore(sqlite, () => now, { regionalRehomeCohortPercent: 100 }) + const store = new RelayAssignmentStore( + { + ...recorded, + transaction: async (operation, options) => + recording + ? await recorded.transaction(operation, options) + : await sqlite.transaction(operation, options) + }, + () => now, + { regionalRehomeCohortPercent: 100 } + ) + await plain.inspectRegionalRehomeControl() + now += 86_400_000 + await plain.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: now, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + const cells = [ + { id: 'source', url: 'https://source.example.test', region: 'asia-east2' as const, capacityRequests: 100 }, + { id: 'target', url: 'https://target.example.test', region: 'us-central1' as const, capacityRequests: 100 } + ] + const incarnations = ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'] + const safety = { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + await plain.reconcileCells(cells) + for (const [index, cell] of cells.entries()) { + await plain.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + await plain.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety + }) + } + const assignment = await plain.assign(identity, undefined, 'asia-east2') + await plain.activateControl(identity, { + cellId: 'source', + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const issued = await plain.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await plain.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } + }, + assignment.assignmentEpoch + ) + const [request] = await plain.selectIdleRegionalRehomeCandidates(safety) + expect(request).toMatchObject({ sourceCellId: 'source', targetCellId: 'target' }) + recording = true + const result = await store.commitIdleRegionalRehome(request!, safety) + recording = false + await sqlite.close() + return { outcome: result.outcome, statements } +} + +function lockedTable(statement: RecordedStatement): string { + return /\bFROM\s+(\w+)/.exec(statement.sql)?.[1] ?? '?' +} + +// The rehome commit takes the host's assignment row before any cell row, the +// reverse of placement (cells, then assignment). That is deadlock-free only +// because its one cell lock never waits. +describe('RelayAssignmentStore idle rehome commit lock order', () => { + it('locks host rows first and the target cell row last, NOWAIT, with no fleet-wide lock', async () => { + const { outcome, statements } = await idleRehomeCommitStatements() + expect(outcome).toBe('committed') + // The commit transaction is everything after the reconcile transaction's + // attempt read, which is the first statement touching the control row. + const commit = statements.slice( + statements.findIndex((statement) => statement.sql.includes('relay_region_rehome_control')) + ) + const locked = commit.filter((statement) => statement.locked) + + expect(locked.map(lockedTable)).toEqual([ + 'relay_region_rehome_control', + 'relay_region_rehome_worker_state', + 'relay_assignments', + 'relay_region_decisions', + 'relay_assignment_migrations', + 'relay_assignment_activity_leases', + 'relay_control_connection_reservations', + // insertControlConnectionReservation re-reads the host's rows locked. + 'relay_control_connection_reservations', + 'relay_cells' + ]) + // Control and worker rows serialise the budget and hard-cap reads; they + // must refuse rather than queue behind another cross-region commit. + expect(locked.slice(0, 2).map((statement) => statement.options?.failIfUnavailable)).toEqual([ + true, + true + ]) + const target = locked[locked.length - 1]! + expect(commit[commit.length - 1]).toBe(target) + expect(target.sql).toContain(TARGET_ROW_LOCK) + expect(target.sql).toMatch(/WHERE cell\.cell_id = \?/) + expect(target.options).toMatchObject({ + failIfUnavailable: true, + lockClauseInStatement: true, + measureHoldMs: true, + holdSite: 'rehome-target-row' + }) + expect( + commit.filter((statement) => + /FROM relay_cell(s|_runtime|_capabilities|_rehome_safety)\b/.test(statement.sql) && + statement !== target && + (statement.locked || /FOR UPDATE/.test(statement.sql)) + ) + ).toEqual([]) + }) + // Why: dialect is optional, and a wrapper that omits it must not silently + // run the target-row write unlocked and without NOWAIT. + it('keeps the target-row lock clause when a wrapper omits the dialect', async () => { + const { outcome, statements } = await idleRehomeCommitStatements('omitted') + expect(outcome).toBe('committed') + const target = statements.filter((statement) => statement.sql.includes('WITH target AS')) + + expect(target).toHaveLength(1) + expect(target[0]!.sql).toContain(TARGET_ROW_LOCK) + }) +}) diff --git a/cloud/apps/relay/src/assignment-store.test.ts b/cloud/apps/relay/src/assignment-store.test.ts index e4a8ce54ebff..6153a2791960 100644 --- a/cloud/apps/relay/src/assignment-store.test.ts +++ b/cloud/apps/relay/src/assignment-store.test.ts @@ -110,6 +110,7 @@ class OneShotInventoryFailureDatabase implements RelayDatabase { return await this.delegate.transaction( async (transaction) => await operation({ + dialect: transaction.dialect, query: async (sql, params = []) => await transaction.query(sql, params), queryLocked: async (sql, params = [], options = {}) => await this.lockedQuery(transaction, sql, params, options), @@ -173,6 +174,7 @@ class RepeatedDrainAccountingFailureDatabase implements RelayDatabase { return await this.delegate.transaction( async (transaction) => await operation({ + dialect: transaction.dialect, query: async (sql, params = []) => await transaction.query(sql, params), queryLocked: async (sql, params = [], options = {}) => await this.lockedQuery(transaction, sql, params, options), @@ -1556,6 +1558,11 @@ describe('RelayAssignmentStore', () => { }) it('keeps migration-only cells pinned inside the stranded window', async () => { + // Why: migration-only is an admission class, not a drain signal. Evacuation + // targets, an Asia `--mode rollback` and a failed wave's re-isolate all park + // loaded cells there durably, and moving those hosts would undo the + // evacuation or scatter the region. Only the same-cap roll's isolate stamp + // releases the pin, and nothing here sets it. let now = 100 const store = await setup(() => now) const identity = { userId: 'user-a', relayHostId: 'host000000000001' } diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index b34e25a69a03..3cb5dcfeee9f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -30,12 +30,14 @@ import { type RelayRegion, type RegionCorrectionRequest, type RegionCorrectionResponse, + type IdleRegionalRehomeCommit, type IdleRegionalRehomeRequest, } from '@orca-cloud/relay-contract' import { cellAdmissionState, cellAdmissionStates, ensureCellAdmission, + isCellAdmissionState, parseCellAdmissionState, RelayCellAdmissionSelector, setCellAdmissionBeforeBoundary, @@ -80,6 +82,10 @@ import { regionalRehomePoolPressure, regionalRehomeSafetyFailure } from './regional-rehome-safety.js' +import { + REGIONAL_REHOME_ARRIVAL_WINDOW_MS, + type RegionalRehomeAbortReason +} from './regional-rehome-abort-reason.js' import { ABANDONED_REGISTERED_MIGRATION, DURABLY_FENCED_MIGRATION_SOURCE, @@ -113,6 +119,8 @@ type CellRegionalRehomeStatus = { type RelayAssignmentStoreOptions = { regionalRehomeCohortPercent?: number + // Rehome selection only uses source cells in this region; see `rehomeSourceRegionAllowed`. + regionalRehomeDirectorRegion?: RelayRegion requireLiveCells?: boolean heartbeatTtlMs?: number recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void @@ -387,6 +395,20 @@ export type CellInventoryLockMode = // ordinary dormancy — which stays governed by the 24h rule. const STRANDED_MIN_GRANT_AGE_MS = 60_000 const STRANDED_RECENT_ACTIVITY_MS = 15 * 60_000 +// Why: a roll's isolate step writes exactly this state +// (cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs:136) and its +// restore writes 'general' (same line). 'existing-only' is C3's decommission +// posture, where the cell still serves the hosts it already has, so it is +// deliberately not an isolation signal here. +const ROLL_ISOLATED_ADMISSION: CellAdmissionState = 'migration-only' +// Why the stamp expires: a roll isolates and restores one cell inside ~15 +// minutes, so a stamp older than this is not a roll in progress. It is a failed +// or stalled wave whose failsafe re-isolated a possibly healthy cell and is +// waiting on an operator, or a director rollback whose restore wrote 'general' +// without the clause that clears the stamp, leaving an orphan that the next +// park would reactivate. Both want the same answer, and it is the pre-existing +// one: keep the pin and let the host retry its own cell. +const ROLL_ISOLATION_STAMP_MAX_AGE_MS = 2 * 60 * 60_000 const REGION_PREFERENCE_RETENTION_MS = 30 * 24 * 60 * 60_000 const REGIONAL_REHOME_UNREGISTERED_REFRESH_MS = 5 * 60_000 const REGIONAL_REHOME_MAX_REFRESH_MS = 24 * 60 * 60_000 @@ -472,6 +494,7 @@ const ABORTABLE_EXPIRED_MIGRATION = `( export class RelayAssignmentStore { private readonly regionalRehomeCohortPercent: number + private readonly regionalRehomeDirectorRegion?: RelayRegion private readonly requireLiveCells: boolean private readonly heartbeatTtlMs: number // Poisoned attempts never complete or abort and stay the oldest rows, so @@ -500,6 +523,7 @@ export class RelayAssignmentStore { this.regionalRehomeCohortPercent > 100 ) throw new Error('invalid_regional_rehome_cohort') + this.regionalRehomeDirectorRegion = options.regionalRehomeDirectorRegion this.requireLiveCells = options.requireLiveCells ?? false this.heartbeatTtlMs = options.heartbeatTtlMs ?? 45_000 this.recordControlRenewal = options.recordControlRenewal @@ -731,10 +755,24 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) await this.recordRegionPreference(transaction, identity, preferredRegion, now) if (mayNormallyReassign(activity(existing), now)) return null + // One read serves the stranded rule and the roll-isolation check below; + // issuing it twice inside one sticky transaction is pure waste. + const pinnedAdmission = await this.pinnedCellAdmission( + transaction, + text(existing, 'cell_id') + ) // Why: a stranded host must fall through to placement — re-granting the // pinned cell here is what refreshes its own activity and sustains the // loop (issue #225). - if (await this.assignmentStrandedOnUnservedCell(transaction, identity, existing, now)) { + if ( + await this.assignmentStrandedOnUnservedCell( + transaction, + identity, + existing, + now, + pinnedAdmission?.state + ) + ) { return null } @@ -771,6 +809,22 @@ export class RelayAssignmentStore { ) { return null } + // Why: a null here means "fall through to placement", which is exactly + // what an isolated incumbent needs — and the only way out, because + // re-granting the pin refreshes the host's own activity and sustains the + // loop. The placement lane re-places it on a cell that will take it. + if ( + await this.incumbentCellIsolatedForRoll( + transaction, + identity, + existing, + now, + undefined, + pinnedAdmission + ) + ) { + return null + } if ( !hadControl && @@ -821,7 +875,9 @@ export class RelayAssignmentStore { transaction: RelayDatabase, identity: AssignmentIdentity, existing: SqlRow, - now: number + now: number, + // Read by the caller when it needs the same row for another question. + admissionState?: CellAdmissionState ): Promise { const lastActivityAt = integer(existing, 'last_activity_at') if ( @@ -830,14 +886,11 @@ export class RelayAssignmentStore { ) { return false } - const admissionRow = ( - await transaction.query( - `SELECT admission_state FROM relay_cell_admission WHERE cell_id = ?`, - [text(existing, 'cell_id')] - ) - )[0] + const state = + admissionState ?? + (await this.pinnedCellAdmission(transaction, text(existing, 'cell_id')))?.state // Unknown or missing admission fails safe: the pin stays. - if (admissionRow?.['admission_state'] !== 'existing-only') { + if (state !== 'existing-only') { return false } const liveLeases = ( @@ -873,7 +926,15 @@ export class RelayAssignmentStore { const now = this.now() let retryScope: RetriedAssignmentInventoryScope = inventoryScope === 'all' ? 'all' : 'general' - return await this.database.transaction(async (transaction) => { + // Why the events ride back out rather than being written where they are + // decided: everything below runs in one transaction, and a reservation or + // lease write that fails after the decision rolls the placement back. A + // line already on stdout cannot be rolled back with it, so the canary would + // count re-placements that never happened. Returning them means only a + // committed attempt emits, and a transaction retry cannot leave a stale + // line behind either. + const outcome = await this.database.transaction(async (transaction) => { + const events: string[] = [] // The retry paths below open with the inventory, so this path takes its // host rows before any of them rather than where the others do. await this.lockControlConnectionReservations(transaction, identity, lockMode) @@ -897,6 +958,8 @@ export class RelayAssignmentStore { let forcedDeadReassignment = false let connectionHeadroomReassignment = false let strandedReassignment = false + let isolatedIncumbent: CellRow | undefined + let isolatedTarget: CellRow | undefined if (existing && !mayNormallyReassign(activity(existing), now)) { lockedCells ??= await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) @@ -917,10 +980,46 @@ export class RelayAssignmentStore { existing, now ) + const currentIsLive = + strandedReassignment || + !this.requireLiveCells || + (await this.cellIsLive(transaction, current.cellId, now)) + // Why: the sticky lane sends an isolated incumbent here, and the gate + // below would hand the pin straight back — the cell is live and, being + // emptied, has more headroom than anyone. A cell that is *not* live is + // left to the dead-cell path below, whose fence is the only proof that + // an unreachable cell has stopped serving the sockets it still holds. if ( !strandedReassignment && - (!this.requireLiveCells || (await this.cellIsLive(transaction, current.cellId, now))) + currentIsLive && + (await this.incumbentCellIsolatedForRoll( + transaction, + identity, + existing, + now, + admission + )) ) { + isolatedTarget = + (await this.leastLoadedCell(transaction, lockedCells, current.region, 'require')) ?? + undefined + if (isolatedTarget) { + isolatedIncumbent = current + } else { + // Keeping the pin is today's behaviour: the host keeps retrying its + // own cell. Scattering a region across the fleet is worse, and it + // cannot be undone without the rehome worker. + events.push( + JSON.stringify({ + event: 'orca_relay_sticky_replacement_deferred', + reason: 'no_same_region_headroom', + cellId: current.cellId, + region: current.region + }) + ) + } + } + if (!strandedReassignment && !isolatedIncumbent && currentIsLive) { const hadControl = holdsControlLease( activityLeases, current.cellId, @@ -951,7 +1050,10 @@ export class RelayAssignmentStore { now ) } - return this.result(identity, existing, current, leaseExpiresAt) + return { + assignment: this.result(identity, existing, current, leaseExpiresAt), + events + } } if (requestUnits(existing) > 0) { throw new Error('relay_connection_headroom_exhausted') @@ -961,7 +1063,14 @@ export class RelayAssignmentStore { } connectionHeadroomReassignment = true } - if (!strandedReassignment && !connectionHeadroomReassignment) { + // Why no fence for a live isolated cell: a fence proves a cell we cannot + // contact has stopped serving a host. This one is contactable and + // enforces the invariant itself — verifyCellAssignment reads + // relay_assignments live, so the epoch bump below makes it close any + // attach naming the old epoch with 4409. `isolatedIncumbent` is only set + // when the cell is live, so a cell that stopped heartbeating while still + // holding sockets still takes this branch. + if (!strandedReassignment && !connectionHeadroomReassignment && !isolatedIncumbent) { if ( (await this.deadCellRequiresCommittedFence( transaction, @@ -983,12 +1092,27 @@ export class RelayAssignmentStore { lockedCells ??= existing ? await this.lockCellInventory(transaction, 'nowait') : await this.lockGeneralCellInventory(transaction, 'nowait') - const target = await this.leastLoadedCell( - transaction, - lockedCells, - placementRegion - ) + // The isolated target was chosen under the same inventory lock, with + // region required rather than preferred; re-picking here would reopen the + // cross-region spill it exists to refuse. + const target = + isolatedTarget ?? + (await this.leastLoadedCell(transaction, lockedCells, placementRegion)) if (!target) throw new Error('relay_capacity_exhausted') + if (isolatedIncumbent) { + // The canary's proof that the fix fired: count these against the + // drained cell's host count. + events.push( + JSON.stringify({ + event: 'orca_relay_sticky_replaced_off_isolated_cell', + fromCellId: isolatedIncumbent.cellId, + fromRegion: isolatedIncumbent.region, + admissionState: ROLL_ISOLATED_ADMISSION, + toCellId: target.cellId, + region: target.region + }) + ) + } const previousUnits = existing ? requestUnits(existing) : 0 if (existing) { await this.adjustCellReservation(transaction, text(existing, 'cell_id'), -previousUnits) @@ -1055,13 +1179,18 @@ export class RelayAssignmentStore { assignmentEpoch, now ) - return { ...identity, ...target, assignmentEpoch, leaseExpiresAt } + return { + assignment: { ...identity, ...target, assignmentEpoch, leaseExpiresAt }, + events + } }).catch((error: unknown) => { if (isDatabaseLockUnavailable(error)) { throw new AssignmentInventoryLockUnavailable(retryScope) } throw error }) + for (const event of outcome.events) console.warn(event) + return outcome.assignment } async resolve(identity: AssignmentIdentity): Promise { @@ -1109,6 +1238,7 @@ export class RelayAssignmentStore { expectedGeneration: number expectedMembershipSha256?: string membership: CellAdmissionMembership + rollIsolatedCells?: string[] }): Promise<{ changed: boolean selector: { @@ -3428,6 +3558,7 @@ export class RelayAssignmentStore { preferenceMaxAgeMs: Number(control.preference_max_age_ms), hostCooldownMs: Number(control.host_cooldown_ms), cursor: this.idleRegionalCandidateCursor, + directorRegion: this.regionalRehomeDirectorRegion, connectionHeadroom: await this.connectionHeadroomByCell(this.database), cellIsClean: regionalRehomeCellSafetyIsClean }) @@ -3436,7 +3567,8 @@ export class RelayAssignmentStore { now, gate: 'open', candidates: selection.candidates.length, - selectionMs: performance.now() - startedAt + selectionMs: performance.now() - startedAt, + skippedOffRegionSourceCells: selection.skippedOffRegionSourceCells }) return selection.candidates } @@ -3445,21 +3577,37 @@ export class RelayAssignmentStore { request: IdleRegionalRehomeRequest, processSafety?: RegionalRehomeSafetySnapshot, cohortPercent = this.regionalRehomeCohortPercent - ): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> { + ): Promise { const prior = await this.reconcileIdleRegionalRehome(request) if (prior !== 'not-committed') return { outcome: prior } - if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { - return { outcome: 'deferred' } + if (!processSafety) return { outcome: 'deferred', reason: 'director-safety-stale' } + if (!Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { + return { outcome: 'deferred', reason: 'cohort-closed' } } let safetyDisable: Record | null = null - const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => { + // Set on every fleet-safety failure, disable or not: the pause is durable + // and global either way, so no later candidate in this poll can get past it. + let safetyPaused = false + let targetDeferral: RegionalRehomeTargetDeferral | null = null + // Lock order: control, worker, host rows, then the target cell row alone, + // as the last statement. No fleet-wide lock: from a cell far from Postgres + // each statement costs a cross-region round trip, and the old inventory + // lock held every relay_cells row across ~20 of them. + const result = await this.database.transaction(async (transaction): Promise => { safetyDisable = null + safetyPaused = false + targetDeferral = null const now = this.now() + // NOWAIT: these rows serialise the rate budget, the open-migration count + // and the hard-cap read, and a second commit must not queue behind one + // that is crossing the ocean. const control = (await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` + `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`, + [], + { failIfUnavailable: true } ))[0] if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) { - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'control-closed' } } await transaction.query( `INSERT INTO relay_region_rehome_worker_state @@ -3467,16 +3615,20 @@ export class RelayAssignmentStore { VALUES ('global', 0, 0, 0, ?) ON CONFLICT (worker_id) DO NOTHING`, [now] ) const worker = (await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` + `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'`, + [], + { failIfUnavailable: true } ))[0]! if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) { - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'budget-closed' } } const open = (await transaction.query( `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` ))[0] - if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' } + if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) { + return { outcome: 'deferred', reason: 'concurrency-limit' } + } const attempt = await this.startRegionalRehomeCandidate(transaction, { identity: request, sourceCellId: request.sourceCellId, @@ -3490,9 +3642,14 @@ export class RelayAssignmentStore { skips: [], idleRequest: request, cohortPercent, - onSafetyDisabled: (event) => { safetyDisable = event } + onSafetyDisabled: (event) => { + safetyDisable = event + safetyPaused = true + } }) - if (!attempt) return { outcome: 'deferred' } + if (!attempt) { + return { outcome: 'deferred', reason: safetyPaused ? 'fleet-safety' : 'candidate-ineligible' } + } await this.markRegionalRehomeDispatchClaimed( transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute)) ) @@ -3500,7 +3657,26 @@ export class RelayAssignmentStore { `UPDATE relay_region_rehome_attempts SET drain_receipt_at = ?, drain_outcome = 'accepted' WHERE attempt_id = ?`, [now, request.attemptId] ) + // Must stay the last statement: the row is held from here to COMMIT. + const target = await this.reserveRegionalRehomeTargetRow( + transaction, attempt.targetCellId, attempt.targetReservedUnits, now + ) + if (target !== 'reserved') { + targetDeferral = target + throw new RegionalRehomeTargetDeferred() + } return { outcome: 'committed' } + }).catch((error: unknown): IdleRegionalRehomeCommit => { + if (!(error instanceof RegionalRehomeTargetDeferred)) throw error + // Rolled back whole: no attempt, migration or reservation survives, so the + // outcome report never sees it and the director moves to its next candidate. + console.warn(JSON.stringify({ + event: 'orca_relay_idle_rehome_target_deferred', + attemptId: request.attemptId, + targetCellId: request.targetCellId, + cause: targetDeferral + })) + return { outcome: 'deferred', reason: 'candidate-ineligible' } }) if (safetyDisable) console.warn(JSON.stringify(safetyDisable)) return result @@ -3557,6 +3733,7 @@ export class RelayAssignmentStore { globalSafetyFailure: processSafety ? regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) : 'process-safety-unavailable', + directorRegion: this.regionalRehomeDirectorRegion, connectionHeadroom: await this.connectionHeadroomByCell(this.database), cellIsClean: regionalRehomeCellSafetyIsClean }) @@ -5452,7 +5629,7 @@ export class RelayAssignmentStore { cohortPercent: number onSafetyDisabled: (event: Record | null) => void } - ): Promise | null> { + ): Promise<(Omit & { targetReservedUnits: number }) | null> { const assignment = await this.assignmentRow(transaction, input.identity) if ( !assignment || @@ -5508,7 +5685,9 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, input.identity) assertAssignmentActivityCounts(assignment, activityLeases, 0) await this.lockControlConnectionReservations(transaction, input.identity, 'nowait') - const cells = await this.lockCellInventory(transaction, 'nowait') + // Unlocked snapshot. It picks the target and feeds the fleet-safety + // heuristic; the target row is locked and re-checked last, in the caller. + const cells = await transaction.query(`SELECT * FROM relay_cells ORDER BY cell_id ASC`) const admission = await cellAdmissionStates(transaction) const regions = new Map( (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -5516,13 +5695,11 @@ export class RelayAssignmentStore { relayRegion(row, 'region') ]) ) - const runtimes = await transaction.queryLocked( - `SELECT * FROM relay_cell_runtime ORDER BY cell_id` - ) - const capabilities = await transaction.queryLocked( + const runtimes = await transaction.query(`SELECT * FROM relay_cell_runtime ORDER BY cell_id`) + const capabilities = await transaction.query( `SELECT * FROM relay_cell_capabilities ORDER BY cell_id` ) - const safetyRows = await transaction.queryLocked( + const safetyRows = await transaction.query( `SELECT * FROM relay_cell_rehome_safety ORDER BY cell_id` ) const source = cells.find((row) => text(row, 'cell_id') === input.sourceCellId) @@ -5708,7 +5885,6 @@ export class RelayAssignmentStore { const targetRuntime = runtimes.find( (runtime) => text(runtime, 'cell_id') === targetCellId )! - await this.adjustCellReservation(transaction, targetCellId, targetReservedUnits) const previousEpoch = integer(assignment, 'assignment_epoch') const assignmentEpoch = previousEpoch + 1 const expiresAt = input.now + ASSIGNMENT_LIMITS.migrationLeaseMs @@ -5833,7 +6009,50 @@ export class RelayAssignmentStore { targetCellIncarnation: text(targetRuntime, 'cell_incarnation'), previousEpoch, assignmentEpoch, - drainGraceMs: input.drainGraceMs + drainGraceMs: input.drainGraceMs, + targetReservedUnits + } + } + + // The rehome commit's only relay_cells lock: one row, NOWAIT, as the final + // statement. NOWAIT because the commit took the host's assignment row first, + // the reverse of placement's order; a wait here could deadlock with it. + // Enabled, admission and capacity are checked against the locked rows inside + // the statement, because an admission flip no longer serialises against this + // transaction any other way. Locking the admission row too makes a flip that + // committed after the statement's snapshot re-evaluate on its new version. + private async reserveRegionalRehomeTargetRow( + database: RelayDatabase, + cellId: string, + units: number, + now: number + ): Promise<'reserved' | RegionalRehomeTargetDeferral> { + const lockClause = database.dialect === 'sqlite' ? '' : 'FOR UPDATE OF cell, admission NOWAIT' + try { + const rows = await database.queryLocked( + `WITH target AS ( + SELECT cell.cell_id FROM relay_cells cell + JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id + WHERE cell.cell_id = ? AND cell.enabled = 1 + AND admission.admission_state = 'general' + ${lockClause} + ) + UPDATE relay_cells SET reserved_requests = reserved_requests + ?, updated_at = ? + WHERE cell_id IN (SELECT cell_id FROM target) + AND reserved_requests + ? <= capacity_requests + RETURNING cell_id`, + [cellId, units, now, units], + { + failIfUnavailable: true, + lockClauseInStatement: true, + measureHoldMs: true, + holdSite: 'rehome-target-row' + } + ) + return rows.length > 0 ? 'reserved' : 'target-not-admitting' + } catch (error) { + if (isDatabaseLockUnavailable(error)) return 'target-row-busy' + throw error } } @@ -6354,8 +6573,43 @@ export class RelayAssignmentStore { return integer(completed[0]!, 'changes') + integer(aborted[0]!, 'changes') } + // A move the host never finished: it holds no activity on the source and is + // not present at the target, so the registered migration row can do nothing + // but occupy one of the eight concurrent slots until something clears it. + // Rolling it back leaves the durable assignment on the source, so the host + // lands where it started whenever it next reconnects. + async abortUnarrivedRegionalRehomes(limit = 100): Promise { + return await this.rollBackStalledRegionalRehomes({ + sweep: 'abort-unarrived-regional-rehomes', + minimumAttemptAgeMs: REGIONAL_REHOME_ARRIVAL_WINDOW_MS, + abortReason: 'host_not_arrived', + disableControl: false, + limit + }) + } + + // The last-resort latch, and the only sweep that disables the switch. With + // the arrival sweep above running it should never reach a row; one that + // survives a day past dispatch means the rollback path itself is broken. async abortExpiredRegionalRehomes(limit = 100): Promise { + return await this.rollBackStalledRegionalRehomes({ + sweep: 'abort-expired-regional-rehomes', + minimumAttemptAgeMs: REGIONAL_REHOME_MAX_REFRESH_MS, + abortReason: 'max_refresh_expired', + disableControl: true, + limit + }) + } + + private async rollBackStalledRegionalRehomes(input: { + sweep: string + minimumAttemptAgeMs: number + abortReason: RegionalRehomeAbortReason + disableControl: boolean + limit: number + }): Promise { const now = this.now() + const dispatchedBefore = now - input.minimumAttemptAgeMs const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) const exclusion = quarantined.length ? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})` @@ -6366,7 +6620,7 @@ export class RelayAssignmentStore { WHERE completed_at IS NULL AND aborted_at IS NULL AND created_at <= ?${exclusion} ORDER BY created_at, attempt_id LIMIT ?`, - [now - REGIONAL_REHOME_MAX_REFRESH_MS, ...quarantined, limit] + [dispatchedBefore, ...quarantined, input.limit] ) let aborted = 0 let inventoryBusy = 0 @@ -6403,7 +6657,7 @@ export class RelayAssignmentStore { !migration || optionalInteger(attempt, 'completed_at') !== undefined || optionalInteger(attempt, 'aborted_at') !== undefined || - integer(attempt, 'created_at') > now - REGIONAL_REHOME_MAX_REFRESH_MS || + integer(attempt, 'created_at') > dispatchedBefore || optionalInteger(migration, 'completed_at') !== undefined || optionalInteger(migration, 'aborted_at') !== undefined ) { @@ -6472,16 +6726,22 @@ export class RelayAssignmentStore { [now, now, identity.userId, identity.relayHostId, assignmentEpoch] ) await transaction.query( - `UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ? + `UPDATE relay_region_rehome_attempts + SET aborted_at = ?, abort_reason = ?, updated_at = ? WHERE attempt_id = ?`, - [now, now, text(attempt, 'attempt_id')] - ) - await transaction.query( - `UPDATE relay_region_rehome_control - SET generation = generation + 1, enabled = 0, updated_at = ? - WHERE control_id = 'global' AND enabled = 1`, - [now] + [now, input.abortReason, now, text(attempt, 'attempt_id')] ) + // Only the last-resort latch turns the feature off. A host that closed + // its laptop mid-move says nothing about whether rehoming is safe, and + // one such row a day would otherwise disable the switch every day. + if (input.disableControl) { + await transaction.query( + `UPDATE relay_region_rehome_control + SET generation = generation + 1, enabled = 0, updated_at = ? + WHERE control_id = 'global' AND enabled = 1`, + [now] + ) + } return true }) this.regionalRehomeCandidateQuarantine.delete(attemptId) @@ -6491,7 +6751,7 @@ export class RelayAssignmentStore { } if (changed) aborted++ } - warnSweepCellInventoryBusy('abort-expired-regional-rehomes', inventoryBusy) + warnSweepCellInventoryBusy(input.sweep, inventoryBusy) return aborted } @@ -7089,7 +7349,12 @@ export class RelayAssignmentStore { // Required: the one caller has already locked the inventory it selects from, // and an optional parameter left a second fleet-wide lock reachable here. rows: SqlRow[], - preferredRegion: RelayRegion + preferredRegion: RelayRegion, + // Ordinary placement treats region as a preference and spills globally + // rather than refuse a host a cell. Re-placing off an isolated cell is the + // one caller that must not: a whole region parked migration-only would send + // every one of its hosts to the fallback region, permanently. + regionMode: 'prefer' | 'require' = 'prefer' ): Promise { const regions = new Map( (await database.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -7135,7 +7400,7 @@ export class RelayAssignmentStore { (candidate) => (regions.get(text(candidate, 'cell_id')) ?? RELAY_DEFAULT_REGION) === preferredRegion ) - const selected = preferred[0] ?? candidates[0] + const selected = regionMode === 'require' ? preferred[0] : (preferred[0] ?? candidates[0]) return selected ? cell(selected, regions.get(text(selected, 'cell_id')) ?? RELAY_DEFAULT_REGION) : null @@ -7256,6 +7521,76 @@ export class RelayAssignmentStore { return rows.length === 1 } + // Why: a cell isolated for a roll keeps heartbeating ready=1 for the whole + // drain while its registry refuses every attach with 4503, so `cellIsLive` + // cannot see that the host's home has stopped taking it. Re-granting the pin + // is what turns a roll into 13-16 minutes of retry loop; returning true here + // sends the host down the ordinary placement lane on its next dial instead. + // + // The stamp, not the admission state, is the signal. 'migration-only' is an + // admission class that evacuation targets, Asia `--mode rollback`, a failed + // wave's re-isolate and newly registered cells all occupy durably while + // holding hosts; moving those would undo an evacuation or scatter a region. + private async incumbentCellIsolatedForRoll( + database: RelayDatabase, + identity: AssignmentIdentity, + existing: SqlRow, + // The caller's clock, not a second this.now(): one assign reasons about one + // instant, and the stamp's age decides whether a host moves. + now: number, + // Placement has already read the whole table; sticky has not, and a single + // pinned cell does not justify a second fleet-wide read. + admission?: ReadonlyMap, + // Sticky has already read the pinned row for the stranded rule. + pinnedAdmission?: PinnedCellAdmission + ): Promise { + const cellId = text(existing, 'cell_id') + // Placement's map answers the common "not parked at all" case for free; the + // stamp itself is only on the row, so a migration-only incumbent still pays + // the single-row read. + if (admission && admission.get(cellId) !== ROLL_ISOLATED_ADMISSION) return false + const pinned = pinnedAdmission ?? (await this.pinnedCellAdmission(database, cellId)) + if (pinned?.state !== ROLL_ISOLATED_ADMISSION || pinned.rollIsolatedAt === undefined) { + return false + } + if (now - pinned.rollIsolatedAt >= ROLL_ISOLATION_STAMP_MAX_AGE_MS) return false + if (integer(existing, 'migration_leases') > 0) return false + // A migration owns this assignment's epoch on both sides, and its durable + // row outlives the 15-minute lease that the counter above tracks, so the + // counter alone would re-place a host out from under a stalled migration. + const open = ( + await database.query( + `SELECT COUNT(*) AS open FROM relay_assignment_migrations + WHERE user_id = ? AND relay_host_id = ? + AND completed_at IS NULL AND aborted_at IS NULL`, + [identity.userId, identity.relayHostId] + ) + )[0] + return integer(open!, 'open') === 0 + } + + // One read for both the class and the roll stamp, so the sticky hot path pays + // a single indexed row lookup rather than one per question. + private async pinnedCellAdmission( + database: RelayDatabase, + cellId: string + ): Promise { + const row = ( + await database.query( + `SELECT admission_state, roll_isolated_at FROM relay_cell_admission WHERE cell_id = ?`, + [cellId] + ) + )[0] + // Unknown, missing or unrecognised admission fails safe: the pin stays. This + // reader must not throw — it now sits on the sticky path every dial takes, + // and the rule it feeds is "move the host", so silence has to mean "don't". + if (!row || !isCellAdmissionState(row['admission_state'])) return undefined + return { + state: row['admission_state'], + rollIsolatedAt: optionalInteger(row, 'roll_isolated_at') + } + } + // Reports which of `cellIsLive`'s conditions failed, so the rejection log // separates an expected drain or boot from a cell whose readiness went out // from under its hosts. @@ -7840,6 +8175,13 @@ export class RelayAssignmentStore { } } +type PinnedCellAdmission = { + state: CellAdmissionState + // Set only by the same-cap roll's isolate step; cleared by any write that + // moves the cell out of 'migration-only'. + rollIsolatedAt: number | undefined +} + type CellRow = { cellId: string; cellUrl: string; region: RelayRegion } function cell(row: SqlRow, region: RelayRegion): CellRow { @@ -8120,6 +8462,16 @@ function isMissingMigration(error: unknown): boolean { return error instanceof Error && error.message === 'migration_not_found' } +// Disabled, not general, full, or gone (target-not-admitting), or locked by +// another writer such as an admission change (target-row-busy). +type RegionalRehomeTargetDeferral = 'target-not-admitting' | 'target-row-busy' + +class RegionalRehomeTargetDeferred extends Error { + constructor() { + super('regional_rehome_target_deferred') + } +} + function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } diff --git a/cloud/apps/relay/src/cell-admission-selector.ts b/cloud/apps/relay/src/cell-admission-selector.ts index 92764e53e409..dbbe5ab4505f 100644 --- a/cloud/apps/relay/src/cell-admission-selector.ts +++ b/cloud/apps/relay/src/cell-admission-selector.ts @@ -38,6 +38,13 @@ type ApplySelectorInput = { expectedGeneration: number expectedMembershipSha256?: string membership: CellAdmissionMembership + // Why a marker rather than reading 'migration-only' directly: that state is an + // admission class, not a drain signal (orca-relay-operations.md:229-233). + // Evacuation targets, Asia `--mode rollback`, a failed wave's re-isolate and + // newly registered cells all sit there durably while holding hosts. Only the + // same-cap isolate step names cells here; every write out of 'migration-only' + // clears the stamp, so a restore cannot leave one behind. + rollIsolatedCells?: string[] } const SELECTOR_ID = 'general' @@ -50,6 +57,12 @@ export function enabledForState(state: CellAdmissionState): number { return state === 'existing-only' ? 0 : 1 } +// The narrowing counterpart of parseCellAdmissionState, for readers that must +// answer "unknown" rather than throw. +export function isCellAdmissionState(value: unknown): value is CellAdmissionState { + return CELL_ADMISSION_STATES.some((state) => state === value) +} + export function parseCellAdmissionState(value: string): CellAdmissionState { if (!CELL_ADMISSION_STATES.includes(value as CellAdmissionState)) { throw new Error('invalid_cell_admission_state') @@ -122,9 +135,10 @@ export async function setCellAdmissionBeforeBoundary( await transaction.query( `UPDATE relay_cell_admission SET updated_at = CASE WHEN admission_state <> ? THEN ? ELSE updated_at END, - admission_state = ? + admission_state = ?, + roll_isolated_at = CASE WHEN ? = 'migration-only' THEN roll_isolated_at ELSE NULL END WHERE cell_id = ?`, - [state, now, state, cellId] + [state, now, state, state, cellId] ) await synchronizeCellAdmissionBoundary(transaction, now) } @@ -184,14 +198,30 @@ export class RelayCellAdmissionSelector { } } const now = this.now() + const rollIsolated = new Set(input.rollIsolatedCells ?? []) for (const [state, cellIds] of membershipEntries(membership)) { for (const cellId of cellIds) { + // Three-way, in the same statement as the state so they cannot tear: + // stamp a named isolate (keeping an earlier stamp, so a failed wave's + // re-isolate stays marked), clear on any move out of 'migration-only', + // and leave an unnamed 'migration-only' cell exactly as it was. + const marker = + state === 'migration-only' + ? rollIsolated.has(cellId) + ? 'stamp' + : 'keep' + : 'clear' await transaction.query( `UPDATE relay_cell_admission SET updated_at = CASE WHEN admission_state <> ? THEN ? ELSE updated_at END, - admission_state = ? + admission_state = ?, + roll_isolated_at = CASE + WHEN ? = 'clear' THEN NULL + WHEN ? = 'stamp' THEN COALESCE(roll_isolated_at, ?) + ELSE roll_isolated_at + END WHERE cell_id = ?`, - [state, now, state, cellId] + [state, now, state, marker, marker, now, cellId] ) await transaction.query( `UPDATE relay_cells diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts index 77c9a270fb55..34c681c9259a 100644 --- a/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts @@ -109,4 +109,26 @@ describe('cell inventory hold samples', () => { expect(samples.consumeCounts().cellInventoryLockUnavailable).toBe(4) expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts()) }) + + // Why: the alert reads one max across every lock, so the label is the only + // thing that says whether a long hold was the inventory or a rehome target row. + it('names the site of the longest hold and reports rehome target rows apart', () => { + const samples = new CellInventoryHoldSamples() + samples.record(40) + samples.record(170, 'rehome-target-row') + samples.record(90, 'rehome-target-row') + + expect(samples.readCounts()).toMatchObject({ + cellInventoryHoldMsMax: 170, + cellInventoryHolds: 3, + cellInventoryHoldMaxSite: 'rehome-target-row', + rehomeTargetRowHoldMsMax: 170, + rehomeTargetRowHolds: 2 + }) + samples.record(300) + expect(samples.readCounts()).toMatchObject({ + cellInventoryHoldMaxSite: 'inventory', + rehomeTargetRowHoldMsMax: 170 + }) + }) }) diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.ts index 9412a76f4953..3454c97b55a5 100644 --- a/cloud/apps/relay/src/cell-inventory-hold-samples.ts +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.ts @@ -1,10 +1,19 @@ // Why: the cell inventory lock is held to COMMIT, and the assignment path runs // many statements after taking it. Tuning the request-path wait bound needs the // hold distribution, and no runtime metric carried it before this change. +// Which lock a hold sample came from. Every site feeds the same max, so one +// alert on cellInventoryHoldMsMax covers them all; the label names the holder. +export type CellLockHoldSite = 'inventory' | 'rehome-target-row' + export type CellInventoryHoldCounts = { cellInventoryHoldMsMax: number cellInventoryHoldMsP95: number cellInventoryHolds: number + cellInventoryHoldMaxSite: CellLockHoldSite | 'none' + // The rehome commit holds one target row, not the inventory; rollout proof + // reads its bound and its presence here. + rehomeTargetRowHoldMsMax: number + rehomeTargetRowHolds: number // Why: a failed acquisition produces no hold sample, so the hold fields alone // read healthy while the lock is saturated. Split by wait policy, not by // caller: fail-fast covers background sweeps that step aside by design AND @@ -24,20 +33,23 @@ export function emptyCellInventoryHoldCounts(): CellInventoryHoldCounts { cellInventoryHoldMsMax: 0, cellInventoryHoldMsP95: 0, cellInventoryHolds: 0, + cellInventoryHoldMaxSite: 'none', + rehomeTargetRowHoldMsMax: 0, + rehomeTargetRowHolds: 0, cellInventoryLockUnavailable: 0, cellInventoryLockTimeouts: 0 } } export class CellInventoryHoldSamples { - private samples: number[] = [] + private samples: { holdMs: number; site: CellLockHoldSite }[] = [] private unavailable = 0 private timeouts = 0 - record(holdMs: number): void { + record(holdMs: number, site: CellLockHoldSite = 'inventory'): void { if (!Number.isFinite(holdMs) || holdMs < 0) return if (this.samples.length === MAX_SAMPLES) this.samples.shift() - this.samples.push(holdMs) + this.samples.push({ holdMs, site }) } // Counted, not sampled: a failed acquisition has no duration to record. @@ -65,11 +77,16 @@ export class CellInventoryHoldSamples { cellInventoryLockTimeouts: this.timeouts } if (this.samples.length === 0) return { ...emptyCellInventoryHoldCounts(), ...failures } - const sorted = [...this.samples].sort((left, right) => left - right) + const sorted = [...this.samples].sort((left, right) => left.holdMs - right.holdMs) + const max = sorted[sorted.length - 1]! + const rehome = sorted.filter((sample) => sample.site === 'rehome-target-row') return { - cellInventoryHoldMsMax: round(sorted[sorted.length - 1]!), - cellInventoryHoldMsP95: round(sorted[Math.ceil(0.95 * sorted.length) - 1] ?? 0), + cellInventoryHoldMsMax: round(max.holdMs), + cellInventoryHoldMsP95: round(sorted[Math.ceil(0.95 * sorted.length) - 1]?.holdMs ?? 0), cellInventoryHolds: sorted.length, + cellInventoryHoldMaxSite: max.site, + rehomeTargetRowHoldMsMax: round(rehome[rehome.length - 1]?.holdMs ?? 0), + rehomeTargetRowHolds: rehome.length, ...failures } } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 6f564844ba6e..e26d8d0551e6 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -45,9 +45,13 @@ const CENSUS: CensusEntry[] = [ { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' }, + // startRegionalRehomeCandidate is gone: the rehome commit reads the inventory + // unlocked and locks only its target row, NOWAIT, as the statement before + // COMMIT (reserveRegionalRehomeTargetRow below). { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, + // Both regional-rehome abort sweeps share this rollback; only the 24-hour + // one also disables the durable switch. + { method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, @@ -77,6 +81,7 @@ const INLINE_CELL_LOCK_SITES = [ 'attestCellFenceAttempt', 'attestCellFenceAttempt', 'configureCell', + 'reserveRegionalRehomeTargetRow', 'assertDrainCellGeneration', 'adjustCellReservation' ] diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts index dcc63e8c02d3..44a4b990ea8d 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({ return { rows: [], rowCount: 0 } }), release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }), end: vi.fn(async () => undefined) })) @@ -19,7 +27,7 @@ vi.mock('pg', () => ({ waitingCount = 0 end = fakes.end on = vi.fn() - connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + connect = vi.fn(async () => fakes.client()) } } })) @@ -318,6 +326,30 @@ describe('bounded cell-inventory lock wait', () => { await database.close() }) + // Why: after the rehome commit stopped locking the inventory, its only hold is + // one row. The alert reads the shared max, so the site label is what names it. + it('records a target-row hold under its own site without appending a lock clause', async () => { + const database = await openFakePostgres() + const statement = 'WITH target AS (SELECT 1 FOR UPDATE NOWAIT) UPDATE relay_cells SET x = 1' + + await database.transaction(async (transaction) => { + await transaction.queryLocked(statement, [], { + failIfUnavailable: true, + lockClauseInStatement: true, + measureHoldMs: true, + holdSite: 'rehome-target-row' + }) + }) + + expect(fakes.statements).toContain(statement) + expect(consumeRelayCellInventoryHold(database)).toMatchObject({ + cellInventoryHolds: 1, + cellInventoryHoldMaxSite: 'rehome-target-row', + rehomeTargetRowHolds: 1 + }) + await database.close() + }) + it('records no hold for a PostgreSQL transaction that took no measured lock', async () => { const database = await openFakePostgres() diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index adc27cc451c6..2922ea3a4d61 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({ lifecycle: [] as string[], query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })), release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }), end: vi.fn(async () => undefined) })) @@ -18,7 +26,7 @@ vi.mock('pg', () => ({ idleCount = 1 waitingCount = 0 on = vi.fn() - connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + connect = vi.fn(async () => fakes.client()) private readonly label: string constructor(config: Record) { diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 725dde4baec9..6a8a1fc202ab 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -16,7 +16,8 @@ import { reportPostgresQueryFailure } from './postgres-query-failure.js' import { CellInventoryHoldSamples, emptyCellInventoryHoldCounts, - type CellInventoryHoldCounts + type CellInventoryHoldCounts, + type CellLockHoldSite } from './cell-inventory-hold-samples.js' export const POSTGRES_LOCK_TIMEOUT_MS = 1_000 @@ -44,13 +45,26 @@ export type RelayLockOptions = { // Report how long this lock is held to COMMIT. The hold, not the wait, is what // forms the queue, and nothing measured it before. measureHoldMs?: boolean + // Labels the measured hold; unset reads as the full-inventory lock. + holdSite?: CellLockHoldSite + // The statement carries its own row-lock clause (a CTE locks inside the + // statement), so none is appended. failIfUnavailable must match that clause. + lockClauseInStatement?: boolean } +type MeasuredHold = { holdMs: number; site: CellLockHoldSite } + // A transaction that can report how long it held a measured lock before COMMIT. -type HoldMeasuringTransaction = { consumeHoldMs(): number | undefined } +type HoldMeasuringTransaction = { consumeHold(): MeasuredHold | undefined } + +function recordMeasuredHold(holds: CellInventoryHoldSamples, transaction: unknown): void { + const hold = (transaction as HoldMeasuringTransaction).consumeHold?.() + if (hold) holds.record(hold.holdMs, hold.site) +} -function measuredHoldMs(transaction: unknown): number | undefined { - return (transaction as HoldMeasuringTransaction).consumeHoldMs?.() +function lockedStatement(sql: string, options: RelayLockOptions): string { + if (options.lockClauseInStatement) return sql + return `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}` } export type RelayTransactionOptions = { reportRetries?: boolean } @@ -297,6 +311,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts ( ), completed_at BIGINT, aborted_at BIGINT, + abort_reason TEXT, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE (user_id, relay_host_id, assignment_epoch) @@ -326,7 +341,8 @@ CREATE TABLE IF NOT EXISTS relay_cell_admission ( cell_id TEXT PRIMARY KEY, admission_state TEXT NOT NULL CHECK (admission_state IN ('existing-only', 'migration-only', 'general')), - updated_at BIGINT NOT NULL + updated_at BIGINT NOT NULL, + roll_isolated_at BIGINT ); CREATE TABLE IF NOT EXISTS relay_admission_selectors ( @@ -676,6 +692,15 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [ DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`, `ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0`, + // Nullable with no default, so the rewrite is catalog-only; every row + // aborted before this column existed reads as an unattributed abort. + `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS abort_reason TEXT`, + // migration-only alone cannot say why a cell is parked there: five flows park loaded cells in + // that state durably. This stamps only the same-cap roll's isolate step, so placement can tell a + // cell being rolled from an evacuation target or an Asia rollback. Nullable with no default, so + // the rewrite is catalog-only; a cell isolated before this column existed reads as unmarked and + // keeps its hosts pinned, which is the pre-existing behaviour. + `ALTER TABLE relay_cell_admission ADD COLUMN IF NOT EXISTS roll_isolated_at BIGINT`, // Dropped, not created: see the comment on relay_assignment_activity_leases. Deferrable because // this is the one boot where it has to take ACCESS EXCLUSIVE on a table under continuous write, // and all 28 directors reach it at once; a lock timeout here must not restart the instance, which @@ -764,20 +789,20 @@ function postgresTransactionErrorPhase(error: unknown): string { class SqliteTransaction implements RelayDatabase { readonly dialect = 'sqlite' as const - private heldFromMs: number | undefined + private held: { fromMs: number; site: CellLockHoldSite } | undefined constructor(protected readonly database: DatabaseSync) {} - consumeHoldMs(): number | undefined { - if (this.heldFromMs === undefined) return undefined - const holdMs = performance.now() - this.heldFromMs - this.heldFromMs = undefined - return holdMs + consumeHold(): MeasuredHold | undefined { + if (this.held === undefined) return undefined + const hold = { holdMs: performance.now() - this.held.fromMs, site: this.held.site } + this.held = undefined + return hold } protected noteHeld(options: RelayLockOptions): void { - if (options.measureHoldMs && this.heldFromMs === undefined) { - this.heldFromMs = performance.now() + if (options.measureHoldMs && this.held === undefined) { + this.held = { fromMs: performance.now(), site: options.holdSite ?? 'inventory' } } } @@ -827,16 +852,18 @@ class SqliteDatabase extends SqliteTransaction { let release!: () => void this.tail = new Promise((resolve) => (release = resolve)) await previous - this.database.exec('BEGIN IMMEDIATE') - const transaction = new SqliteTransaction(this.database) try { - const result = await operation(transaction) - this.database.exec('COMMIT') - this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) - return result - } catch (error) { - this.database.exec('ROLLBACK') - throw error + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + recordMeasuredHold(this.holds, transaction) + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } } finally { release() } @@ -850,17 +877,17 @@ class SqliteDatabase extends SqliteTransaction { class PostgresTransaction implements RelayDatabase { readonly dialect = 'postgres' as const - private heldFromMs: number | undefined + private held: { fromMs: number; site: CellLockHoldSite } | undefined private lockUnavailable = 0 private lockTimeouts = 0 constructor(protected readonly client: pg.PoolClient) {} - consumeHoldMs(): number | undefined { - if (this.heldFromMs === undefined) return undefined - const holdMs = performance.now() - this.heldFromMs - this.heldFromMs = undefined - return holdMs + consumeHold(): MeasuredHold | undefined { + if (this.held === undefined) return undefined + const hold = { holdMs: performance.now() - this.held.fromMs, site: this.held.site } + this.held = undefined + return hold } // Drained by the owning database on both the commit and the rollback path: a @@ -899,12 +926,9 @@ class PostgresTransaction implements RelayDatabase { // A blocked waiter holds its pooled client for the whole lock_timeout, so // hot tiny-table locks bound their own wait well under the pool default. if (bounded) await this.query(setLocalLockTimeout(options.lockTimeoutMs!)) - const rows = await this.query( - `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, - params - ) - if (options.measureHoldMs && this.heldFromMs === undefined) { - this.heldFromMs = performance.now() + const rows = await this.query(lockedStatement(sql, options), params) + if (options.measureHoldMs && this.held === undefined) { + this.held = { fromMs: performance.now(), site: options.holdSite ?? 'inventory' } } return rows } catch (error) { @@ -988,7 +1012,7 @@ async function waitForPostgresRetry(random: () => number = Math.random): Promise await new Promise((resolve) => setTimeout(resolve, delayMs)) } -class PostgresDatabase implements RelayDatabase { +export class PostgresDatabase implements RelayDatabase { readonly dialect = 'postgres' as const private readonly pressure: PostgresPoolPressure private readonly holds = new CellInventoryHoldSamples() @@ -1035,10 +1059,7 @@ class PostgresDatabase implements RelayDatabase { try { // No transaction here, so options.lockTimeoutMs cannot apply: SET LOCAL // would be discarded at the autocommit boundary before the lock is taken. - return await this.query( - `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, - params - ) + return await this.query(lockedStatement(sql, options), params) } catch (error) { if ( options.failIfUnavailable && @@ -1062,7 +1083,7 @@ class PostgresDatabase implements RelayDatabase { await client.query('BEGIN') const result = await operation(transaction) await client.query('COMMIT') - this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) + recordMeasuredHold(this.holds, transaction) this.holds.recordUnavailable(transaction.consumeLockUnavailable()) this.holds.recordLockTimeout(transaction.consumeLockTimeouts()) return result diff --git a/cloud/apps/relay/src/drain-migration-row-scaling.test.ts b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts index 3ffd6490bd1c..cf8024b91eff 100644 --- a/cloud/apps/relay/src/drain-migration-row-scaling.test.ts +++ b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts @@ -14,6 +14,7 @@ it.each([false, true])( let identityReads = 0 let indexedRows = 0 const instrument = (delegate: RelayDatabase): RelayDatabase => ({ + dialect: delegate.dialect, query: (sql, params) => delegate.query(sql, params), queryLocked: async (sql, params, options) => { const rows = await delegate.queryLocked(sql, params, options) diff --git a/cloud/apps/relay/src/fault-injection-test-entry.ts b/cloud/apps/relay/src/fault-injection-test-entry.ts index 3f2cc6b62468..84c7e34f93ab 100644 --- a/cloud/apps/relay/src/fault-injection-test-entry.ts +++ b/cloud/apps/relay/src/fault-injection-test-entry.ts @@ -21,6 +21,7 @@ let faulted = false function wrap(transaction: RelayDatabase): RelayDatabase { return { + dialect: transaction.dialect, query: async (sql, params) => { if (pattern && !faulted && sql.includes(pattern)) { faulted = true diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 69697cce60e4..fcf73695b40a 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -535,6 +535,8 @@ describe('host session cleanup races', () => { const socket = new FakeSocket() const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() socket.close() blocked.resolve('control:production-gce-c3:1') await activation @@ -560,6 +562,8 @@ describe('host session cleanup races', () => { const rebindSocket = new FakeSocket() const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledTimes(2) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -574,6 +578,49 @@ describe('host session cleanup races', () => { ) }) + it('skips a closed queued control so its live retry avoids abandoned database work', async () => { + const stalled = deferred() + const activateControl = vi + .fn() + .mockReturnValueOnce(stalled.promise) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)) + return 'control:production-gce-c3:1' + }) + const { registry, activate, releaseActivity } = createRegistry(activateControl) + const firstSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() + const abandonedSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1) + const liveSocket = new FakeSocket() + const startedAt = Date.now() + let liveCompletedAt: number | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1) + .then(() => { liveCompletedAt = Date.now() }) + firstSocket.close() + abandonedSocket.close() + stalled.resolve('control:production-gce-c3:1') + await vi.advanceTimersByTimeAsync(8_000) + await Promise.all([first, abandoned, live]) + + console.log(JSON.stringify({ + scenario: 'closed queued control before a live retry', + activationCalls: activateControl.mock.calls.length, + activityReleases: releaseActivity.mock.calls.length, + liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt + })) + expect(activateControl).toHaveBeenCalledTimes(2) + expect(releaseActivity).toHaveBeenCalledOnce() + expect(liveCompletedAt! - startedAt).toBe(4_000) + expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket) + .toBe(liveSocket) + }) + it('rejects client lookup when the indexed control socket is not open', async () => { const reservation = { userId: identity.sub, diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 480b1b6b9142..4e3372ce6923 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -18,6 +18,9 @@ import { RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS, RELAY_CLOSE_CODE, + type IdleRegionalRehomeCommit, + type IdleRegionalRehomeDeferReason, + type IdleRegionalRehomeResult, type RelayHostCloseReason, type RelayRegion } from '@orca-cloud/relay-contract' @@ -191,7 +194,7 @@ export class HostSessionRegistry { { attemptId: string authorityKey: string - promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> + promise: Promise } >() @@ -205,9 +208,9 @@ export class HostSessionRegistry { sourceCellIncarnation: string targetCellId: string }, - commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>, + commit: () => Promise, reconcile: () => Promise<'committed' | 'not-committed' | 'stale'> - ): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> { + ): Promise { const authorityKey = JSON.stringify([ input.userId, input.sourceAssignmentEpoch, @@ -236,7 +239,7 @@ export class HostSessionRegistry { !session.socket || !this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) ) - return { outcome: 'deferred' } + return { outcome: 'deferred', reason: 'host-unsupported' } if ( (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || session.activeConnIds.size !== 0 || @@ -246,12 +249,18 @@ export class HostSessionRegistry { return { outcome: 'busy' } const revision = session.authorityRevision const promise = Promise.resolve().then(async () => { - let outcome: 'committed' | 'deferred' | 'stale' + let outcome: IdleRegionalRehomeCommit['outcome'] + // The commit's reason survives only while the outcome stays deferred; + // a reconcile that finds a durable outcome answers with that instead. + let reason: IdleRegionalRehomeDeferReason | undefined try { - outcome = (await commit()).outcome + const commitResult = await commit() + outcome = commitResult.outcome + reason = commitResult.reason if (outcome === 'deferred') { const durable = await reconcile() outcome = durable === 'not-committed' ? 'deferred' : durable + if (outcome !== 'deferred') reason = undefined } } catch { let delay = 100 @@ -259,6 +268,7 @@ export class HostSessionRegistry { try { const durable = await reconcile() outcome = durable === 'not-committed' ? 'deferred' : durable + reason = undefined break } catch { await new Promise((resolve) => { @@ -276,7 +286,7 @@ export class HostSessionRegistry { } if (this.idleAttempts.get(input.relayHostId)?.promise === promise) this.idleAttempts.delete(input.relayHostId) - return { outcome } + return reason === undefined ? { outcome } : { outcome, reason } }) this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise }) return promise @@ -1093,7 +1103,7 @@ export class HostSessionRegistry { .catch(() => undefined) .then(async () => { clearTimeout(queueWaitTimer) - if (queueWaitExpired) return + if (queueWaitExpired || socket.readyState !== socket.OPEN) return if ((this.sessions.get(key) ?? null) !== existing) { socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') return diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts index f24819abf119..bed205273868 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-selection.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -19,6 +19,19 @@ export type IdleRegionalRehomeCandidate = IdleRegionalRehomeRequest & { sourceCe export type IdleRegionalRehomeSelection = { candidates: IdleRegionalRehomeCandidate[] cursor: IdleRehomeHostCursor + // Eligible cells kept out of the source set by `rehomeSourceRegionAllowed`. + skippedOffRegionSourceCells: number +} + +// TEMPORARY STOPGAP: delete with its call sites once the rehome commit stops holding +// relay_cells row locks across cross-region round trips. The source cell runs the commit, +// and one outside the database's region holds fleet-wide locks for seconds, so it may not +// be a source. It stays a target: only the source side of the commit pays the round trips. +export function rehomeSourceRegionAllowed( + sourceRegion: string, + directorRegion: string | undefined +): boolean { + return directorRegion === undefined || sourceRegion === directorRegion } type SourceCell = { @@ -39,6 +52,8 @@ type SelectionInput = { preferenceMaxAgeMs: number hostCooldownMs: number cursor: IdleRehomeHostCursor + // The director's own region; the database lives there. Undefined keeps every source. + directorRegion?: string connectionHeadroom: ReadonlyMap cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean } @@ -56,7 +71,10 @@ export async function selectIdleRegionalRehomes( input: SelectionInput ): Promise { const cells = await readCellInventory(input) - if (!cells.sources.size || !cells.targetsByRegion.size) return { candidates: [], cursor: null } + const skippedOffRegionSourceCells = cells.skippedOffRegionSourceCells + if (!cells.sources.size || !cells.targetsByRegion.size) { + return { candidates: [], cursor: null, skippedOffRegionSourceCells } + } const sourceRegions = [...new Set([...cells.sources.values()].map((cell) => cell.region))] const targetRegions = [...cells.targetsByRegion.keys()] const decisionFilter = `outcome = 'conclusive' AND policy_version = 1 @@ -84,7 +102,7 @@ export async function selectIdleRegionalRehomes( ORDER BY user_id, relay_host_id LIMIT ?`, [...decisionParams, ...after, IDLE_REHOME_DECISION_WINDOW] ) - if (!window.length) return { candidates: [], cursor: null } + if (!window.length) return { candidates: [], cursor: null, skippedOffRegionSourceCells } const windowEnd = window[window.length - 1]! const windowWasFull = window.length === IDLE_REHOME_DECISION_WINDOW @@ -146,7 +164,7 @@ export async function selectIdleRegionalRehomes( // Whole hosts only: the lower-priority targets are a host's fallbacks when // the first one defers, and splitting them across pages loses them. if (candidates.length >= IDLE_REHOME_PAGE_SIZE) { - return { candidates, cursor: stoppedAt } + return { candidates, cursor: stoppedAt, skippedOffRegionSourceCells } } const source = cells.sources.get(String(row.source_cell_id))! const sourceUnits = units.get(hostKey(row)) ?? 0 @@ -158,9 +176,12 @@ export async function selectIdleRegionalRehomes( } // A full verification page may have been cut short of the window's end, so only // a page that ran the window out may wrap to the head of the keyspace. - if (rows.length === IDLE_REHOME_PAGE_SIZE) return { candidates, cursor: stoppedAt } + if (rows.length === IDLE_REHOME_PAGE_SIZE) { + return { candidates, cursor: stoppedAt, skippedOffRegionSourceCells } + } return { candidates, + skippedOffRegionSourceCells, cursor: windowWasFull ? { userId: String(windowEnd.user_id), relayHostId: String(windowEnd.relay_host_id) } : null @@ -172,7 +193,11 @@ export async function selectIdleRegionalRehomes( // resolved once per poll against the four small inventory tables. async function readCellInventory( input: SelectionInput -): Promise<{ sources: Map; targetsByRegion: Map }> { +): Promise<{ + sources: Map + targetsByRegion: Map + skippedOffRegionSourceCells: number +}> { const { database, now } = input const [runtimeRows, safetyRows, inventory] = await Promise.all([ database.query('SELECT * FROM relay_cell_runtime'), @@ -184,6 +209,7 @@ async function readCellInventory( const sources = new Map() const targetsByRegion = new Map() const load = new Map() + let skippedOffRegionSourceCells = 0 for (const cell of inventory) { const cellId = String(cell.cell_id) const runtime = runtimes.get(cellId) @@ -201,13 +227,19 @@ async function readCellInventory( continue } const region = String(cell.region) - sources.set(cellId, { - cellId, - region, - cellIncarnation: String(runtime.cell_incarnation), - startedAt: Number(runtime.started_at), - cellUrl: String(cell.cell_url) - }) + // Only the source set shrinks, which narrows the window's `incumbent_region IN`; + // the cell still falls through to the target list below. + if (rehomeSourceRegionAllowed(region, input.directorRegion)) { + sources.set(cellId, { + cellId, + region, + cellIncarnation: String(runtime.cell_incarnation), + startedAt: Number(runtime.started_at), + cellUrl: String(cell.cell_url) + }) + } else { + skippedOffRegionSourceCells += 1 + } if (input.connectionHeadroom.get(cellId) === false) continue const capacityRequests = Number(cell.capacity_requests) const reservedRequests = Number(cell.reserved_requests) @@ -222,7 +254,7 @@ async function readCellInventory( load.get(left.cellId)! - load.get(right.cellId)! || (left.cellId < right.cellId ? -1 : 1) ) } - return { sources, targetsByRegion } + return { sources, targetsByRegion, skippedOffRegionSourceCells } } // One grouped read for the page instead of a correlated aggregate per (host, cell) pair. diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts index 489f4ce0b536..b83cd57f92f9 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -171,9 +171,11 @@ describe('constrained idle regional assignment transaction', () => { ) const candidates = await store.selectIdleRegionalRehomeCandidates(safety) expect(candidates).toHaveLength(capacity === 11 ? 1 : 0) - expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ - outcome: capacity === 11 ? 'committed' : 'deferred' - }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual( + capacity === 11 + ? { outcome: 'committed' } + : { outcome: 'deferred', reason: 'candidate-ineligible' } + ) const [target] = await database.query("SELECT reserved_requests FROM relay_cells WHERE cell_id = 'target'") expect(Number(target!.reserved_requests)).toBe(capacity === 11 ? 11 : 7) expect(await store.resolve(identity)).toMatchObject({ @@ -292,7 +294,7 @@ describe('constrained idle regional assignment transaction', () => { } finally { held.release() } - expect(await commit).toEqual({ outcome: 'deferred' }) + expect(await commit).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) } @@ -368,7 +370,7 @@ describe('constrained idle regional assignment transaction', () => { const { store, safety, request } = await setup() expect( await store.commitIdleRegionalRehome({ ...request, targetCellId: 'missing' }, safety) - ).toEqual({ outcome: 'deferred' }) + ).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) await store.activateControl(identity, { cellId: 'source', assignmentEpoch: 1, @@ -382,9 +384,13 @@ describe('constrained idle regional assignment transaction', () => { it('does not commit without process safety or cohort authorization', async () => { const { store, safety, request, database } = await setup() - expect(await store.commitIdleRegionalRehome(request)).toEqual({ outcome: 'deferred' }) + expect(await store.commitIdleRegionalRehome(request)).toEqual({ + outcome: 'deferred', + reason: 'director-safety-stale' + }) expect(await store.commitIdleRegionalRehome(request, safety, 0)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'cohort-closed' }) expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) }) diff --git a/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts b/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts new file mode 100644 index 000000000000..871b013cf961 --- /dev/null +++ b/cloud/apps/relay/src/postgres-checked-out-client-error.test.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { PostgresDatabase } from './database.js' + +// Stands in for a pg client between acquire and release. pg-pool assigns +// `release` per checkout, which is the property the guard wraps. +class FakePoolClient extends EventEmitter { + readonly released: Array = [] + readonly statements: string[] = [] + + constructor(private readonly respond: (sql: string) => { rows: unknown[]; rowCount: number }) { + super() + } + + query = vi.fn((sql: string) => { + this.statements.push(sql) + return Promise.resolve(this.respond(sql)) + }) + + release = (error?: Error | boolean): void => { + this.released.push(error) + } +} + +function poolOf(client: FakePoolClient) { + return { totalCount: 1, idleCount: 0, waitingCount: 0, connect: async () => client } +} + +describe('checked-out PostgreSQL client failure handling', () => { + it('crashes the process when nothing listens, which is the bug being fixed', () => { + // Node's own contract: this is what killed cell c28 on 2026-09-20 20:18Z. + const unguarded = new EventEmitter() + expect(() => unguarded.emit('error', new Error('Connection terminated unexpectedly'))).toThrow( + 'Connection terminated unexpectedly' + ) + }) + + it('absorbs the error, rejects the transaction, and releases the client as failed', async () => { + const terminated = Object.assign(new Error('Connection terminated unexpectedly'), { + code: '57P01' + }) + let listenersWhileCheckedOut = 0 + const client: FakePoolClient = new FakePoolClient((sql) => { + if (sql !== 'SELECT 1') return { rows: [], rowCount: 0 } + listenersWhileCheckedOut = client.listenerCount('error') + // Cloud SQL terminating the session: the client emits `error` and the + // in-flight statement rejects with the same failure. + expect(() => client.emit('error', terminated)).not.toThrow() + throw terminated + }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const database = new PostgresDatabase(poolOf(client) as never) + + await expect( + database.transaction(async (transaction) => await transaction.query('SELECT 1')) + ).rejects.toBe(terminated) + + expect(listenersWhileCheckedOut).toBe(1) + expect(client.listenerCount('error')).toBe(0) + expect(client.released).toEqual([terminated]) + expect(client.statements).toEqual(['BEGIN', 'SELECT 1', 'ROLLBACK']) + expect(warning).toHaveBeenCalledWith( + '[orca-relay] checked-out PostgreSQL client failed: 57P01 Connection terminated unexpectedly' + ) + + warning.mockRestore() + }) + + it('releases a healthy client back to the pool with no error', async () => { + const client = new FakePoolClient(() => ({ rows: [{ one: 1 }], rowCount: 1 })) + const database = new PostgresDatabase(poolOf(client) as never) + + await expect( + database.transaction(async (transaction) => await transaction.query('SELECT 1')) + ).resolves.toEqual([{ one: 1 }]) + + expect(client.released).toEqual([undefined]) + expect(client.listenerCount('error')).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/postgres-pool-pressure.test.ts b/cloud/apps/relay/src/postgres-pool-pressure.test.ts index 2e020bf43fb4..4a59dbfef975 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.test.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { PostgresPoolPressure } from './postgres-pool-pressure.js' @@ -32,7 +33,8 @@ describe('PostgreSQL pool pressure', () => { now = 2_250 pool.waitingCount-- - resolveConnection({ query: vi.fn(), release: vi.fn() }) + // An EventEmitter because the acquire path now attaches an `error` listener. + resolveConnection(Object.assign(new EventEmitter(), { query: vi.fn(), release: vi.fn() })) await pending expect(pressure.consumeCounts()).toMatchObject({ databasePoolWaiting: 0, diff --git a/cloud/apps/relay/src/postgres-pool-pressure.ts b/cloud/apps/relay/src/postgres-pool-pressure.ts index ab0f188067f6..afeab79f5a12 100644 --- a/cloud/apps/relay/src/postgres-pool-pressure.ts +++ b/cloud/apps/relay/src/postgres-pool-pressure.ts @@ -30,6 +30,10 @@ function errorMessage(error: unknown): string { return String((error as { message?: unknown } | null)?.message) } +function errorCode(error: unknown): string { + return String((error as { code?: unknown } | null)?.code) +} + function isPostgresPoolAcquireFailure(error: unknown): boolean { return typeof error === 'object' && error !== null && poolAcquireFailures.has(error) } @@ -131,12 +135,45 @@ export class PostgresPoolPressure { } async function markedAcquire(connection: Promise): Promise { + let client: pg.PoolClient try { - return await connection + client = await connection } catch (error) { if (typeof error === 'object' && error !== null) poolAcquireFailures.add(error) throw error } + return guardCheckedOutClient(client) +} + +// pg-pool strips its own `error` listener when it hands a client out +// (pg-pool@3.14.0 index.js:344) and only reattaches it in `_release` +// (index.js:385), so a checked-out client has no `error` listener at all. A +// backend that terminates that session mid-statement therefore emits `error` +// with nothing listening, which is an unhandled 'error' event and kills the +// process. `pool.on('error')` cannot cover this: pg-pool routes there only from +// the idle listener. Every relay checkout awaits this function, so it is the +// one seam that sees them all. +function guardCheckedOutClient(client: pg.PoolClient): pg.PoolClient { + let failure: Error | undefined + const onError = (error: Error) => { + failure ??= error + // Printable unlike the idle path: a checked-out client is past the + // handshake, so its error carries no connection string. + console.warn( + `[orca-relay] checked-out PostgreSQL client failed: ${errorCode(error)} ${errorMessage(error)}` + ) + } + client.on('error', onError) + + // pg-pool assigns a fresh `release` on every acquire, so this never stacks. + const release = client.release.bind(client) + client.release = (releaseError?: Error | boolean) => { + client.removeListener('error', onError) + // Passing the error makes pg-pool destroy the client instead of returning a + // dead connection to the pool for the next caller to trip over. + release(releaseError ?? failure) + } + return client } export function emptyPostgresPoolPressureCounts(): PostgresPoolPressureCounts { diff --git a/cloud/apps/relay/src/postgres-query-failure.test.ts b/cloud/apps/relay/src/postgres-query-failure.test.ts index c0dca423babe..25a3d7f7900e 100644 --- a/cloud/apps/relay/src/postgres-query-failure.test.ts +++ b/cloud/apps/relay/src/postgres-query-failure.test.ts @@ -3,7 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const fakes = vi.hoisted(() => ({ connectError: undefined as unknown, query: vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], rowCount: 0 })), - release: vi.fn() + release: vi.fn(), + // A real pooled client is an EventEmitter, and the acquire path attaches an + // `error` listener to it before handing it to the caller. + client: () => ({ + query: fakes.query, + release: fakes.release, + on: vi.fn(), + removeListener: vi.fn() + }) })) vi.mock('pg', () => ({ @@ -15,7 +23,7 @@ vi.mock('pg', () => ({ on = vi.fn() async connect() { if (fakes.connectError) throw fakes.connectError - return { query: fakes.query, release: fakes.release } + return fakes.client() } async end() {} } diff --git a/cloud/apps/relay/src/region-correction-preview.ts b/cloud/apps/relay/src/region-correction-preview.ts index c120dbb22729..cedf5fdc6a24 100644 --- a/cloud/apps/relay/src/region-correction-preview.ts +++ b/cloud/apps/relay/src/region-correction-preview.ts @@ -1,5 +1,7 @@ import type { RelayDatabase, SqlRow } from './database.js' import { REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS } from './database.js' +import { rehomeSourceRegionAllowed } from './idle-regional-rehome-selection.js' +import { REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS } from './regional-rehome-abort-reason.js' import { REGIONAL_REHOME_CONCURRENT_LIMIT, REGION_DECISION_TTL_MS @@ -13,6 +15,11 @@ export type RegionCorrectionPreview = { availableMigrationSlots: number globalSafetyFailure: string | null counts: Record + // Rehomes rolled back to their source in the last day, by the reason the + // sweep recorded. A rising `host_not_arrived` is what a leak looks like + // before it fills the concurrency budget; `unattributed` covers the abort + // paths that settle an attempt without naming one. + abortedLast24Hours: Record } export async function previewRegionalRehomeEligibility(input: { @@ -21,11 +28,12 @@ export async function previewRegionalRehomeEligibility(input: { heartbeatTtlMs: number cohortPercent: number globalSafetyFailure: string | null + directorRegion?: string connectionHeadroom: ReadonlyMap cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean }): Promise { const { database, now } = input - const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations] = + const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations, aborts] = await Promise.all([ database.query( `SELECT assignment.cell_id, assignment.assignment_epoch, @@ -63,6 +71,12 @@ export async function previewRegionalRehomeEligibility(input: { database.query(`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`), database.query( `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` + ), + database.query( + `SELECT COALESCE(abort_reason, 'unattributed') AS reason, COUNT(*) AS count + FROM relay_region_rehome_attempts WHERE aborted_at >= ? + GROUP BY COALESCE(abort_reason, 'unattributed')`, + [now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS] ) ]) const byCell = (rows: SqlRow[]) => new Map(rows.map((row) => [String(row.cell_id), row])) @@ -121,6 +135,8 @@ export async function previewRegionalRehomeEligibility(input: { !input.cellIsClean(safety.get(String(host.cell_id)), runtimes.get(String(host.cell_id))!, now) ) reason = 'source-unclean' + else if (!rehomeSourceRegionAllowed(String(source.region), input.directorRegion)) + reason = 'source-outside-director-region' if (reason) { count(reason) continue @@ -152,6 +168,9 @@ export async function previewRegionalRehomeEligibility(input: { openMigrations, availableMigrationSlots: Math.max(0, REGIONAL_REHOME_CONCURRENT_LIMIT - openMigrations), globalSafetyFailure: input.globalSafetyFailure, - counts + counts, + abortedLast24Hours: Object.fromEntries( + aborts.map((row) => [String(row.reason), Number(row.count)]) + ) } } diff --git a/cloud/apps/relay/src/regional-rehome-abort-reason.ts b/cloud/apps/relay/src/regional-rehome-abort-reason.ts new file mode 100644 index 000000000000..96be5074a725 --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-abort-reason.ts @@ -0,0 +1,18 @@ +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' + +// Why an attempt was rolled back to its source. Attempts settled by the +// migration-side abort paths, and every row aborted before the column existed, +// leave it null. +export const REGIONAL_REHOME_ABORT_REASONS = ['host_not_arrived', 'max_refresh_expired'] as const + +export type RegionalRehomeAbortReason = (typeof REGIONAL_REHOME_ABORT_REASONS)[number] + +// One whole migration lease with the host absent from the target and owning +// nothing on the source. Nothing legitimately takes that long: the attach +// deadline is seconds, and a host that is still moving holds a lease at one end +// or the other. The control's `drain_grace_ms` does not fit — idle rehome +// commits with a grace of zero, so these attempts never carry one. +export const REGIONAL_REHOME_ARRIVAL_WINDOW_MS = ASSIGNMENT_LIMITS.migrationLeaseMs + +// The window the inventory line and the preview both report aborts over. +export const REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS = 24 * 60 * 60_000 diff --git a/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts b/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts new file mode 100644 index 000000000000..6e0b00a263c6 --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-inventory-line-census.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + formatAssignmentInventorySnapshot, + type AssignmentInventorySnapshot +} from './assignment-inventory-snapshot.js' + +// The enable workflow reads the director's rehome inventory line out of Cloud +// Logging with a parser that lives in another language, in another package, and +// is never exercised against the formatter that writes the line. When +// `hostNotArrivedLast24Hours` shipped, the parser read a healthy line as no +// evidence at all and the run failed closed, disabling the durable switch. This +// test is the missing edge: the real formatter's output, through the real +// parser, so a future field fails here instead of in an operator's run. + +type InventoryEvidence = { + active: number + awaitingReceipt: number + targetRegistered: number + completedLast24Hours: number + abortedLast24Hours: number + hostNotArrivedLast24Hours: number | null + oldestActiveAgeMs: number | null +} + +// Imported through a computed URL on purpose: the script is plain ESM outside +// this package's compile scope, so a static import would not resolve. +async function loadParser(): Promise<(entries: unknown[], options: unknown) => InventoryEvidence> { + const source = new URL( + '../../../dev/scripts/relay-rehome-aggregate-evidence.mjs', + import.meta.url + ).href + const loaded: unknown = await import(/* @vite-ignore */ source) + if (!(loaded !== null && typeof loaded === 'object' && 'parseRegionalRehomeInventory' in loaded)) { + throw new Error('relay-rehome-aggregate-evidence.mjs no longer exports its parser') + } + const parse = loaded.parseRegionalRehomeInventory + if (typeof parse !== 'function') throw new Error('parseRegionalRehomeInventory is not callable') + return (entries, options) => readEvidence(parse(entries, options)) +} + +function readEvidence(value: unknown): InventoryEvidence { + if (value === null || typeof value !== 'object') throw new Error('parser returned no evidence') + const counts = ['active', 'awaitingReceipt', 'targetRegistered', 'completedLast24Hours', 'abortedLast24Hours'] as const + const evidence: Record = {} + for (const key of [...counts, 'hostNotArrivedLast24Hours', 'oldestActiveAgeMs'] as const) { + if (!(key in value)) throw new Error(`parser dropped ${key}`) + const read: unknown = Reflect.get(value, key) + if (read !== null && typeof read !== 'number') throw new Error(`${key} is not a count`) + evidence[key] = read + } + for (const key of counts) { + if (evidence[key] === null) throw new Error(`${key} must be a number`) + } + return { + active: Number(evidence['active']), + awaitingReceipt: Number(evidence['awaitingReceipt']), + targetRegistered: Number(evidence['targetRegistered']), + completedLast24Hours: Number(evidence['completedLast24Hours']), + abortedLast24Hours: Number(evidence['abortedLast24Hours']), + hostNotArrivedLast24Hours: evidence['hostNotArrivedLast24Hours'] ?? null, + oldestActiveAgeMs: evidence['oldestActiveAgeMs'] ?? null + } +} + +function snapshot( + regionalRehomes: AssignmentInventorySnapshot['regionalRehomes'] +): AssignmentInventorySnapshot { + return { + cells: [], + activityLeases: { total: 0, expired: 0, requestUnits: 0 }, + connectionReservations: { outstanding: 0, lateArrivalDebt: 0 }, + regionalRehomes + } +} + +function inventoryLine(snapshotValue: AssignmentInventorySnapshot): string { + const line = formatAssignmentInventorySnapshot(snapshotValue).find((candidate) => + candidate.startsWith('[orca-relay] regional rehome inventory ') + ) + if (!line) throw new Error('the formatter no longer emits a rehome inventory line') + return line +} + +describe('regional rehome inventory line census', () => { + it('parses what the director actually prints, field for field', async () => { + const parse = await loadParser() + const regionalRehomes = { + active: 3, + awaitingReceipt: 1, + targetRegistered: 2, + completedLast24Hours: 41, + abortedLast24Hours: 12, + hostNotArrivedLast24Hours: 5, + oldestActiveAgeMs: 77_731_209 + } + const now = Date.parse('2026-09-20T12:00:00Z') + + const evidence = parse( + [{ timestamp: '2026-09-20T11:59:00Z', textPayload: inventoryLine(snapshot(regionalRehomes)) }], + { now, maxAgeMs: 5 * 60_000 } + ) + + expect(evidence).toEqual({ ...regionalRehomes }) + }) + + it('parses the line an idle fleet prints, with no oldest active age', async () => { + const parse = await loadParser() + const now = Date.parse('2026-09-20T12:00:00Z') + + const evidence = parse( + [ + { + timestamp: '2026-09-20T11:59:00Z', + textPayload: inventoryLine( + snapshot({ + active: 0, + awaitingReceipt: 0, + targetRegistered: 0, + completedLast24Hours: 0, + abortedLast24Hours: 0, + hostNotArrivedLast24Hours: 0, + oldestActiveAgeMs: null + }) + ) + } + ], + { now, maxAgeMs: 5 * 60_000 } + ) + + expect(evidence.oldestActiveAgeMs).toBeNull() + expect(evidence.hostNotArrivedLast24Hours).toBe(0) + }) + + it('covers every counter the formatter puts on the line', async () => { + const parse = await loadParser() + // A field the parser ignores is a field the operator never sees, so the + // census fails when the formatter gains one and this test is not updated. + const line = inventoryLine( + snapshot({ + active: 1, + awaitingReceipt: 1, + targetRegistered: 1, + completedLast24Hours: 1, + abortedLast24Hours: 1, + hostNotArrivedLast24Hours: 1, + oldestActiveAgeMs: 1 + }) + ) + const printed = line + .slice('[orca-relay] regional rehome inventory '.length) + .split(' ') + .map((field) => field.split('=')[0]) + + const surfaced = Object.keys( + parse([{ timestamp: '2026-09-20T11:59:00Z', textPayload: line }], { + now: Date.parse('2026-09-20T12:00:00Z'), + maxAgeMs: 5 * 60_000 + }) + ) + + expect([...printed].sort()).toEqual([...surfaced].sort()) + }) +}) diff --git a/cloud/apps/relay/src/regional-rehome-poll-telemetry.ts b/cloud/apps/relay/src/regional-rehome-poll-telemetry.ts index ca75e1b15056..b25a9476cee2 100644 --- a/cloud/apps/relay/src/regional-rehome-poll-telemetry.ts +++ b/cloud/apps/relay/src/regional-rehome-poll-telemetry.ts @@ -26,6 +26,7 @@ export class RegionalRehomePollTelemetry { private gates = { ...EMPTY_GATES } private candidates = 0 private selectionSamplesMs: number[] = [] + private skippedOffRegionSourceCellsMax = 0 constructor(private readonly write: (line: string) => void = (line) => console.warn(line)) {} @@ -34,11 +35,16 @@ export class RegionalRehomePollTelemetry { gate: RegionalRehomePollGate candidates: number selectionMs?: number + skippedOffRegionSourceCells?: number }): void { if (this.windowStartedAt === null) this.windowStartedAt = input.now this.gates[input.gate] += 1 this.candidates += input.candidates if (input.selectionMs !== undefined) this.selectionSamplesMs.push(input.selectionMs) + this.skippedOffRegionSourceCellsMax = Math.max( + this.skippedOffRegionSourceCellsMax, + input.skippedOffRegionSourceCells ?? 0 + ) if (input.now - this.windowStartedAt < REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS) return this.write( JSON.stringify({ @@ -48,13 +54,16 @@ export class RegionalRehomePollTelemetry { ...this.gates, candidates: this.candidates, selectionMsMax: round(Math.max(0, ...this.selectionSamplesMs)), - selectionMsP95: round(percentile(this.selectionSamplesMs, 0.95)) + selectionMsP95: round(percentile(this.selectionSamplesMs, 0.95)), + // A per-poll maximum: every poll reads the same inventory, so a sum counts polls. + skippedOffRegionSourceCells: this.skippedOffRegionSourceCellsMax }) ) this.windowStartedAt = input.now this.gates = { ...EMPTY_GATES } this.candidates = 0 this.selectionSamplesMs = [] + this.skippedOffRegionSourceCellsMax = 0 } } diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index 073077071243..c1adbb912914 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -402,31 +402,19 @@ describePostgres('PostgreSQL regional rehoming', () => { ).toEqual([{ count: '0' }]) }) - it('rechecks fleet safety under locks before mutating a candidate', async () => { + // The commit reads fleet safety as a snapshot; it no longer locks the safety + // table, so the recheck covers a change committed after selection. + it('rechecks fleet safety inside the commit before mutating a candidate', async () => { const context = await fixture() const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) expect(request).toBeDefined() - let unlock!: () => void - let locked!: () => void - const lockedPromise = new Promise((resolve) => (locked = resolve)) - const unlockPromise = new Promise((resolve) => (unlock = resolve)) - const held = secondary.transaction(async (transaction) => { - await transaction.queryLocked(`SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ - context.target.id - ]) - await transaction.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [context.target.id] - ) - locked() - await unlockPromise - }) - await lockedPromise + await secondary.query( + `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, + [context.target.id] + ) const claim = context.store.commitIdleRegionalRehome(request!, safety(context.now())) - unlock() - await held - await expect(claim).resolves.toEqual({ outcome: 'deferred' }) + await expect(claim).resolves.toEqual({ outcome: 'deferred', reason: 'fleet-safety' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false @@ -450,7 +438,7 @@ describePostgres('PostgreSQL regional rehoming', () => { await expect( context.store.commitIdleRegionalRehome(request!, safety(context.now())) - ).resolves.toEqual({ outcome: 'deferred' }) + ).resolves.toEqual({ outcome: 'deferred', reason: 'fleet-safety' }) await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 2, enabled: false diff --git a/cloud/apps/relay/src/regional-rehome-source-region-filter.test.ts b/cloud/apps/relay/src/regional-rehome-source-region-filter.test.ts new file mode 100644 index 000000000000..3cc18c4802dd --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-source-region-filter.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { rehomeSourceRegionAllowed } from './idle-regional-rehome-selection.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' +import { REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS } from './regional-rehome-poll-telemetry.js' + +type Region = 'us-central1' | 'asia-east2' +const cell = (id: string, region: Region) => ({ + id, + url: `https://${id}.relay.example.test`, + region, + capacityRequests: 100, + connectionHardCap: 1_000 as const, + connectionUnobservedBound: 60 +}) +const usA = cell('us-a', 'us-central1') +const usB = cell('us-b', 'us-central1') +const asiaA = cell('asia-a', 'asia-east2') +const asiaB = cell('asia-b', 'asia-east2') +const cells = [usA, usB, asiaA, asiaB] +const usHost = { userId: 'user-us', relayHostId: 'usususususususus' } +const asiaHost = { userId: 'user-asia', relayHostId: 'asiaasiaasiaasia' } + +const incarnation = (n: number) => + `${String(n).repeat(8)}-${String(n).repeat(4)}-4${String(n).repeat(3)}` + + `-8${String(n).repeat(3)}-${String(n).repeat(12)}` + +async function setup(directorRegion: Region | undefined) { + let clock = 1_000_000 + const database = await openIdleRehomeTestDatabase() + const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100, + regionalRehomeDirectorRegion: directorRegion, + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + await store.inspectRegionalRehomeControl() + clock += 24 * 60 * 60_000 + await store.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: clock, + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60 * 60_000 + }) + await store.reconcileCells(cells) + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + const beatAll = async () => { + for (const [index, config] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: config.id, + cellUrl: config.url, + region: config.region, + cellIncarnation: incarnation(index + 1), + startedAt: 900_000, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionInclusionWatermark: clock, + connectionHardCap: 1_000, + connectionUnobservedBound: 60 + }) + await store.recordCellRegionalRehomeStatus({ + cellId: config.id, + cellIncarnation: incarnation(index + 1), + regionalRehomeProtocol: 3, + safety: safety() + }) + } + } + // Homes the host in `home` with a conclusive measurement preferring the other region. + const placeHost = async (identity: { userId: string; relayHostId: string }, home: Region) => { + const assignment = await store.assign(identity, undefined, home) + const homeCell = cells.find(({ id }) => id === assignment.cellId)! + expect(homeCell.region).toBe(home) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1, + idleRegionalRehome: true, + cellIncarnation: incarnation(cells.indexOf(homeCell) + 1) + }) + const { window } = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + expect(window).toBeDefined() + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: + home === 'us-central1' + ? { 'us-central1': 180, 'asia-east2': 40 } + : { 'us-central1': 40, 'asia-east2': 180 } + }, + assignment.assignmentEpoch + ) + } + await beatAll() + await placeHost(usHost, 'us-central1') + await placeHost(asiaHost, 'asia-east2') + return { + database, + store, + safety, + beatAll, + advance: (ms: number) => { + clock += ms + } + } +} + +const moves = (candidates: { userId: string; sourceCellId: string; targetCellId: string }[]) => + candidates.map(({ userId, sourceCellId, targetCellId }) => ({ + userId, + from: cells.find(({ id }) => id === sourceCellId)!.region, + to: cells.find(({ id }) => id === targetCellId)!.region + })) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('rehome source-region filter', () => { + it('keys on the source region, never on the target region', () => { + expect(rehomeSourceRegionAllowed('us-central1', 'us-central1')).toBe(true) + expect(rehomeSourceRegionAllowed('asia-east2', 'us-central1')).toBe(false) + expect(rehomeSourceRegionAllowed('asia-east2', undefined)).toBe(true) + }) + + it('keeps US to Asia moves and drops Asia to US moves', async () => { + const context = await setup('us-central1') + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + // Asia is filtered as a source yet is still every US host's target. + expect(new Set(moves(candidates).map((move) => JSON.stringify(move)))).toEqual( + new Set([JSON.stringify({ userId: usHost.userId, from: 'us-central1', to: 'asia-east2' })]) + ) + expect(new Set(candidates.map(({ targetCellId }) => targetCellId))).toEqual( + new Set([asiaA.id, asiaB.id]) + ) + await context.database.close() + }) + + it('selects both directions when no director region is configured', async () => { + const context = await setup(undefined) + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + expect(new Set(moves(candidates).map(({ from, to }) => `${from}>${to}`))).toEqual( + new Set(['us-central1>asia-east2', 'asia-east2>us-central1']) + ) + await context.database.close() + }) + + it('counts the skipped source cells in the poll summary line', async () => { + const context = await setup('us-central1') + const lines: string[] = [] + vi.spyOn(console, 'warn').mockImplementation((line: unknown) => { + lines.push(String(line)) + }) + await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + context.advance(REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS) + await context.beatAll() + await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const summary = lines + .map((line) => JSON.parse(line) as Record) + .find(({ event }) => event === 'orca_relay_regional_rehome_poll_summary') + expect(summary).toMatchObject({ open: 2, skippedOffRegionSourceCells: 2 }) + await context.database.close() + }) + + it('reports the filtered hosts in the preview, agreeing with selection', async () => { + const context = await setup('us-central1') + const preview = await context.store.previewRegionalRehomeEligibility(context.safety()) + expect(preview.counts).toEqual({ + 'eligible:us-central1-to-asia-east2': 1, + 'source-outside-director-region': 1 + }) + await context.database.close() + }) +}) diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 2d34661816bd..f53c1c429f03 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { readAssignmentInventorySnapshot } from './assignment-inventory-snapshot.js' +import { REGIONAL_REHOME_ARRIVAL_WINDOW_MS } from './regional-rehome-abort-reason.js' import { RelayAssignmentStore as BaseRelayAssignmentStore, type RegionalRehomeAttempt, @@ -413,7 +415,8 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'fleet-safety' }) } finally { warnings.restore() @@ -452,8 +455,10 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { + // Per-cell, so it excludes this target rather than stopping the poll. expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'candidate-ineligible' }) } finally { warnings.restore() @@ -778,7 +783,8 @@ describe('regional rehome assignment state', () => { ) expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ - outcome: 'deferred' + outcome: 'deferred', + reason: 'fleet-safety' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, @@ -1085,6 +1091,112 @@ describe('regional rehome assignment state', () => { await context.database.close() }) + it('rolls a host that never reached its target back to the source at the arrival window', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS - 1) + await freshHeartbeats(context) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + + context.advance(1) + await freshHeartbeats(context) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(1) + // Where the host was, so its next reconnect lands on the source it left. + expect(await context.store.resolve(identity)).toMatchObject({ + cellId: source.id, + assignmentEpoch: 3 + }) + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + await context.database.close() + }) + + it('leaves the durable switch alone when it rolls back an unarrived host', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + const before = await context.store.inspectRegionalRehomeControl() + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS) + await freshHeartbeats(context) + + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(1) + + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ + generation: before.generation, + enabled: true + }) + const [attempt] = await context.database.query( + 'SELECT abort_reason FROM relay_region_rehome_attempts' + ) + expect(attempt!.abort_reason).toBe('host_not_arrived') + // What the rollout tracker reads to see a leak before it fills the budget. + const preview = await context.store.previewRegionalRehomeEligibility() + expect(preview.abortedLast24Hours).toEqual({ host_not_arrived: 1 }) + expect( + (await readAssignmentInventorySnapshot(context.database, context.now())).regionalRehomes + ).toMatchObject({ abortedLast24Hours: 1, hostNotArrivedLast24Hours: 1 }) + await context.database.close() + }) + + it('leaves a host that did reach its target for the completion sweep', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + // Past the window, but present at the target: the sweep reads live + // ownership, not the attempt's age alone. + context.advance(REGIONAL_REHOME_ARRIVAL_WINDOW_MS) + await freshHeartbeats(context) + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1, + cellIncarnation: targetIncarnation + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + + expect(await context.store.abortUnarrivedRegionalRehomes()).toBe(0) + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + expect(await context.store.resolve(identity)).toMatchObject({ cellId: target.id }) + await context.database.close() + }) + + it('names the 24-hour latch in its own abort reason', async () => { + const context = await setup() + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.store.releaseActivity(identity, sourceControl) + context.advance(24 * 60 * 60_000) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) + + expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) + + const [attempt] = await context.database.query( + 'SELECT abort_reason FROM relay_region_rehome_attempts' + ) + expect(attempt!.abort_reason).toBe('max_refresh_expired') + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ enabled: false }) + await context.database.close() + }) + it('rolls back an inactive registered target only after the 24-hour bound', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -1758,6 +1870,7 @@ function hookAfterCandidateScan( ): RelayDatabase { let fired = false const decorate = (delegate: RelayDatabase): RelayDatabase => ({ + dialect: delegate.dialect, query: async (sql, params) => { const rows = await delegate.query(sql, params) if (!fired && sql.includes('SELECT d.user_id, d.relay_host_id')) { @@ -1898,9 +2011,14 @@ class CellInventoryLockProbe { wrap(database: RelayDatabase): RelayDatabase { const probe = this const decorate = (delegate: RelayDatabase): RelayDatabase => ({ + dialect: delegate.dialect, query: async (sql, params) => await delegate.query(sql, params), queryLocked: async (sql, params, options) => { - if (sql.trim() === 'SELECT * FROM relay_cells ORDER BY cell_id ASC') { + // The idle commit's only cell lock is its target row, so it counts too. + if ( + sql.trim() === 'SELECT * FROM relay_cells ORDER BY cell_id ASC' || + sql.includes('FROM relay_cells cell') + ) { probe.locks.push(options) if (probe.failWith) throw probe.failWith if (options?.failIfUnavailable && probe.failNoWaitTimes > 0) { diff --git a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts new file mode 100644 index 000000000000..2263820407ad --- /dev/null +++ b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts @@ -0,0 +1,428 @@ +import pg from 'pg' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { + consumeRelayCellInventoryHold, + openRelayDatabase, + PostgresDatabase, + type RelayDatabase +} from './database.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +// Roughly the round trip from an Asia cell to the us-central1 database. +const STATEMENT_DELAY_MS = 150 + +// Everything the fleet-wide rehome lock used to hold, per cell, plus the +// admission and region tables the commit reads. +const CELL_TABLES = [ + 'relay_cells', + 'relay_cell_runtime', + 'relay_cell_capabilities', + 'relay_cell_rehome_safety', + 'relay_cell_admission', + 'relay_cell_regions' +] as const + +// The target-row statement locks exactly these, held from it to COMMIT. +const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission'] + +type Trip = { sql: string; lockable: Record } + +type DelayControl = { + enabled: boolean + // Runs before each delayed statement leaves the client, so it sees the locks + // the transaction holds between round trips. + beforeTrip: (sql: string) => Promise +} + +describePostgres('PostgreSQL regional rehome target-row lock', () => { + let primary: RelayDatabase + let observer: RelayDatabase + let delayed: PostgresDatabase + const control: DelayControl = { enabled: false, beforeTrip: async () => undefined } + let sequence = 0 + + beforeAll(async () => { + primary = await openRelayDatabase({ databaseUrl, dataDir: '' }) + observer = await openRelayDatabase({ databaseUrl, dataDir: '' }) + delayed = openDelayedDatabase(databaseUrl!, control) + }) + + beforeEach(async () => { + control.enabled = false + control.beforeTrip = async () => undefined + await cleanup() + }) + + afterAll(async () => { + control.enabled = false + await cleanup() + await delayed.close() + await observer.close() + await primary.close() + }) + + async function cleanup(): Promise { + for (const table of [ + 'relay_region_decisions', + 'relay_control_capabilities', + 'relay_region_rehome_attempts', + 'relay_control_connection_reservations', + 'relay_assignment_migration_incarnations', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignment_region_preferences', + 'relay_assignments' + ]) { + await primary.query(`DELETE FROM ${table} WHERE user_id LIKE 'pg-lock-user-%'`) + } + await primary.query(`DELETE FROM relay_region_rehome_worker_state`) + await primary.query(`DELETE FROM relay_region_rehome_control`) + for (const table of [ + 'relay_cell_rehome_safety', + 'relay_cell_capabilities', + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_runtime', + 'relay_cell_connection_limits', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await primary.query(`DELETE FROM ${table} WHERE cell_id LIKE 'pg-lock-cell-%'`) + } + } + + async function lockable(table: string, cellId: string): Promise { + try { + await observer.transaction( + async (transaction) => { + await transaction.queryLocked(`SELECT cell_id FROM ${table} WHERE cell_id = ?`, [cellId], { + failIfUnavailable: true + }) + }, + { reportRetries: false } + ) + return true + } catch (error) { + if (error instanceof Error && error.message === 'database_lock_unavailable') return false + throw error + } + } + + async function reservedRequests(cellId: string): Promise { + const row = ( + await primary.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellId]) + )[0]! + return Number(row.reserved_requests) + } + + async function commitCounts(userId: string): Promise<{ attempts: number; migrations: number }> { + const attempts = await primary.query( + `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts WHERE user_id = ?`, + [userId] + ) + const migrations = await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [userId] + ) + return { attempts: Number(attempts[0]!.count), migrations: Number(migrations[0]!.count) } + } + + // Mirrors the admission selector's write: every cell row locked, then the + // admission row and the cell's enabled flag rewritten. + async function flipAdmission(transaction: RelayDatabase, cellId: string, now: number) { + await transaction.queryLocked(`SELECT cell_id FROM relay_cells ORDER BY cell_id ASC`) + await transaction.query( + `UPDATE relay_cell_admission SET admission_state = 'migration-only', updated_at = ? + WHERE cell_id = ?`, + [now, cellId] + ) + await transaction.query(`UPDATE relay_cells SET enabled = 1, updated_at = ? WHERE cell_id = ?`, [ + now, + cellId + ]) + } + + it('holds only the target row, for about one round trip, while other cells stay lockable', async () => { + const context = await fixture() + const request = await context.select() + const trips: Trip[] = [] + const probed = { + source: context.source.id, + target: context.target.id, + bystander: context.bystander.id + } + control.beforeTrip = async (sql) => { + const state: Record = {} + for (const [role, cellId] of Object.entries(probed)) { + for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId) + } + trips.push({ sql, lockable: state }) + } + consumeRelayCellInventoryHold(delayed) + control.enabled = true + + const result = await context.delayedStore.commitIdleRegionalRehome(request, context.safety()) + control.enabled = false + + expect(result).toEqual({ outcome: 'committed' }) + // The transaction really spans many cross-region round trips. + expect(trips.length).toBeGreaterThanOrEqual(15) + expect(trips[trips.length - 1]!.sql).toBe('COMMIT') + for (const trip of trips) { + for (const [key, free] of Object.entries(trip.lockable)) { + if (TARGET_LOCKED.includes(key)) continue + expect({ sql: trip.sql, key, free }).toEqual({ sql: trip.sql, key, free: true }) + } + } + // Locked at exactly one trip boundary, the one before COMMIT. + for (const key of TARGET_LOCKED) { + expect(trips.filter((trip) => !trip.lockable[key]).map((trip) => trip.sql)).toEqual([ + 'COMMIT' + ]) + } + const counts = consumeRelayCellInventoryHold(delayed) + console.info( + JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts }) + ) + expect(counts.rehomeTargetRowHolds).toBe(1) + expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row') + expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS) + expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS) + expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2) + }) + + it('defers when the target leaves general admission after selection', async () => { + const context = await fixture() + const request = await context.select() + control.beforeTrip = async (sql) => { + if (!sql.includes('WITH target AS')) return + await observer.transaction( + async (transaction) => await flipAdmission(transaction, context.target.id, context.now()) + ) + } + control.enabled = true + + const result = await context.delayedStore.commitIdleRegionalRehome(request, context.safety()) + control.enabled = false + + expect(result).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) + await expectNothingCommitted(context) + }) + + it('defers instead of waiting while an admission change holds the target row', async () => { + const context = await fixture() + const request = await context.select() + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let writer: Promise | undefined + control.beforeTrip = async (sql) => { + if (!sql.includes('WITH target AS') || writer) return + let locked!: () => void + const lockedPromise = new Promise((resolve) => (locked = resolve)) + writer = observer.transaction(async (transaction) => { + await flipAdmission(transaction, context.target.id, context.now()) + locked() + await released + }) + await lockedPromise + } + consumeRelayCellInventoryHold(delayed) + control.enabled = true + + const startedAt = performance.now() + const result = await context.delayedStore.commitIdleRegionalRehome(request, context.safety()) + const elapsedMs = performance.now() - startedAt + control.enabled = false + release() + await writer + + expect(result).toEqual({ outcome: 'deferred', reason: 'candidate-ineligible' }) + expect(consumeRelayCellInventoryHold(delayed)).toMatchObject({ + cellInventoryLockUnavailable: 1, + rehomeTargetRowHolds: 0 + }) + // NOWAIT: no 1s lock_timeout wait and no transaction retry behind the writer. + expect(elapsedMs).toBeLessThan(40 * STATEMENT_DELAY_MS) + await expectNothingCommitted(context) + }) + + async function expectNothingCommitted(context: Awaited>) { + expect(await commitCounts(context.identity.userId)).toEqual({ attempts: 0, migrations: 0 }) + expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore) + expect( + await primary.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + context.identity.userId + ]) + ).toEqual([{ cell_id: context.source.id }]) + const outcomes = await readRegionCorrectionOutcomes(primary, context.now()) + expect(outcomes.filter((outcome) => outcome.targetCellId === context.target.id)).toEqual([]) + } + + async function fixture() { + sequence++ + let now = 1_000_000 + const suffix = String(sequence) + // An Asia source correcting to a US target: the direction whose commit + // held the whole inventory for ~3.6s in production. + const source = cell(suffix, 'source', 'asia-east2') + const target = cell(suffix, 'target', 'us-central1') + const bystander = cell(suffix, 'zbystander', 'asia-east2') + const store = new RelayAssignmentStore(primary, () => now, storeOptions) + const delayedStore = new RelayAssignmentStore(delayed, () => now, storeOptions) + await store.inspectRegionalRehomeControl() + now += 24 * 60 * 60_000 + await store.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: now, + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + await store.reconcileCells([source, target]) + await heartbeat(store, source, '11111111-1111-4111-8111-111111111111', now) + await heartbeat(store, target, '22222222-2222-4222-8222-222222222222', now) + const identity = { + userId: `pg-lock-user-${suffix}`, + relayHostId: `lockhost${suffix.padStart(8, '0')}` + } + const assignment = await store.assign(identity, undefined, 'asia-east2') + // Added after placement so the host cannot land on it. + await store.reconcileCells([source, target, bystander]) + await heartbeat(store, bystander, '33333333-3333-4333-8333-333333333333', now) + await store.activateControl(identity, { + cellId: source.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1, + idleRegionalRehome: true, + cellIncarnation: '11111111-1111-4111-8111-111111111111' + }) + await store.assign(identity, 'us-central1') + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 150 } + }, + assignment.assignmentEpoch + ) + const safety = () => processSafety(now) + return { + store, + delayedStore, + identity, + source, + target, + bystander, + targetReservedBefore: await reservedRequests(target.id), + now: () => now, + safety, + select: async () => { + const [request] = await store.selectIdleRegionalRehomeCandidates(safety()) + expect(request).toMatchObject({ sourceCellId: source.id, targetCellId: target.id }) + return request! + } + } + } +}) + +// Same session settings as the serving pool, with a fixed delay in front of +// every statement, BEGIN and COMMIT included. +function openDelayedDatabase(url: string, control: DelayControl): PostgresDatabase { + const pool = new pg.Pool({ + connectionString: url, + max: 4, + statement_timeout: 5_000, + lock_timeout: 1_000, + idle_in_transaction_session_timeout: 5_000 + }) + pool.on('error', () => undefined) + pool.on('connect', (client) => { + const query = client.query + Object.assign(client, { + query: async (...args: unknown[]) => { + if (control.enabled) { + await control.beforeTrip(typeof args[0] === 'string' ? args[0] : '') + await new Promise((resolve) => setTimeout(resolve, STATEMENT_DELAY_MS)) + } + return await Reflect.apply(query, client, args) + } + }) + }) + return new PostgresDatabase(pool) +} + +const storeOptions = { + regionalRehomeCohortPercent: 100, + requireLiveCells: true, + heartbeatTtlMs: 45_000 +} + +function cell(suffix: string, role: string, region: 'us-central1' | 'asia-east2') { + return { + id: `pg-lock-cell-${suffix}-${role}`, + url: `https://pg-lock-${suffix}-${role}.example.test`, + region, + capacityRequests: 100, + connectionHardCap: 1_000 as const, + connectionUnobservedBound: 60 + } +} + +function processSafety(now: number) { + return { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } +} + +async function heartbeat( + store: RelayAssignmentStore, + cellConfig: ReturnType, + cellIncarnation: string, + now: number +): Promise { + await store.recordCellHeartbeat({ + cellId: cellConfig.id, + cellUrl: cellConfig.url, + region: cellConfig.region, + cellIncarnation, + startedAt: 900_000, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionInclusionWatermark: 1, + connectionHardCap: 1_000, + connectionUnobservedBound: 60 + }) + await store.recordCellRegionalRehomeStatus({ + cellId: cellConfig.id, + cellIncarnation, + regionalRehomeProtocol: 3, + safety: processSafety(now) + }) +} diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index 640ae06b1213..ffbc4c88497e 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -86,6 +86,76 @@ describe('regional rehome worker', () => { ).toBeNull() }) + it('stops walking the page when the source names a deferral no candidate can pass', async () => { + const fetchImpl = respondWith([{ outcome: 'deferred', reason: 'concurrency-limit' }]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 3, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(summaries.entries).toEqual([ + { + event: 'orca_relay_idle_rehome_dispatch_summary', + candidates: 3, + dispatched: 1, + stoppedBy: 'concurrency-limit', + outcomes: { 'deferred:concurrency-limit': 1 } + } + ]) + }) + + it('keeps its whole-page walk when the source sends no reason at all', async () => { + const fetchImpl = respondWith([{ outcome: 'deferred' }]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 3, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(summaries.entries[0]).toMatchObject({ + stoppedBy: null, + outcomes: { deferred: 3 } + }) + }) + + it('walks past a deferral that only concerns the one candidate', async () => { + const fetchImpl = respondWith([ + { outcome: 'deferred', reason: 'host-unsupported' }, + { outcome: 'busy' }, + { outcome: 'committed' } + ]) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 4, summaries) + } finally { + summaries.restore() + } + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(summaries.entries[0]).toMatchObject({ + dispatched: 3, + stoppedBy: 'committed', + outcomes: { 'deferred:host-unsupported': 1, busy: 1, committed: 1 } + }) + }) + + it('counts a source that answers with an error in the same summary', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 503 })) + const summaries = collectSummaries() + try { + await runOnePoll(fetchImpl, 2, summaries) + } finally { + summaries.restore() + } + + expect(summaries.entries[0]).toMatchObject({ dispatched: 2, outcomes: { failed: 2 } }) + }) + it('treats the reconnect threshold as per-cell and excludes the director', () => { const cells = 2 const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT @@ -108,6 +178,76 @@ describe('regional rehome worker', () => { }) }) +// Answers each POST with the next scripted body, repeating the last one. +function respondWith(bodies: { outcome: string; reason?: string }[]) { + let index = 0 + return vi.fn(async () => { + const body = bodies[Math.min(index++, bodies.length - 1)]! + return new Response(JSON.stringify({ v: 1, ...body }), { + headers: { 'content-type': 'application/json' } + }) + }) +} + +function collectSummaries() { + const entries: Record[] = [] + let arrived: (() => void) | undefined + // The worker polls once the moment it is constructed, so the poll under test + // is that one; `first` is how a test waits for it rather than for a tick. + const first = new Promise((resolve) => { + arrived = resolve + }) + const original = console.warn + console.warn = (line: unknown, ...rest: unknown[]) => { + try { + const parsed = JSON.parse(line as string) as Record + if (parsed.event === 'orca_relay_idle_rehome_dispatch_summary') { + entries.push(parsed) + arrived?.() + return + } + } catch { + // Not a JSON log line; fall through to the original writer. + } + original(line as string, ...rest) + } + return { entries, first, restore: () => (console.warn = original) } +} + +async function runOnePoll( + fetchImpl: typeof fetch, + candidates: number, + summaries: { first: Promise } +): Promise { + const assignments = { + selectIdleRegionalRehomeCandidates: vi.fn(async () => + Array.from({ length: candidates }, (_, index) => ({ + v: 1 as const, + attemptId: `00000000-0000-4000-8000-00000000000${index}`, + userId: `user-${index}`, + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'us-c1', + sourceCellUrl: 'https://us-c1.relay.example.test', + sourceCellIncarnation: '11111111-1111-4111-8111-111111111111', + sourceAssignmentEpoch: 1, + sourceGeneration: 1, + targetCellId: 'asia-c1' + })) + ) + } as unknown as RelayAssignmentStore + const worker = startRegionalRehomeWorker(config(), assignments, { + fetch: fetchImpl, + identityToken: async () => 'token', + safetySnapshot: () => safety(Date.now()), + intervalMs: 60_000 + })! + try { + await summaries.first + } finally { + worker.stop() + } +} + function config(overrides: Partial = {}): RelayConfig { return { role: 'director', diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 67d872b888a7..5bf426e11b38 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -1,4 +1,7 @@ -import { IdleRegionalRehomeResponseSchema } from '@orca-cloud/relay-contract' +import { + IdleRegionalRehomeResponseSchema, + isGlobalIdleRegionalRehomeDeferral +} from '@orca-cloud/relay-contract' import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' @@ -48,8 +51,13 @@ export function startRegionalRehomeWorker( const candidates = await assignments.selectIdleRegionalRehomeCandidates(safetySnapshot()) if (candidates.length === 0) return const token = await tokenProvider(audience) + const outcomes: Record = {} + const tally = (key: string) => { + outcomes[key] = (outcomes[key] ?? 0) + 1 + } + let stoppedBy: string | null = null for (const candidate of candidates) { - if (stopped) return + if (stopped) break const { sourceCellUrl, ...request } = candidate try { const response = await fetchImpl(new URL('/v1/admin/host-idle-rehome', sourceCellUrl), { @@ -64,6 +72,7 @@ export function startRegionalRehomeWorker( }) if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) const body = IdleRegionalRehomeResponseSchema.parse(await response.json()) + tally(body.reason ? `${body.outcome}:${body.reason}` : body.outcome) if (body.outcome === 'committed') { console.warn( JSON.stringify({ @@ -72,9 +81,18 @@ export function startRegionalRehomeWorker( targetCellId: candidate.targetCellId }) ) - return + stoppedBy = 'committed' + break + } + // Every remaining candidate would re-read the same durable row and + // answer the same way, so the rest of this page is wasted POSTs. + // A source on an older image sends no reason and keeps the old walk. + if (body.outcome === 'deferred' && isGlobalIdleRegionalRehomeDeferral(body.reason)) { + stoppedBy = body.reason + break } } catch (error) { + tally('failed') // The source may have committed; its durable outcome owns recovery. console.warn( JSON.stringify({ @@ -84,6 +102,17 @@ export function startRegionalRehomeWorker( ) } } + // One line per poll that dispatched: silence used to be the only signal + // that 100+ candidates all came back deferred. + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_dispatch_summary', + candidates: candidates.length, + dispatched: Object.values(outcomes).reduce((total, count) => total + count, 0), + stoppedBy, + outcomes + }) + ) } catch (error) { console.warn( JSON.stringify({ diff --git a/cloud/apps/relay/src/relay-readiness-coalescing.test.ts b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts new file mode 100644 index 000000000000..2dc8ca0af224 --- /dev/null +++ b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts @@ -0,0 +1,106 @@ +import { expect, it, vi } from 'vitest' +import type { RelayDatabase } from './database.js' +import { createRelayReadiness } from './relay-readiness.js' + +function database(query: RelayDatabase['query']): RelayDatabase { + return { + query, + queryLocked: query, + transaction: (operation) => operation(database(query)), + close: async () => {} + } +} + +function gate() { + let release!: () => void + const wait = new Promise((resolve) => (release = resolve)) + return { wait, release } +} + +it('shares both dependency probes and caches from completion for concurrent callers', async () => { + const sql = gate() + let now = 1_000 + const query = vi.fn(async () => { + await sql.wait + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}')) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + const checks = Array.from({ length: 100 }, () => readiness.check()) + try { + expect({ sql: query.mock.calls.length, jwks: fetchImpl.mock.calls.length }).toEqual({ + sql: 1, + jwks: 1 + }) + expect(observe).not.toHaveBeenCalled() + now = 5_000 + } finally { + sql.release() + } + expect(await Promise.all(checks)).toEqual(Array(100).fill(true)) + expect(observe).toHaveBeenCalledTimes(1) + now = 14_999 + expect(await readiness.check()).toBe(true) + expect(query).toHaveBeenCalledTimes(1) + now = 15_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) + +it('shares failures, retains the failure cache, and retries after expiry', async () => { + let healthy = false + let now = 1_000 + const query = vi.fn(async () => { + if (!healthy) throw new Error('offline') + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}', { status: healthy ? 200 : 503 })) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(false) + ) + expect(query).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(1) + expect(readiness.degradedDependencies()).toEqual([]) + healthy = true + now = 10_999 + expect(await readiness.check()).toBe(false) + expect(query).toHaveBeenCalledTimes(1) + now = 11_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(observe).toHaveBeenCalledTimes(2) +}) + +it('keeps separate readiness owners independent', async () => { + const query = vi.fn(async () => [{ ready: 1 }]) + const fetchImpl = vi.fn(async () => new Response('{}')) + const first = createRelayReadiness(database(query), 'https://one.example.test', { + fetch: fetchImpl + }) + const second = createRelayReadiness(database(query), 'https://two.example.test', { + fetch: fetchImpl + }) + expect(await Promise.all([first.check(), first.check(), second.check(), second.check()])).toEqual( + [true, true, true, true] + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) diff --git a/cloud/apps/relay/src/relay-readiness.ts b/cloud/apps/relay/src/relay-readiness.ts index 973a827e1048..72d3cd5c4713 100644 --- a/cloud/apps/relay/src/relay-readiness.ts +++ b/cloud/apps/relay/src/relay-readiness.ts @@ -131,6 +131,7 @@ export function createRelayReadiness( const settleSql = createDependencyGrace('sql', options.sqlGraceMs ?? RELAY_READINESS_SQL_GRACE_MS) let cachedAt = Number.NEGATIVE_INFINITY let cached = false + let pending: Promise | null = null let lastObservedReady: boolean | undefined let degraded: RelayReadinessDependency[] = [] @@ -153,8 +154,7 @@ export function createRelayReadiness( } } - const check = async (): Promise => { - if (now() - cachedAt < cacheMs) return cached + const probe = async (): Promise => { const startedAt = now() const [jwks, sql] = await Promise.all([timed(now, probeJwks), timed(now, probeSql)]) const completedAt = now() @@ -185,5 +185,13 @@ export function createRelayReadiness( return cached } + const check = async (): Promise => { + if (now() - cachedAt < cacheMs) return cached + pending ??= probe().finally(() => { + pending = null + }) + return pending + } + return { check, degradedDependencies: () => [...degraded] } } diff --git a/cloud/apps/relay/src/relay-schema-lock-targets.test.ts b/cloud/apps/relay/src/relay-schema-lock-targets.test.ts index 68a7c6f0216a..5f1a8a88514d 100644 --- a/cloud/apps/relay/src/relay-schema-lock-targets.test.ts +++ b/cloud/apps/relay/src/relay-schema-lock-targets.test.ts @@ -126,6 +126,8 @@ const GOLDEN_LOCK_TAKING: SchemaLockTarget[] = [ { kind: 'column', table: 'relay_region_rehome_control', name: 'host_cooldown_ms', skipWhen: 'present' }, { kind: 'column', table: 'relay_control_capabilities', name: 'idle_regional_rehome', skipWhen: 'present' }, { kind: 'column', table: 'relay_region_rehome_attempts', name: 'source_generation', skipWhen: 'present' }, + { kind: 'column', table: 'relay_region_rehome_attempts', name: 'abort_reason', skipWhen: 'present' }, + { kind: 'column', table: 'relay_cell_admission', name: 'roll_isolated_at', skipWhen: 'present' }, { kind: 'index-by-name', name: 'relay_assignment_activity_expiry', skipWhen: 'absent' }, { kind: 'reloption', diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 3a0668bbcef3..e9a08398da9f 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -111,6 +111,9 @@ export function createRelayServer( const assignments = new RelayAssignmentStore(observedDatabase, options.now, { requireLiveCells: config.role === 'director', regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0, + // The director runs in the database's region; only its rehome readers use this. + regionalRehomeDirectorRegion: + config.role === 'director' ? (config.region ?? RELAY_DEFAULT_REGION) : undefined, recordControlRenewal: (durationMs, outcome) => observability.recordControlRenewal?.(durationMs, outcome) }) @@ -141,7 +144,7 @@ export function createRelayServer( idleRehome: (input) => { const now = (options.now ?? Date.now)() if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) { - return Promise.resolve({ outcome: 'deferred' }) + return Promise.resolve({ outcome: 'deferred', reason: 'director-safety-stale' }) } return sessions.idleRehome(input, () => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety( diff --git a/cloud/apps/relay/src/sqlite-transaction-queue.test.ts b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts new file mode 100644 index 000000000000..99fb311ae25e --- /dev/null +++ b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openRelayDatabase } from './database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-relay.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index da91ef44a19c..57d7043aeffd 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -135,6 +135,7 @@ "google_iam_workload_identity_pool_provider.github_staging_relay_capacity", "google_iam_workload_identity_pool_provider.github_staging_relay_deploy", "google_logging_metric.relay_incident", + "google_logging_metric.relay_long_cell_inventory_hold", "google_logging_metric.relay_snapshot", "google_monitoring_alert_policy.relay_assignment_5xx", "google_monitoring_alert_policy.relay_assignment_edge_429", @@ -145,8 +146,11 @@ "google_monitoring_alert_policy.relay_cloud_sql_checkpoint_loop", "google_monitoring_alert_policy.relay_cloud_sql_disk", "google_monitoring_alert_policy.relay_custom", + "google_monitoring_alert_policy.relay_director_cell_inventory_hold", "google_monitoring_alert_policy.relay_far_cell_accept_latency", "google_monitoring_alert_policy.relay_gce_connection_headroom", + "google_monitoring_alert_policy.relay_lock_timeout_burst", + "google_monitoring_alert_policy.relay_long_cell_inventory_hold", "google_monitoring_alert_policy.relay_postgres_retry_exhausted", "google_monitoring_alert_policy.relay_region_hint_skew", "google_monitoring_dashboard.relay_incident", diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index 32cd1858dd6f..e78ebdb4b2b5 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -113,6 +113,8 @@ function report(state, final = false) { ...readerQueueEvidence.map(({ increaseBytes }) => increaseBytes) ), readerClosesByCode: state.readerClosesByCode, + // Every cell a control connected to, so a canary can prove its load reached the target. + assignedCellOrigins: [...state.assignedCellOrigins].sort(), controlHeadroom: Math.max(0, state.controls - state.active.size), generatorRssMiB: Number(rssMiB.toFixed(1)), generatorPeakRssMiB: Number(state.generatorPeakRssMiB.toFixed(1)), @@ -241,6 +243,7 @@ const state = { wedgedReaderSplicesClosed: 0, readerEvidence: null, readerClosesByCode: {}, + assignedCellOrigins: new Set(), generatorBaselineRssMiB, generatorBaselineCpu, generatorPeakRssMiB: generatorBaselineRssMiB, @@ -299,6 +302,8 @@ function observe(type, detail) { if (type === 'connected') { state.active.add(detail.index) state.connected++ + const cellOrigin = peers.get(detail.index)?.assignedCellUrl() + if (cellOrigin) state.assignedCellOrigins.add(cellOrigin) state.peakActive = Math.max(state.peakActive, state.active.size) recordSteadyMinimum() } else if (type === 'closed') { diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index 45322bbe6202..b02b7c04cda0 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -12,16 +12,30 @@ const SHAPES = { staging: { directorOrigin: 'https://relay-staging.onorca.dev', domain: 'relay-staging.onorca.dev', - allCells: ['staging-gce-c4'] + allCells: ['staging-gce-c4'], + registrationWaves: [['staging-gce-c4']], + promotionWaves: [['staging-gce-c4']] }, production: { directorOrigin: 'https://relay.onorca.dev', domain: 'relay.onorca.dev', - allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30'], + // The launch set was registered together; each later cell registers alone beside it. + registrationWaves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ], + promotionWaves: [ + ['production-gce-c27'], + ['production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] } } -function parseArguments(argv) { +const PRODUCTION_CANARY_CELL = 'production-gce-c27' + +export function parseRelayAsiaAdmissionArguments(argv) { const values = {} for (let index = 0; index < argv.length; index += 2) { const key = argv[index] @@ -58,13 +72,17 @@ function parseArguments(argv) { throw new Error('--cell-ids are invalid') } const exact = (expected) => JSON.stringify([...cells].sort()) === JSON.stringify([...expected].sort()) + const [launchWave] = shape.registrationWaves if ( - (['inspect', 'initialize', 'register', 'registered', 'verify'].includes(values.mode) && - !exact(shape.allCells)) || - (['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'production' && - !exact(['production-gce-c27']) && !exact(['production-gce-c28', 'production-gce-c29'])) || - (['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'staging' && !exact(shape.allCells)) || - (values.mode === 'rollback' && cells.length === 0) + (['inspect', 'verify'].includes(values.mode) && + !exact(shape.allCells) && !shape.registrationWaves.some(exact)) || + // Generation zero predates every later cell, so the boundary freezes only the launch set. + (values.mode === 'initialize' && !exact(launchWave)) || + (['register', 'registered'].includes(values.mode) && !shape.registrationWaves.some(exact)) || + (['promote', 'recover-promotion'].includes(values.mode) && !shape.promotionWaves.some(exact)) || + // Rollback takes any reviewed wave or the whole set, never a mixed partial set. + (values.mode === 'rollback' && !exact(shape.allCells) && + ![...shape.registrationWaves, ...shape.promotionWaves].some(exact)) ) throw new Error('--cell-ids do not match the reviewed admission wave') const attemptId = values['attempt-id'] if (!['inspect', 'verify', 'registered'].includes(values.mode) && @@ -418,11 +436,19 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) { if ( config.mode === 'promote' && config.environment === 'production' && - config.cells.includes('production-gce-c28') && - selectorCellState(current.selector, 'production-gce-c27') !== 'general' + !config.cells.includes(PRODUCTION_CANARY_CELL) && + selectorCellState(current.selector, PRODUCTION_CANARY_CELL) !== 'general' ) { throw new Error('Asia expansion requires the C27 canary to be general') } + const [launchWave] = shape.registrationWaves + if ( + config.mode === 'promote' && + !config.cells.some((cellId) => launchWave.includes(cellId)) && + launchWave.some((cellId) => selectorCellState(current.selector, cellId) !== 'general') + ) { + throw new Error('a later Asia cell requires every launch cell to be general') + } const result = await applyExactAdmissionSelector( selectorPost, membershipWithStates(current.selector, Object.fromEntries( @@ -434,7 +460,7 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const config = parseArguments(process.argv.slice(2)) + const config = parseRelayAsiaAdmissionArguments(process.argv.slice(2)) if (!config.token) throw new Error('ORCA_RELAY_ADMIN_ID_TOKEN is required') console.log(JSON.stringify(await operateRelayAsiaAdmission(config))) } diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index 7f23bb1f8e62..0a84a3ca20de 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -1,13 +1,16 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { test } from 'node:test' -import { operateRelayAsiaAdmission } from './operate-relay-asia-admission.mjs' +import { + operateRelayAsiaAdmission, + parseRelayAsiaAdmissionArguments +} from './operate-relay-asia-admission.mjs' const digest = `sha256:${'a'.repeat(64)}` const membershipDigest = (membership) => createHash('sha256').update(JSON.stringify(membership)).digest('hex') -function harness(initialSelector) { +function harness(initialSelector, runtimeDigests = {}) { const initialMembership = structuredClone(initialSelector.membership) let selector = structuredClone(initialSelector) const intents = new Map() @@ -23,7 +26,7 @@ function harness(initialSelector) { cellId: `production-gce-${cell}`, cellUrl: parsed.origin, region: 'asia-east2', - imageDigest: digest, + imageDigest: runtimeDigests[`production-gce-${cell}`] ?? digest, draining: false, connectionCapacity: { hardCap: 3_000, unobservedBound: 60 } } @@ -510,3 +513,136 @@ test('requires the C27 canary before promoting C28 and C29', async () => { imageDigest: digest, attemptId: 'asia_wave_before_canary', token: 'not-logged' }, subject), /C27 canary/) }) + +const launchCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + +function admissionArguments(environment, mode, cellIds) { + return [ + '--environment', environment, '--mode', mode, '--cell-ids', cellIds, + '--image-digest', digest, '--expected-generation', '9', '--attempt-id', 'asia_wave_9' + ] +} + +test('accepts only reviewed Asia admission waves', () => { + const accepted = [ + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['inspect', 'production-gce-c30'], + ['verify', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['verify', 'production-gce-c30'], + ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['register', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['register', 'production-gce-c30'], + ['registered', 'production-gce-c30'], + ['promote', 'production-gce-c27'], + ['promote', 'production-gce-c28,production-gce-c29'], + ['promote', 'production-gce-c30'], + ['recover-promotion', 'production-gce-c30'], + ['rollback', 'production-gce-c27'], + ['rollback', 'production-gce-c28,production-gce-c29'], + ['rollback', 'production-gce-c30'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'] + ] + for (const [mode, cellIds] of accepted) { + assert.deepEqual( + parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)).cells, + cellIds.split(','), + `${mode} ${cellIds}` + ) + } + const rejected = [ + ['initialize', 'production-gce-c30'], + ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['register', 'production-gce-c29,production-gce-c30'], + ['registered', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['verify', 'production-gce-c27,production-gce-c30'], + ['promote', 'production-gce-c27,production-gce-c30'], + ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], + ['promote', 'production-gce-c31'], + ['rollback', 'production-gce-c27,production-gce-c30'], + ['rollback', 'production-gce-c28,production-gce-c29,production-gce-c30'], + ['rollback', 'production-gce-c29'], + ['register', 'staging-gce-c4'] + ] + for (const [mode, cellIds] of rejected) { + assert.throws( + () => parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)), + /--cell-ids/, + `${mode} ${cellIds}` + ) + } + assert.deepEqual( + parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'staging-gce-c4')).cells, + ['staging-gce-c4'] + ) + assert.throws( + () => parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'production-gce-c30')), + /--cell-ids are invalid/ + ) +}) + +test('registers C30 alone beside the general launch cells', async () => { + const subject = harness({ + generation: 9, + membership: { existingOnly: [], migrationOnly: [], general: [...launchCells] } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c30'], + expectedGeneration: 9, imageDigest: digest, attemptId: 'asia_register_c30', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c30', cellUrl: 'https://c30.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c30': 'migration-only' }) + assert.deepEqual(subject.selector().membership.general, launchCells) +}) + +test('requires the C27 canary to be general before promoting C30', async () => { + const selector = (general) => ({ + generation: 10, + membership: { + existingOnly: [], + migrationOnly: ['production-gce-c30', ...launchCells.filter((cell) => !general.includes(cell))].sort(), + general + } + }) + const config = { + environment: 'production', mode: 'promote', cells: ['production-gce-c30'], + expectedGeneration: 10, imageDigest: digest, attemptId: 'asia_promote_c30', token: 'not-logged' + } + await assert.rejects( + operateRelayAsiaAdmission(config, harness(selector(['production-gce-c28', 'production-gce-c29']))), + /C27 canary/ + ) + await assert.rejects( + operateRelayAsiaAdmission(config, harness(selector(['production-gce-c27', 'production-gce-c29']))), + /every launch cell to be general/ + ) + const subject = harness(selector([...launchCells])) + const result = await operateRelayAsiaAdmission(config, subject) + assert.deepEqual(result.states, { 'production-gce-c30': 'general' }) + assert.equal(subject.requests.filter(({ path }) => path === '/v1/admin/cell-status').length, 1) +}) + +test('promotes C30 on its own digest while the launch cells serve another', async () => { + const c30Digest = `sha256:${'b'.repeat(64)}` + const selector = { + generation: 10, + membership: { existingOnly: [], migrationOnly: ['production-gce-c30'], general: [...launchCells] } + } + const config = { + environment: 'production', mode: 'promote', cells: ['production-gce-c30'], + expectedGeneration: 10, imageDigest: c30Digest, attemptId: 'asia_promote_c30', token: 'not-logged' + } + const digests = { 'production-gce-c30': c30Digest } + const result = await operateRelayAsiaAdmission(config, harness(selector, digests)) + assert.deepEqual(result.states, { 'production-gce-c30': 'general' }) + await assert.rejects( + operateRelayAsiaAdmission({ ...config, imageDigest: digest }, harness(selector, digests)), + /production-gce-c30 runtime does not match/ + ) +}) diff --git a/cloud/dev/scripts/operate-relay-regional-rehome.mjs b/cloud/dev/scripts/operate-relay-regional-rehome.mjs index 94b21220fc0e..816107ae15f9 100644 --- a/cloud/dev/scripts/operate-relay-regional-rehome.mjs +++ b/cloud/dev/scripts/operate-relay-regional-rehome.mjs @@ -4,6 +4,8 @@ import { inspectAdmissionSelector } from './relay-admission-selector.mjs' const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' const MODES = new Set(['inspect', 'enable', 'pause', 'disable', 'recover-enable']) +// c1-c99 with no leading zero; a fixed ceiling here once refused a newly general cell. +const PRODUCTION_CELL_ID = /^production-gce-c[1-9][0-9]?$/ function canonicalCells(value) { if (value === 'none') return [] @@ -11,7 +13,7 @@ function canonicalCells(value) { if ( cells.length === 0 || new Set(cells).size !== cells.length || - cells.some((cell) => !/^production-gce-c(?:[1-9]|[12][0-9])$/.test(cell)) + cells.some((cell) => !PRODUCTION_CELL_ID.test(cell)) ) throw new Error('selector membership is invalid') return cells } diff --git a/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs b/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs index 51c132aa7c47..8e676833ec75 100644 --- a/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs +++ b/cloud/dev/scripts/operate-relay-regional-rehome.test.mjs @@ -99,6 +99,30 @@ test('parses exact selector and typed control confirmation', () => { ) }) +test('accepts every one- and two-digit production cell and rejects malformed membership', () => { + const parseGeneral = (general) => parseRegionalRehomeArguments( + argumentsFor('inspect').map((value, index, all) => + all[index - 1] === '--expected-general-cells' ? general : value + ), + { ORCA_RELAY_ADMIN_ID_TOKEN: 'token' } + ).expectedMembership.general + assert.deepEqual( + parseGeneral('production-gce-c30,production-gce-c9,production-gce-c29'), + ['production-gce-c29', 'production-gce-c30', 'production-gce-c9'] + ) + assert.deepEqual(parseGeneral('production-gce-c99'), ['production-gce-c99']) + for (const general of [ + 'production-gce-c0', + 'production-gce-c100', + 'production-gce-c09', + 'production-gce-c30,production-gce-c30', + 'staging-gce-c30', + ',' + ]) { + assert.throws(() => parseGeneral(general), /selector membership is invalid/, general) + } +}) + test('binds enable to exact selector and durable control generations', async () => { const requests = [] const controls = [ diff --git a/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs b/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs index cd39a83c5498..5f4cd5996195 100644 --- a/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-director-cells.mjs @@ -15,6 +15,9 @@ function argumentsFrom(argv) { return values } +// Production Asia pools sit 176 ms from Cloud SQL and run at 16; staging C4 stays at 10. +const ASIA_DATABASE_POOL_MAX = { production: 16, staging: 10 } + export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds, imageDigest }) { if (!Array.isArray(currentCells) || !topology || Array.isArray(topology)) { throw new Error('director inputs are invalid') @@ -36,7 +39,7 @@ export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds, !cell || cell.region !== 'asia-east2' || cell.capacity_requests !== 6_000 || - cell.database_pool_max !== 10 || + cell.database_pool_max !== ASIA_DATABASE_POOL_MAX[cellId.split('-')[0]] || cell.connection_hard_cap !== 3_000 || cell.connection_unobserved_bound !== 60 || cell.initially_enabled !== false || diff --git a/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs b/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs index 9a2237250065..da0f4232fcdf 100644 --- a/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-director-cells.test.mjs @@ -5,7 +5,7 @@ import { prepareRelayAsiaDirectorCells } from './prepare-relay-asia-director-cel const digest = `sha256:${'a'.repeat(64)}` const topologyCell = (ordinal, zone) => ({ origin: `https://c${ordinal}.relay.onorca.dev`, region: 'asia-east2', zone, - capacity_requests: 6_000, database_pool_max: 10, + capacity_requests: 6_000, database_pool_max: 16, connection_hard_cap: 3_000, connection_unobserved_bound: 60, initially_enabled: false, image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${digest}` @@ -50,6 +50,69 @@ test('is idempotent for an exact existing Asia cell and rejects director drift', }), /director configuration differs/) }) +test('appends C30 after the configured launch cells without touching them', () => { + const launch = prepareRelayAsiaDirectorCells({ + currentCells: [], + topology: { + 'production-gce-c27': topologyCell(27, 'asia-east2-a'), + 'production-gce-c28': topologyCell(28, 'asia-east2-b'), + 'production-gce-c29': topologyCell(29, 'asia-east2-c') + }, + cellIds: 'production-gce-c27,production-gce-c28,production-gce-c29', + imageDigest: digest + }) + const result = prepareRelayAsiaDirectorCells({ + currentCells: launch, + topology: { 'production-gce-c30': topologyCell(30, 'asia-east2-a') }, + cellIds: 'production-gce-c30', + imageDigest: digest + }) + assert.deepEqual(result.slice(0, 3), launch) + assert.deepEqual(result[3], { + id: 'production-gce-c30', url: 'https://c30.relay.onorca.dev', capacityRequests: 6_000, + region: 'asia-east2', initiallyEnabled: false, connectionHardCap: 3_000, + connectionUnobservedBound: 60 + }) +}) + +test('checks C30 against its own digest, not the launch cells\' digest', () => { + const c30Digest = `sha256:${'b'.repeat(64)}` + const c30 = { + ...topologyCell(30, 'asia-east2-a'), + image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${c30Digest}` + } + const launch = [27, 28, 29].map((ordinal) => ({ + id: `production-gce-c${ordinal}`, url: `https://c${ordinal}.relay.onorca.dev`, + capacityRequests: 6_000, region: 'asia-east2', initiallyEnabled: false, + connectionHardCap: 3_000, connectionUnobservedBound: 60 + })) + assert.equal(prepareRelayAsiaDirectorCells({ + currentCells: launch, topology: { 'production-gce-c30': c30 }, + cellIds: 'production-gce-c30', imageDigest: c30Digest + }).length, 4) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: launch, topology: { 'production-gce-c30': c30 }, + cellIds: 'production-gce-c30', imageDigest: digest + }), /does not match/) +}) + +test('pins the Asia pool per environment', () => { + const staging = { ...topologyCell(4, 'asia-east2-a'), database_pool_max: 10 } + assert.equal(prepareRelayAsiaDirectorCells({ + currentCells: [], topology: { 'staging-gce-c4': staging }, + cellIds: 'staging-gce-c4', imageDigest: digest + }).length, 1) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: [], topology: { 'staging-gce-c4': { ...staging, database_pool_max: 16 } }, + cellIds: 'staging-gce-c4', imageDigest: digest + }), /does not match/) + assert.throws(() => prepareRelayAsiaDirectorCells({ + currentCells: [], + topology: { 'production-gce-c30': { ...topologyCell(30, 'asia-east2-a'), database_pool_max: 10 } }, + cellIds: 'production-gce-c30', imageDigest: digest + }), /does not match/) +}) + test('rejects a mismatching topology state output', () => { const wrong = topologyCell(27, 'asia-east2-a') wrong.database_pool_max = 20 diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index db76a83ede0e..1199b3df3772 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -3,14 +3,26 @@ import { fileURLToPath } from 'node:url' const BOOT_IMAGE = 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21' const SHAPES = { - staging: { project: 'onorca-cloud-staging', cells: { 'staging-gce-c4': 'asia-east2-a' } }, + staging: { + project: 'onorca-cloud-staging', + databasePoolMax: 10, + cells: { 'staging-gce-c4': 'asia-east2-a' }, + waves: [['staging-gce-c4']] + }, production: { project: 'onorca-cloud', + databasePoolMax: 16, cells: { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', - 'production-gce-c29': 'asia-east2-c' - } + 'production-gce-c29': 'asia-east2-c', + 'production-gce-c30': 'asia-east2-a' + }, + // The launch set, then each later additive cell; a plan targets one wave, never live cells. + waves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] } } @@ -46,8 +58,10 @@ export function prepareRelayAsiaTopologyInput({ const shape = SHAPES[environment] if (!shape) throw new Error('invalid environment') const requested = cellIds.split(',').map((value) => value.trim()).filter(Boolean).sort() - const expected = Object.keys(shape.cells).sort() - if (new Set(requested).size !== requested.length || JSON.stringify(requested) !== JSON.stringify(expected)) { + const expected = shape.waves + .map((wave) => [...wave].sort()) + .find((wave) => JSON.stringify(wave) === JSON.stringify(requested)) + if (new Set(requested).size !== requested.length || !expected) { throw new Error('cell IDs do not match the reviewed Asia topology') } const prefix = `us-central1-docker.pkg.dev/${shape.project}/orca-cloud/relay@sha256:` @@ -76,7 +90,7 @@ export function prepareRelayAsiaTopologyInput({ boot_disk_gb: 30, boot_image: BOOT_IMAGE, capacity_requests: 6_000, - database_pool_max: 10, + database_pool_max: shape.databasePoolMax, image, initially_enabled: false, connection_hard_cap: 3_000, diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 03622ffbf569..6cfd04eaf946 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { prepareRelayAsiaTopologyInput } from './prepare-relay-asia-topology-input.mjs' @@ -9,12 +10,13 @@ const additionalRegions = { 'asia-east2': '10.42.1.0/24' } const productionCells = () => Object.fromEntries([ [27, 'asia-east2-a'], [28, 'asia-east2-b'], - [29, 'asia-east2-c'] + [29, 'asia-east2-c'], + [30, 'asia-east2-a'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: 'asia-east2', zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21', - capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false, + capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false, connection_hard_cap: 3_000, connection_unobserved_bound: 60 }])) @@ -32,11 +34,54 @@ test('accepts the exact production topology only after it is durably committed', hostname: 'c27', region: 'asia-east2', zone: 'asia-east2-a', machine_type: 'e2-standard-4', boot_disk_gb: 30, boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21', - capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false, + capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false, connection_hard_cap: 3_000, connection_unobserved_bound: 60 }) }) +test('accepts the additive C30 wave without re-planning the launch cells', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c30', image }) + assert.equal(result.relay_gce_cells['production-gce-c30'].zone, 'asia-east2-a') +}) + +// Reads the committed file so a reviewed-shape constant cannot drift from what the plan reads. +test('matches every committed production Asia cell entry', () => { + const tfvars = readFileSync( + new URL('../../infra/terraform/environments/production.tfvars', import.meta.url), + 'utf8' + ) + const committed = {} + for (const cellId of Object.keys(productionCells())) { + const start = tfvars.indexOf(` "${cellId}" = {`) + assert.notEqual(start, -1, `${cellId} is not committed`) + const body = tfvars.slice(start, tfvars.indexOf('\n }', start)) + const cell = {} + for (const [, key, raw] of body.matchAll(/^\s+([a-z_]+)\s+=\s+("[^"]*"|\S+)/gm)) { + cell[key] = raw.startsWith('"') ? raw.slice(1, -1) + : raw === 'true' || raw === 'false' ? raw === 'true' : Number(raw) + } + committed[cellId] = cell + } + // Each wave is pinned on its own: C30 launches on the director's digest, not C27's. + for (const wave of [ + 'production-gce-c27,production-gce-c28,production-gce-c29', + 'production-gce-c30' + ]) { + const committedImage = committed[wave.split(',')[0]].image + assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ + existingCells: committed, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: wave, image: committedImage + }), wave) + } + assert.throws(() => prepareRelayAsiaTopologyInput({ + existingCells: committed, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: 'production-gce-c30', + image + }), /differs from the reviewed topology/) +}) + test('accepts the one exact committed staging Asia cell', () => { const stagingImage = image.replace('onorca-cloud/', 'onorca-cloud-staging/') const stagingCell = { @@ -59,10 +104,24 @@ test('accepts the one exact committed staging Asia cell', () => { }) test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drift', () => { + for (const cellIds of [ + 'production-gce-c27', + 'production-gce-c27,production-gce-c30', + 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', + 'production-gce-c30,production-gce-c30', + 'production-gce-c31' + ]) { + assert.throws(() => prepareRelayAsiaTopologyInput({ + existingCells: productionCells(), existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds, image + }), /cell IDs/, cellIds) + } + const poolDrift = productionCells() + poolDrift['production-gce-c30'].database_pool_max = 10 assert.throws(() => prepareRelayAsiaTopologyInput({ - existingCells: productionCells(), existingAdditionalRegions: additionalRegions, - environment: 'production', cellIds: 'production-gce-c27', image - }), /cell IDs/) + existingCells: poolDrift, existingAdditionalRegions: additionalRegions, + environment: 'production', cellIds: 'production-gce-c30', image + }), /differs from the reviewed topology/) assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, environment: 'production', diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs index 9787dbdd62ac..5c3452e542cd 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs @@ -136,12 +136,18 @@ export async function prepareProductionCapacityCell(config, overrides = {}) { const desiredState = config.mode === 'isolate' ? 'migration-only' : 'general' const membership = membershipWithStates(before.selector, { [config.cellId]: desiredState }) const result = await applyExactAdmissionSelector(post, membership, { - expectedCurrentSelector: before.selector + expectedCurrentSelector: before.selector, + // The stamp that tells the director this cell is parked for a restart rather + // than held back as capacity, so it may re-place the hosts still on it. The + // activate branch omits it, and moving to 'general' clears it in the same + // statement that writes the state. + ...(config.mode === 'isolate' ? { rollIsolatedCells: [config.cellId] } : {}) }) return { changed: result.changed, generation: result.selector.generation, - admissionState: desiredState + admissionState: desiredState, + rollIsolated: config.mode === 'isolate' } } diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index e721bfb8c75b..5588bcd27fcf 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -115,7 +115,7 @@ describe('production Relay capacity cell admission', () => { it('admits the same-cap Asia and migration-only cells only under the same-cap allowlist', () => { for (const cellId of [ - 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18' ]) { @@ -134,7 +134,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c30']) { + for (const cellId of ['production-gce-c12', 'production-gce-c31']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', @@ -168,6 +168,36 @@ describe('production Relay capacity cell admission', () => { assert.doesNotMatch(fake.calls.map(({ path }) => path).join(','), /\/v1\/admin\/drain/) }) + it('stamps the roll isolation on isolate and never on activate', async () => { + // The stamp is what lets the director tell a cell parked for a restart from + // an evacuation target or an Asia rollback, both of which must keep their + // hosts. Only this call site may send it. + const isolate = canaryFetch() + await prepareProductionCapacityCell( + { ...config, mode: 'isolate' }, + { fetch: isolate.fetch, token: 'token' } + ) + const isolateApply = isolate.calls.find( + ({ path }) => path === '/v1/admin/admission-selector/apply' + ) + assert.deepEqual(isolateApply.body.rollIsolatedCells, [config.cellId]) + + // Restore has to be a real apply, not the no-op an already-general cell + // takes, or the assertion below proves nothing. + isolate.calls.length = 0 + const restored = await prepareProductionCapacityCell( + { ...config, mode: 'activate' }, + { fetch: isolate.fetch, token: 'token' } + ) + assert.equal(restored.admissionState, 'general') + const restoreApply = isolate.calls.find( + ({ path }) => path === '/v1/admin/admission-selector/apply' + ) + assert.ok(restoreApply, 'restore must issue an apply') + assert.equal(restoreApply.body.rollIsolatedCells, undefined) + assert.ok(restoreApply.body.membership.general.includes(config.cellId)) + }) + it('drains the selected cell independently after durable isolation', async () => { const fake = canaryFetch() const result = await prepareProductionCapacityCell( diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 73ea44a40f77..7fef67cd2373 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -1,9 +1,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' -// Every general cell that carries the rehome identity: the sixteen US cells and the -// three asia-east2 cells that drain mis-homed hosts back the other way. -const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29)$/ +// Every cell that carries the rehome identity: the sixteen US cells and the +// four asia-east2 cells that drain mis-homed hosts back the other way. +const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 034fcd06c4fb..f7c9696aeeba 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -113,14 +113,16 @@ test('fails when both trust-probe attempts return a transient 503', async () => }) test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => { - for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) { + for (const cellId of [ + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30' + ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), environment ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c30']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c31']) { assert.throws( () => parseRehomeTrustProbeArguments( diff --git a/cloud/dev/scripts/relay-admission-selector.mjs b/cloud/dev/scripts/relay-admission-selector.mjs index f41528c7e7fc..9b993fdc9aea 100644 --- a/cloud/dev/scripts/relay-admission-selector.mjs +++ b/cloud/dev/scripts/relay-admission-selector.mjs @@ -162,6 +162,9 @@ export async function applyExactAdmissionSelector(post, membership, options = {} ...(before.selector.generation === 0 ? { expectedMembershipSha256: membershipSha256(before.selector.membership) } : {}), + // Only a same-cap roll's isolate passes this; every other caller omits it + // and leaves the cell's hosts pinned, which is the pre-existing behaviour. + ...(options.rollIsolatedCells ? { rollIsolatedCells: options.rollIsolatedCells } : {}), membership: desired }) } catch (error) { diff --git a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs index 21d712601d02..8283d43ca44e 100644 --- a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs @@ -90,7 +90,7 @@ test('uploads one sanitized machine-readable admission result', () => { assert.match(upload, /retention-days: 7/) assert.ok( workflow.indexOf('Upload sanitized admission result') > - workflow.indexOf('Upload immutable C27 canary evidence') + workflow.indexOf('Upload immutable canary evidence') ) }) @@ -109,7 +109,7 @@ test('binds selector operations and director configuration to reviewed implement assert.doesNotMatch(workflow, /dns/i) }) -test('requires immutable staged evidence and a timed C27 canary before expansion', () => { +test('requires immutable staged evidence and a timed production canary before expansion', () => { assert.match(workflow, /actions: read/) assert.match(workflow, /actions\/download-artifact@v4/) assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/) @@ -122,25 +122,26 @@ test('requires immutable staged evidence and a timed C27 canary before expansion assert.match(workflow, /--duration-seconds 300/) assert.match(workflow, /--required-lease-horizons 2/) assert.match(workflow, /pnpm\/action-setup@v4/) - assert.match(workflow, /Install exact C27 canary dependencies/) + assert.match(workflow, /Install exact canary dependencies/) assert.match(workflow, /pnpm install --frozen-lockfile/) assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/) assert.ok( - workflow.indexOf('Build the C27 canary Relay contract') < - workflow.indexOf('Run a real five-minute C27 control and splice canary') + workflow.indexOf('Build the canary Relay contract') < + workflow.indexOf('Run a real five-minute canary control and splice') ) - assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-c27-load\.json"/) - assert.match(workflow, /states\["production-gce-c28"\].*= migration-only/) - assert.match(workflow, /states\["production-gce-c29"\].*= migration-only/) - assert.match(workflow, /relay-asia-c27-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(workflow, /id: c27-evidence-upload/) - assert.match(workflow, /Return an unproven C27 canary to migration-only/) - assert.match(workflow, /steps\.c27-evidence-upload\.outcome != 'success'/) + assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/) + assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/) + // C28/C29 promotion downloads C27's canary under exactly this name. + assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) + assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/) + assert.match(workflow, /id: canary-evidence-upload/) + assert.match(workflow, /Return an unproven canary cell to migration-only/) + assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/) assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/) assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/) assert.match(workflow, /evidence_kind=c27/) assert.match(workflow, /orca_relay_runtime_metrics/) - assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-c27/) + assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/) assert.match(workflow, /retention-days: 7/) assert.match(workflow, /Require the exact director image before promotion/) assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) @@ -153,6 +154,57 @@ test('requires immutable staged evidence and a timed C27 canary before expansion assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/) }) +test('binds each production promotion wave to its exact evidence and canary', () => { + const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1] + assert.ok(cases) + const waves = Object.fromEntries( + [...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], { + evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none', + canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none' + }]) + ) + assert.deepEqual(waves, { + 'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' }, + 'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' }, + 'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' } + }) + assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/) + assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/) + assert.doesNotMatch(workflow, /inputs\.cell-ids == /) +}) + +test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => { + const steps = workflow.split(/\n(?= - )/) + const named = (name) => steps.find((step) => step.includes(`name: ${name}`)) + for (const name of [ + 'Install exact canary dependencies', + 'Build the canary Relay contract', + 'Verify the canary cell state and start the timed canary', + 'Run a real five-minute canary control and splice', + 'Collect regional, Relay SQL, and Cloud SQL canary evidence', + 'Upload immutable canary evidence' + ]) { + assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name) + } + assert.match( + workflow, + /if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/ + ) + const rollback = named('Return an unproven canary cell to migration-only') + assert.match( + rollback, + /if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/ + ) + assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/) + assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) + assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) + assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/) + const start = named('Verify the canary cell state and start the timed canary') + assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/) + assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/) + assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/) +}) + test('creates staging evidence only after the bounded launch-path load and rollback', () => { assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/) assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/) diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs index e833a0ae16b4..cf7d53c52bc7 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs @@ -8,12 +8,17 @@ const ADMISSION_WORKFLOW = relayWorkflowPath('operate-relay-asia-admission.yml') const STAGING_WORKFLOW = relayWorkflowPath('prove-relay-asia-staging.yml') const STAGING_CELL = 'staging-gce-c4' const C27 = 'production-gce-c27' +// Each canary proves its own cell under production load; C28/C29 promotion consumes only C27's. +const PRODUCTION_CANARIES = { + [C27]: { kind: 'production-c27-canary', origin: 'https://c27.relay.onorca.dev' }, + 'production-gce-c30': { kind: 'production-c30-canary', origin: 'https://c30.relay.onorca.dev' } +} const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ const SHA_PATTERN = /^[a-f0-9]{40}$/ const MAX_LOG_EDGE_GAP_MS = 120_000 const MAX_LOG_SAMPLE_GAP_MS = 120_000 const CLOUD_SQL_LIMIT = 320 -const C27_CANARY_MINIMUM_MS = 5 * 60_000 +const CANARY_MINIMUM_MS = 5 * 60_000 const GENERATOR_CPU_PERCENT_LIMIT = 80 const GENERATOR_EVENT_LOOP_P99_MS_LIMIT = 100 const GENERATOR_RSS_GROWTH_MIB_LIMIT = 512 @@ -280,28 +285,37 @@ function cloudSqlMaximum(response, start, end) { return Math.max(...values) } -export function buildC27CanaryEvidence(input) { +function productionCanary(cellId) { + const canary = PRODUCTION_CANARIES[cellId] + if (!canary) throw new Error('canary cell is not a reviewed production Asia canary') + return { ...canary, label: cellId.split('-').at(-1).toUpperCase() } +} + +export function buildProductionCanaryEvidence(input) { + const canary = productionCanary(input.cellId) const start = instant(input.startedAt, 'canary start') const end = instant(input.endedAt, 'canary end') - if (end.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) { - throw new Error('C27 canary window is shorter than 5 minutes') - } - const load = object(input.loadReport, 'C27 load report') - assertC27CanaryLoad(load) - const metrics = runtimeMetrics(input.logs, start, end, C27) - assertPassingRuntimeMetrics(metrics, 'C27 canary') + if (end.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) { + throw new Error(`${canary.label} canary window is shorter than 5 minutes`) + } + const load = object(input.loadReport, `${canary.label} load report`) + assertCanaryLoad(load, input.cellId) + // Directors show a steady relay_cells lock and pool-wait baseline unrelated to the canary cell. + const metrics = runtimeMetrics(input.logs, start, end, input.cellId, { gateDirectorDatabase: false }) + assertPassingRuntimeMetrics(metrics, `${canary.label} canary`) metrics.cloudSqlBackendsMax = cloudSqlMaximum(input.cloudSql, start, end) - assertPassingCanary(metrics) + assertPassingCanary(metrics, input.cellId) return { - ...baseEvidence(input, 'production', [C27]), - kind: 'production-c27-canary', + ...baseEvidence(input, 'production', [input.cellId]), + kind: canary.kind, window: { startedAt: start.toISOString(), endedAt: end.toISOString() }, load, metrics } } -function assertC27CanaryLoad(report) { +function assertCanaryLoad(report, cellId) { + const { label, origin } = productionCanary(cellId) if ( report.event !== 'relay_load_complete' || report.controls !== 1 || report.shardCount !== 1 || report.shardIndex !== 0 || @@ -312,21 +326,25 @@ function assertC27CanaryLoad(report) { report.peakActive !== 1 || report.steadyMinimumActive !== 1 || report.configuredSplices !== 1 || report.peakActiveSplices !== 1 || report.completedSplices !== 1 || report.failedSplices !== 0 - ) throw new Error('C27 control and splice canary did not match') + ) throw new Error(`${label} control and splice canary did not match`) + // Placement picks the least-loaded general Asia cell, so the canary's own control must be on it. + if (JSON.stringify(report.assignedCellOrigins) !== JSON.stringify([origin])) { + throw new Error(`${label} canary load was not placed only on ${label}`) + } for (const key of [ 'connectionFailures', 'unexpectedCloses', 'protocolErrors', 'refreshErrors', 'socketErrors' ]) { - if (number(report[key], key) !== 0) throw new Error(`C27 canary ${key} must be zero`) + if (number(report[key], key) !== 0) throw new Error(`${label} canary ${key} must be zero`) } - const shutdown = object(report.shutdownEvidence, 'C27 load shutdown evidence') + const shutdown = object(report.shutdownEvidence, `${label} load shutdown evidence`) if ( shutdown.peerShutdowns !== 1 || shutdown.activeControls !== 0 || shutdown.activeSplices !== 0 || shutdown.reconnectTimers !== 0 - ) throw new Error('C27 load cleanup is incomplete') + ) throw new Error(`${label} load cleanup is incomplete`) } -function runtimeMetrics(logs, start, end, targetCellId) { +function runtimeMetrics(logs, start, end, targetCellId, { gateDirectorDatabase = true } = {}) { const entries = logs.map((entry) => object(entry, 'runtime metric entry')) const directorEntries = entries.filter((entry) => entry.jsonPayload?.role === 'director') const cellEntries = entries.filter((entry) => @@ -345,8 +363,7 @@ function runtimeMetrics(logs, start, end, targetCellId) { } const directorPayloads = directorEntries.map((entry) => entry.jsonPayload) const cellPayloads = cellEntries.map((entry) => entry.jsonPayload) - const payloads = [...directorPayloads, ...cellPayloads] - return { + const placement = { asiaSelections: directorPayloads.reduce( (total, payload) => total + number(payload.selectedRegionsDelta?.['asia-east2'] ?? 0, 'Asia selections'), 0 ), @@ -361,19 +378,43 @@ function runtimeMetrics(logs, start, end, targetCellId) { ), unavailableRegions: directorPayloads.reduce( (total, payload) => total + sumMap(payload.unavailableRegionsDelta, 'unavailable regions'), 0 - ), - relaySqlFailures: payloads.reduce( - (total, payload) => total + number(payload.sqlFailuresDelta, 'Relay SQL failures'), 0 - ), - databasePoolWaitingMax: Math.max(...payloads.map( - (payload) => number(payload.databasePoolWaiting, 'database pool waiting') - )), - databasePoolWaitersMax: Math.max(...payloads.map( - (payload) => number(payload.databasePoolWaitersMax, 'database pool waiters') - )), - databasePoolWaitMsMax: Math.max(...payloads.map( - (payload) => number(payload.databasePoolWaitMsMax, 'database pool wait time') - )), + ) + } + // Director before cell per metric keeps the old validation order. + const split = (read) => ({ director: read(directorPayloads), cell: read(cellPayloads) }) + const sqlFailures = split((list) => list.reduce( + (total, payload) => total + number(payload.sqlFailuresDelta, 'Relay SQL failures'), 0 + )) + const waitingMax = split((list) => Math.max(...list.map( + (payload) => number(payload.databasePoolWaiting, 'database pool waiting') + ))) + const waitersMax = split((list) => Math.max(...list.map( + (payload) => number(payload.databasePoolWaitersMax, 'database pool waiters') + ))) + const waitMsMax = split((list) => Math.max(...list.map( + (payload) => number(payload.databasePoolWaitMsMax, 'database pool wait time') + ))) + // Ungated director values stay reported so the artifact still shows them. + const gated = gateDirectorDatabase + ? { + relaySqlFailures: sqlFailures.director + sqlFailures.cell, + databasePoolWaitingMax: Math.max(waitingMax.director, waitingMax.cell), + databasePoolWaitersMax: Math.max(waitersMax.director, waitersMax.cell), + databasePoolWaitMsMax: Math.max(waitMsMax.director, waitMsMax.cell) + } + : { + relaySqlFailures: sqlFailures.cell, + directorSqlFailures: sqlFailures.director, + databasePoolWaitingMax: waitingMax.cell, + databasePoolWaitersMax: waitersMax.cell, + databasePoolWaitMsMax: waitMsMax.cell, + directorDatabasePoolWaitingMax: waitingMax.director, + directorDatabasePoolWaitersMax: waitersMax.director, + directorDatabasePoolWaitMsMax: waitMsMax.director + } + return { + ...placement, + ...gated, targetControlsMax: Math.max(...cellPayloads.map( (payload) => number(payload.controls, 'target controls') )), @@ -406,11 +447,12 @@ function assertPassingRuntimeMetrics(metrics, label, expectedRegionFallbacks = 0 ) throw new Error(`${label} transient database pool pressure exceeded its bound`) } -function assertPassingCanary(metrics) { +function assertPassingCanary(metrics, cellId) { + const { label } = productionCanary(cellId) if ( - number(metrics.targetControlsMax, 'C27 controls') < 1 || - number(metrics.targetSplicesMax, 'C27 splices') < 1 - ) throw new Error('C27 canary traffic did not reach C27') + number(metrics.targetControlsMax, `${label} controls`) < 1 || + number(metrics.targetSplicesMax, `${label} splices`) < 1 + ) throw new Error(`${label} canary traffic did not reach ${label}`) if (number(metrics.cloudSqlBackendsMax, 'Cloud SQL backends') >= CLOUD_SQL_LIMIT) { throw new Error(`Cloud SQL backends must remain below ${CLOUD_SQL_LIMIT}`) } @@ -457,13 +499,16 @@ export function verifyRolloutEvidence(evidence, run, expected) { if (proofTime > now || now.valueOf() - proofTime.valueOf() > maxAgeMs) { throw new Error('rollout evidence is stale') } - if (expected.kind === 'production-c27-canary') { + const canaryCell = Object.keys(PRODUCTION_CANARIES) + .find((cellId) => PRODUCTION_CANARIES[cellId].kind === expected.kind) + if (canaryCell) { + if (!exactCells(expected.cellIds, [canaryCell])) throw new Error('evidence topology does not match') const start = instant(evidence.window?.startedAt, 'canary start') - if (proofTime.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) { - throw new Error('C27 canary window is shorter than 5 minutes') + if (proofTime.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) { + throw new Error(`${productionCanary(canaryCell).label} canary window is shorter than 5 minutes`) } - assertC27CanaryLoad(object(evidence.load, 'canary load')) - assertPassingCanary(object(evidence.metrics, 'canary metrics')) + assertCanaryLoad(object(evidence.load, 'canary load'), canaryCell) + assertPassingCanary(object(evidence.metrics, 'canary metrics'), canaryCell) } return evidence } @@ -511,11 +556,11 @@ async function main(argv) { }), null, 2)}\n`) return } - if (command === 'create-c27') { + if (command === 'create-canary') { const startedAt = required(values, 'started-at') const endedAt = required(values, 'ended-at') - writeFileSync(output, `${JSON.stringify(buildC27CanaryEvidence({ - ...commonInput(values), startedAt, endedAt, + writeFileSync(output, `${JSON.stringify(buildProductionCanaryEvidence({ + ...commonInput(values), cellId: required(values, 'cell-id'), startedAt, endedAt, loadReport: JSON.parse(readFileSync(required(values, 'load-report'), 'utf8')), logs: JSON.parse(readFileSync(required(values, 'logs-json'), 'utf8')), cloudSql: await readCloudSqlBackends('production', startedAt, endedAt) diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs index 5d7b316605c5..1da4c9628798 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { RELAY_GITHUB_REPOSITORY, relayWorkflowPath } from './relay-repository.mjs' import { - buildC27CanaryEvidence, + buildProductionCanaryEvidence, buildStagingEvidence, verifyRolloutEvidence } from './relay-asia-rollout-evidence.mjs' @@ -115,18 +115,19 @@ function stagingInput(overrides = {}) { } } -function canaryInput(overrides = {}) { +function canaryInput(overrides = {}, cellId = 'production-gce-c27') { const load = loadReport({ controls: 1, shardIndex: 0, splices: 1 }) Object.assign(load, { shardCount: 1, configuredSteadySeconds: 300, configuredSpliceHoldSeconds: 60, - relayAsiaLoadPrincipalCount: 1 + relayAsiaLoadPrincipalCount: 1, + assignedCellOrigins: [`https://${cellId.split('-').at(-1)}.relay.onorca.dev`] }) return { - ...sourceInput(), startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(), + ...sourceInput(), cellId, startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(), loadReport: load, - logs: completeLogs('production-gce-c27', canaryEnd), cloudSql: cloudSql(319, canaryEnd), + logs: completeLogs(cellId, canaryEnd), cloudSql: cloudSql(319, canaryEnd), ...overrides } } @@ -295,7 +296,7 @@ test('rejects mismatched staging provenance, digest, topology, or age', () => { }) test('builds and verifies a passing continuous 5-minute C27 canary', () => { - const evidence = buildC27CanaryEvidence(canaryInput()) + const evidence = buildProductionCanaryEvidence(canaryInput()) assert.equal(evidence.load.completedSplices, 1) assert.equal(evidence.metrics.asiaSelections, 6) assert.equal(evidence.metrics.cloudSqlBackendsMax, 319) @@ -306,14 +307,14 @@ test('builds and verifies a passing continuous 5-minute C27 canary', () => { }) test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => { - assert.throws(() => buildC27CanaryEvidence(canaryInput({ + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ endedAt: new Date(canaryEnd.valueOf() - 1).toISOString() })), /shorter than 5 minutes/) const sparse = completeLogs('production-gce-c27', canaryEnd).filter((entry) => entry.timestamp === start.toISOString() || entry.timestamp === canaryEnd.toISOString() ) - assert.throws(() => buildC27CanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/) - assert.throws(() => buildC27CanaryEvidence(canaryInput({ + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ logs: completeLogs('production-gce-c26', canaryEnd) })), /production-gce-c27 metrics has no samples/) }) @@ -321,20 +322,20 @@ test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => { test('rejects a C27 canary without a real control and splice', () => { const input = canaryInput() input.loadReport.completedSplices = 0 - assert.throws(() => buildC27CanaryEvidence(input), /did not match/) + assert.throws(() => buildProductionCanaryEvidence(input), /did not match/) const shortHold = canaryInput() shortHold.loadReport.configuredSpliceHoldSeconds = 59 - assert.throws(() => buildC27CanaryEvidence(shortHold), /did not match/) + assert.throws(() => buildProductionCanaryEvidence(shortHold), /did not match/) }) for (const [label, mutation, message] of [ ['Asia selections', (input) => input.logs.forEach((entry) => { entry.jsonPayload.selectedRegionsDelta = {} }), /no Asia selections/], ['region fallbacks', (input) => { input.logs[0].jsonPayload.regionFallbacksDelta = { 'asia-east2': 1 } }, /regionFallbacks/], ['unavailable regions', (input) => { input.logs[0].jsonPayload.unavailableRegionsDelta = { 'asia-east2': 1 } }, /unavailableRegions/], - ['Relay SQL failures', (input) => { input.logs[0].jsonPayload.sqlFailuresDelta = 1 }, /relaySqlFailures/], - ['pool waiting', (input) => { input.logs[0].jsonPayload.databasePoolWaiting = 1 }, /databasePoolWaitingMax/], - ['pool waiters', (input) => { input.logs[0].jsonPayload.databasePoolWaitersMax = 5 }, /transient database pool pressure/], - ['pool wait time', (input) => { input.logs[0].jsonPayload.databasePoolWaitMsMax = 51 }, /transient database pool pressure/], + ['C27 SQL failures', (input) => { input.logs[1].jsonPayload.sqlFailuresDelta = 1 }, /C27 canary relaySqlFailures must be zero/], + ['C27 pool waiting', (input) => { input.logs[1].jsonPayload.databasePoolWaiting = 1 }, /databasePoolWaitingMax/], + ['C27 pool waiters', (input) => { input.logs[1].jsonPayload.databasePoolWaitersMax = 5 }, /transient database pool pressure/], + ['C27 pool wait time', (input) => { input.logs[1].jsonPayload.databasePoolWaitMsMax = 51 }, /transient database pool pressure/], ['C27 controls', (input) => input.logs.filter((entry) => entry.jsonPayload.role === 'cell') .forEach((entry) => { entry.jsonPayload.controls = 0 }), /did not reach C27/], ['C27 splices', (input) => input.logs.filter((entry) => entry.jsonPayload.role === 'cell') @@ -344,14 +345,138 @@ for (const [label, mutation, message] of [ test(`rejects C27 evidence with ${label}`, () => { const input = canaryInput() mutation(input) - assert.throws(() => buildC27CanaryEvidence(input), message) + assert.throws(() => buildProductionCanaryEvidence(input), message) }) } test('rejects C27 evidence from a different selector generation', () => { - const evidence = buildC27CanaryEvidence(canaryInput()) + const evidence = buildProductionCanaryEvidence(canaryInput()) assert.throws(() => verifyRolloutEvidence( evidence, workflowRun(evidence), verifyExpected('production-c27-canary', { selectorGeneration: 10 }) ), /selector generation/) }) + +test('rejects a C27 canary whose control was placed on another cell', () => { + for (const origins of [ + ['https://c28.relay.onorca.dev'], + ['https://c27.relay.onorca.dev', 'https://c28.relay.onorca.dev'], + [], + undefined + ]) { + const input = canaryInput() + input.loadReport.assignedCellOrigins = origins + assert.throws(() => buildProductionCanaryEvidence(input), /C27 canary load was not placed only on C27/) + } +}) + +const c30 = 'production-gce-c30' + +test('builds and verifies a C30 canary from C30 runtime metrics and placement', () => { + const evidence = buildProductionCanaryEvidence(canaryInput({}, c30)) + assert.equal(evidence.kind, 'production-c30-canary') + assert.deepEqual(evidence.topology, { cellIds: [c30], selectorGeneration: 9 }) + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 9 }) + ), evidence) + assert.throws(() => verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 10 }) + ), /selector generation/) + assert.throws(() => verifyRolloutEvidence( + evidence, workflowRun(evidence), verifyExpected('production-c27-canary', { selectorGeneration: 9 }) + ), /evidence kind is invalid/) +}) + +test('rejects a C30 canary that C30 did not serve', () => { + const onLaunchCell = canaryInput({}, c30) + onLaunchCell.loadReport.assignedCellOrigins = ['https://c27.relay.onorca.dev'] + assert.throws(() => buildProductionCanaryEvidence(onLaunchCell), /C30 canary load was not placed only on C30/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ + logs: completeLogs('production-gce-c27', canaryEnd) + }, c30)), /production-gce-c30 metrics has no samples/) + const idle = canaryInput({}, c30) + idle.logs.filter((entry) => entry.jsonPayload.role === 'cell') + .forEach((entry) => { entry.jsonPayload.splices = 0 }) + assert.throws(() => buildProductionCanaryEvidence(idle), /C30 canary traffic did not reach C30/) + const poolPressure = canaryInput({}, c30) + poolPressure.logs.at(-1).jsonPayload.databasePoolWaiting = 1 + assert.throws(() => buildProductionCanaryEvidence(poolPressure), /C30 canary databasePoolWaitingMax/) + assert.throws(() => buildProductionCanaryEvidence(canaryInput({ + endedAt: new Date(canaryEnd.valueOf() - 1).toISOString() + }, c30)), /C30 canary window is shorter than 5 minutes/) +}) + +test('gates a C30 canary on C30 pool pressure and only reports the director baseline', () => { + const directorPressure = canaryInput({}, c30) + const directorLogs = directorPressure.logs.filter((entry) => entry.jsonPayload.role === 'director') + Object.assign(directorLogs[0].jsonPayload, { databasePoolWaiting: 3 }) + Object.assign(directorLogs[1].jsonPayload, { databasePoolWaitersMax: 9, databasePoolWaitMsMax: 240 }) + const evidence = buildProductionCanaryEvidence(directorPressure) + assert.deepEqual([ + evidence.metrics.databasePoolWaitingMax, + evidence.metrics.databasePoolWaitersMax, + evidence.metrics.databasePoolWaitMsMax + ], [0, 0, 0]) + assert.deepEqual([ + evidence.metrics.directorDatabasePoolWaitingMax, + evidence.metrics.directorDatabasePoolWaitersMax, + evidence.metrics.directorDatabasePoolWaitMsMax + ], [3, 9, 240]) + + for (const [field, value, message] of [ + ['databasePoolWaiting', 1, /C30 canary databasePoolWaitingMax must be zero/], + ['databasePoolWaitersMax', 5, /C30 canary transient database pool pressure/], + ['databasePoolWaitMsMax', 51, /C30 canary transient database pool pressure/] + ]) { + const cellPressure = canaryInput({}, c30) + cellPressure.logs.find((entry) => entry.jsonPayload.role === 'cell').jsonPayload[field] = value + assert.throws(() => buildProductionCanaryEvidence(cellPressure), message) + } +}) + +test('gates a C30 canary on C30 SQL failures and only reports the director baseline', () => { + const directorNoise = canaryInput({}, c30) + directorNoise.logs.filter((entry) => entry.jsonPayload.role === 'director') + .slice(0, 3).forEach((entry) => { entry.jsonPayload.sqlFailuresDelta = 2 }) + const evidence = buildProductionCanaryEvidence(directorNoise) + assert.equal(evidence.metrics.relaySqlFailures, 0) + assert.equal(evidence.metrics.directorSqlFailures, 6) + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 9 }) + ), evidence) + + const cellFailure = canaryInput({}, c30) + cellFailure.logs.find((entry) => entry.jsonPayload.role === 'cell').jsonPayload.sqlFailuresDelta = 1 + assert.throws(() => buildProductionCanaryEvidence(cellFailure), /C30 canary relaySqlFailures must be zero/) +}) + +test('keeps staging gated on director database metrics without director-only fields', () => { + const metrics = buildStagingEvidence(stagingInput()).metrics + assert.equal(metrics.relaySqlFailures, 0) + assert.equal(Object.keys(metrics).some((key) => key.startsWith('director')), false) + for (const [field, value, message] of [ + ['databasePoolWaiting', 1, /staging proof databasePoolWaitingMax must be zero/], + ['databasePoolWaitersMax', 5, /staging proof transient database pool pressure/], + ['databasePoolWaitMsMax', 51, /staging proof transient database pool pressure/] + ]) { + const directorPressure = stagingInput() + directorPressure.logs[0].jsonPayload[field] = value + assert.throws(() => buildStagingEvidence(directorPressure), message) + } + + const directorFailure = stagingInput() + directorFailure.logs[0].jsonPayload.sqlFailuresDelta = 1 + assert.throws(() => buildStagingEvidence(directorFailure), /staging proof relaySqlFailures must be zero/) +}) + +test('accepts canaries only for the reviewed production Asia cells', () => { + for (const cellId of ['production-gce-c28', 'production-gce-c29', 'staging-gce-c4', undefined]) { + assert.throws( + () => buildProductionCanaryEvidence({ ...canaryInput(), cellId }), + /not a reviewed production Asia canary/ + ) + } +}) diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index 1927d9016ef4..d8965f028691 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -35,6 +35,20 @@ test('uses only its exact workflow-bound topology identity', () => { assert.match(iam, /assertion\.environment == '\$\{var\.environment\}'/) }) +test('accepts only the reviewed Asia topology waves', () => { + const cases = /case "\$\{TARGET_ENVIRONMENT\}:\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/ + .exec(workflow)?.[1] + assert.ok(cases) + assert.deepEqual( + [...cases.matchAll(/^\s*([a-z]+:[a-z0-9,-]+)\) ;;$/gm)].map((match) => match[1]), + [ + 'staging:staging-gce-c4', + 'production:production-gce-c27,production-gce-c28,production-gce-c29', + 'production:production-gce-c30' + ] + ) +}) + test('plans only additive Asia topology and applies the saved plan', () => { assert.doesNotMatch(workflow, /manage_artifact_dns/) for (const target of [ @@ -52,8 +66,13 @@ test('plans only additive Asia topology and applies the saved plan', () => { workflow, /\.variables\.relay_gce_additional_region_subnetwork_cidrs\.value/ ) - assert.doesNotMatch(workflow, /terraform -chdir=infra\/terraform console/) - assert.equal((workflow.match(/-var-file="\$\{TF_VARS\}"/g) ?? []).length, 2) + // Console evaluates every output against state and fails while a declared cell has no MIG. + assert.doesNotMatch(workflow, /terraform[^\n]*console/) + assert.equal( + (workflow.match(/-var-file="\$\{TF_VARS\}" -var-file="\$\{\{ steps\.live-images\.outputs\.file \}\}"/g) ?? []).length, + 2 + ) + assert.equal((workflow.match(/-var-file=/g) ?? []).length, 5) assert.doesNotMatch(workflow, /terraform[^\n]*apply[^\n]*-target/) assert.doesNotMatch(workflow, /google_(?:sql|cloudflare|dns|certificate_manager)/) }) @@ -71,6 +90,9 @@ test('checks the connection budget and production live ceiling before planning', assert.match(workflow, /select\(\.name == "max_connections"\)/) assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360/) assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17/) + assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS: '500'/) + assert.match(workflow, /live_max="\$\{VERIFIED_DEFAULT_MAX_CONNECTIONS\}"/) + assert.doesNotMatch(workflow, /live_max=\d/) assert.match(workflow, /live_source=verified-shape-default/) assert.match(workflow, /test "\$\(jq -er '\.settings\.tier'/) assert.match(workflow, /test "\$\(jq -er '\.databaseVersion'/) @@ -122,3 +144,34 @@ test('the custom role cannot delete topology or mutate SQL and DNS', () => { /resource "google_storage_bucket_iam_member" "github_relay_asia_topology_state_list"[\s\S]*?role\s+= google_project_iam_custom_role\.github_relay_asia_topology_state_list\[0\]\.id/ ) }) + +test('plans every non-target cell at its served image, read from state templates only', () => { + const step = /- id: live-images\n[\s\S]*?\n\n/.exec(workflow)?.[0] + assert.ok(step) + assert.match(step, /terraform -chdir=infra\/terraform show -json \| jq -ce '\[/) + assert.match(step, /\.type == "google_compute_instance_template" and \.name == "relay_gce_cell"/) + assert.match(step, /\{ index, metadata_startup_script: \.values\.metadata_startup_script \}/) + assert.match(step, /relay-live-cell-image-overlay\.mjs/) + assert.match(step, /--cell-ids "\$\{TARGET_CELL_IDS\}"/) + // The committed map comes from a read-only plan over the same targets, never from console. + assert.match(step, /mapfile -t targets < "\$\{\{ steps\.targets\.outputs\.file \}\}"/) + assert.match( + step, + /terraform -chdir=infra\/terraform plan -input=false -refresh=false -lock=false \\\n\s+-var-file="\$\{TF_VARS\}" "\$\{targets\[@\]\}" -out="\$\{committed_plan\}" > \/dev\/null/ + ) + assert.match(step, /show -json "\$\{committed_plan\}" \\\n\s+\| jq -ce '\.variables\.relay_gce_cells\.value \| objects' > "\$\{cells\}"/) + assert.equal((step.match(/terraform -chdir=infra\/terraform (?:plan|apply)/g) ?? []).length, 1) + assert.ok(workflow.indexOf('- id: targets') < workflow.indexOf('- id: live-images')) + assert.ok(workflow.indexOf('- id: live-images') < workflow.indexOf('- name: Create and validate the saved topology plan')) +}) + +test('the deployments output tolerates a cell declared before its topology apply', () => { + const outputs = readFileSync(new URL('../../infra/terraform/outputs.tf', import.meta.url), 'utf8') + const start = outputs.indexOf('output "relay_gce_cell_deployments" {') + const block = outputs.slice(start, outputs.indexOf('\n}\n', start)) + const lookups = [...block.matchAll(/^\s+\w+\s+=\s+(.*\.relay_gce_cell\[cell_id\].*)$/gm)].map((match) => match[1]) + assert.equal(lookups.length, 6) + for (const lookup of lookups) { + assert.match(lookup, /^try\(google_compute_\w+\.relay_gce_cell\[cell_id\]\.\w+, null\)$/) + } +}) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index f1dc962cb181..333969e5684d 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 20 pools at 10 (200) + the three asia-east2 pools at 16 (48). + // cells: 20 pools at 10 (200) + the four asia-east2 pools at 16 (64). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 248, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 3, poolMax: 16 }) - assert.equal(report.configuredMaximum, 333) + assert.deepEqual(report.consumers, { cells: 264, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 4, poolMax: 16 }) + assert.equal(report.configuredMaximum, 349) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 403) - assert.equal(report.remainingWithinUsableCeiling, 87) - assert.equal(report.budgetedTotal, 413) - assert.equal(report.unallocated, 87) + assert.equal(report.operatingMaximum, 419) + assert.equal(report.remainingWithinUsableCeiling, 71) + assert.equal(report.budgetedTotal, 429) + assert.equal(report.unallocated, 71) assert.equal(report.withinBudget, true) }) @@ -81,6 +81,33 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => { assert.equal(report.budgetedTotal, 47) }) +test('refuses an Asia cell whose pool differs from its siblings', () => { + const budget = (c30PoolMax) => readRelayCloudSqlConnectionBudget({ + appConsumers: { authInstances: 1, authPoolMax: 10, apiInstances: 1, apiPoolMax: 5, maxConnections: 500 }, + sources: { + productionTfvars: ` + relay_max_instances = 1 + relay_gce_fenced_cells = [] + relay_gce_cells = { +${['c27', 'c28', 'c29', 'c30'].map((hostname) => ` "production-gce-${hostname}" = { + region = "asia-east2" + database_pool_max = ${hostname === 'c30' ? c30PoolMax : 16} + }`).join('\n')} + } + `, + terraformVariables: [ + 'variable "relay_director_database_pool_max" { default = 3 }', + 'variable "push_max_instances" { default = 1 }', + 'variable "push_database_pool_max" { default = 2 }' + ].join('\n'), + relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10' + }, + maxConnections: 500 + }) + assert.deepEqual(budget(16).asia, { cells: 4, poolMax: 16 }) + assert.throws(() => budget(10), /Asia Relay cells must use one checked pool maximum/) +}) + test('dedicated push scaling does not consume shared capacity', () => { const report = readRelayCloudSqlConnectionBudget({ proposedAsiaCellCount: 1, diff --git a/cloud/dev/scripts/relay-live-cell-image-overlay.mjs b/cloud/dev/scripts/relay-live-cell-image-overlay.mjs new file mode 100644 index 000000000000..b4444e9cfe98 --- /dev/null +++ b/cloud/dev/scripts/relay-live-cell-image-overlay.mjs @@ -0,0 +1,84 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Committed images lag what same-cap rolls serve, and a URL map target pulls every cell's +// template into the plan, so a non-target cell must be planned at the image it serves. +const IMAGE_PATTERN = /^[a-z0-9.-]+\/[a-z0-9-]+\/[a-z0-9-]+\/relay@sha256:[a-f0-9]{64}$/ + +function liveRelayImage(script, committedImage, cellId) { + const repository = committedImage.split('@')[0] + const pulled = [...script.matchAll(/^docker pull '([^']+)'$/gm)] + .map((match) => match[1]) + .filter((image) => image.split('@')[0] === repository) + const digest = /^\s*printf 'ORCA_RELAY_IMAGE_DIGEST=%s\\n' '(sha256:[a-f0-9]{64})'$/m.exec(script)?.[1] + if (pulled.length !== 1 || !IMAGE_PATTERN.test(pulled[0]) || pulled[0].split('@')[1] !== digest) { + throw new Error(`${cellId} live template has no single pinned Relay image`) + } + return pulled[0] +} + +export function overlayRelayLiveCellImages({ committedCells, liveTemplates, targetCellIds }) { + if (!committedCells || Array.isArray(committedCells) || typeof committedCells !== 'object') { + throw new Error('committed Relay cells must be an object') + } + if (!Array.isArray(liveTemplates)) throw new Error('live templates must be an array') + const targets = new Set(targetCellIds) + for (const cellId of targets) { + if (!committedCells[cellId]) throw new Error(`${cellId} is not a committed Relay cell`) + } + const scripts = new Map() + for (const template of liveTemplates) { + if (typeof template?.index !== 'string' || typeof template.metadata_startup_script !== 'string') { + throw new Error('live template entry is malformed') + } + if (scripts.has(template.index)) throw new Error(`${template.index} has more than one live template`) + scripts.set(template.index, template.metadata_startup_script) + } + const cells = {} + for (const [cellId, cell] of Object.entries(committedCells)) { + if (targets.has(cellId)) { + cells[cellId] = cell + continue + } + const script = scripts.get(cellId) + // A declared cell with no live template would be created here, outside the reviewed wave. + if (script === undefined) throw new Error(`${cellId} is not a target and has no live template`) + cells[cellId] = { ...cell, image: liveRelayImage(script, cell.image, cellId) } + } + return { relay_gce_cells: cells } +} + +function argumentsFrom(argv) { + const values = {} + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value === undefined) throw new Error('invalid arguments') + values[key.slice(2)] = value + } + for (const key of ['cells-json', 'live-templates-json', 'cell-ids', 'output']) { + if (!values[key]) throw new Error(`missing --${key}`) + } + return values +} + +function readJsonFile(path, label) { + const text = readFileSync(path, 'utf8') + if (!text.trim()) throw new Error(`${label} is empty`) + return JSON.parse(text) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const values = argumentsFrom(process.argv.slice(2)) + const committedCells = readJsonFile(values['cells-json'], 'committed Relay cells') + const overlay = overlayRelayLiveCellImages({ + committedCells, + liveTemplates: readJsonFile(values['live-templates-json'], 'live Relay templates'), + targetCellIds: values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean) + }) + writeFileSync(values.output, `${JSON.stringify(overlay)}\n`) + const drifted = Object.keys(committedCells).filter( + (cellId) => overlay.relay_gce_cells[cellId].image !== committedCells[cellId].image + ) + console.log(JSON.stringify({ cells: Object.keys(committedCells).length, drifted })) +} diff --git a/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs b/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs new file mode 100644 index 000000000000..267cc9746395 --- /dev/null +++ b/cloud/dev/scripts/relay-live-cell-image-overlay.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { overlayRelayLiveCellImages } from './relay-live-cell-image-overlay.mjs' + +const repository = 'us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay' +const committed = `${repository}@sha256:${'a'.repeat(64)}` +const served = `${repository}@sha256:${'b'.repeat(64)}` +const template = readFileSync( + new URL('../../infra/terraform/relay-gce-startup.sh.tftpl', import.meta.url), + 'utf8' +) + +// Renders only the two lines the overlay reads, from the real template, so a template edit fails here. +function startupScript(image) { + return template + .replaceAll('${trimprefix(regex("@sha256:[a-f0-9]{64}$", relay_image), "@")}', image.split('@')[1]) + .replaceAll('${relay_image}', image) + .replaceAll('${cloud_sql_proxy_image}', `gcr.io/cloud-sql-connectors/cloud-sql-proxy@sha256:${'c'.repeat(64)}`) +} + +const cell = (image) => ({ hostname: 'x', region: 'asia-east2', zone: 'asia-east2-a', image, connection_hard_cap: 3000 }) +const committedCells = { + 'production-gce-c1': cell(committed), + 'production-gce-c27': cell(committed), + 'production-gce-c30': cell(committed) +} +const live = [ + { index: 'production-gce-c1', metadata_startup_script: startupScript(committed) }, + { index: 'production-gce-c27', metadata_startup_script: startupScript(served) } +] + +test('plans every non-target cell at its served image while the target has no template yet', () => { + const overlay = overlayRelayLiveCellImages({ + committedCells, liveTemplates: live, targetCellIds: ['production-gce-c30'] + }) + assert.deepEqual(overlay, { + relay_gce_cells: { + 'production-gce-c1': cell(committed), + 'production-gce-c27': cell(served), + 'production-gce-c30': cell(committed) + } + }) +}) + +test('leaves a target cell at its committed image even once it has a live template', () => { + const overlay = overlayRelayLiveCellImages({ + committedCells, + liveTemplates: [...live, { index: 'production-gce-c30', metadata_startup_script: startupScript(served) }], + targetCellIds: ['production-gce-c30'] + }) + assert.equal(overlay.relay_gce_cells['production-gce-c30'].image, committed) +}) + +test('refuses a non-target cell that has no live template', () => { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: live.slice(1), targetCellIds: ['production-gce-c30'] + }), /production-gce-c1 is not a target and has no live template/) +}) + +test('refuses a live template without one pinned Relay image', () => { + const digestMismatch = startupScript(served) + .replace(`%s\\n' 'sha256:${'b'.repeat(64)}'`, `%s\\n' 'sha256:${'d'.repeat(64)}'`) + for (const script of [ + digestMismatch, + startupScript(served).replace(`docker pull '${served}'`, ''), + `${startupScript(served)}\ndocker pull '${committed}'`, + startupScript(`${repository}:latest`) + ]) { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, + liveTemplates: [live[0], { index: 'production-gce-c27', metadata_startup_script: script }], + targetCellIds: ['production-gce-c30'] + }), /production-gce-c27 live template has no single pinned Relay image/) + } +}) + +test('refuses duplicate live templates and an undeclared target', () => { + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: [...live, live[1]], targetCellIds: ['production-gce-c30'] + }), /more than one live template/) + assert.throws(() => overlayRelayLiveCellImages({ + committedCells, liveTemplates: live, targetCellIds: ['production-gce-c31'] + }), /production-gce-c31 is not a committed Relay cell/) +}) + +test('builds the overlay from plan variables when the target cell has no template in state yet', () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-live-cell-image-overlay-')) + try { + const cells = join(dir, 'cells.json') + const templates = join(dir, 'templates.json') + const output = join(dir, 'overlay.tfvars.json') + // Plan variables carry the map as written: optional attributes the tfvars omit stay absent. + const { connection_hard_cap: _omitted, ...c30 } = cell(committed) + writeFileSync(cells, JSON.stringify({ ...committedCells, 'production-gce-c30': c30 })) + writeFileSync(templates, JSON.stringify(live)) + assert.ok(!live.some(({ index }) => index === 'production-gce-c30')) + const run = (cellsPath) => spawnSync(process.execPath, [ + fileURLToPath(new URL('./relay-live-cell-image-overlay.mjs', import.meta.url)), + '--cells-json', cellsPath, '--live-templates-json', templates, + '--cell-ids', 'production-gce-c30', '--output', output + ], { encoding: 'utf8' }) + const result = run(cells) + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(JSON.parse(result.stdout), { cells: 3, drifted: ['production-gce-c27'] }) + assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).relay_gce_cells['production-gce-c30'], c30) + const empty = join(dir, 'empty.json') + writeFileSync(empty, '') + const failed = run(empty) + assert.notEqual(failed.status, 0) + assert.match(failed.stderr, /committed Relay cells is empty/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index a075d6bf4e70..26d0ca6a69b0 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -860,11 +860,12 @@ export class RelayLoadControlPeer { scheduleRefresh(delayMs) { if (this.stopped) return + const socket = this.socket + const reschedule = () => { + if (this.socket === socket) this.scheduleRefresh(this.phase.refreshIntervalMs) + } this.refreshTimer = setTimeout(() => { - void this.refresh().then( - () => this.scheduleRefresh(this.phase.refreshIntervalMs), - () => this.scheduleRefresh(this.phase.refreshIntervalMs) - ) + void this.refresh().then(reschedule, reschedule) }, delayMs) } diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 95ebd89e1ef6..08bb3291a11f 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -901,3 +901,105 @@ test('shutdown closes both splice legs and waits for the in-flight splice', asyn assert.equal(observations.at(-1).type, 'shutdown') assert.equal(observations.at(-1).detail.activeSpliceSockets, 0) }) + +function trackRefreshTimers(context) { + const schedule = global.setTimeout + const cancel = global.clearTimeout + const timers = new Map() + context.mock.method(global, 'setTimeout', (callback, milliseconds, ...args) => { + const timer = schedule(() => { + timers.delete(timer) + callback(...args) + }, milliseconds) + timers.set(timer, milliseconds) + return timer + }) + context.mock.method(global, 'clearTimeout', (timer) => { + timers.delete(timer) + cancel(timer) + }) + context.after(() => { + for (const timer of timers.keys()) cancel(timer) + }) + return () => [...timers.values()].filter((milliseconds) => milliseconds >= 180_000).length +} + +function reconnectingPeer(context, observations) { + const peer = new RelayLoadControlPeer( + 0, + peerOptions({ + directorOrigin: undefined, + targetOrigin: 'https://cell.test', + accessToken: 'test-access-token' + }), + (type) => observations.push(type) + ) + peer.phase.refreshOffsetMs = 180_000 + peer.phase.refreshIntervalMs = 200_000 + peer.createSocket = () => { + const socket = fakeHandshakeSocket() + socket.send = (raw) => { + const message = JSON.parse(raw) + if (message.type === 'host-hello') { + queueMicrotask(() => socket.message(validChallenge(peer))) + } else if (message.type === 'host-challenge-ack') { + queueMicrotask(() => + socket.message({ + type: 'host-hello-ack', + generation: 1, + controlResumeSecret: 'test-secret' + }) + ) + } + } + return openOnNextTurn(socket) + } + context.after(() => peer.shutdown()) + return peer +} + +for (const outcome of ['success', 'failure']) { + for (const owner of ['current connection', 'replacement connection', 'shutdown']) { + test(`refresh ${outcome} respects its ${owner} ownership`, async (context) => { + const refreshTimers = trackRefreshTimers(context) + const observations = [] + const peer = reconnectingPeer(context, observations) + const pendingResponse = deferred() + const requestStarted = deferred() + let delayResponse = false + context.mock.method(global, 'fetch', async () => { + if (delayResponse) { + requestStarted.resolve() + return await pendingResponse.promise + } + return response({ relayToken: 'test-relay-token' }) + }) + await peer.connect() + assert.equal(refreshTimers(), 1) + clearTimeout(peer.refreshTimer) + delayResponse = true + peer.scheduleRefresh(0) + await requestStarted.promise + delayResponse = false + if (owner !== 'current connection') { + peer.socket.close(1006) + await new Promise((resolve) => setImmediate(resolve)) + await peer.connect() + assert.equal(refreshTimers(), 1) + } + const shutdown = owner === 'shutdown' ? peer.shutdown() : undefined + if (outcome === 'success') pendingResponse.resolve(response({ relayToken: 'fresh-token' })) + else pendingResponse.reject(new Error('token request failed')) + await new Promise((resolve) => setImmediate(resolve)) + const timersAfterCompletion = refreshTimers() + await (shutdown ?? peer.shutdown()) + + assert.equal(timersAfterCompletion, owner === 'shutdown' ? 0 : 1) + assert.equal(refreshTimers(), 0) + assert.equal( + observations.filter((type) => type === 'refresh').length, + owner === 'current connection' && outcome === 'success' ? 1 : 0 + ) + }) + } +} diff --git a/cloud/dev/scripts/relay-lock-contention-alerts.test.mjs b/cloud/dev/scripts/relay-lock-contention-alerts.test.mjs new file mode 100644 index 000000000000..a922921b277f --- /dev/null +++ b/cloud/dev/scripts/relay-lock-contention-alerts.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: both lock alerts key on text another component writes (a relay runtime field and a +// Postgres error message). A rename on either side silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +const incidentMetric = (key) => { + const body = new RegExp(`\\n ${key} = \\{([\\s\\S]*?)\\n \\}`).exec(terraform)?.[1] + assert.ok(body, `relay_incident_metrics.${key} not found`) + return body +} + +test('the long-hold metric thresholds the field the relay emits', () => { + const emitter = read('../../apps/relay/src/cell-inventory-hold-samples.ts') + assert.match(emitter, /\bcellInventoryHoldMsMax: number\b/) + const metric = block('google_logging_metric', 'relay_long_cell_inventory_hold') + assert.match(metric, /filter\s*=\s*"\$\{local\.relay_runtime_log_filter\} AND jsonPayload\.cellInventoryHoldMsMax>=1000"/) + assert.match(metric, /value_extractor\s*=\s*"EXTRACT\(jsonPayload\.cellInventoryHoldMsMax\)"/) +}) + +test('the long-hold buckets start at the filter bar so any sample reads above zero', () => { + const metric = block('google_logging_metric', 'relay_long_cell_inventory_hold') + const bounds = /bounds\s*=\s*\[([^\]]*)\]/.exec(metric)?.[1].split(',').map(Number) + assert.ok(bounds && bounds.length > 0) + assert.equal(bounds[0], 1000) + for (const name of ['relay_long_cell_inventory_hold', 'relay_director_cell_inventory_hold']) { + const policy = block('google_monitoring_alert_policy', name) + const conditions = [...policy.matchAll(/threshold_value\s*=\s*(\d+)\n/g)] + assert.ok(conditions.length > 0) + for (const match of conditions) assert.equal(match[1], '0') + } +}) + +test('only cell holds page; director holds stay visible without paging', () => { + // Director holds recur with rehoming paused, and the paging runbook's first step is to pause it. + const paging = block('google_monitoring_alert_policy', 'relay_long_cell_inventory_hold') + const pagingRoles = [...paging.matchAll(/metric\.label\.\\"role\\"=\\"([a-z]+)\\"/g)].map((m) => m[1]) + assert.ok(pagingRoles.length > 0) + assert.deepEqual([...new Set(pagingRoles)], ['cell']) + assert.equal([...paging.matchAll(/condition_threshold \{/g)].length, pagingRoles.length) + + const director = block('google_monitoring_alert_policy', 'relay_director_cell_inventory_hold') + const directorRoles = [...director.matchAll(/metric\.label\.\\"role\\"=\\"([a-z]+)\\"/g)].map((m) => m[1]) + assert.deepEqual(directorRoles, ['director']) + assert.match(director, /notification_channels\s*=\s*\[\]/) +}) + +test('the lock-timeout metric counts relay cancels only, never NOWAIT refusals or auth', () => { + const metric = incidentMetric('cloud_sql_lock_timeouts') + const filter = /filter\s*=\s*"(.*)"$/m.exec(metric)?.[1] + assert.ok(filter) + assert.match(filter, /resource\.type=\\"cloudsql_database\\"/) + assert.match(filter, /textPayload:\\"db=orca_relay,\\"/) + assert.match(filter, /textPayload:\\"canceling statement due to lock timeout\\"/) + // Would match the `SET LOCAL lock_timeout` STATEMENT line Postgres logs after every cancel. + assert.doesNotMatch(filter, /lock_timeout/) + // Background sweeps refuse at ~160/min with rehome paused; including them pins the alert on. + assert.doesNotMatch(filter, /could not obtain lock/) +}) + +test('the burst policy fires at 20 relay cancels in a minute and pages the relay channel', () => { + const policy = block('google_monitoring_alert_policy', 'relay_lock_timeout_burst') + assert.match(policy, /logging\.googleapis\.com\/user\/orca_relay_cloud_sql_lock_timeouts/) + assert.match(policy, /comparison\s*=\s*"COMPARISON_GT"\s*\n\s*threshold_value\s*=\s*19\n/) + assert.match(policy, /alignment_period\s*=\s*"60s"\s*\n\s*per_series_aligner\s*=\s*"ALIGN_SUM"/) + for (const name of ['relay_lock_timeout_burst', 'relay_long_cell_inventory_hold']) { + assert.match( + block('google_monitoring_alert_policy', name), + /notification_channels\s*=\s*var\.relay_alert_notification_channels/ + ) + } +}) diff --git a/cloud/dev/scripts/relay-monitor-evidence.mjs b/cloud/dev/scripts/relay-monitor-evidence.mjs index 26eb37d0d4df..072991ba8fe5 100644 --- a/cloud/dev/scripts/relay-monitor-evidence.mjs +++ b/cloud/dev/scripts/relay-monitor-evidence.mjs @@ -10,7 +10,9 @@ const JWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/ const EVIDENCE_MAX_AGE_MS = 5 * 60_000 // Matches the same-cap cell job timeout-minutes; bounds each predecessor wave. const WAVE_PREDECESSOR_TIMEOUT_MS = 75 * 60_000 -const WAVE_INDEX = /^[0-3]$/ +// Widest any wave chain declares (same-cap's cell_1..cell_10); each job workflow +// pins its own narrower range. +const WAVE_INDEX = /^[0-9]$/ const EVIDENCE_SAMPLE_INTERVAL_MS = 60_000 const EVIDENCE_MAX_LINEAGE_MS = 25 * 60_000 const MIGRATION_POLICIES = new Set([ diff --git a/cloud/dev/scripts/relay-monitor-evidence.test.mjs b/cloud/dev/scripts/relay-monitor-evidence.test.mjs index 43d2ac02763e..876b710d0c7c 100644 --- a/cloud/dev/scripts/relay-monitor-evidence.test.mjs +++ b/cloud/dev/scripts/relay-monitor-evidence.test.mjs @@ -242,7 +242,7 @@ test('later same-cap waves accept evidence aged by predecessor cell rolls', asyn await ageState(17 * 60_000) await assert.rejects(authorityAt('0'), /authority is incomplete or stale/) await assert.doesNotReject(authorityAt('1')) - await assert.rejects(authorityAt('4'), /wave index is invalid/) + await assert.rejects(authorityAt('10'), /wave index is invalid/) await assert.rejects(authorityAt('x'), /wave index is invalid/) // Both edges of one predecessor job timeout: 5min + 75min exactly. await ageState(80 * 60_000) @@ -259,6 +259,11 @@ test('later same-cap waves accept evidence aged by predecessor cell rolls', asyn await assert.doesNotReject(authorityAt('3')) await ageState(230 * 60_000 + 1) await assert.rejects(authorityAt('3'), /authority is incomplete or stale/) + // The last cell of a ten-cell same-cap batch: 5min + 9 * 75min exactly. + await ageState(680 * 60_000) + await assert.doesNotReject(authorityAt('9')) + await ageState(680 * 60_000 + 1) + await assert.rejects(authorityAt('9'), /authority is incomplete or stale/) } finally { await rm(directory, { recursive: true, force: true }) } diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 654d9fc55bd4..212f3f1d16d4 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -11,7 +11,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c13', 'production-gce-c14', 'production-gce-c15', 'production-gce-c16', 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', - 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', ...SAME_CAP_MIGRATION_ONLY_CELLS ] @@ -34,7 +34,7 @@ function cells(value) { const parsed = value.split(',').map((cell) => cell.trim()).filter(Boolean) if ( parsed.length < 1 || - parsed.length > 4 || + parsed.length > 10 || new Set(parsed).size !== parsed.length || parsed.some((cell) => !SAME_CAP_CELLS.includes(cell)) ) throw new Error('same-cap wave cells are invalid') @@ -76,8 +76,9 @@ export function validateSameCapWave(input) { if (input.mode === 'canary-apply' && selected.length !== 1) { throw new Error('canary mode requires exactly one cell') } - if (input.mode === 'batch-apply' && (selected.length < 2 || selected.length > 4)) { - throw new Error('batch mode requires two to four cells') + // Ten is the wave workflow's statically declared serial cell-job chain, cell_1..cell_10. + if (input.mode === 'batch-apply' && (selected.length < 2 || selected.length > 10)) { + throw new Error('batch mode requires two to ten cells') } // Later waves expect the selector to advance by exactly 2 per predecessor, // which a resumed rollback cell (isolate skipped, +1) violates. diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 0d2ac6f1a96a..8a3cfc240ef4 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -10,6 +10,7 @@ import { canaryAuthority, entryAdmission, main, + selectorWaveDelta, validateSameCapWave, verifyCanaryAuthority } from './relay-production-same-cap-wave.mjs' @@ -48,15 +49,69 @@ test('requires one canary or a bounded reviewed batch', () => { rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c28` }).cells, ['production-gce-c28']) - assert.throws(() => validateSameCapWave({ + assert.deepEqual(validateSameCapWave({ mode: 'canary-apply', cellIds: 'production-gce-c30', targetDigest, rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c30` + }).cells, ['production-gce-c30']) + assert.throws(() => validateSameCapWave({ + mode: 'canary-apply', + cellIds: 'production-gce-c31', + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31` }), /cells/) }) +// The bound is the wave workflow's static cell_1..cell_10 chain: a batch longer than the +// chain would silently drop its tail cells, so it is refused before any mutation. +test('a batch fills the serial cell chain and never overflows it', () => { + const general = SAME_CAP_CELLS.filter((cell) => entryAdmission(cell) === 'general') + const batch = (count) => { + const cellIds = general.slice(0, count).join(',') + return validateSameCapWave({ + mode: 'batch-apply', + cellIds, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${cellIds}`, + canaryRunId: '42' + }) + } + assert.equal(batch(10).cells.length, 10) + assert.throws(() => batch(11), /same-cap wave cells are invalid/) + assert.throws(() => batch(1), /batch mode requires two to ten cells/) +}) + +// The validator's ten-cell bound is only true if the workflow really declares ten strictly +// serial cell jobs and frees the lease after all of them. +test('the wave workflow chains exactly ten serial cell jobs', () => { + const dispatch = readRelayWorkflow('deploy-relay-production-same-cap.yml') + for (let index = 0; index < 10; index += 1) { + const job = index + 1 + assert.match(dispatch, new RegExp(`\n cell_${job}:\n`), `cell_${job} is missing`) + assert.match(dispatch, new RegExp(`fromJSON\\(needs\\.gate\\.outputs\\.cells\\)\\[${index}\\]`)) + assert.match(dispatch, new RegExp(`wave-index: '${index}'`)) + if (index > 0) { + assert.match(dispatch, new RegExp(`needs: \\[gate, cell_${index}\\]`)) + assert.match( + dispatch, + new RegExp(`if: \\$\\{\\{ needs\\.cell_${index}\\.result == 'success' && ` + + `fromJSON\\(needs\\.gate\\.outputs\\.cells\\)\\[${index}\\] != null \\}\\}`) + ) + } + assert.match(dispatch, new RegExp(`\n - cell_${job}\n`), `release_lease must need cell_${job}`) + } + assert.doesNotMatch(dispatch, /\n cell_11:/) +}) + +test('lists only C17 and C18 as migration-only now that C30 is promoted', () => { + assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, ['production-gce-c17', 'production-gce-c18']) + assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true) +}) + test('rolls the migration-only cells but never mixes the two classes in one wave', () => { for (const cellId of SAME_CAP_MIGRATION_ONLY_CELLS) { assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) @@ -78,6 +133,27 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${cellIds}`, canaryRunId: '42' }).cells, ['production-gce-c17', 'production-gce-c18']) + // C30 is general since its 2026-09-23 promotion, so it cannot share a wave with C17/C18. + assert.equal(entryAdmission('production-gce-c30'), 'general') + assert.equal(selectorWaveDelta('production-gce-c30'), 2) + const asiaGeneral = 'production-gce-c29,production-gce-c30' + assert.deepEqual(validateSameCapWave({ + mode: 'batch-apply', + cellIds: asiaGeneral, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaGeneral}`, + canaryRunId: '42' + }).cells, ['production-gce-c29', 'production-gce-c30']) + const asiaMixed = 'production-gce-c30,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: asiaMixed, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaMixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) // A mixed wave has no single selector delta for its later cells to offset from. const mixed = 'production-gce-c7,production-gce-c17' assert.throws(() => validateSameCapWave({ diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs index e52941244661..aabf80a65f0f 100644 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs @@ -20,7 +20,7 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => { assert.match(wrapper, /needs: \[gate, cell_2\]/) assert.match(wrapper, /needs: \[gate, cell_3\]/) assert.match(job, /on:\n workflow_call:/) - assert.match(job, /c27\|c28\|c29/) + assert.match(job, /c27\|c28\|c29\|c30\)/) assert.match(job, /EXPECTED_HARD_CAP=3000/) assert.match(job, /EXPECTED_REGION=asia-east2/) assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/) @@ -75,8 +75,15 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => { ) assert.match( job, - /host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}" \\\n {14}\| jq -e '\.changes == 2' >\/dev\/null/ + /host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}"\)"\n {12}echo "\$\{RESUME_REVIEW\}"\n {12}jq -e '\.changes == 2' <<< "\$\{RESUME_REVIEW\}" >\/dev\/null/ ) + // A resume applies nothing at all, which is what a resume means: the only accepted + // unconverged plan is the template-and-MIG rollback-image drift, and it is left pending. + const resumeStep = job.slice( + job.indexOf('- name: Require converged Terraform state and a stable MIG on resume'), + job.indexOf('- name: Apply only the selected same-cap template and MIG') + ) + assert.equal(resumeStep.split('terraform -chdir=infra/terraform apply').length, 1) assert.match(job, /resume requires the isolated migration-only cell/) assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/) assert.match(job, /\(.regionalRehomeProtocol \/\/ 0\) == \$protocol/) @@ -139,9 +146,10 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => { // The wrapper validates the override before anything runs, passes it to every // cell, seals it into the canary artifact, and prints it in the run summary. assert.match(wrapper, /--gate-override-reason "\$\{GATE_OVERRIDE_REASON\}" \\\n {12}--gate-override-confirmation "\$\{GATE_OVERRIDE_CONFIRMATION\}"\)/) + // One per cell job in the serial cell_1..cell_10 chain. assert.equal( wrapper.match(/gate-override-confirmation: \$\{\{ inputs\.gate-override-confirmation \}\}/g).length, - 4 + 10 ) assert.match(wrapper, /Aggregate monitor gate overridden \(break-glass\)/) assert.match(wrapper, /ACTOR: \$\{\{ github\.actor \}\}/) @@ -163,12 +171,12 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => { ]) { const body = readFileSync(fileURLToPath(new URL(source, import.meta.url)), 'utf8') assert.match(body, /WAVE_PREDECESSOR_TIMEOUT_MS = 75 \* 60_000/) - assert.match(body, /\^\[0-3\]\$/) + assert.match(body, /\^\[0-9\]\$/) } // Aged-evidence replay via job re-runs is fenced: mutations are // single-dispatch, so a failed cell needs a fresh gate and monitor run. assert.match(job, /test "\$\{GITHUB_RUN_ATTEMPT\}" = 1/) - for (const index of [0, 1, 2, 3]) { + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) { assert.match(wrapper, new RegExp(`wave-index: '${index}'`)) } assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 1\)/) diff --git a/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs b/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs index 82bc2fb522ab..a0563ffa94a6 100644 --- a/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs +++ b/cloud/dev/scripts/relay-rehome-aggregate-evidence.mjs @@ -1,6 +1,24 @@ import { pathToFileURL } from 'node:url' -const INVENTORY = /^\[orca-relay\] regional rehome inventory active=(\d+) awaitingReceipt=(\d+) targetRegistered=(\d+) completedLast24Hours=(\d+) abortedLast24Hours=(\d+) oldestActiveAgeMs=(none|\d+)$/ +const INVENTORY_PREFIX = '[orca-relay] regional rehome inventory ' +// A counters line, so every field is a bare name and a non-negative integer or +// `none`. Pinning the whole line instead is what broke the enable workflow when +// `hostNotArrivedLast24Hours` shipped: the director grew a field and the parser +// read a healthy line as no evidence at all. Tolerating extra fields is safe +// only because the value shape stays fenced — `hostId=someone` is still not a +// counter, so an identity-bearing lookalike cannot slip through as an extra. +const FIELD = /^([A-Za-z][A-Za-z0-9]*)=(none|\d{1,15})$/ +// `oldestActiveAgeMs` is the one required field the director can report as +// `none`; a count that reads `none` is a line this parser does not recognise, +// not evidence worth failing the run over. +const REQUIRED_COUNTS = [ + 'active', + 'awaitingReceipt', + 'targetRegistered', + 'completedLast24Hours', + 'abortedLast24Hours' +] +const REQUIRED_FIELDS = [...REQUIRED_COUNTS, 'oldestActiveAgeMs'] function count(value, name) { const parsed = Number(value) @@ -8,20 +26,43 @@ function count(value, name) { return parsed } +// Returns the field map, or null for anything that is not this line. +export function readRegionalRehomeInventoryFields(textPayload) { + if (typeof textPayload !== 'string' || !textPayload.startsWith(INVENTORY_PREFIX)) return null + const fields = new Map() + for (const token of textPayload.slice(INVENTORY_PREFIX.length).split(' ')) { + const field = FIELD.exec(token) + if (!field || fields.has(field[1])) return null + fields.set(field[1], field[2]) + } + if (!REQUIRED_FIELDS.every((name) => fields.has(name))) return null + if (REQUIRED_COUNTS.some((name) => fields.get(name) === 'none')) return null + return fields +} + +// Absent is not zero: a director on an older image emits no such field, and +// reporting 0 would read as "no leaks" rather than "not measured". +function optionalCount(fields, name) { + const value = fields.get(name) + if (value === undefined || value === 'none') return null + return count(value, name) +} + export function parseRegionalRehomeInventory(entries, options = {}) { if (!Array.isArray(entries)) throw new Error('logging response must be an array') const parsed = entries.flatMap((entry) => { - const match = INVENTORY.exec(entry?.textPayload ?? '') + const fields = readRegionalRehomeInventoryFields(entry?.textPayload ?? '') const timestamp = Date.parse(entry?.timestamp ?? '') - if (!match || !Number.isFinite(timestamp)) return [] + if (!fields || !Number.isFinite(timestamp)) return [] return [{ timestamp, - active: count(match[1], 'active'), - awaitingReceipt: count(match[2], 'awaiting receipt'), - targetRegistered: count(match[3], 'target registered'), - completedLast24Hours: count(match[4], 'completed'), - abortedLast24Hours: count(match[5], 'aborted'), - oldestActiveAgeMs: match[6] === 'none' ? null : count(match[6], 'oldest active age') + active: count(fields.get('active'), 'active'), + awaitingReceipt: count(fields.get('awaitingReceipt'), 'awaiting receipt'), + targetRegistered: count(fields.get('targetRegistered'), 'target registered'), + completedLast24Hours: count(fields.get('completedLast24Hours'), 'completed'), + abortedLast24Hours: count(fields.get('abortedLast24Hours'), 'aborted'), + hostNotArrivedLast24Hours: optionalCount(fields, 'hostNotArrivedLast24Hours'), + oldestActiveAgeMs: optionalCount(fields, 'oldestActiveAgeMs') }] }).sort((left, right) => right.timestamp - left.timestamp) if (parsed.length === 0) throw new Error('no aggregate regional rehome inventory evidence') diff --git a/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs b/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs index 2ce2627fb93d..e85c4d5606d8 100644 --- a/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs +++ b/cloud/dev/scripts/relay-rehome-aggregate-evidence.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { parseRegionalRehomeInventory } from './relay-rehome-aggregate-evidence.mjs' +import { readRelayWorkflow } from './relay-repository.mjs' const now = Date.parse('2026-08-14T12:00:00Z') @@ -22,10 +23,38 @@ test('selects the newest fresh aggregate-only regional rehome inventory', () => targetRegistered: 1, completedLast24Hours: 9, abortedLast24Hours: 0, + hostNotArrivedLast24Hours: null, oldestActiveAgeMs: 30_000 }) }) +test('reads a line the director grew a field on, wherever the field sits', () => { + const result = parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory hostNotArrivedLast24Hours=4 active=2' + + ' awaitingReceipt=1 targetRegistered=1 completedLast24Hours=9 abortedLast24Hours=7' + + ' oldestActiveAgeMs=30000 someFieldFromALaterRelease=11' + }], { now, maxAgeMs: 5 * 60_000 }) + assert.equal(result.hostNotArrivedLast24Hours, 4) + assert.equal(result.abortedLast24Hours, 7) + assert.equal(result.oldestActiveAgeMs, 30_000) +}) + +test('reports an unmeasured host-not-arrived count as absent, not as zero', () => { + const [withField, withoutField] = ['4', null].map((value) => + parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 oldestActiveAgeMs=none' + + (value === null ? '' : ` hostNotArrivedLast24Hours=${value}`) + }], { now, maxAgeMs: 5 * 60_000 }) + ) + assert.equal(withField.hostNotArrivedLast24Hours, 4) + assert.equal(withoutField.hostNotArrivedLast24Hours, null) +}) + test('rejects stale, malformed, and identity-bearing lookalikes', () => { assert.throws(() => parseRegionalRehomeInventory([{ timestamp: '2026-08-14T11:00:00Z', @@ -36,3 +65,71 @@ test('rejects stale, malformed, and identity-bearing lookalikes', () => { textPayload: '[orca-relay] regional rehome inventory active=0 hostId=secret' }], { now }), /no aggregate/) }) + +test('keeps out an identity-bearing field riding along on a complete line', () => { + const complete = + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 oldestActiveAgeMs=none' + for (const extra of [' hostId=secret', ' userId=someone@example.test', ' note=a b']) { + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: complete + extra }], + { now } + ), + /no aggregate/, + extra + ) + } +}) + +test('refuses a line missing a required field, or repeating one', () => { + const missing = + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 oldestActiveAgeMs=none' + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: missing }], + { now } + ), + /no aggregate/ + ) + assert.throws( + () => parseRegionalRehomeInventory( + [{ timestamp: '2026-08-14T11:59:00Z', textPayload: `${missing} abortedLast24Hours=0 abortedLast24Hours=1` }], + { now } + ), + /no aggregate/ + ) + assert.throws( + () => parseRegionalRehomeInventory( + [{ + timestamp: '2026-08-14T11:59:00Z', + textPayload: missing.replace('active=0', 'active=none') + ' abortedLast24Hours=0' + }], + { now } + ), + /no aggregate/ + ) +}) + +// The third edge of the chain the enable workflow depends on. The formatter is +// pinned against this parser in the relay package's inventory-line census; this +// pins the parser against the summary an operator reads, so a field that +// reaches the evidence JSON and stops there fails here. +test('publishes every parsed counter in the operator step summary', () => { + const job = readRelayWorkflow('operate-relay-production-rehome-job.yml') + // The jq program and the file it reads sit on separate continuation lines, so + // match the whole render rather than one line of it. + const summary = /jq -r '([^']*)' \\\n\s*"\$\{RUNNER_TEMP\}\/relay-rehome-inventory\.json"/.exec(job)?.[1] + assert.ok(summary, 'the rehome job no longer renders the inventory evidence') + const evidence = parseRegionalRehomeInventory([{ + timestamp: '2026-08-14T11:58:00Z', + textPayload: + '[orca-relay] regional rehome inventory active=0 awaitingReceipt=0 targetRegistered=0' + + ' completedLast24Hours=0 abortedLast24Hours=0 hostNotArrivedLast24Hours=0 oldestActiveAgeMs=none' + }], { now, maxAgeMs: 5 * 60_000 }) + for (const key of Object.keys(evidence)) { + if (key === 'timestamp') continue + assert.ok(summary.includes(`.${key}`), `${key} is missing from the step summary`) + } +}) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 5324e7489e70..1e023ce0c9d9 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -292,7 +292,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c30').status, 1) + assert.equal(resolveCellShape('production-gce-c31').status, 1) }) it('passes the same-cap allowlist on every canary invocation the job runs', () => { @@ -311,9 +311,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { it('paces the drain it sends to the selected cell', () => { const drain = workflow.split('--mode drain')[1] ?? '' assert.match(drain.split('\n').slice(0, 2).join(' '), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) - assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '120000'/) + // 5 min is the cell's DRAIN_PACE_WINDOW_MAX_MS; a 2,700-host cell at 2 min overruns the director's sticky lane. + assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '300000'/) // The transition wait has to outlast the pacing window on top of the leases it waits on. - assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1020000/) + assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1200000/) }) it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => { @@ -336,6 +337,33 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3) }) + // One cell's compute path and nothing else: the template and the MIG bound to it. The cell + // backend service stays out because the capacity role has no compute.backendServices.update, + // so naming it fails the apply after the MIG has already rolled. + it('targets exactly this cell template and MIG on every plan the job runs', () => { + const plans = workflow.split('terraform -chdir=infra/terraform plan').slice(1) + assert.equal(plans.length, 2) + for (const plan of plans) { + const lines = plan.split('\n') + const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) + const call = lines.slice(0, end + 1).join('\n') + assert.deepEqual( + [...call.matchAll(/-target=([\w.]+)\[\\"\$\{TARGET_CELL_ID\}\\"\]/g)] + .map(([, resource]) => resource), + [ + 'google_compute_instance_template.relay_gce_cell', + 'google_compute_instance_group_manager.relay_gce_cell' + ] + ) + // Any target that is not one of those two, or not scoped to this cell, fails here. + assert.equal(call.split('-target=').length, 3) + } + }) + + it('never names a backend service on any plan or apply in the job', () => { + assert.equal(workflow.includes('google_compute_backend_service'), false) + }) + it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses @@ -695,6 +723,61 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(apply.split('wait-until "${MIG_NAME}" --stable').length, 3) }) + // Run the predicate the job ships rather than restating it, because restating it is how the + // two drift apart. An unconverged resume accepts the template-and-MIG pair and nothing else, + // and it applies nothing: a backend change cannot reach this plan, which no longer targets one. + it('accepts only the template-and-MIG pair on an unconverged resume', () => { + const step = workflow.slice( + workflow.indexOf('- name: Require converged Terraform state and a stable MIG on resume'), + workflow.indexOf('- name: Apply only the selected same-cap template and MIG') + ) + const accept = /jq -e '(\.changes == 2)' <<< "\$\{RESUME_REVIEW\}"/.exec(step) + assert.notEqual(accept, null, 'the resume step no longer gates on a validator verdict') + assert.equal(step.includes('terraform -chdir=infra/terraform apply'), false) + const outcome = (review) => { + const resolved = spawnSync('bash', ['-euo', 'pipefail', '-c', [ + `RESUME_REVIEW=${JSON.stringify(JSON.stringify(review))}`, + `jq -e '${accept[1]}' <<< "\${RESUME_REVIEW}" >/dev/null || { echo refuse; exit 0; }`, + 'echo accept' + ].join('\n')], { encoding: 'utf8' }) + assert.equal(resolved.status, 0, resolved.stderr) + return resolved.stdout.trim() + } + assert.equal(outcome({ changes: 2 }), 'accept') + // Anything the validator did not bound to the reviewed rollback-image drift fails the step. + assert.equal(outcome({ changes: 0 }), 'refuse') + assert.equal(outcome({ changes: 0, backendUpdate: ['log_config.0'] }), 'refuse') + assert.equal(outcome({ changes: 1 }), 'refuse') + assert.equal(outcome({ changes: 3 }), 'refuse') + }) + + // The stranded cell's explicit MIG roll is the only thing that clears its drain flag, and + // `changes` is what decides it, so run the shipped predicate rather than restating it. + it('rolls a stranded MIG on the shipped predicate', () => { + const apply = workflow + .split('name: Apply only the selected same-cap template and MIG')[1] + .split('\n - id:')[0] + const condition = + /if test "\$\{ROLLBACK_STAGE\}" = stranded \\\n\s+(&& test "\$\(jq -er '\.changes' <<< "\$\{PLAN_REVIEW\}"\)" = 0); then/ + .exec(apply) + assert.notEqual(condition, null, 'the stranded roll no longer gates on the plan review') + const rolls = (stage, review) => { + const resolved = spawnSync('bash', ['-euo', 'pipefail', '-c', [ + `ROLLBACK_STAGE=${stage}`, + `PLAN_REVIEW=${JSON.stringify(JSON.stringify(review))}`, + `if test "\${ROLLBACK_STAGE}" = stranded \\\n ${condition[1]}; then`, + 'echo replace; else echo no-replace; fi' + ].join('\n')], { encoding: 'utf8' }) + assert.equal(resolved.status, 0, resolved.stderr) + return resolved.stdout.trim() + } + assert.equal(rolls('stranded', { changes: 0 }), 'replace') + // A real template replacement already restarts the instance; rolling again would be a second. + assert.equal(rolls('stranded', { changes: 2 }), 'no-replace') + assert.equal(rolls('resume', { changes: 0 }), 'no-replace') + assert.equal(rolls('none', { changes: 0 }), 'no-replace') + }) + it('waits on the image a stranded cell actually serves', () => { const isolate = workflow .split('name: Reversibly isolate and drain only the selected cell')[1] diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs new file mode 100644 index 000000000000..dc2ec33f1f39 --- /dev/null +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -0,0 +1,256 @@ +// Windowing, thresholds, and the verdict for the same-cap post-wave shadow health gate. Pure: it +// takes already-read log samples and returns a judgement, so every rule here is unit-testable +// without touching production. The reader lives in relay-same-cap-shadow-gate.mjs. + +// Status vocabulary, worst-first. 'unverified' is a read that did not complete or that hit the +// entry limit; it can never settle to 'pass', because a truncated count is not evidence of calm. +export const CHECK_STATUSES = ['would-block', 'unverified', 'warn', 'pass'] + +export const VERDICTS = { PASS: 'PASS', WARN: 'WARN', WOULD_BLOCK: 'WOULD_BLOCK' } + +// Cloud Logging silently returns only `--limit` entries, so every read is split into sub-windows +// this long and a sub-window that comes back exactly at the limit is reported as truncated. +export const SUB_WINDOW_MINUTES = 10 + +export const ENTRY_LIMIT = 20000 + +// Clock-hour-aligned comparisons: the same wall-clock minutes one and two days earlier. +export const BASELINE_OFFSET_HOURS = [24, 48] + +// The asia-east2 cells share a 16-connection pool at 176 ms RTT, which is where pool pressure +// shows up first for the whole fleet. A cell joins only once it serves: zero samples read as +// unverified, so listing a not-yet-general cell would turn every verdict into WARN. +export const FLEET_POOL_CELL_IDS = [ + 'production-gce-c27', + 'production-gce-c28', + 'production-gce-c29', + 'production-gce-c30' +] + +export const SHADOW_GATE_THRESHOLDS = { + // A US ramp legitimately lifts director 503s far above a quiet baseline (61-71/min against a + // 20-60/min baseline was healthy), so this is a multiple of the busier baseline with an + // absolute floor underneath it, never a fixed rate. + director503: { blockMultiple: 10, blockFloor: 200, warnMultiple: 3, warnFloor: 100 }, + // One sample at 71 waiters is a burst that drains; three in a row is a pool that does not. + pool: { waitersMax: 50, waitersConsecutiveSamples: 3, sqlFailuresDelta: 200 }, + cloudSqlFatal: { warnAbove: 0, blockAbove: 20 }, + // With no drain timestamp (a resumed rollback skips the drain) the window still has to start + // somewhere; this is how far back of the verify end it reaches instead. + fallbackWindowMinutes: 30, + // A read that stalls must not be allowed to spend the job's remaining minutes. + readTimeoutMs: 60_000, + // Reads are serialised, so a failure mode that makes every read cost its full retry budget + // (an expired credential, a Logging 429 storm) scales with the window, not with one read. + // Past this the gate stops reading and reports the rest unverified, which is a verdict; the + // step's own timeout-minutes sits above it and exists only for a hung process. Set well clear + // of a healthy gate's own serial read time, or ordinary days report unverified tails and the + // shadow roll stops measuring the thing it exists to measure. Raise both bounds together. + overallDeadlineMs: 420_000 +} + +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 + +export function parseTimestamp(value, label) { + const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN + if (Number.isNaN(parsed)) throw new Error(`${label} is not an RFC 3339 timestamp: ${value}`) + return new Date(parsed) +} + +export function formatTimestamp(date) { + return `${date.toISOString().slice(0, 19)}Z` +} + +/** + * The window a cell's roll is judged over: its drain start to its verify end. A resumed rollback + * never drains, so the apply start, then a fixed lookback, stands in for it. + */ +export function resolveWindow({ + drainStartedAt, + applyStartedAt, + verifyEndedAt, + fallbackMinutes = SHADOW_GATE_THRESHOLDS.fallbackWindowMinutes +}) { + const endedAt = parseTimestamp(verifyEndedAt, 'verify end') + const start = drainStartedAt || applyStartedAt + const startedAt = start + ? parseTimestamp(start, 'window start') + : new Date(endedAt.getTime() - fallbackMinutes * MINUTE_MS) + if (startedAt >= endedAt) throw new Error('shadow gate window starts at or after it ends') + return { startedAt, endedAt, startedFrom: drainStartedAt ? 'drain' : start ? 'apply' : 'fallback' } +} + +export function splitWindow({ startedAt, endedAt }, minutes = SUB_WINDOW_MINUTES) { + const step = minutes * MINUTE_MS + const windows = [] + for (let cursor = startedAt.getTime(); cursor < endedAt.getTime(); cursor += step) { + windows.push({ + startedAt: new Date(cursor), + endedAt: new Date(Math.min(cursor + step, endedAt.getTime())) + }) + } + return windows +} + +export function shiftWindow({ startedAt, endedAt }, hours) { + return { + startedAt: new Date(startedAt.getTime() - hours * HOUR_MS), + endedAt: new Date(endedAt.getTime() - hours * HOUR_MS) + } +} + +/** + * Counts per clock minute across sub-window reads. A sub-window that returned exactly the entry + * limit is truncated, so its minutes are floors, not counts, and the whole read is unverified. + */ +export function countByMinute(reads, limit = ENTRY_LIMIT) { + const perMinute = new Map() + let truncated = false + for (const read of reads) { + if (read.failed || read.timestamps.length >= limit) truncated = true + for (const timestamp of read.timestamps) { + const minute = timestamp.slice(0, 16) + perMinute.set(minute, (perMinute.get(minute) ?? 0) + 1) + } + } + let peak = 0 + let peakMinute = null + let total = 0 + for (const [minute, count] of perMinute) { + total += count + if (count > peak) { + peak = count + peakMinute = minute + } + } + return { perMinute: Object.fromEntries(perMinute), total, peak, peakMinute, truncated } +} + +// Longest run of consecutive samples at or above the threshold. +export function longestRunAtOrAbove(values, threshold) { + let longest = 0 + let run = 0 + for (const value of values) { + run = value > threshold ? run + 1 : 0 + if (run > longest) longest = run + } + return longest +} + +export function judgeDirector503({ observed, baselines }) { + const { blockMultiple, blockFloor, warnMultiple, warnFloor } = SHADOW_GATE_THRESHOLDS.director503 + const baselinePeak = Math.max(0, ...baselines.map((baseline) => baseline.peak)) + const baselineTruncated = baselines.some((baseline) => baseline.truncated) + const detail = { + peakPerMinute: observed.peak, + peakMinute: observed.peakMinute, + total: observed.total, + baselinePeakPerMinute: baselinePeak, + baselines: baselines.map(({ label, peak, total, truncated }) => ({ + label, + peakPerMinute: peak, + total, + truncated + })), + blockAbove: Math.max(baselinePeak * blockMultiple, blockFloor), + warnAbove: Math.max(baselinePeak * warnMultiple, warnFloor) + } + if (observed.truncated || baselineTruncated) return { status: 'unverified', ...detail } + if (observed.peak > detail.blockAbove) return { status: 'would-block', ...detail } + if (observed.peak > detail.warnAbove) return { status: 'warn', ...detail } + return { status: 'pass', ...detail } +} + +/** + * The cell's own container: it has to have announced its listener since the apply began, and it + * must not have crashed anywhere in that span. Counting crashes only after the *last* listener + * would erase a crash-restart loop, whose later announcement looks like a clean boot; the MIG + * recreates the instance, so everything on this instance id since the apply belongs to this roll. + * + * A missing announcement only means a failure where a restart was expected. A resumed rollback + * deliberately restarts nothing, so there is no boot for this oracle to observe and its silence + * says nothing either way. + */ +export function judgeCellServing({ listeningAt, crashesSinceApply, read, expectBoot = true }) { + const detail = { + listeningAt: listeningAt ?? null, + crashesSinceApply: crashesSinceApply ?? 0, + expectBoot + } + if (read?.failed) return { status: 'unverified', ...detail } + if (!listeningAt) return { status: expectBoot ? 'would-block' : 'unverified', ...detail } + if (detail.crashesSinceApply > 0) return { status: 'would-block', ...detail } + return { status: 'pass', ...detail } +} + +/** + * Pool pressure. A single spike is a burst the pool absorbs; the block rule needs the pressure to + * persist across consecutive samples, which is what separates it from the one-sample false + * positives a literal rule produced this week. + */ +export function judgePool({ label, samples, failed = false, truncated = false }) { + const { waitersMax, waitersConsecutiveSamples, sqlFailuresDelta } = SHADOW_GATE_THRESHOLDS.pool + const waiters = samples.map((sample) => sample.databasePoolWaitersMax ?? 0) + const failures = samples.map((sample) => sample.sqlFailuresDelta ?? 0) + const detail = { + label, + samples: samples.length, + waitersMax: Math.max(0, ...waiters), + consecutiveSamplesOverWaitersThreshold: longestRunAtOrAbove(waiters, waitersMax), + sqlFailuresDeltaMax: Math.max(0, ...failures), + reconnectsDeltaMax: Math.max(0, ...samples.map((sample) => sample.reconnectsDelta ?? 0)), + totalConnectionsMax: Math.max(0, ...samples.map((sample) => sample.totalConnections ?? 0)), + databasePoolWaitingMax: Math.max(0, ...samples.map((sample) => sample.databasePoolWaiting ?? 0)), + waitersThreshold: waitersMax, + consecutiveSamplesThreshold: waitersConsecutiveSamples, + sqlFailuresDeltaThreshold: sqlFailuresDelta, + truncated + } + // A truncated sample run has holes, and the consecutive-sample rule reads a hole as a recovery. + if (failed || truncated || samples.length === 0) return { status: 'unverified', ...detail } + if ( + detail.consecutiveSamplesOverWaitersThreshold >= waitersConsecutiveSamples + || detail.sqlFailuresDeltaMax > sqlFailuresDelta + ) return { status: 'would-block', ...detail } + if (detail.waitersMax > waitersMax) return { status: 'warn', ...detail } + return { status: 'pass', ...detail } +} + +export function judgeCloudSqlFatal({ count, truncated = false, failed = false }) { + const { warnAbove, blockAbove } = SHADOW_GATE_THRESHOLDS.cloudSqlFatal + const detail = { count, warnAbove, blockAbove } + if (failed || truncated) return { status: 'unverified', ...detail } + if (count > blockAbove) return { status: 'would-block', ...detail } + if (count > warnAbove) return { status: 'warn', ...detail } + return { status: 'pass', ...detail } +} + +export function combineVerdict(checks) { + const statuses = Object.values(checks).map((check) => check.status) + if (statuses.includes('would-block')) return VERDICTS.WOULD_BLOCK + if (statuses.includes('unverified') || statuses.includes('warn')) return VERDICTS.WARN + return VERDICTS.PASS +} + +export function renderStepSummary(report) { + const rows = Object.entries(report.checks).map(([name, check]) => { + const numbers = Object.entries(check) + .filter(([key, value]) => key !== 'status' && value !== null && typeof value !== 'object') + .map(([key, value]) => `${key}=${value}`) + .join(', ') + return `| ${name} | ${check.status} | ${numbers} |` + }) + return [ + `## Shadow health gate (report only): ${report.verdict}`, + '', + `Cell \`${report.cellId}\`, window ${report.window.startedAt} to ${report.window.endedAt}`, + `(start taken from: ${report.window.startedFrom}).`, + 'This gate never fails the job. Compare its verdict with the operator call for this cell.', + '', + '| check | status | numbers |', + '| --- | --- | --- |', + ...rows, + '' + ].join('\n') +} diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.mjs new file mode 100644 index 000000000000..983e9761a269 --- /dev/null +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// Post-wave shadow health gate for a same-cap cell roll. Reads exactly the oracles an operator +// reads by hand today, writes a PASS / WARN / WOULD_BLOCK verdict with its numbers to a JSON +// artifact and the step summary, and always exits 0 on a verdict: this runs in report-only mode so +// its calls can be compared with the operator's over a full roll before it is allowed to block. +// +// Every filter is built from validated, pattern-pinned inputs and handed to gcloud as argv, never +// through a shell. + +import { execFile } from 'node:child_process' +import { appendFile, writeFile } from 'node:fs/promises' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { + BASELINE_OFFSET_HOURS, + ENTRY_LIMIT, + FLEET_POOL_CELL_IDS, + SHADOW_GATE_THRESHOLDS, + combineVerdict, + countByMinute, + formatTimestamp, + judgeCellServing, + judgeCloudSqlFatal, + judgeDirector503, + judgePool, + renderStepSummary, + resolveWindow, + shiftWindow, + splitWindow +} from './relay-same-cap-shadow-gate-verdict.mjs' + +const execFileAsync = promisify(execFile) + +const CELL_ID = /^production-gce-c[1-9][0-9]*$/ +const CELL_HOST = /^c[1-9][0-9]*\.relay\.onorca\.dev$/ +const PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/ +const SERVICE_NAME = /^[a-z][a-z0-9-]{0,62}$/ + +export const READ_ATTEMPTS = 3 +const READ_RETRY_DELAY_MS = 5000 +const READ_TIMEOUT_MS = SHADOW_GATE_THRESHOLDS.readTimeoutMs +const OVERALL_DEADLINE_MS = SHADOW_GATE_THRESHOLDS.overallDeadlineMs +// json(timestamp) over a busy minute is a few hundred KB; leave room for the widest sub-window. +const READ_MAX_BUFFER_BYTES = 256 * 1024 * 1024 + +export function parseShadowGateArguments(argv) { + const values = new Map() + for (let index = 0; index < argv.length; index += 2) { + if (!argv[index].startsWith('--')) throw new Error(`expected a flag, got ${argv[index]}`) + values.set(argv[index].slice(2), argv[index + 1]) + } + const required = (name, pattern) => { + const value = values.get(name) ?? '' + if (!pattern.test(value)) throw new Error(`--${name} is not acceptable: ${value}`) + return value + } + const config = { + cellId: required('cell-id', CELL_ID), + cellHost: required('cell-host', CELL_HOST), + projectId: required('project-id', PROJECT_ID), + directorService: required('director-service', SERVICE_NAME), + drainStartedAt: values.get('drain-started-at') || '', + // The listener lands while the MIG is still converging, so the boot search has to open at the + // apply's start; a bound taken at its completion is already past the announcement it looks for. + applyStartedAt: values.get('apply-started-at') || '', + applyCompletedAt: values.get('apply-completed-at') || '', + verifyEndedAt: values.get('verify-ended-at') || '', + outputFile: values.get('output-file') || '', + summaryFile: values.get('summary-file') || '' + } + if (!config.cellHost.startsWith(`${config.cellId.replace('production-gce-', '')}.`)) { + throw new Error(`--cell-host ${config.cellHost} is not the host of ${config.cellId}`) + } + if (!config.outputFile) throw new Error('--output-file is required') + return config +} + +function timestampBounds({ startedAt, endedAt }) { + return `timestamp>="${formatTimestamp(startedAt)}" AND timestamp<"${formatTimestamp(endedAt)}"` +} + +/** + * One bounded `gcloud logging read`. A read that cannot complete is reported as failed rather than + * thrown: a missing oracle must surface as an unverified check, not as a crashed gate. + */ +async function readLogEntries(reader, { filter, projection, limit = ENTRY_LIMIT }) { + const args = [ + 'logging', 'read', filter, + '--project', reader.projectId, + '--format', projection, + '--limit', String(limit), + '--order', 'desc' + ] + let lastError + for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt += 1) { + // Every remaining read short-circuits once the budget is gone, so the gate always reaches a + // verdict instead of being killed part-way through with nothing written. + const remainingMs = reader.deadlineAt - reader.now() + if (remainingMs <= 0) { + return { entries: [], failed: true, error: 'shadow gate read deadline exceeded' } + } + try { + const timeoutMs = Math.min(reader.readTimeoutMs, remainingMs) + const { stdout } = await reader.runGcloud(args, { timeoutMs }) + return { entries: JSON.parse(stdout || '[]'), failed: false } + } catch (error) { + lastError = error + if (attempt < READ_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, reader.retryDelayMs)) + } + } + } + return { entries: [], failed: true, error: String(lastError?.message ?? lastError) } +} + +async function readTimestampsOverWindow(reader, { filter, window }) { + const reads = [] + for (const subWindow of splitWindow(window)) { + const read = await readLogEntries(reader, { + filter: `${filter} AND ${timestampBounds(subWindow)}`, + projection: 'json(timestamp)' + }) + reads.push({ + failed: read.failed, + timestamps: read.entries.map((entry) => entry.timestamp) + }) + } + return countByMinute(reads) +} + +function directorFilter({ directorService }) { + return `resource.type="cloud_run_revision"` + + ` AND resource.labels.service_name="${directorService}"` + + ` AND httpRequest.status=503` +} + +// Cells log through the COS container agent, so the text lives in jsonPayload.message; a +// textPayload filter matches nothing here and returns zero without saying so. +const CELL_LOG_SCOPE = 'resource.type="gce_instance" AND logName:"cos_containers"' + +async function readDirector503(reader, { config, window }) { + const filter = directorFilter(config) + const observed = await readTimestampsOverWindow(reader, { filter, window }) + const baselines = [] + for (const hours of BASELINE_OFFSET_HOURS) { + const counts = await readTimestampsOverWindow(reader, { + filter, + window: shiftWindow(window, hours) + }) + baselines.push({ label: `${hours}h-earlier`, ...counts }) + } + return judgeDirector503({ observed, baselines }) +} + +/** + * The cell's new container. The listener announcement after the apply identifies both that the + * cell is serving and which instance it is serving on; crashes are then scoped to that instance, + * because instance_id is stable across a container restart and is the only cell label these + * entries carry. + */ +async function readCellServing(reader, { config, window, searchFrom, expectBoot }) { + const listening = await readLogEntries(reader, { + filter: `${CELL_LOG_SCOPE}` + + ` AND jsonPayload.message:"listening on https://${config.cellHost}"` + + ` AND ${timestampBounds({ startedAt: searchFrom, endedAt: window.endedAt })}`, + projection: 'json(timestamp,resource.labels.instance_id)', + limit: 50 + }) + // Newest first: the most recent announcement is the boot this wave produced. + const boot = listening.entries[0] + if (listening.failed || !boot) { + return { + serving: judgeCellServing({ listeningAt: null, read: listening, expectBoot }), + instanceId: null + } + } + const crashes = await readLogEntries(reader, { + filter: `${CELL_LOG_SCOPE}` + + ` AND jsonPayload.message:"throw er"` + + ` AND resource.labels.instance_id="${boot.resource.labels.instance_id}"` + + ` AND ${timestampBounds({ startedAt: searchFrom, endedAt: window.endedAt })}`, + projection: 'json(timestamp)', + limit: 100 + }) + return { + serving: judgeCellServing({ + listeningAt: boot.timestamp, + crashesSinceApply: crashes.entries.length, + read: crashes, + expectBoot + }), + instanceId: boot.resource.labels.instance_id + } +} + +const RUNTIME_METRIC_FIELDS = [ + 'totalConnections', + 'databasePoolWaitersMax', + 'databasePoolWaiting', + 'sqlFailuresDelta', + 'reconnectsDelta' +] + +async function readRuntimeMetrics(reader, { cellId, window }) { + const projection = `json(timestamp,${RUNTIME_METRIC_FIELDS + .map((field) => `jsonPayload.${field}`) + .join(',')})` + const samples = [] + let failed = false + let truncated = false + // Samples land every 30 s, so a 10-minute sub-window holds ~20. A read that comes back at this + // many is not a calm sub-window, it is a truncated one, and its gaps read as recoveries. + const limit = 500 + for (const subWindow of splitWindow(window)) { + const read = await readLogEntries(reader, { + filter: `${CELL_LOG_SCOPE}` + + ` AND jsonPayload.event="orca_relay_runtime_metrics"` + + ` AND jsonPayload.cellId="${cellId}"` + + ` AND ${timestampBounds(subWindow)}`, + projection, + limit + }) + if (read.failed) failed = true + if (read.entries.length >= limit) truncated = true + for (const entry of read.entries) { + samples.push({ timestamp: entry.timestamp, ...entry.jsonPayload }) + } + } + return { samples, failed, truncated } +} + +async function readCloudSqlFatal(reader, { window }) { + const counts = await readTimestampsOverWindow(reader, { + filter: `resource.type="cloudsql_database" AND "FATAL"`, + window + }) + return judgeCloudSqlFatal({ count: counts.total, truncated: counts.truncated }) +} + +export async function evaluateShadowGate(config, { + runGcloud, + retryDelayMs = READ_RETRY_DELAY_MS, + readTimeoutMs = READ_TIMEOUT_MS, + overallDeadlineMs = OVERALL_DEADLINE_MS, + now = Date.now +}) { + const reader = { + runGcloud, + retryDelayMs, + readTimeoutMs, + now, + deadlineAt: now() + overallDeadlineMs, + projectId: config.projectId + } + const window = resolveWindow(config) + // Everything this roll's instance logged, from the moment the apply could first restart it. + const searchFrom = config.applyStartedAt + ? new Date(Date.parse(config.applyStartedAt)) + : window.startedAt + // Serialised on purpose: a burst of concurrent reads is what earns a Logging 429, and a 429 is + // the one failure that comes back as a short answer rather than an error. + const director503 = await readDirector503(reader, { config, window }) + // A fallback window start means neither the drain nor the apply ran, which is the resumed + // rollback that restarts nothing; there is then no boot to find. + const cell = await readCellServing(reader, { + config, + window, + searchFrom, + expectBoot: window.startedFrom !== 'fallback' + }) + const cloudSql = await readCloudSqlFatal(reader, { window }) + const cellMetrics = await readRuntimeMetrics(reader, { cellId: config.cellId, window }) + const checks = { + director503, + cellServing: cell.serving, + cellPool: judgePool({ label: config.cellId, ...cellMetrics }), + cloudSqlFatal: cloudSql + } + for (const fleetCellId of FLEET_POOL_CELL_IDS) { + if (fleetCellId === config.cellId) continue + const metrics = await readRuntimeMetrics(reader, { cellId: fleetCellId, window }) + checks[`fleetPool:${fleetCellId}`] = judgePool({ label: fleetCellId, ...metrics }) + } + return { + reportOnly: true, + cellId: config.cellId, + cellHost: config.cellHost, + cellInstanceId: cell.instanceId, + window: { + startedAt: formatTimestamp(window.startedAt), + endedAt: formatTimestamp(window.endedAt), + startedFrom: window.startedFrom, + // Recorded, not judged: an operator comparing verdicts needs to see how long the apply took + // next to when the cell actually came back. + applyCompletedAt: config.applyCompletedAt || null + }, + verdict: combineVerdict(checks), + checks + } +} + +async function main() { + const config = parseShadowGateArguments(process.argv.slice(2)) + const report = await evaluateShadowGate(config, { + // `timeout` makes Node kill the child itself; continue-on-error bounds the job's outcome but + // not its clock, and a stalled read would otherwise spend the rollout's remaining minutes. + runGcloud: (args, { timeoutMs }) => execFileAsync('gcloud', args, { + maxBuffer: READ_MAX_BUFFER_BYTES, + timeout: timeoutMs, + killSignal: 'SIGKILL' + }) + }) + await writeFile(config.outputFile, `${JSON.stringify(report, null, 2)}\n`) + if (config.summaryFile) await appendFile(config.summaryFile, renderStepSummary(report)) + console.log(JSON.stringify(report, null, 2)) +} + +// Report only: a verdict, including WOULD_BLOCK, is a successful run. Only a crash exits non-zero, +// and the job still runs this step under continue-on-error. +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs new file mode 100644 index 000000000000..a206b7f80dfa --- /dev/null +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs @@ -0,0 +1,547 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readRelayWorkflow } from './relay-repository.mjs' +import { + READ_ATTEMPTS, + evaluateShadowGate, + parseShadowGateArguments +} from './relay-same-cap-shadow-gate.mjs' +import { + ENTRY_LIMIT, + FLEET_POOL_CELL_IDS, + SHADOW_GATE_THRESHOLDS, + SUB_WINDOW_MINUTES, + combineVerdict, + countByMinute, + formatTimestamp, + judgeCellServing, + judgeCloudSqlFatal, + judgeDirector503, + judgePool, + longestRunAtOrAbove, + renderStepSummary, + resolveWindow, + shiftWindow, + splitWindow +} from './relay-same-cap-shadow-gate-verdict.mjs' + +const ARGV = [ + '--cell-id', 'production-gce-c28', + '--cell-host', 'c28.relay.onorca.dev', + '--project-id', 'onorca-cloud', + '--director-service', 'orca-cloud-relay', + '--drain-started-at', '2026-09-20T20:00:00Z', + '--apply-started-at', '2026-09-20T20:15:00Z', + '--apply-completed-at', '2026-09-20T20:19:30Z', + '--verify-ended-at', '2026-09-20T20:30:00Z', + '--output-file', '/tmp/shadow.json' +] + +function minuteOfTimestamps(minute, count) { + return Array.from( + { length: count }, + (_, index) => `${minute}:${String(index % 60).padStart(2, '0')}Z` + ) +} + +test('binds every gcloud input to a pinned pattern and to one cell', () => { + assert.equal(parseShadowGateArguments(ARGV).cellId, 'production-gce-c28') + // A filter is a string; anything that could steer one has to be refused before it is built. + assert.throws(() => parseShadowGateArguments(ARGV.with(1, 'production-gce-c28" OR "x'))) + assert.throws(() => parseShadowGateArguments(ARGV.with(3, 'evil.example.test'))) + assert.throws(() => parseShadowGateArguments(ARGV.with(5, 'Onorca Cloud'))) + assert.throws(() => parseShadowGateArguments(ARGV.with(7, 'orca cloud relay'))) + // Host and cell id must name the same cell, or the serving check reads a neighbour. + assert.throws(() => parseShadowGateArguments(ARGV.with(3, 'c29.relay.onorca.dev'))) + // A run with nowhere to write its verdict is not a report-only run, it is a silent one. + assert.throws(() => parseShadowGateArguments(ARGV.slice(0, 16))) +}) + +test('the window runs from drain start to verify end, with named fallbacks', () => { + const full = resolveWindow({ + drainStartedAt: '2026-09-20T20:00:00Z', + applyStartedAt: '2026-09-20T20:15:00Z', + verifyEndedAt: '2026-09-20T20:30:00Z' + }) + assert.equal(formatTimestamp(full.startedAt), '2026-09-20T20:00:00Z') + assert.equal(full.startedFrom, 'drain') + // A resumed rollback never drains, so the apply stands in for the start. + assert.equal(resolveWindow({ + applyStartedAt: '2026-09-20T20:15:00Z', + verifyEndedAt: '2026-09-20T20:30:00Z' + }).startedFrom, 'apply') + assert.equal(formatTimestamp(resolveWindow({ + verifyEndedAt: '2026-09-20T20:30:00Z' + }).startedAt), '2026-09-20T20:00:00Z') + assert.throws(() => resolveWindow({ + drainStartedAt: '2026-09-20T20:30:00Z', + verifyEndedAt: '2026-09-20T20:30:00Z' + })) + assert.throws(() => resolveWindow({ verifyEndedAt: 'not-a-time' })) +}) + +test('reads are split into sub-windows no longer than the truncation bound', () => { + const windows = splitWindow(resolveWindow({ + drainStartedAt: '2026-09-20T20:00:00Z', + verifyEndedAt: '2026-09-20T20:47:00Z' + })) + assert.equal(windows.length, 5) + for (const window of windows) { + const minutes = (window.endedAt - window.startedAt) / 60_000 + assert.ok(minutes > 0 && minutes <= SUB_WINDOW_MINUTES, `${minutes} minutes`) + } + assert.equal(formatTimestamp(windows.at(-1).endedAt), '2026-09-20T20:47:00Z') + const baseline = shiftWindow(windows[0], 24) + assert.equal(formatTimestamp(baseline.startedAt), '2026-09-19T20:00:00Z') +}) + +test('a sub-window that came back at the entry limit is truncated, never a count', () => { + const truncated = countByMinute([ + { timestamps: minuteOfTimestamps('2026-09-19T15:34', ENTRY_LIMIT) } + ]) + assert.equal(truncated.truncated, true) + const failed = countByMinute([{ failed: true, timestamps: [] }]) + assert.equal(failed.truncated, true) + const counted = countByMinute([ + { timestamps: minuteOfTimestamps('2026-09-19T15:34', 4722) }, + { timestamps: minuteOfTimestamps('2026-09-19T15:33', 278) } + ]) + assert.deepEqual( + { + peak: counted.peak, + peakMinute: counted.peakMinute, + total: counted.total, + truncated: counted.truncated + }, + { peak: 4722, peakMinute: '2026-09-19T15:34', total: 5000, truncated: false } + ) +}) + +test('director 503s are judged against the busier baseline, not a fixed rate', () => { + const baselines = [ + { label: '24h-earlier', peak: 48, total: 80, truncated: false }, + { label: '48h-earlier', peak: 69, total: 100, truncated: false } + ] + // The 2026-09-19 c28 wave: 4722/min against 48 and 69/min baselines. + assert.equal(judgeDirector503({ + observed: { peak: 4722, peakMinute: '2026-09-19T15:34', total: 5000, truncated: false }, + baselines + }).status, 'would-block') + // The false positive a literal rule produced: a US ramp at 71/min over a 20-60/min baseline. + assert.equal(judgeDirector503({ + observed: { peak: 71, peakMinute: '2026-09-18T01:10', total: 300, truncated: false }, + baselines: [ + { label: '24h-earlier', peak: 60, total: 400, truncated: false }, + { label: '48h-earlier', peak: 20, total: 90, truncated: false } + ] + }).status, 'pass') + // A truncated read cannot settle to pass, however calm its visible counts are. + assert.equal(judgeDirector503({ + observed: { peak: 3, total: 3, truncated: true }, + baselines + }).status, 'unverified') + assert.equal(judgeDirector503({ + observed: { peak: 3, total: 3, truncated: false }, + baselines: [baselines[0], { ...baselines[1], truncated: true }] + }).status, 'unverified') +}) + +test('the cell has to announce its listener and stay up across the whole apply', () => { + assert.equal(judgeCellServing({ + listeningAt: '2026-09-20T20:18:27Z', + crashesSinceApply: 0 + }).status, 'pass') + assert.equal(judgeCellServing({ listeningAt: null }).status, 'would-block') + // A resumed rollback restarts nothing, so there is no boot to find and silence proves nothing. + assert.equal(judgeCellServing({ listeningAt: null, expectBoot: false }).status, 'unverified') + assert.equal(judgeCellServing({ + listeningAt: '2026-09-20T20:18:27Z', + crashesSinceApply: 1 + }).status, 'would-block') + assert.equal(judgeCellServing({ + listeningAt: null, + read: { failed: true } + }).status, 'unverified') +}) + +test('pool pressure blocks only when it persists across consecutive samples', () => { + assert.equal(longestRunAtOrAbove([10, 60, 10, 60, 60, 60, 10], 50), 3) + const burst = judgePool({ + label: 'production-gce-c27', + // The single-sample waiters=71 that a literal rule called an outage. + samples: [ + { databasePoolWaitersMax: 12 }, + { databasePoolWaitersMax: 71 }, + { databasePoolWaitersMax: 9 } + ] + }) + assert.equal(burst.status, 'warn') + assert.equal(burst.consecutiveSamplesOverWaitersThreshold, 1) + assert.equal(judgePool({ + label: 'production-gce-c28', + samples: [ + { databasePoolWaitersMax: 148 }, + { databasePoolWaitersMax: 125 }, + { databasePoolWaitersMax: 154 } + ] + }).status, 'would-block') + assert.equal(judgePool({ + label: 'production-gce-c28', + samples: [{ databasePoolWaitersMax: 2, sqlFailuresDelta: 489 }] + }).status, 'would-block') + assert.equal(judgePool({ + label: 'production-gce-c29', + samples: [{ databasePoolWaitersMax: 3, sqlFailuresDelta: 0, totalConnections: 500 }] + }).status, 'pass') + // No samples at all is silence, not health. + assert.equal(judgePool({ label: 'production-gce-c29', samples: [] }).status, 'unverified') + assert.equal(judgePool({ + label: 'production-gce-c29', + samples: [{ databasePoolWaitersMax: 1 }], + failed: true + }).status, 'unverified') + // A truncated sample run has holes, and a hole reads to the run rule as a recovery. + assert.equal(judgePool({ + label: 'production-gce-c29', + samples: [{ databasePoolWaitersMax: 1 }], + truncated: true + }).status, 'unverified') +}) + +test('Cloud SQL FATALs warn from the first one and block on a run of them', () => { + assert.equal(judgeCloudSqlFatal({ count: 0 }).status, 'pass') + assert.equal(judgeCloudSqlFatal({ count: 1 }).status, 'warn') + assert.equal(judgeCloudSqlFatal({ count: 21 }).status, 'would-block') + assert.equal(judgeCloudSqlFatal({ count: 0, truncated: true }).status, 'unverified') +}) + +test('the verdict is the worst check, and an unverified read never reads as PASS', () => { + assert.equal(combineVerdict({ a: { status: 'pass' }, b: { status: 'pass' } }), 'PASS') + assert.equal(combineVerdict({ a: { status: 'pass' }, b: { status: 'warn' } }), 'WARN') + assert.equal(combineVerdict({ a: { status: 'pass' }, b: { status: 'unverified' } }), 'WARN') + assert.equal( + combineVerdict({ a: { status: 'would-block' }, b: { status: 'unverified' } }), + 'WOULD_BLOCK' + ) +}) + +// The step that owns each stamp, so a stamp's presence is judged where it has to be written. +const STAMP_STEPS = { + drain: '- name: Reversibly isolate and drain only the selected cell', + apply: '- name: Apply only the selected same-cap template and MIG', + 'verify-target': '- name: Verify new incarnation, exact image, protocol, and durable safety' +} + +// One step's own lines: from its marker to the next sibling step at the same indent. +function stepBody(workflow, marker) { + const start = workflow.indexOf(marker) + assert.notEqual(start, -1, `the job no longer has a step named ${marker}`) + const next = workflow.indexOf('\n - ', start + marker.length) + return workflow.slice(start, next === -1 ? undefined : next) +} + +const C28_INSTANCE = '5031087219978409220' + +// Runtime-metrics samples at the 30 s cadence production emits them at, unless a case needs +// enough of them inside one sub-window to reach the read's limit. +function metricSamples({ cellId, from, count, payload = {}, intervalMs = 30_000 }) { + return Array.from({ length: count }, (_, index) => ({ + matches: ['orca_relay_runtime_metrics', `jsonPayload.cellId="${cellId}"`], + timestamp: new Date(Date.parse(from) + index * intervalMs).toISOString(), + payload: { + totalConnections: 857, + databasePoolWaitersMax: 4, + databasePoolWaiting: 1, + sqlFailuresDelta: 0, + reconnectsDelta: 0, + ...payload + } + })) +} + +// The exact entry shapes production returned for c28 on 2026-09-20: the crash at 20:18:10Z and +// the listener at 20:18:27Z, both on instance 5031087219978409220. +function productionLikeEntries() { + return [ + { + matches: ['listening on https://c28.relay.onorca.dev'], + timestamp: '2026-09-20T20:18:27.470301969Z', + instanceId: C28_INSTANCE + }, + ...metricSamples({ cellId: 'production-gce-c28', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c27', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c29', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }) + ] +} + +/** + * A gcloud seam that honours the filter it is given: its timestamp bounds, its instance-id scope, + * the `--limit`, and the newest-first order. A fake that ignored the bounds would let a + * wrongly-bounded query pass, which is exactly the bug class these tests exist to catch. + */ +function gcloudSeam(entries = productionLikeEntries()) { + const calls = [] + return { + calls, + retryDelayMs: 0, + runGcloud: async (args, options) => { + const filter = args[2] + const limit = Number(args[args.indexOf('--limit') + 1]) + calls.push({ filter, limit, options }) + const startedAt = Date.parse(/timestamp>="([^"]+)"/.exec(filter)[1]) + const endedAt = Date.parse(/timestamp<"([^"]+)"/.exec(filter)[1]) + const instanceId = /resource\.labels\.instance_id="([^"]+)"/.exec(filter)?.[1] + const matched = entries.filter((entry) => { + const at = Date.parse(entry.timestamp) + if (at < startedAt || at >= endedAt) return false + if (instanceId && entry.instanceId !== instanceId) return false + return entry.matches.every((needle) => filter.includes(needle)) + }) + matched.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)) + return { + stdout: JSON.stringify(matched.slice(0, limit).map((entry) => ({ + timestamp: entry.timestamp, + ...(entry.instanceId ? { resource: { labels: { instance_id: entry.instanceId } } } : {}), + ...(entry.payload ? { jsonPayload: entry.payload } : {}) + }))) + } + } + } +} + +test('reads every promoted asia-east2 cell as fleet pool, C30 included', () => { + assert.deepEqual(FLEET_POOL_CELL_IDS, [ + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30' + ]) +}) + +test('a healthy roll reads as PASS and names the instance it proved serving', async () => { + const seam = gcloudSeam() + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.equal(report.verdict, 'PASS') + assert.equal(report.reportOnly, true) + assert.equal(report.cellInstanceId, C28_INSTANCE) + assert.equal(report.window.startedFrom, 'drain') + assert.equal(report.window.applyCompletedAt, '2026-09-20T20:19:30Z') + assert.deepEqual(Object.keys(report.checks).sort(), [ + 'cellPool', + 'cellServing', + 'cloudSqlFatal', + 'director503', + 'fleetPool:production-gce-c27', + 'fleetPool:production-gce-c29', + 'fleetPool:production-gce-c30' + ]) + // Every read carries explicit bounds: --freshness does not bind on these logs. + for (const { filter } of seam.calls) { + assert.match(filter, /timestamp>="[^"]+" AND timestamp<"[^"]+"/) + } + // Cell text lives in jsonPayload.message; a textPayload filter matches nothing and says so. + assert.equal(seam.calls.some(({ filter }) => filter.includes('textPayload')), false) + assert.match(renderStepSummary(report), /Shadow health gate \(report only\): PASS/) +}) + +// The listener lands while the MIG is still converging, so a boot search opening at the apply's +// completion finds nothing and calls a healthy roll a failure. +test('the boot search opens at the apply start, not at its completion', async () => { + const seam = gcloudSeam() + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.equal(report.checks.cellServing.status, 'pass') + assert.equal(report.checks.cellServing.listeningAt, '2026-09-20T20:18:27.470301969Z') + const listenerRead = seam.calls.find(({ filter }) => filter.includes('listening on https://')) + assert.match(listenerRead.filter, /timestamp>="2026-09-20T20:15:00Z"/) + // The listener at 20:18:27 sits after the apply start and before its completion at 20:19:30, + // so a completion-bounded search would have missed it entirely. + assert.ok(Date.parse('2026-09-20T20:18:27.470301969Z') < Date.parse('2026-09-20T20:19:30Z')) +}) + +// A crash-restart loop ends with a listener announcement that looks like a clean boot. Counting +// crashes only after the last announcement erases the loop that produced it. +test('a crash before the final listener still counts against the roll', async () => { + const seam = gcloudSeam([ + ...productionLikeEntries(), + { + matches: ['throw er'], + timestamp: '2026-09-20T20:18:10.651702662Z', + instanceId: C28_INSTANCE + } + ]) + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.equal(report.checks.cellServing.crashesSinceApply, 1) + assert.equal(report.checks.cellServing.status, 'would-block') + assert.equal(report.verdict, 'WOULD_BLOCK') + const crashRead = seam.calls.find(({ filter }) => filter.includes('throw er')) + // Bounded at the apply start, and still scoped to the instance the listener identified. + assert.match(crashRead.filter, /timestamp>="2026-09-20T20:15:00Z"/) + assert.match(crashRead.filter, new RegExp(`resource\\.labels\\.instance_id="${C28_INSTANCE}"`)) +}) + +test('a crash on a neighbouring instance is not charged to this cell', async () => { + const seam = gcloudSeam([ + ...productionLikeEntries(), + { matches: ['throw er'], timestamp: '2026-09-20T20:18:10Z', instanceId: '9999999999999999999' } + ]) + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.equal(report.checks.cellServing.crashesSinceApply, 0) + assert.equal(report.checks.cellServing.status, 'pass') +}) + +// A sample run returned at the read's limit has holes, and the consecutive-sample rule reads a +// hole as a recovery, so it must not be judged as though it were complete. +test('a runtime-metrics read at its limit is unverified, not a calm cell', async () => { + const seam = gcloudSeam([ + ...productionLikeEntries(), + // 600 samples packed into the first sub-window, past the 500-entry read limit. + ...metricSamples({ + cellId: 'production-gce-c28', + from: '2026-09-20T20:00:00Z', + count: 600, + intervalMs: 500, + payload: { databasePoolWaitersMax: 1 } + }) + ]) + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.equal(report.checks.cellPool.status, 'unverified') + assert.equal(report.checks.cellPool.truncated, true) + // The neighbours were read normally, so only the truncated cell is unverified. + assert.equal(report.checks['fleetPool:production-gce-c27'].status, 'pass') + assert.equal(report.verdict, 'WARN') +}) + +test('a resume, which restarts nothing, does not read a missing boot as a failure', async () => { + const resumed = ARGV.with(9, '').with(11, '').with(13, '') + const seam = gcloudSeam(productionLikeEntries().filter( + (entry) => !entry.matches[0].startsWith('listening') + )) + const report = await evaluateShadowGate(parseShadowGateArguments(resumed), seam) + assert.equal(report.window.startedFrom, 'fallback') + assert.equal(report.checks.cellServing.status, 'unverified') + assert.equal(report.verdict, 'WARN') +}) + +test('a gcloud read that never completes is unverified, not a crashed gate', async () => { + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), { + retryDelayMs: 0, + runGcloud: async () => { throw new Error('PERMISSION_DENIED') } + }) + assert.equal(report.verdict, 'WARN') + assert.equal(report.checks.director503.status, 'unverified') + assert.equal(report.checks.cellServing.status, 'unverified') +}) + +// continue-on-error bounds the job's outcome but not its clock; an unbounded read could spend the +// rollout's remaining minutes before the job's own timeout noticed. +test('every read is given a bounded timeout, and a timed-out read is just a failed read', async () => { + const seam = gcloudSeam() + await evaluateShadowGate(parseShadowGateArguments(ARGV), seam) + assert.ok(seam.calls.length > 0) + for (const { options } of seam.calls) { + assert.equal(options.timeoutMs, SHADOW_GATE_THRESHOLDS.readTimeoutMs) + assert.ok(options.timeoutMs > 0 && options.timeoutMs <= 120_000) + } + const timedOut = await evaluateShadowGate(parseShadowGateArguments(ARGV), { + retryDelayMs: 0, + runGcloud: async () => { throw Object.assign(new Error('ETIMEDOUT'), { killed: true }) } + }) + assert.equal(timedOut.checks.director503.status, 'unverified') + assert.equal(timedOut.verdict, 'WARN') +}) + +// The reads are serialised, so the cost of a failure that makes every one of them spend its full +// retry budget scales with the window. The deadline is what turns that into a verdict rather than +// a cancelled job, which would take every later cell in the wave with it. +test('the gate stops reading at its own deadline and still reports a verdict', async () => { + const seam = gcloudSeam() + // A clock where every read costs its whole retry budget, which is the case the deadline exists + // for: an expired credential or a Logging 429 storm answers nothing, slowly, every time. + let elapsedMs = 0 + const report = await evaluateShadowGate(parseShadowGateArguments(ARGV), { + ...seam, + now: () => { + elapsedMs += SHADOW_GATE_THRESHOLDS.readTimeoutMs * READ_ATTEMPTS + return elapsedMs + } + }) + // Everything past the deadline is skipped rather than attempted, so the gate cannot outlive it. + assert.ok(seam.calls.length > 0, 'the gate must still attempt reads inside its budget') + assert.ok( + seam.calls.length * SHADOW_GATE_THRESHOLDS.readTimeoutMs * READ_ATTEMPTS <= + SHADOW_GATE_THRESHOLDS.overallDeadlineMs, + 'the gate read past its own deadline' + ) + // A verdict, not a crash: a skipped read is an unverified check, which can never read as PASS. + assert.equal(report.reportOnly, true) + assert.equal(report.verdict, 'WARN') + assert.equal(report.checks.cellServing.status, 'unverified') + // No read is ever given more time than the budget still has left. + for (const { options } of seam.calls) { + assert.ok(options.timeoutMs > 0) + assert.ok(options.timeoutMs <= SHADOW_GATE_THRESHOLDS.readTimeoutMs) + } +}) + +test('the job runs the gate report-only, after verification, and uploads its artifact', () => { + const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') + const gate = workflow.slice(workflow.indexOf('- name: Shadow health gate (report only)')) + assert.notEqual(gate, '') + // Two independent guarantees that no verdict can fail a cell: the step's own exit code and this. + assert.match(gate.slice(0, gate.indexOf('run:')), /continue-on-error: true/) + assert.match(gate, /relay-same-cap-shadow-gate\.mjs/) + // The gate and its upload must be bounded in time as well as in outcome: a step that runs past + // the job's timeout-minutes gets the job cancelled, and cancellation stops the whole wave. + const gateHeader = gate.slice(0, gate.indexOf('run:')) + assert.match(gateHeader, /timeout-minutes: (\d+)/) + const stepTimeoutMinutes = Number(/timeout-minutes: (\d+)/.exec(gateHeader)[1]) + assert.equal(stepTimeoutMinutes, 8) + // The script has to settle on its own before the runner kills it, or the artifact is never + // written and the step reports nothing at all. + assert.ok( + SHADOW_GATE_THRESHOLDS.overallDeadlineMs < stepTimeoutMinutes * 60_000, + 'the gate deadline must leave the step time to write its verdict' + ) + const upload = workflow.slice(workflow.indexOf('- name: Publish the shadow health gate verdict')) + assert.match(upload.slice(0, upload.indexOf('uses:')), /timeout-minutes: 2/) + assert.match( + workflow, + /name: relay-same-cap-shadow-gate-\$\{\{ inputs\.target-cell-id \}\}-\$\{\{ github\.run_id \}\}\.json/ + ) + // The gate is judged over the wave it just ran, so the job has to stamp its own steps, and the + // stamps reach the script through the environment rather than being expanded into its shell. + for (const [step, output] of [ + ['drain', 'drain-started-at'], + ['apply', 'apply-started-at'], + ['apply', 'apply-completed-at'], + ['verify-target', 'verify-ended-at'] + ]) { + // Scoped to the step that owns the stamp: a stamp written anywhere else in the job would + // still satisfy a whole-file match while recording the wrong instant. + assert.match( + stepBody(workflow, STAMP_STEPS[step]), + new RegExp(`${output}=\\$\\(date -u \\+%FT%TZ\\)`), + `${output} must be stamped inside the ${step} step` + ) + assert.match(gate, new RegExp(`\\$\\{\\{ steps\\.${step}\\.outputs\\.${output} \\}\\}`)) + assert.match(gate, new RegExp(`--${output} "\\$\\{[A-Z_]+\\}"`)) + } + // The apply-start stamp has to precede the operation that can restart the instance, or the + // listener it bounds the search by has already happened. Presence is asserted before order, + // because indexOf answers -1 for an absent stamp and -1 precedes everything. + const applyStep = stepBody(workflow, STAMP_STEPS.apply) + const stampedAt = applyStep.indexOf('apply-started-at=') + const appliedAt = applyStep.indexOf('terraform -chdir=infra/terraform apply') + assert.notEqual(stampedAt, -1, 'the apply step does not stamp apply-started-at at all') + assert.notEqual(appliedAt, -1, 'the apply step no longer runs terraform apply') + assert.ok(stampedAt < appliedAt, 'apply-started-at must be stamped before terraform apply') + // Verification has to have happened first, or the gate judges a cell nothing checked, and the + // restore too, so reading logs never holds the cell out of admission for longer than today. + for (const earlier of [ + '- name: Verify new incarnation, exact image, protocol, and durable safety', + '- name: Restore only the verified selected cell to its entry admission' + ]) { + assert.ok( + workflow.indexOf(earlier) < workflow.indexOf('- name: Shadow health gate (report only)'), + earlier + ) + } +}) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 0e777b06c381..67f30aa6d2b8 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -38,6 +38,8 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca // Scoped to the Asia cells by name: the production capacity cells now serve this digest too, // so a file-wide count no longer isolates Asia. const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] +// C30 launches after the launch cells rolled, so it pins the director's digest instead. +const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) @@ -48,12 +50,13 @@ function cellBlock(tfvars, cellId) { const productionCell = (cellId) => cellBlock(productionTfvars, cellId) // Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. -test('pins staging C4 and all production Asia cells to the same launch image', () => { +test('pins staging C4 and the launch Asia cells to one image, and C30 to the director image', () => { assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } assert.match(recoveryWorkflow, new RegExp(`TARGET_IMAGE_DIGEST: sha256:${launchDigest}`)) + assert.match(productionCell('production-gce-c30'), new RegExp(`relay@sha256:${c30Digest}"`)) }) test('refreshes only empty staging C4 through the trusted capacity identity', () => { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index 953a214156e7..0a7027718522 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -2,24 +2,40 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const REGION = 'asia-east2' +// Mirrors local.relay_gce_topology in infra/terraform/relay-gce-cells.tf, which Terraform +// cannot export to JS; the census test below the validator equates the two by reading the +// .tf source, so this pair and the topology `check` assert cannot drift apart. +export const RELAY_CELL_BACKEND_TIMEOUT_SECONDS = 86_400 +export const RELAY_CELL_CONNECTION_DRAIN_SECONDS = 60 +// Not a topology local: the default of var.relay_gce_cell_log_sample_rate, which no +// environment overrides. The same census test equates it with variables.tf. +export const RELAY_CELL_LOG_SAMPLE_RATE = 1 const CELL_SHAPES = { production: { domain: 'relay.onorca.dev', project: 'onorca-cloud', + databasePoolMax: '16', cells: { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', - 'production-gce-c29': 'asia-east2-c' - } + 'production-gce-c29': 'asia-east2-c', + 'production-gce-c30': 'asia-east2-a' + }, + waves: [ + ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], + ['production-gce-c30'] + ] }, staging: { domain: 'relay-staging.onorca.dev', project: 'onorca-cloud-staging', - cells: { 'staging-gce-c4': 'asia-east2-a' } + databasePoolMax: '10', + cells: { 'staging-gce-c4': 'asia-east2-a' }, + waves: [['staging-gce-c4']] } } -function parseArguments(argv) { +export function parseRelayAsiaTopologyPlanArguments(argv) { const values = {} for (let index = 0; index < argv.length; index += 2) { const key = argv[index] @@ -32,8 +48,11 @@ function parseArguments(argv) { } if (!(values.environment in CELL_SHAPES)) throw new Error('--environment is invalid') const cells = values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean) - const expectedCells = Object.keys(CELL_SHAPES[values.environment].cells) - if (new Set(cells).size !== cells.length || JSON.stringify(cells.sort()) !== JSON.stringify(expectedCells.sort())) { + const sorted = JSON.stringify([...cells].sort()) + if ( + new Set(cells).size !== cells.length || + !CELL_SHAPES[values.environment].waves.some((wave) => JSON.stringify([...wave].sort()) === sorted) + ) { throw new Error('--cell-ids must be the exact reviewed Asia topology set') } if (values.region !== REGION) throw new Error('--region must be asia-east2') @@ -82,7 +101,8 @@ function requireCellTemplate(change, config, cellId) { (after?.network_interface?.[0]?.access_config?.length ?? 0) !== 0 || startupValue(script, 'ORCA_RELAY_REGION') !== REGION || startupValue(script, 'ORCA_RELAY_CELL_CAPACITY') !== '6000' || - startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !== '10' || + startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !== + CELL_SHAPES[config.environment].databasePoolMax || startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP') !== '3000' || startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND') !== '60' || startupValue(script, 'ORCA_RELAY_IMAGE_DIGEST') !== config.image.split('@')[1] || @@ -117,8 +137,8 @@ function requireCellBackend(change, config, cellId) { const hostname = cellId.split('-').at(-1) const name = `${relayGceName(config.environment)}-${hostname}` if ( - after?.timeout_sec !== 86_400 || - after?.connection_draining_timeout_sec !== 300 || + after?.timeout_sec !== RELAY_CELL_BACKEND_TIMEOUT_SECONDS || + after?.connection_draining_timeout_sec !== RELAY_CELL_CONNECTION_DRAIN_SECONDS || after?.load_balancing_scheme !== 'EXTERNAL_MANAGED' || after?.protocol !== 'HTTP' || after?.port_name !== 'relay' || @@ -280,6 +300,12 @@ export function validateRelayAsiaTopologyPlan(plan, config) { (action) => action === 'no-op' || action === 'read' )) for (const change of changes) { + const cellId = /^google_compute_(?:instance_template|instance_group_manager|backend_service)\.relay_gce_cell\["([^"]+)"\]$/ + .exec(change.address)?.[1] + // Usually a stale committed image; the workflow overlays live images so this stays empty. + if (cellId && !config.cells.includes(cellId)) { + throw new Error(`${change.address} changes a live cell outside the planned wave`) + } const allowedActions = required.get(change.address) if (!allowedActions || !allowedActions.some((expected) => sameActions(change, expected))) { throw new Error(`${change.address} has an unreviewed topology action`) @@ -289,7 +315,7 @@ export function validateRelayAsiaTopologyPlan(plan, config) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const config = parseArguments(process.argv.slice(2)) + const config = parseRelayAsiaTopologyPlanArguments(process.argv.slice(2)) const plan = JSON.parse(readFileSync(config.planJson, 'utf8')) console.log(JSON.stringify(validateRelayAsiaTopologyPlan(plan, config))) } diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index 154030489f5b..77071a94fcee 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { validateRelayAsiaTopologyPlan } from './validate-relay-asia-topology-plan.mjs' +import { + RELAY_CELL_BACKEND_TIMEOUT_SECONDS, + RELAY_CELL_CONNECTION_DRAIN_SECONDS, + RELAY_CELL_LOG_SAMPLE_RATE, + parseRelayAsiaTopologyPlanArguments, + validateRelayAsiaTopologyPlan +} from './validate-relay-asia-topology-plan.mjs' const image = `us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:${'a'.repeat(64)}` const config = { environment: 'staging', cells: ['staging-gce-c4'], image } @@ -49,7 +56,8 @@ const resources = [ update_policy: [{ replacement_method: 'RECREATE', max_surge_fixed: 0, max_unavailable_fixed: 1 }] }), create('google_compute_backend_service.relay_gce_cell["staging-gce-c4"]', { - timeout_sec: 86_400, connection_draining_timeout_sec: 300, + timeout_sec: RELAY_CELL_BACKEND_TIMEOUT_SECONDS, + connection_draining_timeout_sec: RELAY_CELL_CONNECTION_DRAIN_SECONDS, load_balancing_scheme: 'EXTERNAL_MANAGED', protocol: 'HTTP', port_name: 'relay', session_affinity: 'NONE', health_checks: ['projects/p/global/healthChecks/orca-cloud-staging-relay-gce-ready'], @@ -83,6 +91,121 @@ test('accepts the exact additive staging Asia topology', () => { }) }) +const productionImage = image.replace('onorca-cloud-staging/', 'onorca-cloud/') +const productionConfig = { + environment: 'production', cells: ['production-gce-c30'], image: productionImage +} + +// C30 joins a live Asia region: the network is a no-op and the existing C27 route is preserved. +function productionC30Plan() { + const plan = JSON.parse(JSON.stringify(resources) + .replaceAll('onorca-cloud-staging/', 'onorca-cloud/') + .replaceAll('orca-cloud-staging-relay-gce', 'orca-cloud-relay-gce') + .replaceAll('staging-gce-c4', 'production-gce-c30') + .replaceAll('relay-gce-c4', 'relay-gce-c30') + .replaceAll('cell-c4', 'cell-c30') + .replaceAll('c4.relay-staging.onorca.dev', 'c30.relay.onorca.dev') + .replaceAll("'10'", "'16'")) + for (const network of plan.slice(0, 3)) { + network.change.actions = ['no-op'] + network.change.before = structuredClone(network.change.after) + } + const existingHost = { hosts: ['c27.relay.onorca.dev'], path_matcher: 'cell-c27' } + const existingMatcher = { + name: 'cell-c27', + default_service: 'projects/p/global/backendServices/orca-cloud-relay-gce-c27' + } + const urlMap = plan.at(-1).change + urlMap.before = { host_rule: [existingHost], path_matcher: [existingMatcher], fingerprint: 'old' } + urlMap.after.host_rule.unshift(structuredClone(existingHost)) + urlMap.after.path_matcher.unshift(structuredClone(existingMatcher)) + return plan +} + +test('accepts the additive production C30 wave at the 16-connection Asia pool', () => { + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionC30Plan() }, productionConfig), + { environment: 'production', cells: ['production-gce-c30'], changes: 4 } + ) + const staleShape = productionC30Plan() + staleShape[3].change.after.metadata_startup_script = + staleShape[3].change.after.metadata_startup_script.replace("'16'", "'10'") + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: staleShape }, productionConfig), + /reviewed Asia cell shape/ + ) + const wrongZone = productionC30Plan() + wrongZone[4].change.after.zone = 'asia-east2-b' + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: wrongZone }, productionConfig), + /fixed-one Asia MIG shape/ + ) + const liveCellTouched = productionC30Plan() + liveCellTouched.push(create('google_compute_instance_template.relay_gce_cell["production-gce-c27"]')) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: liveCellTouched }, productionConfig), + /outside the planned wave/ + ) +}) + +// The URL map pulls every cell's backend, MIG and template into a targeted plan. +const liveCellResources = ['instance_template', 'instance_group_manager', 'backend_service'] + .flatMap((kind) => ['production-gce-c1', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29'] + .map((cellId) => `google_compute_${kind}.relay_gce_cell["${cellId}"]`)) + +test('accepts live cells the URL map pulls in only while they stay unchanged', () => { + const plan = productionC30Plan() + for (const address of liveCellResources) plan.push({ address, change: { actions: ['no-op'] } }) + assert.equal( + validateRelayAsiaTopologyPlan({ resource_changes: plan }, productionConfig).changes, + 4 + ) + for (const address of liveCellResources) { + for (const action of [['update'], ['delete'], ['create', 'delete'], ['delete', 'create']]) { + const drifted = productionC30Plan() + drifted.push({ address, change: { actions: action } }) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: drifted }, productionConfig), + new RegExp(`${address.replaceAll(/[.[\]]/g, '\\$&')} changes a live cell outside the planned wave`) + ) + } + } +}) + +test('accepts only a reviewed Asia topology wave', () => { + const argv = (environment, cellIds, planImage) => [ + '--plan-json', 'plan.json', '--environment', environment, '--cell-ids', cellIds, + '--region', 'asia-east2', '--image', planImage + ] + for (const cellIds of [ + 'production-gce-c27,production-gce-c28,production-gce-c29', + 'production-gce-c29,production-gce-c27,production-gce-c28', + 'production-gce-c30' + ]) { + assert.doesNotThrow( + () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), + cellIds + ) + } + for (const cellIds of [ + 'production-gce-c27', + 'production-gce-c27,production-gce-c30', + 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', + 'production-gce-c30,production-gce-c30', + 'production-gce-c31' + ]) { + assert.throws( + () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), + /exact reviewed Asia topology set/, + cellIds + ) + } + assert.throws( + () => parseRelayAsiaTopologyPlanArguments(argv('staging', 'production-gce-c30', image)), + /exact reviewed Asia topology set/ + ) +}) + test('accepts an idempotent empty plan', () => { const noChanges = structuredClone(resources).map((resource) => ({ ...resource, @@ -221,3 +344,52 @@ test('rejects incomplete NAT, backend, and URL routing shapes', () => { /no exact backend route/ ) }) + +// Terraform cannot export a local to JS, so this validator restates two topology values that +// the Asia workflow applies. Read the .tf source and equate all three statements of each: the +// local, the topology `check` assert that pins it, and the constant above. +test('the reviewed backend constants match the Terraform topology they validate', () => { + const terraform = readFileSync( + new URL('../../infra/terraform/relay-gce-cells.tf', import.meta.url), + 'utf8' + ) + const local = (name) => { + const found = new RegExp(`\\n\\s*${name}\\s*=\\s*(\\d+)\\n`).exec(terraform) + assert.notEqual(found, null, `relay_gce_topology has no ${name}`) + return Number(found[1]) + } + const asserted = (name) => { + const found = + new RegExp(`local\\.relay_gce_topology\\.${name}\\s*==\\s*(\\d+)`).exec(terraform) + assert.notEqual(found, null, `the topology check does not pin ${name}`) + return Number(found[1]) + } + for (const [name, constant] of [ + ['backend_timeout_seconds', RELAY_CELL_BACKEND_TIMEOUT_SECONDS], + ['connection_drain_seconds', RELAY_CELL_CONNECTION_DRAIN_SECONDS] + ]) { + assert.equal(local(name), constant, `${name} local differs from the validator constant`) + assert.equal(asserted(name), constant, `${name} check assert differs from the validator`) + } + // The sample rate is a variable, not a local, and no environment file overrides it, so the + // declared default is what every cell backend gets. Equate the default and that absence. + const variables = readFileSync( + new URL('../../infra/terraform/variables.tf', import.meta.url), + 'utf8' + ) + const declared = + /variable "relay_gce_cell_log_sample_rate" \{[\s\S]*?\n {2}default {5}= (\d+)\n/.exec(variables) + assert.notEqual(declared, null, 'relay_gce_cell_log_sample_rate declares no default') + assert.equal(Number(declared[1]), RELAY_CELL_LOG_SAMPLE_RATE) + assert.match(terraform, /sample_rate = var\.relay_gce_cell_log_sample_rate/) + for (const environment of ['production', 'staging']) { + assert.doesNotMatch( + readFileSync( + new URL(`../../infra/terraform/environments/${environment}.tfvars`, import.meta.url), + 'utf8' + ), + /relay_gce_cell_log_sample_rate/, + `${environment} overrides the reviewed log sample rate` + ) + } +}) diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.mjs index c81cfc8ca552..8ce27e6b27cb 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.mjs @@ -1,5 +1,14 @@ import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' +import { + RELAY_CELL_CONNECTION_DRAIN_SECONDS, + RELAY_CELL_LOG_SAMPLE_RATE +} from './validate-relay-asia-topology-plan.mjs' + +const CELL_BACKEND_RESOURCE = 'google_compute_backend_service.relay_gce_cell' +const CONNECTION_DRAIN_PATH = 'connection_draining_timeout_sec' +// A backend with no logging has `log_config: []`, so gaining the block moves this one path. +const CELL_LOG_CONFIG_PATH = 'log_config.0' const SERVICE_ACCOUNT_EMAIL = /^[a-z][a-z0-9-]{4,28}[a-z0-9]@[a-z0-9-]+\.iam\.gserviceaccount\.com$/ @@ -292,6 +301,57 @@ function requireDesiredStartupScript(script, config) { } } +// The same-cap job no longer targets this cell's backend service (the capacity role has no +// compute.backendServices.update), so a wave plan carries no backend change and this reports an +// empty list. It stays as the bound on any caller that does target one: exactly this cell's +// backend, exactly the reviewed drain and request-logging attributes, each optional because a +// cell that already has one plans no change for it. Splitting the backend out keeps `changes` +// the template-and-MIG count both callers read. +function takeCellBackendUpdate(changes, config) { + const backends = changes.filter( + ({ address }) => typeof address === 'string' && address.startsWith(`${CELL_BACKEND_RESOURCE}[`) + ) + if (config.mode !== 'same-cap-cell' || backends.length === 0) { + return { rest: changes, backendUpdate: [] } + } + const [backend] = backends + if ( + backends.length !== 1 || + backend.address !== `${CELL_BACKEND_RESOURCE}[${JSON.stringify(config.cellId)}]` || + backend.deposed !== undefined || + !sameActions(backend, ['update']) + ) { + throw new Error('cell plan may change only this cell backend drain and request logging') + } + const after = backend.change?.after + const moved = changedPaths(backend.change?.before, after) + const logging = after?.log_config?.[0] + const accepted = [CONNECTION_DRAIN_PATH, CELL_LOG_CONFIG_PATH].filter((path) => + moved.includes(path)) + if ( + accepted.length === 0 || + (moved.includes(CONNECTION_DRAIN_PATH) && + after?.[CONNECTION_DRAIN_PATH] !== RELAY_CELL_CONNECTION_DRAIN_SECONDS) || + (moved.includes(CELL_LOG_CONFIG_PATH) && + (after?.log_config?.length !== 1 || + logging?.enable !== true || + logging?.sample_rate !== RELAY_CELL_LOG_SAMPLE_RATE)) + ) { + throw new Error('cell plan may change only this cell backend drain and request logging') + } + const backendComputed = new Set(['fingerprint', 'generated_id']) + requireOnlyPaths( + backend, + new Set([ + ...accepted, + ...unknownPaths(backend.change.after_unknown).filter((path) => backendComputed.has(path)) + ]), + accepted, + backendComputed + ) + return { rest: changes.filter((change) => change !== backend), backendUpdate: accepted } +} + function plannedResources(module) { if (!module) return [] return [ @@ -376,10 +436,12 @@ function cellPlan(plan, changes, config) { const obsoleteTemplates = changes.filter( ({ address, deposed }) => address === templateAddress && typeof deposed === 'string' ) + // A failed wave apply leaves its predecessor deposed; the wave must plan its own cleanup. const allowsObsoleteTemplates = - config.mode === 'same-cap-image' && obsoleteTemplates.length > 0 && - obsoleteTemplates.every((change) => sameActions(change, ['delete'])) + obsoleteTemplates.every((change) => sameActions(change, ['delete'])) && + (config.mode === 'same-cap-image' || + (config.mode === 'same-cap-cell' && obsoleteTemplates.length === 1)) if ( !template || !manager || @@ -530,39 +592,46 @@ export function validateCapacityPlan(plan, config) { config.mode === 'same-cap-image' && !/^.+@sha256:[a-f0-9]{64}$/.test(config.rollbackImage ?? '') ) throw new Error('same-cap image Terraform plan has an invalid rollback image') - const changes = mutations(plan) + const { rest: changes, backendUpdate } = takeCellBackendUpdate(mutations(plan), config) + const backend = backendUpdate.length > 0 ? { backendUpdate } : {} if (changes.length === 0) { return { mode: config.mode, changes: 0, + ...backend, ...(config.mode === 'same-cap-image' ? { changeKind: 'none' } : {}) } } + const templateAddress = `google_compute_instance_template.relay_gce_cell[${JSON.stringify(config.cellId)}]` const replacement = changes.some( ({ address, deposed, change }) => - address === `google_compute_instance_template.relay_gce_cell[${JSON.stringify(config.cellId)}]` && + address === templateAddress && deposed === undefined && JSON.stringify(change?.actions) === JSON.stringify(['create', 'delete']) ) if (replacement) cellPlan(plan, changes, config) else convergenceCellPlan(plan, changes, config) + const obsoleteTemplates = changes.filter( + ({ address, deposed }) => address === templateAddress && typeof deposed === 'string' + ) const obsoleteTemplateOnly = changes.every( ({ address, deposed, change }) => - address === `google_compute_instance_template.relay_gce_cell[${JSON.stringify(config.cellId)}]` && + address === templateAddress && typeof deposed === 'string' && JSON.stringify(change?.actions) === JSON.stringify(['delete']) ) + // Same as the backend split: `changes` stays the template-and-MIG count the same-cap job gates + // on. same-cap-image keeps its own total and names obsolete deletes in changeKind instead. + const splitsObsoleteTemplates = config.mode === 'same-cap-cell' && obsoleteTemplates.length > 0 return { mode: config.mode, - changes: changes.length, + changes: changes.length - (splitsObsoleteTemplates ? obsoleteTemplates.length : 0), + ...(splitsObsoleteTemplates ? { obsoleteTemplates: obsoleteTemplates.length } : {}), + ...backend, ...(config.mode === 'same-cap-image' ? { changeKind: replacement - ? changes.some( - ({ address, deposed }) => - address === `google_compute_instance_template.relay_gce_cell[${JSON.stringify(config.cellId)}]` && - typeof deposed === 'string' - ) + ? obsoleteTemplates.length > 0 ? 'replacement-with-obsolete-template' : 'replacement' : obsoleteTemplateOnly diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs index 9094e9e0e7a1..e323a0e110e5 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { + RELAY_CELL_CONNECTION_DRAIN_SECONDS, + RELAY_CELL_LOG_SAMPLE_RATE +} from './validate-relay-asia-topology-plan.mjs' import { parseCapacityPlanArguments, validateCapacityPlan as validateCapacityPlanRaw @@ -543,6 +547,80 @@ test('same-cap mode preserves 1000/60 while adding only the reviewed trust confi /reviewed image and capacity/ ) + // Production run 35684694704 died after create_before_destroy made the new template, leaving + // the Sep 18 template deposed; every later wave plan for that cell carries its delete. + const deposedTemplate = structuredClone(template) + deposedTemplate.deposed = '4cb19f83' + deposedTemplate.change.actions = ['delete'] + deposedTemplate.change.after = null + deposedTemplate.change.after_unknown = {} + assert.deepEqual( + validateCapacityPlan( + { resource_changes: [template, manager, deposedTemplate] }, + sameCapConfig + ), + { mode: 'same-cap-cell', changes: 2, obsoleteTemplates: 1 } + ) + const updatedDeposedTemplate = structuredClone(deposedTemplate) + updatedDeposedTemplate.change.actions = ['update'] + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, updatedDeposedTemplate] }, + sameCapConfig + ), + /change only the exact instance template and MIG/ + ) + const foreignDeposedTemplate = structuredClone(deposedTemplate) + foreignDeposedTemplate.address = + 'google_compute_instance_template.relay_gce_cell["production-gce-c8"]' + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, foreignDeposedTemplate] }, + sameCapConfig + ), + /change only the exact instance template and MIG/ + ) + const secondDeposedTemplate = { ...structuredClone(deposedTemplate), deposed: 'aa17b204' } + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, deposedTemplate, secondDeposedTemplate] }, + sameCapConfig + ), + /change only the exact instance template and MIG/ + ) + // Nothing but the deposed delete left: the cell is stranded on the reviewed template, and the + // job's `changes == 0` roll must still fire. + assert.deepEqual( + validateCapacityPlan( + { + resource_changes: [deposedTemplate], + planned_values: { + root_module: { + resources: [ + { + address: template.address, + values: { + metadata_startup_script: template.change.after.metadata_startup_script, + self_link: 'projects/project/global/instanceTemplates/target' + } + }, + { + address: manager.address, + values: { + version: [{ + instance_template: 'projects/project/global/instanceTemplates/target' + }] + } + } + ] + } + } + }, + sameCapConfig + ), + { mode: 'same-cap-cell', changes: 0, obsoleteTemplates: 1 } + ) + const imageOnly = structuredClone(template) imageOnly.change.after.metadata_startup_script = startup({ selectedImage: image }) const imageOnlyConfig = { @@ -1054,3 +1132,171 @@ test('the database pool argument is accepted by same-cap-cell mode alone', () => /applies only to same-cap-cell validation/ ) }) + +test('a same-cap roll may carry only this cell backend drain and request logging', () => { + const rollbackImage = `us-docker.pkg.dev/project/relay/image@sha256:${'d'.repeat(64)}` + const image = `us-docker.pkg.dev/project/relay/image@sha256:${'e'.repeat(64)}` + const capacityIdentity = 'orca-cloud-gha-cap@project.iam.gserviceaccount.com' + const startup = (selectedImage) => [ + " printf 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP=%s\\n' '1000'", + " printf 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND=%s\\n' '60'", + ` printf 'ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT=%s\\n' '${capacityIdentity}'`, + `printf 'ORCA_RELAY_IMAGE_DIGEST=%s\\n' '${selectedImage.split('@')[1]}'`, + `docker pull '${selectedImage}'`, + 'docker run --detach \\', + ' --name orca-relay \\', + ` '${selectedImage}'` + ].join('\n') + const template = { + address: 'google_compute_instance_template.relay_gce_cell["staging-gce-c3"]', + change: { + actions: ['create', 'delete'], + before: { metadata_startup_script: startup(rollbackImage) }, + after: { metadata_startup_script: startup(image), self_link: null }, + after_unknown: { self_link: true } + } + } + const manager = { + address: 'google_compute_instance_group_manager.relay_gce_cell["staging-gce-c3"]', + change: { + actions: ['update'], + before: { target_size: 1, version: [{ instance_template: 'old' }] }, + after: { target_size: 1, version: [{ instance_template: null }] }, + after_unknown: { version: [{ instance_template: true }] } + } + } + // The exact shape a live US cell's backend plans: 300 s drain and no log_config at all. + const loggingAfter = [{ + enable: true, + optional_fields: null, + optional_mode: null, + sample_rate: RELAY_CELL_LOG_SAMPLE_RATE + }] + const backendChange = ({ drain = true, logging = true }) => ({ + address: 'google_compute_backend_service.relay_gce_cell["staging-gce-c3"]', + change: { + actions: ['update'], + before: { + connection_draining_timeout_sec: drain ? 300 : RELAY_CELL_CONNECTION_DRAIN_SECONDS, + log_config: logging ? [] : loggingAfter, + timeout_sec: 86_400, + fingerprint: 'before' + }, + after: { + connection_draining_timeout_sec: RELAY_CELL_CONNECTION_DRAIN_SECONDS, + log_config: loggingAfter, + timeout_sec: 86_400, + fingerprint: null + }, + after_unknown: { fingerprint: true } + } + }) + const backend = backendChange({}) + const drainOnly = backendChange({ logging: false }) + const loggingOnly = backendChange({ drain: false }) + const sameCapConfig = { + ...config, + mode: 'same-cap-cell', + image, + rollbackImage, + capacityServiceAccount: capacityIdentity, + rehomeDirectorServiceAccount: 'relay-director@project.iam.gserviceaccount.com', + rehomeAudience: 'https://relay.onorca.dev/v1/admin/host-drain', + regionalRehomeProtocol: '0' + } + const refused = /only this cell backend drain and request logging/ + // Both attributes ride along with the roll without inflating the template-and-MIG count + // the apply's stranded branch and the resume's drift branch both read. + assert.deepEqual( + validateCapacityPlan({ resource_changes: [template, manager, backend] }, sameCapConfig), + { + mode: 'same-cap-cell', + changes: 2, + backendUpdate: ['connection_draining_timeout_sec', 'log_config.0'] + } + ) + // Each is independently optional: a cell that already has one plans no change for it. + assert.deepEqual( + validateCapacityPlan({ resource_changes: [template, manager, drainOnly] }, sameCapConfig), + { mode: 'same-cap-cell', changes: 2, backendUpdate: ['connection_draining_timeout_sec'] } + ) + assert.deepEqual( + validateCapacityPlan({ resource_changes: [template, manager, loggingOnly] }, sameCapConfig), + { mode: 'same-cap-cell', changes: 2, backendUpdate: ['log_config.0'] } + ) + // Once both are applied the backend is simply absent from the plan. + assert.deepEqual( + validateCapacityPlan({ resource_changes: [template, manager] }, sameCapConfig), + { mode: 'same-cap-cell', changes: 2 } + ) + // A cell whose template and MIG have converged but whose backend has not is still clean. + assert.deepEqual( + validateCapacityPlan({ resource_changes: [backend] }, sameCapConfig), + { + mode: 'same-cap-cell', + changes: 0, + backendUpdate: ['connection_draining_timeout_sec', 'log_config.0'] + } + ) + assert.deepEqual(validateCapacityPlan({ resource_changes: [] }, sameCapConfig), { + mode: 'same-cap-cell', + changes: 0 + }) + const extraAttribute = structuredClone(backend) + extraAttribute.change.after.timeout_sec = 3_600 + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, extraAttribute] }, + sameCapConfig + ), + /changes outside the reviewed capacity fields/ + ) + const otherCell = structuredClone(backend) + otherCell.address = 'google_compute_backend_service.relay_gce_cell["production-gce-c27"]' + assert.throws( + () => validateCapacityPlan({ resource_changes: [template, manager, otherCell] }, sameCapConfig), + refused + ) + const unreviewedDrain = structuredClone(backend) + unreviewedDrain.change.after.connection_draining_timeout_sec = + RELAY_CELL_CONNECTION_DRAIN_SECONDS + 1 + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, unreviewedDrain] }, + sameCapConfig + ), + refused + ) + const sampledLogging = structuredClone(backend) + sampledLogging.change.after.log_config[0].sample_rate = RELAY_CELL_LOG_SAMPLE_RATE / 2 + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, sampledLogging] }, + sameCapConfig + ), + refused + ) + const disabledLogging = structuredClone(backend) + disabledLogging.change.after.log_config[0].enable = false + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, disabledLogging] }, + sameCapConfig + ), + refused + ) + const replaced = structuredClone(backend) + replaced.change.actions = ['create', 'delete'] + assert.throws( + () => validateCapacityPlan({ resource_changes: [template, manager, replaced] }, sameCapConfig), + refused + ) + // Only the same-cap wave targets a backend service; every other mode still refuses one. + assert.throws( + () => validateCapacityPlan( + { resource_changes: [template, manager, backend] }, + { ...config, mode: 'cell', image } + ), + /only the exact instance template and MIG/ + ) +}) diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index ea5717daa875..fc56af8656e5 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -492,10 +492,40 @@ image, not only instance-template configuration, before rollout or enablement. Incompatible cells are excluded from correction selection; enabling the cohort cannot override this check. Director and cell deployments are separate operations. +The idle-rehome commit runs on the source cell, so from an Asia cell every +statement is a cross-region round trip to the database. It takes no fleet-wide +lock. It locks the global rehome control and worker rows NOWAIT, then the host's +own rows, and reads the cell inventory, runtime, capability and safety tables +unlocked. Its only cell lock is the target cell row, taken NOWAIT by the last +statement before COMMIT. That statement re-checks that the target is enabled, in +general admission and has request capacity. The target row is held for about one +round trip, roughly 175 ms from Asia, and no other cell row is held at all. If +the target changed admission, filled up, or is locked by another writer such as +an admission change, the commit rolls back whole and answers `deferred` with +reason `candidate-ineligible`. The cell logs +`orca_relay_idle_rehome_target_deferred` with the cause, and the director moves +to its next candidate. Runtime metrics report the hold as +`rehomeTargetRowHoldMsMax` and `rehomeTargetRowHolds`. The same hold also feeds +`cellInventoryHoldMsMax`, and `cellInventoryHoldMaxSite` names the lock that +produced that max. An Asia-sourced commit still holds the global rehome control +row for its whole length, roughly 6.5 s at 175 ms per statement. An operator +pause through `applyRegionalRehomeControl` waits 1 s for that row, 3 attempts, +so it can fail during one commit: retry a pause that fails once, and do not +treat that as a fault. + `host-cooldown-ms` is the minimum gap between two rehomes of one host. It bounds the damage from a desktop whose region probe flips: without it the host would be dragged back across the ocean on every flip, since the preference age never expires while the host keeps reconnecting. +Rehome is currently inflow-only into Asia. The director only picks source cells in its own region, +which is the database's region (US): hosts move from US cells to Asia cells, and a host already on +an Asia cell stays there. Asia cells remain valid targets. The source cell runs the rehome commit, +and an Asia source pays a cross-ocean round trip per statement while holding row locks every cell +needs, which stalled the fleet's database. The preview reports those hosts as +`source-outside-director-region`, and the poll summary line reports the skipped Asia cells as +`skippedOffRegionSourceCells`. This is a temporary stopgap: it is removed once the rehome commit +no longer holds those locks across round trips. Deploy or remove it only while rehome is paused. + ## Game-day matrix Run and record each scenario in staging before launch: diff --git a/cloud/docs/push-gateway.md b/cloud/docs/push-gateway.md index 9183f693a87c..319ea4a7f19e 100644 --- a/cloud/docs/push-gateway.md +++ b/cloud/docs/push-gateway.md @@ -335,10 +335,23 @@ clients behind one NAT and is an abuse safeguard, not a global provider-spending and waiting work are bounded independently of HTTP concurrency. `push_events` backs quota accounting. `push_event_recipients` deduplicates fanout and -`push_delivery_batches` retains its historical name and persists individual deliveries, worker -leases, retries and outcomes. Identity metadata -is retained for 24 hours. Payloads expire within five minutes and are cleared on completion or by -minute-level expiry cleanup. FCM project-level provider quotas remain independent of host limits. +`push_delivery_batches` retains its historical name and persists individual pending deliveries, +worker leases and retries. A delivery row is deleted when it is sent, dead, dismissed or expired, so +the table holds only live work. Event and recipient identity metadata is retained for 24 hours. +Payloads expire within five minutes. Minute-level cleanup deletes in bounded batches, so a backlog +drains over successive runs instead of in one long statement. FCM project-level provider quotas +remain independent of host limits. + +A worker claims one device's oldest due delivery with a row lock that other claimers skip, and a +non-blocking per-device lock keeps at most one delivery per phone in flight. Each claim also takes +the previous revision's global claim lock in shared mode, so during a deploy overlap an old worker's +claim waits for new leases to commit instead of re-leasing them. That shared lock can be removed one +release after every worker runs this revision. The claim scan only +reads rows due within the notification TTL, so an unpruned backlog does not slow it. Boot adds one +queue index, a partial index of pending rows per device for the head check; it indexes no lease +column, so lease and renew writes stay heap-only updates. Claim, finish and cleanup traffic may +hold at most one fewer connection than the pool size, so request authentication always has a +connection. Lease renewals skip that cap so they never queue behind claims. Logging is aggregate counters only. Never log a token, a title, a body, or a full fingerprint; the first four characters of a fingerprint are the most that may appear. diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 8804b34fae69..78c8c6eae467 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -269,6 +269,31 @@ name segments out as literal maps rather than deriving them, so the same test compares the two declarations directly. Adding a region to the contract without its segment is a compile error in relay-contract, not a silent gap. +## Relay lock contention alert policies + +Two paging policies in `cloud/infra/terraform/relay-observability.tf` alert the +relay channel when one transaction holds the relay cell table long enough to +stall the fleet. `Orca Relay: cell table lock held over 1 second` fires on any +30-second runtime sample from a cell whose `cellInventoryHoldMsMax` is at least +1,000 ms, labelled with the cell's `cell_id`. `Orca Relay: lock timeout burst` +fires when Postgres cancels at least 20 relay statements in one minute for +waiting out their lock timeout. Auth traffic on the shared instance and +fail-fast refusals from background sweeps are excluded. Replayed over +2026-09-20 14:00 to 2026-09-22 15:00 UTC, the cell hold policy matched all 93 +holds from asia-east2 rehome commits, about 3.6 s each, and every one of the 88 +burst minutes overlapped one of them. First response to either alert: if a +cell hold names an asia-east2 cell, pause regional rehoming through +`Operate Relay Production Rehome` with action `pause`. + +Director holds of 1-2.5 s recur a few times a day even with rehoming paused. +They go to `Orca Relay: director cell table lock held over 1 second`, which has +no notification channel. If the burst alert fires with no cell hold in the same +minute, look at that director policy rather than pausing rehome; on 2026-09-23 +at 04:23 UTC a 2,457 ms director hold produced 25 cancels while rehoming was +paused. Do not drain or restart cells for a director hold. A cell that has +stopped reporting emits no hold sample, so these policies catch lock convoys, +not outages. + ## Implementation log - Gave `collector_failed` the same two-consecutive-sample tolerance as an unread @@ -330,7 +355,8 @@ without its segment is a compile error in relay-contract, not a silent gap. latest-sum over 24 healthy hours: mean ~100, 1-minute spikes to 216, with 10 minutes over the old bar of 160 — enough to freeze roughly one in ten 15-minute pre-drain gates on baseline noise. 250 cleared the healthy peaks - measured then and still fired well before the verified 400-connection ceiling; + measured then and still fired well before the 400-connection ceiling assumed at + the time (the live instance measured 500 on 2026-09-16); pool waiters and pool wait latency keep their strict thresholds. Superseded by the 2026-09-17 entry above, which re-measured a grown baseline against the 490-connection budget. diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index d81620b837d2..55e4c59cdef6 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -165,6 +165,30 @@ or moving a cell and rejects intervening drift. Then atomically register the new cells as migration-only, binding every mutation to the exact live selector generation and a durable attempt ID. Deploy and verify the director configuration only after registration, then promote C27 alone before C28/C29. + +The production Asia set is C27-C30. C27-C29 launched as one wave; C30 is an additive wave of its +own at the same shape. Its plan names C30's template, MIG, and backend plus the shared URL map, and +the URL map pulls every existing cell's backend, MIG, and template into the plan. Committed images +lag what same-cap rolls serve, so the workflow first reads each non-target cell's served image out +of its live template in state and plans that cell at it. It reads the committed cell map from a +no-refresh, unlocked plan over the same targets, not `terraform console`. Console evaluates every +output against state, so `relay_gce_cell_deployments` wraps each per-cell resource lookup in +`try`: until C30's topology apply, console succeeds and that output shows C30 with null MIG, +backend, and template fields. The validator then rejects any change to a +cell outside the wave, so the plan must read as C30's three creations plus the URL map update. +Before the apply dispatch, run the plan mode and read its `Plan:` line; any other drift, such as a +cell whose startup script changed since its last roll, fails the plan and must be rolled first. +Register C30 alone as migration-only, configure the director with `cell-ids` set to C30 while +regional rehome is paused, then promote it alone. Promotion requires C27-C29 to be general and takes +no input evidence. It runs the same five-minute production control and splice canary C27 ran, on +C30: the evidence must show the canary control was placed on C30, read C30's own runtime metrics, +and bind the selector generation, and any failure returns C30 to migration-only. The SQL-failure and +database-pool rules read C30's own metrics only. Director values are recorded under +`director`-prefixed names but do not fail the canary, because directors show a steady baseline of +`relay_cells` lock refusals and pool waits unrelated to C30. C30 was promoted to general on +2026-09-23, so the same-cap job now rolls it as a general cell and the shadow gate's fleet pool list +reads it beside C27-C29. A later Asia cell stays in the same-cap migration-only list and out of the +fleet pool list until its own promotion, then moves to both together, as its own reviewed wave. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index 8aa7aa0a95c4..a41c609ddd53 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -311,8 +311,9 @@ Every `relay_gce_cells` entry is one durable cell generation and must pin both i image and its Artifact Registry relay image. Terraform creates one private COS instance template, one size-one zonal MIG, and one backend service for that exact host. The MIG uses `RECREATE`, zero surge, and one unavailable worker; `/health` alone drives autoheal while SQL/JWKS-backed `/ready` controls LB -admission. The backend timeout is 86,400 seconds with connection draining, and the URL map aborts -unknown wildcard hosts before they reach a worker. The startup script obtains short-lived metadata +admission. The backend timeout is 86,400 seconds and connection draining is 60 seconds, which +covers only a host still mid-handshake because the rollout drains a cell before Terraform runs. +The URL map aborts unknown wildcard hosts before they reach a worker. The startup script obtains short-lived metadata credentials, fetches the two relay secrets without logging them, and runs a digest-pinned Cloud SQL Auth Proxy beside the digest-pinned relay image. @@ -322,7 +323,8 @@ only additive regional resources; cells select the subnet from their declared region. Every cell also declares an explicit database pool maximum in startup metadata and deployment outputs. The initial Asia shape is `e2-standard-4`, 3,000 physical connections, 60 unobserved connections, 6,000 request units, -and a database pool maximum of 10. +and a database pool maximum of 10. Production Asia pools now run at 16 and +staging C4 stays at 10; the topology and director validators pin both. Provision the complete identical Asia wave in one `Deploy Relay Asia Topology` saved plan. Its validator permits only the additive subnet/router/NAT, reviewed @@ -330,7 +332,10 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. +digest. A later cell, such as C30, is its own reviewed wave. The shared URL map +pulls every live cell into its plan, so the workflow plans each live cell at the +image its state template already serves, and the validator rejects any change +to a cell outside the wave. Topology creation intentionally does not apply the director resource. Once all MIGs and backends are healthy, register every new cell atomically as diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index dab79b262c6f..0f6ce9deda04 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -393,6 +393,20 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + "production-gce-c30" = { + hostname = "c30" + region = "asia-east2" + zone = "asia-east2-a" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -415,7 +429,8 @@ relay_region_rehome_source_cell_ids = [ # Asia cells carry the same trust so mis-homed hosts can be drained back off them. "production-gce-c27", "production-gce-c28", - "production-gce-c29" + "production-gce-c29", + "production-gce-c30" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/cloud/infra/terraform/outputs.tf b/cloud/infra/terraform/outputs.tf index 5fd3b647b767..f886b137fcee 100644 --- a/cloud/infra/terraform/outputs.tf +++ b/cloud/infra/terraform/outputs.tf @@ -158,17 +158,18 @@ output "relay_gce_cell_backend_services" { } output "relay_gce_cell_deployments" { + # try: a cell declared before its topology apply has no resources, and console evaluates this output. value = { for cell_id, cell in var.relay_gce_cells : cell_id => { origin = local.relay_gce_cell_urls[cell_id] region = cell.region zone = cell.zone - mig_name = google_compute_instance_group_manager.relay_gce_cell[cell_id].name - instance_group = google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group - backend_name = google_compute_backend_service.relay_gce_cell[cell_id].name - backend_id = google_compute_backend_service.relay_gce_cell[cell_id].id + mig_name = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].name, null) + instance_group = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group, null) + backend_name = try(google_compute_backend_service.relay_gce_cell[cell_id].name, null) + backend_id = try(google_compute_backend_service.relay_gce_cell[cell_id].id, null) url_map_name = google_compute_url_map.relay_gce[0].name - generation_identity = google_compute_instance_template.relay_gce_cell[cell_id].self_link + generation_identity = try(google_compute_instance_template.relay_gce_cell[cell_id].self_link, null) image = cell.image capacity_requests = cell.capacity_requests database_pool_max = cell.database_pool_max @@ -177,7 +178,7 @@ output "relay_gce_cell_deployments" { initially_enabled = cell.initially_enabled fenced = contains(var.relay_gce_fenced_cells, cell_id) desired_target_size = local.relay_gce_cell_target_sizes[cell_id] - target_size = google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size + target_size = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size, null) } } description = "Non-secret candidate deployment topology consumed by the GCE preflight workflow." diff --git a/cloud/infra/terraform/relay-gce-cells.tf b/cloud/infra/terraform/relay-gce-cells.tf index 5023b7e1d504..8a505c8f7693 100644 --- a/cloud/infra/terraform/relay-gce-cells.tf +++ b/cloud/infra/terraform/relay-gce-cells.tf @@ -16,7 +16,7 @@ locals { backend_group_count = 1 public_access_config_count = 0 backend_timeout_seconds = 86400 - connection_drain_seconds = 300 + connection_drain_seconds = 60 } relay_gce_cell_urls = { for cell_id, cell in var.relay_gce_cells : @@ -108,9 +108,12 @@ check "relay_gce_fixed_one_topology" { local.relay_gce_topology.max_unavailable == 1 && local.relay_gce_topology.backend_group_count == 1 && local.relay_gce_topology.public_access_config_count == 0 && - local.relay_gce_topology.backend_timeout_seconds == 86400 + local.relay_gce_topology.backend_timeout_seconds == 86400 && + # A rollout drains every host off the cell before Terraform runs, so this covers only a + # host still mid-handshake; pinned so a raise cannot re-add rollout wall clock unseen. + local.relay_gce_topology.connection_drain_seconds == 60 ) - error_message = "Relay cells require fixed-one RECREATE MIGs, one non-public backend, and the 86,400-second WebSocket timeout." + error_message = "Relay cells require fixed-one RECREATE MIGs, one non-public backend, the 86,400-second WebSocket timeout, and the 60-second connection drain." } } diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index e1951eede539..ba1244075641 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -47,6 +47,12 @@ locals { description = "Cloud SQL checkpoints triggered by WAL volume instead of the timed schedule; a sustained run is the fsync loop that stalled every relay process at once on 2026-09-04." filter = "resource.type=\"cloudsql_database\" AND resource.labels.database_id=\"${var.project_id}:${local.relay_database_instance_name}\" AND textPayload:\"checkpoint starting: wal\"" } + # Why: `db=orca_relay,` keeps auth on the shared instance out. NOWAIT refusals ("could not + # obtain lock") are excluded: sweeps step aside by design at ~160/min even with rehome paused. + cloud_sql_lock_timeouts = { + description = "Relay statements Postgres cancelled after waiting out their lock timeout; a burst means one transaction is holding rows every other relay process needs." + filter = "resource.type=\"cloudsql_database\" AND resource.labels.database_id=\"${var.project_id}:${local.relay_database_instance_name}\" AND textPayload:\"db=orca_relay,\" AND textPayload:\"canceling statement due to lock timeout\"" + } } relay_runtime_metrics = { @@ -356,6 +362,45 @@ resource "google_logging_metric" "relay_incident" { } } +# Why: the hold sample is emitted only after COMMIT, so a failed acquisition never reads as a hold. +# The 1,000 ms bar lives in the filter so the alert is on the sampled milliseconds, not on the +# percentile interpolation of a wide exponential bucket. +resource "google_logging_metric" "relay_long_cell_inventory_hold" { + project = var.project_id + name = "orca_relay_long_cell_inventory_hold_ms" + description = "Cell-inventory lock holds of at least 1,000 ms, one value per 30-second runtime sample." + filter = "${local.relay_runtime_log_filter} AND jsonPayload.cellInventoryHoldMsMax>=1000" + value_extractor = "EXTRACT(jsonPayload.cellInventoryHoldMsMax)" + label_extractors = { + role = "EXTRACT(jsonPayload.role)" + cell_id = "EXTRACT(jsonPayload.cellId)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "DISTRIBUTION" + unit = "ms" + + labels { + key = "role" + value_type = "STRING" + description = "Relay process role." + } + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier, or director." + } + } + + bucket_options { + explicit_buckets { + bounds = [1000, 2000, 3000, 4000, 6000, 10000, 30000] + } + } +} + resource "google_monitoring_alert_policy" "relay_custom" { for_each = local.relay_custom_alerts @@ -771,6 +816,138 @@ resource "google_monitoring_alert_policy" "relay_cell_process_exit" { depends_on = [google_logging_metric.relay_incident] } +# Pages on cell holds only: director holds of 1-2.5 s recur several times a day with rehoming paused, +# and pausing rehome does not stop them, so paging on them would ask on-call to do nothing. +resource "google_monitoring_alert_policy" "relay_long_cell_inventory_hold" { + project = var.project_id + display_name = "Orca Relay: cell table lock held over 1 second" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "Cell table lock held at least 1,000 ms (GCE cell)" + + condition_threshold { + # The metric filter already drops samples under 1,000 ms, so any value present is a breach. + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.relay_long_cell_inventory_hold.name}\" AND metric.label.\"role\"=\"cell\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_PERCENTILE_99" + cross_series_reducer = "REDUCE_MAX" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + conditions { + display_name = "Cell table lock held at least 1,000 ms (Cloud Run cell)" + + condition_threshold { + filter = "resource.type=\"cloud_run_revision\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.relay_long_cell_inventory_hold.name}\" AND metric.label.\"role\"=\"cell\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_PERCENTILE_99" + cross_series_reducer = "REDUCE_MAX" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A relay cell held the lock on every row of the relay cell table for at least one second. While it is held, host connects and reservation changes on every cell wait and then fail, so each hold is a fleet-wide stall of that length. Between 2026-09-20 and 2026-09-22 every cell hold came from a regional rehome committed on an asia-east2 cell (c27, c28 or c29), about 3.6 s each. First response: pause regional rehoming with the `Operate Relay Production Rehome` workflow, action `pause`. See `cloud/docs/relay-incident-monitor.md`, section \"Relay lock contention alert policies\"." + mime_type = "text/markdown" + } +} + +# Visibility only, no notification channel, like the non-paging relay_custom policies. +resource "google_monitoring_alert_policy" "relay_director_cell_inventory_hold" { + project = var.project_id + display_name = "Orca Relay: director cell table lock held over 1 second" + combiner = "OR" + enabled = true + notification_channels = [] + + conditions { + display_name = "Director cell table lock held at least 1,000 ms" + + condition_threshold { + filter = "resource.type=\"cloud_run_revision\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.relay_long_cell_inventory_hold.name}\" AND metric.label.\"role\"=\"director\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_PERCENTILE_99" + cross_series_reducer = "REDUCE_MAX" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "The director held the lock on every row of the relay cell table for at least one second. These holds happen a few times a day even with regional rehoming paused, and pausing rehome does not stop them, so this policy does not page. Use it to explain a `Orca Relay: lock timeout burst` alert that has no cell hold in the same minute. Do not drain or restart cells for it." + mime_type = "text/markdown" + } +} + +resource "google_monitoring_alert_policy" "relay_lock_timeout_burst" { + project = var.project_id + display_name = "Orca Relay: lock timeout burst" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "Relay lock timeouts at least 20 in a minute" + + condition_threshold { + filter = "resource.type=\"cloudsql_database\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cloud_sql_lock_timeouts\"" + comparison = "COMPARISON_GT" + threshold_value = 19 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "Postgres cancelled at least 20 relay statements in one minute because they waited too long for a lock. Each cancel is a host connect, reservation or placement that failed and retried. Only the relay database is counted; auth traffic on the same instance and fail-fast lock refusals from background sweeps are excluded. Between 2026-09-20 and 2026-09-22 all 88 such minutes overlapped a lock hold from a regional rehome on an asia-east2 cell. A burst can also come from a director hold while rehoming is paused, as on 2026-09-23 at 04:23 UTC. First response: check the `Orca Relay: cell table lock held over 1 second` policy for the same minute. If an asia-east2 cell held the lock, pause regional rehoming with the `Operate Relay Production Rehome` workflow, action `pause`. If no cell hold appears in that minute, check `Orca Relay: director cell table lock held over 1 second` instead of pausing rehome, and do not drain or restart cells for it. See `cloud/docs/relay-incident-monitor.md`, section \"Relay lock contention alert policies\"." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_incident] +} + # Why: nothing fired while US desktops sat on asia-east2 cells for weeks in 2026-08. The two # per-cell policies below read that as distance, and the fleet-wide one reads it as a bad region # hint. All three are MQL because each needs the sum of a DELTA DISTRIBUTION as a volume floor, diff --git a/cloud/package.json b/cloud/package.json index 62eac879b9bb..0dd2a5466999 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -20,13 +20,13 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts new file mode 100644 index 000000000000..adca4729e793 --- /dev/null +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS, + IDLE_REGIONAL_REHOME_DEFER_REASONS, + IdleRegionalRehomeResponseSchema, + isGlobalIdleRegionalRehomeDeferral +} from './idle-regional-rehome.js' + +describe('idle regional rehome response', () => { + it('accepts a source cell that has never heard of the reason field', () => { + expect(IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred' })).toEqual({ + v: 1, + outcome: 'deferred' + }) + }) + + it('carries every reason the source can send', () => { + for (const reason of IDLE_REGIONAL_REHOME_DEFER_REASONS) { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred', reason }) + ).toEqual({ v: 1, outcome: 'deferred', reason }) + } + }) + + it('reads a reason it does not know as absent instead of failing the response', () => { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'deferred', reason: 'from-a-newer-cell' }) + ).toEqual({ v: 1, outcome: 'deferred' }) + }) + + it('drops a field added after this decoder shipped', () => { + expect( + IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'committed', movedAt: 17 }) + ).toEqual({ v: 1, outcome: 'committed' }) + }) + + it('still rejects an outcome it cannot act on', () => { + expect(() => IdleRegionalRehomeResponseSchema.parse({ v: 1, outcome: 'moved' })).toThrow() + }) + + it('classifies only the poll-wide deferrals as global', () => { + for (const reason of IDLE_REGIONAL_REHOME_DEFER_REASONS) { + expect(isGlobalIdleRegionalRehomeDeferral(reason)).toBe( + GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS.some((global) => global === reason) + ) + } + expect(isGlobalIdleRegionalRehomeDeferral(undefined)).toBe(false) + expect(GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS).toContain('concurrency-limit') + expect(isGlobalIdleRegionalRehomeDeferral('candidate-ineligible')).toBe(false) + }) +}) diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.ts index 9f9eff365934..cb148e06b1cc 100644 --- a/cloud/packages/relay-contract/src/idle-regional-rehome.ts +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.ts @@ -15,12 +15,58 @@ export const IdleRegionalRehomeRequestSchema = z }) .strict() +// Deferrals no other candidate in the same poll can get past: the source +// re-reads the same durable row for every request, so the next POST takes the +// same branch. The director stops walking its list on one of these. +export const GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS = [ + 'control-closed', + 'budget-closed', + 'concurrency-limit', + 'cohort-closed', + 'fleet-safety' +] as const + +export const IDLE_REGIONAL_REHOME_DEFER_REASONS = [ + ...GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS, + 'candidate-ineligible', + 'host-unsupported', + 'director-safety-stale' +] as const + export const IdleRegionalRehomeResponseSchema = z .object({ v: z.literal(1), - outcome: z.enum(['busy', 'committed', 'deferred', 'stale']) + outcome: z.enum(['busy', 'committed', 'deferred', 'stale']), + // Optional both ways: a source cell on an older image omits it and the + // director keeps its walk-the-whole-list behaviour, and a reason a newer + // cell adds later reads as absent instead of failing the whole response. + reason: z.enum(IDLE_REGIONAL_REHOME_DEFER_REASONS).optional().catch(undefined) }) - .strict() + // Unknown keys are dropped rather than rejected, so the next optional field + // on this response does not have to wait for every director to redeploy. + .strip() export type IdleRegionalRehomeRequest = z.infer export type IdleRegionalRehomeOutcome = z.infer['outcome'] +export type IdleRegionalRehomeDeferReason = (typeof IDLE_REGIONAL_REHOME_DEFER_REASONS)[number] + +// What the source cell answers: `busy` and `stale` come from the host session, +// the rest from the durable commit, and `reason` is set only for a deferral. +export type IdleRegionalRehomeResult = { + outcome: IdleRegionalRehomeOutcome + reason?: IdleRegionalRehomeDeferReason +} + +export type IdleRegionalRehomeCommit = { + outcome: Exclude + reason?: IdleRegionalRehomeDeferReason +} + +export type GlobalIdleRegionalRehomeDeferReason = + (typeof GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS)[number] + +export function isGlobalIdleRegionalRehomeDeferral( + reason: IdleRegionalRehomeDeferReason | undefined +): reason is GlobalIdleRegionalRehomeDeferReason { + return GLOBAL_IDLE_REGIONAL_REHOME_DEFER_REASONS.some((global) => global === reason) +} diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 4849a4ecea66..6042ee85cd45 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^24.10.0 version: 24.13.2 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay: dependencies: @@ -114,7 +114,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-fence-broker: dependencies: @@ -139,7 +139,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-ops: dependencies: @@ -164,7 +164,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) packages/postgres-schema: devDependencies: @@ -176,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -192,7 +192,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -208,7 +208,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -227,156 +227,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -593,6 +749,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -871,6 +1032,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -1015,81 +1181,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -1195,13 +1439,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1284,6 +1536,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1542,13 +1823,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@1.0.3: {} typescript@5.9.3: {} undici-types@7.18.2: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1557,14 +1844,54 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.22.4 - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1581,7 +1908,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c981..c4f58fa521ce 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -16,15 +16,34 @@ type OutputChunk = Rollup.OutputChunk // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. -// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run -// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +// The CLI loads these paths after electron-vite replaces out/main. +export const CLI_MAIN_ENTRY_NAMES = [ + 'agent-hooks/managed-agent-hook-controls', + 'orca-profiles/profile-index-store', + 'codex/managed-home-shell-preflight', + 'claude-accounts/keychain', + ...[ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery-command' + ].map((module) => `persistence/profile-state/profile-state-${module}`), + 'persistence/profile-state/legacy-json/profile-state-export-path', + 'persistence/profile-state/legacy-json/profile-state-recovery', + 'startup/http1-compatibility-marker' +] as const + +// Plain-Node processes and CLI modules cannot load Electron's API. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls' + ...CLI_MAIN_ENTRY_NAMES ] as const // Entries executed as worker threads of the main process. Electron's module is @@ -41,7 +60,9 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', - 'usage-scan-worker-entry' + 'usage-scan-worker-entry', + 'profile-state-backup-worker-entry', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/bundled-ripgrep-resources.cjs b/config/bundled-ripgrep-resources.cjs new file mode 100644 index 000000000000..b1689fdf9dd8 --- /dev/null +++ b/config/bundled-ripgrep-resources.cjs @@ -0,0 +1,73 @@ +const { chmodSync, existsSync } = require('node:fs') +const { join } = require('node:path') + +// Why every relay platform in every artifact: SSH deploys upload the remote host's rg from the +// local bundle, and WSL runs the Linux build from the Windows install directory. +const BUNDLED_RIPGREP_PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' +] +const RIPGREP_PACKAGE_BIN_DIR = 'node_modules/@vscode/ripgrep-universal/bin' +const RIPGREP_RESOURCE_DIR = 'ripgrep' + +function ripgrepBinaryName(platform) { + return platform.startsWith('win32-') ? 'rg.exe' : 'rg' +} + +const bundledRipgrepExtraResources = [ + { + from: RIPGREP_PACKAGE_BIN_DIR, + to: RIPGREP_RESOURCE_DIR, + filter: BUNDLED_RIPGREP_PLATFORMS.map((platform) => `${platform}/**`) + }, + // Why: the binaries statically link PCRE2, and on Linux musl, jemalloc and LLVM libunwind -- + // all of which require their notice on binary redistribution. Whole dir, so notices can be added. + { from: 'resources/licenses/ripgrep', to: `${RIPGREP_RESOURCE_DIR}/licenses` } +] + +// Why: codesign would try to sign the Linux/Windows builds; they are inert data on macOS. +const bundledRipgrepMacSignIgnore = ['/ripgrep/(linux|win32)-'] + +// Why: electron-builder only warns on a missing extraResources source. +function assertBundledRipgrepInstalled(projectDir = join(__dirname, '..')) { + const missing = BUNDLED_RIPGREP_PLATFORMS.filter( + (platform) => + !existsSync(join(projectDir, RIPGREP_PACKAGE_BIN_DIR, platform, ripgrepBinaryName(platform))) + ) + if (missing.length > 0) { + throw new Error( + `@vscode/ripgrep-universal is missing binaries for ${missing.join(', ')}; run pnpm install.` + ) + } +} + +function finalizePackagedRipgrep(resourcesDir) { + for (const platform of BUNDLED_RIPGREP_PLATFORMS) { + const binaryPath = join( + resourcesDir, + RIPGREP_RESOURCE_DIR, + platform, + ripgrepBinaryName(platform) + ) + if (!existsSync(binaryPath)) { + throw new Error(`Packaged app is missing bundled ripgrep: ${binaryPath}`) + } + // Why: the upstream tarball's exec bits are not guaranteed after copying. + chmodSync(binaryPath, 0o755) + } +} + +module.exports = { + BUNDLED_RIPGREP_PLATFORMS, + RIPGREP_PACKAGE_BIN_DIR, + RIPGREP_RESOURCE_DIR, + ripgrepBinaryName, + assertBundledRipgrepInstalled, + bundledRipgrepExtraResources, + bundledRipgrepMacSignIgnore, + finalizePackagedRipgrep +} diff --git a/config/docker/daemon-scope-lifetime/Dockerfile b/config/docker/daemon-scope-lifetime/Dockerfile new file mode 100644 index 000000000000..128e491bc61e --- /dev/null +++ b/config/docker/daemon-scope-lifetime/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +ENV container=docker + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + systemd systemd-sysv libpam-systemd dbus dbus-user-session procps util-linux \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 1100 --create-home --shell /bin/sh orca-repro + +COPY process-fixture.cjs run-cases.cjs /opt/daemon-scope-lifetime/ + +STOPSIGNAL SIGRTMIN+3 +CMD ["/lib/systemd/systemd"] diff --git a/config/docker/daemon-scope-lifetime/README.md b/config/docker/daemon-scope-lifetime/README.md new file mode 100644 index 000000000000..27113aa81139 --- /dev/null +++ b/config/docker/daemon-scope-lifetime/README.md @@ -0,0 +1,17 @@ +# Daemon scope lifetime regression + +Run from the repository root with Docker running: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-daemon-scope-lifetime-docker.mjs --baseline +``` + +This starts real systemd and a user manager in a temporary Linux container. Docker must support privileged containers and a writable cgroup v2 mount. On Docker Desktop this uses its Linux VM. The runner removes its container and image afterward. + +The baseline uses the production scope launcher from the selected Git revision. The candidate uses the working tree's production scope launcher and lifetime cleanup. A short-lived runtime launches each daemon and exits. Each daemon starts a worker through an intermediate parent that exits before daemon shutdown, leaving the worker already reparented to PID 1. The worker retains 24 MiB, ignores SIGTERM, and stays in the daemon's cgroup. + +Four cycles cover graceful SIGTERM, SIGKILL, ordinary exit, and SIGKILL of the daemon's entire process group. The baseline must retain one additional worker each cycle. The candidate must return to zero workers and remove each old cgroup after every cycle. The report includes retained worker RSS and cgroup memory. Both must preserve the daemon and worker when only the runtime exits, and an unrelated canary in its own scope. The candidate must also preserve a scope whose daemon lacks exclusive ownership, and keep live work running if the lifetime pipe closes prematurely. + +This proves the lifetime cleanup mechanism against real Linux process and systemd behavior. It does not reproduce a user's complete workload or assert that every reported OOM has this cause. + +`ORCA_DOCKER` overrides the Docker executable. `ORCA_DOCKER_PLATFORM` overrides the Linux architecture. diff --git a/config/docker/daemon-scope-lifetime/bundle-entry.ts b/config/docker/daemon-scope-lifetime/bundle-entry.ts new file mode 100644 index 000000000000..ea8ba04d1fb9 --- /dev/null +++ b/config/docker/daemon-scope-lifetime/bundle-entry.ts @@ -0,0 +1,7 @@ +import { startDaemonScopeDeathWatch } from '../../../src/main/daemon/daemon-scope-death-watch' + +export { buildDurableDaemonScopeCommand } from '../../../src/main/daemon/daemon-cgroup-scope' + +export function startScopeReaper(launchNonce: string, freshScope: boolean) { + return startDaemonScopeDeathWatch({ freshScope, launchNonce, log: () => {} }) +} diff --git a/config/docker/daemon-scope-lifetime/process-fixture.cjs b/config/docker/daemon-scope-lifetime/process-fixture.cjs new file mode 100644 index 000000000000..9c8161fcdda3 --- /dev/null +++ b/config/docker/daemon-scope-lifetime/process-fixture.cjs @@ -0,0 +1,47 @@ +const fs = require('node:fs') +const { spawn } = require('node:child_process') + +const [role, resultPath, bundlePath, nonce, ownership = 'exclusive'] = process.argv.slice(2) + +if (role === 'worker') { + global.retained = Buffer.alloc(24 * 1024 * 1024, 1) + process.on('SIGTERM', () => {}) + fs.writeFileSync(resultPath, String(process.pid)) + setInterval(() => global.retained[0]++, 1000) +} else if (role === 'intermediate') { + const child = spawn(process.execPath, [__filename, 'worker', resultPath], { + detached: true, + stdio: 'ignore' + }) + child.unref() +} else if (role === 'runtime') { + const { buildDurableDaemonScopeCommand } = require(bundlePath) + const command = buildDurableDaemonScopeCommand( + process.execPath, + [__filename, 'daemon', resultPath, bundlePath, nonce, ownership], + nonce, + process.env + ) + const daemon = spawn(command.command, command.args, { + detached: true, + stdio: 'ignore', + env: command.env + }) + daemon.unref() +} else if (role === 'daemon') { + const { startScopeReaper } = require(bundlePath) + const watcher = startScopeReaper?.(nonce, ownership !== 'shared') + if (ownership === 'broken-pipe') { + watcher?.stdin?.destroy() + } + fs.writeFileSync(`${resultPath}.daemon`, String(process.pid)) + spawn(process.execPath, [__filename, 'intermediate', resultPath], { stdio: 'ignore' }) + process.on('SIGTERM', () => process.exit(0)) + setInterval(() => { + if (fs.existsSync(`${resultPath}.exit`)) { + process.exit(0) + } + }, 50) +} else { + throw new Error(`Unknown fixture role: ${role}`) +} diff --git a/config/docker/daemon-scope-lifetime/run-cases.cjs b/config/docker/daemon-scope-lifetime/run-cases.cjs new file mode 100644 index 000000000000..3b7cf0b8a151 --- /dev/null +++ b/config/docker/daemon-scope-lifetime/run-cases.cjs @@ -0,0 +1,199 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const { join } = require('node:path') +const { spawn, execFileSync } = require('node:child_process') + +const [bundlePath, mode] = process.argv.slice(2) +assert.ok(['baseline', 'candidate'].includes(mode)) +const fixture = join(__dirname, 'process-fixture.cjs') +const prefix = `retention-${mode}-${process.pid}-${Date.now()}` +const scopes = [] +const workers = [] +const cgroups = [] +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +function stat(pid) { + try { + const contents = fs.readFileSync(`/proc/${pid}/stat`, 'utf8') + const fields = contents.slice(contents.lastIndexOf(') ') + 2).split(' ') + return fields[0] === 'Z' + ? null + : { ppid: Number(fields[1]), pgid: Number(fields[2]), startTicks: fields[19] } + } catch { + return null + } +} + +function rss(pid) { + try { + const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8') + return Number(status.match(/^VmRSS:\s+(\d+)/m)?.[1] ?? 0) + } catch { + return 0 + } +} + +async function until(predicate, message, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) { + return + } + await delay(25) + } + throw new Error(message) +} + +function readPid(path) { + return fs.existsSync(path) ? Number(fs.readFileSync(path, 'utf8')) : null +} + +function scopePath(pid) { + return fs.readFileSync(`/proc/${pid}/cgroup`, 'utf8').trim() +} + +async function startCycle(cycle, ownership = 'exclusive') { + const nonce = `${prefix}-${cycle}` + const resultPath = `/tmp/${nonce}` + const unit = `orca-daemon-${nonce}.scope` + scopes.push(unit) + const runtime = spawn( + process.execPath, + [fixture, 'runtime', resultPath, bundlePath, nonce, ownership], + { stdio: 'inherit' } + ) + await new Promise((resolve, reject) => { + runtime.on('error', reject) + runtime.on('exit', (code) => + code === 0 ? resolve() : reject(new Error(`runtime exited ${code}`)) + ) + }) + let workerPid, daemonPid + await until(() => { + workerPid = readPid(resultPath) + daemonPid = readPid(`${resultPath}.daemon`) + return workerPid && daemonPid && stat(workerPid)?.ppid === 1 + }, 'worker did not reparent before daemon stop') + assert.ok(stat(daemonPid), 'daemon did not survive its runtime exit') + assert.equal(scopePath(workerPid), scopePath(daemonPid), 'worker escaped its owning scope') + assert.ok(scopePath(workerPid).endsWith(unit), 'daemon did not enter requested scope') + const cgroupDirectory = join('/sys/fs/cgroup', scopePath(workerPid).split(':').slice(2).join(':')) + assert.ok( + fs.existsSync(join(cgroupDirectory, 'memory.current')), + 'cgroup memory accounting missing' + ) + await delay(300) + assert.ok(stat(daemonPid) && stat(workerPid), 'runtime exit killed terminal work') + return { workerPid, daemonPid, resultPath, unit, cgroupDirectory } +} + +async function main() { + const canaryUnit = `orca-canary-${prefix}.scope` + scopes.push(canaryUnit) + const canary = spawn( + 'systemd-run', + [ + '--user', + '--scope', + '--collect', + '--quiet', + `--unit=${canaryUnit}`, + '--', + '/bin/sleep', + '120' + ], + { stdio: 'inherit' } + ) + try { + await until( + () => stat(canary.pid) && scopePath(canary.pid).endsWith(canaryUnit), + 'canary scope missing' + ) + for (const [index, signal] of [ + 'SIGTERM', + 'SIGKILL', + 'natural-exit', + 'process-group-SIGKILL' + ].entries()) { + const cycle = index + 1 + const { workerPid, daemonPid, resultPath, cgroupDirectory } = await startCycle(cycle) + workers.push(workerPid) + cgroups.push(cgroupDirectory) + if (signal === 'natural-exit') { + fs.writeFileSync(`${resultPath}.exit`, '') + } else if (signal === 'process-group-SIGKILL') { + assert.equal(stat(daemonPid)?.pgid, daemonPid, 'daemon does not own its process group') + process.kill(-daemonPid, 'SIGKILL') + } else { + process.kill(daemonPid, signal) + } + await until(() => !stat(daemonPid), 'daemon did not exit') + await (mode === 'candidate' + ? until(() => !stat(workerPid), 'scope retained orphan after daemon exit', 12000) + : delay(700)) + if (mode === 'candidate') { + await until( + () => !fs.existsSync(cgroupDirectory), + 'scope retained a process after worker exit' + ) + } + const live = workers.filter((pid) => stat(pid)) + const retainedRssKiB = live.reduce((sum, pid) => sum + rss(pid), 0) + const retainedCgroupMemoryKiB = cgroups.reduce((sum, directory) => { + const memoryFile = join(directory, 'memory.current') + return ( + sum + (fs.existsSync(memoryFile) ? Number(fs.readFileSync(memoryFile, 'utf8')) / 1024 : 0) + ) + }, 0) + assert.equal(live.length, mode === 'baseline' ? cycle : 0) + assert.ok(stat(canary.pid), 'unrelated scope was killed') + console.log( + JSON.stringify({ + mode, + cycle, + signal, + liveWorkers: live.length, + retainedRssKiB, + retainedCgroupMemoryKiB, + unrelatedCanary: 'live', + runtimeExitPreservedDaemon: true, + workerReparentedBeforeStop: true + }) + ) + } + if (mode === 'candidate') { + const { workerPid, daemonPid } = await startCycle('shared', 'shared') + process.kill(daemonPid, 'SIGKILL') + await until(() => !stat(daemonPid), 'shared-scope daemon did not exit') + await delay(700) + assert.ok(stat(workerPid), 'scope without exclusive ownership was stopped') + assert.ok(stat(canary.pid), 'unrelated canary was killed') + console.log( + JSON.stringify({ mode, sharedScopeWithoutOwnership: 'preserved', unrelatedCanary: 'live' }) + ) + const brokenPipe = await startCycle('broken-pipe', 'broken-pipe') + await delay(5700) + assert.ok(stat(brokenPipe.daemonPid), 'premature pipe closure killed the daemon') + assert.ok(stat(brokenPipe.workerPid), 'premature pipe closure killed live terminal work') + assert.ok(stat(canary.pid), 'unrelated canary was killed') + console.log( + JSON.stringify({ + mode, + prematurePipeClosure: 'live-work-preserved', + unrelatedCanary: 'live' + }) + ) + } + } finally { + for (const unit of scopes) { + try { + execFileSync('systemctl', ['--user', '--signal=SIGKILL', 'kill', unit], { stdio: 'ignore' }) + } catch {} + } + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/config/docker/daemon-shutdown-descendants/Dockerfile b/config/docker/daemon-shutdown-descendants/Dockerfile new file mode 100644 index 000000000000..b012d5738e3d --- /dev/null +++ b/config/docker/daemon-shutdown-descendants/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +ENV NODE_PATH=/usr/local/lib/node_modules + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential python3 procps util-linux \ + && rm -rf /var/lib/apt/lists/* \ + && npm install --global --no-fund --no-audit node-pty@1.1.0 + +COPY run-case.sh /usr/local/bin/run-daemon-shutdown-descendants +COPY fixture.cjs /opt/daemon-shutdown-descendants/fixture.cjs + +ENTRYPOINT ["/usr/local/bin/run-daemon-shutdown-descendants"] diff --git a/config/docker/daemon-shutdown-descendants/README.md b/config/docker/daemon-shutdown-descendants/README.md new file mode 100644 index 000000000000..80a24a8f64e2 --- /dev/null +++ b/config/docker/daemon-shutdown-descendants/README.md @@ -0,0 +1,15 @@ +# Daemon shutdown descendant regression + +Run from the repository root with Docker running: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-daemon-shutdown-descendants-docker.mjs +``` + +The runner bundles the production `TerminalHost` and native PTY wrapper from the working tree. By default it runs the candidate only. Pass `--baseline ` to add a red/green run; the baseline loads the five teardown modules from that ref without changing the checkout. Both run in Linux with `node-pty` 1.1.0. + +A live PTY shell owns a child that called `setsid` and ignores `SIGTERM`. The fixture verifies those relationships, calls `host.dispose()`, and immediately exits its process. A separate supervisor checks that the child has stopped, an unrelated canary is still running, and shutdown finished within the daemon's five-second limit. The baseline must leave the child running; the candidate must stop it. + +`ORCA_DOCKER` overrides the Docker executable. `ORCA_DOCKER_PLATFORM` overrides the Linux architecture. The test creates a temporary image and removes it afterward. + +This proves cleanup of descendants still parented beneath a live shell at shutdown. It does not prove cleanup of processes that had already reparented before shutdown, or reproduce systemd-oomd policy decisions. diff --git a/config/docker/daemon-shutdown-descendants/bundle-entry.ts b/config/docker/daemon-shutdown-descendants/bundle-entry.ts new file mode 100644 index 000000000000..14a9d7026078 --- /dev/null +++ b/config/docker/daemon-shutdown-descendants/bundle-entry.ts @@ -0,0 +1,2 @@ +export { TerminalHost } from '../../../src/main/daemon/terminal-host' +export { createDaemonPtySubprocessHandle } from '../../../src/main/daemon/pty-subprocess/subprocess-handle' diff --git a/config/docker/daemon-shutdown-descendants/fixture.cjs b/config/docker/daemon-shutdown-descendants/fixture.cjs new file mode 100644 index 000000000000..409d21d20e55 --- /dev/null +++ b/config/docker/daemon-shutdown-descendants/fixture.cjs @@ -0,0 +1,73 @@ +const fs = require('node:fs') +const { spawn } = require('node:child_process') +const pty = require('node-pty') + +const bundle = process.argv[2] +const resultPath = process.argv[3] + +async function main() { + const { TerminalHost, createDaemonPtySubprocessHandle } = require(bundle) + let output = '' + const canary = spawn('/bin/sh', ['-c', 'while :; do sleep 1; done'], { + stdio: 'ignore' + }) + canary.unref() + const childScript = + 'setsid /bin/sh -c \'trap "" TERM; while :; do sleep 1; done\' & echo ORCA_DESCENDANT:$!; while :; do sleep 1; done' + const native = pty.spawn('/bin/sh', ['-c', childScript], { + cols: 80, + rows: 24, + cwd: '/tmp', + env: { ...process.env, TERM: 'xterm-256color' } + }) + const handle = createDaemonPtySubprocessHandle({ + process: native, + shellPath: '/bin/sh', + spawnCwd: '/tmp', + env: { ...process.env, TERM: 'xterm-256color' }, + startupCommandDeliveredInShellArgs: true, + reportsChildExitStatus: true, + sessionId: 'docker-daemon-shutdown-descendant', + startupAgentRecognition: null + }) + const host = new TerminalHost({ spawnSubprocess: () => handle }) + try { + await host.createOrAttach({ + sessionId: 'docker-daemon-shutdown-descendant', + cols: 80, + rows: 24, + streamClient: { onData: (data) => (output += data), onExit: () => {} } + }) + const deadline = Date.now() + 5_000 + let childPid = null + while (Date.now() < deadline) { + const match = output.match(/ORCA_DESCENDANT:(\d+)/) + if (match) { + childPid = Number(match[1]) + break + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + if (!childPid) { + throw new Error(`descendant pid was not reported: ${JSON.stringify(output)}`) + } + const childStat = fs.readFileSync(`/proc/${childPid}/stat`, 'utf8').split(' ') + if (Number(childStat[3]) !== native.pid || Number(childStat[5]) !== childPid) { + throw new Error('fixture child is not a live descendant in its own POSIX session') + } + const shutdownStarted = Date.now() + await host.dispose() + const shutdownMs = Date.now() - shutdownStarted + fs.writeFileSync(resultPath, JSON.stringify({ childPid, canaryPid: canary.pid, shutdownMs })) + process.exit(0) + } catch (error) { + canary.kill('SIGKILL') + native.kill('SIGKILL') + throw error + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/config/docker/daemon-shutdown-descendants/run-case.sh b/config/docker/daemon-shutdown-descendants/run-case.sh new file mode 100755 index 000000000000..a8ce8414d745 --- /dev/null +++ b/config/docker/daemon-shutdown-descendants/run-case.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +bundle=${1:?bundle path is required} +mode=${2:?mode is required} +result_dir=/tmp/daemon-shutdown-descendants +mkdir -p "$result_dir" + +# The fixture exits like daemon-entry after dispose(). This shell remains as a +# supervisor so a detached descendant can be inspected after the daemon exits. +timeout --kill-after=2s 20s node /opt/daemon-shutdown-descendants/fixture.cjs "$bundle" "$result_dir/fixture.json" + +child_pid=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.childPid))" "$result_dir/fixture.json") +canary_pid=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.canaryPid))" "$result_dir/fixture.json") +shutdown_ms=$(node -e "const r=require(process.argv[1]); process.stdout.write(String(r.shutdownMs))" "$result_dir/fixture.json") + +process_state() { + awk '{ print $3 }' "/proc/$1/stat" 2>/dev/null || true +} + +is_live() { + state=$(process_state "$1") + test -n "$state" && test "$state" != Z +} + +# Observe after the daemon's explicit exit, while the container remains alive. +sleep 1 +if is_live "$child_pid"; then + child_after_exit=live +else + child_after_exit=gone +fi + +if is_live "$canary_pid"; then + canary_after_exit=live +else + canary_after_exit=gone +fi + +kill -KILL "$child_pid" "$canary_pid" 2>/dev/null || true +printf '%s\n' "{\"mode\":\"$mode\",\"childPid\":$child_pid,\"shutdownMs\":$shutdown_ms,\"childAfterDaemonExit\":\"$child_after_exit\",\"canaryAfterDaemonExit\":\"$canary_after_exit\"}" +test "$shutdown_ms" -lt 5000 || { echo "shutdown exceeded daemon budget" >&2; exit 1; } + +case "$mode:$child_after_exit:$canary_after_exit" in + baseline:live:live|candidate:gone:live) : ;; + *) echo "unexpected daemon shutdown result" >&2; exit 1 ;; +esac diff --git a/config/docker/linux-lid-sleep-assertion/Dockerfile b/config/docker/linux-lid-sleep-assertion/Dockerfile new file mode 100644 index 000000000000..fbee9570fe24 --- /dev/null +++ b/config/docker/linux-lid-sleep-assertion/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-bookworm-slim + +ENV container=docker +ENV ORCA_BACKGROUND_LAUNCH=1 + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends systemd dbus procps \ + && rm -rf /var/lib/apt/lists/* + +STOPSIGNAL SIGRTMIN+3 +CMD ["/lib/systemd/systemd"] diff --git a/config/docker/linux-lid-sleep-assertion/README.md b/config/docker/linux-lid-sleep-assertion/README.md new file mode 100644 index 000000000000..6aa2a3519a52 --- /dev/null +++ b/config/docker/linux-lid-sleep-assertion/README.md @@ -0,0 +1,21 @@ +# Linux sleep inhibitor lifetime regression + +```sh +ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-linux-lid-sleep-assertion-docker.mjs --baseline +``` + +The runner builds the selected baseline and current production `LinuxLidSleepAssertion`, +then runs both in a temporary Linux container with real systemd and logind. It checks +the actual sleep and lid-switch inhibitor, both helper processes, the application's +scope, and an unrelated canary. The baseline must leak after SIGKILL. The candidate +must release the lock and helpers after SIGKILL and after an explicit stop; stopping +the lock must preserve the application itself. + +Docker must support privileged containers and writable cgroups. Docker Desktop runs +this inside its Linux VM. The runner removes its container and image afterward. +`--container ` reuses an existing systemd container without removing it. +`ORCA_DOCKER` and `ORCA_DOCKER_PLATFORM` override the executable and architecture. + +This reproduces the orphaned `systemd-inhibit`/`sleep` pair in issue #19831's process +inventory. That pair keeps an otherwise empty scope and sleep lock alive; it does +not explain the separate multi-gigabyte workload peaks. diff --git a/config/docker/linux-lid-sleep-assertion/oracle.cjs b/config/docker/linux-lid-sleep-assertion/oracle.cjs new file mode 100644 index 000000000000..447c7f5078f4 --- /dev/null +++ b/config/docker/linux-lid-sleep-assertion/oracle.cjs @@ -0,0 +1,180 @@ +const assert = require('node:assert/strict') +const { spawn, execFileSync } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const { readFileSync, rmSync } = require('node:fs') +const { join, dirname } = require('node:path') + +const [mode, bundle] = process.argv.slice(2) +assert(['baseline', 'candidate'].includes(mode)) +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function until(check, label) { + const deadline = Date.now() + 5000 + do { + if (check()) { + return + } + await pause(50) + } while (Date.now() < deadline) + throw new Error(`Timed out: ${label}`) +} + +function live(pid) { + try { + return readFileSync(`/proc/${pid}/stat`, 'utf8').split(')').at(-1).trim()[0] !== 'Z' + } catch (error) { + if (error.code === 'ENOENT') { + return false + } + throw error + } +} + +function children(pid) { + return readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8') + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number) +} + +function ownsInhibitor(pid) { + const result = JSON.parse( + execFileSync( + 'busctl', + [ + '--json=short', + 'call', + 'org.freedesktop.login1', + '/org/freedesktop/login1', + 'org.freedesktop.login1.Manager', + 'ListInhibitors' + ], + { encoding: 'utf8' } + ) + ) + return result.data[0].some((row) => row.at(-1) === pid && row[0] === 'sleep:handle-lid-switch') +} + +function scopeActive(unit) { + return ( + execFileSync('systemctl', ['show', unit, '--property=ActiveState', '--value'], { + encoding: 'utf8' + }).trim() === 'active' + ) +} + +function kill(pid) { + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') { + throw error + } + } +} + +async function runScenario(scenario) { + const unit = `app-orca-inhibitor-oracle-${randomUUID()}.scope` + const identityFile = join('/tmp', `${unit}.pid`) + const owner = spawn( + 'systemd-run', + [ + '--scope', + `--unit=${unit}`, + '--collect', + '--quiet', + '--', + process.execPath, + join(dirname(bundle), 'owner.cjs'), + bundle, + identityFile + ], + { stdio: ['pipe', 'ignore', 'inherit'] } + ) + owner.stdin.on('error', () => {}) + const canary = spawn('sleep', ['60'], { stdio: 'ignore' }) + let ownerPid + let inhibitorPid + let holderPids = [] + try { + await until(() => { + try { + ownerPid = Number(readFileSync(identityFile, 'utf8')) + return ownerPid > 0 + } catch (error) { + if (error.code !== 'ENOENT') { + throw error + } + return false + } + }, 'production assertion owner started') + await until(() => { + inhibitorPid = children(ownerPid)[0] + return inhibitorPid > 0 && ownsInhibitor(inhibitorPid) + }, 'sleep and lid-switch lock held') + await until(() => { + holderPids = children(inhibitorPid) + return holderPids.length > 0 + }, 'holder started') + await pause(250) + assert(live(ownerPid) && live(inhibitorPid) && holderPids.every(live)) + assert(ownsInhibitor(inhibitorPid), 'lock must remain held while owner is alive') + + if (scenario === 'stop') { + owner.stdin.write('stop\n') + } else { + kill(ownerPid) + } + + if (mode === 'baseline') { + await pause(300) + assert( + live(inhibitorPid) && holderPids.every(live), + 'baseline must reproduce both orphan processes' + ) + assert(ownsInhibitor(inhibitorPid), 'baseline must retain the leaked sleep lock') + assert(scopeActive(unit), 'baseline must retain the leaked app scope') + } else { + await until( + () => !live(inhibitorPid) && holderPids.every((pid) => !live(pid)), + 'inhibitor and holder exited' + ) + assert(!ownsInhibitor(inhibitorPid), 'sleep lock must be released') + if (scenario === 'stop') { + assert(live(ownerPid), 'releasing the lock must leave the app alive') + kill(ownerPid) + } + await until(() => !scopeActive(unit), 'app scope collected after owner exit') + } + assert(live(canary.pid), 'unrelated process must survive') + console.log( + JSON.stringify({ + mode, + scenario, + holder: mode === 'baseline' ? 'live' : 'exited', + inhibitor: mode === 'baseline' ? 'held' : 'released', + scope: mode === 'baseline' ? 'active' : 'collected', + canary: 'live' + }) + ) + } finally { + for (const pid of [...holderPids, inhibitorPid, ownerPid ?? owner.pid, canary.pid].filter( + Boolean + )) { + kill(pid) + } + owner.stdin.destroy() + rmSync(identityFile, { force: true }) + } +} + +;(async () => { + await runScenario('crash') + if (mode === 'candidate') { + await runScenario('stop') + } +})().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/config/docker/linux-lid-sleep-assertion/owner.cjs b/config/docker/linux-lid-sleep-assertion/owner.cjs new file mode 100644 index 000000000000..59f6968d9fb0 --- /dev/null +++ b/config/docker/linux-lid-sleep-assertion/owner.cjs @@ -0,0 +1,8 @@ +const { writeFileSync } = require('node:fs') +const { LinuxLidSleepAssertion } = require(process.argv[2]) + +const assertion = new LinuxLidSleepAssertion() +assertion.start('docker-lifetime-oracle') +writeFileSync(process.argv[3], String(process.pid)) +process.stdin.on('data', () => assertion.stop('docker-release-oracle')) +setInterval(() => {}, 1000) diff --git a/config/docker/test-sandbox/Dockerfile b/config/docker/test-sandbox/Dockerfile index a410ad16ab72..f71274e41a21 100644 --- a/config/docker/test-sandbox/Dockerfile +++ b/config/docker/test-sandbox/Dockerfile @@ -92,6 +92,8 @@ COPY --chown=runner:runner config/patches ./config/patches RUN pnpm install --no-frozen-lockfile --no-prefer-frozen-lockfile --ignore-scripts COPY --chown=runner:runner config/scripts ./config/scripts +# Why: install-electron-package-binary.mjs re-exports its zip extractor from src/shared. +COPY --chown=runner:runner src/shared/zip-extractor-command.ts ./src/shared/zip-extractor-command.ts RUN node config/scripts/ensure-native-runtime.mjs --runtime=node \ && node config/scripts/install-electron-package-binary.mjs \ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 03bfa8bd79d8..985c91f8ca48 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -19,6 +19,12 @@ const { assertMobileWebBundleBuilt } = require('./scripts/verify-packaged-mobile-web-bundle.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { + assertBundledRipgrepInstalled, + bundledRipgrepExtraResources, + bundledRipgrepMacSignIgnore, + finalizePackagedRipgrep +} = require('./bundled-ripgrep-resources.cjs') const { verifyPackagedWindowsNodePty } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') @@ -66,6 +72,7 @@ const emojiShortcodeDatasetResource = { } const commonExtraResources = [ relayExtraResource, + ...bundledRipgrepExtraResources, bundledPluginResources, skillFreshnessResources, emojiShortcodeDatasetResource @@ -138,9 +145,9 @@ module.exports = { // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', - // Redundant under !src above, kept explicit: the built bundle ships from out/mobile-web via the - // out rules exactly as out/web does, and the source tree must never be mistaken for it. - '!src/mobile-web{,/**/*}', + '!out/orcad{,/**/*}', + '!out/orcad-template{,/**/*}', + '!out/.orcad-*{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', @@ -232,6 +239,9 @@ module.exports = { 'out/main/grok/**', 'out/main/hermes/**', 'out/main/win32-utils.js', + 'out/main/orca-profiles/profile-index-store.js', + 'out/main/persistence/profile-state/**', + 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', @@ -256,6 +266,7 @@ module.exports = { // so a test can point the guard at a scratch bundle instead of needing the repo's out/ built. beforePack: (context, mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR) => { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) + assertBundledRipgrepInstalled() assertMobileWebBundleBuilt(mobileWebBundleDir) }, afterPack: async (context) => { @@ -343,6 +354,7 @@ module.exports = { // Why: inspect electron-builder's real output so a broken extraResources // mapping fails packaging before bundled content reaches users. verifyPackagedPluginResources(resourcesDir) + finalizePackagedRipgrep(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { @@ -424,6 +436,7 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', + signIgnore: bundledRipgrepMacSignIgnore, extendInfo: { NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', @@ -597,7 +610,11 @@ module.exports = { provider: 'github', owner: 'zpyoung', repo: 'orca', - releaseType: 'release' + // Why draft on the main repo: `--publish always` otherwise creates a + // public GitHub release as soon as the first platform uploads, and + // /releases/latest serves a missing Windows exe. release-cut undrafts + // only after every required asset exists. + releaseType: 'draft' } } diff --git a/config/fork-ownership.json b/config/fork-ownership.json index f61860e8b997..3de0d159680a 100644 --- a/config/fork-ownership.json +++ b/config/fork-ownership.json @@ -854,7 +854,7 @@ "path": "config/scripts/pr-e2e-gate-contract.test.mjs", "feature": "fork-infra", "kind": "registration", - "lines": [" job === 'fork_ownership_guard'"] + "lines": [" if (job === 'code_paths' || job === 'fork_ownership_guard') {"] }, { "path": "src/shared/pane-agent-identity-inventory.test.ts", @@ -1080,6 +1080,30 @@ "kind": "passthrough", "lines": ["const TELEMETRY_ENABLED = false"] }, + { + "path": "config/scripts/pr-ready-check-reuse.test.mjs", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [ + "const PR_CHECK_JOBS = CLASSIFIED_JOBS.filter((job) => job in workflow.jobs)", + " ['code_paths', ...PR_CHECK_JOBS].toSpliced(2, 0, 'fork_ownership_guard')" + ] + }, + { + "path": "config/scripts/pr-ready-check-gate.test.mjs", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [ + "const PR_CHECK_JOBS = CLASSIFIED_JOBS.filter((job) => job in workflow.jobs)", + " FORK_OWNERSHIP_GUARD: 'success'," + ] + }, + { + "path": "src/main/updater.feed-attempt-lifetime.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [" url: 'https://github.com/zpyoung/orca/releases/latest/download'"] + }, { "path": "src/main/updater.publishing-window-feed.test.ts", "feature": "fork-infra", @@ -2189,9 +2213,9 @@ "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { terminalDockPaneOwnsFocus } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, pane.leafId))) {", - " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, clickedPane.leafId))) {" + "import { terminalDockLeafOwnsFocus } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " if (!terminalDockLeafOwnsFocus(tabId, pane.leafId)) {", + " if (!terminalDockLeafOwnsFocus(tabId, clickedPane.leafId)) {" ] }, { @@ -2505,12 +2529,12 @@ ] }, { - "path": "src/renderer/src/components/artifacts/ArtifactListRows.tsx", + "path": "src/renderer/src/components/artifacts/ArtifactListRow.tsx", "feature": "artifact-passwords", "kind": "registration", "lines": [ "import { ArtifactNameWithProtection } from './fork-artifact-passwords/artifact-protection-display'", - " " + " " ] }, { @@ -3397,8 +3421,18 @@ " all = filterWorktreesByActivity(all, opts.workspaceActivity, opts.repoMap)", " workspaceReview?: WorkspaceReviewFilterContext", " all = filterWorktreesByReview(all, opts.workspaceReview, opts.repoMap)", - "import type { WorkspaceActivityFilterContext } from './fork-workspace-activity-window/workspace-activity-filter'", - "import { getWorkspaceActivityFilterContext } from './fork-workspace-activity-window/use-workspace-activity-filter'" + "import type { WorkspaceActivityFilterContext } from './fork-workspace-activity-window/workspace-activity-filter'" + ] + }, + { + "path": "src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts", + "feature": "workspace-activity-window", + "kind": "registration", + "lines": [ + "import { getWorkspaceActivityFilterContext } from './fork-workspace-activity-window/use-workspace-activity-filter'", + "import { getWorkspaceReviewFilterContext } from './fork-workspace-review-filters/use-workspace-review-filter'", + " workspaceActivity: getWorkspaceActivityFilterContext(state),", + " workspaceReview: getWorkspaceReviewFilterContext(state)," ] }, { @@ -4196,6 +4230,33 @@ "kind": "passthrough", "lines": ["const PRE_STACK_REF = 'v1.4.201-rc.0.zy01'"] }, + { + "path": "src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts", + "feature": "hosted-review-sitter", + "kind": "registration", + "lines": [ + "import { HOSTED_REVIEW_FIX_CHECKS_CALLER_PROFILE } from '../fork-hosted-review-sitter/fix-checks-agent-launch-caller-profile'", + " HOSTED_REVIEW_FIX_CHECKS_CALLER_PROFILE" + ] + }, + { + "path": "src/renderer/src/lib/agent-launch-caller-profile-census.test.ts", + "feature": "hosted-review-sitter", + "kind": "registration", + "lines": [" expect(ids.length).toBe(13)", " 'hosted-review-fix-checks',"] + }, + { + "path": "src/main/ipc/filesystem-download-lifetime.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [" cancellations,", " cancellations"] + }, + { + "path": "tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": ["const BASELINE_REF = 'v1.4.208-rc.0.zy01'"] + }, { "path": "src/renderer/src/components/cmd-j/palette-live-status.tsx", "feature": "ask-question-tool", @@ -4312,6 +4373,12 @@ "feature": "heimdall", "kind": "registration", "lines": [" ['main/fork-heimdall/judgment/client.ts', 1]"] + }, + { + "path": "src/main/daemon/serialize-grid-transcript-replay.test.ts", + "feature": "fork-infra", + "kind": "registration", + "lines": ["KNOWN_PREEXISTING_I2_FAILURES['claude-code-ready-cold-start'] = 8"] } ], "exceptions": [ @@ -4455,7 +4522,7 @@ }, { "path": "src/renderer/src/assets/main.css", - "reason": "Adds canonical light/dark warning surface and foreground tokens for non-error attention states; the design-system extension belongs upstream rather than in feature-local CSS.", + "reason": "Adds the canonical light/dark warning foreground token beside upstream's warning hue, surface and border roles; the design-system extension belongs upstream rather than in feature-local CSS.", "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#warning-status-tokens" }, @@ -5058,12 +5125,6 @@ "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#hourly-base-case-is-pinned-to-the-repos-own-package-version" }, - { - "path": "config/scripts/run-typecheck-projects-in-parallel.mjs", - "reason": "Keeps the node and web tsc projects out of the same wave; together they peak near 20 GB and get the 16 GB ubuntu-latest typecheck runner OOM-killed.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#typecheck-projects-oom-the-ci-runner" - }, { "path": "config/scripts/ci-dependency-download-cache.test.mjs", "reason": "Contracts for the fork-owned PR job graph. Upstream asserts its Windows lane still carries the addon checks; this fork removed the Windows PR lanes, so the case asserts the removal instead.", @@ -5461,6 +5522,36 @@ "reason": "Migrates session-option command coverage to terminal-tab-scoped runtime targets.", "status": "pending-upstream", "ledger": "bug-1" + }, + { + "path": ".github/workflows/terminal-ime-e2e.yml", + "reason": "Upstream's #23270 (dffb3498e2), not yet in a stable tag: Playwright 1.63 reads a forwarded `--` as ending file selection, so focused e2e steps run the whole suite.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#focused-playwright-file-selection" + }, + { + "path": ".github/workflows/golden-e2e-experiment.yml", + "reason": "Upstream's #23270 (dffb3498e2), not yet in a stable tag: Playwright 1.63 reads a forwarded `--` as ending file selection, so focused e2e steps run the whole suite.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#focused-playwright-file-selection" + }, + { + "path": "config/scripts/run-terminal-ibus-hangul-e2e.mjs", + "reason": "Upstream's #23270 (dffb3498e2), not yet in a stable tag: Playwright 1.63 reads a forwarded `--` as ending file selection, so focused e2e steps run the whole suite.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#focused-playwright-file-selection" + }, + { + "path": "config/scripts/terminal-ime-e2e-workflow.test.mjs", + "reason": "Upstream's #23270 (dffb3498e2), not yet in a stable tag: Playwright 1.63 reads a forwarded `--` as ending file selection, so focused e2e steps run the whole suite.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#focused-playwright-file-selection" + }, + { + "path": "config/scripts/playwright-focused-workflow-selection.test.mjs", + "reason": "Upstream's #23270 (dffb3498e2), not yet in a stable tag: Playwright 1.63 reads a forwarded `--` as ending file selection, so focused e2e steps run the whole suite.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#focused-playwright-file-selection" } ], "residuals": { @@ -5577,8 +5668,8 @@ "removed": 4 }, "config/scripts/check-max-lines-ratchet.test.mjs": { - "added": 9, - "removed": 9 + "added": 22, + "removed": 22 }, "config/scripts/generate-bundled-skill-guides.mjs": { "added": 6, @@ -5589,7 +5680,7 @@ "removed": 1 }, "config/scripts/pr-e2e-gate-contract.test.mjs": { - "added": 7, + "added": 3, "removed": 1 }, "config/tsconfig.cli.json": { @@ -5677,7 +5768,7 @@ "removed": 1 }, "src/main/ipc/pty/ipc/write-input.ts": { - "added": 90, + "added": 82, "removed": 1 }, "src/main/ipc/pty/ipc/write.ts": { @@ -5825,8 +5916,8 @@ "removed": 2 }, "src/main/runtime/orca-runtime.ts": { - "added": 7, - "removed": 2 + "added": 8, + "removed": 3 }, "src/main/runtime/rpc/methods/artifacts.ts": { "added": 3, @@ -5948,6 +6039,18 @@ "added": 2, "removed": 1 }, + "config/scripts/pr-ready-check-reuse.test.mjs": { + "added": 6, + "removed": 8 + }, + "config/scripts/pr-ready-check-gate.test.mjs": { + "added": 3, + "removed": 1 + }, + "src/main/updater.feed-attempt-lifetime.test.ts": { + "added": 1, + "removed": 1 + }, "src/main/updater.publishing-window-feed.test.ts": { "added": 5, "removed": 5 @@ -6016,7 +6119,7 @@ "added": 3, "removed": 13 }, - "src/renderer/src/components/artifacts/ArtifactListRows.tsx": { + "src/renderer/src/components/artifacts/ArtifactListRow.tsx": { "added": 2, "removed": 3 }, @@ -6284,9 +6387,13 @@ "added": 7, "removed": 2 }, + "src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts": { + "added": 4, + "removed": 0 + }, "src/renderer/src/components/sidebar/visible-worktrees.ts": { - "added": 19, - "removed": 1 + "added": 14, + "removed": 0 }, "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx": { "added": 18, @@ -6473,7 +6580,7 @@ "removed": 3 }, "src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts": { - "added": 16, + "added": 15, "removed": 3 }, "src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts": { @@ -7005,8 +7112,8 @@ "removed": 0 }, "src/main/runtime/rpc/dispatcher.ts": { - "added": 2, - "removed": 1 + "added": 1, + "removed": 0 }, "src/main/runtime/rpc/errors.ts": { "added": 9, @@ -7196,6 +7303,22 @@ "added": 20, "removed": 0 }, + "src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts": { + "added": 3, + "removed": 1 + }, + "src/renderer/src/lib/agent-launch-caller-profile-census.test.ts": { + "added": 2, + "removed": 1 + }, + "src/main/ipc/filesystem-download-lifetime.test.ts": { + "added": 7, + "removed": 1 + }, + "tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts": { + "added": 1, + "removed": 1 + }, "tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts": { "added": 1, "removed": 1 @@ -7255,6 +7378,10 @@ "src/main/proxy-guarded-fetch-call-site-audit.test.ts": { "added": 2, "removed": 1 + }, + "src/main/daemon/serialize-grid-transcript-replay.test.ts": { + "added": 1, + "removed": 0 } } } diff --git a/config/knip.json b/config/knip.json index e5a014b32e14..9af0b8d1a745 100644 --- a/config/knip.json +++ b/config/knip.json @@ -16,7 +16,6 @@ "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", "src/main/agent-hooks/managed-agent-hook-controls.ts", "src/main/claude-accounts/keychain.ts", - "src/mobile-web/src/bootstrap.ts", "src/renderer/src/main.tsx", "src/renderer/src/popout.tsx", "src/renderer/src/web/main.tsx", diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 693a5933a3bf..f943c7d56a01 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -222,5 +222,194 @@ "text": ". Switch to System shell or choose an executable on this host.", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:keywords", + "text": "arguments", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:keywords", + "text": "args", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:keywords", + "text": "login", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:keywords", + "text": "wrapper", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:keywords", + "text": "rcfile", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-text", + "text": "Advanced", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-text", + "text": "Shell arguments", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-expression", + "text": "Starts the shell as a login shell with -l.", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-expression", + "text": "Enter one argument per line. Leave it empty to pass no arguments.", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-attribute:ariaLabel", + "text": "Shell argument mode", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:label", + "text": "-l (default)", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "object-property:label", + "text": "Custom args", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-attribute:placeholder", + "text": "--rcfile /path/to/rcfile", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "kind": "jsx-attribute:aria-label", + "text": "Shell arguments, one per line", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:title", + "text": "Terminal shell", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:description", + "text": "Shell and arguments used for new local interactive terminal panes", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "shell", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "terminal", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "fish", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "zsh", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "bash", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "nushell", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "arguments", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "args", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "login", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "wrapper", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-search.ts", + "kind": "object-property:keywords", + "text": "rcfile", + "dynamic": false, + "count": 1 } ] diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 3d4a968ea23f..c88d70350eb0 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -9,6 +9,7 @@ const { } = require('node:fs') const { dirname, join, resolve } = require('node:path') const { builtinModules, createRequire } = require('node:module') +const { PE_MACHINE, readPeMachine } = require('./scripts/windows-pe-machine.cjs') const projectDir = resolve(__dirname, '..') const requireFromProject = createRequire(join(projectDir, 'package.json')) @@ -367,6 +368,16 @@ function ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) { } } +/** Whether node-pty's source build holds a conpty.node the `electronArch` slice could load. */ +function conptyTargetsArch(nodePtyDir, electronArch) { + const releaseAddon = join(nodePtyDir, 'build', 'Release', 'conpty.node') + if (!existsSync(releaseAddon)) { + return false + } + // Null (not a PE) counts as unloadable, so a truncated or quarantined build keeps the fallback. + return readPeMachine(releaseAddon) === PE_MACHINE[normalizeNodePtyWindowsArch(electronArch)] +} + function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) { const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') if (!existsSync(nodePtyDir)) { @@ -388,14 +399,14 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) // require, and its caller resolves null with silent: true), and removes the // winpty backend that node-pty still selects below Windows build 18309. // - // Why the arch check: a cross-arch package copies the host's build/Release, - // so its mere presence does not mean it matches electronArch -- deleting the - // target-arch prebuild would then remove the only loadable binary. - if ( - electronPlatformName === 'win32' && - electronArch === process.arch && - existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node')) - ) { + // Why the arch check: a cross-HOST package can copy a build/Release that is not a Windows + // binary at all, so its mere presence does not mean the target can load it -- deleting the + // target-arch prebuild would then remove the only loadable binary. This used to approximate + // that with `electronArch === process.arch`, which also skipped the arm64 slice cross-built on + // an x64 Windows host -- a rebuild that DOES emit a correct arm64 addon. That slice kept the + // unpatched prebuild as a reachable fallback for any later load failure of build/Release. + // Read the PE header instead of guessing. + if (electronPlatformName === 'win32' && conptyTargetsArch(nodePtyDir, electronArch)) { const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`) for (const staleFallback of ['conpty.node', 'conpty.pdb']) { rmSync(join(prebuildDir, staleFallback), { force: true }) diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 7c930a5fca56..3855e187ec68 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -28,13 +28,14 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304 "/guard:cf", "/sdl", diff --git a/lib/index.js b/lib/index.js -index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644 +index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e9485f9d181d 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); +@@ -7,11 +7,14 @@ exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0; const util_1 = require("util"); const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime; +exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags; var ProcessDataFlag; (function (ProcessDataFlag) { @@ -45,7 +46,7 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba })(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {})); // requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls // to this cannot be done at the same time. -@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) { +@@ -66,11 +69,12 @@ // • the properties are inlined/splatted // • the 'ppid' field is omitted // • the depth of the tree is limited by `maxDepth` @@ -60,13 +61,14 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba }); return buildNode(root, maxDepth); diff --git a/lib/index.ts b/lib/index.ts -index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644 +index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7530b8cf3 100644 --- a/lib/index.ts +++ b/lib/index.ts -@@ -6,12 +6,15 @@ +@@ -6,12 +6,16 @@ import { promisify } from 'util'; const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime; +/** The flag bits this compiled addon reports; undefined off win32. */ +export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags; import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree'; @@ -80,7 +82,7 @@ index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f238 } type RequestCallback = (processList: IProcessInfo[]) => void; -@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: IterableQueue(); + } + ++Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) { ++ Napi::Env env(args.Env()); ++ if (args.Length() != 1 || !args[0].IsNumber()) { ++ return env.Undefined(); ++ } ++ const double pid = args[0].As().DoubleValue(); ++ if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast(pid)) { ++ return env.Undefined(); ++ } ++ ProcessInfo pinfo{}; ++ pinfo.pid = static_cast(pid); ++ GetProcessCreationTime(pinfo); ++ if (pinfo.creationTimeMs == 0) { ++ return env.Undefined(); ++ } ++ return Napi::Number::New(env, static_cast(pinfo.creationTimeMs)); ++} ++ Napi::Object Init(Napi::Env env, Napi::Object exports) { ++ exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime)); exports.Set("getProcessList", Napi::Function::New(env, GetProcessList)); exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage)); + // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is @@ -398,10 +422,10 @@ index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529 } diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts -index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644 +index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256bdbaf782f 100644 --- a/typings/windows-process-tree.d.ts +++ b/typings/windows-process-tree.d.ts -@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' { +@@ -7,8 +7,17 @@ export enum ProcessDataFlag { None = 0, Memory = 1, @@ -409,18 +433,18 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 + CommandLine = 2, + CreationTime = 4 } - ++ + /** + * The flag bits the compiled addon actually understands, or undefined off + * win32. `ProcessDataFlag` above is source; this is what the binary reports, + * so it is the only way to tell a patched build from a stale prebuilt. + */ + export const supportedProcessDataFlags: number | undefined; -+ ++ export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined; + export interface IProcessInfo { pid: number; - ppid: number; -@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' { +@@ -24,6 +33,9 @@ * The string returned is at most 512 chars, strings exceeding this length are truncated. */ commandLine?: string; @@ -430,7 +454,7 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 } export interface IProcessCpuInfo extends IProcessInfo { -@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' { +@@ -35,6 +47,7 @@ name: string; memory?: number; commandLine?: string; diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch index 1e2c857f9318..c20f2b2864b3 100644 --- a/config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch @@ -1,23 +1,23 @@ diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js -index d669293d06d801ef472d5c2454ce86876e5c321f..b4f77e4976dbe5179d93a44ef919730a828d3331 100644 +index d669293d06d801ef472d5c2454ce86876e5c321f..717f05510e4da07f8acb81097ca1bcee90aac472 100644 --- a/lib/addon-serialize.js +++ b/lib/addon-serialize.js @@ -1,2 +1,2 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); -+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t){return t.extended?.urlId??0}function n(t,e){if(!e)return"";const r=t._core?._oscLinkService?.getLinkData(e);return r?`]8;${void 0===r.id?"":`id=${r.id}`};${r.uri}\\`:""}function o(t,e,r){return Math.max(e,Math.min(t,r))}class l{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!u(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),l=o.getWidth()>1;let a=!1;if(o.getChars()&&(l?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&u(i,o)&&(a=!0),l&&(n.getChars()||0===n.getWidth())&&u(i,o)&&u(n,o)&&(a=!0)),!a){if(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0){r+="";const t=e.length-this._nullCellCount;t>0&&(r+=`[${t}C`),r+=`[${this._nullCellCount}X`,t>0&&(r+=`[${t}D`),r+=""}this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0}}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(f(t,e))return r;const s=!a(t,e),i=!u(t,e),n=!c(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27);const s=t.isBold()!==e.isBold(),i=t.isDim()!==e.isDim();if(s||i){const e=s&&!t.isBold()||i&&!t.isDim();e&&r.push(22),t.isBold()&&(s||e)&&r.push(1),t.isDim()&&(i||e)&&r.push(2)}if(h(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_setOscLink(t){if(t===this._activeOscLinkId)return"";let e=this._activeOscLinkId?"]8;;\\":"";this._activeOscLinkId=0;const r=n(this._terminal,t);return r&&(this._activeOscLinkId=t,e+=r),e}_nextCell(t,e,r,s){if(0===t.getWidth())return;const n=""===t.getChars(),o=n&&t.isInverse()?this._buffer.getLine(r+1):void 0,l=o?.getCell(0,this._nextRowFirstChar),a=s===this._terminal.cols-1&&o?.isWrapped&&(l?.getWidth()??0)>1&&!!l&&f(t,l),h=n&&!!t.isInverse()&&!a,c=this._diffStyle(t,this._cursorStyle);if(n?h?c.length>0:!u(this._cursorStyle,t):c.length>0){this._nullCellCount>0&&(u(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${c.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}if(n&&!h)this._nullCellCount+=t.getWidth();else{if(this._nullCellCount>0&&(u(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=this._setOscLink(i(t)),h){const e=!!t.isUnderline()||!!t.isStrikethrough()||!!t.isOverline();if(e&&(this._currentRow+=""),this._currentRow+=" ".repeat(t.getWidth()),e){const e=this._diffStyle(t,this._defaultCell);this._currentRow+=`[${e.join(";")}m`}}else this._currentRow+=t.getChars();this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth()}}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,i=void 0===r?s:o(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-i,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new _(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new d(r,t,e),i=e.onlySelection??!1,n=e.range;if(n)return s.serialize({start:{x:n.startCol,y:(n.startLine,n.startLine)},end:{x:t.cols,y:(n.endLine,n.endLine)}});if(!i){const i=r.length,n=e.scrollback,l=void 0===n?i:o(n+t.rows,0,i);return s.serialize({start:{x:0,y:i-l},end:{x:t.cols,y:i-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e+=function(t){const e=t._core?._inputHandler?._curAttrData;return e?n(t,i(e)):""}(this._terminal),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class d extends l{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(f(t,e))return;const s=!a(t,e),i=!u(t,e),n=!c(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=d})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=_;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function _(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,_=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=_+Math.round((u-_)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=_(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=_(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=_(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,_=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=_+Math.round((u-_)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(_(i,n)>8));if(o_(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=_(i,u.relativeLuminance(o>>8));if(a_(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t){return t.extended?.urlId??0}function n(t,e){if(!e)return"";const r=t._core?._oscLinkService?.getLinkData(e);return r?`]8;${void 0===r.id?"":`id=${r.id}`};${r.uri}\\`:""}function o(t,e){return Math.min(t.length,e)}function l(t,e,r,s){return e===r-1&&t.length>r&&s.getWidth()>1}function a(t){return t.content=4194304,t}function u(t,e,r){return Math.max(e,Math.min(t,r))}class h{constructor(t,e){this._buffer=t,this._cols=e}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,u=t.end.y,h=t.start.x,c=t.end.x;this._beforeSerialize(u-n,n,u);for(let e=n;e<=u;e++){const n=this._buffer.getLine(e);if(n){const u=e===t.start.y?h:0,_=e===t.end.y?c:o(n,this._cols);for(let t=u;t<_;t++){let o=n.getCell(t,i===r?s:r);o?(l(n,t,this._cols,o)&&(o=a(o)),this._nextCell(o,i,e,t),i=o):console.warn(`Can't get cell at row=${e}, col=${t}`)}}this._rowEnd(e,e===u)}return this._afterSerialize(),this._serializeString(e)}_nextCell(t,e,r,s){}_rowEnd(t,e){}_beforeSerialize(t,e,r){}_afterSerialize(){}_serializeString(t){return""}}function c(t,e){return t.getFgColorMode()===e.getFgColorMode()&&t.getFgColor()===e.getFgColor()}function _(t,e){return t.getBgColorMode()===e.getBgColorMode()&&t.getBgColor()===e.getBgColor()}function f(t,e){if(!t.isUnderline()&&!e.isUnderline())return!0;if(t.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const r=t.isUnderlineColorDefault(),s=e.isUnderlineColorDefault();return!(!r||!s)||r===s&&t.getUnderlineColor()===e.getUnderlineColor()&&t.getUnderlineColorMode()===e.getUnderlineColorMode()}function C(t,e){return t.isInverse()===e.isInverse()&&t.isBold()===e.isBold()&&t.isUnderline()===e.isUnderline()&&f(t,e)&&t.isOverline()===e.isOverline()&&t.isBlink()===e.isBlink()&&t.isInvisible()===e.isInvisible()&&t.isItalic()===e.isItalic()&&t.isDim()===e.isDim()&&t.isStrikethrough()===e.isStrikethrough()}function d(t,e){const r=t;return"function"==typeof r.attributesEquals?r.attributesEquals(e):c(t,e)&&_(t,e)&&C(t,e)}class g extends h{constructor(t,e){super(t,e.cols),this._terminal=e,this._rowIndex=0,this._allRows=new Array,this._allRowSeparators=new Array,this._currentRow="",this._nullCellCount=0,this._cursorStyle=this._buffer.getNullCell(),this._cursorStyleRow=0,this._cursorStyleCol=0,this._backgroundCell=this._buffer.getNullCell(),this._defaultCell=this._buffer.getNullCell(),this._firstRow=0,this._lastCursorRow=0,this._lastCursorCol=0,this._lastContentCursorRow=0,this._lastContentCursorCol=0,this._activeOscLinkId=0,this._lastBackgroundCursorRow=-1,this._lastBackgroundCursorCol=0,this._thisRowLastChar=this._buffer.getNullCell(),this._thisRowLastSecondChar=this._buffer.getNullCell(),this._nextRowFirstChar=this._buffer.getNullCell()}_beforeSerialize(t,e,r){this._allRows=new Array(t),this._lastContentCursorRow=e,this._lastCursorRow=e,this._firstRow=e}_rowEnd(t,e){this._nullCellCount>0&&!_(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`,this._lastBackgroundCursorRow=this._lastCursorRow,this._lastBackgroundCursorCol=this._lastCursorCol);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=o(e,this._terminal.cols);let n=e.getCell(i-1,this._thisRowLastChar);l(e,i-1,this._terminal.cols,n)&&(n=a(n));const u=e.getCell(i-2,this._thisRowLastSecondChar),h=s.getCell(0,this._nextRowFirstChar),c=h.getWidth()>1;let f=!1;if(h.getChars()&&(c?this._nullCellCount<=1:this._nullCellCount<=0)&&((n.getChars()||0===n.getWidth())&&_(n,h)&&(f=!0),c&&(u.getChars()||0===u.getWidth())&&_(n,h)&&_(u,h)&&(f=!0)),!f){if(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0){r+="";const t=i-this._nullCellCount;t>0&&(r+=`[${t}C`),r+=`[${this._nullCellCount}X`,t>0&&(r+=`[${t}D`),r+=""}this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0}}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(d(t,e))return r;const s=!c(t,e),i=!_(t,e),n=!C(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27);const s=t.isBold()!==e.isBold(),i=t.isDim()!==e.isDim();if(s||i){const e=s&&!t.isBold()||i&&!t.isDim();e&&r.push(22),t.isBold()&&(s||e)&&r.push(1),t.isDim()&&(i||e)&&r.push(2)}if(f(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_setOscLink(t){if(t===this._activeOscLinkId)return"";let e=this._activeOscLinkId?"]8;;\\":"";this._activeOscLinkId=0;const r=n(this._terminal,t);return r&&(this._activeOscLinkId=t,e+=r),e}_nextCell(t,e,r,s){if(0===t.getWidth())return;const n=""===t.getChars(),o=n&&t.isInverse()?this._buffer.getLine(r+1):void 0,l=o?.getCell(0,this._nextRowFirstChar),a=s===this._terminal.cols-1&&o?.isWrapped&&(l?.getWidth()??0)>1&&!!l&&d(t,l),u=n&&!!t.isInverse()&&!a,h=this._diffStyle(t,this._cursorStyle);if(n?u?h.length>0:!_(this._cursorStyle,t):h.length>0){this._nullCellCount>0&&(_(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${h.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}if(n&&!u)this._nullCellCount+=t.getWidth();else{if(this._nullCellCount>0&&(_(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=this._setOscLink(i(t)),u){const e=!!t.isUnderline()||!!t.isStrikethrough()||!!t.isOverline();if(e&&(this._currentRow+=""),this._currentRow+=" ".repeat(t.getWidth()),e){const e=this._diffStyle(t,this._defaultCell);this._currentRow+=`[${e.join(";")}m`}}else this._currentRow+=t.getChars();this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth()}}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(this._lastBackgroundCursorRow>this._lastContentCursorRow&&this._buffer.cursorX{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,i=void 0===r?s:u(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-i,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new g(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new p(r,t,e),i=e.onlySelection??!1,n=e.range;if(n)return s.serialize({start:{x:n.startCol,y:(n.startLine,n.startLine)},end:{x:t.cols,y:(n.endLine,n.endLine)}});if(!i){const i=r.length,n=e.scrollback,o=void 0===n?i:u(n+t.rows,0,i);return s.serialize({start:{x:0,y:i-o},end:{x:t.cols,y:i-1}})}const o=this._terminal?.getSelectionPosition();return void 0!==o?s.serialize({start:{x:o.start.x,y:o.start.y},end:{x:o.end.x,y:o.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e+=function(t){const e=t._core?._inputHandler?._curAttrData;return e?n(t,i(e)):""}(this._terminal),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class p extends h{constructor(t,r,s){super(t,r.cols),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(d(t,e))return;const s=!c(t,e),i=!_(t,e),n=!C(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=p})(),s})()); //# sourceMappingURL=addon-serialize.js.map \ No newline at end of file diff --git a/lib/addon-serialize.js.map b/lib/addon-serialize.js.map -index bac96176091146ab7eb07d1d7b280286e047278c..54a78516a87fe7fe3ea2c61bc6d778b1f29acc6c 100644 +index bac96176091146ab7eb07d1d7b280286e047278c..5a26191e7b17a6d7ae2b380b7eb7721a789fccb6 100644 --- a/lib/addon-serialize.js.map +++ b/lib/addon-serialize.js.map @@ -1 +1 @@ -{"version":3,"file":"addon-serialize.js","mappings":"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","sources":["webpack://SerializeAddon/webpack/universalModuleDefinition","webpack://SerializeAddon/../src/browser/Types.ts","webpack://SerializeAddon/../src/common/Color.ts","webpack://SerializeAddon/webpack/bootstrap","webpack://SerializeAddon/./src/SerializeAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"SerializeAddon\"] = factory();\n\telse\n\t\troot[\"SerializeAddon\"] = factory();\n})(globalThis, () => {\nreturn ","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: line.length;\n for (let col = startLineColumn; col < endLineColumn; col++) {\n const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;\n const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n rowSeparator += `\\u001b[${currentLine.length - this._nullCellCount}C`;\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n rowSeparator += `\\u001b[${currentLine.length - this._nullCellCount}D`;\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n // the empty cell style is only assumed to be changed when background changed, because\n // foreground is always 0.\n const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n this._currentRow += cell.getChars();\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","Color_1","__webpack_require__","DEFAULT_ANSI_COLORS","Object","freeze","colors","css","toColor","v","i","r","g","b","push","channels","toCss","rgba","toRgba","c","$r","$g","$b","$a","color","rgb","toPaddedHex","s","toString","length","contrastRatio","l1","l2","NULL_COLOR","a","undefined","color_1","opacity","Math","round","toChannels","blend","bg","fg","fgR","fgG","fgB","bgR","bgG","bgB","isOpaque","ensureContrastRatio","ratio","result","opaque","rgbaColor","multiplyOpacity","factor","toColorRGB","css_1","$ctx","$litmusColor","canvas","document","createElement","width","height","ctx","getContext","willReadFrequently","globalCompositeOperation","createLinearGradient","match","parseInt","slice","repeat","rgbaMatch","parseFloat","Error","fillStyle","fillRect","getImageData","data","rgb_1","relativeLuminance2","rs","gs","bs","pow","relativeLuminance","reduceLuminance","bgRgba","fgRgba","cr","max","ceil","increaseLuminance","min","bgL","fgL","resultA","resultARatio","resultB","value","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Types_1","constrain","low","high","BaseSerializeHandler","constructor","_buffer","serialize","range","excludeFinalCursorPosition","cell1","this","getNullCell","cell2","oldCell","startRow","start","y","endRow","end","startColumn","x","endColumn","_beforeSerialize","row","line","getLine","startLineColumn","endLineColumn","col","getCell","_nextCell","console","warn","_rowEnd","_afterSerialize","_serializeString","cell","isLastRow","rows","equalFg","getFgColorMode","getFgColor","equalBg","getBgColorMode","getBgColor","equalUnderline","isUnderline","getUnderlineStyle","cell1Default","isUnderlineColorDefault","cell2Default","getUnderlineColor","getUnderlineColorMode","equalFlags","isInverse","isBold","isOverline","isBlink","isInvisible","isItalic","isDim","isStrikethrough","attributesEquals","cell1AsBufferCell","StringSerializeHandler","buffer","_terminal","super","_rowIndex","_allRows","Array","_allRowSeparators","_currentRow","_nullCellCount","_cursorStyle","_cursorStyleRow","_cursorStyleCol","_backgroundCell","_firstRow","_lastCursorRow","_lastCursorCol","_lastContentCursorRow","_lastContentCursorCol","_thisRowLastChar","_thisRowLastSecondChar","_nextRowFirstChar","rowSeparator","currentLine","nextLine","isWrapped","thisRowLastChar","thisRowLastSecondChar","nextRowFirstChar","isNextRowFirstCharDoubleWidth","getWidth","isValid","getChars","_diffStyle","sgrSeq","fgChanged","bgChanged","flagsChanged","isAttributeDefault","isFgRGB","isFgPalette","isBgRGB","isBgPalette","style","isUnderlineColorRGB","isEmptyCell","join","rowEnd","content","realCursorRow","baseY","cursorY","realCursorCol","cursorX","moveRight","offset","curAttrData","_core","_inputHandler","_curAttrData","activate","terminal","_serializeBufferByScrollback","scrollback","maxRows","correctRows","_serializeBufferByRange","cols","_serializeBufferAsHTML","options","active","handler","HTMLSerializeHandler","onlySelection","startCol","startLine","endLine","selection","getSelectionPosition","_serializeScrollRegion","scrollTop","scrollBottom","_serializeModes","modes","applicationCursorKeysMode","applicationKeypadMode","bracketedPasteMode","insertMode","originMode","reverseWraparoundMode","sendFocusMode","wraparoundMode","mouseTrackingMode","showCursor","normal","excludeAltBuffer","type","alternate","excludeModes","serializeAsHTML","dispose","_options","_htmlContent","_themeService","_ansiColors","ansi","foreground","background","includeGlobalBackground","theme","globalStyleDefinitions","fontFamily","fontSize","_getHexColor","isFg","map","padStart","_getUnderlineColor","_getUnderlineStyle","fgHexColor","bgHexColor","decorations","underlineColor","styleDefinitions","escapeHTMLChar"],"sourceRoot":""} \ No newline at end of file -+{"version":3,"file":"addon-serialize.js","mappings":"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","sources":["webpack://SerializeAddon/webpack/universalModuleDefinition","webpack://SerializeAddon/../src/browser/Types.ts","webpack://SerializeAddon/../src/common/Color.ts","webpack://SerializeAddon/webpack/bootstrap","webpack://SerializeAddon/./src/SerializeAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"SerializeAddon\"] = factory();\n\telse\n\t\troot[\"SerializeAddon\"] = factory();\n})(globalThis, () => {\nreturn ","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\ntype OscLinkData = { id?: string; uri: string };\ntype OscLinkedCell = { extended?: { urlId?: number } };\ntype TerminalWithOscLinks = Terminal & {\n _core?: {\n _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };\n _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };\n };\n};\n\nfunction getOscLinkId(cell: IBufferCell | IAttributeData): number {\n return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;\n}\n\nfunction getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {\n if (!linkId) {\n return '';\n }\n const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);\n if (!data) {\n return '';\n }\n const params = data.id === undefined ? '' : `id=${data.id}`;\n return `\\u001b]8;${params};${data.uri}\\u001b\\\\`;\n}\n\nfunction getActiveOscLinkSequence(terminal: Terminal): string {\n const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;\n return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';\n}\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: line.length;\n for (let col = startLineColumn; col < endLineColumn; col++) {\n const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n private _defaultCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n private _activeOscLinkId: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;\n const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n const contentCellCount = currentLine.length - this._nullCellCount;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}C`;\n }\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}D`;\n }\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so\n // they must be diffed as one intensity group with the clearing 22 emitted\n // BEFORE any re-set. Upstream's independent per-flag diff could emit\n // \"1;22\" (bold set, then wiped by dim's clear — \\x1b[2mA\\x1b[22m\\x1b[1mB\n // loses B's bold on round-trip) or a bare \"22\" that drops a still-set\n // bold/dim, garbling Orca's hidden-terminal snapshot restores.\n const boldChanged = cell.isBold() !== oldCell.isBold();\n const dimChanged = cell.isDim() !== oldCell.isDim();\n if (boldChanged || dimChanged) {\n const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());\n if (clearsIntensity) { sgrSeq.push(22); }\n if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }\n if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }\n }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n // PATCH(orca): dim handled in the intensity group above.\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n private _setOscLink(linkId: number): string {\n if (linkId === this._activeOscLinkId) {\n return '';\n }\n let sequence = this._activeOscLinkId ? '\\u001b]8;;\\u001b\\\\' : '';\n this._activeOscLinkId = 0;\n const openSequence = getOscLinkOpenSequence(this._terminal, linkId);\n if (openSequence) {\n this._activeOscLinkId = linkId;\n sequence += openSequence;\n }\n return sequence;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;\n const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);\n // A pending wide glyph recreates its own final-column padding during replay.\n const isWideWrapPadding = col === this._terminal.cols - 1 &&\n nextLine?.isWrapped &&\n (nextRowFirstCell?.getWidth() ?? 0) > 1 &&\n !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);\n // Cursor movement cannot reproduce an inverse cell's visible background.\n const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n const styleChanged = isEmptyCell\n ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)\n : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell && !materializeEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.\n this._currentRow += this._setOscLink(getOscLinkId(cell));\n\n if (materializeEmptyCell) {\n const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();\n if (hasDecoration) {\n this._currentRow += '\\u001b[24;29;55m';\n }\n this._currentRow += ' '.repeat(cell.getWidth());\n if (hasDecoration) {\n const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);\n this._currentRow += `\\u001b[0m\\u001b[${restoreSgrSeq.join(';')}m`;\n }\n } else {\n this._currentRow += cell.getChars();\n }\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // Each buffer is self-contained so links cannot leak into a following buffer.\n content += this._setOscLink(0);\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n // Restore the source terminal's live OSC pen only after all buffers are complete.\n content += getActiveOscLinkSequence(this._terminal);\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","Color_1","__webpack_require__","DEFAULT_ANSI_COLORS","Object","freeze","colors","css","toColor","v","i","r","g","b","push","channels","toCss","rgba","toRgba","c","$r","$g","$b","$a","color","rgb","toPaddedHex","s","toString","length","contrastRatio","l1","l2","NULL_COLOR","a","undefined","color_1","opacity","Math","round","toChannels","blend","bg","fg","fgR","fgG","fgB","bgR","bgG","bgB","isOpaque","ensureContrastRatio","ratio","result","opaque","rgbaColor","multiplyOpacity","factor","toColorRGB","css_1","$ctx","$litmusColor","canvas","document","createElement","width","height","ctx","getContext","willReadFrequently","globalCompositeOperation","createLinearGradient","match","parseInt","slice","repeat","rgbaMatch","parseFloat","Error","fillStyle","fillRect","getImageData","data","rgb_1","relativeLuminance2","rs","gs","bs","pow","relativeLuminance","reduceLuminance","bgRgba","fgRgba","cr","max","ceil","increaseLuminance","min","bgL","fgL","resultA","resultARatio","resultB","value","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Types_1","getOscLinkId","cell","extended","urlId","getOscLinkOpenSequence","terminal","linkId","_core","_oscLinkService","getLinkData","id","uri","constrain","low","high","BaseSerializeHandler","constructor","_buffer","serialize","range","excludeFinalCursorPosition","cell1","this","getNullCell","cell2","oldCell","startRow","start","y","endRow","end","startColumn","x","endColumn","_beforeSerialize","row","line","getLine","startLineColumn","endLineColumn","col","getCell","_nextCell","console","warn","_rowEnd","_afterSerialize","_serializeString","isLastRow","rows","equalFg","getFgColorMode","getFgColor","equalBg","getBgColorMode","getBgColor","equalUnderline","isUnderline","getUnderlineStyle","cell1Default","isUnderlineColorDefault","cell2Default","getUnderlineColor","getUnderlineColorMode","equalFlags","isInverse","isBold","isOverline","isBlink","isInvisible","isItalic","isDim","isStrikethrough","attributesEquals","cell1AsBufferCell","StringSerializeHandler","buffer","_terminal","super","_rowIndex","_allRows","Array","_allRowSeparators","_currentRow","_nullCellCount","_cursorStyle","_cursorStyleRow","_cursorStyleCol","_backgroundCell","_defaultCell","_firstRow","_lastCursorRow","_lastCursorCol","_lastContentCursorRow","_lastContentCursorCol","_activeOscLinkId","_thisRowLastChar","_thisRowLastSecondChar","_nextRowFirstChar","rowSeparator","currentLine","nextLine","isWrapped","thisRowLastChar","thisRowLastSecondChar","nextRowFirstChar","isNextRowFirstCharDoubleWidth","getWidth","isValid","getChars","contentCellCount","_diffStyle","sgrSeq","fgChanged","bgChanged","flagsChanged","isAttributeDefault","isFgRGB","isFgPalette","isBgRGB","isBgPalette","boldChanged","dimChanged","clearsIntensity","style","isUnderlineColorRGB","_setOscLink","sequence","openSequence","isEmptyCell","nextRowFirstCell","isWideWrapPadding","cols","materializeEmptyCell","join","hasDecoration","restoreSgrSeq","rowEnd","content","realCursorRow","baseY","cursorY","realCursorCol","cursorX","moveRight","offset","curAttrData","_inputHandler","_curAttrData","activate","_serializeBufferByScrollback","scrollback","maxRows","correctRows","_serializeBufferByRange","_serializeBufferAsHTML","options","active","handler","HTMLSerializeHandler","onlySelection","startCol","startLine","endLine","selection","getSelectionPosition","_serializeScrollRegion","scrollTop","scrollBottom","_serializeModes","modes","applicationCursorKeysMode","applicationKeypadMode","bracketedPasteMode","insertMode","originMode","reverseWraparoundMode","sendFocusMode","wraparoundMode","mouseTrackingMode","showCursor","normal","excludeAltBuffer","type","alternate","excludeModes","attrs","getActiveOscLinkSequence","serializeAsHTML","dispose","_options","_htmlContent","_themeService","_ansiColors","ansi","foreground","background","includeGlobalBackground","theme","globalStyleDefinitions","fontFamily","fontSize","_getHexColor","isFg","map","padStart","_getUnderlineColor","_getUnderlineStyle","fgHexColor","bgHexColor","decorations","underlineColor","styleDefinitions","escapeHTMLChar"],"sourceRoot":""} ++{"version":3,"file":"addon-serialize.js","mappings":"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","sources":["webpack://SerializeAddon/webpack/universalModuleDefinition","webpack://SerializeAddon/../src/browser/Types.ts","webpack://SerializeAddon/../src/common/Color.ts","webpack://SerializeAddon/webpack/bootstrap","webpack://SerializeAddon/./src/SerializeAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"SerializeAddon\"] = factory();\n\telse\n\t\troot[\"SerializeAddon\"] = factory();\n})(globalThis, () => {\nreturn ","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferLine, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\ntype OscLinkData = { id?: string; uri: string };\ntype OscLinkedCell = { extended?: { urlId?: number } };\ntype TerminalWithOscLinks = Terminal & {\n _core?: {\n _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };\n _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };\n };\n};\n\nfunction getOscLinkId(cell: IBufferCell | IAttributeData): number {\n return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;\n}\n\nfunction getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {\n if (!linkId) {\n return '';\n }\n const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);\n if (!data) {\n return '';\n }\n const params = data.id === undefined ? '' : `id=${data.id}`;\n return `\\u001b]8;${params};${data.uri}\\u001b\\\\`;\n}\n\nfunction getActiveOscLinkSequence(terminal: Terminal): string {\n const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;\n return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';\n}\n\n// PATCH(orca): a non-reflowing shrink (always the alternate buffer) leaves each\n// line at its old length; cells past the grid are not on screen and would wrap\n// into extra rows on replay, so serialize only the terminal's width.\nfunction visibleCellCount(line: IBufferLine, cols: number): number {\n return Math.min(line.length, cols);\n}\n\n// PATCH(orca): a wide glyph whose trailing half fell past the grid cannot be\n// replayed in the last column (it would wrap and shift every row below), so it\n// is serialized as a blank that keeps the row exactly the grid's width.\nfunction isClippedWideCell(line: IBufferLine, col: number, cols: number, cell: IBufferCell): boolean {\n return col === cols - 1 && line.length > cols && cell.getWidth() > 1;\n}\n\n// Width 1 with no codepoint (CellData.content layout). A width-0 stand-in would read as a\n// wide glyph's trailer: skipped by _nextCell and treated as content by the wrap check.\nconst BLANK_CELL_CONTENT = 1 << 22;\n\nfunction blankClippedWideCell(cell: IBufferCell): IBufferCell {\n (cell as IBufferCell & { content: number }).content = BLANK_CELL_CONTENT;\n return cell;\n}\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer,\n private readonly _cols: number\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: visibleCellCount(line, this._cols);\n for (let col = startLineColumn; col < endLineColumn; col++) {\n let c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n if (isClippedWideCell(line, col, this._cols, c)) {\n c = blankClippedWideCell(c);\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n private _defaultCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n private _activeOscLinkId: number = 0;\n private _lastBackgroundCursorRow: number = -1;\n private _lastBackgroundCursorCol: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer, _terminal.cols);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n // PATCH(orca): the erase paints this row, so _serializeString must not trim it as trailing blank.\n this._lastBackgroundCursorRow = this._lastCursorRow;\n this._lastBackgroundCursorCol = this._lastCursorCol;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const currentLineCells = visibleCellCount(currentLine, this._terminal.cols);\n let thisRowLastChar = currentLine.getCell(currentLineCells - 1, this._thisRowLastChar)!;\n if (isClippedWideCell(currentLine, currentLineCells - 1, this._terminal.cols, thisRowLastChar)) {\n thisRowLastChar = blankClippedWideCell(thisRowLastChar);\n }\n const thisRowLastSecondChar = currentLine.getCell(currentLineCells - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n const contentCellCount = currentLineCells - this._nullCellCount;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}C`;\n }\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}D`;\n }\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so\n // they must be diffed as one intensity group with the clearing 22 emitted\n // BEFORE any re-set. Upstream's independent per-flag diff could emit\n // \"1;22\" (bold set, then wiped by dim's clear — \\x1b[2mA\\x1b[22m\\x1b[1mB\n // loses B's bold on round-trip) or a bare \"22\" that drops a still-set\n // bold/dim, garbling Orca's hidden-terminal snapshot restores.\n const boldChanged = cell.isBold() !== oldCell.isBold();\n const dimChanged = cell.isDim() !== oldCell.isDim();\n if (boldChanged || dimChanged) {\n const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());\n if (clearsIntensity) { sgrSeq.push(22); }\n if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }\n if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }\n }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n // PATCH(orca): dim handled in the intensity group above.\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n private _setOscLink(linkId: number): string {\n if (linkId === this._activeOscLinkId) {\n return '';\n }\n let sequence = this._activeOscLinkId ? '\\u001b]8;;\\u001b\\\\' : '';\n this._activeOscLinkId = 0;\n const openSequence = getOscLinkOpenSequence(this._terminal, linkId);\n if (openSequence) {\n this._activeOscLinkId = linkId;\n sequence += openSequence;\n }\n return sequence;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;\n const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);\n // A pending wide glyph recreates its own final-column padding during replay.\n const isWideWrapPadding = col === this._terminal.cols - 1 &&\n nextLine?.isWrapped &&\n (nextRowFirstCell?.getWidth() ?? 0) > 1 &&\n !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);\n // Cursor movement cannot reproduce an inverse cell's visible background.\n const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n const styleChanged = isEmptyCell\n ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)\n : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell && !materializeEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.\n this._currentRow += this._setOscLink(getOscLinkId(cell));\n\n if (materializeEmptyCell) {\n const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();\n if (hasDecoration) {\n this._currentRow += '\\u001b[24;29;55m';\n }\n this._currentRow += ' '.repeat(cell.getWidth());\n if (hasDecoration) {\n const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);\n this._currentRow += `\\u001b[0m\\u001b[${restoreSgrSeq.join(';')}m`;\n }\n } else {\n this._currentRow += cell.getChars();\n }\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n // PATCH(orca): keep trailing background-only rows, unless the cursor is wrap-pending:\n // relative moves back from those rows cannot re-create that state.\n if (\n this._lastBackgroundCursorRow > this._lastContentCursorRow &&\n this._buffer.cursorX < this._terminal.cols\n ) {\n this._lastContentCursorRow = this._lastBackgroundCursorRow;\n this._lastContentCursorCol = this._lastBackgroundCursorCol;\n }\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // Each buffer is self-contained so links cannot leak into a following buffer.\n content += this._setOscLink(0);\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n // Restore the source terminal's live OSC pen only after all buffers are complete.\n content += getActiveOscLinkSequence(this._terminal);\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer, _terminal.cols);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","Color_1","__webpack_require__","DEFAULT_ANSI_COLORS","Object","freeze","colors","css","toColor","v","i","r","g","b","push","channels","toCss","rgba","toRgba","c","$r","$g","$b","$a","color","rgb","toPaddedHex","s","toString","length","contrastRatio","l1","l2","NULL_COLOR","a","undefined","color_1","opacity","Math","round","toChannels","blend","bg","fg","fgR","fgG","fgB","bgR","bgG","bgB","isOpaque","ensureContrastRatio","ratio","result","opaque","rgbaColor","multiplyOpacity","factor","toColorRGB","css_1","$ctx","$litmusColor","canvas","document","createElement","width","height","ctx","getContext","willReadFrequently","globalCompositeOperation","createLinearGradient","match","parseInt","slice","repeat","rgbaMatch","parseFloat","Error","fillStyle","fillRect","getImageData","data","rgb_1","relativeLuminance2","rs","gs","bs","pow","relativeLuminance","reduceLuminance","bgRgba","fgRgba","cr","max","ceil","increaseLuminance","min","bgL","fgL","resultA","resultARatio","resultB","value","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Types_1","getOscLinkId","cell","extended","urlId","getOscLinkOpenSequence","terminal","linkId","_core","_oscLinkService","getLinkData","id","uri","visibleCellCount","line","cols","isClippedWideCell","col","getWidth","blankClippedWideCell","content","constrain","low","high","BaseSerializeHandler","constructor","_buffer","_cols","serialize","range","excludeFinalCursorPosition","cell1","this","getNullCell","cell2","oldCell","startRow","start","y","endRow","end","startColumn","x","endColumn","_beforeSerialize","row","getLine","startLineColumn","endLineColumn","getCell","_nextCell","console","warn","_rowEnd","_afterSerialize","_serializeString","isLastRow","rows","equalFg","getFgColorMode","getFgColor","equalBg","getBgColorMode","getBgColor","equalUnderline","isUnderline","getUnderlineStyle","cell1Default","isUnderlineColorDefault","cell2Default","getUnderlineColor","getUnderlineColorMode","equalFlags","isInverse","isBold","isOverline","isBlink","isInvisible","isItalic","isDim","isStrikethrough","attributesEquals","cell1AsBufferCell","StringSerializeHandler","buffer","_terminal","super","_rowIndex","_allRows","Array","_allRowSeparators","_currentRow","_nullCellCount","_cursorStyle","_cursorStyleRow","_cursorStyleCol","_backgroundCell","_defaultCell","_firstRow","_lastCursorRow","_lastCursorCol","_lastContentCursorRow","_lastContentCursorCol","_activeOscLinkId","_lastBackgroundCursorRow","_lastBackgroundCursorCol","_thisRowLastChar","_thisRowLastSecondChar","_nextRowFirstChar","rowSeparator","currentLine","nextLine","isWrapped","currentLineCells","thisRowLastChar","thisRowLastSecondChar","nextRowFirstChar","isNextRowFirstCharDoubleWidth","isValid","getChars","contentCellCount","_diffStyle","sgrSeq","fgChanged","bgChanged","flagsChanged","isAttributeDefault","isFgRGB","isFgPalette","isBgRGB","isBgPalette","boldChanged","dimChanged","clearsIntensity","style","isUnderlineColorRGB","_setOscLink","sequence","openSequence","isEmptyCell","nextRowFirstCell","isWideWrapPadding","materializeEmptyCell","join","hasDecoration","restoreSgrSeq","rowEnd","cursorX","realCursorRow","baseY","cursorY","realCursorCol","moveRight","offset","curAttrData","_inputHandler","_curAttrData","activate","_serializeBufferByScrollback","scrollback","maxRows","correctRows","_serializeBufferByRange","_serializeBufferAsHTML","options","active","handler","HTMLSerializeHandler","onlySelection","startCol","startLine","endLine","selection","getSelectionPosition","_serializeScrollRegion","scrollTop","scrollBottom","_serializeModes","modes","applicationCursorKeysMode","applicationKeypadMode","bracketedPasteMode","insertMode","originMode","reverseWraparoundMode","sendFocusMode","wraparoundMode","mouseTrackingMode","showCursor","normal","excludeAltBuffer","type","alternate","excludeModes","attrs","getActiveOscLinkSequence","serializeAsHTML","dispose","_options","_htmlContent","_themeService","_ansiColors","ansi","foreground","background","includeGlobalBackground","theme","globalStyleDefinitions","fontFamily","fontSize","_getHexColor","isFg","map","padStart","_getUnderlineColor","_getUnderlineStyle","fgHexColor","bgHexColor","decorations","underlineColor","styleDefinitions","escapeHTMLChar"],"sourceRoot":""} \ No newline at end of file diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs -index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..b9bb467ab8a701c6d31a0f7a539010fad828e6da 100644 +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..55479a99d4b419e5e5e071ed04a656e892c27c15 100644 --- a/lib/addon-serialize.mjs +++ b/lib/addon-serialize.mjs @@ -14,6 +14,6 @@ @@ -26,11 +26,11 @@ index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..b9bb467ab8a701c6d31a0f7a539010fa *--------------------------------------------------------------------------------------------*/ -var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r -`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; -+var b=0,_=0,I=0,p=0;var g;(r=>{function s(t,u,a,i){return i!==void 0?`#${L(t)}${L(u)}${L(a)}${L(i)}`:`#${L(t)}${L(u)}${L(a)}`}r.toCss=s;function n(t,u,a,i=255){return(t<<24|u<<16|a<<8|i)>>>0}r.toRgba=n;function e(t,u,a,i){return{css:r.toCss(t,u,a,i),rgba:r.toRgba(t,u,a,i)}}r.toColor=e})(g||={});var W;(i=>{function s(o,l){if(p=(l.rgba&255)/255,p===1)return{css:l.css,rgba:l.rgba};let f=l.rgba>>24&255,d=l.rgba>>16&255,c=l.rgba>>8&255,C=o.rgba>>24&255,h=o.rgba>>16&255,m=o.rgba>>8&255;b=C+Math.round((f-C)*p),_=h+Math.round((d-h)*p),I=m+Math.round((c-m)*p);let w=g.toCss(b,_,I),E=g.toRgba(b,_,I);return{css:w,rgba:E}}i.blend=s;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,l,f){let d=S.ensureContrastRatio(o.rgba,l.rgba,f);if(d)return g.toColor(d>>24&255,d>>16&255,d>>8&255)}i.ensureContrastRatio=e;function r(o){let l=(o.rgba|255)>>>0;return[b,_,I]=S.toChannels(l),{css:g.toCss(b,_,I),rgba:l}}i.opaque=r;function t(o,l){return p=Math.round(l*255),[b,_,I]=S.toChannels(o.rgba),{css:g.toCss(b,_,I,p),rgba:g.toRgba(b,_,I,p)}}i.opacity=t;function u(o,l){return p=o.rgba&255,t(o,p*l/255)}i.multiplyOpacity=u;function a(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=a})(W||={});var x;(r=>{let s,n;try{let t=document.createElement("canvas");t.width=1,t.height=1;let u=t.getContext("2d",{willReadFrequently:!0});u&&(s=u,s.globalCompositeOperation="copy",n=s.createLinearGradient(0,0,1,1))}catch{}function e(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return b=parseInt(t.slice(1,2).repeat(2),16),_=parseInt(t.slice(2,3).repeat(2),16),I=parseInt(t.slice(3,4).repeat(2),16),g.toColor(b,_,I);case 5:return b=parseInt(t.slice(1,2).repeat(2),16),_=parseInt(t.slice(2,3).repeat(2),16),I=parseInt(t.slice(3,4).repeat(2),16),p=parseInt(t.slice(4,5).repeat(2),16),g.toColor(b,_,I,p);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}let u=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(u)return b=parseInt(u[1],10),_=parseInt(u[2],10),I=parseInt(u[3],10),p=Math.round((u[5]===void 0?1:parseFloat(u[5]))*255),g.toColor(b,_,I,p);if(t==="transparent")return{css:"transparent",rgba:0};if(!s||!n)throw new Error("css.toColor: Unsupported css format");if(s.fillStyle=n,s.fillStyle=t,typeof s.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(s.fillRect(0,0,1,1),[b,_,I,p]=s.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(b,_,I,p),css:t}}r.toColor=e})(x||={});var v;(e=>{function s(r){return n(r>>16&255,r>>8&255,r&255)}e.relativeLuminance=s;function n(r,t,u){let a=r/255,i=t/255,o=u/255,l=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),d=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return l*.2126+f*.7152+d*.0722}e.relativeLuminance2=n})(v||={});var S;(u=>{function s(a,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,l=i>>16&255,f=i>>8&255,d=a>>24&255,c=a>>16&255,C=a>>8&255;return b=d+Math.round((o-d)*p),_=c+Math.round((l-c)*p),I=C+Math.round((f-C)*p),g.toRgba(b,_,I)}u.blend=s;function n(a,i,o){let l=v.relativeLuminance(a>>8),f=v.relativeLuminance(i>>8);if(R(l,f)>8));if(m>8));return m>E?h:w}return h}let c=r(a,i,o),C=R(l,v.relativeLuminance(c>>8));if(C>8));return C>m?c:h}return c}}u.ensureContrastRatio=n;function e(a,i,o){let l=a>>24&255,f=a>>16&255,d=a>>8&255,c=i>>24&255,C=i>>16&255,h=i>>8&255,m=R(v.relativeLuminance2(c,C,h),v.relativeLuminance2(l,f,d));for(;m0||C>0||h>0);)c-=Math.max(0,Math.ceil(c*.1)),C-=Math.max(0,Math.ceil(C*.1)),h-=Math.max(0,Math.ceil(h*.1)),m=R(v.relativeLuminance2(c,C,h),v.relativeLuminance2(l,f,d));return(c<<24|C<<16|h<<8|255)>>>0}u.reduceLuminance=e;function r(a,i,o){let l=a>>24&255,f=a>>16&255,d=a>>8&255,c=i>>24&255,C=i>>16&255,h=i>>8&255,m=R(v.relativeLuminance2(c,C,h),v.relativeLuminance2(l,f,d));for(;m>>0}u.increaseLuminance=r;function t(a){return[a>>24&255,a>>16&255,a>>8&255,a&255]}u.toChannels=t})(S||={});function L(s){let n=s.toString(16);return n.length<2?"0"+n:n}function R(s,n){return s{let s=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let r=n[e/36%6|0],t=n[e/6%6|0],u=n[e%6];s.push({css:g.toCss(r,t,u),rgba:g.toRgba(r,t,u)})}for(let e=0;e<24;e++){let r=8+e*10;s.push({css:g.toCss(r,r,r),rgba:g.toRgba(r,r,r)})}return s})());function U(s){return s.extended?.urlId??0}function N(s,n){if(!n)return"";let e=s._core?._oscLinkService?.getLinkData(n);return e?`\x1B]8;${e.id===void 0?"":`id=${e.id}`};${e.uri}\x1B\\`:""}function $(s){let n=s._core?._inputHandler?._curAttrData;return n?N(s,U(n)):""}function A(s,n,e){return Math.max(n,Math.min(s,e))}function P(s){switch(s){case"&":return"&";case"<":return"<"}return s}var B=class{constructor(n){this._buffer=n}serialize(n,e){let r=this._buffer.getNullCell(),t=this._buffer.getNullCell(),u=r,a=n.start.y,i=n.end.y,o=n.start.x,l=n.end.x;this._beforeSerialize(i-a,a,i);for(let f=a;f<=i;f++){let d=this._buffer.getLine(f);if(d){let c=f===n.start.y?o:0,C=f===n.end.y?l:d.length;for(let h=c;h0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let t="";if(!r){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let u=this._buffer.getLine(e),a=this._buffer.getLine(e+1);if(!a.isWrapped)t=`\r -+`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{t="";let i=u.getCell(u.length-1,this._thisRowLastChar),o=u.getCell(u.length-2,this._thisRowLastSecondChar),l=a.getCell(0,this._nextRowFirstChar),f=l.getWidth()>1,d=!1;if(l.getChars()&&(f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,l)&&(d=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,l)&&F(o,l)&&(d=!0)),!d){if(t="-".repeat(this._nullCellCount+1),t+="\x1B[1D\x1B[1X",this._nullCellCount>0){t+="\x1B[A";let c=u.length-this._nullCellCount;c>0&&(t+=`\x1B[${c}C`),t+=`\x1B[${this._nullCellCount}X`,c>0&&(t+=`\x1B[${c}D`),t+="\x1B[B"}this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0}}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=t,this._currentRow="",this._nullCellCount=0}_diffStyle(e,r){let t=[];if(y(e,r))return t;let u=!T(e,r),a=!F(e,r),i=!M(e,r);if(u||a||i)if(e.isAttributeDefault())r.isAttributeDefault()||t.push(0);else{if(u){let o=e.getFgColor();e.isFgRGB()?t.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?t.push(38,5,o):t.push(o&8?90+(o&7):30+(o&7)):t.push(39)}if(a){let o=e.getBgColor();e.isBgRGB()?t.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?t.push(48,5,o):t.push(o&8?100+(o&7):40+(o&7)):t.push(49)}if(i){e.isInverse()!==r.isInverse()&&t.push(e.isInverse()?7:27);let o=e.isBold()!==r.isBold(),l=e.isDim()!==r.isDim();if(o||l){let f=o&&!e.isBold()||l&&!e.isDim();f&&t.push(22),e.isBold()&&(o||f)&&t.push(1),e.isDim()&&(l||f)&&t.push(2)}if(z(e,r))e.isUnderline()!==r.isUnderline()&&t.push(e.isUnderline()?4:24);else{let f=e.getUnderlineStyle();if(f===0)t.push(24);else if(f===1&&e.isUnderlineColorDefault())t.push(4);else if(t.push("4:"+f),!e.isUnderlineColorDefault()){let d=e.getUnderlineColor();e.isUnderlineColorRGB()?t.push("58:2::"+(d>>>16&255)+":"+(d>>>8&255)+":"+(d&255)):t.push("58:5:"+d)}}e.isOverline()!==r.isOverline()&&t.push(e.isOverline()?53:55),e.isBlink()!==r.isBlink()&&t.push(e.isBlink()?5:25),e.isInvisible()!==r.isInvisible()&&t.push(e.isInvisible()?8:28),e.isItalic()!==r.isItalic()&&t.push(e.isItalic()?3:23),e.isStrikethrough()!==r.isStrikethrough()&&t.push(e.isStrikethrough()?9:29)}}return t}_setOscLink(e){if(e===this._activeOscLinkId)return"";let r=this._activeOscLinkId?"\x1B]8;;\x1B\\":"";this._activeOscLinkId=0;let t=N(this._terminal,e);return t&&(this._activeOscLinkId=e,r+=t),r}_nextCell(e,r,t,u){if(e.getWidth()===0)return;let i=e.getChars()==="",o=i&&e.isInverse()?this._buffer.getLine(t+1):void 0,l=o?.getCell(0,this._nextRowFirstChar),f=u===this._terminal.cols-1&&o?.isWrapped&&(l?.getWidth()??0)>1&&!!l&&y(e,l),d=i&&!!e.isInverse()&&!f,c=this._diffStyle(e,this._cursorStyle);if(i?d?c.length>0:!F(this._cursorStyle,e):c.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=t,this._lastContentCursorCol=this._lastCursorCol=u,this._currentRow+=`\x1B[${c.join(";")}m`;let h=this._buffer.getLine(t);h!==void 0&&(h.getCell(u,this._cursorStyle),this._cursorStyleRow=t,this._cursorStyleCol=u)}if(i&&!d)this._nullCellCount+=e.getWidth();else{if(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=this._setOscLink(U(e)),d){let h=!!e.isUnderline()||!!e.isStrikethrough()||!!e.isOverline();if(h&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(e.getWidth()),h){let m=this._diffStyle(e,this._defaultCell);this._currentRow+=`\x1B[0m\x1B[${m.join(";")}m`}}else this._currentRow+=e.getChars();this._lastContentCursorRow=this._lastCursorRow=t,this._lastContentCursorCol=this._lastCursorCol=u+e.getWidth()}}_serializeString(e){let r=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(r=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let t="";for(let i=0;i{c>0?t+=`\x1B[${c}C`:c<0&&(t+=`\x1B[${-c}D`)};l&&((c=>{c>0?t+=`\x1B[${c}B`:c<0&&(t+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let u=this._terminal._core._inputHandler._curAttrData,a=this._diffStyle(u,this._cursorStyle);return a.length>0&&(t+=`\x1B[${a.join(";")}m`),t}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,r){let t=e.length,u=r===void 0?t:A(r+n.rows,0,t);return this._serializeBufferByRange(n,e,{start:t-u,end:t-1},!1)}_serializeBufferByRange(n,e,r,t){return new D(e,n).serialize({start:{x:0,y:typeof r.start=="number"?r.start:r.start.line},end:{x:n.cols,y:typeof r.end=="number"?r.end:r.end.line}},t)}_serializeBufferAsHTML(n,e){let r=n.buffer.active,t=new k(r,n,e),u=e.onlySelection??!1,a=e.range;if(a)return t.serialize({start:{x:a.startCol,y:(typeof a.startLine=="number",a.startLine)},end:{x:n.cols,y:(typeof a.endLine=="number",a.endLine)}});if(!u){let o=r.length,l=e.scrollback,f=l===void 0?o:A(l+n.rows,0,o);return t.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?t.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,r=e.scrollTop,t=e.scrollBottom;return r!==0||t!==n.rows-1?`\x1B[${r+1};${t+1}r`:""}_serializeModes(n){let e="",r=n.modes;if(r.applicationCursorKeysMode&&(e+="\x1B[?1h"),r.applicationKeypadMode&&(e+="\x1B[?66h"),r.bracketedPasteMode&&(e+="\x1B[?2004h"),r.insertMode&&(e+="\x1B[4h"),r.originMode&&(e+="\x1B[?6h"),r.reverseWraparoundMode&&(e+="\x1B[?45h"),r.sendFocusMode&&(e+="\x1B[?1004h"),r.wraparoundMode===!1&&(e+="\x1B[?7l"),r.mouseTrackingMode!=="none")switch(r.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return r.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let r=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${r}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e+=$(this._terminal),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},k=class extends B{constructor(e,r,t){super(e);this._terminal=r;this._options=t;this._currentRow="";this._htmlContent="";r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=O}_beforeSerialize(e,r,t){this._htmlContent+="
";let u="#000000",a="#ffffff";(this._options.includeGlobalBackground??!1)&&(u=this._terminal.options.theme?.foreground??"#ffffff",a=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+u+";"),i.push("background-color: "+a+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,r){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,r){let t=r?e.getFgColor():e.getBgColor();if(r?e.isFgRGB():e.isBgRGB())return"#"+[t>>16&255,t>>8&255,t&255].map(a=>a.toString(16).padStart(2,"0")).join("");if(r?e.isFgPalette():e.isBgPalette())return this._ansiColors[t].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let r=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[r>>16&255,r>>8&255,r&255].map(u=>u.toString(16).padStart(2,"0")).join(""):this._ansiColors[r].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,r){let t=[];if(y(e,r))return;let u=!T(e,r),a=!F(e,r),i=!M(e,r);if(u||a||i){let o=this._getHexColor(e,!0);o&&t.push("color: "+o+";");let l=this._getHexColor(e,!1);l&&t.push("background-color: "+l+";"),e.isInverse()&&t.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&t.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&t.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let d=this._getUnderlineColor(e);d&&t.push("text-decoration-color: "+d+";")}return e.isInvisible()&&t.push("visibility: hidden;"),e.isItalic()&&t.push("font-style: italic;"),e.isDim()&&t.push("opacity: 0.5;"),t}}_nextCell(e,r,t,u){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,r);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=P(e.getChars())}_serializeString(){return this._htmlContent}};export{k as HTMLSerializeHandler,H as SerializeAddon}; ++var b=0,_=0,I=0,p=0;var g;(r=>{function s(t,u,a,i){return i!==void 0?`#${L(t)}${L(u)}${L(a)}${L(i)}`:`#${L(t)}${L(u)}${L(a)}`}r.toCss=s;function n(t,u,a,i=255){return(t<<24|u<<16|a<<8|i)>>>0}r.toRgba=n;function e(t,u,a,i){return{css:r.toCss(t,u,a,i),rgba:r.toRgba(t,u,a,i)}}r.toColor=e})(g||={});var G;(i=>{function s(o,l){if(p=(l.rgba&255)/255,p===1)return{css:l.css,rgba:l.rgba};let f=l.rgba>>24&255,d=l.rgba>>16&255,c=l.rgba>>8&255,h=o.rgba>>24&255,C=o.rgba>>16&255,m=o.rgba>>8&255;b=h+Math.round((f-h)*p),_=C+Math.round((d-C)*p),I=m+Math.round((c-m)*p);let B=g.toCss(b,_,I),E=g.toRgba(b,_,I);return{css:B,rgba:E}}i.blend=s;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,l,f){let d=w.ensureContrastRatio(o.rgba,l.rgba,f);if(d)return g.toColor(d>>24&255,d>>16&255,d>>8&255)}i.ensureContrastRatio=e;function r(o){let l=(o.rgba|255)>>>0;return[b,_,I]=w.toChannels(l),{css:g.toCss(b,_,I),rgba:l}}i.opaque=r;function t(o,l){return p=Math.round(l*255),[b,_,I]=w.toChannels(o.rgba),{css:g.toCss(b,_,I,p),rgba:g.toRgba(b,_,I,p)}}i.opacity=t;function u(o,l){return p=o.rgba&255,t(o,p*l/255)}i.multiplyOpacity=u;function a(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=a})(G||={});var x;(r=>{let s,n;try{let t=document.createElement("canvas");t.width=1,t.height=1;let u=t.getContext("2d",{willReadFrequently:!0});u&&(s=u,s.globalCompositeOperation="copy",n=s.createLinearGradient(0,0,1,1))}catch{}function e(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return b=parseInt(t.slice(1,2).repeat(2),16),_=parseInt(t.slice(2,3).repeat(2),16),I=parseInt(t.slice(3,4).repeat(2),16),g.toColor(b,_,I);case 5:return b=parseInt(t.slice(1,2).repeat(2),16),_=parseInt(t.slice(2,3).repeat(2),16),I=parseInt(t.slice(3,4).repeat(2),16),p=parseInt(t.slice(4,5).repeat(2),16),g.toColor(b,_,I,p);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}let u=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(u)return b=parseInt(u[1],10),_=parseInt(u[2],10),I=parseInt(u[3],10),p=Math.round((u[5]===void 0?1:parseFloat(u[5]))*255),g.toColor(b,_,I,p);if(t==="transparent")return{css:"transparent",rgba:0};if(!s||!n)throw new Error("css.toColor: Unsupported css format");if(s.fillStyle=n,s.fillStyle=t,typeof s.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(s.fillRect(0,0,1,1),[b,_,I,p]=s.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(b,_,I,p),css:t}}r.toColor=e})(x||={});var v;(e=>{function s(r){return n(r>>16&255,r>>8&255,r&255)}e.relativeLuminance=s;function n(r,t,u){let a=r/255,i=t/255,o=u/255,l=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),d=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return l*.2126+f*.7152+d*.0722}e.relativeLuminance2=n})(v||={});var w;(u=>{function s(a,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,l=i>>16&255,f=i>>8&255,d=a>>24&255,c=a>>16&255,h=a>>8&255;return b=d+Math.round((o-d)*p),_=c+Math.round((l-c)*p),I=h+Math.round((f-h)*p),g.toRgba(b,_,I)}u.blend=s;function n(a,i,o){let l=v.relativeLuminance(a>>8),f=v.relativeLuminance(i>>8);if(R(l,f)>8));if(m>8));return m>E?C:B}return C}let c=r(a,i,o),h=R(l,v.relativeLuminance(c>>8));if(h>8));return h>m?c:C}return c}}u.ensureContrastRatio=n;function e(a,i,o){let l=a>>24&255,f=a>>16&255,d=a>>8&255,c=i>>24&255,h=i>>16&255,C=i>>8&255,m=R(v.relativeLuminance2(c,h,C),v.relativeLuminance2(l,f,d));for(;m0||h>0||C>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),C-=Math.max(0,Math.ceil(C*.1)),m=R(v.relativeLuminance2(c,h,C),v.relativeLuminance2(l,f,d));return(c<<24|h<<16|C<<8|255)>>>0}u.reduceLuminance=e;function r(a,i,o){let l=a>>24&255,f=a>>16&255,d=a>>8&255,c=i>>24&255,h=i>>16&255,C=i>>8&255,m=R(v.relativeLuminance2(c,h,C),v.relativeLuminance2(l,f,d));for(;m>>0}u.increaseLuminance=r;function t(a){return[a>>24&255,a>>16&255,a>>8&255,a&255]}u.toChannels=t})(w||={});function L(s){let n=s.toString(16);return n.length<2?"0"+n:n}function R(s,n){return s{let s=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let r=n[e/36%6|0],t=n[e/6%6|0],u=n[e%6];s.push({css:g.toCss(r,t,u),rgba:g.toRgba(r,t,u)})}for(let e=0;e<24;e++){let r=8+e*10;s.push({css:g.toCss(r,r,r),rgba:g.toRgba(r,r,r)})}return s})());function N(s){return s.extended?.urlId??0}function U(s,n){if(!n)return"";let e=s._core?._oscLinkService?.getLinkData(n);return e?`\x1B]8;${e.id===void 0?"":`id=${e.id}`};${e.uri}\x1B\\`:""}function q(s){let n=s._core?._inputHandler?._curAttrData;return n?U(s,N(n)):""}function z(s,n){return Math.min(s.length,n)}function W(s,n,e,r){return n===e-1&&s.length>e&&r.getWidth()>1}var K=1<<22;function $(s){return s.content=K,s}function A(s,n,e){return Math.max(n,Math.min(s,e))}function X(s){switch(s){case"&":return"&";case"<":return"<"}return s}var S=class{constructor(n,e){this._buffer=n;this._cols=e}serialize(n,e){let r=this._buffer.getNullCell(),t=this._buffer.getNullCell(),u=r,a=n.start.y,i=n.end.y,o=n.start.x,l=n.end.x;this._beforeSerialize(i-a,a,i);for(let f=a;f<=i;f++){let d=this._buffer.getLine(f);if(d){let c=f===n.start.y?o:0,h=f===n.end.y?l:z(d,this._cols);for(let C=c;C0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._lastBackgroundCursorRow=this._lastCursorRow,this._lastBackgroundCursorCol=this._lastCursorCol);let t="";if(!r){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let u=this._buffer.getLine(e),a=this._buffer.getLine(e+1);if(!a.isWrapped)t=`\r ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{t="";let i=z(u,this._terminal.cols),o=u.getCell(i-1,this._thisRowLastChar);W(u,i-1,this._terminal.cols,o)&&(o=$(o));let l=u.getCell(i-2,this._thisRowLastSecondChar),f=a.getCell(0,this._nextRowFirstChar),d=f.getWidth()>1,c=!1;if(f.getChars()&&(d?this._nullCellCount<=1:this._nullCellCount<=0)&&((o.getChars()||o.getWidth()===0)&&F(o,f)&&(c=!0),d&&(l.getChars()||l.getWidth()===0)&&F(o,f)&&F(l,f)&&(c=!0)),!c){if(t="-".repeat(this._nullCellCount+1),t+="\x1B[1D\x1B[1X",this._nullCellCount>0){t+="\x1B[A";let h=i-this._nullCellCount;h>0&&(t+=`\x1B[${h}C`),t+=`\x1B[${this._nullCellCount}X`,h>0&&(t+=`\x1B[${h}D`),t+="\x1B[B"}this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0}}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=t,this._currentRow="",this._nullCellCount=0}_diffStyle(e,r){let t=[];if(y(e,r))return t;let u=!T(e,r),a=!F(e,r),i=!M(e,r);if(u||a||i)if(e.isAttributeDefault())r.isAttributeDefault()||t.push(0);else{if(u){let o=e.getFgColor();e.isFgRGB()?t.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?t.push(38,5,o):t.push(o&8?90+(o&7):30+(o&7)):t.push(39)}if(a){let o=e.getBgColor();e.isBgRGB()?t.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?t.push(48,5,o):t.push(o&8?100+(o&7):40+(o&7)):t.push(49)}if(i){e.isInverse()!==r.isInverse()&&t.push(e.isInverse()?7:27);let o=e.isBold()!==r.isBold(),l=e.isDim()!==r.isDim();if(o||l){let f=o&&!e.isBold()||l&&!e.isDim();f&&t.push(22),e.isBold()&&(o||f)&&t.push(1),e.isDim()&&(l||f)&&t.push(2)}if(P(e,r))e.isUnderline()!==r.isUnderline()&&t.push(e.isUnderline()?4:24);else{let f=e.getUnderlineStyle();if(f===0)t.push(24);else if(f===1&&e.isUnderlineColorDefault())t.push(4);else if(t.push("4:"+f),!e.isUnderlineColorDefault()){let d=e.getUnderlineColor();e.isUnderlineColorRGB()?t.push("58:2::"+(d>>>16&255)+":"+(d>>>8&255)+":"+(d&255)):t.push("58:5:"+d)}}e.isOverline()!==r.isOverline()&&t.push(e.isOverline()?53:55),e.isBlink()!==r.isBlink()&&t.push(e.isBlink()?5:25),e.isInvisible()!==r.isInvisible()&&t.push(e.isInvisible()?8:28),e.isItalic()!==r.isItalic()&&t.push(e.isItalic()?3:23),e.isStrikethrough()!==r.isStrikethrough()&&t.push(e.isStrikethrough()?9:29)}}return t}_setOscLink(e){if(e===this._activeOscLinkId)return"";let r=this._activeOscLinkId?"\x1B]8;;\x1B\\":"";this._activeOscLinkId=0;let t=U(this._terminal,e);return t&&(this._activeOscLinkId=e,r+=t),r}_nextCell(e,r,t,u){if(e.getWidth()===0)return;let i=e.getChars()==="",o=i&&e.isInverse()?this._buffer.getLine(t+1):void 0,l=o?.getCell(0,this._nextRowFirstChar),f=u===this._terminal.cols-1&&o?.isWrapped&&(l?.getWidth()??0)>1&&!!l&&y(e,l),d=i&&!!e.isInverse()&&!f,c=this._diffStyle(e,this._cursorStyle);if(i?d?c.length>0:!F(this._cursorStyle,e):c.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=t,this._lastContentCursorCol=this._lastCursorCol=u,this._currentRow+=`\x1B[${c.join(";")}m`;let C=this._buffer.getLine(t);C!==void 0&&(C.getCell(u,this._cursorStyle),this._cursorStyleRow=t,this._cursorStyleCol=u)}if(i&&!d)this._nullCellCount+=e.getWidth();else{if(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=this._setOscLink(N(e)),d){let C=!!e.isUnderline()||!!e.isStrikethrough()||!!e.isOverline();if(C&&(this._currentRow+="\x1B[24;29;55m"),this._currentRow+=" ".repeat(e.getWidth()),C){let m=this._diffStyle(e,this._defaultCell);this._currentRow+=`\x1B[0m\x1B[${m.join(";")}m`}}else this._currentRow+=e.getChars();this._lastContentCursorRow=this._lastCursorRow=t,this._lastContentCursorCol=this._lastCursorCol=u+e.getWidth()}}_serializeString(e){let r=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(this._lastBackgroundCursorRow>this._lastContentCursorRow&&this._buffer.cursorX{c>0?t+=`\x1B[${c}C`:c<0&&(t+=`\x1B[${-c}D`)};l&&((c=>{c>0?t+=`\x1B[${c}B`:c<0&&(t+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let u=this._terminal._core._inputHandler._curAttrData,a=this._diffStyle(u,this._cursorStyle);return a.length>0&&(t+=`\x1B[${a.join(";")}m`),t}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,r){let t=e.length,u=r===void 0?t:A(r+n.rows,0,t);return this._serializeBufferByRange(n,e,{start:t-u,end:t-1},!1)}_serializeBufferByRange(n,e,r,t){return new D(e,n).serialize({start:{x:0,y:typeof r.start=="number"?r.start:r.start.line},end:{x:n.cols,y:typeof r.end=="number"?r.end:r.end.line}},t)}_serializeBufferAsHTML(n,e){let r=n.buffer.active,t=new k(r,n,e),u=e.onlySelection??!1,a=e.range;if(a)return t.serialize({start:{x:a.startCol,y:(typeof a.startLine=="number",a.startLine)},end:{x:n.cols,y:(typeof a.endLine=="number",a.endLine)}});if(!u){let o=r.length,l=e.scrollback,f=l===void 0?o:A(l+n.rows,0,o);return t.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?t.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,r=e.scrollTop,t=e.scrollBottom;return r!==0||t!==n.rows-1?`\x1B[${r+1};${t+1}r`:""}_serializeModes(n){let e="",r=n.modes;if(r.applicationCursorKeysMode&&(e+="\x1B[?1h"),r.applicationKeypadMode&&(e+="\x1B[?66h"),r.bracketedPasteMode&&(e+="\x1B[?2004h"),r.insertMode&&(e+="\x1B[4h"),r.originMode&&(e+="\x1B[?6h"),r.reverseWraparoundMode&&(e+="\x1B[?45h"),r.sendFocusMode&&(e+="\x1B[?1004h"),r.wraparoundMode===!1&&(e+="\x1B[?7l"),r.mouseTrackingMode!=="none")switch(r.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return r.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let r=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${r}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e+=q(this._terminal),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},k=class extends S{constructor(e,r,t){super(e,r.cols);this._terminal=r;this._options=t;this._currentRow="";this._htmlContent="";r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=O}_beforeSerialize(e,r,t){this._htmlContent+="
";let u="#000000",a="#ffffff";(this._options.includeGlobalBackground??!1)&&(u=this._terminal.options.theme?.foreground??"#ffffff",a=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+u+";"),i.push("background-color: "+a+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,r){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,r){let t=r?e.getFgColor():e.getBgColor();if(r?e.isFgRGB():e.isBgRGB())return"#"+[t>>16&255,t>>8&255,t&255].map(a=>a.toString(16).padStart(2,"0")).join("");if(r?e.isFgPalette():e.isBgPalette())return this._ansiColors[t].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let r=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[r>>16&255,r>>8&255,r&255].map(u=>u.toString(16).padStart(2,"0")).join(""):this._ansiColors[r].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,r){let t=[];if(y(e,r))return;let u=!T(e,r),a=!F(e,r),i=!M(e,r);if(u||a||i){let o=this._getHexColor(e,!0);o&&t.push("color: "+o+";");let l=this._getHexColor(e,!1);l&&t.push("background-color: "+l+";"),e.isInverse()&&t.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&t.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&t.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let d=this._getUnderlineColor(e);d&&t.push("text-decoration-color: "+d+";")}return e.isInvisible()&&t.push("visibility: hidden;"),e.isItalic()&&t.push("font-style: italic;"),e.isDim()&&t.push("opacity: 0.5;"),t}}_nextCell(e,r,t,u){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,r);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=X(e.getChars())}_serializeString(){return this._htmlContent}};export{k as HTMLSerializeHandler,H as SerializeAddon}; //# sourceMappingURL=addon-serialize.mjs.map diff --git a/lib/addon-serialize.mjs.map b/lib/addon-serialize.mjs.map -index e9357c67686de63dd29db518781758345aacdd0f..ea0eb0dac817d0d691fd3b1d5b4a58187430efac 100644 +index e9357c67686de63dd29db518781758345aacdd0f..5eadddd8600fcb33252dfa582a3bd0120b822d44 100644 --- a/lib/addon-serialize.mjs.map +++ b/lib/addon-serialize.mjs.map @@ -1,7 +1,7 @@ @@ -40,15 +40,23 @@ index e9357c67686de63dd29db518781758345aacdd0f..ea0eb0dac817d0d691fd3b1d5b4a5818 - "sourcesContent": ["/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n", "/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: line.length;\n for (let col = startLineColumn; col < endLineColumn; col++) {\n const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;\n const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n rowSeparator += `\\u001b[${currentLine.length - this._nullCellCount}C`;\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n rowSeparator += `\\u001b[${currentLine.length - this._nullCellCount}D`;\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n // the empty cell style is only assumed to be changed when background changed, because\n // foreground is always 0.\n const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n this._currentRow += cell.getChars();\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"], - "mappings": "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", - "names": ["$r", "$g", "$b", "$a", "channels", "toCss", "g", "b", "a", "toPaddedHex", "toRgba", "toColor", "color", "blend", "bg", "fg", "$a", "fgR", "fgG", "fgB", "bgR", "bgG", "bgB", "$r", "$g", "$b", "css", "rgba", "isOpaque", "ensureContrastRatio", "ratio", "result", "opaque", "rgbaColor", "opacity", "multiplyOpacity", "factor", "toColorRGB", "$ctx", "$litmusColor", "canvas", "ctx", "rgbaMatch", "rgb", "relativeLuminance", "relativeLuminance2", "r", "rs", "gs", "bs", "rr", "rg", "rb", "bgRgba", "fgRgba", "bgL", "fgL", "contrastRatio", "resultA", "reduceLuminance", "resultARatio", "resultB", "increaseLuminance", "resultBRatio", "cr", "toChannels", "value", "c", "s", "l1", "l2", "DEFAULT_ANSI_COLORS", "colors", "css", "v", "i", "r", "g", "b", "channels", "c", "constrain", "value", "low", "high", "escapeHTMLChar", "c", "BaseSerializeHandler", "_buffer", "range", "excludeFinalCursorPosition", "cell1", "cell2", "oldCell", "startRow", "endRow", "startColumn", "endColumn", "row", "line", "startLineColumn", "endLineColumn", "col", "cell", "isLastRow", "rows", "equalFg", "equalBg", "equalUnderline", "cell1Default", "cell2Default", "equalFlags", "attributesEquals", "cell1AsBufferCell", "StringSerializeHandler", "buffer", "_terminal", "start", "end", "rowSeparator", "currentLine", "nextLine", "thisRowLastChar", "thisRowLastSecondChar", "nextRowFirstChar", "isNextRowFirstCharDoubleWidth", "isValid", "sgrSeq", "fgChanged", "bgChanged", "flagsChanged", "color", "style", "isEmptyCell", "rowEnd", "content", "realCursorRow", "realCursorCol", "cursorMoved", "moveRight", "offset", "curAttrData", "SerializeAddon", "terminal", "scrollback", "maxRows", "correctRows", "options", "handler", "HTMLSerializeHandler", "onlySelection", "selection", "scrollTop", "scrollBottom", "modes", "alternativeScreenContent", "_options", "DEFAULT_ANSI_COLORS", "foreground", "background", "globalStyleDefinitions", "isFg", "x", "fgHexColor", "bgHexColor", "decorations", "underlineColor", "styleDefinitions"] -+ "sourcesContent": ["/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n", "/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\ntype OscLinkData = { id?: string; uri: string };\ntype OscLinkedCell = { extended?: { urlId?: number } };\ntype TerminalWithOscLinks = Terminal & {\n _core?: {\n _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };\n _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };\n };\n};\n\nfunction getOscLinkId(cell: IBufferCell | IAttributeData): number {\n return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;\n}\n\nfunction getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {\n if (!linkId) {\n return '';\n }\n const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);\n if (!data) {\n return '';\n }\n const params = data.id === undefined ? '' : `id=${data.id}`;\n return `\\u001b]8;${params};${data.uri}\\u001b\\\\`;\n}\n\nfunction getActiveOscLinkSequence(terminal: Terminal): string {\n const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;\n return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';\n}\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: line.length;\n for (let col = startLineColumn; col < endLineColumn; col++) {\n const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n private _defaultCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n private _activeOscLinkId: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;\n const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n const contentCellCount = currentLine.length - this._nullCellCount;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}C`;\n }\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}D`;\n }\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so\n // they must be diffed as one intensity group with the clearing 22 emitted\n // BEFORE any re-set. Upstream's independent per-flag diff could emit\n // \"1;22\" (bold set, then wiped by dim's clear \u2014 \\x1b[2mA\\x1b[22m\\x1b[1mB\n // loses B's bold on round-trip) or a bare \"22\" that drops a still-set\n // bold/dim, garbling Orca's hidden-terminal snapshot restores.\n const boldChanged = cell.isBold() !== oldCell.isBold();\n const dimChanged = cell.isDim() !== oldCell.isDim();\n if (boldChanged || dimChanged) {\n const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());\n if (clearsIntensity) { sgrSeq.push(22); }\n if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }\n if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }\n }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n // PATCH(orca): dim handled in the intensity group above.\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n private _setOscLink(linkId: number): string {\n if (linkId === this._activeOscLinkId) {\n return '';\n }\n let sequence = this._activeOscLinkId ? '\\u001b]8;;\\u001b\\\\' : '';\n this._activeOscLinkId = 0;\n const openSequence = getOscLinkOpenSequence(this._terminal, linkId);\n if (openSequence) {\n this._activeOscLinkId = linkId;\n sequence += openSequence;\n }\n return sequence;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;\n const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);\n // A pending wide glyph recreates its own final-column padding during replay.\n const isWideWrapPadding = col === this._terminal.cols - 1 &&\n nextLine?.isWrapped &&\n (nextRowFirstCell?.getWidth() ?? 0) > 1 &&\n !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);\n // Cursor movement cannot reproduce an inverse cell's visible background.\n const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n const styleChanged = isEmptyCell\n ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)\n : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell && !materializeEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.\n this._currentRow += this._setOscLink(getOscLinkId(cell));\n\n if (materializeEmptyCell) {\n const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();\n if (hasDecoration) {\n this._currentRow += '\\u001b[24;29;55m';\n }\n this._currentRow += ' '.repeat(cell.getWidth());\n if (hasDecoration) {\n const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);\n this._currentRow += `\\u001b[0m\\u001b[${restoreSgrSeq.join(';')}m`;\n }\n } else {\n this._currentRow += cell.getChars();\n }\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // Each buffer is self-contained so links cannot leak into a following buffer.\n content += this._setOscLink(0);\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n // Restore the source terminal's live OSC pen only after all buffers are complete.\n content += getActiveOscLinkSequence(this._terminal);\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"], -+ "mappings": "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", -+ "names": ["$r", "$g", "$b", "$a", "channels", "toCss", "r", "g", "b", "a", "toPaddedHex", "toRgba", "toColor", "color", "blend", "bg", "fg", "$a", "fgR", "fgG", "fgB", "bgR", "bgG", "bgB", "$r", "$g", "$b", "css", "rgba", "isOpaque", "ensureContrastRatio", "ratio", "result", "opaque", "rgbaColor", "opacity", "multiplyOpacity", "factor", "toColorRGB", "$ctx", "$litmusColor", "canvas", "ctx", "rgbaMatch", "rgb", "relativeLuminance", "relativeLuminance2", "rs", "gs", "bs", "rr", "rg", "rb", "bgRgba", "fgRgba", "bgL", "fgL", "contrastRatio", "resultA", "reduceLuminance", "resultARatio", "resultB", "increaseLuminance", "resultBRatio", "cr", "toChannels", "value", "c", "s", "l1", "l2", "DEFAULT_ANSI_COLORS", "colors", "css", "v", "i", "g", "b", "channels", "c", "getOscLinkId", "cell", "getOscLinkOpenSequence", "terminal", "linkId", "data", "getActiveOscLinkSequence", "attrs", "constrain", "value", "low", "high", "escapeHTMLChar", "c", "BaseSerializeHandler", "_buffer", "range", "excludeFinalCursorPosition", "cell1", "cell2", "oldCell", "startRow", "endRow", "startColumn", "endColumn", "row", "line", "startLineColumn", "endLineColumn", "col", "isLastRow", "rows", "equalFg", "equalBg", "equalUnderline", "cell1Default", "cell2Default", "equalFlags", "attributesEquals", "cell1AsBufferCell", "StringSerializeHandler", "buffer", "_terminal", "start", "end", "rowSeparator", "currentLine", "nextLine", "thisRowLastChar", "thisRowLastSecondChar", "nextRowFirstChar", "isNextRowFirstCharDoubleWidth", "isValid", "contentCellCount", "sgrSeq", "fgChanged", "bgChanged", "flagsChanged", "color", "boldChanged", "dimChanged", "clearsIntensity", "style", "sequence", "openSequence", "isEmptyCell", "nextRowFirstCell", "isWideWrapPadding", "materializeEmptyCell", "hasDecoration", "restoreSgrSeq", "rowEnd", "content", "realCursorRow", "realCursorCol", "cursorMoved", "moveRight", "offset", "curAttrData", "SerializeAddon", "scrollback", "maxRows", "correctRows", "options", "handler", "HTMLSerializeHandler", "onlySelection", "selection", "scrollTop", "scrollBottom", "modes", "alternativeScreenContent", "_options", "DEFAULT_ANSI_COLORS", "foreground", "background", "globalStyleDefinitions", "isFg", "x", "fgHexColor", "bgHexColor", "decorations", "underlineColor", "styleDefinitions"] ++ "sourcesContent": ["/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, IColorRGB } from './Types';\n\nlet $r = 0;\nlet $g = 0;\nlet $b = 0;\nlet $a = 0;\n\nexport const NULL_COLOR: IColor = {\n css: '#00000000',\n rgba: 0\n};\n\n/**\n * Helper functions where the source type is \"channels\" (individual color channels as numbers).\n */\nexport namespace channels {\n export function toCss(r: number, g: number, b: number, a?: number): string {\n if (a !== undefined) {\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;\n }\n return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;\n }\n\n export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {\n // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32\n // on LE systems, before it can be used for direct 32-bit buffer writes.\n // >>> 0 forces an unsigned int\n return (r << 24 | g << 16 | b << 8 | a) >>> 0;\n }\n\n export function toColor(r: number, g: number, b: number, a?: number): IColor {\n return {\n css: channels.toCss(r, g, b, a),\n rgba: channels.toRgba(r, g, b, a)\n };\n }\n}\n\n/**\n * Helper functions where the source type is `IColor`.\n */\nexport namespace color {\n export function blend(bg: IColor, fg: IColor): IColor {\n $a = (fg.rgba & 0xFF) / 255;\n if ($a === 1) {\n return {\n css: fg.css,\n rgba: fg.rgba\n };\n }\n const fgR = (fg.rgba >> 24) & 0xFF;\n const fgG = (fg.rgba >> 16) & 0xFF;\n const fgB = (fg.rgba >> 8) & 0xFF;\n const bgR = (bg.rgba >> 24) & 0xFF;\n const bgG = (bg.rgba >> 16) & 0xFF;\n const bgB = (bg.rgba >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n const css = channels.toCss($r, $g, $b);\n const rgba = channels.toRgba($r, $g, $b);\n return { css, rgba };\n }\n\n export function isOpaque(color: IColor): boolean {\n return (color.rgba & 0xFF) === 0xFF;\n }\n\n export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {\n const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);\n if (!result) {\n return undefined;\n }\n return channels.toColor(\n (result >> 24 & 0xFF),\n (result >> 16 & 0xFF),\n (result >> 8 & 0xFF)\n );\n }\n\n export function opaque(color: IColor): IColor {\n const rgbaColor = (color.rgba | 0xFF) >>> 0;\n [$r, $g, $b] = rgba.toChannels(rgbaColor);\n return {\n css: channels.toCss($r, $g, $b),\n rgba: rgbaColor\n };\n }\n\n export function opacity(color: IColor, opacity: number): IColor {\n $a = Math.round(opacity * 0xFF);\n [$r, $g, $b] = rgba.toChannels(color.rgba);\n return {\n css: channels.toCss($r, $g, $b, $a),\n rgba: channels.toRgba($r, $g, $b, $a)\n };\n }\n\n export function multiplyOpacity(color: IColor, factor: number): IColor {\n $a = color.rgba & 0xFF;\n return opacity(color, ($a * factor) / 0xFF);\n }\n\n export function toColorRGB(color: IColor): IColorRGB {\n return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];\n }\n}\n\n/**\n * Helper functions where the source type is \"css\" (string: '#rgb', '#rgba', '#rrggbb',\n * '#rrggbbaa').\n */\nexport namespace css {\n // Attempt to set get the shared canvas context\n let $ctx: CanvasRenderingContext2D | undefined;\n let $litmusColor: CanvasGradient | undefined;\n try {\n // This is guaranteed to run in the first window, so document should be correct\n const canvas = document.createElement('canvas');\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true\n });\n if (ctx) {\n $ctx = ctx;\n $ctx.globalCompositeOperation = 'copy';\n $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);\n }\n }\n catch {\n // noop\n }\n\n /**\n * Converts a css string to an IColor, this should handle all valid CSS color strings and will\n * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.\n *\n * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node\n * environment.\n */\n export function toColor(css: string): IColor {\n // Formats: #rgb[a] and #rrggbb[aa]\n if (css.match(/#[\\da-f]{3,8}/i)) {\n switch (css.length) {\n case 4: { // #rgb\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n return channels.toColor($r, $g, $b);\n }\n case 5: { // #rgba\n $r = parseInt(css.slice(1, 2).repeat(2), 16);\n $g = parseInt(css.slice(2, 3).repeat(2), 16);\n $b = parseInt(css.slice(3, 4).repeat(2), 16);\n $a = parseInt(css.slice(4, 5).repeat(2), 16);\n return channels.toColor($r, $g, $b, $a);\n }\n case 7: // #rrggbb\n return {\n css,\n rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0\n };\n case 9: // #rrggbbaa\n return {\n css,\n rgba: parseInt(css.slice(1), 16) >>> 0\n };\n }\n }\n\n // Formats: rgb() or rgba()\n const rgbaMatch = css.match(/rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(,\\s*(0|1|\\d?\\.(\\d+))\\s*)?\\)/);\n if (rgbaMatch) {\n $r = parseInt(rgbaMatch[1], 10);\n $g = parseInt(rgbaMatch[2], 10);\n $b = parseInt(rgbaMatch[3], 10);\n $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);\n return channels.toColor($r, $g, $b, $a);\n }\n\n // Handle the \"transparent\" keyword\n if (css === 'transparent') {\n return {\n css: 'transparent',\n rgba: 0x00000000\n };\n }\n\n // Validate the context is available for canvas-based color parsing\n if (!$ctx || !$litmusColor) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Validate the color using canvas fillStyle\n // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles\n $ctx.fillStyle = $litmusColor;\n $ctx.fillStyle = css;\n if (typeof $ctx.fillStyle !== 'string') {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n $ctx.fillRect(0, 0, 1, 1);\n [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;\n\n // Validate the color is non-transparent as color hue gets lost when drawn to the canvas\n if ($a !== 0xFF) {\n throw new Error('css.toColor: Unsupported css format');\n }\n\n // Extract the color from the canvas' fillStyle property which exposes the color value in rgba()\n // format\n // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color\n return {\n rgba: channels.toRgba($r, $g, $b, $a),\n css\n };\n }\n}\n\n/**\n * Helper functions where the source type is \"rgb\" (number: 0xrrggbb).\n */\nexport namespace rgb {\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param rgb The color to use.\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance(rgb: number): number {\n return relativeLuminance2(\n (rgb >> 16) & 0xFF,\n (rgb >> 8 ) & 0xFF,\n (rgb ) & 0xFF);\n }\n\n /**\n * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio\n * between two colors.\n * @param r The red channel (0x00 to 0xFF).\n * @param g The green channel (0x00 to 0xFF).\n * @param b The blue channel (0x00 to 0xFF).\n * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef\n */\n export function relativeLuminance2(r: number, g: number, b: number): number {\n const rs = r / 255;\n const gs = g / 255;\n const bs = b / 255;\n const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);\n const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);\n const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);\n return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;\n }\n}\n\n/**\n * Helper functions where the source type is \"rgba\" (number: 0xrrggbbaa).\n */\nexport namespace rgba {\n export function blend(bg: number, fg: number): number {\n $a = (fg & 0xFF) / 0xFF;\n if ($a === 1) {\n return fg;\n }\n const fgR = (fg >> 24) & 0xFF;\n const fgG = (fg >> 16) & 0xFF;\n const fgB = (fg >> 8) & 0xFF;\n const bgR = (bg >> 24) & 0xFF;\n const bgG = (bg >> 16) & 0xFF;\n const bgB = (bg >> 8) & 0xFF;\n $r = bgR + Math.round((fgR - bgR) * $a);\n $g = bgG + Math.round((fgG - bgG) * $a);\n $b = bgB + Math.round((fgB - bgB) * $a);\n return channels.toRgba($r, $g, $b);\n }\n\n /**\n * Given a foreground color and a background color, either increase or reduce the luminance of the\n * foreground color until the specified contrast ratio is met. If pure white or black is hit\n * without the contrast ratio being met, go the other direction using the background color as the\n * foreground color and take either the first or second result depending on which has the higher\n * contrast ratio.\n *\n * `undefined` will be returned if the contrast ratio is already met.\n *\n * @param bgRgba The background color in rgba format.\n * @param fgRgba The foreground color in rgba format.\n * @param ratio The contrast ratio to achieve.\n */\n export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {\n const bgL = rgb.relativeLuminance(bgRgba >> 8);\n const fgL = rgb.relativeLuminance(fgRgba >> 8);\n const cr = contrastRatio(bgL, fgL);\n if (cr < ratio) {\n if (fgL < bgL) {\n const resultA = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n const resultA = increaseLuminance(bgRgba, fgRgba, ratio);\n const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));\n if (resultARatio < ratio) {\n const resultB = reduceLuminance(bgRgba, fgRgba, ratio);\n const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));\n return resultARatio > resultBRatio ? resultA : resultB;\n }\n return resultA;\n }\n return undefined;\n }\n\n export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to reducing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {\n // Reduce by 10% until the ratio is hit\n fgR -= Math.max(0, Math.ceil(fgR * 0.1));\n fgG -= Math.max(0, Math.ceil(fgG * 0.1));\n fgB -= Math.max(0, Math.ceil(fgB * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {\n // This is a naive but fast approach to increasing luminance as converting to\n // HSL and back is expensive\n const bgR = (bgRgba >> 24) & 0xFF;\n const bgG = (bgRgba >> 16) & 0xFF;\n const bgB = (bgRgba >> 8) & 0xFF;\n let fgR = (fgRgba >> 24) & 0xFF;\n let fgG = (fgRgba >> 16) & 0xFF;\n let fgB = (fgRgba >> 8) & 0xFF;\n let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {\n // Increase by 10% until the ratio is hit\n fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));\n fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));\n fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));\n cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));\n }\n return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;\n }\n\n export function toChannels(value: number): [number, number, number, number] {\n return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];\n }\n}\n\nexport function toPaddedHex(c: number): string {\n const s = c.toString(16);\n return s.length < 2 ? '0' + s : s;\n}\n\n/**\n * Gets the contrast ratio between two relative luminance values.\n * @param l1 The first relative luminance.\n * @param l2 The second relative luminance.\n * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef\n */\nexport function contrastRatio(l1: number, l2: number): number {\n if (l1 < l2) {\n return (l2 + 0.05) / (l1 + 0.05);\n }\n return (l1 + 0.05) / (l2 + 0.05);\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IColor, ITerminalOptions } from '../common/Types';\nimport { CharData, IBuffer } from '../common/buffer/Types';\nimport { ICoreTerminal } from '../common/CoreTerminal';\nimport { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm';\nimport { channels, css } from '../common/Color';\nimport type { IEvent } from '../common/Event';\n\n/**\n * A portion of the public API that are implemented identially internally and simply passed through.\n */\ntype InternalPassthroughApis = Omit;\n\nexport interface ITerminal extends InternalPassthroughApis, ICoreTerminal {\n screenElement: HTMLElement | undefined;\n browser: IBrowser;\n buffer: IBuffer;\n linkifier: ILinkifier2 | undefined;\n options: Required;\n\n readonly dimensions: IRenderDimensionsApi | undefined;\n\n onBlur: IEvent;\n onFocus: IEvent;\n onDimensionsChange: IEvent;\n onA11yChar: IEvent;\n onA11yTab: IEvent;\n onWillOpen: IEvent;\n}\n\nexport type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;\nexport type CustomWheelEventHandler = (event: WheelEvent) => boolean;\n\nexport type LineData = CharData[];\n\nexport interface ICompositionHelper {\n readonly isComposing: boolean;\n compositionstart(): void;\n compositionupdate(ev: CompositionEvent): void;\n compositionend(): void;\n updateCompositionElements(dontRecurse?: boolean): void;\n keydown(ev: KeyboardEvent): boolean;\n}\n\nexport interface IBrowser {\n isNode: boolean;\n userAgent: string;\n platform: string;\n isFirefox: boolean;\n isMac: boolean;\n isIpad: boolean;\n isIphone: boolean;\n isWindows: boolean;\n}\n\nexport interface IColorSet {\n foreground: IColor;\n background: IColor;\n cursor: IColor;\n cursorAccent: IColor;\n selectionForeground: IColor | undefined;\n selectionBackgroundTransparent: IColor;\n /** The selection blended on top of background. */\n selectionBackgroundOpaque: IColor;\n selectionInactiveBackgroundTransparent: IColor;\n selectionInactiveBackgroundOpaque: IColor;\n scrollbarSliderBackground: IColor;\n scrollbarSliderHoverBackground: IColor;\n scrollbarSliderActiveBackground: IColor;\n overviewRulerBorder: IColor;\n ansi: IColor[];\n /** Maps original colors to colors that respect minimum contrast ratio. */\n contrastCache: IColorContrastCache;\n /** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */\n halfContrastCache: IColorContrastCache;\n}\n\nexport type ReadonlyColorSet = Readonly> & { ansi: Readonly['ansi']> };\n\nexport interface IColorContrastCache {\n clear(): void;\n setCss(bg: number, fg: number, value: string | null): void;\n getCss(bg: number, fg: number): string | null | undefined;\n setColor(bg: number, fg: number, value: IColor | null): void;\n getColor(bg: number, fg: number): IColor | null | undefined;\n}\n\nexport interface IPartialColorSet {\n foreground: IColor;\n background: IColor;\n cursor?: IColor;\n cursorAccent?: IColor;\n selectionBackground?: IColor;\n ansi: IColor[];\n}\n\nexport interface IViewport extends IDisposable {\n scrollBarWidth: number;\n readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;\n syncScrollArea(immediate?: boolean, force?: boolean): void;\n getLinesScrolled(ev: WheelEvent): number;\n getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };\n handleWheel(ev: WheelEvent): boolean;\n handleTouchStart(ev: TouchEvent): void;\n handleTouchMove(ev: TouchEvent): boolean;\n scrollLines(disp: number): void; // todo api name?\n reset(): void;\n}\n\nexport interface ILinkifierEvent {\n x1: number;\n y1: number;\n x2: number;\n y2: number;\n cols: number;\n fg: number | undefined;\n}\n\ninterface ILinkState {\n decorations: ILinkDecorations;\n isHovered: boolean;\n}\nexport interface ILinkWithState {\n link: ILink;\n state?: ILinkState;\n}\n\nexport interface ILinkifier2 extends IDisposable {\n onShowLinkUnderline: IEvent;\n onHideLinkUnderline: IEvent;\n readonly currentLink: ILinkWithState | undefined;\n}\n\nexport interface ILink {\n range: IBufferRange;\n text: string;\n decorations?: ILinkDecorations;\n activate(event: MouseEvent, text: string): void;\n hover?(event: MouseEvent, text: string): void;\n leave?(event: MouseEvent, text: string): void;\n dispose?(): void;\n}\n\nexport interface ILinkDecorations {\n pointerCursor: boolean;\n underline: boolean;\n}\n\nexport interface IBufferRange {\n start: IBufferCellPosition;\n end: IBufferCellPosition;\n}\n\nexport interface IBufferCellPosition {\n x: number;\n y: number;\n}\n\nexport type CharacterJoinerHandler = (text: string) => [number, number][];\n\nexport interface ICharacterJoiner {\n id: number;\n handler: CharacterJoinerHandler;\n}\n\nexport interface IRenderDebouncer extends IDisposable {\n refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;\n}\n\nexport interface IRenderDebouncerWithCallback extends IRenderDebouncer {\n addRefreshCallback(callback: FrameRequestCallback): number;\n}\n\nexport interface IBufferElementProvider {\n provideBufferElements(): DocumentFragment | HTMLElement;\n}\n\n// An IIFE to generate DEFAULT_ANSI_COLORS.\nexport const DEFAULT_ANSI_COLORS = Object.freeze((() => {\n const colors = [\n // dark:\n css.toColor('#2e3436'),\n css.toColor('#cc0000'),\n css.toColor('#4e9a06'),\n css.toColor('#c4a000'),\n css.toColor('#3465a4'),\n css.toColor('#75507b'),\n css.toColor('#06989a'),\n css.toColor('#d3d7cf'),\n // bright:\n css.toColor('#555753'),\n css.toColor('#ef2929'),\n css.toColor('#8ae234'),\n css.toColor('#fce94f'),\n css.toColor('#729fcf'),\n css.toColor('#ad7fa8'),\n css.toColor('#34e2e2'),\n css.toColor('#eeeeec')\n ];\n\n // Fill in the remaining 240 ANSI colors.\n // Generate colors (16-231)\n const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];\n for (let i = 0; i < 216; i++) {\n const r = v[(i / 36) % 6 | 0];\n const g = v[(i / 6) % 6 | 0];\n const b = v[i % 6];\n colors.push({\n css: channels.toCss(r, g, b),\n rgba: channels.toRgba(r, g, b)\n });\n }\n\n // Generate greys (232-255)\n for (let i = 0; i < 24; i++) {\n const c = 8 + i * 10;\n colors.push({\n css: channels.toCss(c, c, c),\n rgba: channels.toRgba(c, c, c)\n });\n }\n\n return colors;\n})());\n", "/**\n * Copyright (c) 2019 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * (EXPERIMENTAL) This Addon is still under development\n */\n\nimport type { IBuffer, IBufferCell, IBufferLine, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';\nimport type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';\nimport { IColor } from 'common/Types';\nimport { IAttributeData } from 'common/buffer/Types';\nimport { DEFAULT_ANSI_COLORS } from 'browser/Types';\nimport { UnderlineStyle } from 'common/buffer/Constants';\n\ntype OscLinkData = { id?: string; uri: string };\ntype OscLinkedCell = { extended?: { urlId?: number } };\ntype TerminalWithOscLinks = Terminal & {\n _core?: {\n _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };\n _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };\n };\n};\n\nfunction getOscLinkId(cell: IBufferCell | IAttributeData): number {\n return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;\n}\n\nfunction getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {\n if (!linkId) {\n return '';\n }\n const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);\n if (!data) {\n return '';\n }\n const params = data.id === undefined ? '' : `id=${data.id}`;\n return `\\u001b]8;${params};${data.uri}\\u001b\\\\`;\n}\n\nfunction getActiveOscLinkSequence(terminal: Terminal): string {\n const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;\n return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';\n}\n\n// PATCH(orca): a non-reflowing shrink (always the alternate buffer) leaves each\n// line at its old length; cells past the grid are not on screen and would wrap\n// into extra rows on replay, so serialize only the terminal's width.\nfunction visibleCellCount(line: IBufferLine, cols: number): number {\n return Math.min(line.length, cols);\n}\n\n// PATCH(orca): a wide glyph whose trailing half fell past the grid cannot be\n// replayed in the last column (it would wrap and shift every row below), so it\n// is serialized as a blank that keeps the row exactly the grid's width.\nfunction isClippedWideCell(line: IBufferLine, col: number, cols: number, cell: IBufferCell): boolean {\n return col === cols - 1 && line.length > cols && cell.getWidth() > 1;\n}\n\n// Width 1 with no codepoint (CellData.content layout). A width-0 stand-in would read as a\n// wide glyph's trailer: skipped by _nextCell and treated as content by the wrap check.\nconst BLANK_CELL_CONTENT = 1 << 22;\n\nfunction blankClippedWideCell(cell: IBufferCell): IBufferCell {\n (cell as IBufferCell & { content: number }).content = BLANK_CELL_CONTENT;\n return cell;\n}\n\nfunction constrain(value: number, low: number, high: number): number {\n return Math.max(low, Math.min(value, high));\n}\n\nfunction escapeHTMLChar(c: string): string {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n }\n return c;\n}\n\n// TODO: Refine this template class later\nabstract class BaseSerializeHandler {\n constructor(\n protected readonly _buffer: IBuffer,\n private readonly _cols: number\n ) {\n }\n\n public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {\n // we need two of them to flip between old and new cell\n const cell1 = this._buffer.getNullCell();\n const cell2 = this._buffer.getNullCell();\n let oldCell = cell1;\n\n const startRow = range.start.y;\n const endRow = range.end.y;\n const startColumn = range.start.x;\n const endColumn = range.end.x;\n\n this._beforeSerialize(endRow - startRow, startRow, endRow);\n\n for (let row = startRow; row <= endRow; row++) {\n const line = this._buffer.getLine(row);\n if (line) {\n const startLineColumn = row === range.start.y ? startColumn : 0;\n const endLineColumn = row === range.end.y ? endColumn: visibleCellCount(line, this._cols);\n for (let col = startLineColumn; col < endLineColumn; col++) {\n let c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);\n if (!c) {\n console.warn(`Can't get cell at row=${row}, col=${col}`);\n continue;\n }\n if (isClippedWideCell(line, col, this._cols, c)) {\n c = blankClippedWideCell(c);\n }\n this._nextCell(c, oldCell, row, col);\n oldCell = c;\n }\n }\n this._rowEnd(row, row === endRow);\n }\n\n this._afterSerialize();\n\n return this._serializeString(excludeFinalCursorPosition);\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }\n protected _rowEnd(row: number, isLastRow: boolean): void { }\n protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }\n protected _afterSerialize(): void { }\n protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }\n}\n\nfunction equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getFgColorMode() === cell2.getFgColorMode()\n && cell1.getFgColor() === cell2.getFgColor();\n}\n\nfunction equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.getBgColorMode() === cell2.getBgColorMode()\n && cell1.getBgColor() === cell2.getBgColor();\n}\n\nfunction equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n // If neither cell has underline, consider them equal regardless of internal underline color\n // values\n if (!cell1.isUnderline() && !cell2.isUnderline()) {\n return true;\n }\n if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {\n return false;\n }\n const cell1Default = cell1.isUnderlineColorDefault();\n const cell2Default = cell2.isUnderlineColorDefault();\n if (cell1Default && cell2Default) {\n return true;\n }\n if (cell1Default !== cell2Default) {\n return false;\n }\n return cell1.getUnderlineColor() === cell2.getUnderlineColor()\n && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();\n}\n\nfunction equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n return cell1.isInverse() === cell2.isInverse()\n && cell1.isBold() === cell2.isBold()\n && cell1.isUnderline() === cell2.isUnderline()\n && equalUnderline(cell1, cell2)\n && cell1.isOverline() === cell2.isOverline()\n && cell1.isBlink() === cell2.isBlink()\n && cell1.isInvisible() === cell2.isInvisible()\n && cell1.isItalic() === cell2.isItalic()\n && cell1.isDim() === cell2.isDim()\n && cell1.isStrikethrough() === cell2.isStrikethrough();\n}\n\nfunction attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {\n const cell1AsBufferCell = cell1 as IBufferCell;\n if (typeof cell1AsBufferCell.attributesEquals === 'function') {\n return cell1AsBufferCell.attributesEquals(cell2);\n }\n return equalFg(cell1, cell2)\n && equalBg(cell1, cell2)\n && equalFlags(cell1, cell2);\n}\n\nclass StringSerializeHandler extends BaseSerializeHandler {\n private _rowIndex: number = 0;\n private _allRows: string[] = new Array();\n private _allRowSeparators: string[] = new Array();\n private _currentRow: string = '';\n private _nullCellCount: number = 0;\n\n // we can see a full colored cell and a null cell that only have background the same style\n // but the information isn't preserved by null cell itself\n // so wee need to record it when required.\n private _cursorStyle: IBufferCell = this._buffer.getNullCell();\n\n // where exact the cursor styles comes from\n // because we can't copy the cell directly\n // so we remember where the content comes from instead\n private _cursorStyleRow: number = 0;\n private _cursorStyleCol: number = 0;\n\n // this is a null cell for reference for checking whether background is empty or not\n private _backgroundCell: IBufferCell = this._buffer.getNullCell();\n private _defaultCell: IBufferCell = this._buffer.getNullCell();\n\n private _firstRow: number = 0;\n private _lastCursorRow: number = 0;\n private _lastCursorCol: number = 0;\n private _lastContentCursorRow: number = 0;\n private _lastContentCursorCol: number = 0;\n private _activeOscLinkId: number = 0;\n private _lastBackgroundCursorRow: number = -1;\n private _lastBackgroundCursorCol: number = 0;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal\n ) {\n super(buffer, _terminal.cols);\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._allRows = new Array(rows);\n this._lastContentCursorRow = start;\n this._lastCursorRow = start;\n this._firstRow = start;\n }\n\n private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();\n private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();\n private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();\n protected _rowEnd(row: number, isLastRow: boolean): void {\n // if there is colorful empty cell at line end, we must pad it back, or the color block\n // will missing\n if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {\n // use clear right to set background.\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n // PATCH(orca): the erase paints this row, so _serializeString must not trim it as trailing blank.\n this._lastBackgroundCursorRow = this._lastCursorRow;\n this._lastBackgroundCursorCol = this._lastCursorCol;\n }\n\n let rowSeparator = '';\n\n // handle row separator\n if (!isLastRow) {\n // Enable BCE\n if (row - this._firstRow >= this._terminal.rows) {\n this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);\n }\n\n // Fetch current line\n const currentLine = this._buffer.getLine(row)!;\n // Fetch next line\n const nextLine = this._buffer.getLine(row + 1)!;\n\n if (!nextLine.isWrapped) {\n // just insert the line break\n rowSeparator = '\\r\\n';\n // we sended the enter\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n } else {\n rowSeparator = '';\n const currentLineCells = visibleCellCount(currentLine, this._terminal.cols);\n let thisRowLastChar = currentLine.getCell(currentLineCells - 1, this._thisRowLastChar)!;\n if (isClippedWideCell(currentLine, currentLineCells - 1, this._terminal.cols, thisRowLastChar)) {\n thisRowLastChar = blankClippedWideCell(thisRowLastChar);\n }\n const thisRowLastSecondChar = currentLine.getCell(currentLineCells - 2, this._thisRowLastSecondChar)!;\n const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;\n const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;\n\n // validate whether this line wrap is ever possible\n // which mean whether cursor can placed at a overflow position (x === row) naturally\n let isValid = false;\n\n if (\n // you must output character to cause overflow, control sequence can't do this\n nextRowFirstChar.getChars() &&\n (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)\n ) {\n if (\n // the last character can't be null,\n // you can't use control sequence to move cursor to (x === row)\n (thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n\n if (\n // the second to last character can't be null if the next line starts with CJK,\n // you can't use control sequence to move cursor to (x === row)\n isNextRowFirstCharDoubleWidth &&\n (thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&\n // change background of the first wrapped cell also affects BCE\n // so we mark it as invalid to simplify the process to determine line separator\n equalBg(thisRowLastChar, nextRowFirstChar) &&\n equalBg(thisRowLastSecondChar, nextRowFirstChar)\n ) {\n isValid = true;\n }\n }\n\n if (!isValid) {\n // force the wrap with magic\n // insert enough character to force the wrap\n rowSeparator = '-'.repeat(this._nullCellCount + 1);\n // move back and erase next line head\n rowSeparator += '\\u001b[1D\\u001b[1X';\n\n if (this._nullCellCount > 0) {\n // do these because we filled the last several null slot, which we shouldn't\n rowSeparator += '\\u001b[A';\n const contentCellCount = currentLineCells - this._nullCellCount;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}C`;\n }\n rowSeparator += `\\u001b[${this._nullCellCount}X`;\n if (contentCellCount > 0) {\n rowSeparator += `\\u001b[${contentCellCount}D`;\n }\n rowSeparator += '\\u001b[B';\n }\n\n // This is content and need the be serialized even it is invisible.\n // without this, wrap will be missing from outputs.\n this._lastContentCursorRow = row + 1;\n this._lastContentCursorCol = 0;\n\n // force commit the cursor position\n this._lastCursorRow = row + 1;\n this._lastCursorCol = 0;\n }\n }\n }\n\n this._allRows[this._rowIndex] = this._currentRow;\n this._allRowSeparators[this._rowIndex++] = rowSeparator;\n this._currentRow = '';\n this._nullCellCount = 0;\n }\n\n private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {\n const sgrSeq: number[] = [];\n if (attributesEquals(cell, oldCell)) {\n return sgrSeq;\n }\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n if (cell.isAttributeDefault()) {\n if (!oldCell.isAttributeDefault()) {\n sgrSeq.push(0);\n }\n } else {\n if (fgChanged) {\n const color = cell.getFgColor();\n if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isFgPalette()) {\n if (color >= 16) { sgrSeq.push(38, 5, color); }\n else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }\n }\n else { sgrSeq.push(39); }\n }\n if (bgChanged) {\n const color = cell.getBgColor();\n if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }\n else if (cell.isBgPalette()) {\n if (color >= 16) { sgrSeq.push(48, 5, color); }\n else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }\n }\n else { sgrSeq.push(49); }\n }\n if (flagsChanged) {\n if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }\n // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so\n // they must be diffed as one intensity group with the clearing 22 emitted\n // BEFORE any re-set. Upstream's independent per-flag diff could emit\n // \"1;22\" (bold set, then wiped by dim's clear \u2014 \\x1b[2mA\\x1b[22m\\x1b[1mB\n // loses B's bold on round-trip) or a bare \"22\" that drops a still-set\n // bold/dim, garbling Orca's hidden-terminal snapshot restores.\n const boldChanged = cell.isBold() !== oldCell.isBold();\n const dimChanged = cell.isDim() !== oldCell.isDim();\n if (boldChanged || dimChanged) {\n const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());\n if (clearsIntensity) { sgrSeq.push(22); }\n if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }\n if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }\n }\n if (!equalUnderline(cell, oldCell)) {\n const style = cell.getUnderlineStyle();\n if (style === UnderlineStyle.NONE) {\n sgrSeq.push(24);\n } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {\n sgrSeq.push(4);\n } else {\n // Use SGR 4:X format for underline styles\n sgrSeq.push('4:' + style as unknown as number);\n // Handle underline color\n if (!cell.isUnderlineColorDefault()) {\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);\n } else {\n sgrSeq.push('58:5:' + color as unknown as number);\n }\n }\n }\n } else if (cell.isUnderline() !== oldCell.isUnderline()) {\n sgrSeq.push(cell.isUnderline() ? 4 : 24);\n }\n if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }\n if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }\n if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }\n if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }\n // PATCH(orca): dim handled in the intensity group above.\n if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }\n }\n }\n }\n\n return sgrSeq;\n }\n\n private _setOscLink(linkId: number): string {\n if (linkId === this._activeOscLinkId) {\n return '';\n }\n let sequence = this._activeOscLinkId ? '\\u001b]8;;\\u001b\\\\' : '';\n this._activeOscLinkId = 0;\n const openSequence = getOscLinkOpenSequence(this._terminal, linkId);\n if (openSequence) {\n this._activeOscLinkId = linkId;\n sequence += openSequence;\n }\n return sequence;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;\n const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);\n // A pending wide glyph recreates its own final-column padding during replay.\n const isWideWrapPadding = col === this._terminal.cols - 1 &&\n nextLine?.isWrapped &&\n (nextRowFirstCell?.getWidth() ?? 0) > 1 &&\n !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);\n // Cursor movement cannot reproduce an inverse cell's visible background.\n const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;\n\n const sgrSeq = this._diffStyle(cell, this._cursorStyle);\n\n const styleChanged = isEmptyCell\n ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)\n : sgrSeq.length > 0;\n\n /**\n * handles style change\n */\n if (styleChanged) {\n // before update the style, we need to fill empty cell back\n if (this._nullCellCount > 0) {\n // use clear right to set background.\n if (!equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n }\n // use move right to move cursor.\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n this._nullCellCount = 0;\n }\n\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col;\n\n this._currentRow += `\\u001b[${sgrSeq.join(';')}m`;\n\n // update the last cursor style\n const line = this._buffer.getLine(row);\n if (line !== undefined) {\n line.getCell(col, this._cursorStyle);\n this._cursorStyleRow = row;\n this._cursorStyleCol = col;\n }\n }\n\n /**\n * handles actual content\n */\n if (isEmptyCell && !materializeEmptyCell) {\n this._nullCellCount += cell.getWidth();\n } else {\n if (this._nullCellCount > 0) {\n // we can just assume we have same style with previous one here\n // because style change is handled by previous stage\n // use move right when background is empty, use clear right when there is background.\n if (equalBg(this._cursorStyle, this._backgroundCell)) {\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n } else {\n this._currentRow += `\\u001b[${this._nullCellCount}X`;\n this._currentRow += `\\u001b[${this._nullCellCount}C`;\n }\n this._nullCellCount = 0;\n }\n\n // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.\n this._currentRow += this._setOscLink(getOscLinkId(cell));\n\n if (materializeEmptyCell) {\n const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();\n if (hasDecoration) {\n this._currentRow += '\\u001b[24;29;55m';\n }\n this._currentRow += ' '.repeat(cell.getWidth());\n if (hasDecoration) {\n const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);\n this._currentRow += `\\u001b[0m\\u001b[${restoreSgrSeq.join(';')}m`;\n }\n } else {\n this._currentRow += cell.getChars();\n }\n\n // update cursor\n this._lastContentCursorRow = this._lastCursorRow = row;\n this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();\n }\n }\n\n protected _serializeString(excludeFinalCursorPosition: boolean): string {\n let rowEnd = this._allRows.length;\n\n // the fixup is only required for data without scrollback\n // because it will always be placed at last line otherwise\n if (this._buffer.length - this._firstRow <= this._terminal.rows) {\n // PATCH(orca): keep trailing background-only rows, unless the cursor is wrap-pending:\n // relative moves back from those rows cannot re-create that state.\n if (\n this._lastBackgroundCursorRow > this._lastContentCursorRow &&\n this._buffer.cursorX < this._terminal.cols\n ) {\n this._lastContentCursorRow = this._lastBackgroundCursorRow;\n this._lastContentCursorCol = this._lastBackgroundCursorCol;\n }\n rowEnd = this._lastContentCursorRow + 1 - this._firstRow;\n this._lastCursorCol = this._lastContentCursorCol;\n this._lastCursorRow = this._lastContentCursorRow;\n }\n\n let content = '';\n\n for (let i = 0; i < rowEnd; i++) {\n content += this._allRows[i];\n if (i + 1 < rowEnd) {\n content += this._allRowSeparators[i];\n }\n }\n\n // Each buffer is self-contained so links cannot leak into a following buffer.\n content += this._setOscLink(0);\n\n // restore the cursor\n if (!excludeFinalCursorPosition) {\n const realCursorRow = this._buffer.baseY + this._buffer.cursorY;\n const realCursorCol = this._buffer.cursorX;\n\n const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);\n\n const moveRight = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}C`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}D`;\n }\n };\n const moveDown = (offset: number): void => {\n if (offset > 0) {\n content += `\\u001b[${offset}B`;\n } else if (offset < 0) {\n content += `\\u001b[${-offset}A`;\n }\n };\n\n if (cursorMoved) {\n moveDown(realCursorRow - this._lastCursorRow);\n moveRight(realCursorCol - this._lastCursorCol);\n }\n }\n\n // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677\n // HACK: Internal API access since it's awkward to expose this in the API and serialize will\n // likely be the only consumer\n const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;\n const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);\n if (sgrSeq.length > 0) {\n content += `\\u001b[${sgrSeq.join(';')}m`;\n }\n\n return content;\n }\n}\n\nexport class SerializeAddon implements ITerminalAddon, ISerializeApi {\n private _terminal: Terminal | undefined;\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n }\n\n private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {\n const maxRows = buffer.length;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return this._serializeBufferByRange(terminal, buffer, {\n start: maxRows - correctRows,\n end: maxRows - 1\n }, false);\n }\n\n private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {\n const handler = new StringSerializeHandler(buffer, terminal);\n return handler.serialize({\n start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },\n end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }\n }, excludeFinalCursorPosition);\n }\n\n private _serializeBufferAsHTML(terminal: Terminal, options: Partial): string {\n const buffer = terminal.buffer.active;\n const handler = new HTMLSerializeHandler(buffer, terminal, options);\n const onlySelection = options.onlySelection ?? false;\n const range = options.range;\n if (range) {\n return handler.serialize({\n start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },\n end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }\n });\n }\n if (!onlySelection) {\n const maxRows = buffer.length;\n const scrollback = options.scrollback;\n const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);\n return handler.serialize({\n start: { x: 0, y: maxRows - correctRows },\n end: { x: terminal.cols, y: maxRows - 1 }\n });\n }\n\n const selection = this._terminal?.getSelectionPosition();\n if (selection !== undefined) {\n return handler.serialize({\n start: { x: selection.start.x, y: selection.start.y },\n end: { x: selection.end.x, y: selection.end.y }\n });\n }\n\n return '';\n }\n\n /**\n * Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.\n * Uses internal API access since scroll region is not exposed in the public API.\n */\n private _serializeScrollRegion(terminal: Terminal): string {\n // HACK: Internal API access since scroll region is not exposed in the public API\n const buffer = (terminal as any)._core.buffer;\n const scrollTop: number = buffer.scrollTop;\n const scrollBottom: number = buffer.scrollBottom;\n\n // Only serialize if scroll region is not the default (full terminal size)\n if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {\n // DECSTBM uses 1-based indices: CSI Ps ; Ps r\n return `\\x1b[${scrollTop + 1};${scrollBottom + 1}r`;\n }\n\n return '';\n }\n\n private _serializeModes(terminal: Terminal): string {\n let content = '';\n const modes = terminal.modes;\n\n // Default: false\n if (modes.applicationCursorKeysMode) content += '\\x1b[?1h';\n if (modes.applicationKeypadMode) content += '\\x1b[?66h';\n if (modes.bracketedPasteMode) content += '\\x1b[?2004h';\n if (modes.insertMode) content += '\\x1b[4h';\n if (modes.originMode) content += '\\x1b[?6h';\n if (modes.reverseWraparoundMode) content += '\\x1b[?45h';\n if (modes.sendFocusMode) content += '\\x1b[?1004h';\n // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode\n\n // Default: true\n if (modes.wraparoundMode === false) content += '\\x1b[?7l';\n\n // Default: 'none'\n if (modes.mouseTrackingMode !== 'none') {\n switch (modes.mouseTrackingMode) {\n case 'x10': content += '\\x1b[?9h'; break;\n case 'vt200': content += '\\x1b[?1000h'; break;\n case 'drag': content += '\\x1b[?1002h'; break;\n case 'any': content += '\\x1b[?1003h'; break;\n }\n }\n\n // Cursor visibility (DECTCEM)\n // Default: visible\n if (!modes.showCursor) {\n content += '\\x1b[?25l';\n }\n\n return content;\n }\n\n public serialize(options?: ISerializeOptions): string {\n // TODO: Add combinedData support\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n // Normal buffer\n let content = options?.range\n ? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)\n : this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);\n\n // Alternate buffer\n if (!options?.excludeAltBuffer) {\n if (this._terminal.buffer.active.type === 'alternate') {\n const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);\n content += `\\u001b[?1049h\\u001b[H${alternativeScreenContent}`;\n }\n }\n\n // Modes and scroll region\n if (!options?.excludeModes) {\n content += this._serializeModes(this._terminal);\n content += this._serializeScrollRegion(this._terminal);\n }\n\n // Restore the source terminal's live OSC pen only after all buffers are complete.\n content += getActiveOscLinkSequence(this._terminal);\n\n return content;\n }\n\n public serializeAsHTML(options?: Partial): string {\n if (!this._terminal) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n return this._serializeBufferAsHTML(this._terminal, options ?? {});\n }\n\n public dispose(): void { }\n}\n\nexport class HTMLSerializeHandler extends BaseSerializeHandler {\n private _currentRow: string = '';\n\n private _htmlContent = '';\n\n private _ansiColors: Readonly;\n\n constructor(\n buffer: IBuffer,\n private readonly _terminal: Terminal,\n private readonly _options: Partial\n ) {\n super(buffer, _terminal.cols);\n\n // For xterm headless: fallback to ansi colors\n if ((_terminal as any)._core._themeService) {\n this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;\n }\n else {\n this._ansiColors = DEFAULT_ANSI_COLORS;\n }\n }\n\n protected _beforeSerialize(rows: number, start: number, end: number): void {\n this._htmlContent += '
';\n\n    let foreground = '#000000';\n    let background = '#ffffff';\n    if (this._options.includeGlobalBackground ?? false) {\n      foreground = this._terminal.options.theme?.foreground ?? '#ffffff';\n      background = this._terminal.options.theme?.background ?? '#000000';\n    }\n\n    const globalStyleDefinitions = [];\n    globalStyleDefinitions.push('color: ' + foreground + ';');\n    globalStyleDefinitions.push('background-color: ' + background + ';');\n    globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');\n    globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');\n    this._htmlContent += '
';\n }\n\n protected _afterSerialize(): void {\n this._htmlContent += '
';\n this._htmlContent += '
';\n }\n\n protected _rowEnd(row: number, isLastRow: boolean): void {\n this._htmlContent += '
' + this._currentRow + '
';\n this._currentRow = '';\n }\n\n private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {\n const color = isFg ? cell.getFgColor() : cell.getBgColor();\n if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {\n return this._ansiColors[color].css;\n }\n return undefined;\n }\n\n private _getUnderlineColor(cell: IBufferCell): string | undefined {\n if (cell.isUnderlineColorDefault()) {\n return undefined;\n }\n const color = cell.getUnderlineColor();\n if (cell.isUnderlineColorRGB()) {\n const rgb = [\n (color >> 16) & 255,\n (color >> 8) & 255,\n (color ) & 255\n ];\n return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');\n }\n // Palette color\n return this._ansiColors[color].css;\n }\n\n private _getUnderlineStyle(cell: IBufferCell): string {\n switch (cell.getUnderlineStyle()) {\n case UnderlineStyle.SINGLE:\n return 'underline';\n case UnderlineStyle.DOUBLE:\n return 'underline double';\n case UnderlineStyle.CURLY:\n return 'underline wavy';\n case UnderlineStyle.DOTTED:\n return 'underline dotted';\n case UnderlineStyle.DASHED:\n return 'underline dashed';\n default:\n return 'underline';\n }\n }\n\n private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {\n const content: string[] = [];\n\n if (attributesEquals(cell, oldCell)) {\n return undefined;\n }\n\n const fgChanged = !equalFg(cell, oldCell);\n const bgChanged = !equalBg(cell, oldCell);\n const flagsChanged = !equalFlags(cell, oldCell);\n\n if (fgChanged || bgChanged || flagsChanged) {\n const fgHexColor = this._getHexColor(cell, true);\n if (fgHexColor) {\n content.push('color: ' + fgHexColor + ';');\n }\n\n const bgHexColor = this._getHexColor(cell, false);\n if (bgHexColor) {\n content.push('background-color: ' + bgHexColor + ';');\n }\n\n if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); }\n if (cell.isBold()) { content.push('font-weight: bold;'); }\n\n // Handle text-decoration (underline, overline, strikethrough, blink)\n const decorations: string[] = [];\n if (cell.isUnderline()) {\n decorations.push(this._getUnderlineStyle(cell));\n }\n if (cell.isOverline()) {\n decorations.push('overline');\n }\n if (cell.isStrikethrough()) {\n decorations.push('line-through');\n }\n if (cell.isBlink()) {\n decorations.push('blink');\n }\n if (decorations.length > 0) {\n content.push('text-decoration: ' + decorations.join(' ') + ';');\n }\n\n // Handle underline color\n if (cell.isUnderline()) {\n const underlineColor = this._getUnderlineColor(cell);\n if (underlineColor) {\n content.push('text-decoration-color: ' + underlineColor + ';');\n }\n }\n\n if (cell.isInvisible()) { content.push('visibility: hidden;'); }\n if (cell.isItalic()) { content.push('font-style: italic;'); }\n if (cell.isDim()) { content.push('opacity: 0.5;'); }\n\n return content;\n }\n\n return undefined;\n }\n\n protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {\n // a width 0 cell don't need to be count because it is just a placeholder after a CJK character;\n const isPlaceHolderCell = cell.getWidth() === 0;\n if (isPlaceHolderCell) {\n return;\n }\n\n // this cell don't have content\n const isEmptyCell = cell.getChars() === '';\n\n const styleDefinitions = this._diffStyle(cell, oldCell);\n\n // handles style change\n if (styleDefinitions) {\n this._currentRow += styleDefinitions.length === 0 ?\n '
' :\n '';\n }\n\n // handles actual content\n if (isEmptyCell) {\n this._currentRow += ' ';\n } else {\n this._currentRow += escapeHTMLChar(cell.getChars());\n }\n }\n\n protected _serializeString(): string {\n return this._htmlContent;\n }\n}\n"], ++ "mappings": ";;;;;;;;;;;;;;;;AAOA,IAAIA,EAAK,EACLC,EAAK,EACLC,EAAK,EACLC,EAAK,EAUF,IAAUC,MAAV,CACE,SAASC,EAAMC,EAAWC,EAAWC,EAAWC,EAAoB,CACzE,OAAIA,IAAM,OACD,IAAIC,EAAYJ,CAAC,CAAC,GAAGI,EAAYH,CAAC,CAAC,GAAGG,EAAYF,CAAC,CAAC,GAAGE,EAAYD,CAAC,CAAC,GAEvE,IAAIC,EAAYJ,CAAC,CAAC,GAAGI,EAAYH,CAAC,CAAC,GAAGG,EAAYF,CAAC,CAAC,EAC7D,CALOJ,EAAS,MAAAC,EAOT,SAASM,EAAOL,EAAWC,EAAWC,EAAWC,EAAY,IAAc,CAIhF,OAAQH,GAAK,GAAKC,GAAK,GAAKC,GAAK,EAAIC,KAAO,CAC9C,CALOL,EAAS,OAAAO,EAOT,SAASC,EAAQN,EAAWC,EAAWC,EAAWC,EAAoB,CAC3E,MAAO,CACL,IAAKL,EAAS,MAAME,EAAGC,EAAGC,EAAGC,CAAC,EAC9B,KAAML,EAAS,OAAOE,EAAGC,EAAGC,EAAGC,CAAC,CAClC,CACF,CALOL,EAAS,QAAAQ,IAfDR,IAAA,IA0BV,IAAUS,MAAV,CACE,SAASC,EAAMC,EAAYC,EAAoB,CAEpD,GADAC,GAAMD,EAAG,KAAO,KAAQ,IACpBC,IAAO,EACT,MAAO,CACL,IAAKD,EAAG,IACR,KAAMA,EAAG,IACX,EAEF,IAAME,EAAOF,EAAG,MAAQ,GAAM,IACxBG,EAAOH,EAAG,MAAQ,GAAM,IACxBI,EAAOJ,EAAG,MAAQ,EAAK,IACvBK,EAAON,EAAG,MAAQ,GAAM,IACxBO,EAAOP,EAAG,MAAQ,GAAM,IACxBQ,EAAOR,EAAG,MAAQ,EAAK,IAC7BS,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAOJ,CAAE,EACtCQ,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAOL,CAAE,EACtCS,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAON,CAAE,EACtC,IAAMU,EAAMvB,EAAS,MAAMoB,EAAIC,EAAIC,CAAE,EAC/BE,EAAOxB,EAAS,OAAOoB,EAAIC,EAAIC,CAAE,EACvC,MAAO,CAAE,IAAAC,EAAK,KAAAC,CAAK,CACrB,CApBOf,EAAS,MAAAC,EAsBT,SAASe,EAAShB,EAAwB,CAC/C,OAAQA,EAAM,KAAO,OAAU,GACjC,CAFOA,EAAS,SAAAgB,EAIT,SAASC,EAAoBf,EAAYC,EAAYe,EAAmC,CAC7F,IAAMC,EAASJ,EAAK,oBAAoBb,EAAG,KAAMC,EAAG,KAAMe,CAAK,EAC/D,GAAKC,EAGL,OAAO5B,EAAS,QACb4B,GAAU,GAAK,IACfA,GAAU,GAAK,IACfA,GAAU,EAAK,GAClB,CACF,CAVOnB,EAAS,oBAAAiB,EAYT,SAASG,EAAOpB,EAAuB,CAC5C,IAAMqB,GAAarB,EAAM,KAAO,OAAU,EAC1C,OAACW,EAAIC,EAAIC,CAAE,EAAIE,EAAK,WAAWM,CAAS,EACjC,CACL,IAAK9B,EAAS,MAAMoB,EAAIC,EAAIC,CAAE,EAC9B,KAAMQ,CACR,CACF,CAPOrB,EAAS,OAAAoB,EAST,SAASE,EAAQtB,EAAesB,EAAyB,CAC9D,OAAAlB,EAAK,KAAK,MAAMkB,EAAU,GAAI,EAC9B,CAACX,EAAIC,EAAIC,CAAE,EAAIE,EAAK,WAAWf,EAAM,IAAI,EAClC,CACL,IAAKT,EAAS,MAAMoB,EAAIC,EAAIC,EAAIT,CAAE,EAClC,KAAMb,EAAS,OAAOoB,EAAIC,EAAIC,EAAIT,CAAE,CACtC,CACF,CAPOJ,EAAS,QAAAsB,EAST,SAASC,EAAgBvB,EAAewB,EAAwB,CACrE,OAAApB,EAAKJ,EAAM,KAAO,IACXsB,EAAQtB,EAAQI,EAAKoB,EAAU,GAAI,CAC5C,CAHOxB,EAAS,gBAAAuB,EAKT,SAASE,EAAWzB,EAA0B,CACnD,MAAO,CAAEA,EAAM,MAAQ,GAAM,IAAOA,EAAM,MAAQ,GAAM,IAAOA,EAAM,MAAQ,EAAK,GAAI,CACxF,CAFOA,EAAS,WAAAyB,IA9DDzB,IAAA,IAuEV,IAAUc,MAAV,CAEL,IAAIY,EACAC,EACJ,GAAI,CAEF,IAAMC,EAAS,SAAS,cAAc,QAAQ,EAC9CA,EAAO,MAAQ,EACfA,EAAO,OAAS,EAChB,IAAMC,EAAMD,EAAO,WAAW,KAAM,CAClC,mBAAoB,EACtB,CAAC,EACGC,IACFH,EAAOG,EACPH,EAAK,yBAA2B,OAChCC,EAAeD,EAAK,qBAAqB,EAAG,EAAG,EAAG,CAAC,EAEvD,MACM,CAEN,CASO,SAAS3B,EAAQe,EAAqB,CAE3C,GAAIA,EAAI,MAAM,gBAAgB,EAC5B,OAAQA,EAAI,OAAQ,CAClB,IAAK,GACH,OAAAH,EAAK,SAASG,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EAC3CF,EAAK,SAASE,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EAC3CD,EAAK,SAASC,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EACpCvB,EAAS,QAAQoB,EAAIC,EAAIC,CAAE,EAEpC,IAAK,GACH,OAAAF,EAAK,SAASG,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EAC3CF,EAAK,SAASE,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EAC3CD,EAAK,SAASC,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EAC3CV,EAAK,SAASU,EAAI,MAAM,EAAG,CAAC,EAAE,OAAO,CAAC,EAAG,EAAE,EACpCvB,EAAS,QAAQoB,EAAIC,EAAIC,EAAIT,CAAE,EAExC,IAAK,GACH,MAAO,CACL,IAAAU,EACA,MAAO,SAASA,EAAI,MAAM,CAAC,EAAG,EAAE,GAAK,EAAI,OAAU,CACrD,EACF,IAAK,GACH,MAAO,CACL,IAAAA,EACA,KAAM,SAASA,EAAI,MAAM,CAAC,EAAG,EAAE,IAAM,CACvC,CACJ,CAIF,IAAMgB,EAAYhB,EAAI,MAAM,oFAAoF,EAChH,GAAIgB,EACF,OAAAnB,EAAK,SAASmB,EAAU,CAAC,EAAG,EAAE,EAC9BlB,EAAK,SAASkB,EAAU,CAAC,EAAG,EAAE,EAC9BjB,EAAK,SAASiB,EAAU,CAAC,EAAG,EAAE,EAC9B1B,EAAK,KAAK,OAAO0B,EAAU,CAAC,IAAM,OAAY,EAAI,WAAWA,EAAU,CAAC,CAAC,GAAK,GAAI,EAC3EvC,EAAS,QAAQoB,EAAIC,EAAIC,EAAIT,CAAE,EAIxC,GAAIU,IAAQ,cACV,MAAO,CACL,IAAK,cACL,KAAM,CACR,EAIF,GAAI,CAACY,GAAQ,CAACC,EACZ,MAAM,IAAI,MAAM,qCAAqC,EAOvD,GAFAD,EAAK,UAAYC,EACjBD,EAAK,UAAYZ,EACb,OAAOY,EAAK,WAAc,SAC5B,MAAM,IAAI,MAAM,qCAAqC,EAOvD,GAJAA,EAAK,SAAS,EAAG,EAAG,EAAG,CAAC,EACxB,CAACf,EAAIC,EAAIC,EAAIT,CAAE,EAAIsB,EAAK,aAAa,EAAG,EAAG,EAAG,CAAC,EAAE,KAG7CtB,IAAO,IACT,MAAM,IAAI,MAAM,qCAAqC,EAMvD,MAAO,CACL,KAAMb,EAAS,OAAOoB,EAAIC,EAAIC,EAAIT,CAAE,EACpC,IAAAU,CACF,CACF,CA5EOA,EAAS,QAAAf,IA7BDe,IAAA,IA+GV,IAAUiB,MAAV,CAOE,SAASC,EAAkBD,EAAqB,CACrD,OAAOE,EACJF,GAAO,GAAM,IACbA,GAAO,EAAM,IACbA,EAAa,GAAI,CACtB,CALOA,EAAS,kBAAAC,EAeT,SAASC,EAAmB,EAAWvC,EAAWC,EAAmB,CAC1E,IAAMuC,EAAK,EAAI,IACTC,EAAKzC,EAAI,IACT0C,EAAKzC,EAAI,IACT0C,EAAKH,GAAM,OAAUA,EAAK,MAAQ,KAAK,KAAKA,EAAK,MAAS,MAAO,GAAG,EACpEI,EAAKH,GAAM,OAAUA,EAAK,MAAQ,KAAK,KAAKA,EAAK,MAAS,MAAO,GAAG,EACpEI,EAAKH,GAAM,OAAUA,EAAK,MAAQ,KAAK,KAAKA,EAAK,MAAS,MAAO,GAAG,EAC1E,OAAOC,EAAK,MAASC,EAAK,MAASC,EAAK,KAC1C,CAROR,EAAS,mBAAAE,IAtBDF,IAAA,IAoCV,IAAUhB,MAAV,CACE,SAASd,EAAMC,EAAYC,EAAoB,CAEpD,GADAC,GAAMD,EAAK,KAAQ,IACfC,IAAO,EACT,OAAOD,EAET,IAAME,EAAOF,GAAM,GAAM,IACnBG,EAAOH,GAAM,GAAM,IACnBI,EAAOJ,GAAM,EAAK,IAClBK,EAAON,GAAM,GAAM,IACnBO,EAAOP,GAAM,GAAM,IACnBQ,EAAOR,GAAM,EAAK,IACxB,OAAAS,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAOJ,CAAE,EACtCQ,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAOL,CAAE,EACtCS,EAAKH,EAAM,KAAK,OAAOH,EAAMG,GAAON,CAAE,EAC/Bb,EAAS,OAAOoB,EAAIC,EAAIC,CAAE,CACnC,CAfOE,EAAS,MAAAd,EA8BT,SAASgB,EAAoBuB,EAAgBC,EAAgBvB,EAAmC,CACrG,IAAMwB,EAAMX,EAAI,kBAAkBS,GAAU,CAAC,EACvCG,EAAMZ,EAAI,kBAAkBU,GAAU,CAAC,EAE7C,GADWG,EAAcF,EAAKC,CAAG,EACxBzB,EAAO,CACd,GAAIyB,EAAMD,EAAK,CACb,IAAMG,EAAUC,EAAgBN,EAAQC,EAAQvB,CAAK,EAC/C6B,EAAeH,EAAcF,EAAKX,EAAI,kBAAkBc,GAAW,CAAC,CAAC,EAC3E,GAAIE,EAAe7B,EAAO,CACxB,IAAM8B,EAAUC,EAAkBT,EAAQC,EAAQvB,CAAK,EACjDgC,EAAeN,EAAcF,EAAKX,EAAI,kBAAkBiB,GAAW,CAAC,CAAC,EAC3E,OAAOD,EAAeG,EAAeL,EAAUG,CACjD,CACA,OAAOH,CACT,CACA,IAAMA,EAAUI,EAAkBT,EAAQC,EAAQvB,CAAK,EACjD6B,EAAeH,EAAcF,EAAKX,EAAI,kBAAkBc,GAAW,CAAC,CAAC,EAC3E,GAAIE,EAAe7B,EAAO,CACxB,IAAM8B,EAAUF,EAAgBN,EAAQC,EAAQvB,CAAK,EAC/CgC,EAAeN,EAAcF,EAAKX,EAAI,kBAAkBiB,GAAW,CAAC,CAAC,EAC3E,OAAOD,EAAeG,EAAeL,EAAUG,CACjD,CACA,OAAOH,CACT,CAEF,CAzBO9B,EAAS,oBAAAE,EA2BT,SAAS6B,EAAgBN,EAAgBC,EAAgBvB,EAAuB,CAGrF,IAAMV,EAAOgC,GAAU,GAAM,IACvB/B,EAAO+B,GAAU,GAAM,IACvB9B,EAAO8B,GAAW,EAAK,IACzBnC,EAAOoC,GAAU,GAAM,IACvBnC,EAAOmC,GAAU,GAAM,IACvBlC,EAAOkC,GAAW,EAAK,IACvBU,EAAKP,EAAcb,EAAI,mBAAmB1B,EAAKC,EAAKC,CAAG,EAAGwB,EAAI,mBAAmBvB,EAAKC,EAAKC,CAAG,CAAC,EACnG,KAAOyC,EAAKjC,IAAUb,EAAM,GAAKC,EAAM,GAAKC,EAAM,IAEhDF,GAAO,KAAK,IAAI,EAAG,KAAK,KAAKA,EAAM,EAAG,CAAC,EACvCC,GAAO,KAAK,IAAI,EAAG,KAAK,KAAKA,EAAM,EAAG,CAAC,EACvCC,GAAO,KAAK,IAAI,EAAG,KAAK,KAAKA,EAAM,EAAG,CAAC,EACvC4C,EAAKP,EAAcb,EAAI,mBAAmB1B,EAAKC,EAAKC,CAAG,EAAGwB,EAAI,mBAAmBvB,EAAKC,EAAKC,CAAG,CAAC,EAEjG,OAAQL,GAAO,GAAKC,GAAO,GAAKC,GAAO,EAAI,OAAU,CACvD,CAlBOQ,EAAS,gBAAA+B,EAoBT,SAASG,EAAkBT,EAAgBC,EAAgBvB,EAAuB,CAGvF,IAAMV,EAAOgC,GAAU,GAAM,IACvB/B,EAAO+B,GAAU,GAAM,IACvB9B,EAAO8B,GAAW,EAAK,IACzBnC,EAAOoC,GAAU,GAAM,IACvBnC,EAAOmC,GAAU,GAAM,IACvBlC,EAAOkC,GAAW,EAAK,IACvBU,EAAKP,EAAcb,EAAI,mBAAmB1B,EAAKC,EAAKC,CAAG,EAAGwB,EAAI,mBAAmBvB,EAAKC,EAAKC,CAAG,CAAC,EACnG,KAAOyC,EAAKjC,IAAUb,EAAM,KAAQC,EAAM,KAAQC,EAAM,MAEtDF,EAAM,KAAK,IAAI,IAAMA,EAAM,KAAK,MAAM,IAAMA,GAAO,EAAG,CAAC,EACvDC,EAAM,KAAK,IAAI,IAAMA,EAAM,KAAK,MAAM,IAAMA,GAAO,EAAG,CAAC,EACvDC,EAAM,KAAK,IAAI,IAAMA,EAAM,KAAK,MAAM,IAAMA,GAAO,EAAG,CAAC,EACvD4C,EAAKP,EAAcb,EAAI,mBAAmB1B,EAAKC,EAAKC,CAAG,EAAGwB,EAAI,mBAAmBvB,EAAKC,EAAKC,CAAG,CAAC,EAEjG,OAAQL,GAAO,GAAKC,GAAO,GAAKC,GAAO,EAAI,OAAU,CACvD,CAlBOQ,EAAS,kBAAAkC,EAoBT,SAASG,EAAWC,EAAiD,CAC1E,MAAO,CAAEA,GAAS,GAAM,IAAOA,GAAS,GAAM,IAAOA,GAAS,EAAK,IAAMA,EAAQ,GAAI,CACvF,CAFOtC,EAAS,WAAAqC,IAlGDrC,IAAA,IAuGV,SAASlB,EAAYyD,EAAmB,CAC7C,IAAMC,EAAID,EAAE,SAAS,EAAE,EACvB,OAAOC,EAAE,OAAS,EAAI,IAAMA,EAAIA,CAClC,CAQO,SAASX,EAAcY,EAAYC,EAAoB,CAC5D,OAAID,EAAKC,GACCA,EAAK,MAASD,EAAK,MAErBA,EAAK,MAASC,EAAK,IAC7B,CCzMO,IAAMC,EAAsB,OAAO,QAAQ,IAAM,CACtD,IAAMC,EAAS,CAEbC,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EAErBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,EACrBA,EAAI,QAAQ,SAAS,CACvB,EAIMC,EAAI,CAAC,EAAM,GAAM,IAAM,IAAM,IAAM,GAAI,EAC7C,QAASC,EAAI,EAAGA,EAAI,IAAKA,IAAK,CAC5B,IAAM,EAAID,EAAGC,EAAI,GAAM,EAAI,CAAC,EACtBC,EAAIF,EAAGC,EAAI,EAAK,EAAI,CAAC,EACrBE,EAAIH,EAAEC,EAAI,CAAC,EACjBH,EAAO,KAAK,CACV,IAAKM,EAAS,MAAM,EAAGF,EAAGC,CAAC,EAC3B,KAAMC,EAAS,OAAO,EAAGF,EAAGC,CAAC,CAC/B,CAAC,CACH,CAGA,QAASF,EAAI,EAAGA,EAAI,GAAIA,IAAK,CAC3B,IAAMI,EAAI,EAAIJ,EAAI,GAClBH,EAAO,KAAK,CACV,IAAKM,EAAS,MAAMC,EAAGA,EAAGA,CAAC,EAC3B,KAAMD,EAAS,OAAOC,EAAGA,EAAGA,CAAC,CAC/B,CAAC,CACH,CAEA,OAAOP,CACT,GAAG,CAAC,EC5MJ,SAASQ,EAAaC,EAA4C,CAChE,OAAQA,EAAqC,UAAU,OAAS,CAClE,CAEA,SAASC,EAAuBC,EAAoBC,EAAwB,CAC1E,GAAI,CAACA,EACH,MAAO,GAET,IAAMC,EAAQF,EAAkC,OAAO,iBAAiB,YAAYC,CAAM,EAC1F,OAAKC,EAIE,UADQA,EAAK,KAAO,OAAY,GAAK,MAAMA,EAAK,EAAE,EAChC,IAAIA,EAAK,GAAG,SAH5B,EAIX,CAEA,SAASC,EAAyBH,EAA4B,CAC5D,IAAMI,EAASJ,EAAkC,OAAO,eAAe,aACvE,OAAOI,EAAQL,EAAuBC,EAAUH,EAAaO,CAAK,CAAC,EAAI,EACzE,CAKA,SAASC,EAAiBC,EAAmBC,EAAsB,CACjE,OAAO,KAAK,IAAID,EAAK,OAAQC,CAAI,CACnC,CAKA,SAASC,EAAkBF,EAAmBG,EAAaF,EAAcT,EAA4B,CACnG,OAAOW,IAAQF,EAAO,GAAKD,EAAK,OAASC,GAAQT,EAAK,SAAS,EAAI,CACrE,CAIA,IAAMY,EAAqB,GAAK,GAEhC,SAASC,EAAqBb,EAAgC,CAC5D,OAACA,EAA2C,QAAUY,EAC/CZ,CACT,CAEA,SAASc,EAAUC,EAAeC,EAAaC,EAAsB,CACnE,OAAO,KAAK,IAAID,EAAK,KAAK,IAAID,EAAOE,CAAI,CAAC,CAC5C,CAEA,SAASC,EAAeC,EAAmB,CACzC,OAAQA,EAAG,CACT,IAAK,IAAK,MAAO,QACjB,IAAK,IAAK,MAAO,MACnB,CACA,OAAOA,CACT,CAGA,IAAeC,EAAf,KAAoC,CAClC,YACqBC,EACFC,EACjB,CAFmB,aAAAD,EACF,WAAAC,CAEnB,CAEO,UAAUC,EAAqBC,EAA8C,CAElF,IAAMC,EAAQ,KAAK,QAAQ,YAAY,EACjCC,EAAQ,KAAK,QAAQ,YAAY,EACnCC,EAAUF,EAERG,EAAWL,EAAM,MAAM,EACvBM,EAASN,EAAM,IAAI,EACnBO,EAAcP,EAAM,MAAM,EAC1BQ,EAAYR,EAAM,IAAI,EAE5B,KAAK,iBAAiBM,EAASD,EAAUA,EAAUC,CAAM,EAEzD,QAASG,EAAMJ,EAAUI,GAAOH,EAAQG,IAAO,CAC7C,IAAMxB,EAAO,KAAK,QAAQ,QAAQwB,CAAG,EACrC,GAAIxB,EAAM,CACR,IAAMyB,EAAkBD,IAAQT,EAAM,MAAM,EAAIO,EAAc,EACxDI,EAAgBF,IAAQT,EAAM,IAAI,EAAIQ,EAAWxB,EAAiBC,EAAM,KAAK,KAAK,EACxF,QAASG,EAAMsB,EAAiBtB,EAAMuB,EAAevB,IAAO,CAC1D,IAAIQ,EAAIX,EAAK,QAAQG,EAAKgB,IAAYF,EAAQC,EAAQD,CAAK,EAC3D,GAAI,CAACN,EAAG,CACN,QAAQ,KAAK,yBAAyBa,CAAG,SAASrB,CAAG,EAAE,EACvD,QACF,CACID,EAAkBF,EAAMG,EAAK,KAAK,MAAOQ,CAAC,IAC5CA,EAAIN,EAAqBM,CAAC,GAE5B,KAAK,UAAUA,EAAGQ,EAASK,EAAKrB,CAAG,EACnCgB,EAAUR,CACZ,CACF,CACA,KAAK,QAAQa,EAAKA,IAAQH,CAAM,CAClC,CAEA,YAAK,gBAAgB,EAEd,KAAK,iBAAiBL,CAA0B,CACzD,CAEU,UAAUxB,EAAmB2B,EAAsBK,EAAarB,EAAmB,CAAE,CACrF,QAAQqB,EAAaG,EAA0B,CAAE,CACjD,iBAAiBC,EAAcR,EAAkBC,EAAsB,CAAE,CACzE,iBAAwB,CAAE,CAC1B,iBAAiBL,EAA8C,CAAE,MAAO,EAAI,CACxF,EAEA,SAASa,EAAQZ,EAAqCC,EAA6B,CACjF,OAAOD,EAAM,eAAe,IAAMC,EAAM,eAAe,GAClDD,EAAM,WAAW,IAAMC,EAAM,WAAW,CAC/C,CAEA,SAASY,EAAQb,EAAqCC,EAA6B,CACjF,OAAOD,EAAM,eAAe,IAAMC,EAAM,eAAe,GAClDD,EAAM,WAAW,IAAMC,EAAM,WAAW,CAC/C,CAEA,SAASa,EAAed,EAAqCC,EAA6B,CAGxF,GAAI,CAACD,EAAM,YAAY,GAAK,CAACC,EAAM,YAAY,EAC7C,MAAO,GAET,GAAID,EAAM,kBAAkB,IAAMC,EAAM,kBAAkB,EACxD,MAAO,GAET,IAAMc,EAAef,EAAM,wBAAwB,EAC7CgB,EAAef,EAAM,wBAAwB,EACnD,OAAIc,GAAgBC,EACX,GAELD,IAAiBC,EACZ,GAEFhB,EAAM,kBAAkB,IAAMC,EAAM,kBAAkB,GACxDD,EAAM,sBAAsB,IAAMC,EAAM,sBAAsB,CACrE,CAEA,SAASgB,EAAWjB,EAAqCC,EAA6B,CACpF,OAAOD,EAAM,UAAU,IAAMC,EAAM,UAAU,GACxCD,EAAM,OAAO,IAAMC,EAAM,OAAO,GAChCD,EAAM,YAAY,IAAMC,EAAM,YAAY,GAC1Ca,EAAed,EAAOC,CAAK,GAC3BD,EAAM,WAAW,IAAMC,EAAM,WAAW,GACxCD,EAAM,QAAQ,IAAMC,EAAM,QAAQ,GAClCD,EAAM,YAAY,IAAMC,EAAM,YAAY,GAC1CD,EAAM,SAAS,IAAMC,EAAM,SAAS,GACpCD,EAAM,MAAM,IAAMC,EAAM,MAAM,GAC9BD,EAAM,gBAAgB,IAAMC,EAAM,gBAAgB,CACzD,CAEA,SAASiB,EAAiBlB,EAAqCC,EAA6B,CAC1F,IAAMkB,EAAoBnB,EAC1B,OAAI,OAAOmB,EAAkB,kBAAqB,WACzCA,EAAkB,iBAAiBlB,CAAK,EAE1CW,EAAQZ,EAAOC,CAAK,GACtBY,EAAQb,EAAOC,CAAK,GACpBgB,EAAWjB,EAAOC,CAAK,CAC9B,CAEA,IAAMmB,EAAN,cAAqCzB,CAAqB,CA+BxD,YACE0B,EACiBC,EACjB,CACA,MAAMD,EAAQC,EAAU,IAAI,EAFX,eAAAA,EAhCnB,KAAQ,UAAoB,EAC5B,KAAQ,SAAqB,IAAI,MACjC,KAAQ,kBAA8B,IAAI,MAC1C,KAAQ,YAAsB,GAC9B,KAAQ,eAAyB,EAKjC,KAAQ,aAA4B,KAAK,QAAQ,YAAY,EAK7D,KAAQ,gBAA0B,EAClC,KAAQ,gBAA0B,EAGlC,KAAQ,gBAA+B,KAAK,QAAQ,YAAY,EAChE,KAAQ,aAA4B,KAAK,QAAQ,YAAY,EAE7D,KAAQ,UAAoB,EAC5B,KAAQ,eAAyB,EACjC,KAAQ,eAAyB,EACjC,KAAQ,sBAAgC,EACxC,KAAQ,sBAAgC,EACxC,KAAQ,iBAA2B,EACnC,KAAQ,yBAAmC,GAC3C,KAAQ,yBAAmC,EAgB3C,KAAQ,iBAAgC,KAAK,QAAQ,YAAY,EACjE,KAAQ,uBAAsC,KAAK,QAAQ,YAAY,EACvE,KAAQ,kBAAiC,KAAK,QAAQ,YAAY,CAXlE,CAEU,iBAAiBX,EAAcY,EAAeC,EAAmB,CACzE,KAAK,SAAW,IAAI,MAAcb,CAAI,EACtC,KAAK,sBAAwBY,EAC7B,KAAK,eAAiBA,EACtB,KAAK,UAAYA,CACnB,CAKU,QAAQhB,EAAaG,EAA0B,CAGnD,KAAK,eAAiB,GAAK,CAACG,EAAQ,KAAK,aAAc,KAAK,eAAe,IAE7E,KAAK,aAAe,QAAU,KAAK,cAAc,IAEjD,KAAK,yBAA2B,KAAK,eACrC,KAAK,yBAA2B,KAAK,gBAGvC,IAAIY,EAAe,GAGnB,GAAI,CAACf,EAAW,CAEVH,EAAM,KAAK,WAAa,KAAK,UAAU,MACzC,KAAK,QAAQ,QAAQ,KAAK,eAAe,GAAG,QAAQ,KAAK,gBAAiB,KAAK,eAAe,EAIhG,IAAMmB,EAAc,KAAK,QAAQ,QAAQnB,CAAG,EAEtCoB,EAAW,KAAK,QAAQ,QAAQpB,EAAM,CAAC,EAE7C,GAAI,CAACoB,EAAS,UAEZF,EAAe;AAAA,EAEf,KAAK,eAAiBlB,EAAM,EAC5B,KAAK,eAAiB,MACjB,CACLkB,EAAe,GACf,IAAMG,EAAmB9C,EAAiB4C,EAAa,KAAK,UAAU,IAAI,EACtEG,EAAkBH,EAAY,QAAQE,EAAmB,EAAG,KAAK,gBAAgB,EACjF3C,EAAkByC,EAAaE,EAAmB,EAAG,KAAK,UAAU,KAAMC,CAAe,IAC3FA,EAAkBzC,EAAqByC,CAAe,GAExD,IAAMC,EAAwBJ,EAAY,QAAQE,EAAmB,EAAG,KAAK,sBAAsB,EAC7FG,EAAmBJ,EAAS,QAAQ,EAAG,KAAK,iBAAiB,EAC7DK,EAAgCD,EAAiB,SAAS,EAAI,EAIhEE,EAAU,GAgCd,GA5BEF,EAAiB,SAAS,IACzBC,EAAgC,KAAK,gBAAkB,EAAI,KAAK,gBAAkB,MAKhFH,EAAgB,SAAS,GAAKA,EAAgB,SAAS,IAAM,IAG9DhB,EAAQgB,EAAiBE,CAAgB,IAEzCE,EAAU,IAMVD,IACCF,EAAsB,SAAS,GAAKA,EAAsB,SAAS,IAAM,IAG1EjB,EAAQgB,EAAiBE,CAAgB,GACzClB,EAAQiB,EAAuBC,CAAgB,IAE/CE,EAAU,KAIV,CAACA,EAAS,CAOZ,GAJAR,EAAe,IAAI,OAAO,KAAK,eAAiB,CAAC,EAEjDA,GAAgB,iBAEZ,KAAK,eAAiB,EAAG,CAE3BA,GAAgB,SAChB,IAAMS,EAAmBN,EAAmB,KAAK,eAC7CM,EAAmB,IACrBT,GAAgB,QAAUS,CAAgB,KAE5CT,GAAgB,QAAU,KAAK,cAAc,IACzCS,EAAmB,IACrBT,GAAgB,QAAUS,CAAgB,KAE5CT,GAAgB,QAClB,CAIA,KAAK,sBAAwBlB,EAAM,EACnC,KAAK,sBAAwB,EAG7B,KAAK,eAAiBA,EAAM,EAC5B,KAAK,eAAiB,CACxB,CACF,CACF,CAEA,KAAK,SAAS,KAAK,SAAS,EAAI,KAAK,YACrC,KAAK,kBAAkB,KAAK,WAAW,EAAIkB,EAC3C,KAAK,YAAc,GACnB,KAAK,eAAiB,CACxB,CAEQ,WAAWlD,EAAoC2B,EAAgC,CACrF,IAAMiC,EAAmB,CAAC,EAC1B,GAAIjB,EAAiB3C,EAAM2B,CAAO,EAChC,OAAOiC,EAET,IAAMC,EAAY,CAACxB,EAAQrC,EAAM2B,CAAO,EAClCmC,EAAY,CAACxB,EAAQtC,EAAM2B,CAAO,EAClCoC,EAAe,CAACrB,EAAW1C,EAAM2B,CAAO,EAE9C,GAAIkC,GAAaC,GAAaC,EAC5B,GAAI/D,EAAK,mBAAmB,EACrB2B,EAAQ,mBAAmB,GAC9BiC,EAAO,KAAK,CAAC,MAEV,CACL,GAAIC,EAAW,CACb,IAAMG,EAAQhE,EAAK,WAAW,EAC1BA,EAAK,QAAQ,EAAK4D,EAAO,KAAK,GAAI,EAAII,IAAU,GAAM,IAAOA,IAAU,EAAK,IAAMA,EAAQ,GAAI,EACzFhE,EAAK,YAAY,EACpBgE,GAAS,GAAMJ,EAAO,KAAK,GAAI,EAAGI,CAAK,EACpCJ,EAAO,KAAKI,EAAQ,EAAI,IAAMA,EAAQ,GAAK,IAAMA,EAAQ,EAAE,EAE7DJ,EAAO,KAAK,EAAE,CACvB,CACA,GAAIE,EAAW,CACb,IAAME,EAAQhE,EAAK,WAAW,EAC1BA,EAAK,QAAQ,EAAK4D,EAAO,KAAK,GAAI,EAAII,IAAU,GAAM,IAAOA,IAAU,EAAK,IAAMA,EAAQ,GAAI,EACzFhE,EAAK,YAAY,EACpBgE,GAAS,GAAMJ,EAAO,KAAK,GAAI,EAAGI,CAAK,EACpCJ,EAAO,KAAKI,EAAQ,EAAI,KAAOA,EAAQ,GAAK,IAAMA,EAAQ,EAAE,EAE9DJ,EAAO,KAAK,EAAE,CACvB,CACA,GAAIG,EAAc,CACZ/D,EAAK,UAAU,IAAM2B,EAAQ,UAAU,GAAKiC,EAAO,KAAK5D,EAAK,UAAU,EAAI,EAAI,EAAE,EAOrF,IAAMiE,EAAcjE,EAAK,OAAO,IAAM2B,EAAQ,OAAO,EAC/CuC,EAAalE,EAAK,MAAM,IAAM2B,EAAQ,MAAM,EAClD,GAAIsC,GAAeC,EAAY,CAC7B,IAAMC,EAAmBF,GAAe,CAACjE,EAAK,OAAO,GAAOkE,GAAc,CAAClE,EAAK,MAAM,EAClFmE,GAAmBP,EAAO,KAAK,EAAE,EACjC5D,EAAK,OAAO,IAAMiE,GAAeE,IAAoBP,EAAO,KAAK,CAAC,EAClE5D,EAAK,MAAM,IAAMkE,GAAcC,IAAoBP,EAAO,KAAK,CAAC,CACtE,CACA,GAAKrB,EAAevC,EAAM2B,CAAO,EAmBtB3B,EAAK,YAAY,IAAM2B,EAAQ,YAAY,GACpDiC,EAAO,KAAK5D,EAAK,YAAY,EAAI,EAAI,EAAE,MApBL,CAClC,IAAMoE,EAAQpE,EAAK,kBAAkB,EACrC,GAAIoE,IAAU,EACZR,EAAO,KAAK,EAAE,UACLQ,IAAU,GAAyBpE,EAAK,wBAAwB,EACzE4D,EAAO,KAAK,CAAC,UAGbA,EAAO,KAAK,KAAOQ,CAA0B,EAEzC,CAACpE,EAAK,wBAAwB,EAAG,CACnC,IAAMgE,EAAQhE,EAAK,kBAAkB,EACjCA,EAAK,oBAAoB,EAC3B4D,EAAO,KAAK,UAAaI,IAAU,GAAM,KAAQ,KAAQA,IAAU,EAAK,KAAQ,KAAOA,EAAQ,IAA0B,EAEzHJ,EAAO,KAAK,QAAUI,CAA0B,CAEpD,CAEJ,CAGIhE,EAAK,WAAW,IAAM2B,EAAQ,WAAW,GAAKiC,EAAO,KAAK5D,EAAK,WAAW,EAAI,GAAK,EAAE,EACrFA,EAAK,QAAQ,IAAM2B,EAAQ,QAAQ,GAAKiC,EAAO,KAAK5D,EAAK,QAAQ,EAAI,EAAI,EAAE,EAC3EA,EAAK,YAAY,IAAM2B,EAAQ,YAAY,GAAKiC,EAAO,KAAK5D,EAAK,YAAY,EAAI,EAAI,EAAE,EACvFA,EAAK,SAAS,IAAM2B,EAAQ,SAAS,GAAKiC,EAAO,KAAK5D,EAAK,SAAS,EAAI,EAAI,EAAE,EAE9EA,EAAK,gBAAgB,IAAM2B,EAAQ,gBAAgB,GAAKiC,EAAO,KAAK5D,EAAK,gBAAgB,EAAI,EAAI,EAAE,CACzG,CACF,CAGF,OAAO4D,CACT,CAEQ,YAAYzD,EAAwB,CAC1C,GAAIA,IAAW,KAAK,iBAClB,MAAO,GAET,IAAIkE,EAAW,KAAK,iBAAmB,iBAAuB,GAC9D,KAAK,iBAAmB,EACxB,IAAMC,EAAerE,EAAuB,KAAK,UAAWE,CAAM,EAClE,OAAImE,IACF,KAAK,iBAAmBnE,EACxBkE,GAAYC,GAEPD,CACT,CAEU,UAAUrE,EAAmB2B,EAAsBK,EAAarB,EAAmB,CAI3F,GAF0BX,EAAK,SAAS,IAAM,EAG5C,OAIF,IAAMuE,EAAcvE,EAAK,SAAS,IAAM,GAClCoD,EAAWmB,GAAevE,EAAK,UAAU,EAAI,KAAK,QAAQ,QAAQgC,EAAM,CAAC,EAAI,OAC7EwC,EAAmBpB,GAAU,QAAQ,EAAG,KAAK,iBAAiB,EAE9DqB,EAAoB9D,IAAQ,KAAK,UAAU,KAAO,GACtDyC,GAAU,YACToB,GAAkB,SAAS,GAAK,GAAK,GACtC,CAAC,CAACA,GAAoB7B,EAAiB3C,EAAMwE,CAAgB,EAEzDE,EAAuBH,GAAe,CAAC,CAACvE,EAAK,UAAU,GAAK,CAACyE,EAE7Db,EAAS,KAAK,WAAW5D,EAAM,KAAK,YAAY,EAStD,GAPqBuE,EACjBG,EAAuBd,EAAO,OAAS,EAAI,CAACtB,EAAQ,KAAK,aAActC,CAAI,EAC3E4D,EAAO,OAAS,EAKF,CAEZ,KAAK,eAAiB,IAEnBtB,EAAQ,KAAK,aAAc,KAAK,eAAe,IAClD,KAAK,aAAe,QAAU,KAAK,cAAc,KAGnD,KAAK,aAAe,QAAU,KAAK,cAAc,IACjD,KAAK,eAAiB,GAGxB,KAAK,sBAAwB,KAAK,eAAiBN,EACnD,KAAK,sBAAwB,KAAK,eAAiBrB,EAEnD,KAAK,aAAe,QAAUiD,EAAO,KAAK,GAAG,CAAC,IAG9C,IAAMpD,EAAO,KAAK,QAAQ,QAAQwB,CAAG,EACjCxB,IAAS,SACXA,EAAK,QAAQG,EAAK,KAAK,YAAY,EACnC,KAAK,gBAAkBqB,EACvB,KAAK,gBAAkBrB,EAE3B,CAKA,GAAI4D,GAAe,CAACG,EAClB,KAAK,gBAAkB1E,EAAK,SAAS,MAChC,CAiBL,GAhBI,KAAK,eAAiB,IAIpBsC,EAAQ,KAAK,aAAc,KAAK,eAAe,EACjD,KAAK,aAAe,QAAU,KAAK,cAAc,KAEjD,KAAK,aAAe,QAAU,KAAK,cAAc,IACjD,KAAK,aAAe,QAAU,KAAK,cAAc,KAEnD,KAAK,eAAiB,GAIxB,KAAK,aAAe,KAAK,YAAYvC,EAAaC,CAAI,CAAC,EAEnD0E,EAAsB,CACxB,IAAMC,EAAgB,CAAC,CAAC3E,EAAK,YAAY,GAAK,CAAC,CAACA,EAAK,gBAAgB,GAAK,CAAC,CAACA,EAAK,WAAW,EAK5F,GAJI2E,IACF,KAAK,aAAe,kBAEtB,KAAK,aAAe,IAAI,OAAO3E,EAAK,SAAS,CAAC,EAC1C2E,EAAe,CACjB,IAAMC,EAAgB,KAAK,WAAW5E,EAAM,KAAK,YAAY,EAC7D,KAAK,aAAe,eAAmB4E,EAAc,KAAK,GAAG,CAAC,GAChE,CACF,MACE,KAAK,aAAe5E,EAAK,SAAS,EAIpC,KAAK,sBAAwB,KAAK,eAAiBgC,EACnD,KAAK,sBAAwB,KAAK,eAAiBrB,EAAMX,EAAK,SAAS,CACzE,CACF,CAEU,iBAAiBwB,EAA6C,CACtE,IAAIqD,EAAS,KAAK,SAAS,OAIvB,KAAK,QAAQ,OAAS,KAAK,WAAa,KAAK,UAAU,OAIvD,KAAK,yBAA2B,KAAK,uBACrC,KAAK,QAAQ,QAAU,KAAK,UAAU,OAEtC,KAAK,sBAAwB,KAAK,yBAClC,KAAK,sBAAwB,KAAK,0BAEpCA,EAAS,KAAK,sBAAwB,EAAI,KAAK,UAC/C,KAAK,eAAiB,KAAK,sBAC3B,KAAK,eAAiB,KAAK,uBAG7B,IAAIC,EAAU,GAEd,QAAS,EAAI,EAAG,EAAID,EAAQ,IAC1BC,GAAW,KAAK,SAAS,CAAC,EACtB,EAAI,EAAID,IACVC,GAAW,KAAK,kBAAkB,CAAC,GAQvC,GAHAA,GAAW,KAAK,YAAY,CAAC,EAGzB,CAACtD,EAA4B,CAC/B,IAAMuD,EAAgB,KAAK,QAAQ,MAAQ,KAAK,QAAQ,QAClDC,EAAgB,KAAK,QAAQ,QAE7BC,EAAeF,IAAkB,KAAK,gBAAkBC,IAAkB,KAAK,eAE/EE,EAAaC,GAAyB,CACtCA,EAAS,EACXL,GAAW,QAAUK,CAAM,IAClBA,EAAS,IAClBL,GAAW,QAAU,CAACK,CAAM,IAEhC,EASIF,KARcE,GAAyB,CACrCA,EAAS,EACXL,GAAW,QAAUK,CAAM,IAClBA,EAAS,IAClBL,GAAW,QAAU,CAACK,CAAM,IAEhC,GAGWJ,EAAgB,KAAK,cAAc,EAC5CG,EAAUF,EAAgB,KAAK,cAAc,EAEjD,CAKA,IAAMI,EAA+B,KAAK,UAAkB,MAAM,cAAc,aAC1ExB,EAAS,KAAK,WAAWwB,EAAa,KAAK,YAAY,EAC7D,OAAIxB,EAAO,OAAS,IAClBkB,GAAW,QAAUlB,EAAO,KAAK,GAAG,CAAC,KAGhCkB,CACT,CACF,EAEaO,EAAN,KAA8D,CAG5D,SAASnF,EAA0B,CACxC,KAAK,UAAYA,CACnB,CAEQ,6BAA6BA,EAAoB4C,EAAiBwC,EAA6B,CACrG,IAAMC,EAAUzC,EAAO,OACjB0C,EAAeF,IAAe,OAAaC,EAAUzE,EAAUwE,EAAapF,EAAS,KAAM,EAAGqF,CAAO,EAC3G,OAAO,KAAK,wBAAwBrF,EAAU4C,EAAQ,CACpD,MAAOyC,EAAUC,EACjB,IAAKD,EAAU,CACjB,EAAG,EAAK,CACV,CAEQ,wBAAwBrF,EAAoB4C,EAAiBvB,EAAwBC,EAA6C,CAExI,OADgB,IAAIqB,EAAuBC,EAAQ5C,CAAQ,EAC5C,UAAU,CACvB,MAAO,CAAE,EAAG,EAAe,EAAG,OAAOqB,EAAM,OAAU,SAAWA,EAAM,MAAQA,EAAM,MAAM,IAAK,EAC/F,IAAO,CAAE,EAAGrB,EAAS,KAAM,EAAG,OAAOqB,EAAM,KAAU,SAAWA,EAAM,IAAQA,EAAM,IAAI,IAAO,CACjG,EAAGC,CAA0B,CAC/B,CAEQ,uBAAuBtB,EAAoBuF,EAAiD,CAClG,IAAM3C,EAAS5C,EAAS,OAAO,OACzBwF,EAAU,IAAIC,EAAqB7C,EAAQ5C,EAAUuF,CAAO,EAC5DG,EAAgBH,EAAQ,eAAiB,GACzClE,EAAQkE,EAAQ,MACtB,GAAIlE,EACF,OAAOmE,EAAQ,UAAU,CACvB,MAAO,CAAE,EAAGnE,EAAM,SAAsB,GAAG,OAAOA,EAAM,WAAc,SAAWA,EAAM,UAA4B,EACnH,IAAO,CAAE,EAAGrB,EAAS,KAAM,GAAG,OAAOqB,EAAM,SAAc,SAAWA,EAAM,QAA4B,CACxG,CAAC,EAEH,GAAI,CAACqE,EAAe,CAClB,IAAML,EAAUzC,EAAO,OACjBwC,EAAaG,EAAQ,WACrBD,EAAeF,IAAe,OAAaC,EAAUzE,EAAUwE,EAAapF,EAAS,KAAM,EAAGqF,CAAO,EAC3G,OAAOG,EAAQ,UAAU,CACvB,MAAO,CAAE,EAAG,EAAe,EAAGH,EAAUC,CAAY,EACpD,IAAO,CAAE,EAAGtF,EAAS,KAAM,EAAGqF,EAAU,CAAY,CACtD,CAAC,CACH,CAEA,IAAMM,EAAY,KAAK,WAAW,qBAAqB,EACvD,OAAIA,IAAc,OACTH,EAAQ,UAAU,CACvB,MAAO,CAAE,EAAGG,EAAU,MAAM,EAAG,EAAGA,EAAU,MAAM,CAAE,EACpD,IAAO,CAAE,EAAGA,EAAU,IAAI,EAAK,EAAGA,EAAU,IAAI,CAAI,CACtD,CAAC,EAGI,EACT,CAMQ,uBAAuB3F,EAA4B,CAEzD,IAAM4C,EAAU5C,EAAiB,MAAM,OACjC4F,EAAoBhD,EAAO,UAC3BiD,EAAuBjD,EAAO,aAGpC,OAAIgD,IAAc,GAAKC,IAAiB7F,EAAS,KAAO,EAE/C,QAAQ4F,EAAY,CAAC,IAAIC,EAAe,CAAC,IAG3C,EACT,CAEQ,gBAAgB7F,EAA4B,CAClD,IAAI4E,EAAU,GACRkB,EAAQ9F,EAAS,MAgBvB,GAbI8F,EAAM,4BAA2BlB,GAAW,YAC5CkB,EAAM,wBAAuBlB,GAAW,aACxCkB,EAAM,qBAAoBlB,GAAW,eACrCkB,EAAM,aAAYlB,GAAW,WAC7BkB,EAAM,aAAYlB,GAAW,YAC7BkB,EAAM,wBAAuBlB,GAAW,aACxCkB,EAAM,gBAAelB,GAAW,eAIhCkB,EAAM,iBAAmB,KAAOlB,GAAW,YAG3CkB,EAAM,oBAAsB,OAC9B,OAAQA,EAAM,kBAAmB,CAC/B,IAAK,MAAOlB,GAAW,WAAY,MACnC,IAAK,QAASA,GAAW,cAAe,MACxC,IAAK,OAAQA,GAAW,cAAe,MACvC,IAAK,MAAOA,GAAW,cAAe,KACxC,CAKF,OAAKkB,EAAM,aACTlB,GAAW,aAGNA,CACT,CAEO,UAAUW,EAAqC,CAEpD,GAAI,CAAC,KAAK,UACR,MAAM,IAAI,MAAM,2CAA2C,EAI7D,IAAIX,EAAUW,GAAS,MACnB,KAAK,wBAAwB,KAAK,UAAW,KAAK,UAAU,OAAO,OAAQA,EAAQ,MAAO,EAAI,EAC9F,KAAK,6BAA6B,KAAK,UAAW,KAAK,UAAU,OAAO,OAAQA,GAAS,UAAU,EAGvG,GAAI,CAACA,GAAS,kBACR,KAAK,UAAU,OAAO,OAAO,OAAS,YAAa,CACrD,IAAMQ,EAA2B,KAAK,6BAA6B,KAAK,UAAW,KAAK,UAAU,OAAO,UAAW,MAAS,EAC7HnB,GAAW,oBAAwBmB,CAAwB,EAC7D,CAIF,OAAKR,GAAS,eACZX,GAAW,KAAK,gBAAgB,KAAK,SAAS,EAC9CA,GAAW,KAAK,uBAAuB,KAAK,SAAS,GAIvDA,GAAWzE,EAAyB,KAAK,SAAS,EAE3CyE,CACT,CAEO,gBAAgBW,EAAkD,CACvE,GAAI,CAAC,KAAK,UACR,MAAM,IAAI,MAAM,2CAA2C,EAG7D,OAAO,KAAK,uBAAuB,KAAK,UAAWA,GAAW,CAAC,CAAC,CAClE,CAEO,SAAgB,CAAE,CAC3B,EAEaE,EAAN,cAAmCvE,CAAqB,CAO7D,YACE0B,EACiBC,EACAmD,EACjB,CACA,MAAMpD,EAAQC,EAAU,IAAI,EAHX,eAAAA,EACA,cAAAmD,EATnB,KAAQ,YAAsB,GAE9B,KAAQ,aAAe,GAYhBnD,EAAkB,MAAM,cAC3B,KAAK,YAAeA,EAAkB,MAAM,cAAc,OAAO,KAGjE,KAAK,YAAcoD,CAEvB,CAEU,iBAAiB/D,EAAcY,EAAeC,EAAmB,CACzE,KAAK,cAAgB,wCAErB,IAAImD,EAAa,UACbC,EAAa,WACb,KAAK,SAAS,yBAA2B,MAC3CD,EAAa,KAAK,UAAU,QAAQ,OAAO,YAAc,UACzDC,EAAa,KAAK,UAAU,QAAQ,OAAO,YAAc,WAG3D,IAAMC,EAAyB,CAAC,EAChCA,EAAuB,KAAK,UAAYF,EAAa,GAAG,EACxDE,EAAuB,KAAK,qBAAuBD,EAAa,GAAG,EACnEC,EAAuB,KAAK,gBAAkB,KAAK,UAAU,QAAQ,WAAa,GAAG,EACrFA,EAAuB,KAAK,cAAgB,KAAK,UAAU,QAAQ,SAAW,KAAK,EACnF,KAAK,cAAgB,eAAkBA,EAAuB,KAAK,GAAG,EAAI,IAC5E,CAEU,iBAAwB,CAChC,KAAK,cAAgB,SACrB,KAAK,cAAgB,wCACvB,CAEU,QAAQtE,EAAaG,EAA0B,CACvD,KAAK,cAAgB,cAAgB,KAAK,YAAc,gBACxD,KAAK,YAAc,EACrB,CAEQ,aAAanC,EAAmBuG,EAAmC,CACzE,IAAMvC,EAAQuC,EAAOvG,EAAK,WAAW,EAAIA,EAAK,WAAW,EACzD,GAAIuG,EAAOvG,EAAK,QAAQ,EAAIA,EAAK,QAAQ,EAMvC,MAAO,IALK,CACTgE,GAAS,GAAM,IACfA,GAAU,EAAK,IACfA,EAAe,GAClB,EACiB,IAAIwC,GAAKA,EAAE,SAAS,EAAE,EAAE,SAAS,EAAG,GAAG,CAAC,EAAE,KAAK,EAAE,EAEpE,GAAID,EAAOvG,EAAK,YAAY,EAAIA,EAAK,YAAY,EAC/C,OAAO,KAAK,YAAYgE,CAAK,EAAE,GAGnC,CAEQ,mBAAmBhE,EAAuC,CAChE,GAAIA,EAAK,wBAAwB,EAC/B,OAEF,IAAMgE,EAAQhE,EAAK,kBAAkB,EACrC,OAAIA,EAAK,oBAAoB,EAMpB,IALK,CACTgE,GAAS,GAAM,IACfA,GAAU,EAAK,IACfA,EAAe,GAClB,EACiB,IAAIwC,GAAKA,EAAE,SAAS,EAAE,EAAE,SAAS,EAAG,GAAG,CAAC,EAAE,KAAK,EAAE,EAG7D,KAAK,YAAYxC,CAAK,EAAE,GACjC,CAEQ,mBAAmBhE,EAA2B,CACpD,OAAQA,EAAK,kBAAkB,EAAG,CAChC,OACE,MAAO,YACT,OACE,MAAO,mBACT,OACE,MAAO,iBACT,OACE,MAAO,mBACT,OACE,MAAO,mBACT,QACE,MAAO,WACX,CACF,CAEQ,WAAWA,EAAmB2B,EAA4C,CAChF,IAAMmD,EAAoB,CAAC,EAE3B,GAAInC,EAAiB3C,EAAM2B,CAAO,EAChC,OAGF,IAAMkC,EAAY,CAACxB,EAAQrC,EAAM2B,CAAO,EAClCmC,EAAY,CAACxB,EAAQtC,EAAM2B,CAAO,EAClCoC,EAAe,CAACrB,EAAW1C,EAAM2B,CAAO,EAE9C,GAAIkC,GAAaC,GAAaC,EAAc,CAC1C,IAAM0C,EAAa,KAAK,aAAazG,EAAM,EAAI,EAC3CyG,GACF3B,EAAQ,KAAK,UAAY2B,EAAa,GAAG,EAG3C,IAAMC,EAAa,KAAK,aAAa1G,EAAM,EAAK,EAC5C0G,GACF5B,EAAQ,KAAK,qBAAuB4B,EAAa,GAAG,EAGlD1G,EAAK,UAAU,GAAK8E,EAAQ,KAAK,4CAA4C,EAC7E9E,EAAK,OAAO,GAAK8E,EAAQ,KAAK,oBAAoB,EAGtD,IAAM6B,EAAwB,CAAC,EAkB/B,GAjBI3G,EAAK,YAAY,GACnB2G,EAAY,KAAK,KAAK,mBAAmB3G,CAAI,CAAC,EAE5CA,EAAK,WAAW,GAClB2G,EAAY,KAAK,UAAU,EAEzB3G,EAAK,gBAAgB,GACvB2G,EAAY,KAAK,cAAc,EAE7B3G,EAAK,QAAQ,GACf2G,EAAY,KAAK,OAAO,EAEtBA,EAAY,OAAS,GACvB7B,EAAQ,KAAK,oBAAsB6B,EAAY,KAAK,GAAG,EAAI,GAAG,EAI5D3G,EAAK,YAAY,EAAG,CACtB,IAAM4G,EAAiB,KAAK,mBAAmB5G,CAAI,EAC/C4G,GACF9B,EAAQ,KAAK,0BAA4B8B,EAAiB,GAAG,CAEjE,CAEA,OAAI5G,EAAK,YAAY,GAAK8E,EAAQ,KAAK,qBAAqB,EACxD9E,EAAK,SAAS,GAAK8E,EAAQ,KAAK,qBAAqB,EACrD9E,EAAK,MAAM,GAAK8E,EAAQ,KAAK,eAAe,EAEzCA,CACT,CAGF,CAEU,UAAU9E,EAAmB2B,EAAsBK,EAAarB,EAAmB,CAG3F,GAD0BX,EAAK,SAAS,IAAM,EAE5C,OAIF,IAAMuE,EAAcvE,EAAK,SAAS,IAAM,GAElC6G,EAAmB,KAAK,WAAW7G,EAAM2B,CAAO,EAGlDkF,IACF,KAAK,aAAeA,EAAiB,SAAW,EAC9C,gBACA,uBAA0BA,EAAiB,KAAK,GAAG,EAAI,MAIvDtC,EACF,KAAK,aAAe,IAEpB,KAAK,aAAerD,EAAelB,EAAK,SAAS,CAAC,CAEtD,CAEU,kBAA2B,CACnC,OAAO,KAAK,YACd,CACF", ++ "names": ["$r", "$g", "$b", "$a", "channels", "toCss", "r", "g", "b", "a", "toPaddedHex", "toRgba", "toColor", "color", "blend", "bg", "fg", "$a", "fgR", "fgG", "fgB", "bgR", "bgG", "bgB", "$r", "$g", "$b", "css", "rgba", "isOpaque", "ensureContrastRatio", "ratio", "result", "opaque", "rgbaColor", "opacity", "multiplyOpacity", "factor", "toColorRGB", "$ctx", "$litmusColor", "canvas", "ctx", "rgbaMatch", "rgb", "relativeLuminance", "relativeLuminance2", "rs", "gs", "bs", "rr", "rg", "rb", "bgRgba", "fgRgba", "bgL", "fgL", "contrastRatio", "resultA", "reduceLuminance", "resultARatio", "resultB", "increaseLuminance", "resultBRatio", "cr", "toChannels", "value", "c", "s", "l1", "l2", "DEFAULT_ANSI_COLORS", "colors", "css", "v", "i", "g", "b", "channels", "c", "getOscLinkId", "cell", "getOscLinkOpenSequence", "terminal", "linkId", "data", "getActiveOscLinkSequence", "attrs", "visibleCellCount", "line", "cols", "isClippedWideCell", "col", "BLANK_CELL_CONTENT", "blankClippedWideCell", "constrain", "value", "low", "high", "escapeHTMLChar", "c", "BaseSerializeHandler", "_buffer", "_cols", "range", "excludeFinalCursorPosition", "cell1", "cell2", "oldCell", "startRow", "endRow", "startColumn", "endColumn", "row", "startLineColumn", "endLineColumn", "isLastRow", "rows", "equalFg", "equalBg", "equalUnderline", "cell1Default", "cell2Default", "equalFlags", "attributesEquals", "cell1AsBufferCell", "StringSerializeHandler", "buffer", "_terminal", "start", "end", "rowSeparator", "currentLine", "nextLine", "currentLineCells", "thisRowLastChar", "thisRowLastSecondChar", "nextRowFirstChar", "isNextRowFirstCharDoubleWidth", "isValid", "contentCellCount", "sgrSeq", "fgChanged", "bgChanged", "flagsChanged", "color", "boldChanged", "dimChanged", "clearsIntensity", "style", "sequence", "openSequence", "isEmptyCell", "nextRowFirstCell", "isWideWrapPadding", "materializeEmptyCell", "hasDecoration", "restoreSgrSeq", "rowEnd", "content", "realCursorRow", "realCursorCol", "cursorMoved", "moveRight", "offset", "curAttrData", "SerializeAddon", "scrollback", "maxRows", "correctRows", "options", "handler", "HTMLSerializeHandler", "onlySelection", "selection", "scrollTop", "scrollBottom", "modes", "alternativeScreenContent", "_options", "DEFAULT_ANSI_COLORS", "foreground", "background", "globalStyleDefinitions", "isFg", "x", "fgHexColor", "bgHexColor", "decorations", "underlineColor", "styleDefinitions"] } diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts -index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644 +index e1728feb219c362dfa2ecb602ff99f830d520757..265b9792403601c286bc341f9d046dcd3062e1be 100644 --- a/src/SerializeAddon.ts +++ b/src/SerializeAddon.ts -@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types'; +@@ -5,13 +5,66 @@ + * (EXPERIMENTAL) This Addon is still under development + */ + +-import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm'; ++import type { IBuffer, IBufferCell, IBufferLine, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm'; + import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize'; + import { IColor } from 'common/Types'; + import { IAttributeData } from 'common/buffer/Types'; import { DEFAULT_ANSI_COLORS } from 'browser/Types'; import { UnderlineStyle } from 'common/buffer/Constants'; @@ -81,11 +89,63 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b + const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData; + return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : ''; +} ++ ++// PATCH(orca): a non-reflowing shrink (always the alternate buffer) leaves each ++// line at its old length; cells past the grid are not on screen and would wrap ++// into extra rows on replay, so serialize only the terminal's width. ++function visibleCellCount(line: IBufferLine, cols: number): number { ++ return Math.min(line.length, cols); ++} ++ ++// PATCH(orca): a wide glyph whose trailing half fell past the grid cannot be ++// replayed in the last column (it would wrap and shift every row below), so it ++// is serialized as a blank that keeps the row exactly the grid's width. ++function isClippedWideCell(line: IBufferLine, col: number, cols: number, cell: IBufferCell): boolean { ++ return col === cols - 1 && line.length > cols && cell.getWidth() > 1; ++} ++ ++// Width 1 with no codepoint (CellData.content layout). A width-0 stand-in would read as a ++// wide glyph's trailer: skipped by _nextCell and treated as content by the wrap check. ++const BLANK_CELL_CONTENT = 1 << 22; ++ ++function blankClippedWideCell(cell: IBufferCell): IBufferCell { ++ (cell as IBufferCell & { content: number }).content = BLANK_CELL_CONTENT; ++ return cell; ++} + function constrain(value: number, low: number, high: number): number { return Math.max(low, Math.min(value, high)); } -@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -27,7 +80,8 @@ function escapeHTMLChar(c: string): string { + // TODO: Refine this template class later + abstract class BaseSerializeHandler { + constructor( +- protected readonly _buffer: IBuffer ++ protected readonly _buffer: IBuffer, ++ private readonly _cols: number + ) { + } + +@@ -48,13 +102,16 @@ abstract class BaseSerializeHandler { + const line = this._buffer.getLine(row); + if (line) { + const startLineColumn = row === range.start.y ? startColumn : 0; +- const endLineColumn = row === range.end.y ? endColumn: line.length; ++ const endLineColumn = row === range.end.y ? endColumn: visibleCellCount(line, this._cols); + for (let col = startLineColumn; col < endLineColumn; col++) { +- const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); ++ let c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); + if (!c) { + console.warn(`Can't get cell at row=${row}, col=${col}`); + continue; + } ++ if (isClippedWideCell(line, col, this._cols, c)) { ++ c = blankClippedWideCell(c); ++ } + this._nextCell(c, oldCell, row, col); + oldCell = c; + } +@@ -148,18 +205,22 @@ class StringSerializeHandler extends BaseSerializeHandler { // this is a null cell for reference for checking whether background is empty or not private _backgroundCell: IBufferCell = this._buffer.getNullCell(); @@ -97,10 +157,44 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b private _lastContentCursorRow: number = 0; private _lastContentCursorCol: number = 0; + private _activeOscLinkId: number = 0; ++ private _lastBackgroundCursorRow: number = -1; ++ private _lastBackgroundCursorCol: number = 0; constructor( buffer: IBuffer, -@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + private readonly _terminal: Terminal + ) { +- super(buffer); ++ super(buffer, _terminal.cols); + } + + protected _beforeSerialize(rows: number, start: number, end: number): void { +@@ -178,6 +239,9 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { + // use clear right to set background. + this._currentRow += `\u001b[${this._nullCellCount}X`; ++ // PATCH(orca): the erase paints this row, so _serializeString must not trim it as trailing blank. ++ this._lastBackgroundCursorRow = this._lastCursorRow; ++ this._lastBackgroundCursorCol = this._lastCursorCol; + } + + let rowSeparator = ''; +@@ -202,8 +266,12 @@ class StringSerializeHandler extends BaseSerializeHandler { + this._lastCursorCol = 0; + } else { + rowSeparator = ''; +- const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!; +- const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!; ++ const currentLineCells = visibleCellCount(currentLine, this._terminal.cols); ++ let thisRowLastChar = currentLine.getCell(currentLineCells - 1, this._thisRowLastChar)!; ++ if (isClippedWideCell(currentLine, currentLineCells - 1, this._terminal.cols, thisRowLastChar)) { ++ thisRowLastChar = blankClippedWideCell(thisRowLastChar); ++ } ++ const thisRowLastSecondChar = currentLine.getCell(currentLineCells - 2, this._thisRowLastSecondChar)!; + const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!; + const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1; + +@@ -214,7 +282,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if ( // you must output character to cause overflow, control sequence can't do this nextRowFirstChar.getChars() && @@ -109,12 +203,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b ) { if ( // the last character can't be null, -@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -251,9 +319,14 @@ class StringSerializeHandler extends BaseSerializeHandler { if (this._nullCellCount > 0) { // do these because we filled the last several null slot, which we shouldn't rowSeparator += '\u001b[A'; - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`; -+ const contentCellCount = currentLine.length - this._nullCellCount; ++ const contentCellCount = currentLineCells - this._nullCellCount; + if (contentCellCount > 0) { + rowSeparator += `\u001b[${contentCellCount}C`; + } @@ -126,7 +220,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b rowSeparator += '\u001b[B'; } -@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -310,7 +383,20 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (flagsChanged) { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } @@ -148,7 +242,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b if (!equalUnderline(cell, oldCell)) { const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { -@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -337,7 +423,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } @@ -157,7 +251,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } } } -@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -346,6 +432,20 @@ class StringSerializeHandler extends BaseSerializeHandler { return sgrSeq; } @@ -178,7 +272,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { // a width 0 cell don't need to be count because it is just a placeholder after a CJK character; const isPlaceHolderCell = cell.getWidth() === 0; -@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -356,12 +456,21 @@ class StringSerializeHandler extends BaseSerializeHandler { // this cell don't have content const isEmptyCell = cell.getChars() === ''; @@ -203,7 +297,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b /** * handles style change -@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -395,7 +504,7 @@ class StringSerializeHandler extends BaseSerializeHandler { /** * handles actual content */ @@ -212,7 +306,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b this._nullCellCount += cell.getWidth(); } else { if (this._nullCellCount > 0) { -@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -411,7 +520,22 @@ class StringSerializeHandler extends BaseSerializeHandler { this._nullCellCount = 0; } @@ -236,7 +330,23 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b // update cursor this._lastContentCursorRow = this._lastCursorRow = row; -@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -425,6 +549,15 @@ class StringSerializeHandler extends BaseSerializeHandler { + // the fixup is only required for data without scrollback + // because it will always be placed at last line otherwise + if (this._buffer.length - this._firstRow <= this._terminal.rows) { ++ // PATCH(orca): keep trailing background-only rows, unless the cursor is wrap-pending: ++ // relative moves back from those rows cannot re-create that state. ++ if ( ++ this._lastBackgroundCursorRow > this._lastContentCursorRow && ++ this._buffer.cursorX < this._terminal.cols ++ ) { ++ this._lastContentCursorRow = this._lastBackgroundCursorRow; ++ this._lastContentCursorCol = this._lastBackgroundCursorCol; ++ } + rowEnd = this._lastContentCursorRow + 1 - this._firstRow; + this._lastCursorCol = this._lastContentCursorCol; + this._lastCursorRow = this._lastContentCursorRow; +@@ -439,6 +572,9 @@ class StringSerializeHandler extends BaseSerializeHandler { } } @@ -246,7 +356,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b // restore the cursor if (!excludeFinalCursorPosition) { const realCursorRow = this._buffer.baseY + this._buffer.cursorY; -@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi { +@@ -616,6 +752,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi { content += this._serializeScrollRegion(this._terminal); } @@ -256,3 +366,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b return content; } +@@ -642,7 +781,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { + private readonly _terminal: Terminal, + private readonly _options: Partial + ) { +- super(buffer); ++ super(buffer, _terminal.cols); + + // For xterm headless: fallback to ansi colors + if ((_terminal as any)._core._themeService) { diff --git a/config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch index 00e8abcfa1d0..c975ff074101 100644 --- a/config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch +++ b/config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch @@ -1,8 +1,16 @@ diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts -index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644 +index e1728feb219c362dfa2ecb602ff99f830d520757..265b9792403601c286bc341f9d046dcd3062e1be 100644 --- a/src/SerializeAddon.ts +++ b/src/SerializeAddon.ts -@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types'; +@@ -5,13 +5,66 @@ + * (EXPERIMENTAL) This Addon is still under development + */ + +-import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm'; ++import type { IBuffer, IBufferCell, IBufferLine, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm'; + import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize'; + import { IColor } from 'common/Types'; + import { IAttributeData } from 'common/buffer/Types'; import { DEFAULT_ANSI_COLORS } from 'browser/Types'; import { UnderlineStyle } from 'common/buffer/Constants'; @@ -35,11 +43,63 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b + const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData; + return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : ''; +} ++ ++// PATCH(orca): a non-reflowing shrink (always the alternate buffer) leaves each ++// line at its old length; cells past the grid are not on screen and would wrap ++// into extra rows on replay, so serialize only the terminal's width. ++function visibleCellCount(line: IBufferLine, cols: number): number { ++ return Math.min(line.length, cols); ++} ++ ++// PATCH(orca): a wide glyph whose trailing half fell past the grid cannot be ++// replayed in the last column (it would wrap and shift every row below), so it ++// is serialized as a blank that keeps the row exactly the grid's width. ++function isClippedWideCell(line: IBufferLine, col: number, cols: number, cell: IBufferCell): boolean { ++ return col === cols - 1 && line.length > cols && cell.getWidth() > 1; ++} ++ ++// Width 1 with no codepoint (CellData.content layout). A width-0 stand-in would read as a ++// wide glyph's trailer: skipped by _nextCell and treated as content by the wrap check. ++const BLANK_CELL_CONTENT = 1 << 22; ++ ++function blankClippedWideCell(cell: IBufferCell): IBufferCell { ++ (cell as IBufferCell & { content: number }).content = BLANK_CELL_CONTENT; ++ return cell; ++} + function constrain(value: number, low: number, high: number): number { return Math.max(low, Math.min(value, high)); } -@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -27,7 +80,8 @@ function escapeHTMLChar(c: string): string { + // TODO: Refine this template class later + abstract class BaseSerializeHandler { + constructor( +- protected readonly _buffer: IBuffer ++ protected readonly _buffer: IBuffer, ++ private readonly _cols: number + ) { + } + +@@ -48,13 +102,16 @@ abstract class BaseSerializeHandler { + const line = this._buffer.getLine(row); + if (line) { + const startLineColumn = row === range.start.y ? startColumn : 0; +- const endLineColumn = row === range.end.y ? endColumn: line.length; ++ const endLineColumn = row === range.end.y ? endColumn: visibleCellCount(line, this._cols); + for (let col = startLineColumn; col < endLineColumn; col++) { +- const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); ++ let c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); + if (!c) { + console.warn(`Can't get cell at row=${row}, col=${col}`); + continue; + } ++ if (isClippedWideCell(line, col, this._cols, c)) { ++ c = blankClippedWideCell(c); ++ } + this._nextCell(c, oldCell, row, col); + oldCell = c; + } +@@ -148,18 +205,22 @@ class StringSerializeHandler extends BaseSerializeHandler { // this is a null cell for reference for checking whether background is empty or not private _backgroundCell: IBufferCell = this._buffer.getNullCell(); @@ -51,10 +111,44 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b private _lastContentCursorRow: number = 0; private _lastContentCursorCol: number = 0; + private _activeOscLinkId: number = 0; ++ private _lastBackgroundCursorRow: number = -1; ++ private _lastBackgroundCursorCol: number = 0; constructor( buffer: IBuffer, -@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + private readonly _terminal: Terminal + ) { +- super(buffer); ++ super(buffer, _terminal.cols); + } + + protected _beforeSerialize(rows: number, start: number, end: number): void { +@@ -178,6 +239,9 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { + // use clear right to set background. + this._currentRow += `\u001b[${this._nullCellCount}X`; ++ // PATCH(orca): the erase paints this row, so _serializeString must not trim it as trailing blank. ++ this._lastBackgroundCursorRow = this._lastCursorRow; ++ this._lastBackgroundCursorCol = this._lastCursorCol; + } + + let rowSeparator = ''; +@@ -202,8 +266,12 @@ class StringSerializeHandler extends BaseSerializeHandler { + this._lastCursorCol = 0; + } else { + rowSeparator = ''; +- const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!; +- const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!; ++ const currentLineCells = visibleCellCount(currentLine, this._terminal.cols); ++ let thisRowLastChar = currentLine.getCell(currentLineCells - 1, this._thisRowLastChar)!; ++ if (isClippedWideCell(currentLine, currentLineCells - 1, this._terminal.cols, thisRowLastChar)) { ++ thisRowLastChar = blankClippedWideCell(thisRowLastChar); ++ } ++ const thisRowLastSecondChar = currentLine.getCell(currentLineCells - 2, this._thisRowLastSecondChar)!; + const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!; + const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1; + +@@ -214,7 +282,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if ( // you must output character to cause overflow, control sequence can't do this nextRowFirstChar.getChars() && @@ -63,12 +157,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b ) { if ( // the last character can't be null, -@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -251,9 +319,14 @@ class StringSerializeHandler extends BaseSerializeHandler { if (this._nullCellCount > 0) { // do these because we filled the last several null slot, which we shouldn't rowSeparator += '\u001b[A'; - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`; -+ const contentCellCount = currentLine.length - this._nullCellCount; ++ const contentCellCount = currentLineCells - this._nullCellCount; + if (contentCellCount > 0) { + rowSeparator += `\u001b[${contentCellCount}C`; + } @@ -80,7 +174,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b rowSeparator += '\u001b[B'; } -@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -310,7 +383,20 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (flagsChanged) { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } @@ -102,7 +196,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b if (!equalUnderline(cell, oldCell)) { const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { -@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -337,7 +423,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } @@ -111,7 +205,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } } } -@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -346,6 +432,20 @@ class StringSerializeHandler extends BaseSerializeHandler { return sgrSeq; } @@ -132,7 +226,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { // a width 0 cell don't need to be count because it is just a placeholder after a CJK character; const isPlaceHolderCell = cell.getWidth() === 0; -@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -356,12 +456,21 @@ class StringSerializeHandler extends BaseSerializeHandler { // this cell don't have content const isEmptyCell = cell.getChars() === ''; @@ -157,7 +251,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b /** * handles style change -@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -395,7 +504,7 @@ class StringSerializeHandler extends BaseSerializeHandler { /** * handles actual content */ @@ -166,7 +260,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b this._nullCellCount += cell.getWidth(); } else { if (this._nullCellCount > 0) { -@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -411,7 +520,22 @@ class StringSerializeHandler extends BaseSerializeHandler { this._nullCellCount = 0; } @@ -190,7 +284,23 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b // update cursor this._lastContentCursorRow = this._lastCursorRow = row; -@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler { +@@ -425,6 +549,15 @@ class StringSerializeHandler extends BaseSerializeHandler { + // the fixup is only required for data without scrollback + // because it will always be placed at last line otherwise + if (this._buffer.length - this._firstRow <= this._terminal.rows) { ++ // PATCH(orca): keep trailing background-only rows, unless the cursor is wrap-pending: ++ // relative moves back from those rows cannot re-create that state. ++ if ( ++ this._lastBackgroundCursorRow > this._lastContentCursorRow && ++ this._buffer.cursorX < this._terminal.cols ++ ) { ++ this._lastContentCursorRow = this._lastBackgroundCursorRow; ++ this._lastContentCursorCol = this._lastBackgroundCursorCol; ++ } + rowEnd = this._lastContentCursorRow + 1 - this._firstRow; + this._lastCursorCol = this._lastContentCursorCol; + this._lastCursorRow = this._lastContentCursorRow; +@@ -439,6 +572,9 @@ class StringSerializeHandler extends BaseSerializeHandler { } } @@ -200,7 +310,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b // restore the cursor if (!excludeFinalCursorPosition) { const realCursorRow = this._buffer.baseY + this._buffer.cursorY; -@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi { +@@ -616,6 +752,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi { content += this._serializeScrollRegion(this._terminal); } @@ -210,3 +320,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b return content; } +@@ -642,7 +781,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { + private readonly _terminal: Terminal, + private readonly _options: Partial + ) { +- super(buffer); ++ super(buffer, _terminal.cols); + + // For xterm headless: fallback to ansi colors + if ((_terminal as any)._core._themeService) { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 11b8bb5fad4b..faab9baee825 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,94 @@ } }, "gates": [ + { + "id": "workspace-hooks.execution-host-ownership", + "title": "Hook reads and writes use the stored repository execution owner", + "maturity": "experimental", + "protection": "partial", + "owner": "workspace-runtime", + "layer": "cross-layer-unit", + "surfaces": [ + "desktop worktree hook IPC", + "runtime repository hooks", + "issue-command read and write", + "hosted-review own-store host resolution" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Actual hydration and authoritative desktop owner selection with mocked filesystem, Git, inspection and provider boundaries; synthetic local/SSH/runtime rows. No physical provider or native platform certification.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts" + ], + "invariant": "Hook file operations follow the authoritative repository row in this process Store: canonical SSH selects its target, explicit local and self-addressed runtime rows use registered local files, and missing SSH providers never substitute local I/O.", + "oracle": "Nine desktop/runtime operations never use local I/O for canonical SSH or dial a stale legacy target; legacy SSH/local/runtime controls retain behavior, folder skips remain, missing providers do not gain local fallback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts" + ], + "testFiles": [ + "src/main/ipc/hooks/worktree-hook-execution-host.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "assertions": [ + "routes %s to the stored owner", + "refuses an unreachable canonical SSH write without touching local files" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "result": "passed", + "durationSeconds": 0.739, + "summary": "Independent68pass/original37fail31controls. Eleven additional authority/unavailable-provider/encoded-ID/folder controls pass; author102unique permanent tests across four suites." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Frozen permanent suite68pass; original sources37fail31controls; independently reproduced with eleven extra controls." + }, + "performanceBudget": { + "required": true, + "evidence": "Incorrect local/stale-target routes9to0 for each of four changed identity shapes. This is correctness evidence only, no latency/CPU/heap gain. Existing host resolver reused, no polling, cache or inventory/subprocess fanout added." + }, + "knownGaps": [ + "CI soak pending.", + "Physical SSH/WSL/remote-runtime, native Linux/Windows, mobile and mixed-version integration untested. Local/daemon PTY data, identity and liveness unchanged.", + "Runtime absent-provider issue-command write still returns ok:true without a write; this preexisting convention is not a completion claim.", + "Only own-store rows use the generalized helper; generic runtime dispatch still rejects hosts this process does not execute.", + "No actual filesystem writes, provider operations, native UI or packaged app validation in routing fixtures." + ], + "promotionCriteria": [ + "Retain exact route, no-local-fallback and authority oracles; obtain CI soak and physical host evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate authority/routing failures without replacing exact-target or no-local-I/O assertions." + }, { "id": "agent-session.journal-streaming-replay", "title": "Journal replay bounds obsolete revision memory without changing recovery", @@ -20143,6 +20231,80 @@ "Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution." ], "demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines." + }, + { + "id": "relay.control-activation-ownership", + "title": "Closed queued controls leave persistence for the live retry", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "registry-unit-and-local-websocket", + "surfaces": ["relay host control activation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual registry queue with controlled store latency plus local WebSocket proof and connection-capacity suites on macOS. No PostgreSQL or live remote host run. PTY, daemon, SSH process state, WSL execution and folder/git workspace behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/cloud/apps/relay/src/host-session-registry.ts" + ], + "invariant": "A closed queued transport starts no durable activation; an already-started activation retains cleanup, and a live successor becomes the indexed control without concurrent same-host activation.", + "oracle": "Queue a closed control before a live retry behind stalled persistence. With controlled 4s store calls, require two total activations, one release for the already-started closed control, and live readiness at 4s. Existing tests retain cleanup when closure happens during I/O.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/host-session-registry.test.ts", + "cloud/apps/relay/src/host-session-client-accept.test.ts", + "cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts", + "cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/host-session-registry.test.ts", + "assertions": [ + "skips a closed queued control so its live retry avoids abandoned database work", + "does not publish a control that closes during activation", + "does not rebind a control that closes during activation", + "fails a control waiting behind a stalled activation without breaking serialization" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.407, + "summary": "88 tests across four files passed; relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused unit and local socket suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline regression failed: three activation calls, two releases, live ready at 8s. Guarded registry passed with two calls, one release, live ready at 4s under controlled store latency." + }, + "performanceBudget": { + "required": true, + "evidence": "Deletes abandoned persistence before it starts. No new timer, polling, queue, provider call or wire change; existing FIFO and post-I/O cleanup remain." + }, + "knownGaps": [ + "No real PostgreSQL contention benchmark or live remote host/physical mobile run.", + "Windows/Linux execution and CI soak are not recorded; queue cleanup after a never-settling predecessor remains unchanged." + ], + "promotionCriteria": [ + "Retain red/green count and latency assertions; collect cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without retries or weakening count, identity or cleanup assertions." } ] } diff --git a/config/scripts/assert-github-release-is-draft.mjs b/config/scripts/assert-github-release-is-draft.mjs new file mode 100644 index 000000000000..cce1d51cee2c --- /dev/null +++ b/config/scripts/assert-github-release-is-draft.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url' + +const API_VERSION = '2022-11-28' + +function githubHeaders(token) { + return { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': API_VERSION + } +} + +async function githubJson(fetchImpl, url, token, options = {}) { + const res = await fetchImpl(url, { + ...options, + headers: { + ...githubHeaders(token), + ...options.headers + } + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) + } + return res.json() +} + +export function matchingDesktopReleases(releases, tag) { + const version = tag.startsWith('v') ? tag.slice(1) : tag + return (releases ?? []).filter((release) => { + const tagName = release?.tag_name + const name = release?.name + return tagName === tag || tagName === version || name === tag || name === version + }) +} + +export async function restorePublishedDesktopReleasesToDraft({ + repo, + tag, + token, + fetchImpl = fetch, + log = console.log +}) { + if (!repo) { + throw new Error('repo is required') + } + if (!tag) { + throw new Error('tag is required') + } + if (!token) { + throw new Error('token is required') + } + + const releases = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases?per_page=100`, + token + ) + if (!Array.isArray(releases)) { + throw new Error(`GitHub releases response for ${repo} was not an array`) + } + + const matches = matchingDesktopReleases(releases, tag) + if (matches.length === 0) { + throw new Error(`No GitHub release named ${tag} was found after artifact upload`) + } + + const restored = [] + for (const release of matches) { + if (release?.draft === true) { + continue + } + if (!Number.isInteger(release.id)) { + throw new Error(`Release ${tag} is missing a GitHub release id`) + } + const patched = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${release.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ draft: true, make_latest: 'false' }) + } + ) + log(`Restored GitHub release ${release.id} (${release.tag_name}) to draft.`) + restored.push(patched) + } + return restored +} + +async function main() { + // Why env TAG: the Windows release-cut matrix uses pwsh, which does not + // expand bash-style "$TAG" in argv. The step still exports TAG. + const tag = process.argv[2] || process.env.TAG + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca' + const restored = await restorePublishedDesktopReleasesToDraft({ + repo, + tag, + token, + log: (message) => console.error(message) + }) + if (restored.length > 0) { + console.error( + `::error::Release ${tag} was published during artifact upload. Restored ${restored.length} release(s) to draft so /releases/latest does not serve partial assets.` + ) + process.exit(1) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message) + process.exit(1) + }) +} diff --git a/config/scripts/assert-github-release-is-draft.test.mjs b/config/scripts/assert-github-release-is-draft.test.mjs new file mode 100644 index 000000000000..aed313495dea --- /dev/null +++ b/config/scripts/assert-github-release-is-draft.test.mjs @@ -0,0 +1,165 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + matchingDesktopReleases, + restorePublishedDesktopReleasesToDraft +} from './assert-github-release-is-draft.mjs' + +const require = createRequire(import.meta.url) +const repoRoot = join(import.meta.dirname, '../..') + +function jsonResponse(body, init = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: vi.fn(async () => body), + text: vi.fn(async () => JSON.stringify(body)) + } +} + +describe('matchingDesktopReleases', () => { + it('matches tagged, untagged-name, and version-name releases', () => { + const releases = [ + { id: 1, tag_name: 'v1.4.206', name: 'v1.4.206', draft: true }, + { id: 2, tag_name: 'untagged-abc', name: '1.4.206', draft: false }, + { id: 3, tag_name: 'v1.4.205', name: 'v1.4.205', draft: false } + ] + + expect(matchingDesktopReleases(releases, 'v1.4.206').map((release) => release.id)).toEqual([ + 1, 2 + ]) + }) +}) + +describe('restorePublishedDesktopReleasesToDraft', () => { + it('leaves drafts alone', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([{ id: 1, tag_name: 'v1.4.206', name: 'v1.4.206', draft: true }]) + ) + + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl, + log: vi.fn() + }) + ).resolves.toEqual([]) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it('re-drafts a published match immediately', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([{ id: 9, tag_name: 'v1.4.206', name: '1.4.206', draft: false }]) + ) + .mockResolvedValueOnce(jsonResponse({ id: 9, tag_name: 'v1.4.206', draft: true })) + + const log = vi.fn() + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl, + log + }) + ).resolves.toEqual([{ id: 9, tag_name: 'v1.4.206', draft: true }]) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/stablyai/orca/releases/9', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ draft: true, make_latest: 'false' }) + }) + ) + expect(log).toHaveBeenCalledWith('Restored GitHub release 9 (v1.4.206) to draft.') + }) + + it('fails closed when no matching release exists', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([])) + + await expect( + restorePublishedDesktopReleasesToDraft({ + repo: 'stablyai/orca', + tag: 'v1.4.206', + token: 'token', + fetchImpl + }) + ).rejects.toThrow('No GitHub release named v1.4.206 was found after artifact upload') + }) +}) + +describe('release draft workflow contract', () => { + it('keeps GitHub releases draft until publish-release undrafts complete assets', () => { + const releaseWorkflow = parse( + readFileSync(join(repoRoot, '.github/workflows/release-cut.yml'), 'utf8') + ) + const macWorkflow = parse( + readFileSync(join(repoRoot, '.github/workflows/release-mac-build.yml'), 'utf8') + ) + const electronBuilderConfig = require('../electron-builder.config.cjs') + const cutCheckout = releaseWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') + const linuxDraftStep = releaseWorkflow.jobs.build.steps.find( + (step) => step.name === 'Verify release remains draft after artifact upload' + ) + const publishRelease = releaseWorkflow.jobs['publish-release'].steps.find( + (step) => step.name === 'Publish release' + ) + const macSteps = macWorkflow.jobs['build-mac'].steps + const abortParentStep = macSteps.find( + (step) => step.name === 'Abort if the parent release-cut run was cancelled' + ) + const macPublishStep = macSteps.find( + (step) => step.name === 'Publish release artifacts (macOS)' + ) + const macDraftStep = macSteps.find( + (step) => step.name === 'Verify release remains draft after artifact upload' + ) + + expect(electronBuilderConfig.publish.releaseType).toBe('draft') + expect(cutCheckout.with['fetch-tags']).toBe(true) + expect(linuxDraftStep.shell).toBe('bash') + expect(linuxDraftStep.run).toContain('assert-github-release-is-draft.mjs') + expect(linuxDraftStep.run).toContain('needs.cut.outputs.tag') + expect(publishRelease.run).toContain('gh release edit') + expect(publishRelease.run).toContain('--draft=false') + expect(macSteps.indexOf(abortParentStep)).toBeLessThan(macSteps.indexOf(macPublishStep)) + expect(abortParentStep.env.PARENT_RUN).toBe('${{ inputs.release_run_id }}') + expect(abortParentStep.run).toContain('refusing to publish mac artifacts') + expect(macDraftStep.shell).toBe('bash') + expect(macDraftStep.run).toContain('assert-github-release-is-draft.mjs') + expect(macDraftStep.run).toContain('inputs.tag') + expect(macPublishStep.with.command).toContain('-c.publish.releaseType=draft') + + const linuxCommands = releaseWorkflow.jobs.build.strategy.matrix.include + .filter((entry) => String(entry.platform).startsWith('linux')) + .map((entry) => entry.release_command) + expect(linuxCommands.length).toBe(2) + for (const command of linuxCommands) { + expect(command).toContain('-c.publish.releaseType=draft') + } + + const createRestore = releaseWorkflow.jobs['create-release'].steps.find( + (step) => step.name === 'Restore draft-release scripts from the workflow ref' + ) + const buildRestore = releaseWorkflow.jobs.build.steps.find( + (step) => step.name === 'Restore draft-publish scripts from the workflow ref' + ) + const macRestore = macSteps.find( + (step) => step.name === 'Restore draft-publish scripts from the workflow ref' + ) + expect(createRestore.run).toContain('create-draft-release.mjs') + expect(buildRestore.run).toContain('assert-github-release-is-draft.mjs') + expect(macRestore.run).toContain('assert-github-release-is-draft.mjs') + }) +}) diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 4d394190e84d..51178e9bed5f 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -1,18 +1,19 @@ import { readFile } from 'node:fs/promises' import { realpathSync } from 'node:fs' import { basename, extname, join, resolve } from 'node:path' +import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import * as esbuild from 'esbuild' import { MOBILE_WEB_BUNDLE_ENTRYPOINT, hashedAsset, - isDirectInvocation, readDesktopVersion, readProtocolWindow, sha256Hex, writeMobileWebBundleTree, contentTypeForExtension -} from './build-mobile-web-bundle.mjs' +} from './mobile-web-bundle-manifest.mjs' +import { isDirectInvocation } from './script-entry-detection.mjs' import { ROUTE_SOURCE_LOADERS, assertRoutesCarryNoSynchronousExports, @@ -26,7 +27,12 @@ const projectDir = fileURLToPath(new URL('../..', import.meta.url)) const mobileDir = join(projectDir, 'mobile') const defaultAppDir = join(mobileDir, 'app') const entryPoint = join(mobileDir, 'web-entry', 'index.tsx') -const defaultOutDir = join(projectDir, 'out', 'mobile-web-app') +// The one definition of where the packaged bundle lives, taken from the guard that enforces it: +// a second constant here could drift and leave electron-builder's beforePack looking at an empty +// directory while the builder reported a tree it had written somewhere else. +const { MOBILE_WEB_BUNDLE_DIR: defaultOutDir } = createRequire(import.meta.url)( + './verify-packaged-mobile-web-bundle.cjs' +) /** * Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the @@ -54,6 +60,21 @@ export const MOBILE_WEB_APP_SHIMS = [ name: 'process-banner', appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true }, + { + // Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even + // though Zod catches the throw and runs interpreted. Turned off before any module, because a + // schema constructed at module scope reaches the probe before our own code can run. + name: 'zod-jitless-banner', + appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true + }, + { + // Four modules under src/shared resolve `zod` upward to the root's copy, so the page bundled + // two Zods and built salvage combinators with one instance to nest inside schemas built by the + // other. mobile/tsconfig.json already maps `zod` to mobile's for the whole mobile program, + // those shared modules included; this is the bundler catching up to that contract. + name: 'one-zod', + appliesTo: (options) => options.alias?.zod === MOBILE_ZOD_PACKAGE + }, { // lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does // not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not. @@ -71,6 +92,13 @@ export const MOBILE_WEB_APP_SHIMS = [ appliesTo: (options) => options.alias?.['@react-native-async-storage/async-storage'] === PAGE_ASYNC_STORAGE_MODULE }, + { + // react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device px on a phone. + // Native's value is one device px, so the page paints native's count at that assignment. + name: 'hairline-device-pixel', + appliesTo: (options) => + options.plugins?.some((plugin) => plugin.name === HAIRLINE_PLUGIN_NAME) === true + }, { // esbuild has no require.context, so the route tree is generated and injected. name: 'route-manifest', @@ -103,6 +131,17 @@ export const MOBILE_WEB_APP_ROOT_RESET = '' +/** + * Rules that make the page paint what the native app paints where the browser's defaults differ. + * Native is the reference. Zero specificity (`:where`), so a component's own style still wins. + * + * Inputs and textareas: Chromium rings a focused one (`:focus-visible` matches every focused + * text field); no native TextInput paints one, and the caret and the IME already mark focus. + * Those only: a button reached by a hardware keyboard keeps the browser's ring. + */ +export const MOBILE_WEB_APP_NATIVE_PARITY_STYLE = + '' + const PAGE_ASYNC_STORAGE_MODULE = join( mobileDir, 'src', @@ -111,8 +150,48 @@ const PAGE_ASYNC_STORAGE_MODULE = join( 'page-async-storage.ts' ) +/** + * The one Zod the page runs. + * + * `nodePaths` is a fallback, consulted only where normal resolution fails, so it never reached + * `src/shared/zod-salvage.ts`: that file sits above `mobile/`, its bare `zod` resolves upward to + * the root's 4.5.4, and the 58 mobile modules beside it resolved to mobile's 4.4.3. Both shipped. + * + * Mobile's copy and not the root's, because the mobile app already says so: `mobile/tsconfig.json` + * maps `zod` to `./node_modules/zod`, and a shared module joins that program as an imported file, + * so tsc holds `zod-salvage.ts` to 4.4.3 today. The composition says the same thing from the other + * side — `salvagingArray` and friends are leaves nested inside `z.object(...)` built by mobile's + * Zod, so the leaves belong to the container's instance. + * + * The package directory rather than a file: nothing imports a `zod/...` subpath, and esbuild reads + * the `module` field here, which is the same ESM entry the package's `import` condition names. + */ +const MOBILE_ZOD_PACKAGE = join(mobileDir, 'node_modules', 'zod') + +/** + * Zod's compiled path, off before any module runs. + * + * Zod decides whether it may compile by constructing `new Function('')` and reading the throw as + * "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod + * catches it and takes the interpreted path — but the browser files a `securitypolicyviolation` + * report first, and it does so on every page load. Zod's own source gates the probe on `jitless` + * for this case, so nothing here is a workaround. + * + * In the banner rather than a module that calls `z.config`, because a module cannot win the race. + * `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first + * module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk + * holding zod and its callers before the chunk holding any module of ours that imports zod. An + * entry import placed first was measured losing that race; the banner runs before every module. + * + * `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than + * replacing, so setting the flag on it here is what zod itself reads. + */ +const ZOD_JITLESS_BANNER = + 'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;' + const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest' const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider' +const HAIRLINE_PLUGIN_NAME = 'orca-hairline-device-pixel' /** The entry output's name, so classifying the outputs never has to guess which one it is. */ const ENTRY_CHUNK_NAME = 'entry' @@ -132,7 +211,11 @@ function routeManifestPlugin(manifestSource) { } } -const lucideBarrelPlugin = { +/** + * Exported so a component-level render check builds the icons the same way the page does, rather + * than carrying a second copy of this shim that could drift from it. + */ +export const lucideBarrelPlugin = { name: LUCIDE_PLUGIN_NAME, setup(build) { build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({ @@ -142,6 +225,37 @@ const lucideBarrelPlugin = { } } +// react-native-web's own assignment, matched whole so an upgrade that moves it fails the build. +const RNW_HAIRLINE_ASSIGNMENT = 'StyleSheet.hairlineWidth = 1;' +// React Native's device-pixel count (roundToNearestPixel(0.4), else one) over the ratio, rounded up +// to the 1/64 CSS px both engines lay out in. WebKit stores an exact 1/3 as 21/64, under one device +// pixel at 3, and paints nothing; 22/64 is the smallest step that paints. Any width above 1/ratio +// can still straddle two rows at some offsets; the smallest such step does so least. +const DEVICE_PIXEL_HAIRLINE_ASSIGNMENT = + 'StyleSheet.hairlineWidth = (function (ratio) {' + + ' return Math.ceil((64 * (Math.round(0.4 * ratio) || 1)) / ratio) / 64; })' + + "(typeof window !== 'undefined' && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1);" + +const hairlineDevicePixelPlugin = { + name: HAIRLINE_PLUGIN_NAME, + setup(build) { + build.onLoad( + // Both builds: once a dependency requires it, esbuild resolves every importer to cjs. + { filter: /react-native-web[\\/]dist[\\/](cjs[\\/])?exports[\\/]StyleSheet[\\/]index\.js$/ }, + async (args) => { + const source = await readFile(args.path, 'utf8') + if (!source.includes(RNW_HAIRLINE_ASSIGNMENT)) { + throw new Error(`${HAIRLINE_PLUGIN_NAME}: ${args.path} no longer assigns hairlineWidth`) + } + return { + contents: source.replace(RNW_HAIRLINE_ASSIGNMENT, DEVICE_PIXEL_HAIRLINE_ASSIGNMENT), + loader: 'js' + } + } + ) + } +} + /** Split out so a test can read the options MOBILE_WEB_APP_SHIMS claims, without a build. */ export function mobileWebAppBuildOptions(routes) { return { @@ -175,12 +289,19 @@ export function mobileWebAppBuildOptions(routes) { logLevel: 'silent', jsx: 'automatic', // One React: resolve everything from mobile/node_modules, which is where the entry lives. + // A fallback only, so it settles nothing for a module that resolves on its own — see + // MOBILE_ZOD_PACKAGE, which is a repo-root import this never reached. nodePaths: [join(mobileDir, 'node_modules')], alias: { 'react-native': 'react-native-web', - '@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE + '@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE, + zod: MOBILE_ZOD_PACKAGE }, - plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin], + plugins: [ + routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), + lucideBarrelPlugin, + hairlineDevicePixelPlugin + ], resolveExtensions: [ '.web.tsx', '.web.ts', @@ -210,7 +331,7 @@ export function mobileWebAppBuildOptions(routes) { // script would resolve against the route instead. publicPath: '/assets', banner: { - js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };" + js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}` }, define: { global: 'globalThis', @@ -341,28 +462,45 @@ const isScriptOutput = (path) => path.endsWith('.js') /** * Every source module one page route reaches, as the builder itself resolves them. * - * One definition of "what a page contains", read from `metafile.inputs` — the modules the route - * pulls in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a - * browser must download. Both entry points are needed: `app/h/_layout.tsx` wraps every route under - * it, and its imports are part of the page as surely as the route module's. + * Every layout above the route is an entry: `app/_layout` (its web sibling) and `app/h/_layout.tsx` + * wrap every route, and their imports are part of the page as surely as the route module's. + */ +export async function mobileWebAppRouteClosure(routeModule) { + return await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule]) +} + +/** + * The same closure for any entry modules, which a route plus the layout is one case of. + * + * One definition of "what a page contains", read from `metafile.inputs` — the modules the entries + * pull in — rather than from `entryStaticClosure`, which walks emitted chunks and answers what a + * browser must download. * - * `splitting: false` and a per-name output are required for a two-entry build; with the defaults + * A component a route mounts rather than one the router registers — `MobileBrowserPane` is the + * first with a pin of its own — has a closure to certify and no route to name it by. Pass it alone + * to read what it reaches on its own, or beside `app/h/_layout` to read what it adds to a page. + * + * `splitting: false` and a per-path output are required for a multi-entry build; with the defaults * esbuild fails on two outputs claiming `dist/entry.js`. * * Note for anyone comparing this with a parity pin: `c1-page-closure.ts`, and the closures C2.6, * C5.2 and C3.2 generate, derive theirs by the C1.6 method inside the mobile suite. The two are * not the same computation, and a divergence between them is a finding rather than noise. */ -export async function mobileWebAppRouteClosure(routeModule) { +export async function mobileWebAppModuleClosure(entryModules, { absWorkingDir } = {}) { const base = mobileWebAppBuildOptions(MOBILE_WEB_PAGE_ROUTES) const result = await esbuild.build({ ...base, + // A census that plants a module to show the walk would report it needs a tree of its own; the + // real ones never pass this and keep measuring `mobile/`. + ...(absWorkingDir ? { absWorkingDir } : {}), // Extensionless, so `resolveExtensions` picks the same file the bundle ships: a route with a // `.web.tsx` sibling resolves to that one, and naming the `.tsx` path explicitly would measure // the native switch no browser ever loads. - entryPoints: ['app/h/_layout', routeModule.replace(/\.tsx?$/, '')], + entryPoints: entryModules.map((entry) => entry.replace(/\.tsx?$/, '')), splitting: false, - entryNames: '[name]', + // `[dir]` too: `app/_layout` and `app/h/_layout` share a name. + entryNames: '[dir]/[name]', plugins: base.plugins.filter((plugin) => plugin.name !== ROUTE_MANIFEST_PLUGIN_NAME), write: false, metafile: true, @@ -429,7 +567,18 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE ) } } - return declared.map((route) => ({ pathname: route.pathname, grants: [...route.grants] })) + // Mapped member by member rather than spread: the manifest is `.strict()`, so a field this + // declaration grows and this map does not name is dropped in silence -- which is how + // `optionalGrants` would have reached a phone as a route that declared nothing optional. + // `optionalGrants` is omitted when the route declares none, because absent and empty are the same + // answer to a shell and a key written empty would be a manifest field with no reader. + return declared.map((route) => ({ + pathname: route.pathname, + grants: [...route.grants], + ...(route.optionalGrants === undefined || route.optionalGrants.length === 0 + ? {} + : { optionalGrants: [...route.optionalGrants] }) + })) } /** @@ -465,8 +614,15 @@ export async function buildMobileWebAppBundle({ // module and chunk both load under the shell's script-src 'self'; the policy is unchanged. const html = '\n\n\n\n' + - '\n' + - `Orca\n${MOBILE_WEB_APP_ROOT_RESET}\n\n\n
\n` + + // No viewport-fit=cover: the page never asks to extend under the system bars. The shell owns + // the safe area: it pads the WebView and, on Android, zeroes the insets the WebView would + // report via env(); on iOS the padded WKWebView reports none. + '\n' + + // Undeclared, a browser asks the origin for /favicon.ico itself and the shell's asset server + // answers 403, the path being in no manifest. Empty rather than an asset: a WebView document + // has no tab for an icon, and the bundle's images are route assets named by their own bytes. + '\n' + + `Orca\n${MOBILE_WEB_APP_ROOT_RESET}\n${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}\n\n\n
\n` + `\n\n\n` const indexBytes = Buffer.from(html, 'utf8') const indexAsset = { diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index fb70640924aa..1b0954167082 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -2,8 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { deserialize, serialize } from 'node:v8' import { describe, expect, it } from 'vitest' import { + MOBILE_WEB_APP_NATIVE_PARITY_STYLE, MOBILE_WEB_APP_ROOT_RESET, MOBILE_WEB_APP_SHIMS, bundleMobileWebApp, @@ -11,19 +13,20 @@ import { entryStaticClosure, mobileWebAppBuildOptions, renameOutputsByContent, - resolveMobileWebPageRoutes, routeChunkNames } from './build-mobile-web-app-bundle.mjs' import { MOBILE_WEB_APP_ROUTE_ROOT, ROUTE_SOURCE_LOADERS, collectMobileWebAppRouteKeys, - collectMobileWebAppRoutes, - routePathnameFromKey + collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs' import { MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES, MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES, + MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD, + MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN, + MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP, MOBILE_WEB_APP_SOURCE_DIRS, assertAssetCeilingFitsShell, mobileWebAppBundleMaxAssets, @@ -34,15 +37,15 @@ import { import { BINARY_SOURCE_EXTENSIONS, assertNoCarriageReturnsInSource -} from './verify-mobile-web-bundle.mjs' +} from './mobile-web-source-line-endings.mjs' +import { spelledCountsAgainstTables } from './spelled-count-census.mjs' import { - computeMobileWebBundleBuildId, hashedAsset, readDesktopVersion, readProtocolWindow, sha256Hex, writeMobileWebBundleTree -} from './build-mobile-web-bundle.mjs' +} from './mobile-web-bundle-manifest.mjs' import { MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, MOBILE_WEB_BUNDLE_MAX_ASSETS @@ -72,70 +75,24 @@ async function withScratch(run) { } } -/** - * Every page route this bundle declares, written out rather than read from the source that - * produces it: the point is to pin the list, and comparing the manifest to its own input would - * pass whatever that input became. Shared by the two assertions below, which is also what keeps - * this file under the 600-line cap. - */ -const EXPECTED_PAGE_ROUTES = [ - { pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }, - { pathname: '/h/[hostId]/agent-history/[worktreeId]', grants: ['navigate', 'storage'] }, - { - pathname: '/h/[hostId]/tasks', - grants: ['navigate', 'storage', 'externalLink', 'native.clipboard.write'] - }, - { pathname: '/h/[hostId]/files/[worktreeId]', grants: ['navigate', 'storage', 'externalLink'] }, - { - pathname: '/h/[hostId]/files/preview/[worktreeId]', - grants: ['navigate', 'storage', 'externalLink'] - } -] - -describe('the page routes the manifest declares', () => { - it('turns a route key into the URL pattern expo-router gives it', () => { - expect(routePathnameFromKey('./h/[hostId]/index.tsx')).toBe('/h/[hostId]') - expect(routePathnameFromKey('./h/[hostId]/tasks.tsx')).toBe('/h/[hostId]/tasks') - expect(routePathnameFromKey('./h/[hostId]/session/[worktreeId].tsx')).toBe( - '/h/[hostId]/session/[worktreeId]' - ) - }) +// Snapshots preserve Buffer methods and give each assertion its own mutable copy. +let appBundleSnapshot +let writtenBundleSnapshot - it('answers null for a layout, which is not a screen anyone navigates to', () => { - expect(routePathnameFromKey('./h/_layout.tsx')).toBeNull() - expect(routePathnameFromKey('./h/[hostId]/_layout.tsx')).toBeNull() - }) +async function readAppBundle() { + appBundleSnapshot ??= bundleMobileWebApp().then(serialize) + // Deserialized Buffers alias their snapshot, so copy it before exposing them. + return deserialize(Buffer.from(await appBundleSnapshot)) +} - it('declares only routes the bundle has a module for', async () => { - const keys = await collectMobileWebAppRouteKeys(appDir) - expect(resolveMobileWebPageRoutes(keys)).toEqual(EXPECTED_PAGE_ROUTES) +async function readWrittenBundle() { + writtenBundleSnapshot ??= withScratch(async (scratch) => { + const { outDir, ...result } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + return serialize({ ...result, html }) }) - - it('fails the build on a declaration the bundle cannot render', () => { - // The mismatch reaches a phone as a route the shell opens the page for and the page then - // paints as Unmatched. This is the only place whoever wrote the declaration can see it. - expect(() => - resolveMobileWebPageRoutes( - ['./h/[hostId]/index.tsx'], - [{ pathname: '/h/[hostId]/gone', grants: [] }] - ) - ).toThrow('has no module in the bundle') - }) - - itBundling( - 'reaches the built manifest, where the build id does not move for it', - async () => { - await withScratch(async (scratch) => { - const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) - expect(manifest.routes).toEqual(EXPECTED_PAGE_ROUTES) - // The routes are derived from the same tree the script is built from, so the assets - // already decide them and the id has no reason to carry them as well. - expect(manifest.buildId).toBe(computeMobileWebBundleBuildId(manifest.assets)) - }) - }, - 240_000 - ) -}) + return deserialize(await writtenBundleSnapshot) +} describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { @@ -154,8 +111,23 @@ describe('the CRLF pin', () => { }) describeBundling('the app bundle', () => { + it('isolates read-only fixture consumers from mutations in another assertion', async () => { + const first = await readAppBundle() + const original = first.script[0] + first.script[0] ^= 255 + first.chunks.length = 0 + first.routeKeys.length = 0 + const next = await readAppBundle() + expect(next.script[0]).toBe(original) + expect(next.chunks.length).toBeGreaterThan(0) + expect(next.routeKeys.length).toBeGreaterThan(0) + const written = await readWrittenBundle() + written.manifest.assets.length = 0 + expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0) + }, 120_000) + it('resolves react-native to react-native-web and leaves no require.context', async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) for (const source of sources) { expect(source).not.toContain('require.context') } @@ -164,7 +136,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { - const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + const { script, chunks, entryStaticBytes } = await readAppBundle() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. @@ -175,7 +147,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('names the chunk each route lands in', async () => { - const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + const { chunks, routeChunks, routeKeys } = await readAppBundle() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { @@ -265,7 +237,7 @@ describeBundling('the app bundle', () => { ) it('bundles every route module', async () => { - const { routeKeys } = await bundleMobileWebApp() + const { routeKeys } = await readAppBundle() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) @@ -337,7 +309,7 @@ describeBundling('the app bundle', () => { }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { - const { script, chunks } = await bundleMobileWebApp() + const { script, chunks } = await readAppBundle() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) @@ -374,8 +346,8 @@ describeBundling('the app bundle', () => { it('fails the named shim, not the whole build, when its option goes missing', async () => { const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir)) - // Each shim reads a different option, so removing one leaves the other five true. Without - // that, the list could name a shim the build stopped applying. + // Each shim reads an option of its own (two read `banner.js`), so stripping every option + // leaves none applying. Without that, the list could name a shim the build stopped applying. const stripped = { ...options, alias: {}, @@ -387,25 +359,13 @@ describeBundling('the app bundle', () => { expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([]) }) - it('keeps the shims out of the shipped Phase A bootstrap builder', async () => { - const shipped = await readFile( - join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'), - 'utf8' - ) - for (const { name } of MOBILE_WEB_APP_SHIMS) { - expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name) - } - expect(shipped).not.toContain('react-native-web') - expect(shipped).not.toContain('lucide') - }) - it('ships no haptic that reaches for the DOM', async () => { // expo-haptics' web build fakes an iOS haptic by appending a hidden // `