mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 02:31:38 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff19e2ffa | ||
|
|
dbbc70d71b | ||
|
|
03073d83e5 | ||
|
|
2dda0be909 | ||
|
|
46da4e8f5f | ||
|
|
39e884a07e | ||
|
|
129f0cc0d0 | ||
|
|
adc252ccd4 | ||
|
|
7a9baac475 | ||
|
|
20fe72de09 | ||
|
|
d7edf8bded |
No files matched your search
@@ -3,6 +3,3 @@ contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/l0ng-ai/openidea/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
@@ -103,7 +103,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
|
||||
@@ -47,7 +47,6 @@ env:
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -55,9 +54,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
|
||||
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
|
||||
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,12 +74,6 @@ jobs:
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
|
||||
- if: matrix.language == 'c' || matrix.language == 'cpp'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gdb
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
@@ -116,13 +108,6 @@ jobs:
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
|
||||
- if: matrix.language == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with: { php-version: "8.5", coverage: xdebug, tools: composer }
|
||||
- if: matrix.language == 'php'
|
||||
run: npm install --global "intelephense@$INTELEPHENSE"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
|
||||
# version, so remote development finds the server of that version. The `nightly` release holds
|
||||
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
|
||||
# nothing newer stops there. Only the latest builds are kept.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2,14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Build even if nothing is newer than the last nightly"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
if: github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- id: commit
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
|
||||
--json headSha --jq '.[0].headSha // ""')"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "::notice::no main commit has passed CI"
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { ref: "${{ steps.commit.outputs.sha }}" }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { node-version: 22 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.commit.outputs.sha != ''
|
||||
- id: release
|
||||
if: steps.commit.outputs.sha != ''
|
||||
env:
|
||||
SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
|
||||
tag="v$version"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "chore: $tag"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
|
||||
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
release_id: ${{ needs.prepare.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
KEEP: 14
|
||||
steps:
|
||||
- name: Publish the build
|
||||
run: gh release edit "$TAG" --tag "$TAG" --draft=false
|
||||
- name: Point the updater at it
|
||||
run: |
|
||||
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
|
||||
gh release view nightly >/dev/null 2>&1 ||
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
|
||||
--notes "The update manifest of the nightly builds, and the commit last built."
|
||||
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
|
||||
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
|
||||
# Failed builds' drafts go too, once they are this old.
|
||||
- name: Delete old builds
|
||||
run: |
|
||||
gh release list --limit 200 --json tagName,createdAt --jq \
|
||||
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
|
||||
while read -r tag; do
|
||||
gh release delete "$tag" --cleanup-tag --yes
|
||||
done
|
||||
@@ -22,8 +22,6 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
name: Release build
|
||||
|
||||
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
|
||||
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
release_id: { type: string, required: true }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
plugin-keys:
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
keys: ${{ steps.keys.outputs.keys }}
|
||||
steps:
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: plugin-keys
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
|
||||
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ inputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
# Release assets have no spaces: GitHub stored the first upload with dots.
|
||||
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
|
||||
cp "$dmg" "$asset"
|
||||
gh release upload "$TAG" "$asset" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
permissions:
|
||||
contents: write
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
|
||||
const nightly = tag.includes("-nightly.");
|
||||
const windows = (arch, msi, nsis) => nightly
|
||||
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
|
||||
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
// The update's notes: the release body without its Download section. That
|
||||
// section only matters on the release page, and What's new follows it.
|
||||
const { data: release } = await github.rest.repos.getRelease({ owner, repo, release_id });
|
||||
const withoutDownloadSection = (body) => {
|
||||
const kept = [];
|
||||
let skip = 0;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
if (!skip && level <= 3 && /^download$/i.test(heading[2].trim())) {
|
||||
skip = level;
|
||||
continue;
|
||||
}
|
||||
if (skip && level <= skip) skip = 0;
|
||||
}
|
||||
if (!skip) kept.push(line);
|
||||
}
|
||||
return kept.join("\n").trim();
|
||||
};
|
||||
const notes = withoutDownloadSection(String(release.body ?? ""));
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes, pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
+229
-24
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*", "!v*-nightly.*"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event.inputs.tag || github.ref_name }}
|
||||
@@ -23,12 +23,10 @@ env:
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
create-release:
|
||||
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -49,6 +47,20 @@ jobs:
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
@@ -62,26 +74,161 @@ jobs:
|
||||
core.setOutput("id", existing.id);
|
||||
return;
|
||||
}
|
||||
// Nightly builds are prereleases: the notes span stable release to stable release.
|
||||
const previous = releases.find((r) => !r.draft && !r.prerelease);
|
||||
const { data: notes } = await github.rest.repos.generateReleaseNotes({
|
||||
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
|
||||
});
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
|
||||
draft: true, prerelease: tag.includes("-"), body: notes.body,
|
||||
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
|
||||
});
|
||||
core.setOutput("id", data.id);
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
build:
|
||||
needs: create-release
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
secrets: inherit
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
@@ -93,8 +240,6 @@ jobs:
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -158,10 +303,70 @@ jobs:
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [create-release, binaries, plugins]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"plugins/themes.md",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
|
||||
@@ -21,13 +21,11 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
- `crates/ide-host` — the `openidea-server` binary for remote development and the
|
||||
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
|
||||
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
|
||||
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
|
||||
preview with `npx mint dev` in `docs/`.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
Generated
+156
-191
@@ -791,7 +791,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,6 +1578,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1598,6 +1608,20 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1607,7 +1631,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1620,10 +1644,21 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -1828,7 +1863,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2154,7 +2189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2650,7 +2685,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4145,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4169,7 +4204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4183,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4194,13 +4229,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4212,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4226,9 +4259,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -4236,7 +4268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4253,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4267,19 +4299,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jdwp",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-attach",
|
||||
"ide-jvm-inspect",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4292,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
@@ -4302,7 +4330,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracledb",
|
||||
"oracle",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4322,7 +4350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4335,11 +4363,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4356,7 +4383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4368,7 +4395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4381,7 +4408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4404,7 +4431,6 @@ dependencies = [
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-attach",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
@@ -4412,7 +4438,6 @@ dependencies = [
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-scaffold",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
@@ -4449,10 +4474,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4468,7 +4492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4488,21 +4512,9 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4517,29 +4529,9 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4595,14 +4587,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-inspect",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
@@ -4622,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4641,7 +4632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4664,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4687,7 +4678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4700,7 +4691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
@@ -4719,7 +4710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4741,25 +4732,9 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"insta",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4767,7 +4742,6 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4779,10 +4753,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
@@ -4800,7 +4773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4813,7 +4786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4834,7 +4807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4849,7 +4822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4862,7 +4835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4875,7 +4848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4896,7 +4869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4917,7 +4890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4941,7 +4914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4957,7 +4930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5157,7 +5130,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5604,9 +5577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -5774,7 +5747,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5832,7 +5805,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5940,7 +5913,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6386,15 +6359,6 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.2.2"
|
||||
@@ -6510,6 +6474,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6535,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6569,7 +6542,6 @@ dependencies = [
|
||||
"ide-profiler",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-scaffold",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
@@ -6624,24 +6596,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracledb"
|
||||
version = "26.0.0-beta.5"
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base16ct",
|
||||
"base64ct",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"pbkdf2",
|
||||
"pkcs8",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"sha2 0.11.1",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7776,15 +7753,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7852,7 +7829,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8508,7 +8485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8648,7 +8625,8 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8660,7 +8638,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8682,7 +8661,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -8695,7 +8674,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8707,7 +8687,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8718,7 +8699,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -8731,7 +8712,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8756,7 +8738,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8790,7 +8773,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
@@ -8928,6 +8912,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8964,6 +8954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9186,7 +9177,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -9548,10 +9539,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10083,7 +10074,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10505,7 +10496,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10726,15 +10717,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -10744,15 +10726,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11332,13 +11305,6 @@ name = "whoami"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win_uds"
|
||||
@@ -11373,7 +11339,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11394,7 +11360,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
@@ -11812,7 +11778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12174,15 +12140,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.2.3"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00fb4744e923442ecadfd3f85f51a5d24e03bf43d53813b81ebb4c91d02baa62"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap 2.14.2",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix 0.31.3",
|
||||
"typed-path",
|
||||
|
||||
+5
-13
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -43,14 +43,10 @@ ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-mcp = { path = "crates/ide-mcp" }
|
||||
ide-scaffold = { path = "crates/ide-scaffold" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
ide-jvm-attach = { path = "crates/ide-jvm-attach" }
|
||||
ide-jvm-inspect = { path = "crates/ide-jvm-inspect" }
|
||||
ide-jdwp = { path = "crates/ide-jdwp" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -86,7 +82,7 @@ trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
dirs = "6"
|
||||
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
@@ -107,11 +103,9 @@ portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
@@ -137,9 +131,7 @@ debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
|
||||
# (Linux arm64 was killed mid-link from v0.1.39 on).
|
||||
lto = "thin"
|
||||
lto = true
|
||||
opt-level = 3
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh">Docs</a>
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
@@ -27,7 +25,9 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
|
||||
its search-everything habits — to a native app with a Rust core.
|
||||
|
||||
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
|
||||
for 60+ languages. Builds for macOS, Windows and Linux.
|
||||
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
|
||||
rather skip the installer, each release also ships a portable zip — unpack it
|
||||
and run `openidea.exe`.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -56,7 +56,7 @@ themes, file icons and languages. They run as sandboxed WebAssembly components;
|
||||
themes, icon themes and languages need no code at all.
|
||||
|
||||
Install them from Settings › Plugins, or start your own with
|
||||
`openidea-plugin new` — see [Writing OpenIDEA plugins](https://openidea.sh/en/plugins) and
|
||||
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
|
||||
the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
## Community
|
||||
@@ -64,9 +64,13 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
|
||||
- WeChat group — scan to join:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security problems in public issues, pull requests or discussions.
|
||||
|
||||
Report them privately through GitHub:
|
||||
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
|
||||
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
|
||||
the report.
|
||||
|
||||
Include what you can of:
|
||||
|
||||
- the affected version, platform, and whether remote development (`openidea-server`)
|
||||
or plugins are involved;
|
||||
- steps to reproduce, or a proof of concept;
|
||||
- the impact you expect, and any fix you suggest.
|
||||
|
||||
We will acknowledge the report, keep you updated in the advisory, and agree on a
|
||||
disclosure date with you. Fixes are released as a new version, and the advisory is
|
||||
published once users can update.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Please check that the problem still occurs
|
||||
there before reporting.
|
||||
+2
-12
@@ -1,11 +1,5 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Project type icons
|
||||
|
||||
`src/ui/brands/*.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
Iconify (`simple-icons:angular`, `simple-icons:astro`, `simple-icons:cplusplus`, `simple-icons:dart`, `simple-icons:django`, `simple-icons:dotnet`, `simple-icons:elixir`, `simple-icons:express`, `simple-icons:fastapi`, `simple-icons:flask`, `simple-icons:flutter`, `simple-icons:go`, `simple-icons:haskell`, `simple-icons:html5`, `simple-icons:kotlin`, `simple-icons:ktor`, `simple-icons:laravel`, `simple-icons:nestjs`, `simple-icons:nextdotjs`, `simple-icons:nodedotjs`, `simple-icons:nuxt`, `simple-icons:openjdk`, `simple-icons:phoenixframework`, `simple-icons:php`, `simple-icons:python`, `simple-icons:quarkus`, `simple-icons:ruby`, `simple-icons:rubyonrails`, `simple-icons:rust`, `simple-icons:scala`, `simple-icons:springboot`, `simple-icons:svelte`, `simple-icons:swift`, `simple-icons:symfony`, `simple-icons:vite`, `simple-icons:zig`) and filled with the brand colors (the standard icon grey for black logos). Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
@@ -268,7 +262,7 @@ END OF TERMS AND CONDITIONS
|
||||
|
||||
The Solarized palettes in `src/styles/tokens.css` are adapted from
|
||||
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
|
||||
MIT license:
|
||||
See [theme sources and mapping](docs/themes.md). MIT license:
|
||||
|
||||
```text
|
||||
Copyright (c) 2011 Ethan Schoonover
|
||||
@@ -296,11 +290,7 @@ THE SOFTWARE.
|
||||
|
||||
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
|
||||
adapted from GitHub’s Atom One syntax and UI themes.
|
||||
Source revisions:
|
||||
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
|
||||
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
|
||||
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
|
||||
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
|
||||
Source revisions are listed in [theme sources and mapping](docs/themes.md).
|
||||
Both syntax themes use the following MIT notice:
|
||||
|
||||
```text
|
||||
|
||||
@@ -164,11 +164,6 @@ impl Negotiated {
|
||||
self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// `mcpCapabilities.<name>` (`http`, `sse`).
|
||||
pub fn mcp(&self, name: &str) -> bool {
|
||||
self.capabilities.pointer(&format!("/mcpCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// `sessionCapabilities.<name>` (`fork`, `resume`, `close`, `list`, `delete`).
|
||||
pub fn session(&self, name: &str) -> bool {
|
||||
self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null())
|
||||
@@ -178,17 +173,6 @@ impl Negotiated {
|
||||
pub fn meta(&self, pointer: &str) -> Option<&Value> {
|
||||
self.capabilities.get("_meta")?.pointer(pointer)
|
||||
}
|
||||
|
||||
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
|
||||
pub fn agent_auth_method(&self) -> Option<&str> {
|
||||
self.auth_methods.iter().find_map(|m| {
|
||||
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
|
||||
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
|
||||
.then(|| m.get("id").and_then(Value::as_str))
|
||||
.flatten()
|
||||
.filter(|id| !id.is_empty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
|
||||
|
||||
@@ -135,36 +135,6 @@ fn the_shape_decides_the_version_not_the_number() {
|
||||
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_non_terminal_agent_auth_method() {
|
||||
let v1 = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 1,
|
||||
"authMethods": [
|
||||
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
|
||||
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
|
||||
{ "id": "api_key", "type": "env_var" },
|
||||
{ "id": "github_oauth", "name": "GitHub OAuth" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
|
||||
|
||||
let v2 = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 2,
|
||||
"info": { "name": "x", "version": "1" },
|
||||
"authMethods": [
|
||||
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
|
||||
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(v2.agent_auth_method(), Some("oauth"));
|
||||
|
||||
let terminal_only = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 1,
|
||||
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
|
||||
}));
|
||||
assert_eq!(terminal_only.agent_auth_method(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_and_auth_methods_pass_through() {
|
||||
// The native shims advertise their extensions in `agentCapabilities._meta`.
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
|
||||
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A session on this machine that no task holds yet.
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalSession {
|
||||
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
|
||||
pub provider: String,
|
||||
/// The id resuming it names (`--resume`, `thread/resume`).
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
/// Where it ran, when the transcript names one.
|
||||
pub cwd: Option<String>,
|
||||
/// File modification time, ms since the epoch.
|
||||
pub updated_ms: f64,
|
||||
}
|
||||
|
||||
/// Modification time of `path` in ms, else 0.
|
||||
fn mtime(path: &Path) -> f64 {
|
||||
path.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as f64)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
|
||||
fn short(text: &str) -> String {
|
||||
const MAX_TITLE: usize = 80;
|
||||
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.chars().count() <= MAX_TITLE {
|
||||
flat
|
||||
} else {
|
||||
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a Claude Code transcript entry's message, when it carries prose.
|
||||
fn message_text(e: &serde_json::Value) -> Option<String> {
|
||||
match e.pointer("/message/content") {
|
||||
Some(serde_json::Value::String(s)) => Some(s.clone()),
|
||||
Some(serde_json::Value::Array(blocks)) => {
|
||||
let text: Vec<&str> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
|
||||
.collect();
|
||||
(!text.is_empty()).then(|| text.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
|
||||
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
let projects = config_dir.join("projects");
|
||||
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
|
||||
for dir in dirs {
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|d| d.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
|
||||
.collect();
|
||||
for path in files {
|
||||
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entries = history::read(&path).unwrap_or_default();
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = history::title(&entries)
|
||||
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
|
||||
.map(|t| short(&t))
|
||||
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
|
||||
let cwd = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
|
||||
.next()
|
||||
.map(str::to_owned);
|
||||
out.push(ExternalSession {
|
||||
provider: "claude".to_owned(),
|
||||
session_id: id,
|
||||
title,
|
||||
cwd,
|
||||
updated_ms: mtime(&path),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
|
||||
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
|
||||
let payload =
|
||||
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
|
||||
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
|
||||
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
|
||||
})?;
|
||||
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
|
||||
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
|
||||
Some((id, cwd))
|
||||
}
|
||||
|
||||
/// A prose line of a Codex rollout file (a user message), across shapes.
|
||||
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
|
||||
let payload = line.get("payload")?;
|
||||
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
|
||||
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
|
||||
return None;
|
||||
}
|
||||
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
|
||||
for v in [payload, inner] {
|
||||
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One rollout file's session, or `None` when it names no `session_meta`.
|
||||
fn codex_file(path: &Path) -> Option<ExternalSession> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let mut id: Option<(String, Option<String>)> = None;
|
||||
let mut title: Option<String> = None;
|
||||
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
|
||||
for line in text.lines().take(400) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
if id.is_none()
|
||||
&& let Some(meta) = codex_meta(&v)
|
||||
{
|
||||
id = Some(meta);
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(t) = codex_user_text(&v)
|
||||
{
|
||||
title = Some(short(&t));
|
||||
}
|
||||
if id.is_some() && title.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (session_id, cwd) = id?;
|
||||
Some(ExternalSession {
|
||||
provider: "codex".to_owned(),
|
||||
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
|
||||
session_id,
|
||||
cwd,
|
||||
updated_ms: mtime(path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
|
||||
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rollout_files(&path, depth - 1, out);
|
||||
} else if path.extension().is_some_and(|e| e == "jsonl") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions: `<home>/.codex/sessions/**`.
|
||||
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn write(path: &Path, lines: &[serde_json::Value]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
for line in lines {
|
||||
writeln!(f, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
|
||||
json!({"type": "user", "uuid": uuid, "parentUuid": null,
|
||||
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_claude_transcripts_with_titles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("projects/-work-app/abc123.jsonl"),
|
||||
&[
|
||||
claude_user("u1", "fix the login bug"),
|
||||
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
|
||||
"message": {"role": "assistant", "content": []}}),
|
||||
json!({"type": "ai-title", "aiTitle": "Login fix"}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
|
||||
assert_eq!(s.title, "Login fix");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
assert!(s.updated_ms > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_title_falls_back_to_the_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].title, "hello there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_codex_rollouts_with_meta() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
|
||||
&[
|
||||
json!({"timestamp": "t", "payload": {"type": "session_meta",
|
||||
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
|
||||
json!({"timestamp": "t", "payload": {"type": "user_message",
|
||||
"payload": {"message": "add tests"}}}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
|
||||
let sessions = codex_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directories_list_nothing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
|
||||
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
|
||||
}
|
||||
}
|
||||
+52
-419
@@ -10,8 +10,6 @@
|
||||
mod acp;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod external;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
mod native;
|
||||
mod provider;
|
||||
@@ -20,7 +18,6 @@ mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
|
||||
pub use external::ExternalSession;
|
||||
pub use model::*;
|
||||
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
@@ -94,9 +91,6 @@ type Connector = Arc<
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Returns the configured MCP servers (User and Workspace) for this project.
|
||||
pub type McpServersFn = dyn Fn() -> Vec<McpServer> + Send + Sync;
|
||||
|
||||
struct Shared {
|
||||
paths: AgentPaths,
|
||||
runtime: tokio::runtime::Handle,
|
||||
@@ -106,11 +100,10 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
/// One pre-session command lookup per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
|
||||
}
|
||||
|
||||
/// What an agent offered in its last session, for the New Task page.
|
||||
@@ -138,13 +131,6 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_servers_for_session(&self, provider: &str, init: &acp::Negotiated) -> Vec<Value> {
|
||||
let mut servers = mcp_servers_for(init.mcp_stdio(), &self.paths.project_root);
|
||||
let configured = self.mcp_servers_hook.read().as_ref().map(|hook| hook()).unwrap_or_default();
|
||||
servers.extend(mcp::acp_mcp_servers(&configured, provider, init));
|
||||
servers
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
@@ -167,7 +153,7 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
@@ -177,25 +163,14 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(found) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
if let Ok(commands) =
|
||||
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
let levels: Vec<_> = found
|
||||
.efforts
|
||||
.into_iter()
|
||||
.map(|(model, o)| {
|
||||
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
|
||||
(model, options.unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands, &levels);
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -212,22 +187,6 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the thinking levels of every model the agent offers are known (#1686).
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| &o.values)
|
||||
.all(|v| known.thought_levels.contains_key(&v.value))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -240,78 +199,41 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
self.remember_inner(provider, config, commands, model, &[], false);
|
||||
}
|
||||
|
||||
/// `levels`: each model's thinking-level options, so a new task offers them for any model
|
||||
/// it picks (#1686). A session's report for a model wins over these.
|
||||
fn remember_discovered(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: &[AgentConfigOption],
|
||||
commands: &[AgentCommand],
|
||||
levels: &[(String, Vec<AgentConfigOption>)],
|
||||
) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered_levels: &[(String, Vec<AgentConfigOption>)],
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
for (model, levels) in discovered_levels {
|
||||
if !known.thought_levels.contains_key(model) {
|
||||
known.thought_levels.insert(model.clone(), levels.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
let snapshot = {
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
}
|
||||
// The cached provider config actually changed (a session report, discovery,
|
||||
// or a turn update): tell subscribers to re-list, so a changed model list
|
||||
// reaches the composer without waiting for the next task (#1490). Unchanged
|
||||
// reports stay silent, so routine session traffic emits nothing. The cache
|
||||
// lock is released first; the sink never runs under it.
|
||||
drop(cache);
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,9 +249,6 @@ struct TaskFile {
|
||||
/// In the project's own checkout: files already changed when the task started.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
before: BTreeMap<String, worktree::Stamp>,
|
||||
/// The folder (in the task store) with copies of the `before` files, for rolling back.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
saved: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
subagents: Vec<SavedSubagent>,
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
@@ -346,7 +265,6 @@ struct TaskState {
|
||||
conversation: Conversation,
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
saved: Option<String>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
@@ -369,7 +287,7 @@ struct Live {
|
||||
embedded: bool,
|
||||
/// `session/load`: the process can be stopped between turns and the session reloaded.
|
||||
resumable: bool,
|
||||
init: acp::Negotiated,
|
||||
mcp_stdio: bool,
|
||||
}
|
||||
|
||||
struct Task {
|
||||
@@ -399,8 +317,6 @@ struct Task {
|
||||
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
|
||||
config_sent: Mutex<HashSet<(String, String)>>,
|
||||
dirty: Mutex<BTreeSet<(Session, usize)>>,
|
||||
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
|
||||
streamed: Mutex<HashMap<Session, Sent>>,
|
||||
flush_scheduled: AtomicBool,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
/// Set by `delete`; held while saving.
|
||||
@@ -439,7 +355,6 @@ impl AgentManager {
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
mcp_servers_hook: RwLock::new(None),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
|
||||
@@ -480,11 +395,8 @@ impl AgentManager {
|
||||
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
|
||||
pub fn providers(&self) -> Vec<AgentProvider> {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let in_use: std::collections::HashSet<String> =
|
||||
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
|
||||
let list = specs
|
||||
.iter()
|
||||
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
|
||||
.map(|p| {
|
||||
let mut info = provider::describe(p);
|
||||
info.config = self.shared.known_config(&p.id);
|
||||
@@ -530,11 +442,6 @@ impl AgentManager {
|
||||
self.shared.emit(AgentEvent::Providers);
|
||||
}
|
||||
|
||||
/// Provides the user and workspace MCP servers configured in settings.
|
||||
pub fn set_mcp_servers_hook(&self, hook: Box<McpServersFn>) {
|
||||
*self.shared.mcp_servers_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
|
||||
/// in the project: what the agent offered last time there, unadvertised builtins,
|
||||
/// and, for Claude Code, skills and custom commands on disk it has not listed.
|
||||
@@ -551,10 +458,10 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
|
||||
&& offered.is_empty()
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
self.shared.fetch_commands(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
@@ -587,91 +494,6 @@ impl AgentManager {
|
||||
Ok(timeline.entries.clone())
|
||||
}
|
||||
|
||||
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
|
||||
fn imported_session_ids(&self) -> HashSet<String> {
|
||||
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(&self) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(spec) = self.shared.spec("claude")
|
||||
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
|
||||
{
|
||||
out.extend(external::claude_sessions(&dir));
|
||||
}
|
||||
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
|
||||
if let Some(home) = home {
|
||||
out.extend(external::codex_sessions(&home.join(".codex")));
|
||||
}
|
||||
let known = self.imported_session_ids();
|
||||
out.retain(|s| !known.contains(&s.session_id));
|
||||
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
|
||||
out.truncate(50);
|
||||
out
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
|
||||
if !matches!(provider, "claude" | "codex") {
|
||||
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
|
||||
}
|
||||
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
|
||||
if self.imported_session_ids().contains(session_id) {
|
||||
return Err(IdeError::invalid("that session is already a task"));
|
||||
}
|
||||
let found = self
|
||||
.external_sessions()
|
||||
.into_iter()
|
||||
.find(|s| s.provider == provider && s.session_id == session_id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
|
||||
let config = self.shared.known_config(&spec.id);
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let task = AgentTask {
|
||||
project_root: Some(IpcPath(root)),
|
||||
id,
|
||||
title: found.title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
status: AgentStatus::Ready,
|
||||
isolation: Isolation::Local,
|
||||
cwd: IpcPath(cwd),
|
||||
branch: None,
|
||||
base: None,
|
||||
base_commit: None,
|
||||
error: None,
|
||||
created_ms: now,
|
||||
updated_ms: now,
|
||||
config,
|
||||
commands: self.shared.known_commands(&spec.id),
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: Some(session_id.to_owned()),
|
||||
entries: Vec::new(),
|
||||
wanted: Vec::new(),
|
||||
before: BTreeMap::new(),
|
||||
saved: None,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: true,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
|
||||
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
let spec = self
|
||||
@@ -724,24 +546,9 @@ impl AgentManager {
|
||||
}));
|
||||
}
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
// Copies of the files the user had changed already, for rolling back the task's changes to them.
|
||||
let saved = if before.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let name = format!("{id}.before");
|
||||
let (dir, at, files) = (self.shared.paths.store_dir.join(&name), cwd.clone(), before.clone());
|
||||
match blocking(move || worktree::save_before(&at, &files, &dir)).await {
|
||||
Ok(saved) => saved.then_some(name),
|
||||
Err(e) => {
|
||||
tracing::warn!("saving the changed files of agent task {id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let task = AgentTask {
|
||||
project_root,
|
||||
id,
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
@@ -769,7 +576,6 @@ impl AgentManager {
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
@@ -937,16 +743,10 @@ impl AgentManager {
|
||||
return Err(IdeError::invalid("this agent can't copy a conversation from there"));
|
||||
}
|
||||
let cwd = source.state.lock().task.cwd.0.clone();
|
||||
let provider_id = source.state.lock().task.provider.clone();
|
||||
let mcp_servers = source.shared.mcp_servers_for_session(&provider_id, &live.init);
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
let reply: Value = live
|
||||
.peer
|
||||
.request(
|
||||
"session/fork",
|
||||
json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers }),
|
||||
None,
|
||||
)
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(source.describe(e)))?;
|
||||
let session = reply
|
||||
@@ -980,7 +780,6 @@ impl AgentManager {
|
||||
entries,
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1025,7 +824,6 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
|
||||
task.emit_task();
|
||||
@@ -1051,7 +849,6 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
@@ -1244,12 +1041,6 @@ impl AgentManager {
|
||||
self.tasks.write().remove(id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
|
||||
let saved = task.state.lock().saved.clone();
|
||||
if let Some(saved) = saved
|
||||
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
|
||||
}
|
||||
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1268,41 +1059,6 @@ impl AgentManager {
|
||||
blocking(move || worktree::file_at(&cwd, &path, &base)).await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
/// were when it started (files the user had changed then, as the user left them).
|
||||
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let (before, saved) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
|
||||
let mut restamped = Vec::new();
|
||||
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
|
||||
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
|
||||
// Back to the user's own edits: not a change of the task any more.
|
||||
if before.contains_key(&change.rel_path) {
|
||||
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
|
||||
}
|
||||
}
|
||||
Ok(restamped)
|
||||
})
|
||||
.await?;
|
||||
if !restamped.is_empty() {
|
||||
let mut s = task.state.lock();
|
||||
let mut before = (*s.before).clone();
|
||||
before.extend(restamped);
|
||||
s.before = Arc::new(before);
|
||||
}
|
||||
task.persist();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops every agent (project closed).
|
||||
pub fn shutdown(&self) {
|
||||
for task in self.tasks.read().values() {
|
||||
@@ -1328,7 +1084,6 @@ impl Task {
|
||||
conversation,
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
saved: file.saved,
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
@@ -1346,7 +1101,6 @@ impl Task {
|
||||
idle_epoch: AtomicU64::new(0),
|
||||
config_sent: Mutex::new(HashSet::new()),
|
||||
dirty: Mutex::new(BTreeSet::new()),
|
||||
streamed: Mutex::new(HashMap::new()),
|
||||
flush_scheduled: AtomicBool::new(false),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
deleted: Mutex::new(false),
|
||||
@@ -1441,7 +1195,6 @@ impl Task {
|
||||
entries: s.conversation.main.entries.clone(),
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1469,12 +1222,11 @@ impl Task {
|
||||
let indices = std::mem::take(&mut *self.dirty.lock());
|
||||
let events: Vec<AgentEvent> = {
|
||||
let s = self.state.lock();
|
||||
let mut streamed = self.streamed.lock();
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|(subagent, i)| {
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
|
||||
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -1710,30 +1462,6 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
|
||||
async fn request_with_auth(
|
||||
&self,
|
||||
peer: &acp::Conn,
|
||||
init: &acp::Negotiated,
|
||||
tried_auth: &mut bool,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, RpcError> {
|
||||
let first = peer.request(method, params.clone(), None).await;
|
||||
if !*tried_auth
|
||||
&& let Err(RpcError::Server { code, ref message, .. }) = first
|
||||
&& acp::auth_required(code, message)
|
||||
&& let Some(method_id) = init.agent_auth_method()
|
||||
{
|
||||
*tried_auth = true;
|
||||
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
|
||||
Ok(_) => return peer.request(method, params, None).await,
|
||||
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
let _guard = self.connecting.lock().await;
|
||||
if let Some(live) = self.live.lock().clone()
|
||||
@@ -1771,17 +1499,16 @@ impl Task {
|
||||
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
|
||||
let sign_in_message = spec.sign_in_message(&init.auth_methods);
|
||||
let can_load = init.load_session();
|
||||
let mcp_stdio = init.mcp_stdio();
|
||||
let (capabilities, embedded) = capabilities_of(&init);
|
||||
let mcp_servers = self.shared.mcp_servers_for_session(&provider_id, &init);
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
|
||||
let mut tried_auth = false;
|
||||
let mut session = None;
|
||||
if let (Some(id), true) = (saved, can_load) {
|
||||
self.replaying.store(true, Ordering::Release);
|
||||
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
|
||||
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
|
||||
let loaded: Result<Value, RpcError> =
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1793,9 +1520,12 @@ impl Task {
|
||||
let (session_id, result) = match session {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
|
||||
let v = self
|
||||
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
|
||||
let v: Value = peer
|
||||
.request(
|
||||
"session/new",
|
||||
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
@@ -1887,7 +1617,7 @@ impl Task {
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live =
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, init };
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1958,7 +1688,7 @@ impl Task {
|
||||
format!("The agent process exited:\n{}", tail.join("\n"))
|
||||
}
|
||||
}
|
||||
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
|
||||
RpcError::Server { message, .. } => message,
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2416,24 +2146,6 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
|
||||
.then_some(None)
|
||||
}
|
||||
|
||||
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
|
||||
/// generic codes such as "Internal error" say nothing without it.
|
||||
fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
let details = match data {
|
||||
Some(Value::String(text)) => text.as_str(),
|
||||
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
|
||||
None => "",
|
||||
}
|
||||
.trim();
|
||||
if details.is_empty() || message.contains(details) {
|
||||
message
|
||||
} else if message.trim().is_empty() {
|
||||
details.to_owned()
|
||||
} else {
|
||||
format!("{message}: {details}")
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
@@ -2541,11 +2253,6 @@ fn now_ms() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
||||
}
|
||||
|
||||
/// The user's home, for tools that keep state there (Codex sessions).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
@@ -2581,77 +2288,3 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
|
||||
let command = ide_mcp::stdio_command(executable, root);
|
||||
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
|
||||
}
|
||||
|
||||
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
|
||||
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
|
||||
/// that went on from one that was replaced without comparing (or keeping) all of it.
|
||||
struct Sent {
|
||||
index: usize,
|
||||
bytes: usize,
|
||||
utf16: usize,
|
||||
tail: String,
|
||||
}
|
||||
|
||||
const SENT_TAIL: usize = 64;
|
||||
|
||||
impl Sent {
|
||||
fn new(index: usize, text: &str) -> Self {
|
||||
Self::after(index, text, 0, 0)
|
||||
}
|
||||
|
||||
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
|
||||
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
|
||||
let mut start = text.len().saturating_sub(SENT_TAIL);
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let utf16 = utf16 + text[bytes..].encode_utf16().count();
|
||||
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
|
||||
}
|
||||
|
||||
/// Whether `text` at `index` is this message, gone on.
|
||||
fn extended_by(&self, index: usize, text: &str) -> bool {
|
||||
self.index == index
|
||||
&& text.len() > self.bytes
|
||||
&& text.is_char_boundary(self.bytes)
|
||||
&& text[..self.bytes].ends_with(&self.tail)
|
||||
}
|
||||
}
|
||||
|
||||
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
|
||||
/// it extends the message last sent for the session, else the whole entry.
|
||||
fn entry_event(
|
||||
task: &str,
|
||||
streamed: &mut HashMap<Session, Sent>,
|
||||
subagent: Session,
|
||||
i: usize,
|
||||
entry: &AgentEntry,
|
||||
) -> AgentEvent {
|
||||
let text = match entry {
|
||||
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
|
||||
_ => None,
|
||||
};
|
||||
let Some(text) = text else {
|
||||
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
|
||||
streamed.remove(&subagent);
|
||||
}
|
||||
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
|
||||
};
|
||||
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
|
||||
Some(sent) => {
|
||||
let (bytes, from) = (sent.bytes, sent.utf16);
|
||||
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
|
||||
AgentEvent::Append {
|
||||
task: task.to_owned(),
|
||||
subagent,
|
||||
index: i as u32,
|
||||
from: from as u32,
|
||||
text: text[bytes..].to_owned(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
streamed.insert(subagent.clone(), Sent::new(i, text));
|
||||
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
//! Model Context Protocol (MCP) server configurations stored in OpenIDEA's own settings
|
||||
//! (global `settings.toml` and project `.openidea/settings.toml`), passed to ACP agents in
|
||||
//! `session/new`, `session/load`, and `session/fork`, plus one-way import from `.mcp.json` /
|
||||
//! `.vscode/mcp.json`.
|
||||
|
||||
use crate::acp::Negotiated;
|
||||
use crate::{AgentMcpServer, McpKeyValue, McpScope, McpServer, McpServerInput, McpTransport};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
/// Stable ID for a server in a given scope (`"user::<name>"` or `"workspace::<name>"`).
|
||||
pub fn server_id(scope: McpScope, name: &str) -> String {
|
||||
let prefix = match scope {
|
||||
McpScope::User => "user",
|
||||
McpScope::Workspace => "workspace",
|
||||
};
|
||||
format!("{prefix}::{name}")
|
||||
}
|
||||
|
||||
/// Parses a server ID (`"user::<name>"` or `"workspace::<name>"`).
|
||||
pub fn parse_server_id(id: &str) -> Option<(McpScope, &str)> {
|
||||
let (prefix, name) = id.split_once("::")?;
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match prefix {
|
||||
"user" => Some((McpScope::User, name)),
|
||||
"workspace" => Some((McpScope::Workspace, name)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the User (`settings.toml`) and Workspace (`.openidea/settings.toml`) MCP server maps
|
||||
/// into the ordered list displayed in Settings › Agents › MCP Servers.
|
||||
pub fn servers_from_settings(
|
||||
user: &BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &BTreeMap<String, AgentMcpServer>,
|
||||
) -> Vec<McpServer> {
|
||||
user.iter()
|
||||
.map(|(name, cfg)| to_mcp_server(McpScope::User, name, cfg))
|
||||
.chain(workspace.iter().map(|(name, cfg)| to_mcp_server(McpScope::Workspace, name, cfg)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_mcp_server(scope: McpScope, name: &str, cfg: &AgentMcpServer) -> McpServer {
|
||||
McpServer {
|
||||
id: server_id(scope, name),
|
||||
name: name.to_owned(),
|
||||
scope,
|
||||
transport: cfg.transport,
|
||||
enabled: cfg.enabled,
|
||||
command: cfg.command.clone(),
|
||||
args: cfg.args.clone(),
|
||||
env: cfg.env.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
|
||||
url: cfg.url.clone(),
|
||||
headers: cfg.headers.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
|
||||
agents: cfg.agents.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider(id: &str) -> &str {
|
||||
id.strip_suffix("-acp").unwrap_or(id)
|
||||
}
|
||||
|
||||
fn matches_agent(agents: &[String], provider: &str) -> bool {
|
||||
if agents.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let target = normalize_provider(provider);
|
||||
agents.iter().any(|a| {
|
||||
let trimmed = a.trim();
|
||||
trimmed == provider || normalize_provider(trimmed) == target
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the ACP `mcpServers` array for `session/new`, `session/load`, and `session/fork`.
|
||||
/// Workspace entries take precedence over User entries with the same name.
|
||||
pub(crate) fn acp_mcp_servers(all: &[McpServer], provider: &str, init: &Negotiated) -> Vec<Value> {
|
||||
let supports_stdio = init.mcp_stdio();
|
||||
let supports_http = init.mcp("http");
|
||||
let supports_sse = init.mcp("sse");
|
||||
let mut seen = HashSet::new();
|
||||
let mut servers = Vec::new();
|
||||
|
||||
let ordered =
|
||||
all.iter().filter(|s| s.scope == McpScope::Workspace).chain(all.iter().filter(|s| s.scope == McpScope::User));
|
||||
|
||||
for srv in ordered {
|
||||
if !srv.enabled || !matches_agent(&srv.agents, provider) || !seen.insert(srv.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
match srv.transport {
|
||||
McpTransport::Stdio if supports_stdio => {
|
||||
let Some(cmd) = srv.command.as_deref().filter(|c| !c.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let env: Vec<Value> = srv.env.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"name": srv.name,
|
||||
"command": cmd,
|
||||
"args": srv.args,
|
||||
"env": env,
|
||||
}));
|
||||
}
|
||||
McpTransport::Http if supports_http => {
|
||||
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let headers: Vec<Value> =
|
||||
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"type": "http",
|
||||
"name": srv.name,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
}));
|
||||
}
|
||||
McpTransport::Sse if supports_sse => {
|
||||
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let headers: Vec<Value> =
|
||||
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"type": "sse",
|
||||
"name": srv.name,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
servers
|
||||
}
|
||||
|
||||
/// Applies a create or update operation to the User / Workspace MCP server maps.
|
||||
pub fn apply_save(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
has_workspace: bool,
|
||||
input: McpServerInput,
|
||||
) -> Result<(), String> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Server name is required".into());
|
||||
}
|
||||
if input.scope == McpScope::Workspace && !has_workspace {
|
||||
return Err("Open a project to configure workspace MCP servers".into());
|
||||
}
|
||||
let cfg = match input.transport {
|
||||
McpTransport::Stdio => {
|
||||
let cmd = input.command.as_deref().map(str::trim).unwrap_or_default();
|
||||
if cmd.is_empty() {
|
||||
return Err("Command is required for stdio MCP servers".into());
|
||||
}
|
||||
let env = input
|
||||
.env
|
||||
.into_iter()
|
||||
.filter_map(|kv| {
|
||||
let k = kv.name.trim();
|
||||
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
|
||||
})
|
||||
.collect();
|
||||
AgentMcpServer {
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: input.enabled,
|
||||
command: Some(cmd.to_owned()),
|
||||
args: input.args,
|
||||
env,
|
||||
url: None,
|
||||
headers: BTreeMap::new(),
|
||||
agents: normalize_agents_list(input.agents),
|
||||
}
|
||||
}
|
||||
McpTransport::Http | McpTransport::Sse => {
|
||||
let url = input.url.as_deref().map(str::trim).unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
return Err("URL is required for HTTP/SSE MCP servers".into());
|
||||
}
|
||||
let headers = input
|
||||
.headers
|
||||
.into_iter()
|
||||
.filter_map(|kv| {
|
||||
let k = kv.name.trim();
|
||||
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
|
||||
})
|
||||
.collect();
|
||||
AgentMcpServer {
|
||||
transport: input.transport,
|
||||
enabled: input.enabled,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
url: Some(url.to_owned()),
|
||||
headers,
|
||||
agents: normalize_agents_list(input.agents),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(prev_id) = input.id.as_deref()
|
||||
&& let Some((prev_scope, prev_name)) = parse_server_id(prev_id)
|
||||
&& (prev_scope != input.scope || prev_name != name)
|
||||
{
|
||||
match prev_scope {
|
||||
McpScope::User => {
|
||||
user.remove(prev_name);
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
workspace.remove(prev_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match input.scope {
|
||||
McpScope::User => {
|
||||
user.insert(name.to_owned(), cfg);
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
workspace.insert(name.to_owned(), cfg);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enables or disables a User or Workspace MCP server by ID.
|
||||
pub fn apply_set_enabled(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<McpScope, String> {
|
||||
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
|
||||
let map = match scope {
|
||||
McpScope::User => user,
|
||||
McpScope::Workspace => workspace,
|
||||
};
|
||||
let srv = map.get_mut(name).ok_or_else(|| format!("MCP server `{name}` not found"))?;
|
||||
srv.enabled = enabled;
|
||||
Ok(scope)
|
||||
}
|
||||
|
||||
/// Removes a User or Workspace MCP server by ID.
|
||||
pub fn apply_remove(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
id: &str,
|
||||
) -> Result<McpScope, String> {
|
||||
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
|
||||
let map = match scope {
|
||||
McpScope::User => user,
|
||||
McpScope::Workspace => workspace,
|
||||
};
|
||||
if map.remove(name).is_none() {
|
||||
return Err(format!("MCP server `{name}` not found"));
|
||||
}
|
||||
Ok(scope)
|
||||
}
|
||||
|
||||
/// Imports MCP servers from `<workspace_root>/.mcp.json` and `<workspace_root>/.vscode/mcp.json`
|
||||
/// into `workspace` without modifying the external files. Returns the number of newly added servers.
|
||||
pub fn apply_import(workspace: &mut BTreeMap<String, AgentMcpServer>, workspace_root: &Path) -> usize {
|
||||
let mut added = 0;
|
||||
for rel in [".mcp.json", ".vscode/mcp.json"] {
|
||||
let path = workspace_root.join(rel);
|
||||
for (name, srv) in read_external_file(&path) {
|
||||
if let std::collections::btree_map::Entry::Vacant(e) = workspace.entry(name) {
|
||||
e.insert(srv);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
fn normalize_agents_list(agents: Vec<String>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for a in agents {
|
||||
let trimmed = a.trim();
|
||||
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
|
||||
out.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_external_file(path: &Path) -> BTreeMap<String, AgentMcpServer> {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Ok(root) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Some(servers_obj) = root.get("mcpServers").or_else(|| root.get("servers")).and_then(Value::as_object) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, def) in servers_obj {
|
||||
let name = name.trim();
|
||||
let Some(obj) = def.as_object().filter(|_| !name.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let disabled = obj.get("disabled").and_then(Value::as_bool).unwrap_or(false)
|
||||
|| obj.get("enabled").and_then(Value::as_bool) == Some(false);
|
||||
let type_str = obj.get("type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
||||
let url = obj
|
||||
.get("url")
|
||||
.or_else(|| obj.get("serverUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
let command =
|
||||
obj.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
|
||||
let transport = match type_str.as_str() {
|
||||
"sse" => McpTransport::Sse,
|
||||
"http" | "streamable-http" | "streamable_http" => McpTransport::Http,
|
||||
"stdio" | "local" => McpTransport::Stdio,
|
||||
_ if url.is_some() && command.is_none() => McpTransport::Http,
|
||||
_ => McpTransport::Stdio,
|
||||
};
|
||||
|
||||
match transport {
|
||||
McpTransport::Stdio if command.is_none() => continue,
|
||||
McpTransport::Http | McpTransport::Sse if url.is_none() => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let args = obj
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
out.insert(
|
||||
name.to_owned(),
|
||||
AgentMcpServer {
|
||||
transport,
|
||||
enabled: !disabled,
|
||||
command,
|
||||
args,
|
||||
env: parse_key_values_map(obj.get("env")),
|
||||
url,
|
||||
headers: parse_key_values_map(obj.get("headers")),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_key_values_map(val: Option<&Value>) -> BTreeMap<String, String> {
|
||||
match val {
|
||||
Some(Value::Object(map)) => map
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let s = match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((k.clone(), s))
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let obj = item.as_object()?;
|
||||
let name = obj.get("name")?.as_str()?.to_owned();
|
||||
let value = obj.get("value")?.as_str()?.to_owned();
|
||||
Some((name, value))
|
||||
})
|
||||
.collect(),
|
||||
_ => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crud_and_acp_filtering_by_scope_agent_and_transport() {
|
||||
let mut user = BTreeMap::new();
|
||||
let mut workspace = BTreeMap::new();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "shared".into(),
|
||||
scope: McpScope::User,
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: Some("npx".into()),
|
||||
args: vec!["-y".into(), "user-pkg".into()],
|
||||
env: vec![McpKeyValue { name: "TOKEN".into(), value: "u1".into() }],
|
||||
url: None,
|
||||
headers: Vec::new(),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "shared".into(),
|
||||
scope: McpScope::Workspace,
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: Some("npx".into()),
|
||||
args: vec!["-y".into(), "workspace-pkg".into()],
|
||||
env: Vec::new(),
|
||||
url: None,
|
||||
headers: Vec::new(),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "claude-http".into(),
|
||||
scope: McpScope::User,
|
||||
transport: McpTransport::Http,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
url: Some("https://example.com/mcp".into()),
|
||||
headers: vec![McpKeyValue { name: "Authorization".into(), value: "Bearer secret".into() }],
|
||||
agents: vec!["claude".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let list = servers_from_settings(&user, &workspace);
|
||||
assert_eq!(list.len(), 3);
|
||||
|
||||
// Negotiated with HTTP support: `claude` gets workspace `shared` + `claude-http`; `codex` only gets `shared`.
|
||||
let init_http = Negotiated::from_initialize(&json!({
|
||||
"agentCapabilities": { "mcpCapabilities": { "http": true, "sse": false } }
|
||||
}));
|
||||
let claude_servers = acp_mcp_servers(&list, "claude-acp", &init_http);
|
||||
assert_eq!(claude_servers.len(), 2);
|
||||
assert_eq!(claude_servers[0]["name"], "shared");
|
||||
assert_eq!(claude_servers[0]["args"], json!(["-y", "workspace-pkg"]));
|
||||
assert_eq!(claude_servers[1]["name"], "claude-http");
|
||||
assert_eq!(claude_servers[1]["type"], "http");
|
||||
|
||||
let codex_servers = acp_mcp_servers(&list, "codex", &init_http);
|
||||
assert_eq!(codex_servers.len(), 1);
|
||||
assert_eq!(codex_servers[0]["name"], "shared");
|
||||
|
||||
// Disable workspace `shared` and remove user `claude-http`.
|
||||
apply_set_enabled(&mut user, &mut workspace, "workspace::shared", false).unwrap();
|
||||
apply_remove(&mut user, &mut workspace, "user::claude-http").unwrap();
|
||||
let list = servers_from_settings(&user, &workspace);
|
||||
let after = acp_mcp_servers(&list, "claude", &init_http);
|
||||
assert_eq!(after.len(), 1);
|
||||
assert_eq!(after[0]["args"], json!(["-y", "user-pkg"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_from_mcp_json_and_vscode_mcp_json_without_modifying_them() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mcp_json = tmp.path().join(".mcp.json");
|
||||
let vscode_dir = tmp.path().join(".vscode");
|
||||
std::fs::create_dir_all(&vscode_dir).unwrap();
|
||||
let vscode_mcp = vscode_dir.join("mcp.json");
|
||||
|
||||
let raw_mcp = r#"{"mcpServers":{"sqlite":{"command":"uvx","args":["mcp-server-sqlite"]}}}"#;
|
||||
let raw_vscode = r#"{"servers":{"remote":{"type":"http","url":"https://mcp.example.com"}}}"#;
|
||||
std::fs::write(&mcp_json, raw_mcp).unwrap();
|
||||
std::fs::write(&vscode_mcp, raw_vscode).unwrap();
|
||||
|
||||
let mut workspace = BTreeMap::new();
|
||||
let added = apply_import(&mut workspace, tmp.path());
|
||||
assert_eq!(added, 2);
|
||||
assert_eq!(workspace["sqlite"].command.as_deref(), Some("uvx"));
|
||||
assert_eq!(workspace["remote"].transport, McpTransport::Http);
|
||||
assert_eq!(workspace["remote"].url.as_deref(), Some("https://mcp.example.com"));
|
||||
|
||||
// Re-importing does not duplicate or overwrite existing entries, and leaves files untouched.
|
||||
workspace.get_mut("sqlite").unwrap().enabled = false;
|
||||
assert_eq!(apply_import(&mut workspace, tmp.path()), 0);
|
||||
assert!(!workspace["sqlite"].enabled);
|
||||
assert_eq!(std::fs::read_to_string(&mcp_json).unwrap(), raw_mcp);
|
||||
assert_eq!(std::fs::read_to_string(&vscode_mcp).unwrap(), raw_vscode);
|
||||
}
|
||||
}
|
||||
@@ -518,15 +518,6 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// Text was appended to entry `index` (an agent message or a thought): it read the first
|
||||
/// `from` UTF-16 units of its text when the previous event for it was sent.
|
||||
Append {
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
index: u32,
|
||||
from: u32,
|
||||
text: String,
|
||||
},
|
||||
/// An agent reported the usage limits of its account (shared by every task of `provider`).
|
||||
Limits {
|
||||
provider: String,
|
||||
@@ -535,70 +526,3 @@ pub enum AgentEvent {
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
|
||||
pub use ide_core::settings::{AgentMcpServer, McpTransport};
|
||||
|
||||
/// Where an MCP server configuration is stored (`settings.toml` vs `.openidea/settings.toml`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpScope {
|
||||
User,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/// A named environment variable or HTTP header for an MCP server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpKeyValue {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// One configured Model Context Protocol (MCP) server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scope: McpScope,
|
||||
pub transport: McpTransport,
|
||||
pub enabled: bool,
|
||||
pub command: Option<String>,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<McpKeyValue>,
|
||||
pub url: Option<String>,
|
||||
pub headers: Vec<McpKeyValue>,
|
||||
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
|
||||
#[serde(default)]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
/// Payload for creating or updating a user or workspace MCP server.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerInput {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub scope: McpScope,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: Vec<McpKeyValue>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: Vec<McpKeyValue>,
|
||||
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
|
||||
#[serde(default)]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Cli for Process {
|
||||
}
|
||||
|
||||
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
|
||||
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
let set = env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
|
||||
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
|
||||
|
||||
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
|
||||
/// steer, or text Claude Code added.
|
||||
pub(crate) fn is_turn_start(e: &Value) -> bool {
|
||||
pub(super) fn is_turn_start(e: &Value) -> bool {
|
||||
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
|
||||
}
|
||||
|
||||
/// The newest title Claude Code gave the session.
|
||||
pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
pub(super) fn title(entries: &[Value]) -> Option<String> {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
|
||||
//! way.
|
||||
|
||||
pub(crate) mod cli;
|
||||
pub(crate) mod history;
|
||||
mod cli;
|
||||
mod history;
|
||||
mod settings;
|
||||
mod tools;
|
||||
mod translate;
|
||||
@@ -69,17 +69,8 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// What discovery found: ACP `availableCommands`, `configOptions` for the configured model,
|
||||
/// and each model's effort option by model value (`None`: none).
|
||||
pub(crate) struct Discovered {
|
||||
pub commands: Vec<Value>,
|
||||
pub options: Value,
|
||||
pub efforts: Vec<(String, Option<Value>)>,
|
||||
}
|
||||
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discovered, String> {
|
||||
/// Discover commands without a prompt, ACP session, or pending session file.
|
||||
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
@@ -88,56 +79,28 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let request = json!({ "type": "control_request", "request_id": "commands",
|
||||
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
|
||||
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
io.stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok(Discovered {
|
||||
commands: settings.commands.clone(),
|
||||
options: settings.options(),
|
||||
efforts: settings.efforts_by_model(),
|
||||
})
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
|
||||
let response = &msg["response"];
|
||||
return if response["subtype"] == "success" {
|
||||
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
|
||||
} else {
|
||||
Err("Claude Code did not initialize".into())
|
||||
};
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
|
||||
@@ -132,18 +132,19 @@ impl Settings {
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
if let Some(effort) = self.current_model().and_then(|m| effort_option(m, &self.effort)) {
|
||||
out.push(effort);
|
||||
let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default();
|
||||
if !efforts.is_empty() {
|
||||
let mut values = vec![json!({ "value": "default", "name": "Default" })];
|
||||
values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
|
||||
let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" };
|
||||
out.push(json!({
|
||||
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
|
||||
"currentValue": current, "options": values,
|
||||
}));
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Each model's effort option by model value (`None`: it has no effort levels): what a new
|
||||
/// task offers once that model is picked, before any session has run it.
|
||||
pub fn efforts_by_model(&self) -> Vec<(String, Option<Value>)> {
|
||||
self.models.iter().map(|m| (m.value.clone(), effort_option(m, &self.effort))).collect()
|
||||
}
|
||||
|
||||
pub fn valid(&self, option: &str, value: &str) -> bool {
|
||||
self.options()
|
||||
.as_array()
|
||||
@@ -155,20 +156,6 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// `model`'s effort option, with `effort` selected when the model offers it; `None` without efforts.
|
||||
fn effort_option(model: &Model, effort: &str) -> Option<Value> {
|
||||
if model.efforts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut values = vec![json!({ "value": "default", "name": "Default" })];
|
||||
values.extend(model.efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
|
||||
let current = if model.efforts.iter().any(|e| e == effort) { effort } else { "default" };
|
||||
Some(json!({
|
||||
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
|
||||
"currentValue": current, "options": values,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal.
|
||||
pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec<Value> {
|
||||
list.and_then(Value::as_array)
|
||||
@@ -244,20 +231,4 @@ mod tests {
|
||||
let cmds = commands(init().get("commands"), &["doctor".into()]);
|
||||
assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_offers_its_own_efforts() {
|
||||
let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}}));
|
||||
let efforts = s.efforts_by_model();
|
||||
let values = |model: &str| {
|
||||
let (_, o) = efforts.iter().find(|(m, _)| m == model).unwrap();
|
||||
let o = o.as_ref().unwrap();
|
||||
let values: Vec<_> = o["options"].as_array().unwrap().iter().map(|v| v["value"].clone()).collect();
|
||||
(values, o["currentValue"].clone())
|
||||
};
|
||||
assert_eq!(values("default"), (vec![json!("default"), json!("low"), json!("high")], json!("high")));
|
||||
// The configured effort only where the model offers it.
|
||||
assert_eq!(values("opus"), (vec![json!("default"), json!("low"), json!("max")], json!("default")));
|
||||
assert!(efforts.contains(&("claude-haiku-4-5".into(), None)), "no effort option without levels");
|
||||
}
|
||||
}
|
||||
@@ -59,14 +59,11 @@ const BUILTIN: &[(&str, &str, Driver, &[&str], &str)] = &[
|
||||
("cursor", "Cursor", Driver::Acp, &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
|
||||
("opencode", "OpenCode", Driver::Acp, &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
|
||||
("qwen", "Qwen Code", Driver::Acp, &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
|
||||
("qoder", "Qoder", Driver::Acp, &["qodercli", "--acp"], "Run `qodercli login` in a terminal."),
|
||||
("qodercn", "Qoder CN", Driver::Acp, &["qoderclicn", "--acp"], "Run `qoderclicn login` in a terminal."),
|
||||
("kimi", "Kimi CLI", Driver::Acp, &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
|
||||
("goose", "Goose", Driver::Acp, &["goose", "acp"], "Run `goose configure` in a terminal."),
|
||||
("auggie", "Auggie", Driver::Acp, &["auggie", "--acp"], "Run `auggie login` in a terminal."),
|
||||
("kiro", "Kiro", Driver::Acp, &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
|
||||
("vibe", "Mistral Vibe", Driver::Acp, &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
|
||||
("omp", "OMP", Driver::Acp, &["omp", "acp"], "Run `omp` in a terminal and sign in."),
|
||||
];
|
||||
|
||||
fn builtins() -> Vec<ProviderSpec> {
|
||||
@@ -187,14 +184,6 @@ pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
|
||||
builtins().into_iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// The ACP adapters of Claude Code and Codex, which the native drivers replace: they still
|
||||
/// launch, but are listed only once customized through an agent file or used by a task.
|
||||
const UNLISTED: &[&str] = &["claude-acp", "codex-acp"];
|
||||
|
||||
pub(crate) fn is_unlisted(spec: &ProviderSpec) -> bool {
|
||||
UNLISTED.contains(&spec.id.as_str()) && builtin_spec(&spec.id).as_ref() == Some(spec)
|
||||
}
|
||||
|
||||
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
|
||||
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
let builtin = builtin_spec(&spec.id);
|
||||
@@ -411,45 +400,6 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn qoder_global_and_cn_editions_are_both_built_in() {
|
||||
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
|
||||
let (global, cn) = (info("qoder"), info("qodercn"));
|
||||
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
|
||||
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
|
||||
assert_eq!(global.login_hint.as_deref(), Some("Run `qodercli login` in a terminal."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(qodercn.driver, Driver::Acp);
|
||||
assert!(!is_unlisted(&qodercn));
|
||||
let info = describe(&qodercn);
|
||||
assert_eq!(info.name, "Qoder CN");
|
||||
assert_eq!(info.command, "qoderclicn --acp");
|
||||
assert_eq!(info.source, AgentSource::Builtin);
|
||||
assert!(info.command_editable);
|
||||
assert_eq!(info.login_hint.as_deref(), Some("Run `qoderclicn login` in a terminal."));
|
||||
|
||||
std::fs::write(
|
||||
dir.path().join("qodercn.toml"),
|
||||
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let specs = load(Some(dir.path()));
|
||||
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
|
||||
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(custom.command, "/custom/qoderclicn");
|
||||
assert_eq!(custom.env, [("K".into(), "v".into())]);
|
||||
set_builtin_command(dir.path(), "qodercn", None).unwrap();
|
||||
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(reset.command, "qoderclicn");
|
||||
assert_eq!(reset.args, ["--acp"]);
|
||||
assert_eq!(reset.env, custom.env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
for spec in builtins() {
|
||||
@@ -640,15 +590,6 @@ mod tests {
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
|
||||
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+13
-425
@@ -35,9 +35,6 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
auth_on_load: bool,
|
||||
authenticate_method: Option<String>,
|
||||
authenticated: AtomicBool,
|
||||
request_error: Option<(i64, String)>,
|
||||
auth_methods: Vec<Value>,
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
@@ -130,9 +127,9 @@ impl Handler for FakeAgent {
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
let needs_auth = !self.authenticated.load(Ordering::Acquire)
|
||||
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
|
||||
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
@@ -179,16 +176,6 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
}
|
||||
"authenticate" if self.authenticate_method.is_some() => {
|
||||
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
|
||||
self.calls.lock().push(format!("authenticate {id}"));
|
||||
if Some(id) == self.authenticate_method.as_deref() {
|
||||
self.authenticated.store(true, Ordering::Release);
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
|
||||
}
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
|
||||
@@ -379,41 +366,12 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Review the saved conversation"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.shutdown();
|
||||
let restored_agent = Arc::new(FakeAgent::default());
|
||||
let restored = manager(tmp.path(), restored_agent.clone());
|
||||
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
|
||||
assert!(restored_agent.calls.lock().is_empty());
|
||||
assert!(restored.entries("missing-task", None).is_err());
|
||||
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
|
||||
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
|
||||
assert!(restored.entries(&task.id, None).unwrap().is_empty());
|
||||
restored.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
for outcome in ["success", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
@@ -422,7 +380,6 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
(root / 'pid').write_text(str(os.getpid()))
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
@@ -434,34 +391,16 @@ if outcome == 'exit':
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
if outcome == 'success':
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
|
||||
'models': [
|
||||
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
|
||||
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
|
||||
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
|
||||
if outcome == 'empty':
|
||||
response['response'] = {}
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
if outcome == 'session_report':
|
||||
(root / 'initialized').touch()
|
||||
while not (root / 'continue').exists():
|
||||
time.sleep(0.01)
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
request = json.loads(line)
|
||||
if request['request']['subtype'] == 'get_settings':
|
||||
if outcome == 'settings_timeout':
|
||||
time.sleep(60)
|
||||
reply = {'subtype': 'success', 'request_id': request['request_id'],
|
||||
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
|
||||
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -478,59 +417,19 @@ for line in sys.stdin:
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
|
||||
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
std::fs::write(root.join("continue"), "").unwrap();
|
||||
}
|
||||
mgr.commands("claude", None).await
|
||||
};
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
if outcome == "success" {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
// The same handshake lists the models, so the composer has them before any task.
|
||||
for mgr in [&mgr, &restored] {
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
assert_eq!(model.current.as_deref(), Some("default"));
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus"]);
|
||||
let effort =
|
||||
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
|
||||
assert_eq!(
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert_eq!(root.join("models.json").exists(), outcome == "empty");
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
|
||||
assert!(!root.join("models.json").exists());
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
@@ -540,25 +439,10 @@ for line in sys.stdin:
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
// The handshake and the settings read: no prompt or other control request.
|
||||
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
|
||||
&["initialize", "get_settings"]
|
||||
} else {
|
||||
&["initialize"]
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery process must exit");
|
||||
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
|
||||
let request: Value = serde_json::from_str(requests.trim()).unwrap();
|
||||
assert_eq!(request["type"], "control_request");
|
||||
assert_eq!(request["request"]["subtype"], "initialize");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,91 +593,6 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let methods = vec![
|
||||
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
|
||||
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
|
||||
];
|
||||
let agent = Arc::new(FakeAgent {
|
||||
auth_on_new: true,
|
||||
authenticate_method: Some("github_oauth".into()),
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "copilot".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(
|
||||
agent
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
|
||||
);
|
||||
|
||||
// Resuming after an IDE restart also authenticates once and retries `session/load`.
|
||||
mgr.shutdown();
|
||||
let agent2 = Arc::new(FakeAgent {
|
||||
auth_on_load: true,
|
||||
auth_on_new: true,
|
||||
authenticate_method: Some("github_oauth".into()),
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr2 = manager(tmp.path(), agent2.clone());
|
||||
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(
|
||||
agent2
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
|
||||
);
|
||||
|
||||
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
|
||||
mgr2.shutdown();
|
||||
let agent3 = Arc::new(FakeAgent {
|
||||
auth_on_load: true,
|
||||
auth_on_new: true,
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods,
|
||||
..Default::default()
|
||||
});
|
||||
let mgr3 = manager(tmp.path(), agent3.clone());
|
||||
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
|
||||
assert_eq!(
|
||||
agent3
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/load", "authenticate", "session/new"],
|
||||
);
|
||||
mgr3.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2273,49 +2072,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
assert!(probe.error.is_none());
|
||||
}
|
||||
|
||||
/// A session report that changes the cached provider config notifies subscribers,
|
||||
/// so the composer re-lists models without waiting for the next task (#1490).
|
||||
/// Reports that change nothing stay silent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn remembered_config_changes_notify_subscribers_once() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
|
||||
events.lock().clear();
|
||||
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a first report changing the cache notifies subscribers"
|
||||
);
|
||||
events.lock().clear();
|
||||
|
||||
// The same report again: the cache is unchanged, so subscribers hear nothing.
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
|
||||
|
||||
// A changed model list notifies again, and providers() serves the new list.
|
||||
let updated = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a changed model list notifies subscribers"
|
||||
);
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
let new = || NewAgentTask {
|
||||
@@ -2602,171 +2358,3 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[]})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let effort = |values: &[&str], current: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
|
||||
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
|
||||
] }))
|
||||
};
|
||||
let model = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
|
||||
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
|
||||
] }));
|
||||
// A session already reported sonnet's levels; discovery adds opus's.
|
||||
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
|
||||
let levels =
|
||||
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
|
||||
mgr.shared.remember_discovered("claude", &model, &[], &levels);
|
||||
let known = mgr.shared.known.lock();
|
||||
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
|
||||
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_sent_as_appends() {
|
||||
let mut streamed = HashMap::new();
|
||||
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
|
||||
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
|
||||
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
|
||||
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
|
||||
assert!(
|
||||
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
|
||||
"{next:?}"
|
||||
);
|
||||
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
|
||||
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
|
||||
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
|
||||
assert!(
|
||||
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
|
||||
"from counts UTF-16 units: {after:?}"
|
||||
);
|
||||
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
|
||||
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
|
||||
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
|
||||
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
|
||||
let tool = AgentEntry::Error { message: "x".into() };
|
||||
entry_event("t", &mut streamed, None, 4, &tool);
|
||||
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
|
||||
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
|
||||
|
||||
let long = "é".repeat(5000);
|
||||
entry_event("t", &mut streamed, None, 5, &agent(&long));
|
||||
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
|
||||
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
|
||||
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_keep_the_agents_details() {
|
||||
let details = json!({ "details": "spawn claude ENOENT" });
|
||||
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
|
||||
assert_eq!(
|
||||
with_details("Internal error".into(), Some(&json!("model not found"))),
|
||||
"Internal error: model not found"
|
||||
);
|
||||
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
|
||||
assert_eq!(with_details("Internal error".into(), None), "Internal error");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "{args:?}");
|
||||
};
|
||||
let write = |rel: &str, text: &str| std::fs::write(dir.join(rel), text).unwrap();
|
||||
let read = |rel: &str| std::fs::read_to_string(dir.join(rel)).unwrap();
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
|
||||
write("mine.txt", "base\n");
|
||||
write("other.txt", "base\n");
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-q", "-m", "init"]);
|
||||
// The user's own uncommitted edit, before the task starts.
|
||||
write("mine.txt", "my edit\n");
|
||||
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Hello"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// What the agent did.
|
||||
write("mine.txt", "my edit\nand the agent's\n");
|
||||
write("other.txt", "agent\n");
|
||||
write("new.txt", "agent\n");
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
|
||||
assert_eq!(read("other.txt"), "base\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
|
||||
|
||||
// After a restart, from the saved copy.
|
||||
mgr.shutdown();
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
|
||||
}
|
||||
@@ -116,7 +116,7 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
pub(crate) fn stamp(path: &Path) -> Stamp {
|
||||
fn stamp(path: &Path) -> Stamp {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
|
||||
Some((meta.len(), modified))
|
||||
@@ -132,77 +132,6 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
|
||||
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
|
||||
}
|
||||
|
||||
/// Files larger than this that were changed before the task keep no copy: they can't be rolled back.
|
||||
const SAVED_MAX: u64 = 8 * 1024 * 1024;
|
||||
|
||||
fn saved_name(rel: &str) -> String {
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
let mut hash = DefaultHasher::new();
|
||||
rel.hash(&mut hash);
|
||||
format!("{:016x}", hash.finish())
|
||||
}
|
||||
|
||||
/// Copies the files in `before` (already changed in the checkout at `cwd` when the task started)
|
||||
/// into `dir`, so rolling back the task's changes to them restores the user's own edits.
|
||||
/// Whether anything was saved.
|
||||
pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Path) -> IdeResult<bool> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(false) };
|
||||
let mut saved = false;
|
||||
for (rel, stamp) in before {
|
||||
let path = repo.root().join(rel);
|
||||
if stamp.is_none() || !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= SAVED_MAX) {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
std::fs::copy(&path, dir.join(saved_name(rel)))?;
|
||||
saved = true;
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Rolls `change` back: to the copy saved when the task started when the file had changes
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
|
||||
/// lacks it).
|
||||
pub(crate) fn restore(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
change: &AgentChange,
|
||||
before: &BTreeMap<String, Stamp>,
|
||||
saved: Option<&Path>,
|
||||
) -> IdeResult<()> {
|
||||
let path = &change.path.0;
|
||||
let content = match before.get(&change.rel_path) {
|
||||
Some(None) => None,
|
||||
Some(Some(_)) => {
|
||||
let copy =
|
||||
saved.map(|d| d.join(saved_name(&change.rel_path))).filter(|p| p.is_file()).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"{} had changes of yours when the task started that weren't saved; roll it back in Git instead",
|
||||
change.rel_path
|
||||
))
|
||||
})?;
|
||||
Some(std::fs::read(copy)?)
|
||||
}
|
||||
None => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(base))?
|
||||
}
|
||||
};
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)?;
|
||||
}
|
||||
None => match std::fs::remove_file(path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
|
||||
let encoding = match ide_vfs::load_text(path) {
|
||||
|
||||
@@ -7,9 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
chardetng.workspace = true
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -79,26 +79,23 @@ fn scan(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One planned step as fingerprinted: resolved program, args, cwd and env.
|
||||
type StepFingerprint = (PathBuf, Vec<String>, PathBuf, BTreeMap<String, String>);
|
||||
|
||||
/// Environment, planned steps and input paths shared by every Maven build
|
||||
/// fingerprint: the filtered process environment plus the wrapper, settings
|
||||
/// and JDK files the planned invocation reads.
|
||||
struct Collected {
|
||||
inputs: Vec<PathBuf>,
|
||||
environment: BTreeMap<String, String>,
|
||||
steps: Vec<StepFingerprint>,
|
||||
inherited_hash: u64,
|
||||
}
|
||||
|
||||
fn collect(
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
projects: &[&maven::MavenProject],
|
||||
plan: &BuildPlan,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
) -> IdeResult<Option<Collected>> {
|
||||
let Some(mut inputs) = maven::launch_inputs(root, projects, local_repo) else { return Ok(None) };
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
@@ -168,124 +165,23 @@ fn collect(
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| (process::program_path(&s.program, &s.env), s.args.clone(), s.cwd.clone(), s.env.clone()))
|
||||
.collect();
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
Ok(Some(Collected { inputs, environment, steps, inherited_hash: inherited_hash.finish() }))
|
||||
}
|
||||
|
||||
/// Scans every input, storing project inputs relative to the project.
|
||||
fn scan_all(
|
||||
root: &Path,
|
||||
inputs: Vec<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BTreeMap<PathBuf, Option<Stamp>>> {
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
Ok(files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let Some(collected) = collect(root, &projects, &plan, local_repo)? else { return Ok(None) };
|
||||
let Collected { inputs, environment, steps, inherited_hash } = collected;
|
||||
let parameters = serde_json::to_string(&(root, module, test, steps, environment, inherited_hash, projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
|
||||
}
|
||||
|
||||
/// Inputs fingerprint of a menu build (`Build Project` / `Build Module`): the
|
||||
/// built modules' sources, resources and POM chain plus the exact planned
|
||||
/// steps, so an unchanged project skips Maven entirely (#2476). Only `Build`
|
||||
/// callers reuse it, never `Rebuild` or `Artifacts`; `None` disables reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn menu_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let plan = plan(root, model, BuildKind::Build, file, None)?;
|
||||
if plan.system != BuildSystem::Maven {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects: Vec<&maven::MavenProject> = match file {
|
||||
Some(file) => {
|
||||
// The innermost linked project owning the file, as in single-file compilation.
|
||||
let rel = rel_str(root, file);
|
||||
let module = m
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
|
||||
.max_by_key(|p| p.dir.len());
|
||||
match module {
|
||||
Some(project) if project.packaging != "pom" => {
|
||||
maven_launch_projects(root, m, &subdir(root, &project.dir), true)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
// A project build walks every linked project.
|
||||
None => m.projects.iter().collect(),
|
||||
};
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(collected) = collect(root, &projects, &plan, None)? else { return Ok(None) };
|
||||
let Collected { inputs, environment, steps, inherited_hash } = collected;
|
||||
let scope = file.map(|f| rel_str(root, f));
|
||||
let parameters = serde_json::to_string(&("menu-build", scope, steps, environment, inherited_hash))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
|
||||
}
|
||||
|
||||
impl LaunchFingerprint {
|
||||
/// Relative paths whose content changed while `(length, mtime)` stayed the same
|
||||
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
|
||||
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
|
||||
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
|
||||
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
|
||||
/// re-stamp the returned paths before building. Inputs without a content hash
|
||||
/// (directories, files outside the project) are never reported.
|
||||
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
|
||||
let mut edits = Vec::new();
|
||||
for (path, current_stamp) in ¤t.files {
|
||||
let Some(current_stamp) = current_stamp else { continue };
|
||||
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
|
||||
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
|
||||
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
|
||||
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
|
||||
edits.push(path.clone());
|
||||
}
|
||||
}
|
||||
edits.sort();
|
||||
edits
|
||||
}
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -386,60 +282,6 @@ mod tests {
|
||||
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtime_preserved_edits_only_reports_lying_timestamps() {
|
||||
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
|
||||
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
|
||||
}
|
||||
fn prints(
|
||||
previous: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
current: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
) -> Vec<PathBuf> {
|
||||
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
|
||||
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
|
||||
previous.mtime_preserved_edits(¤t)
|
||||
}
|
||||
// Same length and mtime, different content: the builder would skip it.
|
||||
assert_eq!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
|
||||
vec![PathBuf::from("a.java")]
|
||||
);
|
||||
// A normal edit bumps the mtime: the builder already sees it.
|
||||
let mut touched = stamp(5, "new").unwrap();
|
||||
touched.1 += std::time::Duration::from_secs(1);
|
||||
assert!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
|
||||
);
|
||||
// Added, deleted, and hash-less inputs (directories, files outside the
|
||||
// project) carry no comparable content: never reported.
|
||||
assert!(
|
||||
prints(
|
||||
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
|
||||
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_source_is_found_against_a_real_fingerprint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let source = root.join("lib/src/main/kotlin/Channel.kt");
|
||||
let before = fingerprint(root, &model).unwrap();
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content, preserved mtime.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let after = fingerprint(root, &model).unwrap();
|
||||
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
|
||||
// A normal edit (mtime advances) needs no repair.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
|
||||
let normal = fingerprint(root, &model).unwrap();
|
||||
assert!(before.mtime_preserved_edits(&normal).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -5,14 +5,8 @@
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
mod single;
|
||||
mod up_to_date;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
|
||||
pub use single::{
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
|
||||
};
|
||||
pub use up_to_date::{menu_up_to_date, record_menu_build};
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
@@ -269,12 +263,7 @@ pub(super) fn maven_steps(
|
||||
return Err(IdeError::not_found("no Maven project found"));
|
||||
}
|
||||
let step = |p: &maven::MavenProject, extra: &[String]| {
|
||||
let mut s = BuildStep::new(
|
||||
&p.command,
|
||||
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
|
||||
&subdir(root, &p.dir),
|
||||
Format::Maven,
|
||||
);
|
||||
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
@@ -1055,21 +1044,9 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
"clean",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
|
||||
@@ -1343,36 +1320,12 @@ mod tests {
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
goal
|
||||
]
|
||||
);
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
"clean",
|
||||
goal
|
||||
]
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! [`super::resolve`] turns them into IDE coordinates.
|
||||
|
||||
use ide_core::Severity;
|
||||
pub(crate) use ide_core::output_encoding::strip_ansi;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Output dialect of a build step.
|
||||
@@ -99,6 +98,32 @@ pub(crate) struct OutputParser {
|
||||
pending: Option<Pending>,
|
||||
}
|
||||
|
||||
/// Removes ANSI escape sequences (colour, cursor movement).
|
||||
pub(crate) fn strip_ansi(s: &str) -> String {
|
||||
if !s.contains('\u{1b}') {
|
||||
return s.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\u{1b}' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for c in chars.by_ref() {
|
||||
if c.is_ascii_alphabetic() || c == '~' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn num(s: &str) -> Option<u32> {
|
||||
s.trim().parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
//! Single-file Java compilation with `javac` for the Compile File action (#2476).
|
||||
//!
|
||||
//! A module build (`-pl … -am`) still walks every upstream module, and packaging
|
||||
//! walks the whole reactor; compiling one file only needs that file's module
|
||||
//! outputs plus its dependency classpath, which is much faster to assemble.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command as ProcCommand;
|
||||
|
||||
/// Output file of the `dependency:build-classpath` probe, relative to each module.
|
||||
const CP_FILE: &str = "target/ide-compile-cp.txt";
|
||||
|
||||
/// A `.java` source and the module build that compiles it.
|
||||
pub struct SingleFileTarget {
|
||||
/// Absolute module directory.
|
||||
pub module: PathBuf,
|
||||
/// The source is under `src/test`: compile to test classes with the test classpath.
|
||||
pub test: bool,
|
||||
/// Absolute `target/classes` (or `target/test-classes`).
|
||||
pub out_dir: PathBuf,
|
||||
/// Reactor root building the module (owns the Maven command and options).
|
||||
pub top: maven::MavenProject,
|
||||
/// `-pl` value of the module relative to the reactor root; empty when the
|
||||
/// module is the root itself.
|
||||
pub pl: String,
|
||||
/// `maven.compiler.release` (or equivalent) of the module's project.
|
||||
pub java_version: Option<u32>,
|
||||
}
|
||||
|
||||
/// Locates the linked Maven module owning a `.java` source file.
|
||||
pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<SingleFileTarget> {
|
||||
if file.extension().is_none_or(|ext| ext != "java") {
|
||||
return Err(IdeError::invalid("only Java sources can be compiled as a single file"));
|
||||
}
|
||||
if !file.is_file() {
|
||||
return Err(IdeError::not_found("no Java source found for single-file compilation"));
|
||||
}
|
||||
let Some(m) = &model.maven else {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
};
|
||||
// The innermost project whose directory contains the file owns it; the
|
||||
// aggregator root (`""`) is a prefix of everything, so take the longest match.
|
||||
let rel = rel_str(root, file);
|
||||
let project = m
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
|
||||
.max_by_key(|p| p.dir.len())
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
if project.packaging == "pom" {
|
||||
// An aggregator owns no sources itself: a file that only matches it
|
||||
// belongs to no linked module.
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let module = subdir(root, &project.dir);
|
||||
let top = maven_reactor(m, &project.dir)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
// Test sources see the test classpath and compile into test classes.
|
||||
let parts: Vec<&str> = rel.split('/').collect();
|
||||
let test = parts.windows(2).any(|w| w == ["src", "test"]);
|
||||
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
|
||||
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
let home = env.get("JAVA_HOME").map(Path::new);
|
||||
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
|
||||
if let Some(javac) = home.map(|h| h.join("bin").join(exe)).filter(|p| p.is_file()) {
|
||||
return javac.to_string_lossy().into_owned();
|
||||
}
|
||||
exe.to_owned()
|
||||
}
|
||||
|
||||
/// Dependency classpath of the module: existing reactor class directories plus
|
||||
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
|
||||
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
|
||||
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
// Upstream reactor modules first: their freshly built classes shadow
|
||||
// installed snapshots of the same artifacts.
|
||||
for project in maven_launch_projects(root, m, &target.module, target.test) {
|
||||
let dir = subdir(root, &project.dir);
|
||||
if dir == target.module {
|
||||
continue;
|
||||
}
|
||||
for classes in ["target/classes", "target/test-classes"] {
|
||||
let path = dir.join(classes);
|
||||
if path.is_dir() {
|
||||
entries.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let probe = CP_FILE;
|
||||
let mut args = vec!["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG];
|
||||
args.extend(target.top.options.iter().map(String::as_str));
|
||||
if !target.pl.is_empty() {
|
||||
args.extend(["-pl", target.pl.as_str(), "-am"]);
|
||||
}
|
||||
let output_arg = format!("-Dmdep.outputFile={probe}");
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
|
||||
let top_dir = subdir(root, &target.top.dir);
|
||||
let status = ProcCommand::new(&target.top.command)
|
||||
.args(&args)
|
||||
.envs(&target.top.env)
|
||||
.current_dir(&top_dir)
|
||||
.output()
|
||||
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
|
||||
}
|
||||
// With `-am` every visited module writes its own file; only this module's counts.
|
||||
let cp_file = target.module.join(probe);
|
||||
let content = fs::read_to_string(&cp_file).map_err(|e| {
|
||||
IdeError::internal(format!("cannot resolve the compile classpath: {} ({e})", cp_file.display()))
|
||||
})?;
|
||||
let separator = if cfg!(windows) { ';' } else { ':' };
|
||||
entries.extend(content.split([separator, '\n', '\r']).map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned));
|
||||
entries.dedup();
|
||||
Ok(entries.join(&separator.to_string()))
|
||||
}
|
||||
|
||||
/// The `javac` step compiling `file` into `target`'s output directory.
|
||||
pub fn javac_step(target: &SingleFileTarget, file: &Path, classpath: &str, javac: &str) -> IdeResult<BuildStep> {
|
||||
fs::create_dir_all(&target.out_dir)
|
||||
.map_err(|e| IdeError::internal(format!("cannot create {} ({e})", target.out_dir.display())))?;
|
||||
let mut step = BuildStep::new(javac, &[], &target.module, Format::Gradle);
|
||||
// Raw `javac` prints `path:line: error: …`, the same form Gradle forwards.
|
||||
step.args.push("-encoding".into());
|
||||
step.args.push("UTF-8".into());
|
||||
step.args.push("-d".into());
|
||||
step.args.push(target.out_dir.to_string_lossy().into_owned());
|
||||
step.args.push("-cp".into());
|
||||
step.args.push(classpath.to_owned());
|
||||
if let Some(version) = target.java_version {
|
||||
step.args.push("--release".into());
|
||||
step.args.push(version.to_string());
|
||||
}
|
||||
step.args.push(file.to_string_lossy().into_owned());
|
||||
step.env = target.top.env.clone();
|
||||
Ok(step)
|
||||
}
|
||||
|
||||
/// Resolves the classpath and plans the `javac` invocation for one Java source.
|
||||
pub fn compile_file_plan(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<BuildPlan> {
|
||||
let target = single_file_target(root, model, file)?;
|
||||
let classpath = single_file_classpath(root, model, &target)?;
|
||||
let javac = find_javac(&target.top.env);
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok(BuildPlan {
|
||||
system: BuildSystem::Maven,
|
||||
title: format!("Compile {name}"),
|
||||
steps: vec![javac_step(&target, file, &classpath, &javac)?],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "app/src/main/java/demo/Main.java", "class Main {}");
|
||||
write(root, "app/src/test/java/demo/MainTest.java", "class MainTest {}");
|
||||
write(root, "lib/target/classes/Lib.class", "built");
|
||||
crate::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_finds_the_owning_module_and_the_test_scope() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let main = single_file_target(root, &model, &root.join("app/src/main/java/demo/Main.java")).unwrap();
|
||||
assert_eq!(main.module, root.join("app"));
|
||||
assert!(!main.test);
|
||||
assert_eq!(main.out_dir, root.join("app/target/classes"));
|
||||
let test = single_file_target(root, &model, &root.join("app/src/test/java/demo/MainTest.java")).unwrap();
|
||||
assert!(test.test);
|
||||
assert_eq!(test.out_dir, root.join("app/target/test-classes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_rejects_non_java_missing_and_unlinked_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
assert!(single_file_target(root, &model, &root.join("app/pom.xml")).is_err());
|
||||
assert!(single_file_target(root, &model, &root.join("app/src/main/java/demo/Missing.java")).is_err());
|
||||
write(root, "other/Main.java", "class Main {}");
|
||||
assert!(single_file_target(root, &model, &root.join("other/Main.java")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javac_prefers_the_module_jdk_and_scopes_test_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let file = root.join("app/src/test/java/demo/MainTest.java");
|
||||
let target = single_file_target(root, &model, &file).unwrap();
|
||||
let jdk = root.join("jdk");
|
||||
fs::create_dir_all(jdk.join("bin")).unwrap();
|
||||
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
|
||||
fs::write(jdk.join("bin").join(exe), "fake").unwrap();
|
||||
let mut target = target;
|
||||
target.top.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
|
||||
let step = javac_step(&target, &file, "/repo/a.jar", &find_javac(&target.top.env)).unwrap();
|
||||
assert!(step.program.ends_with(exe));
|
||||
assert!(target.out_dir.is_dir());
|
||||
let args = step.args.join(" ");
|
||||
assert!(args.contains("-encoding UTF-8"), "{args}");
|
||||
assert!(args.contains("-d ") && args.contains("test-classes"), "{args}");
|
||||
assert!(args.contains("-cp /repo/a.jar"), "{args}");
|
||||
assert!(args.contains("MainTest.java"), "{args}");
|
||||
// No `JAVA_HOME`: plain `javac` from `PATH`.
|
||||
assert_eq!(find_javac(&BTreeMap::new()), exe);
|
||||
}
|
||||
|
||||
/// The classpath probe runs the project's Maven command with `-pl … -am`
|
||||
/// `dependency:build-classpath`; the fake `mvnw` below only records its
|
||||
/// arguments and writes a canned classpath file for the `-pl` module.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn classpath_probe_writes_one_file_per_module_and_reads_the_compiled_one() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let model = crate::load(root, &root.join("cache"), None);
|
||||
let file = root.join("app/src/main/java/demo/Main.java");
|
||||
let target = single_file_target(root, &model, &file).unwrap();
|
||||
assert!(target.top.command.ends_with("mvnw"));
|
||||
assert_eq!(target.pl, "app");
|
||||
let cp = single_file_classpath(root, &model, &target).unwrap();
|
||||
let separator = ':';
|
||||
// Reactor class directories first, so fresh outputs shadow installed snapshots.
|
||||
let lib = root.join("lib/target/classes").to_string_lossy().into_owned();
|
||||
assert!(cp.starts_with(&lib), "{cp}");
|
||||
assert!(cp.contains("/repo/a.jar"), "{cp}");
|
||||
assert!(cp.contains("/repo/b.jar"), "{cp}");
|
||||
assert!(cp.split(separator).count() >= 3, "{cp}");
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
//! Up-to-date checks for menu builds (`Build Project` / `Build Module`, #2476).
|
||||
//!
|
||||
//! Menu builds have no fingerprint record on the launch path, so a second
|
||||
//! click always pays a full offline Maven run. This module stores one JSON
|
||||
//! record per scope (project, or one module file's build) after a successful
|
||||
//! build and compares it before the next build; on a match the build is
|
||||
//! skipped entirely. Like launch reuse, timestamp-preserving external edits
|
||||
//! are re-stamped first so Maven actually recompiles them on the next run.
|
||||
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Record of the last successful menu build of one scope.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Record {
|
||||
fingerprint: LaunchFingerprint,
|
||||
}
|
||||
|
||||
fn record_path(cache: &Path, root: &Path, file: Option<&Path>) -> PathBuf {
|
||||
let base = cache.join("menu-build").join("records");
|
||||
match file {
|
||||
Some(f) => base.join(f.strip_prefix(root).unwrap_or(f)).with_extension("json"),
|
||||
None => base.join("project.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-stamps files whose content changed without advancing mtime (git
|
||||
/// checkout, external sync): timestamp-incremental Maven would otherwise skip
|
||||
/// them as "unchanged" and the rebuild would compile nothing.
|
||||
fn restamp(root: &Path, edits: &[PathBuf]) {
|
||||
let now = std::time::SystemTime::now();
|
||||
for rel in edits {
|
||||
let path = root.join(rel);
|
||||
let _ = fs::File::options().write(true).open(&path).and_then(|f| f.set_modified(now)).or_else(|_| {
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.and_then(|f| f.set_modified(now))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last successful menu build of this scope is still fresh: its
|
||||
/// record exists and the fingerprint matches. On a mismatch caused by
|
||||
/// timestamp-preserving external edits the files are re-stamped first, so the
|
||||
/// rebuild Maven runs actually recompiles them.
|
||||
pub fn menu_up_to_date(
|
||||
cache: &Path,
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) -> bool {
|
||||
let current = match menu_fingerprint(root, model, file, cancel) {
|
||||
Ok(Some(current)) => current,
|
||||
_ => return false,
|
||||
};
|
||||
let bytes = match fs::read(record_path(cache, root, file)) {
|
||||
Ok(bytes) => bytes,
|
||||
_ => return false,
|
||||
};
|
||||
let record: Record = match serde_json::from_slice(&bytes) {
|
||||
Ok(record) => record,
|
||||
_ => return false,
|
||||
};
|
||||
if record.fingerprint != current {
|
||||
restamp(root, &record.fingerprint.mtime_preserved_edits(¤t));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records a successful menu build as the baseline for future up-to-date
|
||||
/// checks. Failures are swallowed: a missing record only costs one Maven run.
|
||||
pub fn record_menu_build(
|
||||
cache: &Path,
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) {
|
||||
let current = match menu_fingerprint(root, model, file, cancel) {
|
||||
Ok(Some(current)) => current,
|
||||
_ => return,
|
||||
};
|
||||
// A torn write fails to parse later and is treated as "no record", so a
|
||||
// plain write is enough.
|
||||
let path = record_path(cache, root, file);
|
||||
if path.parent().is_some_and(|p| fs::create_dir_all(p).is_err()) {
|
||||
return;
|
||||
}
|
||||
let bytes = match serde_json::to_vec(&Record { fingerprint: current }) {
|
||||
Ok(bytes) => bytes,
|
||||
_ => return,
|
||||
};
|
||||
let _ = fs::write(&path, bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
maven_two_module(root, "int x;");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn maven_two_module(root: &Path, extra_app: &str) {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><groupId>t</groupId><artifactId>root</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module><module>lib</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>app</artifactId><dependencies><dependency><groupId>t</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>lib</artifactId></project>"#,
|
||||
);
|
||||
write(root, "app/src/main/java/t/A.java", &format!("package t; public class A {{ {extra_app} }}"));
|
||||
write(root, "lib/src/main/java/t/L.java", "package t; public class L {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_then_skip_without_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
// The project scope is independent of the module scope.
|
||||
assert!(!menu_up_to_date(&cache, root, &model, None, &cancel));
|
||||
record_menu_build(&cache, root, &model, None, &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_invalidates_and_rerecord_restores_skip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
write(root, "app/src/main/java/t/A.java", "package t; public class A { int y; }");
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtime_preserved_edit_forces_rebuild_and_restamps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let rel = "app/src/main/java/t/A.java";
|
||||
let file = root.join(rel);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
let before = fs::metadata(&file).unwrap().modified().unwrap();
|
||||
// Same length, same mtime, different content: an external edit that
|
||||
// preserved the timestamp.
|
||||
write(root, rel, "package t; public class A { int z; }");
|
||||
fs::File::options().write(true).open(&file).and_then(|f| f.set_modified(before)).unwrap();
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
assert!(fs::metadata(&file).unwrap().modified().unwrap() > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_record_never_skips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
fs::create_dir_all(cache.join("menu-build").join("records").join("app/src/main/java/t")).unwrap();
|
||||
fs::write(cache.join("menu-build").join("records").join("app/src/main/java/t/A.json"), b"{not json").unwrap();
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_builds_never_skip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(root, "other/Main.java", "public class Main {}");
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
// A file outside any non-aggregator module has no fingerprint.
|
||||
let stray = root.join("other/Main.java");
|
||||
record_menu_build(&cache, root, &model, Some(&stray), &cancel);
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&stray), &cancel));
|
||||
}
|
||||
}
|
||||
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
|
||||
/// Quoted string literals in `s`.
|
||||
fn literals(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find(['"', '\'']) {
|
||||
let Some((value, used)) = literal(&rest[start..]) else { break };
|
||||
out.push(value);
|
||||
rest = &rest[start + used..];
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' || c == '\'' {
|
||||
out.push(chars.by_ref().take_while(|&n| n != c).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -565,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
|
||||
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(statements) {
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
@@ -592,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let v = interpolate(&v, properties);
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
@@ -604,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
|
||||
out
|
||||
}
|
||||
|
||||
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
|
||||
fn statements(line: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut index = 0;
|
||||
while index < line.len() {
|
||||
let c = line[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '{' | '}' | ';' | '\n') {
|
||||
out.push(&line[start..index]);
|
||||
start = index + c.len_utf8();
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
out.push(&line[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
|
||||
/// targets, as written in the script.
|
||||
pub fn applied_scripts(text: &str) -> Vec<String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if word != "apply" || !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = text[index + "apply".len()..].trim_start();
|
||||
if let Some(inner) = rest.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
let Some(rest) = rest.strip_prefix("from") else { continue };
|
||||
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
|
||||
continue;
|
||||
};
|
||||
let mut rest = rest.trim_start();
|
||||
if let Some(inner) = rest.strip_prefix("file") {
|
||||
let inner = inner.trim_start();
|
||||
if let Some(inner) = inner.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
}
|
||||
if let Some((path, _)) = literal(rest) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
|
||||
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = BTreeMap::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let rest = text[index + word.len()..].trim_start();
|
||||
if word == "val" || word == "var" {
|
||||
let (name, rest) = identifier(rest);
|
||||
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
|
||||
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
|
||||
if let Some(rest) = rest.strip_prefix('(')
|
||||
&& let Some((value, used)) = literal(rest.trim_start())
|
||||
&& rest.trim_start()[used..].trim_start().starts_with(')')
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word != "ext" && word != "extra" {
|
||||
continue;
|
||||
}
|
||||
if let Some(block) = rest.strip_prefix('{') {
|
||||
if let Some(end) = matching_brace(block) {
|
||||
out.extend(assignments(&block[..end]));
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('.') {
|
||||
let (name, rest) = identifier(rest);
|
||||
if let Some(value) = assigned_literal(rest)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('[')
|
||||
&& let Some((name, used)) = literal(rest.trim_start())
|
||||
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
|
||||
&& let Some(value) = assigned_literal(rest)
|
||||
{
|
||||
out.insert(name, value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
|
||||
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with(['#', '!']) {
|
||||
return None;
|
||||
}
|
||||
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
|
||||
let (key, rest) = line.split_at(separator);
|
||||
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
|
||||
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
|
||||
/// read from disk: absolute path and text. Targets that do not exist are skipped.
|
||||
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
|
||||
const MAX_SCRIPTS: usize = 256;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let first = normalize(&root.join(build_file));
|
||||
let Ok(identity) = dunce::canonicalize(&first) else { return out };
|
||||
seen.insert(identity);
|
||||
let mut stack = vec![(first, 0)];
|
||||
while let Some((path, depth)) = stack.pop() {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
if depth < MAX_DEPTH {
|
||||
let mut targets = Vec::new();
|
||||
for spec in applied_scripts(&text) {
|
||||
if seen.len() >= MAX_SCRIPTS {
|
||||
break;
|
||||
}
|
||||
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
|
||||
&& let Ok(identity) = dunce::canonicalize(&target)
|
||||
&& seen.insert(identity)
|
||||
{
|
||||
targets.push((target, depth + 1));
|
||||
}
|
||||
}
|
||||
stack.extend(targets.into_iter().rev());
|
||||
}
|
||||
out.push((path, text));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
|
||||
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
|
||||
let spec = spec.trim();
|
||||
if spec.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let properties = BTreeMap::from([
|
||||
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
|
||||
]);
|
||||
let spec = interpolate(spec, &properties);
|
||||
if spec.contains('$') {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(&spec);
|
||||
if path.is_absolute() {
|
||||
return path.is_file().then(|| normalize(path));
|
||||
}
|
||||
let project_path = normalize(&project_dir.join(path));
|
||||
if project_path.is_file() {
|
||||
return Some(project_path);
|
||||
}
|
||||
let path = normalize(&script.parent()?.join(path));
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
|
||||
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
|
||||
root.join(dir.map_or(&model.dir, |p| &p.dir))
|
||||
}
|
||||
|
||||
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
|
||||
/// expressions are left as written, so ranges and catalogs stay skipped.
|
||||
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
|
||||
if !value.contains('$') {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let braced = chars.peek() == Some(&'{');
|
||||
if braced {
|
||||
chars.next();
|
||||
}
|
||||
let mut name = String::new();
|
||||
if braced {
|
||||
for n in chars.by_ref() {
|
||||
if n == '}' {
|
||||
break;
|
||||
}
|
||||
name.push(n);
|
||||
}
|
||||
} else {
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n.is_alphanumeric() || n == '_' || n == '.' {
|
||||
name.push(n);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
|
||||
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
|
||||
let (key, used) = literal(rest.trim_start())?;
|
||||
(rest.trim_start()[used..].trim() == "]").then_some(key)
|
||||
});
|
||||
match properties.get(bracket.as_deref().unwrap_or(key)) {
|
||||
Some(v) if !name.is_empty() => out.push_str(v),
|
||||
_ => {
|
||||
out.push('$');
|
||||
if braced {
|
||||
out.push('{');
|
||||
}
|
||||
out.push_str(&name);
|
||||
if braced {
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
|
||||
fn starts_word(text: &str, index: usize) -> bool {
|
||||
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
}
|
||||
|
||||
/// Identifiers outside quoted strings, with their byte positions.
|
||||
fn code_words(text: &str) -> Vec<(usize, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let (word, _) = identifier(&text[index..]);
|
||||
out.push((index, word));
|
||||
index += word.len();
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn identifier(text: &str) -> (&str, &str) {
|
||||
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
|
||||
text.split_at(end)
|
||||
}
|
||||
|
||||
fn assigned_literal(text: &str) -> Option<String> {
|
||||
let rest = text.trim_start().strip_prefix('=')?.trim_start();
|
||||
let (value, used) = literal(rest)?;
|
||||
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
|
||||
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
|
||||
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
|
||||
}
|
||||
|
||||
/// The quoted string at the start of `s`, and how many bytes it spans.
|
||||
fn literal(s: &str) -> Option<(String, usize)> {
|
||||
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||||
let rest = &s[quote.len_utf8()..];
|
||||
let mut index = 0;
|
||||
while index < rest.len() {
|
||||
let c = rest[index..].chars().next()?;
|
||||
if c == quote {
|
||||
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
|
||||
}
|
||||
if c == '\\' {
|
||||
index += c.len_utf8();
|
||||
index += rest[index..].chars().next()?.len_utf8();
|
||||
} else if quote == '"' && rest[index..].starts_with("${") {
|
||||
index += 2;
|
||||
index += matching_brace(&rest[index..])? + 1;
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
|
||||
fn matching_brace(text: &str) -> Option<usize> {
|
||||
let mut depth = 1usize;
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next()?;
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..])?.1;
|
||||
continue;
|
||||
}
|
||||
if c == '{' {
|
||||
depth += 1;
|
||||
} else if c == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `name = 'value'` pairs in `text`.
|
||||
fn assignments(text: &str) -> Vec<(String, String)> {
|
||||
statements(text)
|
||||
.into_iter()
|
||||
.filter_map(|statement| {
|
||||
let (name, rest) = identifier(statement.trim_start());
|
||||
let value = assigned_literal(rest)?;
|
||||
(!name.is_empty()).then(|| (name.to_owned(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// Scripts a build script applies are build files too: editing one can change the model.
|
||||
for project in &model.projects {
|
||||
let Some(file) = project.build_file.as_deref() else { continue };
|
||||
let build_dir = project_build_dir(root, model, project);
|
||||
let properties = build_dir.join("gradle.properties");
|
||||
if properties.is_file() {
|
||||
out.push(properties);
|
||||
}
|
||||
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
|
||||
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
|
||||
}
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
@@ -969,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1155,7 +808,7 @@ dependencies {
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script, &BTreeMap::new());
|
||||
let deps = script_dependencies(script);
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -1169,291 +822,6 @@ dependencies {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_read_groovy_and_kotlin_forms() {
|
||||
let script = r#"
|
||||
apply plugin: 'java'
|
||||
apply from: 'gradle/dependencies.gradle'
|
||||
apply from: file('gradle/extra.gradle')
|
||||
apply(from = "gradle/kotlin.gradle.kts")
|
||||
apply(from = file("gradle/kotlin-file.gradle.kts"))
|
||||
// apply from: 'commented.gradle'
|
||||
reapply from: 'not-a-call.gradle'
|
||||
"#;
|
||||
assert_eq!(
|
||||
applied_scripts(script),
|
||||
[
|
||||
"gradle/dependencies.gradle",
|
||||
"gradle/extra.gradle",
|
||||
"gradle/kotlin.gradle.kts",
|
||||
"gradle/kotlin-file.gradle.kts"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_values_fill_in_dependency_versions() {
|
||||
let script = r#"
|
||||
ext {
|
||||
gsonVersion = '2.8.9'
|
||||
junit = "4.13.2"
|
||||
}
|
||||
ext.logback = '1.4.14'
|
||||
dependencies {
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
testImplementation "junit:junit:$junit"
|
||||
implementation "ch.qos.logback:logback-classic:${ext.logback}"
|
||||
implementation 'org.plain:literal:1.0'
|
||||
implementation "org.x:unknown:${missing}"
|
||||
}
|
||||
"#;
|
||||
let names: Vec<String> = script_dependencies(script, &script_properties(script))
|
||||
.into_iter()
|
||||
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.code.gson:gson:2.8.9",
|
||||
"junit:junit:4.13.2",
|
||||
"ch.qos.logback:logback-classic:1.4.14",
|
||||
"org.plain:literal:1.0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_properties_support_brackets_and_kotlin_delegates() {
|
||||
for declaration in [
|
||||
"ext.x = '1.2'",
|
||||
"ext { x = '1.2' }",
|
||||
"ext[\"x\"] = '1.2'",
|
||||
"extra[\"x\"] = \"1.2\"",
|
||||
"val x by extra(\"1.2\")",
|
||||
] {
|
||||
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
|
||||
assert_eq!(
|
||||
script_dependencies(&script, &script_properties(&script)),
|
||||
[("g".into(), "a".into(), "1.2".into())],
|
||||
"{declaration}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsers_ignore_apply_and_ext_inside_strings() {
|
||||
let script = r#"
|
||||
println "apply from: 'fake.gradle'"
|
||||
println "ext.x = 'bogus'"
|
||||
ext.real = '1'
|
||||
"#;
|
||||
assert!(applied_scripts(script).is_empty());
|
||||
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
|
||||
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
|
||||
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
|
||||
assert_eq!(
|
||||
script_dependencies(script, &properties),
|
||||
[
|
||||
("g".into(), "a".into(), "1.2".into()),
|
||||
("g".into(), "b".into(), "1.2-RC1".into()),
|
||||
("g".into(), "c".into(), "2".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
|
||||
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_versions_skip_unresolved_names_and_expressions() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:missing:${unknown}"
|
||||
implementation "g:expression:${1 + 2}"
|
||||
implementation "g:cyclic:${cycle}"
|
||||
implementation 'g:range:[1,2)'
|
||||
implementation 'g:dynamic:1.+'
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
|
||||
assert!(script_dependencies(script, &properties).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_property_references_support_qualified_and_bracket_access() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:a:${ext.x}"
|
||||
implementation "g:b:${extra.x}"
|
||||
implementation "g:c:${ext['x']}"
|
||||
implementation "g:d:${extra["x"]}"
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("x".into(), "1".into())]);
|
||||
assert_eq!(script_dependencies(script, &properties).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_are_read_recursively_and_survive_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
|
||||
|
||||
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
|
||||
.into_iter()
|
||||
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
|
||||
.collect();
|
||||
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
|
||||
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_prefer_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
|
||||
std::fs::write(root.join("versions.gradle"), "right").unwrap();
|
||||
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].1, "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_applied_paths_are_relative_to_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_fall_back_to_the_applying_script_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_resolve_gradle_directory_variables() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let project = root.join("app");
|
||||
let script = project.join("gradle/deps.gradle");
|
||||
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join("shared.gradle"), "").unwrap();
|
||||
std::fs::write(project.join("local.gradle"), "").unwrap();
|
||||
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
|
||||
Some(root.join("shared.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
for prefix in ["$projectDir", "${projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
|
||||
Some(project.join("local.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
|
||||
Some(root.join("shared.gradle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_skip_urls_and_missing_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Even a local path resembling a URL must not be read.
|
||||
if !cfg!(windows) {
|
||||
for scheme in ["http", "https"] {
|
||||
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("build.gradle"),
|
||||
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_symlink_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_traversal_bounds_depth_and_total_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for i in 0..300 {
|
||||
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
|
||||
}
|
||||
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
|
||||
assert_eq!(chain.len(), 33);
|
||||
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
|
||||
std::fs::write(root.join("build.gradle"), fanout).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_use_their_composite_build_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("included/app")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
|
||||
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
|
||||
let mut model = load(root).unwrap();
|
||||
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
|
||||
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
|
||||
project.build = Some(":included".into());
|
||||
model.projects.push(project);
|
||||
}
|
||||
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
|
||||
let model = crate::BuildModel { maven: None, gradle: vec![model] };
|
||||
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -528,17 +528,7 @@ mod tests {
|
||||
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
|
||||
assert_eq!(step.cwd, root.join(name));
|
||||
assert_eq!(step.program, format!("{name}-mvnw"));
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
|
||||
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
|
||||
/// including the scripts they pull in with `apply from`.
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
for p in &g.projects {
|
||||
let Some(build_file) = p.build_file.as_deref() else { continue };
|
||||
let build_dir = gradle::project_build_dir(root, g, p);
|
||||
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
|
||||
// The script's own `ext` values win over the scripts it applies.
|
||||
let mut properties = std::collections::BTreeMap::new();
|
||||
for (_, text) in scripts.iter().rev() {
|
||||
properties.extend(gradle::script_properties(text));
|
||||
}
|
||||
properties.extend(gradle::file_properties(&build_dir));
|
||||
for (_, text) in &scripts {
|
||||
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|
||||
|(group_id, artifact_id, version)| DependencyCoordinate {
|
||||
gradle: true,
|
||||
group_id,
|
||||
artifact_id,
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -328,39 +312,4 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/dependencies.gradle"),
|
||||
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_properties_override_script_extra_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
|
||||
std::fs::write(
|
||||
root.join("deps.gradle"),
|
||||
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
|
||||
assert_eq!(versions, ["1.2", "2.3"]);
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,6 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod sources;
|
||||
|
||||
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
|
||||
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
|
||||
/// isn't declared in the build, such as a vendor jar downloaded through another project or
|
||||
/// installed by hand, and fails when no declared repository has it. Tracking under another
|
||||
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
|
||||
/// plain Maven's behaviour.
|
||||
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
||||
@@ -507,7 +497,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
|
||||
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
|
||||
/// `activeByDefault`.
|
||||
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
|
||||
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
@@ -527,7 +517,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
let text = xml::read_text(settings)?;
|
||||
let text = std::fs::read_to_string(settings).ok()?;
|
||||
let root = xml::parse(&text)?;
|
||||
let repo = root.text_of("localRepository")?.trim().to_owned();
|
||||
if repo.is_empty() {
|
||||
@@ -553,7 +543,7 @@ impl Resolver {
|
||||
fn pom(&mut self, path: &Path) -> Option<RawPom> {
|
||||
self.poms
|
||||
.entry(path.to_path_buf())
|
||||
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
|
||||
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -744,7 +734,7 @@ pub(crate) fn launch_inputs(
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = xml::read_text(&path)?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
@@ -1648,7 +1638,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
|
||||
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
|
||||
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
|
||||
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
|
||||
let Some(next) = xml::read_text(&pp) else { return chain };
|
||||
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
|
||||
pom = resolver.pom(&pp);
|
||||
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
chain.push((pp, next));
|
||||
@@ -2305,24 +2295,6 @@ mod tests {
|
||||
assert!(!is_maven_project(&root.join("c")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_pom_with_declared_encoding_is_a_build() {
|
||||
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
|
||||
// instead of vanishing as "No maven project".
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let (bytes, _, _) = encoding_rs::GBK.encode(
|
||||
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
|
||||
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
|
||||
);
|
||||
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
|
||||
let model = load_with_repo(root, None);
|
||||
assert_eq!(model.projects.len(), 1);
|
||||
assert_eq!(model.projects[0].artifact_id, "应用");
|
||||
assert_eq!(model.projects[0].display_name, "演示");
|
||||
assert!(is_maven_project(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_pom_in_src_is_a_build() {
|
||||
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//! On-demand sources resolution using the project's Maven launcher and repositories.
|
||||
|
||||
use super::MavenProject;
|
||||
use crate::process;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Artifact {
|
||||
pub group: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub sources: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve only jars inside the selected local repository, including custom repositories.
|
||||
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
|
||||
let relative = jar.strip_prefix(repository).ok()?;
|
||||
let parts: Vec<&str> = relative
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
if parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = parts.len();
|
||||
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
|
||||
};
|
||||
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
|
||||
return None;
|
||||
}
|
||||
Some(Artifact {
|
||||
group: parts[..n - 3].join("."),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn download(
|
||||
root: &Path,
|
||||
project: &MavenProject,
|
||||
artifact: &Artifact,
|
||||
repository: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<PathBuf>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
|
||||
args.extend(project.options.clone());
|
||||
args.extend([
|
||||
format!("-Dmaven.repo.local={}", repository.display()),
|
||||
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
|
||||
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
|
||||
"-Dtransitive=false".into(),
|
||||
]);
|
||||
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
|
||||
if output.success
|
||||
|| output.text.lines().any(|line| {
|
||||
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
|
||||
})
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(IdeError::unavailable(
|
||||
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_sources_coordinates_from_maven_layouts() {
|
||||
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
|
||||
for (group, name, version) in [
|
||||
("org/apache/commons", "commons-lang3", "3.17.0"),
|
||||
("com/example", "example-lib", "1.0-SNAPSHOT"),
|
||||
("single", "lib", "1.0.0-rc.1"),
|
||||
] {
|
||||
let repository = Path::new(repository);
|
||||
let dir = repository.join(group).join(name).join(version);
|
||||
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
|
||||
assert_eq!(found.group, group.replace('/', "."));
|
||||
assert_eq!(found.name, name);
|
||||
assert_eq!(found.version, version);
|
||||
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_artifacts_and_paths_outside_the_repository() {
|
||||
let repository = Path::new("/m2/repository");
|
||||
for path in [
|
||||
"/m2/repository-other/g/a/1/a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1-sources.jar",
|
||||
"/m2/repository/g/a/1/other.jar",
|
||||
"/m2/repository/g/a/../a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1.pom",
|
||||
"/m2/repository/g/a-1.jar",
|
||||
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
|
||||
] {
|
||||
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,13 +341,7 @@ pub fn sync_maven(
|
||||
// `-T1` keeps mvnd serial: its default parallel build interleaves the
|
||||
// `dependency:tree` output of concurrent modules, which the sync parses
|
||||
// as one stream. mvn is serial anyway, so this only pins the daemon.
|
||||
let mut args: Vec<String> = vec![
|
||||
"-B".into(),
|
||||
"-fae".into(),
|
||||
"-Dstyle.color=never".into(),
|
||||
"-T1".into(),
|
||||
maven::LOCAL_REPO_TRACKING_ARG.into(),
|
||||
];
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
|
||||
args.extend(sync_maven_options(&options.maven_options));
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
@@ -824,40 +818,6 @@ mod tests {
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_edits_invalidate_the_cached_gradle_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
|
||||
let applied = root.join("gradle/dependencies.gradle");
|
||||
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
|
||||
let model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
projects: vec![ReportedProject {
|
||||
path: ":".into(),
|
||||
dir: root.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
let stale = || {
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
model.stale
|
||||
};
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
|
||||
assert!(stale());
|
||||
std::fs::remove_file(&applied).unwrap();
|
||||
assert!(stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
|
||||
let online = maven_sync_goals(Path::new("e.xml"), false);
|
||||
|
||||
@@ -3,57 +3,6 @@
|
||||
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
|
||||
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
use std::path::Path;
|
||||
|
||||
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
|
||||
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
|
||||
/// whole project as "No maven project" while CLI Maven reads it fine.
|
||||
pub fn read_text(path: &Path) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
|
||||
/// every single-byte encoding, so the head scans before decoding), then
|
||||
/// UTF-8, then a chardetng guess.
|
||||
pub fn decode_bytes(bytes: &[u8]) -> String {
|
||||
if let Some((enc, len)) = Encoding::for_bom(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Some(enc) = declared_encoding(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(bytes);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_owned();
|
||||
}
|
||||
let mut detector = chardetng::EncodingDetector::new();
|
||||
detector.feed(bytes, true);
|
||||
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
|
||||
text.into_owned()
|
||||
}
|
||||
|
||||
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
|
||||
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
|
||||
let mut rest = head.split("?>").next().unwrap_or(head);
|
||||
while let Some(i) = rest.find("encoding") {
|
||||
rest = &rest[i + "encoding".len()..];
|
||||
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
|
||||
let value = value.trim_start();
|
||||
let ("e, after) = value.as_bytes().split_first()?;
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
continue;
|
||||
}
|
||||
let after = std::str::from_utf8(after).ok()?;
|
||||
let end = after.find(quote as char)?;
|
||||
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
|
||||
return Some(enc);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
@@ -261,19 +210,4 @@ mod tests {
|
||||
assert!(parse("").is_none());
|
||||
assert!(parse("just text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_the_declared_encoding() {
|
||||
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
|
||||
let (bytes, _, _) =
|
||||
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
|
||||
let text = decode_bytes(&bytes);
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
|
||||
// An unknown label falls back to UTF-8 rather than mis-decoding.
|
||||
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
|
||||
// No declaration: plain UTF-8 passes through untouched.
|
||||
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,3 @@ license.workspace = true
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
@@ -21,12 +21,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
|
||||
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -171,12 +165,8 @@ mod imp {
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
open_for(ptr::null_mut())
|
||||
}
|
||||
|
||||
fn open_for(owner: Handle) -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(owner) } != 0 {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
@@ -240,29 +230,6 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
let (memory, _) = global_text(text);
|
||||
if memory.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
|
||||
unsafe {
|
||||
if !open_for(owner as Handle) {
|
||||
GlobalFree(memory);
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
|
||||
let error = std::io::Error::last_os_error();
|
||||
CloseClipboard();
|
||||
if taken {
|
||||
Ok(())
|
||||
} else {
|
||||
GlobalFree(memory);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
@@ -373,19 +340,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
board.clearContents();
|
||||
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("Cannot write clipboard text"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -416,19 +370,6 @@ mod imp {
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = text;
|
||||
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
|
||||
@@ -73,8 +73,6 @@ pub struct EditorAppearanceSettings {
|
||||
pub whitespace_inner: bool,
|
||||
pub whitespace_trailing: bool,
|
||||
pub indent_guides: bool,
|
||||
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
|
||||
pub rainbow_indent_guides: bool,
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
@@ -97,7 +95,6 @@ impl Default for EditorAppearanceSettings {
|
||||
whitespace_inner: true,
|
||||
whitespace_trailing: true,
|
||||
indent_guides: true,
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
distraction_free_width: 80,
|
||||
|
||||
@@ -42,10 +42,6 @@ pub enum IdeEvent {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
ignore_rules: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
@@ -152,12 +148,6 @@ pub enum IdeEvent {
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
},
|
||||
UpdateStatusChanged {
|
||||
status: crate::updates::UpdateStatus,
|
||||
},
|
||||
UpdateAvailable {
|
||||
update: crate::updates::UpdateInfo,
|
||||
},
|
||||
/// The project's source / test / resource / excluded roots changed (build files
|
||||
/// changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
ProjectRootsChanged {
|
||||
@@ -273,27 +263,6 @@ pub enum DebugEvent {
|
||||
change_type: String,
|
||||
message: String,
|
||||
},
|
||||
/// The session's capabilities changed after it started: fetch them again.
|
||||
Capabilities,
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
}
|
||||
|
||||
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmBackendState {
|
||||
Ready,
|
||||
/// Turned off for the rest of the session: the debug adapter lacks what it needs.
|
||||
Unsupported {
|
||||
detail: String,
|
||||
},
|
||||
/// The Kotlin JDWP link only forwards now; inspecting stops.
|
||||
Degraded {
|
||||
detail: String,
|
||||
},
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Where backend crates publish [`IdeEvent`]s. Cheap to clone via `Arc`.
|
||||
@@ -315,19 +284,3 @@ pub struct NullSink;
|
||||
impl EventSink for NullSink {
|
||||
fn emit(&self, _: IdeEvent) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn jvm_backend_events_json_shape() {
|
||||
let degraded = DebugEvent::JvmBackend { state: JvmBackendState::Degraded { detail: "x".into() } };
|
||||
assert_eq!(
|
||||
serde_json::to_value(°raded).unwrap(),
|
||||
json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
|
||||
}
|
||||
}
|
||||
+10
-128
@@ -4,12 +4,10 @@ use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -30,11 +28,10 @@ pub struct InputWriter {
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
/// Unix writers must first be made nonblocking with `nonblocking`.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -77,19 +74,11 @@ impl InputWriter {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
@@ -179,46 +168,14 @@ impl Drop for InputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
/// PTY clones share file status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,11 +221,6 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -291,11 +243,6 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -309,26 +256,6 @@ mod tests {
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -339,59 +266,14 @@ mod tests {
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
|
||||
@@ -16,7 +16,6 @@ pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
@@ -26,7 +25,6 @@ pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod updates;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -21,32 +21,6 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Removes ANSI escape sequences (colour, cursor movement).
|
||||
pub fn strip_ansi(s: &str) -> String {
|
||||
if !s.contains('\u{1b}') {
|
||||
return s.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\u{1b}' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for c in chars.by_ref() {
|
||||
if c.is_ascii_alphabetic() || c == '~' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
|
||||
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
|
||||
/// Callers must buffer incomplete lines so multibyte characters are never split.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -72,75 +72,6 @@ pub fn kill_tree_of(pid: u32) {
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
/// Whether a process with this id exists. PID reuse makes this conservative: a reused id keeps
|
||||
/// whatever was tied to the old process alive, a live process is never reported dead.
|
||||
pub fn process_alive(pid: u32) -> bool {
|
||||
// `kill(0, 0)` would signal the caller's own process group.
|
||||
if pid == 0 {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Above `i32::MAX` the id would wrap to a negative value, which `kill` reads as a group.
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else { return false };
|
||||
// SAFETY: signal 0 delivers nothing; it only checks that the process exists and is reachable.
|
||||
unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by this function.
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
// A process we may not open still exists.
|
||||
// SAFETY: reads the thread's last error, set by the call above.
|
||||
return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
|
||||
}
|
||||
// SAFETY: `handle` is valid and nobody else owns it.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
let mut code = 0u32;
|
||||
// SAFETY: a live process handle opened with query access; one out-parameter.
|
||||
let ok = unsafe { GetExitCodeProcess(handle.as_raw_handle(), &mut code) };
|
||||
// A handle that cannot be queried is not evidence that the process is gone.
|
||||
ok == 0 || code == STILL_ACTIVE as u32
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
pub fn process_tree(pid: i32) -> Vec<i32> {
|
||||
use std::collections::HashMap;
|
||||
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
|
||||
return vec![pid];
|
||||
};
|
||||
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
|
||||
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
|
||||
children.entry(parent).or_default().push(p);
|
||||
}
|
||||
let mut tree = vec![pid];
|
||||
let mut at = 0;
|
||||
while at < tree.len() {
|
||||
if let Some(kids) = children.get(&tree[at]) {
|
||||
tree.extend(kids.iter().copied());
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
@@ -173,23 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_job::Job;
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
@@ -210,48 +130,13 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -262,7 +147,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -302,12 +187,8 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
@@ -322,167 +203,6 @@ mod windows_tree {
|
||||
}
|
||||
}
|
||||
|
||||
/// A kernel job object over one launched process tree. Everything a launch starts joins
|
||||
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
|
||||
/// started after the launch's snapshot was taken, orphans of a launcher that already
|
||||
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
|
||||
/// processes born in the window a process-enumeration kill would miss. Spawns assign
|
||||
/// their root right after it starts; when the assignment fails (an enclosing job that
|
||||
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
|
||||
#[cfg(windows)]
|
||||
mod windows_job {
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A job object holding one launch's processes. The handle is kept open for the
|
||||
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
|
||||
/// launch record (a run entry, a debug session) is finally dropped.
|
||||
pub struct Job(OwnedHandle);
|
||||
|
||||
impl Job {
|
||||
/// Takes the just-started `child` into a new job, so everything it starts is in
|
||||
/// there too. `None` when the job could not be made or the process rejected the
|
||||
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
|
||||
pub fn for_child(child: &std::process::Child) -> Option<Job> {
|
||||
// SAFETY: the child's own handle is a live process handle.
|
||||
Self::from_handle(child.as_raw_handle())
|
||||
}
|
||||
|
||||
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
|
||||
/// a `tokio` child). `None` when the id no longer names an openable process — a
|
||||
/// caller that holds the child cannot have had it reaped, so this is a dead or
|
||||
/// already-gone launch.
|
||||
pub fn for_pid(pid: u32) -> Option<Job> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the handle was just opened and is owned here.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
Self::from_handle(handle.as_raw_handle())
|
||||
}
|
||||
|
||||
fn from_handle(handle: RawHandle) -> Option<Job> {
|
||||
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job.is_null() || job == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let job = unsafe { OwnedHandle::from_raw_handle(job) };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
PerProcessUserTimeLimit: 0,
|
||||
PerJobUserTimeLimit: 0,
|
||||
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
MinimumWorkingSetSize: 0,
|
||||
MaximumWorkingSetSize: 0,
|
||||
ActiveProcessLimit: 0,
|
||||
Affinity: 0,
|
||||
PriorityClass: 0,
|
||||
SchedulingClass: 0,
|
||||
},
|
||||
IoInfo: IO_COUNTERS {
|
||||
ReadOperationCount: 0,
|
||||
WriteOperationCount: 0,
|
||||
OtherOperationCount: 0,
|
||||
ReadTransferCount: 0,
|
||||
WriteTransferCount: 0,
|
||||
OtherTransferCount: 0,
|
||||
},
|
||||
ProcessMemoryLimit: 0,
|
||||
JobMemoryLimit: 0,
|
||||
PeakProcessMemoryUsed: 0,
|
||||
PeakJobMemoryUsed: 0,
|
||||
};
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
job.as_raw_handle(),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut limits as *mut _ as *const _,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the process handle belongs to the just-spawned child and outlives
|
||||
// this call; the job handle is owned here.
|
||||
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Job(job))
|
||||
}
|
||||
|
||||
/// Adds another just-started root of the same launch to the job (a Go build's
|
||||
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
|
||||
/// take; the process then relies on its own kill.
|
||||
pub fn assign_pid(&self, pid: u32) -> bool {
|
||||
// SAFETY: plain call; the handle is opened only to assign it, and the job
|
||||
// handle is owned and valid.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: both handles are valid for the duration of the call.
|
||||
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
|
||||
// SAFETY: the process handle was opened above and is owned here.
|
||||
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
|
||||
ok != 0
|
||||
}
|
||||
|
||||
/// Terminates every process in the job. `false` when the call failed (the job
|
||||
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
|
||||
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
|
||||
/// the tree is gone.
|
||||
pub fn terminate(&self) -> bool {
|
||||
// SAFETY: a job handle owned here.
|
||||
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
|
||||
}
|
||||
|
||||
/// Whether any process of the launch is still in the job. When the job's state
|
||||
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
|
||||
/// [`Job::terminate`] does not depend on the answer.
|
||||
pub fn is_running(&self) -> bool {
|
||||
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
|
||||
// longer, which means there are processes.
|
||||
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
|
||||
NumberOfAssignedProcesses: 0,
|
||||
NumberOfProcessIdsInList: 0,
|
||||
ProcessIdList: [0],
|
||||
};
|
||||
let mut returned = 0u32;
|
||||
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
QueryInformationJobObject(
|
||||
self.0.as_raw_handle(),
|
||||
JobObjectBasicProcessIdList,
|
||||
&mut list as *mut _ as *mut _,
|
||||
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
|
||||
&mut returned,
|
||||
)
|
||||
};
|
||||
if ok != 0 {
|
||||
list.NumberOfProcessIdsInList > 0
|
||||
} else {
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -799,30 +519,6 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn process_alive_tells_live_from_dead_pids() {
|
||||
assert!(process_alive(std::process::id()));
|
||||
assert!(!process_alive(0));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", "exit 0"]);
|
||||
cmd
|
||||
} else {
|
||||
Command::new("true")
|
||||
};
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let pid = child.id();
|
||||
child.wait().unwrap();
|
||||
drop(child);
|
||||
assert!(!process_alive(pid));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_alive_rejects_ids_that_are_not_a_single_process() {
|
||||
assert!(!process_alive(u32::MAX));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_tree_of_reaches_what_the_child_started() {
|
||||
@@ -1143,25 +839,6 @@ mod tests {
|
||||
}
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_tree_lists_the_child_of_a_shell() {
|
||||
let mut cmd = Command::new("sh");
|
||||
own_group(&mut cmd).args(["-c", "sleep 5 & wait"]);
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let sh_pid = child.id() as i32;
|
||||
// The shell forks `sleep` after it starts, so the tree grows a moment later.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut tree = process_tree(sh_pid);
|
||||
while tree.len() < 2 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
tree = process_tree(sh_pid);
|
||||
}
|
||||
kill_tree(&mut child);
|
||||
assert_eq!(tree[0], sh_pid, "the pid itself comes first");
|
||||
assert!(tree.len() >= 2 && tree[1..].iter().all(|p| *p != sh_pid), "the shell's child follows: {tree:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
@@ -1231,66 +908,3 @@ mod kill_tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
|
||||
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
|
||||
/// application: the job must reach both.
|
||||
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let child = cmd.spawn().unwrap();
|
||||
let job = Job::for_child(&child).expect("the job takes the launch");
|
||||
let tree = ProcessTree::capture(child.id());
|
||||
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
|
||||
(child, job, tree)
|
||||
}
|
||||
|
||||
/// Termination is asynchronous: the job reports its processes gone while their
|
||||
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
|
||||
fn assert_gone(tree: &ProcessTree) {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while tree.is_running() {
|
||||
assert!(Instant::now() < until, "the tree survived the job");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_terminates_the_tree_the_root_started() {
|
||||
let (mut child, job, tree) = launch_tree();
|
||||
assert!(job.is_running());
|
||||
assert!(job.terminate(), "TerminateJobObject succeeds");
|
||||
// The captured tree's handles survive the root's death, so the wait below is the
|
||||
// grandchild's, not a reused pid's.
|
||||
assert_gone(&tree);
|
||||
assert!(!job.is_running());
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_spawned_root_joins_the_job() {
|
||||
let (mut first, job, _) = launch_tree();
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let mut second = cmd.spawn().unwrap();
|
||||
let second_tree = ProcessTree::capture(second.id());
|
||||
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
|
||||
assert!(job.terminate());
|
||||
assert_gone(&second_tree);
|
||||
let _ = first.wait();
|
||||
let _ = second.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_pid_makes_no_job() {
|
||||
// An id of a process this test cannot open (or that does not exist) gives no job,
|
||||
// which is the callers' cue for the `taskkill /T /F` fallback.
|
||||
assert!(Job::for_pid(u32::MAX).is_none());
|
||||
}
|
||||
}
|
||||
@@ -201,18 +201,7 @@ pub struct PluginRunTarget {
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<PluginCommandOverride>,
|
||||
}
|
||||
|
||||
/// Per-command argv prefix and optional replacement working-directory arguments.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommandOverride {
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
/// None inherits the target's list; Some([]) suppresses it.
|
||||
#[serde(default)]
|
||||
pub workdir_args: Option<Vec<String>>,
|
||||
pub commands: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -59,16 +59,16 @@ pub struct Settings {
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
/// Settings › Agents.
|
||||
/// Tools › Agents › Chat.
|
||||
pub agents: AgentSettings,
|
||||
/// Agents › MCP Server (global only).
|
||||
/// Tools › MCP Server (global only).
|
||||
pub mcp_server: McpServerSettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Settings › Agents: what the conversation shows and how messages are sent.
|
||||
/// What the meters in an agent task's header show.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSettings {
|
||||
@@ -80,182 +80,15 @@ pub struct AgentSettings {
|
||||
pub show_usage_limits: bool,
|
||||
/// What the session has cost so far, as the agent reports it.
|
||||
pub show_cost: bool,
|
||||
/// A finished turn's work folds into one line under its message, leaving the reply.
|
||||
pub fold_turns: bool,
|
||||
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
|
||||
pub smooth_text: bool,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ThinkingDisplay)]
|
||||
pub thinking: ThinkingDisplay,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ToolCallDisplay)]
|
||||
pub tool_calls: ToolCallDisplay,
|
||||
/// A tool call's output (a command's, a diff) is shown without opening it.
|
||||
pub expand_tool_output: bool,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = SendKey)]
|
||||
pub send_with: SendKey,
|
||||
/// What sending does while the agent works: queue the message for after the turn, or
|
||||
/// send it into the turn.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = WhileRunning)]
|
||||
pub while_running: WhileRunning,
|
||||
/// Settings new tasks of an agent start with: agent id → option id → value.
|
||||
#[serde(deserialize_with = "agent_defaults")]
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
|
||||
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
|
||||
/// MCP servers passed to coding agents in `session/new`, `session/load` and `session/fork`,
|
||||
/// keyed by server name (`[agents.mcpServers.<name>]`).
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = BTreeMap<String, AgentMcpServer>)]
|
||||
pub mcp_servers: BTreeMap<String, AgentMcpServer>,
|
||||
}
|
||||
|
||||
impl Default for AgentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_context_usage: true,
|
||||
show_work_time: true,
|
||||
show_usage_limits: true,
|
||||
show_cost: false,
|
||||
fold_turns: true,
|
||||
smooth_text: true,
|
||||
thinking: ThinkingDisplay::Auto,
|
||||
tool_calls: ToolCallDisplay::Grouped,
|
||||
expand_tool_output: false,
|
||||
send_with: SendKey::Enter,
|
||||
while_running: WhileRunning::Queue,
|
||||
defaults: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
}
|
||||
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport of an MCP server configured in Settings › Agents › MCP Servers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpTransport {
|
||||
#[default]
|
||||
Stdio,
|
||||
Http,
|
||||
Sse,
|
||||
}
|
||||
|
||||
/// An MCP server passed to coding agents in `session/new`, `session/load` and `session/fork`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentMcpServer {
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = McpTransport)]
|
||||
pub transport: McpTransport,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Agent provider ids this server is enabled for (e.g. `["claude", "codex"]`);
|
||||
/// empty means enabled for all agents.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentMcpServer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
url: None,
|
||||
headers: BTreeMap::new(),
|
||||
agents: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand-edited value these settings don't know reads as the default, rather than
|
||||
/// failing the whole settings file.
|
||||
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::de::DeserializeOwned + Default,
|
||||
{
|
||||
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
|
||||
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
|
||||
Ok(agents
|
||||
.into_iter()
|
||||
.filter_map(|(agent, options)| match options {
|
||||
serde_json::Value::Object(options) => Some((
|
||||
agent,
|
||||
options
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
serde_json::Value::String(v) => Some((k, v)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// How the agent's reasoning shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThinkingDisplay {
|
||||
/// Open while it streams, folded once it ends.
|
||||
#[default]
|
||||
Auto,
|
||||
Expanded,
|
||||
Collapsed,
|
||||
}
|
||||
|
||||
/// How tool calls between messages show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ToolCallDisplay {
|
||||
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
|
||||
#[default]
|
||||
Grouped,
|
||||
/// Each call on its own line.
|
||||
Each,
|
||||
}
|
||||
|
||||
/// The key that sends a message; the other one starts a new line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SendKey {
|
||||
#[default]
|
||||
Enter,
|
||||
ModEnter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WhileRunning {
|
||||
#[default]
|
||||
Queue,
|
||||
Steer,
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
/// Tools › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
@@ -433,10 +266,6 @@ pub struct CustomKeymap {
|
||||
pub struct GeneralSettings {
|
||||
pub reopen_last_project: bool,
|
||||
pub confirm_exit: bool,
|
||||
/// When on, the last window closes instead of showing the Welcome screen.
|
||||
pub close_without_welcome: bool,
|
||||
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
|
||||
pub close_tabs_with_window: bool,
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
@@ -452,11 +281,6 @@ pub struct GeneralSettings {
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
pub auto_check_updates: bool,
|
||||
/// System Settings › Updates: open the What's New page after the app starts a new version.
|
||||
pub show_whats_new_after_update: bool,
|
||||
/// The version the app last started with, so the first start after an update is recognisable.
|
||||
pub last_run_version: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -464,8 +288,6 @@ impl Default for GeneralSettings {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: true,
|
||||
close_without_welcome: false,
|
||||
close_tabs_with_window: false,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -474,9 +296,6 @@ impl Default for GeneralSettings {
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
auto_check_updates: true,
|
||||
show_whats_new_after_update: true,
|
||||
last_run_version: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,21 +350,6 @@ impl Default for NotificationGroupSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
|
||||
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NavigationBarPosition {
|
||||
/// Above the editor, below the main menu / toolbar.
|
||||
Top,
|
||||
/// In the footer, next to the status bar widgets — the default, matching
|
||||
/// where the bar lived before the three-state choice existed.
|
||||
#[default]
|
||||
StatusBar,
|
||||
/// Not shown at all.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
@@ -584,8 +388,6 @@ pub struct AppearanceSettings {
|
||||
pub main_menu_style: String,
|
||||
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
|
||||
pub main_menu_alt: bool,
|
||||
/// Always show the main menu titles instead of the hamburger button (#1521).
|
||||
pub always_show_main_menu: bool,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -596,9 +398,6 @@ pub struct AppearanceSettings {
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
|
||||
/// the status bar by default, above the editor, or not at all.
|
||||
pub navigation_bar: NavigationBarPosition,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -663,7 +462,6 @@ impl Default for AppearanceSettings {
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
main_menu_alt: true,
|
||||
always_show_main_menu: false,
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
@@ -672,7 +470,6 @@ impl Default for AppearanceSettings {
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -710,9 +507,6 @@ pub struct EditorSettings {
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
|
||||
pub semantic_highlighting: bool,
|
||||
/// Docstring markup format whose tags and keywords the Python editor highlights
|
||||
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
|
||||
pub docstring_format: String,
|
||||
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
|
||||
pub rainbow_brackets: bool,
|
||||
pub ligatures: bool,
|
||||
@@ -757,7 +551,6 @@ impl Default for EditorSettings {
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
docstring_format: "rest".into(),
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
fallback_font: String::new(),
|
||||
@@ -802,15 +595,6 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposeFileOverride {
|
||||
Kept,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
@@ -838,7 +622,6 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -860,7 +643,6 @@ pub struct LanguagesSettings {
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
@@ -882,22 +664,10 @@ impl Default for LanguagesSettings {
|
||||
java: JavaSettings::default(),
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArduinoSettings {
|
||||
pub cli: String,
|
||||
pub clangd: String,
|
||||
pub language_server: String,
|
||||
pub cli_config: String,
|
||||
pub fqbn: String,
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -1115,8 +885,6 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -1339,11 +1107,6 @@ pub struct ProjectStructureSettings {
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
|
||||
/// A member not listed here (or with an empty list) navigates only into itself.
|
||||
pub member_dependencies: BTreeMap<String, Vec<String>>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
@@ -1405,26 +1168,6 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
let settings: Settings = serde_json::from_str(old).unwrap();
|
||||
assert!(!settings.general.close_without_welcome);
|
||||
assert!(!settings.general.close_tabs_with_window);
|
||||
}
|
||||
for close_window in [false, true] {
|
||||
for close_tabs in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.general.close_without_welcome = close_window;
|
||||
settings.general.close_tabs_with_window = close_tabs;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
|
||||
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
|
||||
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
@@ -1487,21 +1230,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docstring_format_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
|
||||
assert_eq!(old.editor.docstring_format, "rest");
|
||||
assert_eq!(Settings::default().editor.docstring_format, "rest");
|
||||
for format in ["plain", "rest", "epytext", "google", "numpy"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.editor.docstring_format = format.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["editor"]["docstringFormat"], format);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.editor.docstring_format, format);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
@@ -1517,27 +1245,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
|
||||
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
|
||||
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
|
||||
// accepts a key path it finds there.
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
|
||||
for on in [true, false] {
|
||||
let mut settings = Settings::default();
|
||||
settings.vcs.commit.double_click_opens_diff = Some(on);
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
// Explicit `null` reads back as unset.
|
||||
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
|
||||
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
@@ -1552,22 +1259,6 @@ mod tests {
|
||||
assert_eq!(restored.agents, settings.agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
|
||||
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.editor.font_size, 17.0);
|
||||
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
|
||||
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
|
||||
assert_eq!(
|
||||
settings.agents.defaults,
|
||||
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
@@ -1579,11 +1270,6 @@ mod tests {
|
||||
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
|
||||
assert!(!off.appearance.main_menu_alt);
|
||||
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert!(!old.appearance.always_show_main_menu);
|
||||
let shown: Settings = serde_json::from_str(r#"{"appearance":{"alwaysShowMainMenu":true}}"#).unwrap();
|
||||
assert!(shown.appearance.always_show_main_menu);
|
||||
assert_eq!(serde_json::to_value(shown).unwrap()["appearance"]["alwaysShowMainMenu"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1780,26 +1466,6 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
#[default]
|
||||
Github,
|
||||
Cnb,
|
||||
}
|
||||
|
||||
impl UpdateSource {
|
||||
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
|
||||
match preference {
|
||||
"github" => return Self::Github,
|
||||
"cnb" => return Self::Cnb,
|
||||
_ => {}
|
||||
}
|
||||
let locale = locale.trim();
|
||||
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
|
||||
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
|
||||
Self::Cnb
|
||||
} else {
|
||||
Self::Github
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatus {
|
||||
/// Unix milliseconds of the last successful check in this process.
|
||||
pub last_checked: Option<f64>,
|
||||
pub checking: bool,
|
||||
}
|
||||
|
||||
/// One schedule and announcement history for the desktop process, independent of its windows.
|
||||
#[derive(Default)]
|
||||
pub struct UpdatePolicy {
|
||||
attempted: Option<Instant>,
|
||||
pub status: UpdateStatus,
|
||||
announced: HashSet<String>,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn due_in(&self, now: Instant) -> Duration {
|
||||
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
|
||||
}
|
||||
|
||||
pub fn begin(&mut self, now: Instant) {
|
||||
self.attempted = Some(now);
|
||||
self.status.checking = true;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, checked_at: Option<f64>) {
|
||||
self.status.checking = false;
|
||||
if let Some(at) = checked_at {
|
||||
self.status.last_checked = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn announced(&self, version: &str) -> bool {
|
||||
self.announced.contains(version)
|
||||
}
|
||||
|
||||
/// Manual checks always show their result, but also count as an announcement.
|
||||
pub fn announce(&mut self, version: &str) -> bool {
|
||||
self.announced.insert(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
|
||||
for (preference, locale, system, expected) in [
|
||||
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
|
||||
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
|
||||
("auto", "system", "zh-CN", UpdateSource::Cnb),
|
||||
("auto", "auto", "zh-TW", UpdateSource::Cnb),
|
||||
("auto", "", "zh", UpdateSource::Cnb),
|
||||
("auto", "en", "zh-CN", UpdateSource::Github),
|
||||
("github", "zh", "zh-CN", UpdateSource::Github),
|
||||
("cnb", "en", "en-US", UpdateSource::Cnb),
|
||||
] {
|
||||
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_at_startup_and_every_four_hours_even_after_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(policy.due_in(now), Duration::ZERO);
|
||||
policy.begin(now);
|
||||
assert!(policy.status.checking);
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, None);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
|
||||
policy.begin(now + CHECK_INTERVAL);
|
||||
policy.finish(Some(1234.0));
|
||||
assert_eq!(policy.status.last_checked, Some(1234.0));
|
||||
assert!(!policy.status.checking);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
policy.begin(now);
|
||||
policy.finish(Some(1000.0));
|
||||
policy.begin(now + Duration::from_secs(60));
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, Some(1000.0));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
assert!(policy.announce("0.1.40"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(policy.announce("0.1.41"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(UpdatePolicy::default().announce("0.1.40"));
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ impl Default for VcsSettings {
|
||||
pub struct GitSettings {
|
||||
/// Show blame after the current editor line.
|
||||
pub inline_blame: bool,
|
||||
pub show_branch_in_project_view: bool,
|
||||
/// Path to the git executable; empty = auto-detect.
|
||||
pub executable: String,
|
||||
/// Default of Update Project: `merge` | `rebase`.
|
||||
@@ -75,7 +74,6 @@ impl Default for GitSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inline_blame: false,
|
||||
show_branch_in_project_view: false,
|
||||
executable: String::new(),
|
||||
update_method: "merge".into(),
|
||||
auto_update_on_push_rejected: false,
|
||||
@@ -117,13 +115,6 @@ pub struct CommitSettings {
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// Double-click / Enter in the changes view: `None` follows the interface (the
|
||||
/// Commit tool window opens the diff, Local Changes the source file).
|
||||
///
|
||||
/// No `skip_serializing_if`: an unset value must still appear in
|
||||
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
|
||||
#[specta(optional)]
|
||||
pub double_click_opens_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -144,7 +135,6 @@ impl Default for CommitSettings {
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +312,6 @@ pub struct TerminalSettings {
|
||||
pub override_ide_shortcuts: bool,
|
||||
pub shell_integration: bool,
|
||||
pub highlight_hyperlinks: bool,
|
||||
pub highlight_selection: bool,
|
||||
pub option_as_meta: bool,
|
||||
/// Escape moves focus to the editor instead of reaching the shell.
|
||||
pub escape_to_editor: bool,
|
||||
@@ -355,7 +344,6 @@ impl Default for TerminalSettings {
|
||||
override_ide_shortcuts: true,
|
||||
shell_integration: true,
|
||||
highlight_hyperlinks: true,
|
||||
highlight_selection: true,
|
||||
option_as_meta: false,
|
||||
escape_to_editor: false,
|
||||
cursor_shape: "block".into(),
|
||||
|
||||
@@ -11,16 +11,12 @@ tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-jvm-attach.workspace = true
|
||||
ide-jvm-inspect.workspace = true
|
||||
ide-jdwp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
percent-encoding = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
||||
+82
-342
@@ -101,14 +101,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
|
||||
// (`prepare_gdb` refuses older ones with the required version in the message).
|
||||
"gdb" => cfg(
|
||||
"gdb",
|
||||
&["-i", "dap"],
|
||||
"stdio",
|
||||
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
|
||||
),
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
@@ -135,7 +127,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
"delve" => "go",
|
||||
"netcoredbg" => "coreclr",
|
||||
"lldb-dap" => "lldb-dap",
|
||||
"gdb" => "gdb",
|
||||
"java-debug" => "java",
|
||||
_ => return None,
|
||||
})
|
||||
@@ -175,11 +166,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
}
|
||||
|
||||
/// A port that was free a moment ago on the loopback interface.
|
||||
///
|
||||
/// Inherently racy: another process can bind the port between the listener being
|
||||
/// dropped here and the adapter binding it. Callers that hand the port to a child
|
||||
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
|
||||
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
|
||||
pub fn free_port() -> std::io::Result<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
@@ -341,89 +327,6 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
|
||||
/// "Interpreter `dap' unrecognized".
|
||||
const GDB_MIN_MAJOR: u32 = 14;
|
||||
|
||||
/// `(major, minor)` of the first version number in the output of `gdb --version`
|
||||
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
|
||||
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
|
||||
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
|
||||
let line = output.lines().next()?;
|
||||
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
|
||||
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
|
||||
let b = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if !b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
let major: u32 = line[start..i].parse().ok()?;
|
||||
let mut minor = 0;
|
||||
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let ms = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
minor = line[ms..i].parse().unwrap_or(0);
|
||||
}
|
||||
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
|
||||
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
|
||||
continue;
|
||||
}
|
||||
return Some((major, minor));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe the already resolved and verified executable with its launch environment.
|
||||
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
|
||||
let mut probe = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
probe.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(probe.as_std_mut());
|
||||
let probe = probe
|
||||
.arg("--version")
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(spec.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// Refuse old GDB versions and report whether launch must follow configurationDone.
|
||||
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
|
||||
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
|
||||
let Some((major, minor)) = gdb_version(binary, spec).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if major >= GDB_MIN_MAJOR {
|
||||
return Ok(major == 15);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
|
||||
this one has no DAP interpreter (`gdb -i dap`)"
|
||||
);
|
||||
if let Some(hint) = &spec.install_hint {
|
||||
msg.push_str(". ");
|
||||
msg.push_str(hint);
|
||||
}
|
||||
Err(IdeError::unavailable(msg))
|
||||
}
|
||||
|
||||
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
|
||||
/// than a program: it has no process of its own and is reached through `debugServer`.
|
||||
pub fn is_plugin_bundle(command: &str) -> bool {
|
||||
@@ -457,8 +360,6 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
|
||||
|
||||
/// A connected adapter: byte streams plus the process (if we spawned one).
|
||||
pub struct AdapterIo {
|
||||
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
|
||||
pub launch_after_configuration: bool,
|
||||
pub reader: BoxReader,
|
||||
pub writer: BoxWriter,
|
||||
pub child: Option<Child>,
|
||||
@@ -493,9 +394,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Probe of a `connect` address before starting the command.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
|
||||
/// claimed by another process before the adapter binds it, so a new one usually wins.
|
||||
const PORT_ATTEMPTS: u32 = 3;
|
||||
|
||||
impl AdapterLauncher for ProcessLauncher {
|
||||
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
|
||||
@@ -510,7 +408,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = connect_with_retries(host, port, None, timeout).await?;
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -534,7 +431,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -563,152 +459,95 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let launch_after_configuration =
|
||||
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => {
|
||||
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
Ok(io)
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => Some(free_port()?),
|
||||
},
|
||||
Transport::Stdio => None,
|
||||
};
|
||||
let args = substitute_root(&spec.args, &spec.cwd);
|
||||
let args = match port {
|
||||
Some(port) => substitute_port(&args, port),
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
}
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Transport::Tcp => {
|
||||
// An embedded adapter started without `${port}` listens on its usual
|
||||
// address; every other TCP adapter gets a port chosen here so it can be
|
||||
// retried if another process takes it before the adapter binds.
|
||||
let fixed = match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => None,
|
||||
};
|
||||
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
for attempt in 1..=PORT_ATTEMPTS {
|
||||
let port = match fixed {
|
||||
Some(port) => port,
|
||||
None => free_port()?,
|
||||
};
|
||||
let args = substitute_port(&base_args, port);
|
||||
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
|
||||
Ok(mut io) => {
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
return Ok(io);
|
||||
}
|
||||
Err(e) => {
|
||||
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
|
||||
tracing::warn!(
|
||||
target: "ide_dap::adapter",
|
||||
"debug adapter failed to start on port {port}: {}; retrying",
|
||||
e.message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
unreachable!("the retry loop returns on its last attempt")
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
|
||||
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
|
||||
async fn spawn_adapter(
|
||||
spec: &AdapterSpec,
|
||||
binary: &Path,
|
||||
args: &[String],
|
||||
port: Option<u16>,
|
||||
start_timeout: Duration,
|
||||
) -> IdeResult<AdapterIo> {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let mut cmd = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
// Every adapter leads its own process group: the program an adapter launches itself
|
||||
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
|
||||
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.args(args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed adapter start that another process grabbing the free port can explain
|
||||
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
|
||||
/// it could not bind). Anything else is a real start failure and is not retried.
|
||||
fn is_port_race(err: &IdeError) -> bool {
|
||||
let message = err.message.to_ascii_lowercase();
|
||||
message.contains("address already in use")
|
||||
|| message.contains("addrinuse")
|
||||
|| message.contains("only one usage of each socket address")
|
||||
|| message.contains("exited before accepting connections")
|
||||
}
|
||||
|
||||
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
@@ -844,102 +683,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builtins_cover_common_adapters() {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
|
||||
let cfg = builtin(name).unwrap();
|
||||
assert!(cfg.install_hint.is_some());
|
||||
}
|
||||
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
|
||||
assert_eq!(delve.transport, Transport::Tcp);
|
||||
// GDB's own DAP interpreter over stdio, with the required version in its hint.
|
||||
let gdb = builtin("gdb").unwrap();
|
||||
assert_eq!(gdb.command, "gdb");
|
||||
assert_eq!(gdb.args, ["-i", "dap"]);
|
||||
assert_eq!(gdb.transport, "stdio");
|
||||
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
|
||||
assert_eq!(default_type("gdb"), Some("gdb"));
|
||||
assert!(builtin("nope").is_none());
|
||||
}
|
||||
|
||||
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
|
||||
#[test]
|
||||
fn parses_gdb_versions() {
|
||||
for (out, want) in [
|
||||
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
|
||||
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
|
||||
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
|
||||
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
|
||||
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
|
||||
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
|
||||
("GNU gdb 14", Some((14, 0))),
|
||||
("no version here", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(parse_gdb_version(out), want, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_never_executes_a_changed_pinned_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
std::fs::write(&fake, "approved").unwrap();
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
spec.command = fake.to_string_lossy().into_owned();
|
||||
spec.pinned = Some(pin);
|
||||
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
// Only the approved path is executable; the symbolic command must never be probed.
|
||||
spec.command = "definitely-not-a-real-binary-openidea".into();
|
||||
spec.pinned = Some(pin.clone());
|
||||
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
|
||||
let result = ProcessLauncher.launch(spec, None).await;
|
||||
if version == "12.1" {
|
||||
let error = result.err().expect("GDB 12 has no DAP interpreter");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
|
||||
assert!(error.message.contains("12.1"), "{}", error.message);
|
||||
} else {
|
||||
let mut io = result.unwrap();
|
||||
assert_eq!(io.launch_after_configuration, deferred, "{version}");
|
||||
let mut output = String::new();
|
||||
io.reader.read_to_string(&mut output).await.unwrap();
|
||||
assert_eq!(output.trim(), "adapter");
|
||||
io.child.as_mut().unwrap().wait().await.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_port_is_nonzero() {
|
||||
assert_ne!(free_port().unwrap(), 0);
|
||||
@@ -1023,16 +775,4 @@ mod tests {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
|
||||
);
|
||||
assert!(is_port_race(&in_use));
|
||||
assert!(is_port_race(&IdeError::unavailable(
|
||||
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
|
||||
)));
|
||||
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,13 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
@@ -279,25 +285,6 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
|
||||
}
|
||||
}
|
||||
|
||||
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
|
||||
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
|
||||
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
|
||||
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
|
||||
/// output (UTF-8, else the system code page) before parsing.
|
||||
fn parse_message(body: &[u8]) -> Option<Value> {
|
||||
let error = match serde_json::from_slice(body) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
if std::str::from_utf8(body).is_err()
|
||||
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
tracing::warn!("dap: unparsable message: {error}");
|
||||
None
|
||||
}
|
||||
|
||||
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
|
||||
fn error_message(msg: &Value) -> String {
|
||||
if let Some(err) = msg.pointer("/body/error")
|
||||
@@ -319,22 +306,6 @@ fn error_message(msg: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_output_events_with_non_utf8_text() {
|
||||
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
|
||||
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
|
||||
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
|
||||
body.extend_from_slice(br#"\n"}}"#);
|
||||
let msg = parse_message(&body).expect("parsed");
|
||||
assert_eq!(msg["event"], "output");
|
||||
let output = msg["body"]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with('\n'));
|
||||
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
|
||||
#[cfg(not(windows))]
|
||||
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
|
||||
assert!(parse_message(b"{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_structured_errors() {
|
||||
let msg =
|
||||
|
||||
+12
-99
@@ -12,35 +12,6 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
/// How long the relay waits for java-debug to attach once the JVM is connected.
|
||||
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Loopback ports relaying a connected JVM, while their relay runs.
|
||||
static RELAY_PORTS: std::sync::Mutex<Vec<u16>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn relay_ports() -> std::sync::MutexGuard<'static, Vec<u16>> {
|
||||
RELAY_PORTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Whether `port` is a relay of [`listen_for_jvm`]: a session attaching to it debugs a JVM that
|
||||
/// connected to the IDE, so the JVM has no JDWP listener the memory view could find it by.
|
||||
pub(crate) fn is_listen_relay(port: u16) -> bool {
|
||||
relay_ports().contains(&port)
|
||||
}
|
||||
|
||||
/// Registers a relay port for as long as it lives.
|
||||
struct RelayPort(u16);
|
||||
|
||||
impl RelayPort {
|
||||
fn new(port: u16) -> Self {
|
||||
relay_ports().push(port);
|
||||
Self(port)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RelayPort {
|
||||
fn drop(&mut self) {
|
||||
relay_ports().retain(|&p| p != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The address to listen on for `host`: the interface it names, or every interface when
|
||||
/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to).
|
||||
async fn bind(host: &str, port: u16) -> IdeResult<TcpListener> {
|
||||
@@ -69,11 +40,11 @@ pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) ->
|
||||
let _ = jvm.set_nodelay(true);
|
||||
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = relay.local_addr()?.port();
|
||||
tokio::spawn(relay_one(relay, jvm, RelayPort::new(local)));
|
||||
tokio::spawn(relay_one(relay, jvm));
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPort) {
|
||||
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
|
||||
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
|
||||
Ok(Ok((s, _))) => s,
|
||||
_ => return,
|
||||
@@ -87,64 +58,22 @@ async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPor
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A remote JVM (`server=n`) connected to [`listen_for_jvm`], and the relay port. A port
|
||||
/// picked by binding to 0 can be taken by a parallel test before `listen_for_jvm` binds it,
|
||||
/// so a failed listen is retried on a fresh one.
|
||||
pub(crate) async fn connected_jvm() -> (TcpStream, u16) {
|
||||
for _ in 0..20 {
|
||||
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
|
||||
if let Some(connected) = jvm_on(port).await {
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
panic!("listen_for_jvm never accepted a JVM");
|
||||
}
|
||||
|
||||
/// One attempt of [`connected_jvm`]; `None` when the port turned out to be taken.
|
||||
async fn jvm_on(port: u16) -> Option<(TcpStream, u16)> {
|
||||
let mut waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
|
||||
let mut jvm = None;
|
||||
for _ in 0..100 {
|
||||
// A listen that failed to bind leaves nothing to connect to.
|
||||
if waiting.is_finished() {
|
||||
break;
|
||||
}
|
||||
// macOS does not refuse a connect to a port a socket holds without listening: the
|
||||
// SYN goes unanswered until the connect times out.
|
||||
let connect = TcpStream::connect(("127.0.0.1", port));
|
||||
if let Ok(Ok(s)) = tokio::time::timeout(Duration::from_secs(1), connect).await {
|
||||
jvm = Some(s);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
// A connection to another test's listener never reaches `listen_for_jvm`.
|
||||
let relay = tokio::time::timeout(Duration::from_secs(2), &mut waiting).await;
|
||||
if let (Some(jvm), Ok(Ok(Ok(relay)))) = (jvm, relay) {
|
||||
return Some((jvm, relay));
|
||||
}
|
||||
waiting.abort();
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_port_another_socket_holds_is_given_up() {
|
||||
// Bound but not listening, as a parallel test's outgoing connection holds its local port.
|
||||
let held = tokio::net::TcpSocket::new_v4().unwrap();
|
||||
held.bind(([127, 0, 0, 1], 0).into()).unwrap();
|
||||
let port = held.local_addr().unwrap().port();
|
||||
let attempt = tokio::time::timeout(Duration::from_secs(5), jvm_on(port)).await;
|
||||
assert!(matches!(attempt, Ok(None)), "an attempt on a taken port gives up instead of waiting forever");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relays_the_jdwp_handshake_both_ways() {
|
||||
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
|
||||
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
|
||||
// The remote JVM (`server=n`) connects to the IDE.
|
||||
let (mut jvm, local) = connected_jvm().await;
|
||||
let mut jvm = loop {
|
||||
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
|
||||
break s;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let local = waiting.await.unwrap().unwrap();
|
||||
// java-debug attaches to the relay and starts the handshake.
|
||||
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
|
||||
debugger.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
@@ -156,22 +85,6 @@ pub(crate) mod tests {
|
||||
assert_eq!(&buf, b"JDWP-Handshake");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_port_is_known_while_the_relay_runs() {
|
||||
let (jvm, local) = connected_jvm().await;
|
||||
assert!(is_listen_relay(local));
|
||||
|
||||
let debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
|
||||
drop((jvm, debugger));
|
||||
for _ in 0..200 {
|
||||
if !is_listen_relay(local) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("relay port {local} still registered after the relay ended");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_is_cancellable() {
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//! The class table counted by the debugger (JDWP `InstanceCounts`) where no histogram can be
|
||||
//! taken: JDK 8-16 while suspended, Windows, a remote JVM. Only while suspended, and slow on a
|
||||
//! large heap (seconds on JDK 17+).
|
||||
|
||||
use super::inspect::Call;
|
||||
use super::snapshot::diff_rows;
|
||||
use super::{ClassCounts, JvmMemoryResult, JvmMemorySnapshot, JvmMemorySource, JvmMemoryState, JvmMemoryUnavailable};
|
||||
use crate::session::Session;
|
||||
use ide_core::IdeResult;
|
||||
use ide_jvm_attach::HistogramRow;
|
||||
use ide_jvm_inspect::ClassCount;
|
||||
|
||||
/// Reasons the debugger may be able to work around; the others (the program exited, the JVM
|
||||
/// did not answer) would fail for it too.
|
||||
pub(super) fn falls_back(reason: &JvmMemoryUnavailable) -> bool {
|
||||
use JvmMemoryUnavailable as U;
|
||||
matches!(
|
||||
reason,
|
||||
U::UnsafeWhileSuspended { .. }
|
||||
| U::UnsupportedPlatform
|
||||
| U::AttachedJvmNotFound
|
||||
| U::ListenerNotRunning
|
||||
| U::ListenModeNotSupported
|
||||
| U::NoJvmProcess
|
||||
)
|
||||
}
|
||||
|
||||
/// `histogram` unless it is unavailable for a reason `session`'s inspector can work around.
|
||||
pub(super) async fn or_debugger_counts(
|
||||
state: &mut JvmMemoryState,
|
||||
histogram: Result<JvmMemorySnapshot, JvmMemoryUnavailable>,
|
||||
session: Option<&Session>,
|
||||
) -> IdeResult<JvmMemoryResult> {
|
||||
let reason = match histogram {
|
||||
Ok(snapshot) => return Ok(JvmMemoryResult::Ready { snapshot }),
|
||||
Err(reason) => reason,
|
||||
};
|
||||
let unavailable = |can_count_when_paused| Ok(JvmMemoryResult::Unavailable { reason, can_count_when_paused });
|
||||
let Some(session) = session.filter(|_| falls_back(&reason)) else {
|
||||
return unavailable(false);
|
||||
};
|
||||
let call = match Call::begin(session, None).await {
|
||||
Ok(call) => call,
|
||||
Err(super::JvmInspectUnavailable::NotSuspended) => return unavailable(true),
|
||||
Err(_) => return unavailable(false),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let result = call.run(call.inspector.class_counts(&call.target)).await;
|
||||
match call.finish(result) {
|
||||
Ok(counts) => {
|
||||
let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
let previous = state.baseline(JvmMemorySource::Debugger);
|
||||
let (snapshot, counts) = debugger_snapshot(counts, previous.as_deref(), elapsed_ms);
|
||||
state.remember(JvmMemorySource::Debugger, counts);
|
||||
Ok(JvmMemoryResult::Ready { snapshot })
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("debugger class counts: {e}");
|
||||
let running = !session.suspended.get();
|
||||
unavailable(running && session.jvm_inspector.lock().is_some())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classes without instances are left out; most instances first.
|
||||
pub(super) fn debugger_snapshot(
|
||||
counts: Vec<ClassCount>,
|
||||
previous: Option<&ClassCounts>,
|
||||
elapsed_ms: u32,
|
||||
) -> (JvmMemorySnapshot, ClassCounts) {
|
||||
let rows = counts
|
||||
.into_iter()
|
||||
.filter(|c| c.instances > 0)
|
||||
.map(|c| HistogramRow { name: c.name, module: None, instances: c.instances, bytes: 0 })
|
||||
.collect();
|
||||
let (mut rows, counts) = diff_rows(rows, previous);
|
||||
for row in &mut rows {
|
||||
row.delta_bytes = None;
|
||||
}
|
||||
rows.sort_by(|a, b| b.instances.cmp(&a.instances).then_with(|| a.name.cmp(&b.name)));
|
||||
let snapshot = JvmMemorySnapshot {
|
||||
total_instances: rows.iter().map(|r| r.instances).sum(),
|
||||
total_bytes: 0,
|
||||
rows,
|
||||
java_version: String::new(),
|
||||
taken_while_suspended: true,
|
||||
has_baseline: previous.is_some(),
|
||||
elapsed_ms,
|
||||
source: JvmMemorySource::Debugger,
|
||||
};
|
||||
(snapshot, counts)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
//! A suspended JVM's instances, fields and referrers, read through the session's
|
||||
//! [`JvmInspector`]. An object is only valid within the stop it was read in.
|
||||
|
||||
use super::launch_pid;
|
||||
use crate::session::Session;
|
||||
use ide_core::event::{DebugEvent, JvmBackendState};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_jvm_inspect::{
|
||||
DEFAULT_REFERRERS, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, MAX_INSTANCES,
|
||||
MAX_REFERRERS, ObjectId, VmTarget,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmObjectRef {
|
||||
/// The `generation` of the [`JvmInstancesResult`] the object came from.
|
||||
pub generation: u64,
|
||||
pub id: ObjectId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmInspectUnavailable {
|
||||
/// The session has no inspector: not a JVM session, not ready yet, or turned off.
|
||||
NoBackend,
|
||||
/// The debug adapter lacks what the inspector needs; turned off for the session.
|
||||
Unsupported {
|
||||
detail: String,
|
||||
},
|
||||
NotSuspended,
|
||||
/// The object is from an earlier stop, or the program stopped or resumed meanwhile.
|
||||
Stale,
|
||||
Collected,
|
||||
ClassNotLoaded,
|
||||
VmNotFound,
|
||||
/// Transient: ask again.
|
||||
TimedOut,
|
||||
BackendClosed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmInstancesResult {
|
||||
Ready {
|
||||
/// Goes into the [`JvmObjectRef`] of each instance.
|
||||
generation: u64,
|
||||
instances: Vec<JvmValue>,
|
||||
/// The limit was reached; there may be more.
|
||||
truncated: bool,
|
||||
},
|
||||
Unavailable {
|
||||
reason: JvmInspectUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmFieldsResult {
|
||||
Ready { fields: Vec<JvmField> },
|
||||
Unavailable { reason: JvmInspectUnavailable },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmReferrersResult {
|
||||
Ready { referrers: Vec<JvmReferrer>, truncated: bool },
|
||||
Unavailable { reason: JvmInspectUnavailable },
|
||||
}
|
||||
|
||||
/// The session's JVM for the inspector: java-debug's VM is found by the launched program's pid
|
||||
/// or by the port of an attach (the thread lists of two JVMs can be identical).
|
||||
pub(crate) fn vm_target(s: &Session) -> VmTarget {
|
||||
let (pid, attach) = if s.request == "launch" {
|
||||
(u32::try_from(launch_pid(s)).ok().filter(|&p| p > 0), None)
|
||||
} else {
|
||||
(None, s.attach_target.get())
|
||||
};
|
||||
VmTarget {
|
||||
pid,
|
||||
attach_host: attach.map(|(host, _)| host.clone()).filter(|h| !h.is_empty()),
|
||||
attach_port: attach.map(|&(_, port)| port),
|
||||
generation: s.suspended.ticket(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_inspector(s: &Session, inspector: Arc<dyn JvmInspector>) {
|
||||
*s.jvm_inspector.lock() = Some(inspector);
|
||||
s.emit(DebugEvent::Capabilities);
|
||||
}
|
||||
|
||||
pub(crate) async fn instances(s: &Session, class_name: &str, limit: u32) -> IdeResult<JvmInstancesResult> {
|
||||
let limit = limit.clamp(1, MAX_INSTANCES);
|
||||
let call = match Call::begin(s, None).await {
|
||||
Ok(call) => call,
|
||||
Err(reason) => return Ok(JvmInstancesResult::Unavailable { reason }),
|
||||
};
|
||||
let generation = call.target.generation;
|
||||
let result = call.run(call.inspector.instances(&call.target, class_name, limit)).await;
|
||||
Ok(match settle(call.finish(result))? {
|
||||
Ok(instances) => {
|
||||
JvmInstancesResult::Ready { generation, truncated: instances.len() >= limit as usize, instances }
|
||||
}
|
||||
Err(reason) => JvmInstancesResult::Unavailable { reason },
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn fields(s: &Session, object: JvmObjectRef) -> IdeResult<JvmFieldsResult> {
|
||||
let call = match Call::begin(s, Some(object.generation)).await {
|
||||
Ok(call) => call,
|
||||
Err(reason) => return Ok(JvmFieldsResult::Unavailable { reason }),
|
||||
};
|
||||
let result = call.run(call.inspector.fields(&call.target, object.id)).await;
|
||||
Ok(match settle(call.finish(result))? {
|
||||
Ok(fields) => JvmFieldsResult::Ready { fields },
|
||||
Err(reason) => JvmFieldsResult::Unavailable { reason },
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn referrers(s: &Session, object: JvmObjectRef, limit: u32) -> IdeResult<JvmReferrersResult> {
|
||||
let limit = if limit == 0 { DEFAULT_REFERRERS } else { limit.min(MAX_REFERRERS) };
|
||||
let call = match Call::begin(s, Some(object.generation)).await {
|
||||
Ok(call) => call,
|
||||
Err(reason) => return Ok(JvmReferrersResult::Unavailable { reason }),
|
||||
};
|
||||
let result = call.run(call.inspector.referrers(&call.target, object.id, limit)).await;
|
||||
Ok(match settle(call.finish(result))? {
|
||||
Ok(referrers) => JvmReferrersResult::Ready { truncated: referrers.len() >= limit as usize, referrers },
|
||||
Err(reason) => JvmReferrersResult::Unavailable { reason },
|
||||
})
|
||||
}
|
||||
|
||||
/// One inspector request. Holds the session's inspect lock and a clone of its inspector, which
|
||||
/// [`Call::run`] lets go of when the session ends.
|
||||
pub(super) struct Call<'s> {
|
||||
session: &'s Session,
|
||||
pub(super) inspector: Arc<dyn JvmInspector>,
|
||||
pub(super) target: VmTarget,
|
||||
_turn: tokio::sync::MutexGuard<'s, ()>,
|
||||
}
|
||||
|
||||
impl<'s> Call<'s> {
|
||||
/// `object_generation`: the stop the request's object was read in.
|
||||
pub(super) async fn begin(
|
||||
s: &'s Session,
|
||||
object_generation: Option<u64>,
|
||||
) -> Result<Call<'s>, JvmInspectUnavailable> {
|
||||
// Checked under the lock: a request queued behind another must see the state it runs in.
|
||||
let turn = s.inspect_lock.lock().await;
|
||||
let Some(inspector) = s.jvm_inspector.lock().clone() else {
|
||||
return Err(JvmInspectUnavailable::NoBackend);
|
||||
};
|
||||
if !s.suspended.get() {
|
||||
return Err(JvmInspectUnavailable::NotSuspended);
|
||||
}
|
||||
let target = vm_target(s);
|
||||
if object_generation.is_some_and(|g| g != target.generation) {
|
||||
return Err(JvmInspectUnavailable::Stale);
|
||||
}
|
||||
Ok(Call { session: s, inspector, target, _turn: turn })
|
||||
}
|
||||
|
||||
pub(super) async fn run<T>(&self, request: InspectFuture<'_, T>) -> Result<T, InspectError> {
|
||||
tokio::select! {
|
||||
result = request => result,
|
||||
() = self.session.ended.cancelled() => Err(InspectError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// The result unless the program stopped or resumed while it was read: a request of several
|
||||
/// commands can straddle a stop. `Unsupported` turns the backend off.
|
||||
pub(super) fn finish<T>(self, result: Result<T, InspectError>) -> Result<T, InspectError> {
|
||||
let s = self.session;
|
||||
match result {
|
||||
Err(InspectError::Unsupported(detail)) => {
|
||||
turn_off(s, &self.inspector, &detail);
|
||||
Err(InspectError::Unsupported(detail))
|
||||
}
|
||||
_ if !s.suspended.get() || s.suspended.ticket() != self.target.generation => Err(InspectError::Stale),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter that lacks what the inspector needs will not gain it during the session.
|
||||
fn turn_off(s: &Session, inspector: &Arc<dyn JvmInspector>, detail: &str) {
|
||||
let removed = {
|
||||
let mut slot = s.jvm_inspector.lock();
|
||||
// Not one installed since this request began.
|
||||
let current = slot.as_ref().is_some_and(|i| std::ptr::addr_eq(Arc::as_ptr(i), Arc::as_ptr(inspector)));
|
||||
current && slot.take().is_some()
|
||||
};
|
||||
if removed {
|
||||
s.emit(DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail: detail.to_owned() } });
|
||||
s.emit(DebugEvent::Capabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure the inspector could not classify is an error, not a reason shown to the user.
|
||||
fn settle<T>(result: Result<T, InspectError>) -> IdeResult<Result<T, JvmInspectUnavailable>> {
|
||||
use JvmInspectUnavailable as U;
|
||||
let error = match result {
|
||||
Ok(value) => return Ok(Ok(value)),
|
||||
Err(error) => error,
|
||||
};
|
||||
Ok(Err(match error {
|
||||
InspectError::NotSuspended => U::NotSuspended,
|
||||
InspectError::Stale => U::Stale,
|
||||
InspectError::Collected => U::Collected,
|
||||
InspectError::ClassNotLoaded => U::ClassNotLoaded,
|
||||
InspectError::VmNotFound => U::VmNotFound,
|
||||
InspectError::Unsupported(detail) => U::Unsupported { detail },
|
||||
// Also the Kotlin link's `Busy`: the backend stays.
|
||||
InspectError::TimedOut => U::TimedOut,
|
||||
InspectError::Closed => U::BackendClosed,
|
||||
InspectError::Failed(message) => return Err(IdeError::internal(format!("JVM inspector: {message}"))),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reports a Kotlin link that stops inspecting or closes. Holds neither the link nor the
|
||||
/// session: it ends once the session drops the link, and never drops it itself (after
|
||||
/// `VirtualMachine.Dispose` the link is closed but still forwarding the reply).
|
||||
pub(crate) fn watch_link(s: &Arc<Session>, link: &ide_jdwp::JdwpLink) {
|
||||
let mut states = link.subscribe();
|
||||
let session = Arc::downgrade(s);
|
||||
tokio::spawn(async move {
|
||||
let mut reported = None;
|
||||
loop {
|
||||
let state = match &*states.borrow_and_update() {
|
||||
ide_jdwp::LinkState::Degraded(detail) => Some(JvmBackendState::Degraded { detail: detail.clone() }),
|
||||
ide_jdwp::LinkState::Closed => Some(JvmBackendState::Closed),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(state) = state
|
||||
&& reported.as_ref() != Some(&state)
|
||||
{
|
||||
let Some(s) = session.upgrade() else { return };
|
||||
if s.ended.is_cancelled() || s.terminated.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
s.emit(DebugEvent::JvmBackend { state: state.clone() });
|
||||
reported = Some(state);
|
||||
}
|
||||
if states.changed().await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
//! Class-table snapshots of a debugged JVM, read over the HotSpot attach listener or, where
|
||||
//! that is unavailable, counted by the debugger; and the JVM's objects through the debugger.
|
||||
|
||||
mod fallback;
|
||||
mod inspect;
|
||||
mod scan;
|
||||
mod snapshot;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::session::Suspension;
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
pub use inspect::{JvmFieldsResult, JvmInspectUnavailable, JvmInstancesResult, JvmObjectRef, JvmReferrersResult};
|
||||
pub(crate) use inspect::{fields, instances, referrers, set_inspector, watch_link};
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) use scan::watch_for_attached_jvm;
|
||||
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
|
||||
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
|
||||
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
|
||||
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmClassRow {
|
||||
pub name: String,
|
||||
pub module: Option<String>,
|
||||
pub instances: u64,
|
||||
pub bytes: u64,
|
||||
/// Change since the previous snapshot; `None` on the first one and for ambiguous rows.
|
||||
pub delta_instances: Option<i64>,
|
||||
pub delta_bytes: Option<i64>,
|
||||
/// Several classes share this name (different class loaders), so it has no delta.
|
||||
pub ambiguous: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmMemorySnapshot {
|
||||
/// Largest `bytes` first.
|
||||
pub rows: Vec<JvmClassRow>,
|
||||
pub total_instances: u64,
|
||||
pub total_bytes: u64,
|
||||
/// Empty when unknown.
|
||||
pub java_version: String,
|
||||
pub taken_while_suspended: bool,
|
||||
/// A previous snapshot from the same source exists; deltas are against it.
|
||||
pub has_baseline: bool,
|
||||
/// Wall time of the histogram or count request.
|
||||
pub elapsed_ms: u32,
|
||||
pub source: JvmMemorySource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JvmMemorySource {
|
||||
/// `GC.class_histogram -all`: sizes, and objects not yet collected.
|
||||
Histogram,
|
||||
/// JDWP `InstanceCounts`: reachable objects only, no sizes (`bytes` is 0).
|
||||
Debugger,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmMemoryUnavailable {
|
||||
/// An attach session whose JVM is remote, or local but not found by its JDWP port.
|
||||
AttachedJvmNotFound,
|
||||
/// An attach through the IDE's listen-mode relay: the JVM connected to the IDE, so it has
|
||||
/// no JDWP listener to be found by.
|
||||
ListenModeNotSupported,
|
||||
UnsupportedPlatform,
|
||||
NoJvmProcess,
|
||||
ListenerNotRunning,
|
||||
/// `java_major` is 0 when the version is unknown.
|
||||
UnsafeWhileSuspended {
|
||||
java_major: u32,
|
||||
},
|
||||
ProgramExited,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmMemoryResult {
|
||||
Ready {
|
||||
snapshot: JvmMemorySnapshot,
|
||||
},
|
||||
Unavailable {
|
||||
reason: JvmMemoryUnavailable,
|
||||
/// The debugger could count the classes if the program were paused.
|
||||
can_count_when_paused: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Instances and bytes per class name, for the next snapshot's deltas; `None` for a name
|
||||
/// several classes shared, so it gets no delta next time either.
|
||||
type ClassCounts = HashMap<String, Option<(u64, u64)>>;
|
||||
|
||||
/// Per-session baseline; its mutex also keeps one class-table request per session at a time.
|
||||
#[derive(Default)]
|
||||
pub struct JvmMemoryState {
|
||||
/// Shared with the blocking task, so a task that panics leaves it in place.
|
||||
previous: Option<(JvmMemorySource, Arc<ClassCounts>)>,
|
||||
}
|
||||
|
||||
impl JvmMemoryState {
|
||||
/// The previous snapshot's counts if it came from `source`: counts from the other source
|
||||
/// are not comparable (only reachable objects versus all of them).
|
||||
pub(crate) fn baseline(&self, source: JvmMemorySource) -> Option<Arc<ClassCounts>> {
|
||||
self.previous.as_ref().filter(|(s, _)| *s == source).map(|(_, counts)| counts.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn remember(&mut self, source: JvmMemorySource, counts: ClassCounts) {
|
||||
self.previous = Some((source, Arc::new(counts)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn session_classes(session: Arc<Session>) -> IdeResult<JvmMemoryResult> {
|
||||
until_located(&session).await;
|
||||
let launched = histogram_pid(&session);
|
||||
// Detached, so a caller that gives up cannot release the lock while the JVM is still
|
||||
// answering and let a second histogram request run next to it.
|
||||
tokio::spawn(async move {
|
||||
let target = session_target(&session, launched);
|
||||
let probe = |suspended| -> Arc<dyn AttachProbe> { Arc::new(JdkProbe { suspended }) };
|
||||
snapshot(&session.jvm_memory, &session.suspended, probe, target, Some(&session)).await
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Bounds the wait of [`until_located`]; `configure` has its own timeouts, this only guards them.
|
||||
const LOCATE_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Until `configure` has recorded where `session`'s JVM is. The Memory view asks as soon as the
|
||||
/// session appears; looked up earlier, an attached JVM is reported as missing.
|
||||
async fn until_located(session: &Session) {
|
||||
let mut located = session.jvm_located.subscribe();
|
||||
tokio::select! {
|
||||
_ = located.wait_for(|v| *v) => {}
|
||||
() = session.ended.cancelled() => {}
|
||||
() = tokio::time::sleep(LOCATE_WAIT) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `launched`: [`histogram_pid`] of `session`.
|
||||
fn session_target(session: &Session, launched: Result<Option<i32>, JvmMemoryUnavailable>) -> JvmTarget<'_> {
|
||||
match launched {
|
||||
Ok(Some(pid)) => JvmTarget::Launched(pid),
|
||||
Ok(None) => JvmTarget::Attached { pid: &session.jvm_pid, jdwp_port: session.jdwp_port.get().copied() },
|
||||
Err(reason) => JvmTarget::Unavailable(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pid of a launch session's program (`None` for an attach session), or why no histogram
|
||||
/// can be taken.
|
||||
fn histogram_pid(session: &Session) -> Result<Option<i32>, JvmMemoryUnavailable> {
|
||||
if !JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str()) {
|
||||
return Err(JvmMemoryUnavailable::NoJvmProcess);
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return Err(JvmMemoryUnavailable::UnsupportedPlatform);
|
||||
}
|
||||
if session.request == "launch" {
|
||||
return match i32::try_from(launch_pid(session)).ok().filter(|&p| p > 0) {
|
||||
Some(pid) => Ok(Some(pid)),
|
||||
None => Err(JvmMemoryUnavailable::NoJvmProcess),
|
||||
};
|
||||
}
|
||||
if session.jvm_pid.load(Ordering::Acquire) <= 0 {
|
||||
if session.terminated.load(Ordering::Acquire) {
|
||||
return Err(JvmMemoryUnavailable::ProgramExited);
|
||||
}
|
||||
if session.listen_relay.load(Ordering::Acquire) {
|
||||
return Err(JvmMemoryUnavailable::ListenModeNotSupported);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The JVM a launch session started: the one the IDE started for an app-launched (Kotlin)
|
||||
/// session, else the program the adapter reported; 0 when neither is known.
|
||||
pub(crate) fn launch_pid(session: &Session) -> i64 {
|
||||
match session.jvm_pid.load(Ordering::Acquire) {
|
||||
p if p > 0 => p,
|
||||
_ => session.debuggee_pid.load(Ordering::Acquire),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum JvmTarget<'a> {
|
||||
/// The program a launch session started: its pid and everything it started.
|
||||
Launched(i32),
|
||||
/// The JVM of an attach session. While `pid` is 0 it is looked up by `jdwp_port`, which is
|
||||
/// `None` for a remote JVM.
|
||||
Attached { pid: &'a AtomicI64, jdwp_port: Option<u16> },
|
||||
/// Known without asking any JVM: no histogram can be taken, for this reason.
|
||||
Unavailable(JvmMemoryUnavailable),
|
||||
}
|
||||
|
||||
enum Lookup {
|
||||
Tree(i32),
|
||||
Known(i32),
|
||||
Scan(u16),
|
||||
}
|
||||
|
||||
/// `probe` is built from the suspension read once the lock is held: a request queued behind
|
||||
/// another must not decide on a state from before the program hit a breakpoint. Where no
|
||||
/// histogram can be taken, `debugger` (the session) may count the classes instead.
|
||||
pub(crate) async fn snapshot(
|
||||
state: &tokio::sync::Mutex<JvmMemoryState>,
|
||||
suspended: &Suspension,
|
||||
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
|
||||
target: JvmTarget<'_>,
|
||||
debugger: Option<&Session>,
|
||||
) -> IdeResult<JvmMemoryResult> {
|
||||
let mut state = state.lock().await;
|
||||
let outcome = histogram(&mut state, suspended, probe, target).await?;
|
||||
fallback::or_debugger_counts(&mut state, outcome, debugger).await
|
||||
}
|
||||
|
||||
async fn histogram(
|
||||
state: &mut JvmMemoryState,
|
||||
suspended: &Suspension,
|
||||
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
|
||||
target: JvmTarget<'_>,
|
||||
) -> IdeResult<Result<JvmMemorySnapshot, JvmMemoryUnavailable>> {
|
||||
let suspended = suspended.get();
|
||||
let lookup = match lookup(target) {
|
||||
Ok(lookup) => lookup,
|
||||
Err(reason) => return Ok(Err(reason)),
|
||||
};
|
||||
let probe = probe(suspended);
|
||||
let previous = state.baseline(JvmMemorySource::Histogram);
|
||||
let (found, outcome) = tokio::task::spawn_blocking(move || match resolve_jvm(lookup, &*probe, suspended) {
|
||||
Ok((tree, found)) => (found, take_snapshot(&*probe, &tree, suspended, previous.as_deref())),
|
||||
Err(reason) => (None, Ok(Err(reason))),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))?;
|
||||
keep_found(target, found);
|
||||
Ok(outcome?.map(|(snapshot, counts)| {
|
||||
state.remember(JvmMemorySource::Histogram, counts);
|
||||
snapshot
|
||||
}))
|
||||
}
|
||||
|
||||
/// Where to look for `target`'s JVM, or why there is none to ask.
|
||||
fn lookup(target: JvmTarget<'_>) -> Result<Lookup, JvmMemoryUnavailable> {
|
||||
match target {
|
||||
JvmTarget::Launched(pid) => Ok(Lookup::Tree(pid)),
|
||||
JvmTarget::Attached { pid, jdwp_port } => match i32::try_from(pid.load(Ordering::Acquire)) {
|
||||
Ok(p) if p > 0 => Ok(Lookup::Known(p)),
|
||||
_ => jdwp_port.map(Lookup::Scan).ok_or(JvmMemoryUnavailable::AttachedJvmNotFound),
|
||||
},
|
||||
JvmTarget::Unavailable(reason) => Err(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking. The pids to pick the JVM from, and the pid a scan found (for [`keep_found`]).
|
||||
fn resolve_jvm(
|
||||
lookup: Lookup,
|
||||
probe: &dyn AttachProbe,
|
||||
suspended: bool,
|
||||
) -> Result<(Vec<i32>, Option<i32>), JvmMemoryUnavailable> {
|
||||
match lookup {
|
||||
Lookup::Tree(pid) => Ok((pid_tree(pid), None)),
|
||||
Lookup::Known(pid) => Ok((vec![pid], None)),
|
||||
Lookup::Scan(port) => scan_for_jvm(probe, port, suspended).map(|pid| (vec![pid], Some(pid))),
|
||||
}
|
||||
}
|
||||
|
||||
fn keep_found(target: JvmTarget<'_>, found: Option<i32>) {
|
||||
if let (Some(found), JvmTarget::Attached { pid, .. }) = (found, target) {
|
||||
// The attach-time scan may have found it meanwhile; either answer is the same JVM.
|
||||
let _ = pid.compare_exchange(0, i64::from(found), Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
/// A suspended JDK below 17 hangs on an attach request, so while suspended only a JVM known to
|
||||
/// be 17 or later is asked anything. Pass a version read without attaching (the `release` file).
|
||||
fn safe_while_suspended(version: Option<&ide_jvm_attach::JavaVersion>) -> bool {
|
||||
version.is_some_and(|v| ide_jvm_attach::histogram_safe_while_suspended(v.major))
|
||||
}
|
||||
|
||||
/// The JVM listening on JDWP `port`. While suspended the scan reaches the session's own JVM
|
||||
/// too, so JVMs unsafe to ask are skipped, and when one of them may be the session's the
|
||||
/// answer is a refusal rather than "not found".
|
||||
fn scan_for_jvm(probe: &dyn AttachProbe, port: u16, suspended: bool) -> Result<i32, JvmMemoryUnavailable> {
|
||||
// Majors of the JVMs left out; 0 for an unknown version.
|
||||
let skipped = std::cell::RefCell::new(std::collections::BTreeSet::new());
|
||||
let may_query = |pid: u32| {
|
||||
if !suspended {
|
||||
return true;
|
||||
}
|
||||
let version = probe.version(pid);
|
||||
let safe = safe_while_suspended(version.as_ref());
|
||||
if !safe {
|
||||
skipped.borrow_mut().insert(version.map_or(0, |v| v.major));
|
||||
}
|
||||
safe
|
||||
};
|
||||
match probe.find_by_jdwp_port(port, &may_query).and_then(|p| i32::try_from(p).ok()) {
|
||||
Some(pid) if pid > 0 => Ok(pid),
|
||||
_ => {
|
||||
let skipped = skipped.into_inner();
|
||||
Err(match skipped.first() {
|
||||
None => JvmMemoryUnavailable::AttachedJvmNotFound,
|
||||
Some(&major) => JvmMemoryUnavailable::UnsafeWhileSuspended {
|
||||
java_major: if skipped.len() == 1 { major } else { 0 },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pid_tree(pid: i32) -> Vec<i32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
ide_core::process::process_tree(pid)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
vec![pid]
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
//! Finding the JVM behind an attach session by its JDWP port.
|
||||
|
||||
use super::safe_while_suspended;
|
||||
use crate::session::Session;
|
||||
use serde_json::Value;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One try of the scan at attach time, and the limit per socket within it: a silent socket's
|
||||
/// query thread lives that long, so a longer per-socket limit piles threads up across tries.
|
||||
const ATTACH_SCAN_TRY: Duration = Duration::from_millis(500);
|
||||
const ATTACH_SCAN_INTERVAL: Duration = Duration::from_millis(150);
|
||||
/// The JVM's attach socket can appear a little after the JDWP "Listening" banner, which is
|
||||
/// when the IDE attaches.
|
||||
const ATTACH_SCAN_WINDOW: Duration = Duration::from_secs(3);
|
||||
/// The single scan a refresh makes while the attach-time one found nothing.
|
||||
pub(super) const RESCAN_PER_SOCKET: Duration = Duration::from_secs(1);
|
||||
pub(super) const RESCAN_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// JDWP port of an attach to this machine (`hostName` loopback or absent): only such a JVM has
|
||||
/// an attach socket the IDE can find.
|
||||
pub(crate) fn local_jdwp_port(arguments: &Value) -> Option<u16> {
|
||||
let local = match arguments.get("hostName") {
|
||||
None | Some(Value::Null) => true,
|
||||
Some(Value::String(host)) => {
|
||||
let host = host.trim();
|
||||
let ip = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
|
||||
host.eq_ignore_ascii_case("localhost") || ip.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
Some(_) => false,
|
||||
};
|
||||
jdwp_port(arguments).filter(|_| local)
|
||||
}
|
||||
|
||||
/// `port` of an attach request: a number or a numeric string, never 0.
|
||||
fn jdwp_port(arguments: &Value) -> Option<u16> {
|
||||
let port = match arguments.get("port")? {
|
||||
Value::Number(n) => n.as_u64().and_then(|p| u16::try_from(p).ok()),
|
||||
Value::String(s) => s.trim().parse().ok(),
|
||||
_ => None,
|
||||
};
|
||||
port.filter(|&p| p != 0)
|
||||
}
|
||||
|
||||
/// `hostName` (empty when absent) and `port` of an attach request, local or not.
|
||||
pub(crate) fn attach_target(arguments: &Value) -> Option<(String, u16)> {
|
||||
let host = arguments.get("hostName").and_then(Value::as_str).unwrap_or_default();
|
||||
Some((host.to_owned(), jdwp_port(arguments)?))
|
||||
}
|
||||
|
||||
/// Pid of the local JVM listening on a JDWP port (first argument), given whether the session is
|
||||
/// suspended (second). Replaceable so tests never send attach requests to real JVMs.
|
||||
pub(crate) type JvmFinder = Arc<dyn Fn(u16, bool) -> Option<u32> + Send + Sync>;
|
||||
|
||||
/// The [`JvmFinder`] of the attach-time scan.
|
||||
pub(crate) fn find_local_jvm(port: u16, suspended: bool) -> Option<u32> {
|
||||
let may_query = |pid| !suspended || safe_while_suspended(ide_jvm_attach::java_version_of(pid).as_ref());
|
||||
ide_jvm_attach::find_jvm_by_jdwp_port(port, ATTACH_SCAN_TRY, ATTACH_SCAN_TRY, may_query)
|
||||
}
|
||||
|
||||
/// Looks for the JVM behind a local attach: one try now, the rest in the background, so a JVM
|
||||
/// that never opens an attach socket holds the attach up by one try at most. Call it before the
|
||||
/// debugger connects: the JVM clears its JDWP listener address then, and a scan can no longer
|
||||
/// tell it by its port.
|
||||
pub(crate) async fn find_attached_jvm(session: &Arc<Session>, port: u16, finder: JvmFinder) {
|
||||
let _ = session.jdwp_port.set(port);
|
||||
let find = move |suspended: bool| finder(port, suspended);
|
||||
let first = find.clone();
|
||||
if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || first(false)).await {
|
||||
let _ = session.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire);
|
||||
return;
|
||||
}
|
||||
drop(watch_for_attached_jvm(session, port, find, ATTACH_SCAN_INTERVAL, ATTACH_SCAN_WINDOW));
|
||||
}
|
||||
|
||||
/// Retries `find` for the session's `jvm_pid`, holding the session weakly so an ended session
|
||||
/// is not kept alive. `find` is told whether the session is suspended.
|
||||
pub(crate) fn watch_for_attached_jvm<F>(
|
||||
session: &Arc<Session>,
|
||||
port: u16,
|
||||
find: F,
|
||||
interval: Duration,
|
||||
window: Duration,
|
||||
) -> tokio::task::JoinHandle<()>
|
||||
where
|
||||
F: Fn(bool) -> Option<u32> + Clone + Send + 'static,
|
||||
{
|
||||
let weak = Arc::downgrade(session);
|
||||
tokio::spawn(async move {
|
||||
let gate = || match weak.upgrade() {
|
||||
Some(s) if !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() => {
|
||||
if s.jvm_pid.load(Ordering::Acquire) > 0 {
|
||||
ScanGate::Ended
|
||||
} else {
|
||||
ScanGate::Scan { suspended: s.suspended.get() }
|
||||
}
|
||||
}
|
||||
_ => ScanGate::Ended,
|
||||
};
|
||||
let Some(pid) = retry_find(find, gate, interval, window).await else {
|
||||
tracing::debug!("no local JVM found for JDWP port {port} yet");
|
||||
return;
|
||||
};
|
||||
if let Some(s) = weak.upgrade() {
|
||||
let _ = s.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum ScanGate {
|
||||
Scan { suspended: bool },
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Calls `find` on a blocking thread, on each try `gate` allows, until it returns a pid, `gate`
|
||||
/// reports the end or `window` has passed.
|
||||
pub(crate) async fn retry_find<F>(
|
||||
find: F,
|
||||
gate: impl Fn() -> ScanGate,
|
||||
interval: Duration,
|
||||
window: Duration,
|
||||
) -> Option<u32>
|
||||
where
|
||||
F: Fn(bool) -> Option<u32> + Clone + Send + 'static,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + window;
|
||||
loop {
|
||||
match gate() {
|
||||
ScanGate::Ended => break,
|
||||
ScanGate::Scan { suspended } => {
|
||||
let find = find.clone();
|
||||
if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || find(suspended)).await {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() + interval >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
//! One class histogram of a JVM and its deltas against the previous one.
|
||||
|
||||
use super::scan::{RESCAN_BUDGET, RESCAN_PER_SOCKET};
|
||||
use super::{
|
||||
ClassCounts, HISTOGRAM_TIMEOUT, JvmClassRow, JvmMemorySnapshot, JvmMemorySource, JvmMemoryUnavailable,
|
||||
safe_while_suspended,
|
||||
};
|
||||
use ide_core::IdeResult;
|
||||
use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Version probe for a JVM whose `release` file could not be read.
|
||||
const VERSION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The attach calls a snapshot makes, so tests can stand in for a JVM.
|
||||
pub(crate) trait AttachProbe: Send + Sync {
|
||||
fn has_listener(&self, pid: u32) -> bool;
|
||||
/// Asks only the JVMs whose pid `may_query` accepts.
|
||||
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<u32>;
|
||||
fn version(&self, pid: u32) -> Option<JavaVersion>;
|
||||
fn histogram(&self, pid: u32) -> Result<String, AttachError>;
|
||||
fn alive(&self, pid: u32) -> bool;
|
||||
}
|
||||
|
||||
pub(super) struct JdkProbe {
|
||||
pub(super) suspended: bool,
|
||||
}
|
||||
|
||||
impl AttachProbe for JdkProbe {
|
||||
fn has_listener(&self, pid: u32) -> bool {
|
||||
ide_jvm_attach::has_listener(pid)
|
||||
}
|
||||
|
||||
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<u32> {
|
||||
ide_jvm_attach::find_jvm_by_jdwp_port(port, RESCAN_PER_SOCKET, RESCAN_BUDGET, may_query)
|
||||
}
|
||||
|
||||
fn version(&self, pid: u32) -> Option<JavaVersion> {
|
||||
ide_jvm_attach::java_version_of(pid).or_else(|| {
|
||||
// The version decides whether attaching to a suspended VM hangs it, so never
|
||||
// attach to learn it while suspended.
|
||||
if self.suspended {
|
||||
return None;
|
||||
}
|
||||
let out = ide_jvm_attach::jcmd(pid, "VM.version", VERSION_TIMEOUT).ok()?;
|
||||
ide_jvm_attach::parse_vm_version(&out)
|
||||
})
|
||||
}
|
||||
|
||||
fn histogram(&self, pid: u32) -> Result<String, AttachError> {
|
||||
ide_jvm_attach::jcmd(pid, "GC.class_histogram -all", HISTOGRAM_TIMEOUT)
|
||||
}
|
||||
|
||||
fn alive(&self, pid: u32) -> bool {
|
||||
alive(pid)
|
||||
}
|
||||
}
|
||||
|
||||
fn alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// 0 and negative pids address process groups.
|
||||
let Ok(pid) = i32::try_from(pid) else { return false };
|
||||
// SAFETY: signal 0 only checks that the process exists and may be signalled.
|
||||
pid > 0
|
||||
&& (unsafe { libc::kill(pid, 0) } == 0
|
||||
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `pid`'s attach listener cannot answer: it stopped listening or the program ended.
|
||||
pub(super) fn gone(probe: &dyn AttachProbe, pid: u32) -> JvmMemoryUnavailable {
|
||||
if probe.alive(pid) { JvmMemoryUnavailable::ListenerNotRunning } else { JvmMemoryUnavailable::ProgramExited }
|
||||
}
|
||||
|
||||
/// The reason for an attach request (`what`) to `jvm` that failed, or an error where no reason
|
||||
/// fits.
|
||||
pub(super) fn attach_failure(
|
||||
probe: &dyn AttachProbe,
|
||||
jvm: u32,
|
||||
error: AttachError,
|
||||
what: &str,
|
||||
) -> IdeResult<JvmMemoryUnavailable> {
|
||||
use JvmMemoryUnavailable as U;
|
||||
match error {
|
||||
AttachError::Timeout => Ok(U::TimedOut),
|
||||
AttachError::Refused | AttachError::NoListener => Ok(gone(probe, jvm)),
|
||||
AttachError::ForeignListener => {
|
||||
tracing::warn!("attach socket of JVM {jvm} is no longer private to this user; not using it");
|
||||
Ok(U::ListenerNotRunning)
|
||||
}
|
||||
AttachError::Unsupported => Ok(U::UnsupportedPlatform),
|
||||
e => Err(ide_core::IdeError::internal(format!("{what} of JVM {jvm}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pick_jvm_pid(tree: &[i32], has_listener: impl Fn(u32) -> bool) -> Option<u32> {
|
||||
tree.iter().filter_map(|&p| u32::try_from(p).ok()).filter(|&p| p > 0).find(|&p| has_listener(p))
|
||||
}
|
||||
|
||||
pub(crate) fn diff_rows(rows: Vec<HistogramRow>, previous: Option<&ClassCounts>) -> (Vec<JvmClassRow>, ClassCounts) {
|
||||
let mut seen: HashMap<&str, u32> = HashMap::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
*seen.entry(r.name.as_str()).or_default() += 1;
|
||||
}
|
||||
let ambiguous: Vec<bool> = rows.iter().map(|r| seen[r.name.as_str()] > 1).collect();
|
||||
drop(seen);
|
||||
|
||||
let mut counts = ClassCounts::with_capacity(rows.len());
|
||||
let mut out: Vec<JvmClassRow> = rows
|
||||
.into_iter()
|
||||
.zip(ambiguous)
|
||||
.map(|(r, ambiguous)| {
|
||||
counts.insert(r.name.clone(), (!ambiguous).then_some((r.instances, r.bytes)));
|
||||
let delta = match previous {
|
||||
_ if ambiguous => None,
|
||||
None => None,
|
||||
Some(p) => match p.get(&r.name) {
|
||||
Some(None) => None,
|
||||
// Absent: new since the previous snapshot, so it grew from nothing.
|
||||
before => {
|
||||
let (instances, bytes) = before.copied().flatten().unwrap_or_default();
|
||||
Some((delta(r.instances, instances), delta(r.bytes, bytes)))
|
||||
}
|
||||
},
|
||||
};
|
||||
JvmClassRow {
|
||||
name: r.name,
|
||||
module: r.module,
|
||||
instances: r.instances,
|
||||
bytes: r.bytes,
|
||||
delta_instances: delta.map(|d| d.0),
|
||||
delta_bytes: delta.map(|d| d.1),
|
||||
ambiguous,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
|
||||
(out, counts)
|
||||
}
|
||||
|
||||
/// Exact while the difference fits in an i64 (two's complement).
|
||||
fn delta(now: u64, before: u64) -> i64 {
|
||||
now.wrapping_sub(before) as i64
|
||||
}
|
||||
|
||||
/// `pid_tree` starts with the session's reported pid, followed by its descendants.
|
||||
pub(crate) fn take_snapshot(
|
||||
probe: &dyn AttachProbe,
|
||||
pid_tree: &[i32],
|
||||
suspended: bool,
|
||||
previous: Option<&ClassCounts>,
|
||||
) -> IdeResult<Result<(JvmMemorySnapshot, ClassCounts), JvmMemoryUnavailable>> {
|
||||
use JvmMemoryUnavailable as U;
|
||||
let Some(root) = pid_tree.first().and_then(|&p| u32::try_from(p).ok()) else {
|
||||
return Ok(Err(U::NoJvmProcess));
|
||||
};
|
||||
let Some(jvm) = pick_jvm_pid(pid_tree, |p| probe.has_listener(p)) else {
|
||||
return Ok(Err(gone(probe, root)));
|
||||
};
|
||||
|
||||
let version = probe.version(jvm);
|
||||
if suspended && !safe_while_suspended(version.as_ref()) {
|
||||
return Ok(Err(U::UnsafeWhileSuspended { java_major: version.map_or(0, |v| v.major) }));
|
||||
}
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let text = match probe.histogram(jvm) {
|
||||
Ok(text) => text,
|
||||
Err(e) => return attach_failure(probe, jvm, e, "class histogram").map(Err),
|
||||
};
|
||||
let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
|
||||
let histogram = ide_jvm_attach::parse_class_histogram(&text);
|
||||
if histogram.skipped_lines > 0 {
|
||||
tracing::debug!("class histogram of JVM {jvm}: {} unreadable lines", histogram.skipped_lines);
|
||||
}
|
||||
let (rows, counts) = diff_rows(histogram.rows, previous);
|
||||
let snapshot = JvmMemorySnapshot {
|
||||
total_instances: rows.iter().map(|r| r.instances).sum(),
|
||||
total_bytes: rows.iter().map(|r| r.bytes).sum(),
|
||||
rows,
|
||||
java_version: version.map(|v| v.text).unwrap_or_default(),
|
||||
taken_while_suspended: suspended,
|
||||
has_baseline: previous.is_some(),
|
||||
elapsed_ms,
|
||||
source: JvmMemorySource::Histogram,
|
||||
};
|
||||
Ok(Ok((snapshot, counts)))
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! A scripted [`JvmInspector`] and a session without an adapter behind it.
|
||||
|
||||
use crate::SessionOwner;
|
||||
use crate::adapter::{AdapterSpec, Transport};
|
||||
use crate::connection::Connection;
|
||||
use crate::session::Session;
|
||||
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
|
||||
use ide_jvm_inspect::{
|
||||
ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FakeInspector {
|
||||
pub targets: Mutex<Vec<VmTarget>>,
|
||||
/// The `limit` of each `instances` / `referrers` call.
|
||||
pub limits: Mutex<Vec<u32>>,
|
||||
pub calls: AtomicUsize,
|
||||
/// Returned by every call when set.
|
||||
pub error: Mutex<Option<InspectError>>,
|
||||
pub counts: Mutex<Vec<ClassCount>>,
|
||||
pub instances: Mutex<Vec<JvmValue>>,
|
||||
/// Every call signals `entered`, then waits for `release`.
|
||||
pub hold: bool,
|
||||
pub entered: Notify,
|
||||
pub release: Notify,
|
||||
}
|
||||
|
||||
impl FakeInspector {
|
||||
pub fn failing(error: InspectError) -> Self {
|
||||
Self { error: Mutex::new(Some(error)), ..Self::default() }
|
||||
}
|
||||
|
||||
pub fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn answer<T: Send>(&self, target: &VmTarget, limit: Option<u32>, ok: T) -> Result<T, InspectError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.targets.lock().push(target.clone());
|
||||
self.limits.lock().extend(limit);
|
||||
if self.hold {
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
}
|
||||
match self.error.lock().clone() {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(ok),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JvmInspector for FakeInspector {
|
||||
fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec<ClassCount>> {
|
||||
Box::pin(self.answer(target, None, self.counts.lock().clone()))
|
||||
}
|
||||
|
||||
fn instances<'a>(&'a self, target: &'a VmTarget, _: &'a str, limit: u32) -> InspectFuture<'a, Vec<JvmValue>> {
|
||||
Box::pin(self.answer(target, Some(limit), self.instances.lock().clone()))
|
||||
}
|
||||
|
||||
fn fields<'a>(&'a self, target: &'a VmTarget, _: ObjectId) -> InspectFuture<'a, Vec<JvmField>> {
|
||||
Box::pin(self.answer(target, None, Vec::new()))
|
||||
}
|
||||
|
||||
fn referrers<'a>(&'a self, target: &'a VmTarget, _: ObjectId, limit: u32) -> InspectFuture<'a, Vec<JvmReferrer>> {
|
||||
Box::pin(self.answer(target, Some(limit), Vec::new()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BareSession {
|
||||
pub session: Arc<Session>,
|
||||
pub events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
/// The adapter's end of the connection, kept open.
|
||||
_adapter: tokio::io::DuplexStream,
|
||||
}
|
||||
|
||||
/// A java-debug session that no adapter answers; must be called inside a Tokio runtime.
|
||||
pub(crate) fn bare_session(request: &str) -> BareSession {
|
||||
bare_session_of("java-debug", request)
|
||||
}
|
||||
|
||||
/// A session of adapter `name` that no adapter answers; must be called inside a Tokio runtime.
|
||||
pub(crate) fn bare_session_of(name: &str, request: &str) -> BareSession {
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink_events = events.clone();
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| sink_events.lock().push(e));
|
||||
let (client, adapter) = tokio::io::duplex(1 << 10);
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
let (conn, _) = Connection::start(reader, writer);
|
||||
let spec = AdapterSpec {
|
||||
adapter: name.into(),
|
||||
command: String::new(),
|
||||
args: Vec::new(),
|
||||
transport: Transport::Stdio,
|
||||
install_hint: None,
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
server_host: None,
|
||||
connect: None,
|
||||
pinned: None,
|
||||
};
|
||||
let session = Session::new(
|
||||
DebugSessionId(1),
|
||||
SessionOwner::new("test", sink),
|
||||
"test".into(),
|
||||
name.into(),
|
||||
None,
|
||||
request.into(),
|
||||
conn,
|
||||
spec,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
BareSession { session: Arc::new(session), events, _adapter: adapter }
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
use super::fallback::{debugger_snapshot, falls_back};
|
||||
use super::scan::*;
|
||||
use super::snapshot::*;
|
||||
use super::test_support::{BareSession, FakeInspector, bare_session, bare_session_of};
|
||||
use super::*;
|
||||
use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
fn row(name: &str, instances: u64, bytes: u64) -> HistogramRow {
|
||||
HistogramRow { name: name.into(), module: None, instances, bytes }
|
||||
}
|
||||
|
||||
struct FakeProbe {
|
||||
listener: u32,
|
||||
version: Option<JavaVersion>,
|
||||
histograms: parking_lot::Mutex<VecDeque<Result<String, AttachError>>>,
|
||||
alive: bool,
|
||||
delay: Duration,
|
||||
in_flight: AtomicUsize,
|
||||
max_in_flight: AtomicUsize,
|
||||
/// JDWP port → pid of the JVM listening on it.
|
||||
jdwp: Option<(u16, u32)>,
|
||||
scans: AtomicUsize,
|
||||
asked: parking_lot::Mutex<Vec<u32>>,
|
||||
/// Pids the scan sent a request to.
|
||||
queried: parking_lot::Mutex<Vec<u32>>,
|
||||
/// Attach commands sent, in order.
|
||||
commands: parking_lot::Mutex<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeProbe {
|
||||
fn new(listener: u32, histograms: Vec<Result<String, AttachError>>) -> Self {
|
||||
Self {
|
||||
listener,
|
||||
version: Some(JavaVersion { major: 21, text: "21.0.5".into() }),
|
||||
histograms: parking_lot::Mutex::new(histograms.into()),
|
||||
alive: true,
|
||||
delay: Duration::ZERO,
|
||||
in_flight: AtomicUsize::new(0),
|
||||
max_in_flight: AtomicUsize::new(0),
|
||||
jdwp: None,
|
||||
scans: AtomicUsize::new(0),
|
||||
asked: parking_lot::Mutex::new(Vec::new()),
|
||||
queried: parking_lot::Mutex::new(Vec::new()),
|
||||
commands: parking_lot::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(&self, command: &'static str) {
|
||||
self.commands.lock().push(command);
|
||||
let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.max_in_flight.fetch_max(now, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn leave(&self) {
|
||||
self.in_flight.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl AttachProbe for FakeProbe {
|
||||
fn has_listener(&self, pid: u32) -> bool {
|
||||
self.asked.lock().push(pid);
|
||||
pid == self.listener
|
||||
}
|
||||
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<u32> {
|
||||
self.scans.fetch_add(1, Ordering::SeqCst);
|
||||
let (listening, pid) = self.jdwp?;
|
||||
if !may_query(pid) {
|
||||
return None;
|
||||
}
|
||||
self.queried.lock().push(pid);
|
||||
(listening == port).then_some(pid)
|
||||
}
|
||||
fn version(&self, _pid: u32) -> Option<JavaVersion> {
|
||||
self.version.clone()
|
||||
}
|
||||
fn histogram(&self, _pid: u32) -> Result<String, AttachError> {
|
||||
self.enter("histogram");
|
||||
std::thread::sleep(self.delay);
|
||||
self.leave();
|
||||
self.histograms.lock().pop_front().expect("unexpected histogram request")
|
||||
}
|
||||
fn alive(&self, _pid: u32) -> bool {
|
||||
self.alive
|
||||
}
|
||||
}
|
||||
|
||||
const HISTOGRAM: &str = " num #instances #bytes class name (module)\n-------------------------------------------------------\n 1: 5 80 A\n 2: 1 16 B\nTotal 6 96\n";
|
||||
|
||||
fn unavailable(r: IdeResult<Result<(JvmMemorySnapshot, ClassCounts), JvmMemoryUnavailable>>) -> JvmMemoryUnavailable {
|
||||
r.unwrap().map(|_| ()).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_first_descendant_with_listener() {
|
||||
assert_eq!(pick_jvm_pid(&[10, 11, 12], |p| p == 12), Some(12));
|
||||
assert_eq!(pick_jvm_pid(&[10], |_| false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_snapshot_has_no_deltas() {
|
||||
let (rows, map) = diff_rows(vec![row("A", 5, 80), row("B", 1, 16)], None);
|
||||
assert!(rows.iter().all(|r| r.delta_instances.is_none() && r.delta_bytes.is_none() && !r.ambiguous));
|
||||
assert_eq!(map, HashMap::from([("A".into(), Some((5, 80))), ("B".into(), Some((1, 16)))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_against_previous_snapshot() {
|
||||
let previous = HashMap::from([("A".to_owned(), Some((2, 32)))]);
|
||||
let (rows, _) = diff_rows(vec![row("A", 5, 80), row("B", 1, 16)], Some(&previous));
|
||||
assert_eq!((rows[0].name.as_str(), rows[0].delta_instances, rows[0].delta_bytes), ("A", Some(3), Some(48)));
|
||||
assert_eq!((rows[1].name.as_str(), rows[1].delta_instances, rows[1].delta_bytes), ("B", Some(1), Some(16)));
|
||||
|
||||
let previous = HashMap::from([("A".to_owned(), Some((9, 144)))]);
|
||||
let (rows, _) = diff_rows(vec![row("A", 5, 80)], Some(&previous));
|
||||
assert_eq!((rows[0].delta_instances, rows[0].delta_bytes), (Some(-4), Some(-64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_are_ambiguous_without_delta() {
|
||||
let previous = HashMap::from([("Foo".to_owned(), Some((1, 16)))]);
|
||||
let (rows, map) = diff_rows(vec![row("Foo", 2, 32), row("Foo", 3, 48), row("Bar", 1, 8)], Some(&previous));
|
||||
let foos: Vec<_> = rows.iter().filter(|r| r.name == "Foo").collect();
|
||||
assert_eq!(foos.len(), 2);
|
||||
assert!(foos.iter().all(|r| r.ambiguous && r.delta_instances.is_none() && r.delta_bytes.is_none()));
|
||||
assert_eq!(map.get("Foo"), Some(&None));
|
||||
assert_eq!(map.get("Bar"), Some(&Some((1, 8))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previously_duplicate_now_unique_has_no_delta() {
|
||||
let (_, previous) = diff_rows(vec![row("Foo", 2, 32), row("Foo", 3, 48)], None);
|
||||
let (rows, map) = diff_rows(vec![row("Foo", 4, 64)], Some(&previous));
|
||||
assert!(!rows[0].ambiguous);
|
||||
assert_eq!((rows[0].delta_instances, rows[0].delta_bytes), (None, None));
|
||||
assert_eq!(map.get("Foo"), Some(&Some((4, 64))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_sorted_by_bytes_descending() {
|
||||
let (rows, _) = diff_rows(vec![row("S", 1, 8), row("L", 1, 800), row("M", 1, 80)], None);
|
||||
let names: Vec<_> = rows.iter().map(|r| r.name.as_str()).collect();
|
||||
assert_eq!(names, ["L", "M", "S"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_socket_reports_program_exited() {
|
||||
let mut probe = FakeProbe::new(12, vec![Err(AttachError::Refused)]);
|
||||
probe.alive = false;
|
||||
assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ProgramExited);
|
||||
|
||||
let probe = FakeProbe::new(12, vec![Err(AttachError::Refused)]);
|
||||
assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ListenerNotRunning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_listener_in_tree() {
|
||||
let mut probe = FakeProbe::new(99, Vec::new());
|
||||
assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ListenerNotRunning);
|
||||
probe.alive = false;
|
||||
assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ProgramExited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspended_vm_needs_a_safe_jdk() {
|
||||
let mut probe = FakeProbe::new(12, Vec::new());
|
||||
probe.version = Some(JavaVersion { major: 11, text: "11.0.25".into() });
|
||||
assert_eq!(
|
||||
unavailable(take_snapshot(&probe, &[12], true, None)),
|
||||
JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 }
|
||||
);
|
||||
probe.version = None;
|
||||
assert_eq!(
|
||||
unavailable(take_snapshot(&probe, &[12], true, None)),
|
||||
JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 0 }
|
||||
);
|
||||
|
||||
let probe = FakeProbe::new(12, vec![Ok(HISTOGRAM.into())]);
|
||||
let (snapshot, _) = take_snapshot(&probe, &[12], true, None).unwrap().unwrap();
|
||||
assert!(snapshot.taken_while_suspended);
|
||||
assert_eq!(snapshot.java_version, "21.0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_errors_are_internal() {
|
||||
let probe = FakeProbe::new(12, vec![Err(AttachError::Command { code: 1, message: "no".into() })]);
|
||||
let err = take_snapshot(&probe, &[12], false, None).unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_serializes_with_camel_case_tags() {
|
||||
let r = JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 },
|
||||
can_count_when_paused: true,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(r).unwrap(),
|
||||
serde_json::json!({
|
||||
"status": "unavailable",
|
||||
"reason": {"kind": "unsafeWhileSuspended", "javaMajor": 11},
|
||||
"canCountWhenPaused": true
|
||||
})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(JvmMemorySource::Debugger).unwrap(), "debugger");
|
||||
let stale = JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale };
|
||||
assert_eq!(
|
||||
serde_json::to_value(stale).unwrap(),
|
||||
serde_json::json!({"status": "unavailable", "reason": {"kind": "stale"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_jdwp_port_accepts_loopback_only() {
|
||||
let port = |args: serde_json::Value| local_jdwp_port(&args);
|
||||
for host in ["127.0.0.1", "localhost", "LocalHost", "::1", "[::1]"] {
|
||||
assert_eq!(port(serde_json::json!({"hostName": host, "port": 5005})), Some(5005), "{host}");
|
||||
}
|
||||
assert_eq!(port(serde_json::json!({"port": 5005})), Some(5005));
|
||||
assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": "5005"})), Some(5005));
|
||||
assert_eq!(port(serde_json::json!({"hostName": "10.0.0.5", "port": 5005})), None);
|
||||
assert_eq!(port(serde_json::json!({"hostName": "build-box", "port": 5005})), None);
|
||||
assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": 0})), None);
|
||||
assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": 70000})), None);
|
||||
assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": "x"})), None);
|
||||
assert_eq!(port(serde_json::json!({"hostName": "localhost"})), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_with_pid_takes_snapshot() {
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let running = Suspension::default();
|
||||
let jvm_pid = AtomicI64::new(4242);
|
||||
let fake = Arc::new(FakeProbe::new(4242, vec![Ok(HISTOGRAM.into())]));
|
||||
let probe: Arc<dyn AttachProbe> = fake.clone();
|
||||
let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) };
|
||||
let r = snapshot(&state, &running, |_| probe, target, None).await.unwrap();
|
||||
assert!(matches!(r, JvmMemoryResult::Ready { .. }), "{r:?}");
|
||||
// The attached JVM's own pid only: its children are not part of the session.
|
||||
assert_eq!(*fake.asked.lock(), [4242]);
|
||||
assert_eq!(fake.scans.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rescans_local_attach_without_pid() {
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let running = Suspension::default();
|
||||
let jvm_pid = AtomicI64::new(0);
|
||||
|
||||
let missing = Arc::new(FakeProbe::new(4242, Vec::new()));
|
||||
let probe: Arc<dyn AttachProbe> = missing.clone();
|
||||
let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) };
|
||||
assert_eq!(
|
||||
snapshot(&state, &running, |_| probe, target, None).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::AttachedJvmNotFound,
|
||||
can_count_when_paused: false
|
||||
}
|
||||
);
|
||||
assert_eq!(missing.scans.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(jvm_pid.load(Ordering::Acquire), 0);
|
||||
|
||||
let mut found = FakeProbe::new(4242, vec![Ok(HISTOGRAM.into()), Ok(HISTOGRAM.into())]);
|
||||
found.jdwp = Some((5005, 4242));
|
||||
let found = Arc::new(found);
|
||||
let probe: Arc<dyn AttachProbe> = found.clone();
|
||||
let target = || JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) };
|
||||
let r = snapshot(&state, &running, |_| probe.clone(), target(), None).await.unwrap();
|
||||
assert!(matches!(r, JvmMemoryResult::Ready { .. }), "{r:?}");
|
||||
assert_eq!(jvm_pid.load(Ordering::Acquire), 4242);
|
||||
snapshot(&state, &running, |_| probe, target(), None).await.unwrap();
|
||||
assert_eq!(found.scans.load(Ordering::SeqCst), 1, "a found pid is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_attach_without_pid_is_not_scanned() {
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let running = Suspension::default();
|
||||
let jvm_pid = AtomicI64::new(0);
|
||||
let mut fake = FakeProbe::new(4242, Vec::new());
|
||||
fake.jdwp = Some((5005, 4242));
|
||||
let fake = Arc::new(fake);
|
||||
let probe: Arc<dyn AttachProbe> = fake.clone();
|
||||
let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: None };
|
||||
assert_eq!(
|
||||
snapshot(&state, &running, |_| probe, target, None).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::AttachedJvmNotFound,
|
||||
can_count_when_paused: false
|
||||
}
|
||||
);
|
||||
assert_eq!(fake.scans.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_find_until_the_jvm_appears() {
|
||||
let tries = Arc::new(AtomicUsize::new(0));
|
||||
let counted = tries.clone();
|
||||
let find = move |_| (counted.fetch_add(1, Ordering::SeqCst) >= 2).then_some(4242);
|
||||
let found = retry_find(find, || RUNNING, Duration::from_millis(1), Duration::from_secs(5)).await;
|
||||
assert_eq!(found, Some(4242));
|
||||
assert_eq!(tries.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
const RUNNING: ScanGate = ScanGate::Scan { suspended: false };
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_find_tells_each_try_whether_the_jvm_is_suspended() {
|
||||
let seen = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let recorded = seen.clone();
|
||||
// The JVM is an old JDK, so a suspended try skips it.
|
||||
let find = move |suspended: bool| {
|
||||
recorded.lock().push(suspended);
|
||||
(!suspended).then_some(4242)
|
||||
};
|
||||
let checks = AtomicUsize::new(0);
|
||||
// Suspended for the first three checks, then resumed.
|
||||
let gate = || ScanGate::Scan { suspended: checks.fetch_add(1, Ordering::SeqCst) < 3 };
|
||||
let found = retry_find(find, gate, Duration::from_millis(1), Duration::from_secs(5)).await;
|
||||
assert_eq!(found, Some(4242));
|
||||
assert_eq!(*seen.lock(), [true, true, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_known_jdk_17_or_later_is_safe_while_suspended() {
|
||||
let jdk = |major| Some(JavaVersion { major, text: major.to_string() });
|
||||
assert!(safe_while_suspended(jdk(21).as_ref()));
|
||||
assert!(safe_while_suspended(jdk(17).as_ref()));
|
||||
assert!(!safe_while_suspended(jdk(11).as_ref()));
|
||||
assert!(!safe_while_suspended(None));
|
||||
}
|
||||
|
||||
/// A suspended local attach session whose JVM (pid 4242, JDWP 5005) is not known yet.
|
||||
async fn suspended_scan(fake: FakeProbe) -> (JvmMemoryResult, Arc<FakeProbe>, i64) {
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let suspended = Suspension::default();
|
||||
suspended.stopped(None, true);
|
||||
let jvm_pid = AtomicI64::new(0);
|
||||
let fake = Arc::new(fake);
|
||||
let probe: Arc<dyn AttachProbe> = fake.clone();
|
||||
let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) };
|
||||
let result = snapshot(&state, &suspended, |_| probe, target, None).await.unwrap();
|
||||
(result, fake, jvm_pid.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suspended_scan_finds_a_jdk_17_or_later() {
|
||||
let mut fake = FakeProbe::new(4242, vec![Ok(HISTOGRAM.into())]);
|
||||
fake.jdwp = Some((5005, 4242));
|
||||
let (result, fake, pid) = suspended_scan(fake).await;
|
||||
let JvmMemoryResult::Ready { snapshot } = result else { panic!("{result:?}") };
|
||||
assert!(snapshot.taken_while_suspended);
|
||||
assert_eq!(*fake.queried.lock(), [4242]);
|
||||
assert_eq!(pid, 4242);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suspended_scan_never_asks_an_older_jdk() {
|
||||
for (version, java_major) in [(Some(JavaVersion { major: 11, text: "11.0.25".into() }), 11), (None, 0)] {
|
||||
let mut fake = FakeProbe::new(4242, Vec::new());
|
||||
fake.jdwp = Some((5005, 4242));
|
||||
fake.version = version;
|
||||
let (result, fake, pid) = suspended_scan(fake).await;
|
||||
assert_eq!(
|
||||
result,
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major },
|
||||
can_count_when_paused: false,
|
||||
}
|
||||
);
|
||||
assert!(fake.queried.lock().is_empty());
|
||||
assert!(fake.asked.lock().is_empty());
|
||||
assert_eq!(pid, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suspended_scan_without_candidates_is_not_found() {
|
||||
let (result, _, _) = suspended_scan(FakeProbe::new(4242, Vec::new())).await;
|
||||
assert_eq!(
|
||||
result,
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::AttachedJvmNotFound,
|
||||
can_count_when_paused: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_find_gives_up_after_its_window() {
|
||||
let tries = Arc::new(AtomicUsize::new(0));
|
||||
let counted = tries.clone();
|
||||
let find = move |_| {
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let found = retry_find(find, || RUNNING, Duration::from_millis(10), Duration::from_millis(100)).await;
|
||||
assert_eq!(found, None);
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
assert!(tries.load(Ordering::SeqCst) >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_find_stops_when_the_session_ends() {
|
||||
let tries = Arc::new(AtomicUsize::new(0));
|
||||
let counted = tries.clone();
|
||||
let find = move |_| {
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
};
|
||||
let live = AtomicBool::new(true);
|
||||
let gate = || if live.swap(false, Ordering::SeqCst) { RUNNING } else { ScanGate::Ended };
|
||||
let found = retry_find(find, gate, Duration::from_millis(1), Duration::from_secs(5)).await;
|
||||
assert_eq!(found, None);
|
||||
assert_eq!(tries.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn histogram_lock_released_after_timeout() {
|
||||
let pid = std::process::id();
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let running = Suspension::default();
|
||||
let probe: Arc<dyn AttachProbe> =
|
||||
Arc::new(FakeProbe::new(pid, vec![Err(AttachError::Timeout), Ok(HISTOGRAM.into())]));
|
||||
let first = snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None).await.unwrap();
|
||||
assert_eq!(
|
||||
first,
|
||||
JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::TimedOut, can_count_when_paused: false }
|
||||
);
|
||||
let second = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
snapshot(&state, &running, |_| probe, JvmTarget::Launched(pid as i32), None),
|
||||
)
|
||||
.await
|
||||
.expect("second request blocked by the first")
|
||||
.unwrap();
|
||||
let JvmMemoryResult::Ready { snapshot } = second else { panic!("{second:?}") };
|
||||
assert!(!snapshot.has_baseline);
|
||||
assert_eq!((snapshot.total_instances, snapshot.total_bytes), (6, 96));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn one_histogram_at_a_time_per_session() {
|
||||
let pid = std::process::id();
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let mut fake = FakeProbe::new(pid, vec![Ok(HISTOGRAM.into()), Ok(HISTOGRAM.into())]);
|
||||
fake.delay = Duration::from_millis(100);
|
||||
let fake = Arc::new(fake);
|
||||
let probe: Arc<dyn AttachProbe> = fake.clone();
|
||||
let running = Suspension::default();
|
||||
let (a, b) = tokio::join!(
|
||||
snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None),
|
||||
snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None)
|
||||
);
|
||||
assert_eq!(fake.max_in_flight.load(Ordering::SeqCst), 1);
|
||||
let has_baseline = |r: JvmMemoryResult| match r {
|
||||
JvmMemoryResult::Ready { snapshot } => snapshot.has_baseline,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let mut baselines = [has_baseline(a.unwrap()), has_baseline(b.unwrap())];
|
||||
baselines.sort();
|
||||
assert_eq!(baselines, [false, true]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn queued_request_reads_suspension_under_the_lock() {
|
||||
let pid = std::process::id();
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let suspended = Suspension::default();
|
||||
let mut fake = FakeProbe::new(pid, Vec::new());
|
||||
fake.version = Some(JavaVersion { major: 11, text: "11.0.25".into() });
|
||||
let probe: Arc<dyn AttachProbe> = Arc::new(fake);
|
||||
let seen = parking_lot::Mutex::new(None);
|
||||
|
||||
let running = state.lock().await;
|
||||
let mut queued = Box::pin(snapshot(
|
||||
&state,
|
||||
&suspended,
|
||||
|s| {
|
||||
*seen.lock() = Some(s);
|
||||
probe
|
||||
},
|
||||
JvmTarget::Launched(pid as i32),
|
||||
None,
|
||||
));
|
||||
assert!(tokio::time::timeout(Duration::from_millis(50), &mut queued).await.is_err());
|
||||
// The program hits a breakpoint while the request waits behind another one.
|
||||
suspended.stopped(None, true);
|
||||
drop(running);
|
||||
assert_eq!(
|
||||
queued.await.unwrap(),
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 },
|
||||
can_count_when_paused: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(*seen.lock(), Some(true));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn failed_blocking_task_keeps_the_baseline() {
|
||||
let pid = std::process::id();
|
||||
let state = tokio::sync::Mutex::new(JvmMemoryState::default());
|
||||
let running = Suspension::default();
|
||||
let probe = |histograms: Vec<_>| -> Arc<dyn AttachProbe> { Arc::new(FakeProbe::new(pid, histograms)) };
|
||||
snapshot(&state, &running, |_| probe(vec![Ok(HISTOGRAM.into())]), JvmTarget::Launched(pid as i32), None)
|
||||
.await
|
||||
.unwrap();
|
||||
// An empty fake panics on its histogram call, failing the blocking task.
|
||||
assert!(snapshot(&state, &running, |_| probe(Vec::new()), JvmTarget::Launched(pid as i32), None).await.is_err());
|
||||
let third =
|
||||
snapshot(&state, &running, |_| probe(vec![Ok(HISTOGRAM.into())]), JvmTarget::Launched(pid as i32), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let JvmMemoryResult::Ready { snapshot } = third else { panic!("{third:?}") };
|
||||
assert!(snapshot.has_baseline);
|
||||
}
|
||||
|
||||
fn class(name: &str, instances: u64) -> ide_jvm_inspect::ClassCount {
|
||||
ide_jvm_inspect::ClassCount { name: name.into(), instances }
|
||||
}
|
||||
|
||||
fn jdk11(listener: u32) -> FakeProbe {
|
||||
let mut probe = FakeProbe::new(listener, Vec::new());
|
||||
probe.version = Some(JavaVersion { major: 11, text: "11.0.25".into() });
|
||||
probe
|
||||
}
|
||||
|
||||
/// The class table of `bare`'s session, histogram from `probe` or else `target`'s reason.
|
||||
async fn table(bare: &BareSession, probe: FakeProbe, target: JvmTarget<'_>) -> JvmMemoryResult {
|
||||
let s = &bare.session;
|
||||
let probe: Arc<dyn AttachProbe> = Arc::new(probe);
|
||||
snapshot(&s.jvm_memory, &s.suspended, |_| probe, target, Some(s)).await.unwrap()
|
||||
}
|
||||
|
||||
fn inspected(bare: &BareSession, fake: FakeInspector) -> Arc<FakeInspector> {
|
||||
let fake = Arc::new(fake);
|
||||
*bare.session.jvm_inspector.lock() = Some(fake.clone());
|
||||
fake
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn suspended_jdk11_falls_back_to_debugger_counts() {
|
||||
let pid = std::process::id();
|
||||
let bare = bare_session("launch");
|
||||
bare.session.suspended.stopped(None, true);
|
||||
let fake = inspected(&bare, FakeInspector::default());
|
||||
fake.counts.lock().extend([class("A", 3), class("B", 0)]);
|
||||
let target = JvmTarget::Launched(pid as i32);
|
||||
let result = table(&bare, jdk11(pid), target).await;
|
||||
let JvmMemoryResult::Ready { snapshot } = result else { panic!("{result:?}") };
|
||||
assert_eq!(snapshot.source, JvmMemorySource::Debugger);
|
||||
assert_eq!(
|
||||
snapshot.rows,
|
||||
[JvmClassRow {
|
||||
name: "A".into(),
|
||||
module: None,
|
||||
instances: 3,
|
||||
bytes: 0,
|
||||
delta_instances: None,
|
||||
delta_bytes: None,
|
||||
ambiguous: false
|
||||
}]
|
||||
);
|
||||
assert_eq!((snapshot.total_instances, snapshot.total_bytes), (3, 0));
|
||||
assert!(snapshot.taken_while_suspended && !snapshot.has_baseline);
|
||||
assert_eq!(fake.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasons_found_before_asking_the_jvm_fall_back_too() {
|
||||
let bare = bare_session("launch");
|
||||
bare.session.suspended.stopped(None, true);
|
||||
let fake = inspected(&bare, FakeInspector::default());
|
||||
fake.counts.lock().push(class("A", 3));
|
||||
let result = table(&bare, jdk11(1), JvmTarget::Unavailable(JvmMemoryUnavailable::UnsupportedPlatform)).await;
|
||||
assert!(matches!(&result, JvmMemoryResult::Ready { snapshot } if snapshot.source == JvmMemorySource::Debugger));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_without_listener_offers_counting_when_paused() {
|
||||
let bare = bare_session("launch");
|
||||
let reason = JvmMemoryUnavailable::ListenerNotRunning;
|
||||
let target = JvmTarget::Unavailable(reason);
|
||||
let without = table(&bare, jdk11(1), target).await;
|
||||
assert_eq!(without, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false });
|
||||
|
||||
let fake = inspected(&bare, FakeInspector::default());
|
||||
let with = table(&bare, jdk11(1), target).await;
|
||||
assert_eq!(with, JvmMemoryResult::Unavailable { reason, can_count_when_paused: true });
|
||||
assert_eq!(fake.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_reasons_do_not_fall_back() {
|
||||
let bare = bare_session("attach");
|
||||
bare.session.suspended.stopped(None, true);
|
||||
let fake = inspected(&bare, FakeInspector::default());
|
||||
for reason in [JvmMemoryUnavailable::ProgramExited, JvmMemoryUnavailable::TimedOut] {
|
||||
let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await;
|
||||
assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false });
|
||||
}
|
||||
assert_eq!(fake.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_debugger_count_reports_the_histogram_reason() {
|
||||
let bare = bare_session("launch");
|
||||
bare.session.suspended.stopped(None, true);
|
||||
inspected(&bare, FakeInspector::failing(ide_jvm_inspect::InspectError::TimedOut));
|
||||
let reason = JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 };
|
||||
let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await;
|
||||
assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_count_turns_the_backend_off() {
|
||||
use ide_core::event::{DebugEvent, JvmBackendState};
|
||||
let bare = bare_session("launch");
|
||||
bare.session.suspended.stopped(None, true);
|
||||
inspected(&bare, FakeInspector::failing(ide_jvm_inspect::InspectError::Unsupported("x".into())));
|
||||
let reason = JvmMemoryUnavailable::UnsupportedPlatform;
|
||||
let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await;
|
||||
assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false });
|
||||
assert!(bare.session.jvm_inspector.lock().is_none());
|
||||
let events: Vec<_> = bare
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
ide_core::IdeEvent::Debug { event, .. } => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(
|
||||
events.as_slice(),
|
||||
[DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { .. } }, DebugEvent::Capabilities]
|
||||
),
|
||||
"{events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_only_where_the_debugger_can_count() {
|
||||
use JvmMemoryUnavailable as U;
|
||||
for reason in [
|
||||
U::UnsafeWhileSuspended { java_major: 11 },
|
||||
U::UnsupportedPlatform,
|
||||
U::AttachedJvmNotFound,
|
||||
U::ListenerNotRunning,
|
||||
U::ListenModeNotSupported,
|
||||
U::NoJvmProcess,
|
||||
] {
|
||||
assert!(falls_back(&reason), "{reason:?}");
|
||||
}
|
||||
assert!(!falls_back(&U::ProgramExited) && !falls_back(&U::TimedOut));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_only_against_the_same_source() {
|
||||
use JvmMemorySource::{Debugger, Histogram};
|
||||
let mut state = JvmMemoryState::default();
|
||||
let probe = FakeProbe::new(12, vec![Ok(HISTOGRAM.into())]);
|
||||
let (_, counts) = take_snapshot(&probe, &[12], false, None).unwrap().unwrap();
|
||||
state.remember(Histogram, counts);
|
||||
|
||||
let (snapshot, counts) =
|
||||
debugger_snapshot(vec![class("A", 3), class("C", 1)], state.baseline(Debugger).as_deref(), 0);
|
||||
assert!(!snapshot.has_baseline);
|
||||
assert!(snapshot.rows.iter().all(|r| r.delta_instances.is_none() && r.delta_bytes.is_none()), "{snapshot:?}");
|
||||
state.remember(Debugger, counts);
|
||||
assert!(state.baseline(Histogram).is_none());
|
||||
|
||||
let (snapshot, _) = debugger_snapshot(vec![class("A", 4)], state.baseline(Debugger).as_deref(), 0);
|
||||
assert!(snapshot.has_baseline);
|
||||
assert_eq!((snapshot.rows[0].delta_instances, snapshot.rows[0].delta_bytes), (Some(1), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debugger_rows_are_ordered_by_instances() {
|
||||
let (snapshot, _) = debugger_snapshot(vec![class("B", 1), class("A", 9), class("C", 9)], None, 0);
|
||||
let names: Vec<_> = snapshot.rows.iter().map(|r| r.name.as_str()).collect();
|
||||
assert_eq!(names, ["A", "C", "B"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_matches_memory() {
|
||||
for adapter in JVM_MEMORY_ADAPTERS.iter().copied().chain(["debugpy"]) {
|
||||
let bare = bare_session_of(adapter, "launch");
|
||||
let caps = crate::session_capabilities(&bare.session);
|
||||
let jvm = adapter != "debugpy";
|
||||
assert_eq!(caps.jvm_memory, jvm, "{adapter}");
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,6 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
|
||||
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
|
||||
/// until the attach is done.
|
||||
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: true }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
dir: tempfile::TempDir,
|
||||
paths: HashMap<PathBuf, PathBuf>,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
|
||||
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
|
||||
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
|
||||
use ide_core::IdeResult;
|
||||
use std::{io, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
pub(crate) struct StartupRelay {
|
||||
release: Option<oneshot::Sender<()>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl StartupRelay {
|
||||
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = listener.local_addr()?.port();
|
||||
let (release, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let connect = async {
|
||||
let (adapter, _) = listener.accept().await?;
|
||||
let jvm = TcpStream::connect((host.as_str(), port)).await?;
|
||||
adapter.set_nodelay(true)?;
|
||||
jvm.set_nodelay(true)?;
|
||||
Ok::<_, io::Error>((adapter, jvm))
|
||||
};
|
||||
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
|
||||
let (mut ar, mut aw) = adapter.into_split();
|
||||
let (mut jr, mut jw) = jvm.into_split();
|
||||
let upstream = forward(&mut ar, &mut jw, ready);
|
||||
let downstream = tokio::io::copy(&mut jr, &mut aw);
|
||||
// Close both sockets as soon as either side disconnects.
|
||||
tokio::select! {
|
||||
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
|
||||
_ = downstream => {},
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((Self { release: Some(release), task }, local))
|
||||
}
|
||||
|
||||
pub fn release(&mut self) {
|
||||
if let Some(ready) = self.release.take() {
|
||||
let _ = ready.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StartupRelay {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
|
||||
let mut header = [0; 11];
|
||||
reader.read_exact(&mut header).await?;
|
||||
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
|
||||
if !(11..=16 * 1024 * 1024).contains(&len) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
|
||||
}
|
||||
let mut packet = vec![0; len];
|
||||
packet[..11].copy_from_slice(&header);
|
||||
reader.read_exact(&mut packet[11..]).await?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
async fn forward(
|
||||
reader: &mut (impl AsyncRead + Unpin),
|
||||
writer: &mut (impl AsyncWrite + Unpin),
|
||||
mut ready: oneshot::Receiver<()>,
|
||||
) -> io::Result<()> {
|
||||
let mut handshake = [0; 14];
|
||||
reader.read_exact(&mut handshake).await?;
|
||||
if &handshake != b"JDWP-Handshake" {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
|
||||
}
|
||||
writer.write_all(&handshake).await?;
|
||||
let mut released = false;
|
||||
let mut held: Vec<Vec<u8>> = Vec::new();
|
||||
loop {
|
||||
// Retain the in-flight read when attach completes, including partial packets.
|
||||
let read = packet(reader);
|
||||
tokio::pin!(read);
|
||||
let packet = tokio::select! {
|
||||
result = &mut read => result?,
|
||||
result = &mut ready, if !released => {
|
||||
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
|
||||
released = true;
|
||||
for packet in held.drain(..) {
|
||||
writer.write_all(&packet).await?;
|
||||
}
|
||||
read.await?
|
||||
}
|
||||
};
|
||||
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
|
||||
// Other commands must proceed: attach itself queries threads and installs requests.
|
||||
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
|
||||
if held.len() >= 1024 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
|
||||
}
|
||||
held.push(packet);
|
||||
} else {
|
||||
writer.write_all(&packet).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
|
||||
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
|
||||
let (mut adapter, mut reader) = tokio::io::duplex(4096);
|
||||
let (mut writer, mut jvm) = tokio::io::duplex(4096);
|
||||
let (release, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
|
||||
adapter.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
let mut handshake = [0; 14];
|
||||
jvm.read_exact(&mut handshake).await.unwrap();
|
||||
assert_eq!(&handshake, b"JDWP-Handshake");
|
||||
let resume = command(1, 1, 9);
|
||||
let thread_resume = command(2, 11, 3);
|
||||
let setup = command(3, 1, 4);
|
||||
adapter.write_all(&resume).await.unwrap();
|
||||
adapter.write_all(&thread_resume).await.unwrap();
|
||||
adapter.write_all(&setup).await.unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
|
||||
let next = command(4, 1, 9);
|
||||
adapter.write_all(&next[..6]).await.unwrap();
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
|
||||
adapter.write_all(&next[6..]).await.unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), next);
|
||||
drop(adapter);
|
||||
assert!(task.await.unwrap().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_session_closes_relay() {
|
||||
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
|
||||
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
drop(relay);
|
||||
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_packet_lengths() {
|
||||
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
|
||||
let mut bytes = command(1, 1, 9);
|
||||
bytes[..4].copy_from_slice(&len.to_be_bytes());
|
||||
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-181
@@ -13,8 +13,8 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -22,7 +22,6 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -127,12 +126,6 @@ pub struct DebugCapabilities {
|
||||
pub log_points: bool,
|
||||
/// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request.
|
||||
pub hot_code_replace: bool,
|
||||
/// The session debugs a JVM, so it has a Memory view; [`DebugManager::jvm_memory_classes`]
|
||||
/// says why when it has no data (platform, remote JVM, ...).
|
||||
pub jvm_memory: bool,
|
||||
/// The session has a JVM inspector: instances, fields and referrers while suspended. Can
|
||||
/// change during the session ([`ide_core::event::DebugEvent::Capabilities`]).
|
||||
pub jvm_instances: bool,
|
||||
}
|
||||
|
||||
/// Result of [`DebugManager::redefine_classes`].
|
||||
@@ -309,7 +302,6 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
jvm_finder: Mutex<jvm_memory::JvmFinder>,
|
||||
}
|
||||
|
||||
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
|
||||
@@ -574,19 +566,9 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"),
|
||||
log_points: has("supportsLogPoints"),
|
||||
hot_code_replace: false,
|
||||
jvm_memory: false,
|
||||
jvm_instances: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
|
||||
let mut caps = capabilities_from(&session.caps.lock());
|
||||
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_instances = session.jvm_inspector.lock().is_some();
|
||||
caps
|
||||
}
|
||||
|
||||
/// States of a file's line breakpoints that no session has been sent.
|
||||
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
|
||||
breakpoints
|
||||
@@ -625,22 +607,11 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
// Windows: a just-killed dlv / debuggee can still hold this session's
|
||||
// binary while the previous one's lock is already gone, so one try
|
||||
// leaves exactly the current leftover behind. Retry on a short back-off
|
||||
// to outlast the handle release; a truly busy file is retried into the
|
||||
// next run's sweep anyway.
|
||||
for attempt in 0..5 {
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt == 4 => {
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
|
||||
}
|
||||
if leftover
|
||||
&& entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,15 +646,9 @@ impl DebugManager {
|
||||
function_breakpoints: Mutex::default(),
|
||||
data_breakpoints: Mutex::default(),
|
||||
stopping: Mutex::default(),
|
||||
jvm_finder: Mutex::new(Arc::new(jvm_memory::find_local_jvm)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_jvm_finder(&self, finder: jvm_memory::JvmFinder) {
|
||||
*self.jvm_finder.lock() = finder;
|
||||
}
|
||||
|
||||
fn session(&self, s: DebugSessionId) -> IdeResult<Arc<Session>> {
|
||||
self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s))
|
||||
}
|
||||
@@ -742,10 +707,6 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
// Delve looks up Go before applying the debuggee's launch environment.
|
||||
if config.adapter == "delve" {
|
||||
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
@@ -778,18 +739,6 @@ impl DebugManager {
|
||||
if let Some(server) = &debug_server {
|
||||
spec.server_host = server.host.clone();
|
||||
}
|
||||
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
|
||||
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
|
||||
// instead belong on the adapter process, which the inferior inherits. Explicit
|
||||
// launch JSON keeps GDB's replacement semantics.
|
||||
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
|
||||
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
|
||||
});
|
||||
if !explicit_env {
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
arguments.as_object_mut().unwrap().remove("env");
|
||||
}
|
||||
}
|
||||
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
|
||||
let cargo_args: Vec<String> = cargo
|
||||
.get("args")
|
||||
@@ -899,7 +848,6 @@ impl DebugManager {
|
||||
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
|
||||
}
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
|
||||
let launch_after_configuration = io.launch_after_configuration && request == "launch";
|
||||
let stderr = io.stderr.clone();
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
@@ -937,7 +885,7 @@ impl DebugManager {
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments, launch_after_configuration) => result,
|
||||
result = self.configure(&session, arguments) => result,
|
||||
};
|
||||
match configured {
|
||||
Ok(()) => Ok(id),
|
||||
@@ -964,7 +912,7 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let project = arguments
|
||||
.get("projectRoot")
|
||||
@@ -1003,31 +951,13 @@ impl DebugManager {
|
||||
.await?;
|
||||
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
|
||||
|
||||
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
|
||||
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
|
||||
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
&s.request
|
||||
};
|
||||
// Both blocks below run before the Kotlin link replaces the JVM's address with its own.
|
||||
if request == "attach"
|
||||
&& let Some(target) = jvm_memory::attach_target(&arguments)
|
||||
{
|
||||
let _ = s.attach_target.set(target);
|
||||
}
|
||||
if cfg!(unix)
|
||||
&& s.request == "attach"
|
||||
&& JVM_MEMORY_ADAPTERS.contains(&s.adapter.as_str())
|
||||
&& let Some(port) = jvm_memory::local_jdwp_port(&arguments)
|
||||
{
|
||||
if jdwp::is_listen_relay(port) {
|
||||
s.listen_relay.store(true, Ordering::Release);
|
||||
} else {
|
||||
let finder = self.jvm_finder.lock().clone();
|
||||
jvm_memory::find_attached_jvm(s, port, finder).await;
|
||||
}
|
||||
}
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
@@ -1040,39 +970,24 @@ impl DebugManager {
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
|
||||
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
|
||||
jvm_memory::watch_link(s, &link);
|
||||
*s.jdwp_link.lock() = Some(link);
|
||||
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
|
||||
*s.kotlin_relay.lock() = Some(relay);
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
s.jvm_located.send_replace(true);
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
|
||||
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
|
||||
let mut launch = if launch_in_config {
|
||||
None
|
||||
} else {
|
||||
let reply = s.conn.send_request(request, arguments.clone())?;
|
||||
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
|
||||
};
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
let mut launch_result = None;
|
||||
let got_initialized = match launch.as_mut() {
|
||||
Some(launch) => tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
},
|
||||
None => {
|
||||
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
|
||||
true
|
||||
let got_initialized = tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = &mut launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
};
|
||||
if !got_initialized {
|
||||
@@ -1130,20 +1045,11 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
match launch.as_mut() {
|
||||
Some(launch) => {
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
|
||||
let reply = s.conn.send_request(request, arguments)?;
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
}
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
}
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1160,9 +1066,6 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
@@ -1190,7 +1093,7 @@ impl DebugManager {
|
||||
if own_terminate && !graceful {
|
||||
// Signal before anything else: waiting on a request the adapter may not answer
|
||||
// would delay the shutdown the console is supposed to show.
|
||||
crate::session::signal_debuggee(s, false).await;
|
||||
crate::session::signal_debuggee(s, false);
|
||||
}
|
||||
if graceful {
|
||||
// java-debug sends `terminated` only after it has drained the program's console
|
||||
@@ -1376,9 +1279,8 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
let session = self.session(s)?;
|
||||
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
let threads: Vec<ThreadInfo> = body
|
||||
let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
Ok(body
|
||||
.get("threads")
|
||||
.and_then(Value::as_array)
|
||||
.map(|t| {
|
||||
@@ -1389,8 +1291,7 @@ impl DebugManager {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(threads)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
@@ -1519,11 +1420,8 @@ impl DebugManager {
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// DAP: a response without the flag continued every thread.
|
||||
let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true);
|
||||
session.suspended.resumed(ticket, thread_id, all);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1563,10 +1461,7 @@ impl DebugManager {
|
||||
StepKind::Out => "stepOut",
|
||||
};
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// A step runs its thread; other threads stopped by their own events stay stopped.
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1664,12 +1559,10 @@ impl DebugManager {
|
||||
/// Step into a specific call of the current line (Smart Step Into).
|
||||
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1703,54 +1596,10 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub fn capabilities(&self, s: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
Ok(session_capabilities(&*self.session(s)?))
|
||||
}
|
||||
|
||||
/// Class table of the session's JVM (`GC.class_histogram -all`, or counted by the debugger
|
||||
/// while suspended where no histogram can be taken), with deltas against the session's
|
||||
/// previous snapshot from the same source.
|
||||
pub async fn jvm_memory_classes(&self, s: DebugSessionId) -> IdeResult<JvmMemoryResult> {
|
||||
let session = self.session(s)?;
|
||||
jvm_memory::session_classes(session).await
|
||||
}
|
||||
|
||||
/// Gives the session the backend for [`Self::jvm_memory_instances`] and the debugger's class
|
||||
/// counts; the UI is told to reload the session's capabilities.
|
||||
pub fn set_jvm_inspector(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
inspector: Arc<dyn ide_jvm_inspect::JvmInspector>,
|
||||
) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
if session.ended.is_cancelled() {
|
||||
return Err(no_session(s));
|
||||
}
|
||||
jvm_memory::set_inspector(&session, inspector);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Up to `limit` (clamped to `1..=MAX_INSTANCES`) instances of `class_name` while suspended.
|
||||
pub async fn jvm_memory_instances(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
class_name: String,
|
||||
limit: u32,
|
||||
) -> IdeResult<JvmInstancesResult> {
|
||||
jvm_memory::instances(&*self.session(s)?, &class_name, limit).await
|
||||
}
|
||||
|
||||
pub async fn jvm_memory_fields(&self, s: DebugSessionId, object: JvmObjectRef) -> IdeResult<JvmFieldsResult> {
|
||||
jvm_memory::fields(&*self.session(s)?, object).await
|
||||
}
|
||||
|
||||
/// Objects that point at `object`; `limit` 0 means `DEFAULT_REFERRERS`, at most `MAX_REFERRERS`.
|
||||
pub async fn jvm_memory_referrers(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
object: JvmObjectRef,
|
||||
limit: u32,
|
||||
) -> IdeResult<JvmReferrersResult> {
|
||||
jvm_memory::referrers(&*self.session(s)?, object, limit).await
|
||||
let mut caps = capabilities_from(&session.caps.lock());
|
||||
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// HotSwap: reloads the classes compiled since the session started (or since the
|
||||
|
||||
+62
-526
@@ -5,8 +5,6 @@ use crate::adapter::AdapterSpec;
|
||||
use crate::connection::{Connection, DapError, Incoming};
|
||||
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
|
||||
use ide_core::event::DebugEvent;
|
||||
#[cfg(unix)]
|
||||
use ide_core::process::process_tree;
|
||||
use ide_core::{DebugSessionId, IdeEvent, TaskId};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
@@ -47,13 +45,6 @@ pub(crate) struct Session {
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// The job object over the adapter and everything it started: the program an adapter
|
||||
/// like java-debug launches is its child, so a forced stop (and the session's
|
||||
/// cleanup) ends the whole tree even when the adapter exited first and the reported
|
||||
/// pid no longer names it. `None` when the assignment failed; the kill then falls
|
||||
/// back to the captured process tree.
|
||||
#[cfg(windows)]
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
@@ -64,21 +55,10 @@ pub(crate) struct Session {
|
||||
pub stop_force: AtomicBool,
|
||||
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
|
||||
pub debuggee_pid: AtomicI64,
|
||||
/// The JVM an app-launched (Kotlin) session started, or the local JVM an attach session
|
||||
/// found by its JDWP port. Separate from `debuggee_pid`, which decides how Stop signals the
|
||||
/// program and is 0 for Kotlin.
|
||||
pub jvm_pid: AtomicI64,
|
||||
/// JDWP port of an attach to a local JVM, to look the JVM up again; unset for a remote one.
|
||||
pub jdwp_port: std::sync::OnceLock<u16>,
|
||||
/// The attach goes through the listen-mode relay of [`crate::jdwp::listen_for_jvm`].
|
||||
pub listen_relay: AtomicBool,
|
||||
pub suspended: Suspension,
|
||||
/// Pids signalled for the current stop: the reported pid and everything it started (an
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -86,105 +66,8 @@ pub(crate) struct Session {
|
||||
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
|
||||
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
|
||||
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
|
||||
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
|
||||
/// One inspector request per session at a time.
|
||||
pub inspect_lock: tokio::sync::Mutex<()>,
|
||||
/// Cancelled when the session ends, so a request still waiting on the inspector lets go of
|
||||
/// it (and of the JDWP link it holds).
|
||||
pub ended: ide_core::CancellationToken,
|
||||
/// `hostName` (empty when absent) and `port` of the session's `attach` request, as the IDE
|
||||
/// was given them: Kotlin then replaces them with the link's. Unlike `jdwp_port`, also set
|
||||
/// for a remote JVM.
|
||||
pub attach_target: std::sync::OnceLock<(String, u16)>,
|
||||
/// Set once `configure` has recorded where the program's JVM is (the fields above, the
|
||||
/// Kotlin link), just before the `launch`/`attach` request goes out.
|
||||
pub jvm_located: watch::Sender<bool>,
|
||||
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
|
||||
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
|
||||
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
|
||||
}
|
||||
|
||||
/// Which threads of the program are suspended: each from its `stopped` event until a
|
||||
/// `continued` event or a successful resume/step request for it. Never cleared elsewhere, since
|
||||
/// a stale "suspended" only makes a later check refuse. fwcd and java-debug stop a breakpoint's
|
||||
/// thread alone, so a thread can run while another stays stopped.
|
||||
///
|
||||
/// A resume reply ends only the stops taken before the request was sent: a thread's next
|
||||
/// `stopped` event can overtake the reply. The check and the removal happen under one lock, so
|
||||
/// no stop can land between them and be lost.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Suspension(Mutex<Stops>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Stops {
|
||||
/// Counts stops, and moves back to an earlier stopped thread: an object read under one
|
||||
/// ticket is stale under another.
|
||||
ticket: u64,
|
||||
/// Threads stopped alone by their event, oldest first, with the ticket of that stop.
|
||||
threads: Vec<(i64, u64)>,
|
||||
/// The ticket of a stop of every thread (`allThreadsStopped`, or no thread id): any resume
|
||||
/// ends it.
|
||||
all: Option<u64>,
|
||||
}
|
||||
|
||||
impl Stops {
|
||||
fn latest(&self) -> Option<u64> {
|
||||
self.threads.last().map(|&(_, ticket)| ticket).max(self.all)
|
||||
}
|
||||
|
||||
/// Ends the stops taken by ticket `upto` of `thread`, or of every thread.
|
||||
fn release(&mut self, thread: Option<i64>, every: bool, upto: u64) {
|
||||
let latest = self.latest();
|
||||
if every || thread.is_none() || self.all.is_some_and(|t| t <= upto) {
|
||||
self.all = self.all.filter(|&t| t > upto);
|
||||
self.threads.retain(|&(_, t)| t > upto);
|
||||
} else {
|
||||
self.threads.retain(|&(id, t)| Some(id) != thread || t > upto);
|
||||
}
|
||||
// Back on an earlier thread's stop, as the JDWP link moves to it with a new generation.
|
||||
let now = self.latest();
|
||||
if now.is_some() && now != latest {
|
||||
self.ticket += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Suspension {
|
||||
/// Whether some thread is stopped.
|
||||
pub fn get(&self) -> bool {
|
||||
let stops = self.0.lock();
|
||||
stops.all.is_some() || !stops.threads.is_empty()
|
||||
}
|
||||
|
||||
/// A `stopped` event; `all`: its `allThreadsStopped`.
|
||||
pub fn stopped(&self, thread: Option<i64>, all: bool) {
|
||||
let mut stops = self.0.lock();
|
||||
stops.ticket += 1;
|
||||
let ticket = stops.ticket;
|
||||
match thread.filter(|_| !all) {
|
||||
Some(thread) => {
|
||||
stops.threads.retain(|&(id, _)| id != thread);
|
||||
stops.threads.push((thread, ticket));
|
||||
}
|
||||
None => stops.all = Some(ticket),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `continued` event; `all`: its `allThreadsContinued`.
|
||||
pub fn continued(&self, thread: Option<i64>, all: bool) {
|
||||
self.0.lock().release(thread, all, u64::MAX);
|
||||
}
|
||||
|
||||
/// Take before sending a resume-like request and pass to [`Self::resumed`].
|
||||
pub fn ticket(&self) -> u64 {
|
||||
self.0.lock().ticket
|
||||
}
|
||||
|
||||
/// After a successful resume-like request for `thread`; `all`: every thread ran.
|
||||
pub fn resumed(&self, ticket: u64, thread: i64, all: bool) {
|
||||
self.0.lock().release(Some(thread), all, ticket);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapters that only match files by their resolved path: Godot maps a script to
|
||||
@@ -206,10 +89,6 @@ impl Session {
|
||||
port: Option<u16>,
|
||||
child: Option<Child>,
|
||||
) -> Self {
|
||||
// Before `child` moves into the session: the adapter is the root of the job, so
|
||||
// everything it starts (java-debug's JVM) is in there by inheritance.
|
||||
#[cfg(windows)]
|
||||
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
@@ -224,8 +103,6 @@ impl Session {
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
@@ -233,25 +110,13 @@ impl Session {
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
jvm_pid: AtomicI64::new(0),
|
||||
jdwp_port: std::sync::OnceLock::new(),
|
||||
listen_relay: AtomicBool::new(false),
|
||||
suspended: Suspension::default(),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
attach_target: std::sync::OnceLock::new(),
|
||||
jvm_located: watch::channel(false).0,
|
||||
kotlin_relay: Mutex::new(None),
|
||||
kotlin_mirror: Mutex::new(None),
|
||||
jvm_memory: tokio::sync::Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,28 +277,7 @@ impl Session {
|
||||
|
||||
/// Kills the adapter and every debuggee we started.
|
||||
pub fn kill_processes(&self) {
|
||||
self.ended.cancel();
|
||||
// The inspector holds a clone of the link, so both go.
|
||||
self.jvm_inspector.lock().take();
|
||||
self.jdwp_link.lock().take();
|
||||
#[cfg(unix)]
|
||||
if self.request == "launch" {
|
||||
for group in launch_groups(self) {
|
||||
ide_core::process::kill_tree_of(group as u32);
|
||||
}
|
||||
}
|
||||
// The job first: it ends the adapter and everything it started (the program
|
||||
// java-debug launched is its child), members included that the stop's captured
|
||||
// tree never saw. An empty job is unaffected, and whatever survives this session
|
||||
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = self.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(tree) = self.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
self.kotlin_relay.lock().take();
|
||||
if let Some(mut c) = self.adapter_process.lock().take() {
|
||||
if self.adapter == "rdbg"
|
||||
&& let Some(pid) = c.id()
|
||||
@@ -447,9 +291,6 @@ impl Session {
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = program.child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,54 +298,47 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
fn process_tree(pid: i32) -> Vec<i32> {
|
||||
use std::collections::HashMap;
|
||||
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
|
||||
return vec![pid];
|
||||
};
|
||||
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
|
||||
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
|
||||
children.entry(parent).or_default().push(p);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
let mut tree = vec![pid];
|
||||
let mut at = 0;
|
||||
while at < tree.len() {
|
||||
if let Some(kids) = children.get(&tree[at]) {
|
||||
tree.extend(kids.iter().copied());
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
|
||||
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
|
||||
/// tree, because the reported pid may die before its program does.
|
||||
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
if s.request != "launch" {
|
||||
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
|
||||
if needs_tree {
|
||||
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
*s.stop_pids.lock() = tree;
|
||||
let mut known = s.stop_pids.lock();
|
||||
if known.is_empty() {
|
||||
*known = process_tree(pid as i32);
|
||||
}
|
||||
let known = s.stop_pids.lock();
|
||||
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
|
||||
if force {
|
||||
// A forced stop kills the launch: every root the session spawned leads its own
|
||||
// process group (`own_group` at spawn), so one signal reaches the program and
|
||||
// everything it started, even processes born after the snapshot below was
|
||||
// taken — the adapter's program is in the adapter's group. The session's own
|
||||
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
|
||||
// The roots are unreaped until the session drops them, so their ids (and the
|
||||
// groups named by them) cannot have been reused yet.
|
||||
for group in launch_groups(s) {
|
||||
// SAFETY: plain syscalls on the processes this session started.
|
||||
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
|
||||
unsafe { libc::kill(group, libc::SIGKILL) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Children first: the shell that started the program would otherwise reparent it.
|
||||
for p in known.iter().rev() {
|
||||
// SAFETY: plain syscall on the processes this session started.
|
||||
@@ -515,53 +349,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Console programs have no external graceful signal; the adapter gets its chance
|
||||
// first. The job ends the program and everything it started, members included
|
||||
// that the captured tree never saw.
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
// A separately spawned program may have rejected assignment to the adapter's job.
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
cmd.arg("/F");
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
}
|
||||
|
||||
/// The process groups the session spawned roots for: the adapter's (the program it
|
||||
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
|
||||
#[cfg(unix)]
|
||||
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
|
||||
let mut groups = Vec::new();
|
||||
let mut push = |id: Option<u32>| {
|
||||
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
|
||||
groups.push(pid);
|
||||
}
|
||||
};
|
||||
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
|
||||
for child in s.debuggees.lock().iter() {
|
||||
push(child.id());
|
||||
}
|
||||
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
|
||||
groups.retain(|g| *g > 0);
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether the launch is still running: the stop's captured tree when there is one,
|
||||
/// otherwise the session's job (the adapter and everything it started).
|
||||
#[cfg(windows)]
|
||||
fn launch_running(s: &Arc<Session>) -> bool {
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.is_running()
|
||||
} else {
|
||||
s.job.lock().as_ref().is_some_and(|job| job.is_running())
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
|
||||
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
if !terminate || s.request != "launch" {
|
||||
@@ -575,39 +373,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::time::Instant;
|
||||
let mut targets = s.stop_pids.lock().clone();
|
||||
if targets.is_empty() {
|
||||
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
}
|
||||
let targets = {
|
||||
let known = s.stop_pids.lock();
|
||||
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
|
||||
};
|
||||
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
|
||||
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while alive() {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) {
|
||||
signal_debuggee(s, true).await;
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// The program ignored the terminate: do not leave it running.
|
||||
signal_debuggee(s, true).await;
|
||||
signal_debuggee(s, true);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
// The captured tree is the oracle when it was captured; otherwise the job (every
|
||||
// process the launch started) is, so a failed capture can no longer end the wait
|
||||
// while the program still holds its port.
|
||||
while launch_running(s) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -695,27 +479,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
s.emit(DebugEvent::Initialized);
|
||||
}
|
||||
"stopped" => {
|
||||
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.suspended.stopped(int("threadId"), all);
|
||||
let reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
mgr.restore_breakpoints(s).await;
|
||||
s.emit(DebugEvent::Stopped {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
reason,
|
||||
reason: str_("reason").unwrap_or_else(|| "pause".into()),
|
||||
description: str_("description").or_else(|| str_("text")),
|
||||
all_threads_stopped: all,
|
||||
});
|
||||
}
|
||||
"continued" => {
|
||||
let all = body.get("allThreadsContinued").and_then(Value::as_bool);
|
||||
// DAP: an event without the flag continues its thread alone. The UI keeps treating
|
||||
// it as all threads; the suspension, which holds only threads that stopped, need not.
|
||||
s.suspended.continued(int("threadId"), all.unwrap_or(false));
|
||||
s.emit(DebugEvent::Continued {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
all_threads_continued: all.unwrap_or(true),
|
||||
all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false),
|
||||
});
|
||||
}
|
||||
"continued" => s.emit(DebugEvent::Continued {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
all_threads_continued: body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true),
|
||||
}),
|
||||
"output" => {
|
||||
let category = str_("category").unwrap_or_else(|| "console".into());
|
||||
if category == "telemetry" {
|
||||
@@ -855,15 +630,9 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
}
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -890,38 +659,19 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
}
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
// The program joins the session's job, so Stop reaches what it starts (its children),
|
||||
// though only the program itself is the session's child.
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.process_id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
})
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -930,11 +680,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -946,14 +692,6 @@ fn forward_terminal_output(
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
@@ -1021,22 +759,10 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
|
||||
});
|
||||
}
|
||||
|
||||
/// The attach listener must be on the command line before the user's arguments, which may
|
||||
/// override it; the memory view needs it because the JVM is started by the IDE, not attached to.
|
||||
/// Not on Windows (`windows`), which the memory view does not support.
|
||||
fn kotlin_jvm_args(user: Vec<String>, windows: bool) -> Vec<String> {
|
||||
if windows || !ide_jvm_attach::wants_listener_flag(&user.join(" ")) {
|
||||
return user;
|
||||
}
|
||||
let mut args = Vec::with_capacity(user.len() + 1);
|
||||
args.push(ide_jvm_attach::START_LISTENER_FLAG.to_owned());
|
||||
args.extend(user);
|
||||
args
|
||||
}
|
||||
|
||||
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
|
||||
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let port = crate::adapter::free_port()?;
|
||||
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
args.get(key)
|
||||
@@ -1048,10 +774,8 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let mut cmd = tokio::process::Command::new(java);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(kotlin_jvm_args(strings("vmArguments"), cfg!(windows)))
|
||||
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
|
||||
// port here races with other processes that may take it before the JVM binds.
|
||||
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
|
||||
cmd.args(strings("vmArguments"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
.arg("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
@@ -1078,34 +802,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
|
||||
// forced stop), the session's job on Windows, so a stop reaches what it forks.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
let mut child = cmd.spawn()?;
|
||||
if let Some(pid) = child.id() {
|
||||
s.jvm_pid.store(i64::from(pid), Ordering::Release);
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
|
||||
// the free-port race window entirely.
|
||||
let port = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line).await? == 0 {
|
||||
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
|
||||
}
|
||||
if line.contains("Listening for transport dt_socket") {
|
||||
return parse_jdwp_port(&line).ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
@@ -1119,177 +829,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
args["timeout"] = json!(10000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
|
||||
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
|
||||
fn parse_jdwp_port(line: &str) -> Option<u16> {
|
||||
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Suspension, kotlin_jvm_args, parse_jdwp_port};
|
||||
|
||||
#[test]
|
||||
fn a_resume_reply_clears_the_stop_it_was_sent_for() {
|
||||
let s = Suspension::default();
|
||||
assert!(!s.get());
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_after_the_resume_request_keeps_the_session_suspended() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
// The next breakpoint's `stopped` event overtakes the reply to `continue`.
|
||||
s.continued(Some(1), true);
|
||||
s.stopped(Some(1), true);
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(s.get());
|
||||
// A reply for the new stop still clears it.
|
||||
s.resumed(s.ticket(), 1, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_after_a_continued_event_changes_nothing() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(1), true);
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(!s.get());
|
||||
s.stopped(Some(1), true);
|
||||
assert!(s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_stops_are_never_lost() {
|
||||
let s = std::sync::Arc::new(Suspension::default());
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
let stopping = {
|
||||
let s = s.clone();
|
||||
std::thread::spawn(move || (0..10_000).for_each(|_| s.stopped(Some(1), true)))
|
||||
};
|
||||
for _ in 0..10_000 {
|
||||
s.resumed(ticket, 1, true);
|
||||
}
|
||||
stopping.join().unwrap();
|
||||
// However they interleave, a resume for the old ticket never clears a later stop.
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket + 10_000);
|
||||
}
|
||||
|
||||
/// fwcd and java-debug stop a breakpoint's thread alone (`SUSPEND_EVENT_THREAD`): 7, then 8.
|
||||
fn two_threads_stopped() -> Suspension {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(7), false);
|
||||
s.stopped(Some(8), false);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_the_latest_stopped_thread_keeps_the_other_as_a_new_stop() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(8), false);
|
||||
assert!(s.get());
|
||||
// Objects read on thread 8's stop are stale.
|
||||
assert_eq!(s.ticket(), ticket + 1);
|
||||
s.continued(Some(7), false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_an_earlier_stopped_thread_keeps_the_latest_stop() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(7), false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_all_threads_ends_every_stop() {
|
||||
let s = two_threads_stopped();
|
||||
s.continued(Some(7), true);
|
||||
assert!(!s.get());
|
||||
let s = two_threads_stopped();
|
||||
s.continued(None, false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_a_thread_that_did_not_stop_changes_nothing() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(5), false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_reply_resumes_its_thread_alone() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.resumed(ticket, 8, false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket + 1);
|
||||
s.resumed(s.ticket(), 7, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thread_stopping_again_before_its_resume_reply_stays_stopped() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
// Thread 7's step ends, and its `stopped` event overtakes the reply to `next`.
|
||||
s.stopped(Some(7), false);
|
||||
s.resumed(ticket, 7, false);
|
||||
s.continued(Some(8), false);
|
||||
assert!(s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_of_every_thread_ends_with_any_resume() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(7), false);
|
||||
s.stopped(Some(8), true);
|
||||
s.continued(Some(8), false);
|
||||
assert!(!s.get());
|
||||
// A stop without a thread.
|
||||
s.stopped(None, false);
|
||||
s.resumed(s.ticket(), 5, false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_launch_starts_attach_listener_first() {
|
||||
assert_eq!(
|
||||
kotlin_jvm_args(vec!["-Xmx1g".into()], false),
|
||||
vec!["-XX:+StartAttachListener".to_string(), "-Xmx1g".into()]
|
||||
);
|
||||
assert_eq!(
|
||||
kotlin_jvm_args(vec!["-XX:+DisableAttachMechanism".into()], false),
|
||||
vec!["-XX:+DisableAttachMechanism".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_launch_on_windows_gets_no_attach_listener() {
|
||||
assert_eq!(kotlin_jvm_args(vec!["-Xmx1g".into()], true), vec!["-Xmx1g".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_the_jvm_chose() {
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("unrelated output"), None);
|
||||
}
|
||||
}
|
||||
+35
-743
@@ -1,7 +1,5 @@
|
||||
//! Protocol flow against an in-memory fake adapter.
|
||||
|
||||
mod jvm_inspect;
|
||||
|
||||
use super::*;
|
||||
use crate::adapter::{AdapterIo, LaunchFuture};
|
||||
use ide_core::IdeEvent;
|
||||
@@ -16,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
@@ -122,24 +33,12 @@ struct Script {
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
/// Send a `continued` event right after the first `stopped` one.
|
||||
continued_after_stop: bool,
|
||||
/// Close the connection when a `pause` request arrives (the adapter dies).
|
||||
hang_up_on_pause: bool,
|
||||
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
|
||||
continue_reply: Option<Value>,
|
||||
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
|
||||
delay_initialize: Option<Duration>,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
close_during_startup: bool,
|
||||
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
|
||||
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
|
||||
gdb15_starts_at_launch: bool,
|
||||
launch_after_configuration: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -154,17 +53,11 @@ impl Default for Script {
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
continued_after_stop: false,
|
||||
hang_up_on_pause: false,
|
||||
continue_reply: None,
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,12 +72,10 @@ impl AdapterLauncher for FakeLauncher {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
let log = self.log.clone();
|
||||
let script = self.script.clone();
|
||||
let launch_after_configuration = script.launch_after_configuration;
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -202,7 +93,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut running = false;
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
let mut v: Value = $v;
|
||||
@@ -223,40 +113,24 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
continue;
|
||||
}
|
||||
match msg["command"].as_str().unwrap_or_default() {
|
||||
"initialize" => {
|
||||
if let Some(delay) = script.delay_initialize {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
);
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 announces itself before it sees `launch` (which a client that
|
||||
// defers it sends late).
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
}
|
||||
"initialize" => ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
),
|
||||
"launch" | "attach" => {
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 starts the program here and answers `launch` right away.
|
||||
running = true;
|
||||
ok!(msg, Value::Null);
|
||||
continue;
|
||||
}
|
||||
if script.close_during_startup {
|
||||
return;
|
||||
}
|
||||
@@ -328,14 +202,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
}
|
||||
"configurationDone" => {
|
||||
if script.gdb15_starts_at_launch && running {
|
||||
// GDB 15 answers `configurationDone` with `notStopped` while the program
|
||||
// it started at `launch` is still running.
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
@@ -358,9 +224,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
if script.continued_after_stop {
|
||||
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
|
||||
}
|
||||
send!(
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
|
||||
);
|
||||
@@ -392,13 +255,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
{"label": ""},
|
||||
]})
|
||||
),
|
||||
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
|
||||
"continue" => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
|
||||
send!(json!({"type": "event", "event": "terminated"}));
|
||||
}
|
||||
"pause" if script.hang_up_on_pause => break,
|
||||
"stepIn" | "stepOut" | "pause" => ok!(msg, Value::Null),
|
||||
"redefineClasses" => {
|
||||
send!(
|
||||
@@ -452,8 +313,6 @@ fn harness(script: Script) -> Harness {
|
||||
Arc::new(LanguageRegistry::from_configs(Vec::new())),
|
||||
Arc::new(FakeLauncher { log: log.clone(), script }),
|
||||
);
|
||||
// A local attach must not send requests to the developer's own JVMs.
|
||||
manager.set_jvm_finder(Arc::new(|_, _| None));
|
||||
Harness { manager, log, events, owner }
|
||||
}
|
||||
|
||||
@@ -564,60 +423,6 @@ async fn stop_cancels_pending_launch_and_attach() {
|
||||
}
|
||||
}
|
||||
|
||||
/// GDB 15 must receive breakpoints and configurationDone before launch.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_15_launch_waits_for_the_configuration() {
|
||||
for deferred in [false, true] {
|
||||
let h =
|
||||
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
|
||||
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
|
||||
if deferred {
|
||||
let id = result.expect("GDB 15 defers its launch past the configuration");
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_attach_keeps_the_normal_configuration_order() {
|
||||
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
|
||||
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_run_environment_overrides_preserve_inheritance() {
|
||||
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
|
||||
let h = harness(Script::default());
|
||||
let id = h
|
||||
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
|
||||
.await
|
||||
.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
{
|
||||
let log = h.log.lock();
|
||||
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
|
||||
if explicit.is_some() {
|
||||
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
|
||||
assert!(session.spec.env.is_empty());
|
||||
} else {
|
||||
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
|
||||
assert!(session.spec.env.contains(&("A".into(), "1".into())));
|
||||
}
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
for request in ["launch", "attach"] {
|
||||
@@ -625,6 +430,7 @@ async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
|
||||
let h = harness(Script { close_during_startup: true, ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
|
||||
@@ -1147,39 +953,6 @@ async fn connects_to_running_debug_server_over_tcp() {
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delve_adapter_inherits_the_selected_go_environment() {
|
||||
struct CaptureLauncher {
|
||||
inner: FakeLauncher,
|
||||
env: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
impl AdapterLauncher for CaptureLauncher {
|
||||
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
|
||||
*self.env.lock() = spec.env.clone();
|
||||
self.inner.launch(spec, reuse)
|
||||
}
|
||||
}
|
||||
let env = Arc::new(Mutex::new(Vec::new()));
|
||||
let launcher =
|
||||
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
|
||||
let (owner, _) = recording_owner("main");
|
||||
let config = LaunchConfig {
|
||||
name: "Go".into(),
|
||||
adapter: "delve".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/project".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
|
||||
extra_json: Some(json!({"mode":"debug"}).to_string()),
|
||||
};
|
||||
let id = manager.start(config, owner).await.unwrap();
|
||||
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
|
||||
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
|
||||
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
|
||||
#[tokio::test]
|
||||
@@ -1376,36 +1149,6 @@ async fn hot_swap_only_for_java_debug() {
|
||||
assert_eq!(failed.error_message.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
/// A loopback port nothing listens on until `listen` is called on the socket, and that no parallel
|
||||
/// test can bind meanwhile. A port picked by binding to 0 and closing the listener can be taken
|
||||
/// by another test before the code under test connects to it.
|
||||
fn reserved_port() -> (tokio::net::TcpSocket, u16) {
|
||||
// Without SO_REUSEADDR, which every `TcpListener::bind` sets: no other socket may bind it.
|
||||
let socket = tokio::net::TcpSocket::new_v4().unwrap();
|
||||
socket.bind(([127, 0, 0, 1], 0).into()).unwrap();
|
||||
let port = socket.local_addr().unwrap().port();
|
||||
(socket, port)
|
||||
}
|
||||
|
||||
/// An editor that starts listening on the reserved port after `delay` and serves one session.
|
||||
#[cfg(unix)]
|
||||
fn fake_editor(socket: tokio::net::TcpSocket, delay: Duration, log: Log) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let listener = socket.listen(1).unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
// Closed once its session is accepted, so a later start finds no editor open.
|
||||
drop(listener);
|
||||
fake_adapter(stream, log, Script::default()).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reserved_port_cannot_be_taken_by_a_parallel_test() {
|
||||
let (_socket, port) = reserved_port();
|
||||
assert!(tokio::net::TcpListener::bind(("127.0.0.1", port)).await.is_err(), "port {port} is still free to bind");
|
||||
}
|
||||
|
||||
fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegistry> {
|
||||
let lang = ide_language::LanguageConfig {
|
||||
id: ide_core::LanguageId::new("gdscript"),
|
||||
@@ -1473,7 +1216,7 @@ async fn godot_connects_to_the_editor_adapter() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn godot_without_editor_or_executable_says_what_to_do() {
|
||||
let (_no_editor, port) = reserved_port();
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
|
||||
let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err();
|
||||
@@ -1486,9 +1229,15 @@ async fn godot_without_editor_or_executable_says_what_to_do() {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn godot_starts_a_headless_editor_when_none_is_open() {
|
||||
let (editor, port) = reserved_port();
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let log: Log = Arc::default();
|
||||
fake_editor(editor, Duration::from_millis(300), log.clone());
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
// `sleep` stands in for `godot --headless --editor --path ${root} …`.
|
||||
let manager = DebugManager::new(godot_registry(port, "sleep", &["30"]));
|
||||
@@ -1691,7 +1440,7 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let (editor, port) = reserved_port();
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
|
||||
let debugger = gdscript.debugger.as_mut().unwrap();
|
||||
debugger.args.clear();
|
||||
@@ -1708,7 +1457,13 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
let _language = store.add("acme.godot".into(), gdscript);
|
||||
|
||||
let log: Log = Arc::default();
|
||||
fake_editor(editor, Duration::from_millis(300), log.clone());
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(registry.clone());
|
||||
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
|
||||
@@ -2060,416 +1815,6 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn is_suspended(h: &Harness, id: DebugSessionId) -> bool {
|
||||
h.manager.session(id).unwrap().suspended.get()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopped_event_marks_session_suspended() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(is_suspended(&h, id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_clears_suspended_without_continued_event() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
// The fake adapter ends the session after `continue`, so keep the session alive here.
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.suspended.get());
|
||||
h.manager.resume(id, 1).await.unwrap();
|
||||
assert!(!session.suspended.get());
|
||||
}
|
||||
|
||||
/// After the fake adapter's stop of every thread ends, threads 7 then 8 stop alone, as a
|
||||
/// breakpoint does on fwcd and java-debug (`SUSPEND_EVENT_THREAD`).
|
||||
async fn threads_stopped(script: Script) -> (Harness, Arc<Session>, DebugSessionId) {
|
||||
let h = harness(script);
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
session.suspended.continued(Some(1), true);
|
||||
session.suspended.stopped(Some(7), false);
|
||||
session.suspended.stopped(Some(8), false);
|
||||
(h, session, id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_of_one_thread_keeps_the_others_suspended() {
|
||||
let only_it = Script { continue_reply: Some(json!({"allThreadsContinued": false})), ..Script::default() };
|
||||
let (h, session, id) = threads_stopped(only_it).await;
|
||||
h.manager.resume(id, 7).await.unwrap();
|
||||
assert!(session.suspended.get());
|
||||
h.manager.resume(id, 8).await.unwrap();
|
||||
assert!(!session.suspended.get());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_reply_without_the_flag_continues_every_thread() {
|
||||
let (h, session, id) = threads_stopped(Script { continue_reply: Some(json!({})), ..Script::default() }).await;
|
||||
h.manager.resume(id, 7).await.unwrap();
|
||||
assert!(!session.suspended.get());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn step_of_one_thread_keeps_the_others_suspended() {
|
||||
let (h, session, id) = threads_stopped(Script::default()).await;
|
||||
let ticket = session.suspended.ticket();
|
||||
h.manager.step(id, 8, StepKind::Into).await.unwrap();
|
||||
assert!(session.suspended.get());
|
||||
// Thread 8 was the latest stop: what was read on it is stale.
|
||||
assert_eq!(session.suspended.ticket(), ticket + 1);
|
||||
h.manager.step_in_target(id, 7, 1).await.unwrap();
|
||||
assert!(!session.suspended.get());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continued_event_clears_suspended() {
|
||||
let h = harness(Script { continued_after_stop: true, ..Script::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Continued { .. })).await;
|
||||
assert!(!is_suspended(&h, id));
|
||||
}
|
||||
|
||||
fn java_config(request: &str) -> LaunchConfig {
|
||||
LaunchConfig { adapter: "java-debug".into(), request: request.into(), extra_json: None, ..config() }
|
||||
}
|
||||
|
||||
fn remote_attach(adapter: &str, project: &std::path::Path) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: adapter.into(),
|
||||
cwd: Some(project.to_owned().into()),
|
||||
extra_json: Some(json!({"projectRoot": project, "hostName": "10.0.0.5", "port": 5005}).to_string()),
|
||||
..java_config("attach")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_session_reports_capability() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for adapter in ["java-debug", kotlin::ADAPTER] {
|
||||
let id = h.start(remote_attach(adapter, project.path())).await.unwrap();
|
||||
let caps = h.manager.capabilities(id).unwrap();
|
||||
assert!(caps.jvm_memory, "{adapter}");
|
||||
}
|
||||
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let caps = h.manager.capabilities(id).unwrap();
|
||||
assert!(!caps.jvm_memory);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_classes(id).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::NoJvmProcess, can_count_when_paused: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn ended_attach_session_without_pid_reports_program_exited() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(remote_attach("java-debug", project.path())).await.unwrap();
|
||||
h.manager.session(id).unwrap().terminated.store(true, Ordering::Release);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_classes(id).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::ProgramExited, can_count_when_paused: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn listen_mode_attach_is_not_scanned() {
|
||||
let h = harness(Script::default());
|
||||
let scans = Arc::new(Mutex::new(Vec::new()));
|
||||
let counted = scans.clone();
|
||||
h.manager.set_jvm_finder(Arc::new(move |port, _| {
|
||||
counted.lock().push(port);
|
||||
None
|
||||
}));
|
||||
let (_jvm, relay) = jdwp::tests::connected_jvm().await;
|
||||
let mut cfg = java_config("attach");
|
||||
cfg.extra_json = Some(json!({"hostName": "127.0.0.1", "port": relay}).to_string());
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_classes(id).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::ListenModeNotSupported,
|
||||
can_count_when_paused: false
|
||||
}
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(scans.lock().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn remote_host_is_not_scanned() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(remote_attach("java-debug", project.path())).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert_eq!(session.jdwp_port.get(), None);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_classes(id).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable {
|
||||
reason: JvmMemoryUnavailable::AttachedJvmNotFound,
|
||||
can_count_when_paused: false
|
||||
}
|
||||
);
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 0);
|
||||
}
|
||||
|
||||
/// The Memory view asks as soon as the session appears, while its JVM is still being looked up:
|
||||
/// answering then would claim there is no JVM to ask.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn memory_asked_while_an_attach_connects_waits_for_its_jvm() {
|
||||
let h = harness(Script { delay_initialize: Some(Duration::from_millis(300)), ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let (manager, owner) = (h.manager.clone(), h.owner.clone());
|
||||
let config = remote_attach("java-debug", project.path());
|
||||
let start = tokio::spawn(async move { manager.start(config, owner).await });
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Started { .. })).await;
|
||||
let id = DebugSessionId(*event_sessions(&h.events).first().unwrap());
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.attach_target.get().is_none(), "asked too late to cover the connecting session");
|
||||
|
||||
h.manager.jvm_memory_classes(id).await.unwrap();
|
||||
assert!(session.attach_target.get().is_some(), "answered before the attach was looked at");
|
||||
start.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// A JVM clears its JDWP listener address once a debugger connects, so only a scan made before
|
||||
/// the `attach` request can tell it by its port.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn local_attach_finds_its_jvm_before_the_debugger_connects() {
|
||||
let h = harness(Script::default());
|
||||
let log = h.log.clone();
|
||||
h.manager.set_jvm_finder(Arc::new(move |port, _| {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let attached = log.lock().iter().any(|m| m["command"] == "attach");
|
||||
(port == 5005 && !attached).then_some(4242)
|
||||
}));
|
||||
let mut cfg = java_config("attach");
|
||||
cfg.extra_json = Some(json!({"hostName": "127.0.0.1", "port": 5005}).to_string());
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn local_attach_remembers_its_jdwp_port() {
|
||||
let h = harness(Script::default());
|
||||
let ports = Arc::new(Mutex::new(Vec::new()));
|
||||
let asked = ports.clone();
|
||||
h.manager.set_jvm_finder(Arc::new(move |port, _| {
|
||||
asked.lock().push(port);
|
||||
Some(4242)
|
||||
}));
|
||||
let mut cfg = java_config("attach");
|
||||
cfg.extra_json = Some(json!({"hostName": "localhost", "port": "5005"}).to_string());
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert_eq!(session.jdwp_port.get(), Some(&5005));
|
||||
assert_eq!(h.request("attach")["arguments"]["port"], "5005");
|
||||
for _ in 0..200 {
|
||||
if session.jvm_pid.load(Ordering::Acquire) != 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242);
|
||||
assert_eq!(ports.lock().first(), Some(&5005));
|
||||
}
|
||||
|
||||
/// A fake `find_jvm_by_jdwp_port` for the JVM 4242, recording whether each scan ran while
|
||||
/// suspended. A JVM not `safe_while_suspended` (JDK below 17) is skipped by a suspended scan.
|
||||
#[cfg(unix)]
|
||||
fn counting_find(
|
||||
safe_while_suspended: bool,
|
||||
) -> (Arc<Mutex<Vec<bool>>>, impl Fn(bool) -> Option<u32> + Clone + Send + 'static) {
|
||||
let scans = Arc::new(Mutex::new(Vec::new()));
|
||||
let counted = scans.clone();
|
||||
(scans, move |suspended| {
|
||||
counted.lock().push(suspended);
|
||||
(!suspended || safe_while_suspended).then_some(4242)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn suspended_remote_attach(h: &Harness) -> Arc<Session> {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(remote_attach("java-debug", project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.suspended.get());
|
||||
session
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_scan_skips_an_old_jdk_while_suspended() {
|
||||
let h = harness(Script::default());
|
||||
let session = suspended_remote_attach(&h).await;
|
||||
let (scans, find) = counting_find(false);
|
||||
let task =
|
||||
jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5));
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!scans.lock().is_empty() && scans.lock().iter().all(|&suspended| suspended));
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 0);
|
||||
|
||||
session.suspended.continued(None, true);
|
||||
tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap();
|
||||
assert_eq!(scans.lock().last(), Some(&false));
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_scan_finds_a_jdk_17_or_later_while_suspended() {
|
||||
let h = harness(Script::default());
|
||||
let session = suspended_remote_attach(&h).await;
|
||||
let (scans, find) = counting_find(true);
|
||||
let task =
|
||||
jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5));
|
||||
tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap();
|
||||
assert_eq!(*scans.lock(), [true]);
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_scan_keeps_a_known_pid_and_stops_with_the_session() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(remote_attach("java-debug", project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
session.suspended.continued(None, true);
|
||||
session.jvm_pid.store(7, Ordering::Release);
|
||||
let watch = |find| {
|
||||
jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5))
|
||||
};
|
||||
let (scans, find) = counting_find(true);
|
||||
tokio::time::timeout(Duration::from_secs(2), watch(find)).await.unwrap().unwrap();
|
||||
assert!(scans.lock().is_empty(), "a known pid needs no scan");
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 7);
|
||||
|
||||
// A refresh stores the pid while this scan is still running.
|
||||
session.jvm_pid.store(0, Ordering::Release);
|
||||
let racing = session.clone();
|
||||
let find = move |_| {
|
||||
racing.jvm_pid.store(7, Ordering::Release);
|
||||
Some(4242)
|
||||
};
|
||||
let task =
|
||||
jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5));
|
||||
tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap();
|
||||
assert_eq!(session.jvm_pid.load(Ordering::Acquire), 7, "a pid stored first is kept");
|
||||
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let (scans, find) = counting_find(true);
|
||||
tokio::time::timeout(Duration::from_secs(2), watch(find)).await.unwrap().unwrap();
|
||||
assert!(scans.lock().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unknown_pid_reports_no_jvm_process() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(java_config("launch")).await.unwrap();
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_classes(id).await.unwrap(),
|
||||
JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::NoJvmProcess, can_count_when_paused: false }
|
||||
);
|
||||
}
|
||||
|
||||
fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: kotlin::ADAPTER.into(),
|
||||
cwd: Some(project.to_owned().into()),
|
||||
extra_json: Some(json!({"projectRoot": project, "hostName": host, "port": port}).to_string()),
|
||||
..java_config("attach")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_session_installs_link_inspector() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005)).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.jdwp_link.lock().is_some());
|
||||
assert!(session.jvm_inspector.lock().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_target_recorded_before_kotlin_rewrite() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_attach(project.path(), "10.0.0.5", 5005)).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert_eq!(session.attach_target.get(), Some(&("10.0.0.5".to_owned(), 5005)));
|
||||
let sent = &h.request("attach")["arguments"];
|
||||
assert_eq!(sent["hostName"], "127.0.0.1");
|
||||
let link_port = sent["port"].as_u64().unwrap();
|
||||
assert!(link_port != 0 && link_port != 5005);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_attach_records_target() {
|
||||
let h = harness(Script::default());
|
||||
let mut cfg = java_config("attach");
|
||||
cfg.extra_json = Some(json!({"hostName": "localhost", "port": 5005}).to_string());
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert_eq!(session.attach_target.get(), Some(&("localhost".to_owned(), 5005)));
|
||||
assert!(session.jdwp_link.lock().is_none());
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_target_recorded_for_any_adapter() {
|
||||
let h = harness(Script::default());
|
||||
let mut cfg = java_config("attach");
|
||||
// "x" is not a registered adapter; debugpy has no JVM link or memory support.
|
||||
cfg.adapter = "debugpy".into();
|
||||
cfg.extra_json = Some(json!({"hostName": "h", "port": 7}).to_string());
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.manager.session(id).unwrap().attach_target.get(), Some(&("h".to_owned(), 7)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_sessions_have_no_attach_target() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(java_config("launch")).await.unwrap();
|
||||
assert_eq!(h.manager.session(id).unwrap().attach_target.get(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_the_session_drops_the_link() {
|
||||
let h = harness(Script { hang_up_on_pause: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005)).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
|
||||
let _ = session.conn.request_timeout("pause", json!({"threadId": 1}), Duration::from_secs(2)).await;
|
||||
for _ in 0..200 {
|
||||
if h.manager.session(id).is_err() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(session.jdwp_link.lock().is_none());
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
@@ -2496,7 +1841,6 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
});
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(reader),
|
||||
writer: Box::new(writer),
|
||||
child: Some(child),
|
||||
@@ -2531,55 +1875,3 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
let mut command = tokio::process::Command::new("python3");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
command
|
||||
.args([
|
||||
"-u",
|
||||
"-c",
|
||||
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
|
||||
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
|
||||
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = command.spawn().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
|
||||
let pid = child.id().unwrap();
|
||||
*session.adapter_process.lock() = Some(child);
|
||||
session.kill_processes();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
|
||||
if Instant::now() >= deadline {
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
panic!("adapter cleanup left its descendant holding the port");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_detaches_from_an_attached_program() {
|
||||
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
|
||||
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
|
||||
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let alive = child.try_wait().unwrap().is_none();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert!(alive, "Stop killed the attached program");
|
||||
assert!(!h.commands().contains(&"terminate".to_owned()));
|
||||
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
//! Instances, fields and referrers through a session's JVM inspector.
|
||||
|
||||
use super::*;
|
||||
use crate::jvm_memory::test_support::FakeInspector;
|
||||
use ide_core::event::JvmBackendState;
|
||||
use ide_jvm_inspect::{InspectError, JvmValue, VmTarget};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
fn debug_events(h: &Harness) -> Vec<DebugEvent> {
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event, .. } => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn backend_events(h: &Harness) -> Vec<JvmBackendState> {
|
||||
debug_events(h)
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
DebugEvent::JvmBackend { state } => Some(state),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A java-debug session stopped at its breakpoint, inspected by `fake`.
|
||||
async fn stopped_with(h: &Harness, config: LaunchConfig, fake: &Arc<FakeInspector>) -> DebugSessionId {
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
h.manager.set_jvm_inspector(id, fake.clone()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn object(id: u64) -> JvmValue {
|
||||
JvmValue::Object { id, type_name: "A".into() }
|
||||
}
|
||||
|
||||
const NOT_SUSPENDED: JvmInstancesResult =
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NotSuspended };
|
||||
|
||||
#[tokio::test]
|
||||
async fn instances_need_a_suspended_session() {
|
||||
let h = harness(Script { continued_after_stop: true, ..Script::default() });
|
||||
let id = h.start(java_config("launch")).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Continued { .. })).await;
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
h.manager.set_jvm_inspector(id, fake.clone()).unwrap();
|
||||
assert_eq!(h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), NOT_SUSPENDED);
|
||||
assert_eq!(fake.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instances_without_an_inspector_have_no_backend() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(java_config("launch")).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_from_previous_stop_is_stale() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
fake.instances.lock().push(object(7));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let JvmInstancesResult::Ready { generation, instances, truncated } =
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap()
|
||||
else {
|
||||
panic!("instances unavailable");
|
||||
};
|
||||
assert_eq!((generation, instances, truncated), (1, vec![object(7)], false));
|
||||
|
||||
h.manager.session(id).unwrap().suspended.stopped(None, true);
|
||||
let stale = JvmObjectRef { generation, id: 7 };
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_fields(id, stale).await.unwrap(),
|
||||
JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale }
|
||||
);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_referrers(id, stale, 10).await.unwrap(),
|
||||
JvmReferrersResult::Unavailable { reason: JvmInspectUnavailable::Stale }
|
||||
);
|
||||
assert_eq!(fake.calls(), 1, "a stale object never reaches the inspector");
|
||||
let current = JvmObjectRef { generation: 2, id: 7 };
|
||||
assert_eq!(h.manager.jvm_memory_fields(id, current).await.unwrap(), JvmFieldsResult::Ready { fields: Vec::new() });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instances_stay_readable_while_another_thread_is_stopped() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
fake.instances.lock().push(object(7));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
// Threads 20, 21, then 22 stop alone (SUSPEND_EVENT_THREAD).
|
||||
session.suspended.continued(Some(1), true);
|
||||
for thread in [20, 21, 22] {
|
||||
session.suspended.stopped(Some(thread), false);
|
||||
}
|
||||
let instances = || h.manager.jvm_memory_instances(id, "A".into(), 10);
|
||||
let result = instances().await.unwrap();
|
||||
let JvmInstancesResult::Ready { generation, .. } = result else { panic!("{result:?}") };
|
||||
let read_on_22 = JvmObjectRef { generation, id: 7 };
|
||||
|
||||
// An earlier thread resumes: the stop on 22 goes on, and so does what was read on it.
|
||||
session.suspended.continued(Some(20), false);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(),
|
||||
JvmFieldsResult::Ready { fields: Vec::new() }
|
||||
);
|
||||
|
||||
// The latest one resumes: 21 is still stopped, but what was read on 22 is stale.
|
||||
session.suspended.continued(Some(22), false);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(),
|
||||
JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale }
|
||||
);
|
||||
let result = instances().await.unwrap();
|
||||
assert!(matches!(result, JvmInstancesResult::Ready { .. }), "{result:?}");
|
||||
}
|
||||
|
||||
/// Starts `instances` on a held fake inspector; returns once the inspector was entered.
|
||||
async fn held_request(
|
||||
h: &Harness,
|
||||
id: DebugSessionId,
|
||||
fake: &Arc<FakeInspector>,
|
||||
) -> tokio::task::JoinHandle<IdeResult<JvmInstancesResult>> {
|
||||
let manager = h.manager.clone();
|
||||
let request = tokio::spawn(async move { manager.jvm_memory_instances(id, "A".into(), 10).await });
|
||||
tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap();
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_during_request_is_stale() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
|
||||
fake.instances.lock().push(object(7));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let request = held_request(&h, id, &fake).await;
|
||||
h.manager.session(id).unwrap().suspended.stopped(None, true);
|
||||
fake.release.notify_one();
|
||||
assert_eq!(
|
||||
request.await.unwrap().unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_during_request_is_stale() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let request = held_request(&h, id, &fake).await;
|
||||
h.manager.session(id).unwrap().suspended.continued(None, true);
|
||||
fake.release.notify_one();
|
||||
assert_eq!(
|
||||
request.await.unwrap().unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_request_at_a_time_per_session() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let first = held_request(&h, id, &fake).await;
|
||||
let manager = h.manager.clone();
|
||||
let second = tokio::spawn(async move { manager.jvm_memory_instances(id, "B".into(), 10).await });
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(fake.calls(), 1, "the second request waits for the first");
|
||||
fake.release.notify_one();
|
||||
first.await.unwrap().unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap();
|
||||
fake.release.notify_one();
|
||||
second.await.unwrap().unwrap();
|
||||
assert_eq!(fake.calls(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_end_lets_go_of_a_waiting_inspector() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let request = held_request(&h, id, &fake).await;
|
||||
h.manager.session(id).unwrap().kill_processes();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(2), request).await.unwrap().unwrap().unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::BackendClosed }
|
||||
);
|
||||
assert_eq!(Arc::strong_count(&fake), 1, "no clone of the inspector outlives the session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_carries_pid_for_launch() {
|
||||
let h = harness(Script { debuggee_pid: Some(4242), java_process_id: true, ..Script::default() });
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap();
|
||||
assert_eq!(
|
||||
*fake.targets.lock(),
|
||||
[VmTarget { pid: Some(4242), attach_host: None, attach_port: None, generation: 1 }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_carries_attach_port_and_host() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await;
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap();
|
||||
assert_eq!(
|
||||
*fake.targets.lock(),
|
||||
[VmTarget { pid: None, attach_host: Some("10.0.0.5".into()), attach_port: Some(5005), generation: 1 }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_backend_turns_instances_off() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::failing(InspectError::Unsupported("x".into())));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let before = debug_events(&h).len();
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Unsupported { detail: "x".into() } }
|
||||
);
|
||||
let after: Vec<_> = debug_events(&h).split_off(before);
|
||||
assert!(
|
||||
matches!(
|
||||
after.as_slice(),
|
||||
[DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail } }, DebugEvent::Capabilities]
|
||||
if detail == "x"
|
||||
),
|
||||
"{after:?}"
|
||||
);
|
||||
assert!(!h.manager.capabilities(id).unwrap().jvm_instances);
|
||||
assert_eq!(
|
||||
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
|
||||
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend }
|
||||
);
|
||||
assert_eq!(fake.calls(), 1);
|
||||
}
|
||||
|
||||
/// `error` is reported but leaves the backend on.
|
||||
async fn keeps_the_backend(error: InspectError, reason: JvmInspectUnavailable) {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::failing(error));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let object = JvmObjectRef { generation: 1, id: 7 };
|
||||
assert_eq!(h.manager.jvm_memory_fields(id, object).await.unwrap(), JvmFieldsResult::Unavailable { reason });
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
|
||||
assert!(backend_events(&h).is_empty());
|
||||
assert!(h.manager.session(id).unwrap().jvm_inspector.lock().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collected_keeps_the_backend() {
|
||||
keeps_the_backend(InspectError::Collected, JvmInspectUnavailable::Collected).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timed_out_keeps_the_backend() {
|
||||
keeps_the_backend(InspectError::TimedOut, JvmInspectUnavailable::TimedOut).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_inspection_is_an_error() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::failing(InspectError::Failed("boom".into())));
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let err = h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap_err();
|
||||
assert!(err.to_string().contains("boom"), "{err}");
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn limits_are_clamped() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
for limit in [5000, 0] {
|
||||
h.manager.jvm_memory_instances(id, "A".into(), limit).await.unwrap();
|
||||
}
|
||||
let object = JvmObjectRef { generation: 1, id: 7 };
|
||||
for limit in [0, 900, 20] {
|
||||
h.manager.jvm_memory_referrers(id, object, limit).await.unwrap();
|
||||
}
|
||||
assert_eq!(*fake.limits.lock(), [1000, 1, 50, 200, 20]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_full_page_is_truncated() {
|
||||
let h = harness(Script::default());
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
fake.instances.lock().extend([object(1), object(2)]);
|
||||
let id = stopped_with(&h, java_config("launch"), &fake).await;
|
||||
let truncated = |r: JvmInstancesResult| match r {
|
||||
JvmInstancesResult::Ready { truncated, .. } => truncated,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(truncated(h.manager.jvm_memory_instances(id, "A".into(), 2).await.unwrap()));
|
||||
assert!(!truncated(h.manager.jvm_memory_instances(id, "A".into(), 3).await.unwrap()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_follows_the_inspector() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(java_config("launch")).await.unwrap();
|
||||
assert!(!h.manager.capabilities(id).unwrap().jvm_instances);
|
||||
let before = debug_events(&h).len();
|
||||
h.manager.set_jvm_inspector(id, Arc::new(FakeInspector::default())).unwrap();
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
|
||||
assert!(matches!(debug_events(&h).split_off(before).as_slice(), [DebugEvent::Capabilities]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_attach_counts_classes_through_the_debugger() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let fake = Arc::new(FakeInspector::default());
|
||||
fake.counts.lock().push(ide_jvm_inspect::ClassCount { name: "A".into(), instances: 3 });
|
||||
let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await;
|
||||
let result = h.manager.jvm_memory_classes(id).await.unwrap();
|
||||
assert!(
|
||||
matches!(&result, JvmMemoryResult::Ready { snapshot }
|
||||
if snapshot.source == JvmMemorySource::Debugger && snapshot.rows.len() == 1),
|
||||
"{result:?}"
|
||||
);
|
||||
assert_eq!(fake.targets.lock()[0].attach_port, Some(5005));
|
||||
}
|
||||
|
||||
const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake";
|
||||
|
||||
/// A Kotlin attach whose link reaches a fake JVM: (adapter end, JVM end) after both handshakes.
|
||||
async fn linked_kotlin(h: &Harness) -> (DebugSessionId, tokio::net::TcpStream, tokio::net::TcpStream) {
|
||||
let jvm = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_attach(project.path(), "127.0.0.1", jvm.local_addr().unwrap().port())).await.unwrap();
|
||||
let link_port = h.request("attach")["arguments"]["port"].as_u64().unwrap() as u16;
|
||||
let mut adapter = tokio::net::TcpStream::connect(("127.0.0.1", link_port)).await.unwrap();
|
||||
adapter.write_all(HANDSHAKE).await.unwrap();
|
||||
let (mut jvm, _) = tokio::time::timeout(Duration::from_secs(2), jvm.accept()).await.unwrap().unwrap();
|
||||
let mut buf = [0; 14];
|
||||
jvm.read_exact(&mut buf).await.unwrap();
|
||||
jvm.write_all(HANDSHAKE).await.unwrap();
|
||||
adapter.read_exact(&mut buf).await.unwrap();
|
||||
(id, adapter, jvm)
|
||||
}
|
||||
|
||||
/// A command the adapter sends with an id from the link's own range: the link then only forwards.
|
||||
async fn degrade(adapter: &mut tokio::net::TcpStream) {
|
||||
let mut packet = 11u32.to_be_bytes().to_vec();
|
||||
packet.extend(ide_jdwp::OWN_IDS.start.to_be_bytes());
|
||||
packet.extend([0, 1, 1]);
|
||||
adapter.write_all(&packet).await.unwrap();
|
||||
}
|
||||
|
||||
async fn wait_backend(h: &Harness, want: impl Fn(&JvmBackendState) -> bool) {
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JvmBackend { state } if want(state))).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_link_reports_degraded_then_closed() {
|
||||
let h = harness(Script::default());
|
||||
let (id, mut adapter, jvm) = linked_kotlin(&h).await;
|
||||
degrade(&mut adapter).await;
|
||||
wait_backend(&h, |s| matches!(s, JvmBackendState::Degraded { .. })).await;
|
||||
drop(jvm);
|
||||
wait_backend(&h, |s| *s == JvmBackendState::Closed).await;
|
||||
assert_eq!(backend_events(&h).len(), 2, "{:?}", backend_events(&h));
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.jdwp_link.lock().is_some(), "a closed link is kept until the session ends");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_link_is_quiet_once_the_program_ended() {
|
||||
let h = harness(Script::default());
|
||||
let (id, _adapter, jvm) = linked_kotlin(&h).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
// The adapter reported the end; the session keeps its link until it shuts down.
|
||||
session.terminated.store(true, Ordering::Release);
|
||||
drop(jvm);
|
||||
for _ in 0..100 {
|
||||
if session.jdwp_link.lock().as_ref().is_some_and(|l| l.state() == ide_jdwp::LinkState::Closed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(backend_events(&h).is_empty(), "{:?}", backend_events(&h));
|
||||
}
|
||||
@@ -151,16 +151,6 @@ fn cases(dir: &Path) -> Vec<Case> {
|
||||
field: None,
|
||||
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
|
||||
},
|
||||
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
|
||||
Case {
|
||||
adapter: "gdb",
|
||||
source: c.join("main.c"),
|
||||
loop_line: 13,
|
||||
after_loop: 15,
|
||||
function: "parse",
|
||||
field: None,
|
||||
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
|
||||
},
|
||||
]);
|
||||
out
|
||||
}
|
||||
@@ -240,16 +230,6 @@ fn installed(adapter: &str) -> bool {
|
||||
"js-debug" => "js-debug-adapter",
|
||||
"delve" => "dlv",
|
||||
"java-debug" => return true,
|
||||
// Only a GDB with the DAP interpreter (14+) counts as installed.
|
||||
"gdb" => {
|
||||
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(adapter::parse_gdb_version)
|
||||
.is_some_and(|(major, _)| major >= 14)
|
||||
});
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
adapter::resolve_binary(cmd, Path::new("/")).is_some()
|
||||
|
||||
@@ -20,7 +20,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracledb.workspace = true
|
||||
oracle.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
@@ -594,13 +594,6 @@ fn is_identifier(name: &str) -> bool {
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
|
||||
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
|
||||
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
|
||||
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
|
||||
if vendor == "starrocks" { (false, false) } else { (true, true) }
|
||||
}
|
||||
|
||||
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
|
||||
fn mysql_value(v: &str) -> String {
|
||||
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
|
||||
@@ -846,8 +839,6 @@ impl Pool {
|
||||
if let Some(c) = &setup.collation {
|
||||
opts = opts.collation(c);
|
||||
}
|
||||
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
|
||||
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
@@ -1042,7 +1033,7 @@ pub enum Held {
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1058,7 +1049,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1474,15 +1465,6 @@ mod tests {
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
|
||||
// MySQL and its other wire-compatible presets keep sqlx's defaults.
|
||||
for vendor in ["", "mysql", "mariadb", "tidb"] {
|
||||
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
|
||||
}
|
||||
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_properties_become_pragmas() {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
|
||||
@@ -57,7 +57,6 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -93,21 +92,12 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// An Oracle package (its procedures and functions are not listed separately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPackageInfo")]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions), packages and sequences.
|
||||
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
@@ -306,7 +296,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
@@ -863,14 +853,6 @@ impl DbManager {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::packages(driver, schema) {
|
||||
out.packages = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
@@ -888,29 +870,9 @@ impl DbManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
|
||||
match pool.rows(&q, MAX_META_ROWS).await {
|
||||
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
|
||||
// A spec-only package has no body: the spec alone still opens.
|
||||
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
return Ok(defs
|
||||
.iter()
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
|
||||
+244
-606
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,7 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
@@ -268,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
@@ -557,7 +559,6 @@ mod tests {
|
||||
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Oracle));
|
||||
|
||||
@@ -664,32 +664,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
|
||||
/// listed separately, as in IDEA).
|
||||
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading a package's specification (`body` false) or body as DDL.
|
||||
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
|
||||
if body { "PACKAGE_BODY" } else { "PACKAGE" },
|
||||
quote_literal(driver, name),
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
@@ -989,7 +963,6 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
@@ -1011,17 +984,6 @@ mod tests {
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1039,7 +1001,6 @@ mod tests {
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(packages(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
|
||||
}
|
||||
}
|
||||
|
||||
fn check_id(id: &str) -> IdeResult<&str> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
|
||||
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
Ok(parse_image_history(&out))
|
||||
}
|
||||
|
||||
/// `docker top <id>` of a running container.
|
||||
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["top", id], QUICK)?;
|
||||
Ok(parse_container_top(&out))
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
|
||||
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
|
||||
}
|
||||
|
||||
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
|
||||
/// until it ends or `cancel` fires.
|
||||
pub fn logs(
|
||||
|
||||
+19
-181
@@ -41,15 +41,6 @@ pub struct DockerContainer {
|
||||
pub compose_working_dir: Option<String>,
|
||||
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<String>,
|
||||
/// Connected network names reported by `docker ps` (`Networks`).
|
||||
#[serde(default)]
|
||||
pub networks: Vec<String>,
|
||||
/// User and tool labels assigned to the container.
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DockerContainer {
|
||||
@@ -89,10 +80,6 @@ pub struct DockerVolume {
|
||||
pub driver: String,
|
||||
pub mountpoint: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
|
||||
pub driver: String,
|
||||
pub scope: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub ipv6: bool,
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerImageLayer {
|
||||
pub id: String,
|
||||
pub created_by: String,
|
||||
pub size: String,
|
||||
pub created_since: String,
|
||||
pub created_at: String,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
/// Process table returned by `docker top <container>`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerProcessList {
|
||||
pub titles: Vec<String>,
|
||||
pub processes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerContainerStats {
|
||||
pub cpu_perc: String,
|
||||
pub mem_usage: String,
|
||||
pub mem_perc: String,
|
||||
pub net_io: String,
|
||||
pub block_io: String,
|
||||
pub pids: String,
|
||||
}
|
||||
|
||||
/// Everything the Services tree shows, fetched in one go.
|
||||
@@ -239,20 +186,17 @@ struct RawContainer {
|
||||
ports: String,
|
||||
created_at: String,
|
||||
labels: String,
|
||||
mounts: String,
|
||||
networks: String,
|
||||
}
|
||||
|
||||
fn split_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// `docker ps -a --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
|
||||
.map(|r| {
|
||||
let all_labels = parse_labels(&r.labels);
|
||||
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
|
||||
let mut labels = parse_labels(&r.labels);
|
||||
let files = labels
|
||||
.remove(COMPOSE_FILES)
|
||||
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
// Older docker versions derive `State` from nothing: fall back on the status text.
|
||||
let state = if r.state.is_empty() {
|
||||
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
|
||||
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
status: r.status,
|
||||
ports: r.ports,
|
||||
created_at: r.created_at,
|
||||
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
|
||||
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
|
||||
compose_files: files,
|
||||
mounts: split_csv(&r.mounts),
|
||||
networks: split_csv(&r.networks),
|
||||
labels: all_labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -317,23 +258,17 @@ struct RawVolume {
|
||||
name: String,
|
||||
driver: String,
|
||||
mountpoint: String,
|
||||
scope: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker volume ls --format '{{json .}}'`.
|
||||
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
|
||||
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerVolume {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
scope: r.scope,
|
||||
labels,
|
||||
}
|
||||
.map(|r| DockerVolume {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -348,121 +283,24 @@ struct RawNetwork {
|
||||
name: String,
|
||||
driver: String,
|
||||
scope: String,
|
||||
created_at: String,
|
||||
#[serde(rename = "IPv6")]
|
||||
ipv6: String,
|
||||
internal: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker network ls --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
|
||||
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerNetwork {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
created_at: r.created_at,
|
||||
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
|
||||
internal: r.internal.eq_ignore_ascii_case("true"),
|
||||
labels,
|
||||
}
|
||||
.map(|r| DockerNetwork {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawImageLayer {
|
||||
#[serde(rename = "ID")]
|
||||
id: String,
|
||||
created_by: String,
|
||||
size: String,
|
||||
created_since: String,
|
||||
created_at: String,
|
||||
comment: String,
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
|
||||
lines::<RawImageLayer>(text)
|
||||
.map(|r| DockerImageLayer {
|
||||
id: r.id,
|
||||
created_by: r.created_by,
|
||||
size: r.size,
|
||||
created_since: r.created_since,
|
||||
created_at: r.created_at,
|
||||
comment: r.comment,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `docker top <container>` tabular output.
|
||||
pub fn parse_container_top(text: &str) -> DockerProcessList {
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
let Some(header) = lines.next() else {
|
||||
return DockerProcessList::default();
|
||||
};
|
||||
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
|
||||
if titles.is_empty() {
|
||||
return DockerProcessList::default();
|
||||
}
|
||||
let cols = titles.len();
|
||||
let processes = lines
|
||||
.map(|line| {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
let mut rest = line.trim_start();
|
||||
for _ in 0..cols.saturating_sub(1) {
|
||||
match rest.split_once(char::is_whitespace) {
|
||||
Some((field, tail)) => {
|
||||
row.push(field.to_owned());
|
||||
rest = tail.trim_start();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() || row.len() < cols {
|
||||
row.push(rest.to_owned());
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
DockerProcessList { titles, processes }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawStats {
|
||||
#[serde(rename = "CPUPerc")]
|
||||
cpu_perc: String,
|
||||
mem_usage: String,
|
||||
mem_perc: String,
|
||||
#[serde(rename = "NetIO")]
|
||||
net_io: String,
|
||||
#[serde(rename = "BlockIO")]
|
||||
block_io: String,
|
||||
#[serde(rename = "PIDs")]
|
||||
pids: String,
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
|
||||
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
|
||||
cpu_perc: r.cpu_perc,
|
||||
mem_usage: r.mem_usage,
|
||||
mem_perc: r.mem_perc,
|
||||
net_io: r.net_io,
|
||||
block_io: r.block_io,
|
||||
pids: r.pids,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawVersion {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Parsing of recorded `docker … --format '{{json .}}'` output.
|
||||
|
||||
use ide_docker::{
|
||||
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
|
||||
parse_volumes,
|
||||
};
|
||||
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
|
||||
|
||||
const PS: &str = include_str!("fixtures/ps.jsonl");
|
||||
const IMAGES: &str = include_str!("fixtures/images.jsonl");
|
||||
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
|
||||
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
|
||||
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
|
||||
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
|
||||
assert_eq!(db.mounts, ["shop_pgdata"]);
|
||||
assert_eq!(db.networks, ["shop_default"]);
|
||||
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
|
||||
|
||||
let web = &c[1];
|
||||
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
|
||||
assert!(!web.running());
|
||||
assert_eq!(web.compose_project, None);
|
||||
assert!(web.mounts.is_empty());
|
||||
assert_eq!(web.networks, ["bridge"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
|
||||
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
|
||||
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
|
||||
assert_eq!(v[1].scope, "local");
|
||||
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
|
||||
|
||||
let n = parse_networks(NETWORKS);
|
||||
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
|
||||
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(n[1].driver, "host");
|
||||
assert!(!n[2].ipv6);
|
||||
assert!(!n[2].internal);
|
||||
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_history_top_and_stats() {
|
||||
let history = [
|
||||
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
|
||||
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
|
||||
]
|
||||
.join("\n");
|
||||
let layers = parse_image_history(&history);
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
|
||||
assert_eq!(layers[1].size, "42.5MB");
|
||||
|
||||
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
|
||||
let parsed_top = parse_container_top(top);
|
||||
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
|
||||
assert_eq!(parsed_top.processes.len(), 1);
|
||||
assert_eq!(parsed_top.processes[0][0], "root");
|
||||
assert_eq!(parsed_top.processes[0][1], "1234");
|
||||
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
|
||||
|
||||
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
|
||||
let stats = parse_container_stats(stats_line).expect("stats parsed");
|
||||
assert_eq!(stats.cpu_perc, "1.25%");
|
||||
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
|
||||
assert_eq!(stats.mem_perc, "2.50%");
|
||||
assert_eq!(stats.net_io, "12kB / 8kB");
|
||||
assert_eq!(stats.block_io, "1.2MB / 0B");
|
||||
assert_eq!(stats.pids, "14");
|
||||
}
|
||||
@@ -18,7 +18,6 @@ semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
dunce.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -38,10 +38,6 @@ pub enum Capability {
|
||||
Clipboard,
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Ask the user when the first request goes to an HTTPS (or loopback) host
|
||||
/// the manifest does not list; each answer applies to one host. Plain
|
||||
/// HTTP elsewhere always needs `net.http:<host>`.
|
||||
NetAsk,
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
@@ -75,7 +71,6 @@ impl Capability {
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetAsk
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
@@ -109,10 +104,6 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetAsk => {
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
|
||||
.into()
|
||||
}
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
@@ -239,7 +230,6 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetAsk => f.write_str("net:ask"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
@@ -261,9 +251,6 @@ impl FromStr for Capability {
|
||||
}
|
||||
return match kind {
|
||||
"net" | "net.http" => {
|
||||
if kind == "net" && arg == "ask" {
|
||||
return Ok(Self::NetAsk);
|
||||
}
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -464,25 +451,6 @@ mod command_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod net_ask_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
|
||||
let cap: Capability = "net:ask".parse().unwrap();
|
||||
assert_eq!(cap, Capability::NetAsk);
|
||||
assert_eq!(cap.to_string(), "net:ask");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
// It asks; it does not itself connect anywhere or pre-approve a host.
|
||||
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
|
||||
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
|
||||
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
|
||||
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
|
||||
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -53,9 +53,6 @@ pub struct Grant {
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: BTreeSet<String>,
|
||||
/// Exact hosts approved through `net:ask`, separate from manifest grants.
|
||||
#[serde(default)]
|
||||
pub net_hosts: BTreeSet<String>,
|
||||
/// Optional capabilities the user declined (not asked again until re-confirmed).
|
||||
#[serde(default)]
|
||||
pub declined: BTreeSet<String>,
|
||||
@@ -204,9 +201,6 @@ impl Ledger {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
@@ -231,9 +225,6 @@ impl Ledger {
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
@@ -261,12 +252,6 @@ impl Ledger {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Net(host) = cap {
|
||||
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
|
||||
}
|
||||
if cap == &Capability::NetAsk {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
@@ -277,19 +262,6 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers one exact host without granting a manifest capability.
|
||||
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
|
||||
return false;
|
||||
}
|
||||
grant.net_hosts.insert(host.to_owned())
|
||||
}
|
||||
|
||||
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
|
||||
}
|
||||
|
||||
/// Records the program approved for a `process:` grant.
|
||||
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
@@ -420,13 +392,7 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
|
||||
}
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
|
||||
let pathext: Vec<String> = pathext.split(';').filter(|e| !e.is_empty()).map(str::to_owned).collect();
|
||||
// A name given with its extension (`cargo.exe`) is looked up as written.
|
||||
let has_ext = candidate.extension().is_some_and(|e| {
|
||||
pathext.iter().any(|x| x.trim_start_matches('.').eq_ignore_ascii_case(&e.to_string_lossy()))
|
||||
});
|
||||
if has_ext { vec![String::new()] } else { pathext }
|
||||
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
@@ -447,13 +413,11 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
/// `p` with its parent canonicalized but the file name as found: PATH
|
||||
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
|
||||
/// their real directory, while a linked program (`cargo` → rustup) keeps
|
||||
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
|
||||
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
|
||||
/// match the path the program was actually started with.
|
||||
/// its link name so the shim still dispatches on argv[0].
|
||||
fn canonical_dir(p: &Path) -> PathBuf {
|
||||
let Some(dir) = p.parent() else { return p.to_path_buf() };
|
||||
let Some(name) = p.file_name() else { return p.to_path_buf() };
|
||||
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
@@ -506,20 +470,6 @@ mod tests {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn pinned_program_path_has_no_verbatim_prefix() {
|
||||
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
|
||||
// stored in that form would never match the plain path the program is
|
||||
// started with. dunce keeps the usual form.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
|
||||
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
|
||||
let shown = pinned.to_string_lossy();
|
||||
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
|
||||
assert!(pinned.is_absolute());
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
@@ -560,40 +510,6 @@ mod tests {
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
let r = req("1.0.0", "h", &["net:ask"], &[]);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
let host = "api.example.com";
|
||||
assert!(l.remember_net_host(&r.subject, host));
|
||||
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
|
||||
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
|
||||
l.save().unwrap();
|
||||
l = Ledger::load(tmp.path());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
|
||||
for confirm in [false, true] {
|
||||
l.remember_net_host(&r.subject, host);
|
||||
// A newly declared host must not inherit an ask grant after net:ask is removed.
|
||||
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
|
||||
if confirm {
|
||||
l.confirm(&update, &[], BTreeMap::new());
|
||||
} else {
|
||||
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
l.remember_net_host(&r.subject, host);
|
||||
l.set(&r.subject, &Capability::NetAsk, false);
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -19,10 +19,8 @@ mod index;
|
||||
pub mod ledger;
|
||||
mod manifest;
|
||||
pub mod package;
|
||||
mod plugin_point;
|
||||
mod point;
|
||||
mod registry;
|
||||
mod resolve;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
@@ -32,12 +30,7 @@ pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use plugin_point::{FieldType, PointDecl};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{
|
||||
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
|
||||
PreparedSet,
|
||||
};
|
||||
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -26,15 +26,13 @@
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::resolve::Dependency;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.5.0";
|
||||
pub const ENGINE_VERSION: &str = "1.4.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -68,10 +66,6 @@ pub struct Manifest {
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `[[depends]]`: plugins this one needs (API 1.5).
|
||||
pub depends: Vec<Dependency>,
|
||||
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
|
||||
pub extension_points: Vec<PointDecl>,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
@@ -157,7 +151,6 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"extensions-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
@@ -217,10 +210,6 @@ struct Raw {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
depends: Vec<Dependency>,
|
||||
#[serde(default)]
|
||||
extension_points: Vec<PointDecl>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
@@ -292,8 +281,6 @@ impl Manifest {
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
depends: raw.depends,
|
||||
extension_points: raw.extension_points,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
@@ -351,9 +338,6 @@ impl Manifest {
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
if matches!(cap, Capability::NetAsk) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 5)?;
|
||||
}
|
||||
}
|
||||
if let Some(dark) = &self.icon_dark {
|
||||
if self.icon.is_none() {
|
||||
@@ -370,9 +354,6 @@ impl Manifest {
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
|
||||
self.needs_api("`on-event:extensions-changed`", 5)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -383,7 +364,6 @@ impl Manifest {
|
||||
for c in &self.declared_categories {
|
||||
validate_item_id(c, "category")?;
|
||||
}
|
||||
self.validate_relations()?;
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
@@ -393,42 +373,6 @@ impl Manifest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[[depends]]` and `[[extension-points]]`.
|
||||
fn validate_relations(&self) -> Result<(), String> {
|
||||
if !self.depends.is_empty() {
|
||||
self.needs_api("`[[depends]]`", 5)?;
|
||||
}
|
||||
if !self.extension_points.is_empty() {
|
||||
self.needs_api("`[[extension-points]]`", 5)?;
|
||||
}
|
||||
let mut required = BTreeSet::new();
|
||||
for dep in &self.depends {
|
||||
validate_plugin_id(&dep.id)?;
|
||||
if dep.id == self.id {
|
||||
return Err(format!("`depends`: {} cannot depend on itself", self.id));
|
||||
}
|
||||
if let Some(req) = &dep.version
|
||||
&& semver::VersionReq::parse(req).is_err()
|
||||
{
|
||||
return Err(format!(
|
||||
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
|
||||
req, dep.id
|
||||
));
|
||||
}
|
||||
if !required.insert(&dep.id) {
|
||||
return Err(format!("`depends`: {} is listed twice", dep.id));
|
||||
}
|
||||
}
|
||||
let mut declared = BTreeSet::new();
|
||||
for point in &self.extension_points {
|
||||
point.validate(&self.id)?;
|
||||
if !declared.insert(&point.id) {
|
||||
return Err(format!("extension point {} is declared twice", point.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
@@ -525,12 +469,11 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
@@ -765,86 +708,6 @@ mod api14_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod relations_tests {
|
||||
use super::*;
|
||||
|
||||
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
|
||||
|
||||
fn parse(rest: &str) -> Result<Manifest, String> {
|
||||
Manifest::parse(&format!("{HEAD}{rest}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_depends_and_extension_points() {
|
||||
let m = parse(
|
||||
r#"
|
||||
[[depends]]
|
||||
id = "acme.base"
|
||||
version = "^0.2"
|
||||
|
||||
[[depends]]
|
||||
id = "acme.extra"
|
||||
optional = true
|
||||
|
||||
[[extension-points]]
|
||||
id = "acme.app.hooks"
|
||||
required = ["name"]
|
||||
fields = { name = "string" }
|
||||
|
||||
[[contributes."acme.base.things"]]
|
||||
name = "x"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.depends.len(), 2);
|
||||
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
|
||||
assert!(m.depends[1].optional);
|
||||
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
|
||||
assert_eq!(m.declarations("acme.base.things").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_keys_need_api_1_5() {
|
||||
let older =
|
||||
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
|
||||
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let current = older.replace("^1.4", "^1.5");
|
||||
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_dependencies() {
|
||||
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
|
||||
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
|
||||
assert!(
|
||||
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
|
||||
);
|
||||
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("listed twice"));
|
||||
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_points_outside_the_plugins_namespace() {
|
||||
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
|
||||
assert!(err.contains("acme.app.<name>"), "{err}");
|
||||
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("declared twice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_ids_with_underscores_work_as_contribution_keys() {
|
||||
assert!(validate_point_id("my_tools.base.things").is_ok());
|
||||
assert!(validate_point_id("Bad").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
@@ -855,24 +718,3 @@ mod commands_api_tests {
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod net_ask_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_requires_api_1_5() {
|
||||
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
|
||||
for engine in ["^1.4", "^1.5"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.5" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Extension points a plugin declares for other plugins to contribute to
|
||||
//! (`[[extension-points]]`), and the check of a contribution against them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const MAX_FIELDS: usize = 32;
|
||||
const MAX_ID: usize = 128;
|
||||
/// Longest string a contribution may carry in one field.
|
||||
const MAX_TEXT: usize = 64 * 1024;
|
||||
const MAX_LIST: usize = 1000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FieldType {
|
||||
String,
|
||||
Boolean,
|
||||
Integer,
|
||||
Number,
|
||||
StringList,
|
||||
}
|
||||
|
||||
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PointDecl {
|
||||
/// `<plugin id>.<name>`.
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Fields a contribution must have.
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
/// Every field a contribution may have; a contribution with another key is refused.
|
||||
#[serde(default)]
|
||||
pub fields: BTreeMap<String, FieldType>,
|
||||
}
|
||||
|
||||
impl PointDecl {
|
||||
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
|
||||
let prefix = format!("{plugin_id}.");
|
||||
let name = self.id.strip_prefix(&prefix).unwrap_or("");
|
||||
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if !name_ok || self.id.len() > MAX_ID {
|
||||
return Err(format!(
|
||||
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
|
||||
self.id
|
||||
));
|
||||
}
|
||||
if self.description.len() > 500 {
|
||||
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
|
||||
}
|
||||
if self.fields.len() > MAX_FIELDS {
|
||||
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
|
||||
}
|
||||
for field in self.fields.keys() {
|
||||
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
&& field.len() <= 64;
|
||||
if !ok {
|
||||
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
|
||||
}
|
||||
}
|
||||
for field in &self.required {
|
||||
if !self.fields.contains_key(field) {
|
||||
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
|
||||
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
let toml::Value::Table(table) = value else {
|
||||
return Err("a contribution is a table".into());
|
||||
};
|
||||
for key in table.keys() {
|
||||
if !self.fields.contains_key(key) {
|
||||
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
|
||||
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
|
||||
}
|
||||
}
|
||||
for key in &self.required {
|
||||
if !table.contains_key(key) {
|
||||
return Err(format!("missing required field {key:?}"));
|
||||
}
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, value) in table {
|
||||
out.insert(key.clone(), convert(key, self.fields[key], value)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
use serde_json::Value as J;
|
||||
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
|
||||
match (ty, value) {
|
||||
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
|
||||
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
|
||||
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
|
||||
(FieldType::StringList, toml::Value::Array(items)) => {
|
||||
if items.len() > MAX_LIST {
|
||||
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
toml::Value::String(s) => text(key, s).map(J::from),
|
||||
_ => Err(wrong("a list of strings")),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(J::Array)
|
||||
}
|
||||
(FieldType::String, _) => Err(wrong("a string")),
|
||||
(FieldType::Boolean, _) => Err(wrong("true or false")),
|
||||
(FieldType::Integer, _) => Err(wrong("an integer")),
|
||||
(FieldType::Number, _) => Err(wrong("a number")),
|
||||
(FieldType::StringList, _) => Err(wrong("a list of strings")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(key: &str, s: &str) -> Result<String, String> {
|
||||
if s.len() > MAX_TEXT {
|
||||
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
|
||||
}
|
||||
Ok(s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decl(src: &str) -> PointDecl {
|
||||
toml::from_str(src).unwrap()
|
||||
}
|
||||
|
||||
const COMPLETIONS: &str = r#"
|
||||
id = "acme.base.completions"
|
||||
description = "Extra completions"
|
||||
required = ["label"]
|
||||
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn the_id_is_namespaced_by_the_declaring_plugin() {
|
||||
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
|
||||
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
|
||||
assert!(err.contains("acme.other.<name>"), "{err}");
|
||||
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
|
||||
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_must_be_declared() {
|
||||
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
|
||||
assert!(err.contains("required field \"a\""), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_a_contribution_and_converts_it_to_json() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let contribution: toml::Value =
|
||||
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
|
||||
let json = point.check(&contribution).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_the_point_does_not_declare() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
|
||||
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
|
||||
let err = check("label = \"a\"\nextra = 1");
|
||||
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
|
||||
assert!(check("label = 1").contains("must be a string"));
|
||||
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
|
||||
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_take_integers_and_floats() {
|
||||
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Extension points plus the contributions committed to them, accounted per plugin.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -24,7 +23,7 @@ pub struct ContributionInfo {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: String,
|
||||
point: &'static str,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
@@ -39,7 +38,7 @@ pub struct PreparedSet {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(String, Prepared)>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
@@ -62,41 +61,11 @@ impl std::fmt::Debug for PreparedSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one plugin contributed to an extension point another plugin declared.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Contribution {
|
||||
/// The contributing plugin.
|
||||
pub plugin: String,
|
||||
/// The declaration as JSON, checked against the point's fields.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// An extension point a plugin declared, with who contributes to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeclaredPoint {
|
||||
pub owner: String,
|
||||
pub decl: PointDecl,
|
||||
/// Plugin ids, in commit order, once each.
|
||||
pub contributors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Dynamic {
|
||||
points: BTreeMap<String, (String, PointDecl)>,
|
||||
contributions: BTreeMap<String, Vec<Contribution>>,
|
||||
}
|
||||
|
||||
/// Called with (point id, owner plugin) after contributions to a declared point changed.
|
||||
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionRegistry {
|
||||
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
|
||||
/// plugin id → its contributions in commit order.
|
||||
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
|
||||
/// Points declared by plugins and what was contributed to them.
|
||||
dynamic: Arc<Mutex<Dynamic>>,
|
||||
listener: Arc<RwLock<Option<ChangeListener>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
@@ -128,111 +97,6 @@ impl ExtensionRegistry {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Registers the function told when contributions to a plugin-declared point change.
|
||||
pub fn set_change_listener(&self, listener: ChangeListener) {
|
||||
*self.listener.write() = Some(listener);
|
||||
}
|
||||
|
||||
/// The plugin that declared `point`, while it is committed.
|
||||
pub fn point_owner(&self, point: &str) -> Option<String> {
|
||||
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
|
||||
}
|
||||
|
||||
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
|
||||
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
if !dynamic.points.contains_key(point) {
|
||||
return Vec::new();
|
||||
}
|
||||
dynamic.contributions.get(point).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The points `owner` declared, with their contributors.
|
||||
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
dynamic
|
||||
.points
|
||||
.iter()
|
||||
.filter(|(_, (o, _))| o == owner)
|
||||
.map(|(id, (owner, decl))| {
|
||||
let mut contributors: Vec<String> = Vec::new();
|
||||
for c in dynamic.contributions.get(id).into_iter().flatten() {
|
||||
if !contributors.contains(&c.plugin) {
|
||||
contributors.push(c.plugin.clone());
|
||||
}
|
||||
}
|
||||
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
|
||||
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
|
||||
pkg.manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|decl| {
|
||||
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
|
||||
let id = decl.id.clone();
|
||||
let prepared = Prepared::new(format!("Extension point {id}"), move || {
|
||||
let id = decl.id.clone();
|
||||
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
|
||||
Ok(Disposable::new(move || {
|
||||
dynamic.lock().points.remove(&id);
|
||||
}))
|
||||
})
|
||||
.key(format!("extension-point:{id}"));
|
||||
(id, prepared)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
|
||||
/// when no plugin declares `point` (the caller then skips it as unknown).
|
||||
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
|
||||
let declared = self.dynamic.lock().points.get(point).cloned();
|
||||
let own =
|
||||
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
|
||||
let (owner, decl) = declared.or(own)?;
|
||||
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
|
||||
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
|
||||
}
|
||||
let listener = self.listener.clone();
|
||||
let result = pkg
|
||||
.manifest
|
||||
.declarations(point)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
|
||||
let label = decl
|
||||
.required
|
||||
.first()
|
||||
.and_then(|field| data.get(field))
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or_else(|| point.to_owned(), str::to_owned);
|
||||
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
|
||||
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
|
||||
Ok(Prepared::new(label, move || {
|
||||
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
|
||||
notify(&dynamic, &listener, &point);
|
||||
Ok(Disposable::new(move || {
|
||||
{
|
||||
let mut guard = dynamic.lock();
|
||||
if let Some(list) = guard.contributions.get_mut(&point)
|
||||
&& let Some(pos) = list.iter().position(|c| *c == contribution)
|
||||
{
|
||||
list.remove(pos);
|
||||
}
|
||||
}
|
||||
notify(&dynamic, &listener, &point);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
|
||||
/// whole on the first invalid declaration; unknown points are skipped with
|
||||
/// a warning (forward compatibility).
|
||||
@@ -244,24 +108,9 @@ impl ExtensionRegistry {
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
// Declared points first: contributions to them (the plugin's own too) commit after.
|
||||
set.items.extend(self.prepare_declarations(pkg));
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
if let Some(result) = self.prepare_contributions(pkg, point_id) {
|
||||
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
|
||||
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
|
||||
continue;
|
||||
}
|
||||
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
|
||||
let owner =
|
||||
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
|
||||
let msg = match owner {
|
||||
Some(owner) => {
|
||||
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
|
||||
}
|
||||
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
|
||||
};
|
||||
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
|
||||
tracing::warn!(plugin = %pkg.id(), "{msg}");
|
||||
set.warnings.push(msg);
|
||||
continue;
|
||||
@@ -280,7 +129,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
}
|
||||
if let Some(point) = self.point(LOCALES_POINT)
|
||||
@@ -295,7 +144,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
@@ -352,7 +201,7 @@ impl ExtensionRegistry {
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -368,14 +217,6 @@ impl ExtensionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
|
||||
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
|
||||
let callback = listener.read().clone();
|
||||
if let (Some(owner), Some(callback)) = (owner, callback) {
|
||||
callback(point, &owner);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
@@ -469,122 +310,6 @@ mod tests {
|
||||
assert!(reg.contributions("p").is_empty());
|
||||
}
|
||||
|
||||
fn pkg_of(id: &str, body: &str) -> Package {
|
||||
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
[[extension-points]]
|
||||
id = "acme.base.things"
|
||||
required = ["name"]
|
||||
fields = { name = "string", weight = "integer" }
|
||||
"#;
|
||||
const PLUGIN: &str =
|
||||
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
|
||||
|
||||
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
|
||||
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
let got = reg.contributions_to("acme.base.things");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].plugin, "acme.more");
|
||||
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
|
||||
assert_eq!(
|
||||
reg.contributions("acme.more")[0],
|
||||
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
|
||||
);
|
||||
let declared = reg.declared_points("acme.base");
|
||||
assert_eq!(declared.len(), 1);
|
||||
assert_eq!(declared[0].contributors, ["acme.more"]);
|
||||
|
||||
assert!(reg.dispose_plugin("acme.more"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
assert!(reg.dispose_plugin("acme.base"));
|
||||
assert_eq!(reg.point_owner("acme.base.things"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributions_disappear_from_view_while_the_owner_is_gone() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.base");
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributing_needs_a_dependency_on_the_owner() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let err =
|
||||
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
|
||||
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contribution_is_checked_against_the_declared_fields() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
|
||||
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
|
||||
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_nobody_declares_is_skipped_with_a_warning() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
assert!(set.is_empty());
|
||||
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
let warning = &set.warnings[0];
|
||||
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
|
||||
assert!(!warning.contains("this version of the IDE"), "{warning}");
|
||||
|
||||
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
|
||||
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
|
||||
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_may_contribute_to_its_own_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
|
||||
commit(®, &pkg_of("acme.base", &own));
|
||||
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_hears_about_changes_to_its_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
|
||||
let sink = heard.clone();
|
||||
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert!(heard.lock().is_empty(), "declaring is not a contribution");
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.more");
|
||||
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
|
||||
assert_eq!(*heard.lock(), [expected.clone(), expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
|
||||
let (reg, _) = setup();
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
//! Which plugins can run, and in which order, given what they depend on.
|
||||
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
/// `[[depends]]` of a manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
pub id: String,
|
||||
/// Semver requirement on the dependency's version; none accepts any.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// The plugin runs without it; when present it starts first.
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
impl Dependency {
|
||||
/// `acme.base ^1.0`.
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.version {
|
||||
Some(v) => format!("{} {v}", self.id),
|
||||
None => self.id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin as the resolver sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Node<'a> {
|
||||
pub id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub depends: &'a [Dependency],
|
||||
/// Not switched off by the user.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Why an enabled plugin cannot run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum Blocked {
|
||||
/// A required plugin is not installed.
|
||||
Missing { requirement: String },
|
||||
/// A required plugin is switched off.
|
||||
Disabled { id: String },
|
||||
/// A required plugin is installed in a version that does not satisfy the requirement.
|
||||
Version { requirement: String, found: String },
|
||||
/// A required plugin cannot run itself.
|
||||
Dependency { id: String },
|
||||
/// The plugin is part of a dependency cycle (`a → b → a`).
|
||||
Cycle { ring: Vec<String> },
|
||||
}
|
||||
|
||||
impl fmt::Display for Blocked {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
|
||||
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
|
||||
Self::Version { requirement, found } => {
|
||||
write!(f, "Requires {requirement}, but version {found} is installed")
|
||||
}
|
||||
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
|
||||
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
/// Plugins that can run, every plugin after the plugins it depends on.
|
||||
pub order: Vec<String>,
|
||||
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
|
||||
pub blocked: BTreeMap<String, Blocked>,
|
||||
}
|
||||
|
||||
/// Decides, for every enabled plugin, whether its required plugins allow it to
|
||||
/// run and the order to start them in (the counterpart of IntelliJ's
|
||||
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
|
||||
pub fn plan(nodes: &[Node<'_>]) -> Plan {
|
||||
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
|
||||
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
|
||||
|
||||
for node in &enabled {
|
||||
for dep in node.depends.iter().filter(|d| !d.optional) {
|
||||
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
|
||||
blocked.entry(node.id.to_owned()).or_insert(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles among plugins whose requirements are otherwise present.
|
||||
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
let graph: Graph<'_> = candidates
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
|
||||
(*id, deps.filter(|d| candidates.contains(d)).collect())
|
||||
})
|
||||
.collect();
|
||||
for component in components(&graph) {
|
||||
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
|
||||
if cyclic {
|
||||
let ring = ring_of(&component, &graph);
|
||||
for id in component {
|
||||
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that needs a blocked plugin is blocked too.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for node in &enabled {
|
||||
if blocked.contains_key(node.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
|
||||
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
Plan { order: topological(&runnable, &by_id), blocked }
|
||||
}
|
||||
|
||||
/// Plugins that need `id`, directly or through other plugins, whether or not
|
||||
/// they are enabled. Optional dependencies do not count.
|
||||
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
|
||||
let mut found: BTreeSet<String> = BTreeSet::new();
|
||||
let mut frontier = vec![id.to_owned()];
|
||||
while let Some(current) = frontier.pop() {
|
||||
for node in nodes {
|
||||
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
|
||||
frontier.push(node.id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
found.remove(id);
|
||||
found.into_iter().collect()
|
||||
}
|
||||
|
||||
/// How one dependency stands right now, for the plugin details page.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyState {
|
||||
Satisfied,
|
||||
Missing,
|
||||
Disabled,
|
||||
/// Installed, wrong version.
|
||||
Version,
|
||||
/// Installed and enabled, but cannot run itself.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
|
||||
match direct_reason(dep, found) {
|
||||
Some(Blocked::Missing { .. }) => DependencyState::Missing,
|
||||
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
|
||||
Some(Blocked::Version { .. }) => DependencyState::Version,
|
||||
_ if found_blocked => DependencyState::Blocked,
|
||||
_ => DependencyState::Satisfied,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
|
||||
let Some(target) = found else {
|
||||
return Some(Blocked::Missing { requirement: dep.describe() });
|
||||
};
|
||||
if !target.enabled {
|
||||
return Some(Blocked::Disabled { id: dep.id.clone() });
|
||||
}
|
||||
let want = dep.version.as_deref()?;
|
||||
let satisfied = VersionReq::parse(want)
|
||||
.ok()
|
||||
.zip(Version::parse(target.version).ok())
|
||||
.is_some_and(|(req, have)| req.matches(&have));
|
||||
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
|
||||
}
|
||||
|
||||
/// Plugin id → the plugin ids it requires.
|
||||
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
|
||||
|
||||
/// Strongly connected components (Tarjan) of `graph`.
|
||||
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
|
||||
struct State<'a, 'g> {
|
||||
index: usize,
|
||||
stack: Vec<&'a str>,
|
||||
info: BTreeMap<&'a str, (usize, usize, bool)>,
|
||||
out: Vec<Vec<&'a str>>,
|
||||
graph: &'g Graph<'a>,
|
||||
}
|
||||
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
|
||||
s.info.insert(v, (s.index, s.index, true));
|
||||
s.index += 1;
|
||||
s.stack.push(v);
|
||||
for &w in &s.graph[v] {
|
||||
match s.info.get(w).copied() {
|
||||
None => {
|
||||
visit(s, w);
|
||||
let low = s.info[w].1;
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(low);
|
||||
}
|
||||
Some((index, _, true)) => {
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(index);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let (index, low, _) = s.info[v];
|
||||
if index == low {
|
||||
let mut component = Vec::new();
|
||||
while let Some(w) = s.stack.pop() {
|
||||
s.info.get_mut(w).expect("visited").2 = false;
|
||||
component.push(w);
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
component.sort_unstable();
|
||||
s.out.push(component);
|
||||
}
|
||||
}
|
||||
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
|
||||
for &id in graph.keys() {
|
||||
if !state.info.contains_key(id) {
|
||||
visit(&mut state, id);
|
||||
}
|
||||
}
|
||||
state.out
|
||||
}
|
||||
|
||||
/// One cycle through `component`, from its smallest id back to it.
|
||||
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
|
||||
let start = component[0];
|
||||
let mut ring = vec![start.to_owned()];
|
||||
let mut current = start;
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
|
||||
loop {
|
||||
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
|
||||
next.sort_unstable();
|
||||
// Prefer closing the ring; otherwise take the smallest unseen member.
|
||||
let step =
|
||||
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
|
||||
ring.push(step.to_owned());
|
||||
if step == start {
|
||||
return ring;
|
||||
}
|
||||
seen.insert(step);
|
||||
current = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
|
||||
/// dependencies order too, but never block: leftovers (an optional edge
|
||||
/// closing a loop) are appended by id.
|
||||
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
|
||||
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
|
||||
(*id, deps.collect())
|
||||
})
|
||||
.collect();
|
||||
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
|
||||
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
|
||||
waiting.remove(next);
|
||||
for deps in waiting.values_mut() {
|
||||
deps.remove(next);
|
||||
}
|
||||
order.push(next.to_owned());
|
||||
}
|
||||
order.extend(waiting.keys().map(|id| (*id).to_owned()));
|
||||
order
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: false }
|
||||
}
|
||||
|
||||
fn dep_v(id: &str, version: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: Some(version.into()), optional: false }
|
||||
}
|
||||
|
||||
fn optional(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: true }
|
||||
}
|
||||
|
||||
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
|
||||
Node { id, version: "1.0.0", depends, enabled: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_start_first_whatever_the_ids_sort_like() {
|
||||
let (a, z) = ([dep("z.base")], []);
|
||||
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
|
||||
assert_eq!(plan.order, ["z.base", "a.app"]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_plugins_keep_id_order() {
|
||||
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
|
||||
assert_eq!(plan.order, ["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_requirement_blocks_with_the_requirement_text() {
|
||||
let needs = [dep_v("acme.base", "^1.0")];
|
||||
let plan = plan(&[node("acme.app", &needs)]);
|
||||
assert!(plan.order.is_empty());
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
|
||||
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
|
||||
let needs = [dep("acme.base")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.enabled = false;
|
||||
let plan = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
|
||||
assert!(!plan.blocked.contains_key("acme.base"));
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_installed_version_must_satisfy_the_requirement() {
|
||||
let needs = [dep_v("acme.base", "^0.2")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.version = "0.3.0";
|
||||
let plan_bad = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(
|
||||
plan_bad.blocked["acme.app"],
|
||||
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
|
||||
);
|
||||
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
|
||||
|
||||
base.version = "0.2.7";
|
||||
let plan_ok = plan(&[base, node("acme.app", &needs)]);
|
||||
assert!(plan_ok.blocked.is_empty());
|
||||
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_propagates_to_everything_downstream() {
|
||||
let (b, c) = ([dep("a")], [dep("b")]);
|
||||
let plan = plan(&[node("b", &b), node("c", &c)]);
|
||||
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
|
||||
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_requirement_never_blocks_but_orders_when_present() {
|
||||
let app = [optional("acme.extra")];
|
||||
let alone = plan(&[node("acme.app", &app)]);
|
||||
assert_eq!(alone.order, ["acme.app"]);
|
||||
assert!(alone.blocked.is_empty());
|
||||
|
||||
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
|
||||
assert_eq!(with.order, ["acme.extra", "acme.app"]);
|
||||
|
||||
let mut off = node("acme.extra", &[]);
|
||||
off.enabled = false;
|
||||
let disabled = plan(&[node("acme.app", &app), off]);
|
||||
assert_eq!(disabled.order, ["acme.app"]);
|
||||
assert!(disabled.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_blocks_its_members_and_names_the_ring() {
|
||||
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
|
||||
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
|
||||
assert_eq!(plan.blocked["a"], ring);
|
||||
assert_eq!(plan.blocked["b"], ring);
|
||||
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
|
||||
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_requiring_itself_is_a_cycle() {
|
||||
let a = [dep("a")];
|
||||
let plan = plan(&[node("a", &a)]);
|
||||
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
|
||||
let (a, b) = ([optional("b")], [optional("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b)]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
assert_eq!(plan.order.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependents_are_transitive_and_ignore_optional_edges() {
|
||||
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
|
||||
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
|
||||
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
|
||||
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_state_tells_the_details_page_what_to_say() {
|
||||
let want = dep_v("base", "^1.0");
|
||||
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
|
||||
let mut base = node("base", &[]);
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
|
||||
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
|
||||
base.version = "2.0.0";
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
|
||||
base.enabled = false;
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -627,8 +627,8 @@ mod tests {
|
||||
#[test]
|
||||
fn finds_remote_by_repo() {
|
||||
let remotes = vec![
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
|
||||
];
|
||||
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
|
||||
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
|
||||
|
||||
@@ -27,7 +27,6 @@ ide-search.workspace = true
|
||||
ide-lsp.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-dap.workspace = true
|
||||
ide-jvm-attach.workspace = true
|
||||
ide-build.workspace = true
|
||||
ide-analysis.workspace = true
|
||||
ide-coverage.workspace = true
|
||||
@@ -50,7 +49,6 @@ ide-notebook.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
ide-scaffold.workspace = true
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
|
||||
@@ -368,9 +368,7 @@ fn check(dir: &Path) -> Result<(), String> {
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
println!("{} {} — {}", preview.id, preview.version, preview.name);
|
||||
if manifest.legacy {
|
||||
println!(
|
||||
" note: plugin.toml uses the 0.1 format (`openidea-api`); its code no longer runs: rebuild it against the 1.x SDK and declare `engines.openidea` (see plugins/README.md)"
|
||||
);
|
||||
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
let engine = PluginEngine::new()?;
|
||||
|
||||
@@ -208,7 +208,7 @@ authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
|
||||
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
|
||||
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
|
||||
/// debug builds apart under one root. A set but unusable path is an error, never a
|
||||
/// silent fallback to the OS location.
|
||||
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
|
||||
};
|
||||
// The working directory depends on how the IDE was launched.
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
let dir = root.join(identifier);
|
||||
fs::create_dir_all(&dir)
|
||||
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
|
||||
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
@@ -304,102 +276,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_dir_follows_the_os_unless_the_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os-local"));
|
||||
for var in [None, Some(OsString::new())] {
|
||||
assert_eq!(
|
||||
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
|
||||
Path::new("/os-local/ai.l0ng.openidea")
|
||||
);
|
||||
}
|
||||
assert!(resolve_local(None, None, "id").is_err());
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("missing/nested");
|
||||
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
|
||||
assert_eq!(dir, root.join("ai.l0ng.openidea"));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
|
||||
// Debug worktrees have their own identifiers and stay apart under one root.
|
||||
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
fs::write(&probe, "keep").unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let target = tmp.path().join("target");
|
||||
fs::write(&target, "keep").unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
std::os::unix::fs::symlink(&target, &probe).unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
|
||||
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let barrier = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
let barrier = &barrier;
|
||||
let root = tmp.path();
|
||||
scope.spawn(move || {
|
||||
barrier.wait();
|
||||
for _ in 0..16 {
|
||||
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_rejects_relative_and_unwritable_paths() {
|
||||
for relative in ["local", "./local", "../local"] {
|
||||
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocker = tmp.path().join("file");
|
||||
fs::write(&blocker, "").unwrap();
|
||||
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains("cannot write"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_rejects_an_existing_read_only_directory() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores permission bits.
|
||||
if fs::write(dir.join("probe"), "").is_err() {
|
||||
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
|
||||
}
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
|
||||
@@ -28,8 +28,6 @@ pub struct Reports(RwLock<State>);
|
||||
struct State {
|
||||
windows: HashMap<String, Window>,
|
||||
focused: Option<String>,
|
||||
/// Windows in the order they were last focused, the most recent last.
|
||||
focus_order: Vec<String>,
|
||||
remote: HashMap<String, PathBuf>,
|
||||
libraries: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
@@ -48,16 +46,8 @@ impl Reports {
|
||||
state.windows.entry(window.into()).or_default().focused = editor;
|
||||
if focused {
|
||||
state.focused = Some(window.into());
|
||||
state.focus_order.retain(|w| w != window);
|
||||
state.focus_order.push(window.into());
|
||||
}
|
||||
}
|
||||
/// Sorts `windows` by when they were last focused, the most recent first; windows
|
||||
/// never focused keep their order after them.
|
||||
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
|
||||
let order = &self.0.read().focus_order;
|
||||
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
|
||||
}
|
||||
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
|
||||
let mut state = self.0.write();
|
||||
let problems = &mut state.windows.entry(window.into()).or_default().problems;
|
||||
@@ -78,7 +68,6 @@ impl Reports {
|
||||
state.windows.remove(window);
|
||||
state.remote.remove(window);
|
||||
state.libraries.remove(window);
|
||||
state.focus_order.retain(|w| w != window);
|
||||
if state.focused.as_deref() == Some(window) {
|
||||
state.focused = None;
|
||||
}
|
||||
@@ -133,24 +122,3 @@ impl Reports {
|
||||
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_sort_by_their_last_focus() {
|
||||
let reports = Reports::default();
|
||||
for window in ["a", "b", "a", "c"] {
|
||||
reports.set_focused_editor(window, None, true);
|
||||
}
|
||||
reports.set_focused_editor("b", None, false);
|
||||
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["c", "a", "b", "never"]);
|
||||
reports.forget_window("c");
|
||||
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["a", "b", "never", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -181,17 +181,6 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
|
||||
Ok(host.project(window)?.agents.external_sessions())
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.import_session(&provider, &session_id)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
@@ -288,105 +277,6 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
}
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
let user = host.global_settings.effective().agents.mcp_servers;
|
||||
let project = host.project(window).ok().filter(|p| !p.light);
|
||||
let workspace = project
|
||||
.as_ref()
|
||||
.map(|p| p.settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers)
|
||||
.unwrap_or_default();
|
||||
(user, workspace, project)
|
||||
}
|
||||
|
||||
fn persist_mcp_map(
|
||||
host: &Host,
|
||||
project: Option<&std::sync::Arc<crate::Project>>,
|
||||
scope: McpScope,
|
||||
map: &McpServerMap,
|
||||
) -> IdeResult<()> {
|
||||
let value = if map.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
};
|
||||
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
|
||||
match scope {
|
||||
McpScope::User => {
|
||||
host.global_settings.set(ide_workspace::settings::SettingsScope::Global, "agents.mcpServers", value)?;
|
||||
for p in host.projects() {
|
||||
p.settings.reload();
|
||||
p.sink.emit(event.clone());
|
||||
}
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
let p = project.ok_or_else(|| IdeError::invalid("Open a project to configure workspace MCP servers"))?;
|
||||
p.settings.set(ide_workspace::settings::SettingsScope::Project, "agents.mcpServers", value)?;
|
||||
p.sink.emit(event);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
|
||||
where
|
||||
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
|
||||
{
|
||||
let (mut user, mut workspace, project) = mcp_maps(host, window);
|
||||
let prev_user = user.clone();
|
||||
let prev_workspace = workspace.clone();
|
||||
let project_root = project.as_ref().map(|p| p.root.as_path());
|
||||
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
|
||||
if workspace != prev_workspace {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
|
||||
}
|
||||
if user != prev_user {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
|
||||
}
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
|
||||
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
let (user, workspace, _) = mcp_maps(host, window);
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Creates or updates a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, project_root| {
|
||||
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables or disables an MCP server and returns the updated list.
|
||||
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, _| {
|
||||
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
|
||||
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |_, workspace, project_root| {
|
||||
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
|
||||
ide_agent::mcp::apply_import(workspace, root);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
@@ -398,21 +288,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("agent_registry_remove", |c| async move { registry_remove(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("agent_mcp_list", |c| async move { mcp_list(&c.host, &c.window) });
|
||||
d.add("agent_mcp_save", |c| async move { mcp_save(&c.host, &c.window, c.arg("input")?) });
|
||||
d.add(
|
||||
"agent_mcp_set_enabled",
|
||||
|c| async move { mcp_set_enabled(&c.host, &c.window, c.arg("id")?, c.arg("enabled")?) },
|
||||
);
|
||||
d.add("agent_mcp_remove", |c| async move { mcp_remove(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("agent_mcp_import", |c| async move { mcp_import(&c.host, &c.window) });
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
|
||||
d.add("agent_import_session", |c| async move {
|
||||
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
|
||||
});
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
});
|
||||
@@ -443,9 +321,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
|
||||
|
||||
@@ -538,35 +538,6 @@ pub async fn build_run(
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
// Fast path (#2476): an unchanged single-project Maven build skips
|
||||
// Maven entirely. Previous diagnostics stay: nothing changed, so they
|
||||
// are still accurate.
|
||||
let single = cmake.is_none() && ide_workspace::workspace::Workspace::load(&root)?.is_none();
|
||||
if single && kind == BuildKind::Build {
|
||||
let model = with_profiles(load(&root, &cache, &settings), &cache);
|
||||
if compile::menu_up_to_date(&cache, &root, &model, file.as_deref(), &token) {
|
||||
let started = std::time::Instant::now();
|
||||
let plan = prepare_plan(
|
||||
compile::plan(&root, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
|
||||
&root,
|
||||
&settings,
|
||||
);
|
||||
let _ = on_event.send(BuildEvent::Started { system: plan.system, title: plan.title.clone() });
|
||||
let _ = on_event.send(BuildEvent::Output {
|
||||
text: "Up to date: no changes, build skipped.\n".into(),
|
||||
stderr: false,
|
||||
});
|
||||
return Ok(BuildResult {
|
||||
system: plan.system,
|
||||
title: plan.title,
|
||||
success: true,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
messages: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
@@ -581,8 +552,7 @@ pub async fn build_run(
|
||||
let mut combined: Option<compile::BuildPlan> = None;
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache =
|
||||
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = prepare_plan(
|
||||
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
|
||||
@@ -613,51 +583,6 @@ pub async fn build_run(
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
if result.success && single && kind == BuildKind::Build {
|
||||
let model = with_profiles(load(&root, &cache, &settings), &cache);
|
||||
compile::record_menu_build(&cache, &root, &model, file.as_deref(), &token);
|
||||
}
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Build › Compile File: compiles one Java source of a linked Maven module with
|
||||
/// `javac`, streaming the console and messages on `on_event`; cancellable with
|
||||
/// `cancel_request(request)`. Diagnostics of `clear` (the files of the previous
|
||||
/// build) are cleared first; the new messages are published as `build`
|
||||
/// diagnostics.
|
||||
pub async fn build_compile_file(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
file: std::path::PathBuf,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::compile_file_plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), &file)?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
@@ -727,10 +652,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("build_compile_file", |c| async move {
|
||||
build_compile_file(&c.host, &c.window, c.arg("file")?, c.arg("request")?, c.arg("clear")?, c.sender("onEvent")?)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,18 +12,13 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
let window_root = root;
|
||||
Ok(match ide_workspace::workspace::Workspace::load(root)? {
|
||||
Some(workspace) => workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|root| Scope {
|
||||
cache: if root == window_root {
|
||||
cache.to_owned()
|
||||
} else {
|
||||
cache.join(crate::project::project_key(&root))
|
||||
},
|
||||
cache: cache.join(crate::project::project_key(&root)),
|
||||
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
|
||||
root,
|
||||
})
|
||||
@@ -41,12 +36,10 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
let mut combined = BuildModel::default();
|
||||
for scope in scopes {
|
||||
let mut model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
if scopes.len() == 1 && scope.root == root {
|
||||
if scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if scope.root != root
|
||||
&& let Some(maven) = &mut model.maven
|
||||
{
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let owner = ide_core::ipc_path::to_ipc(&scope.root);
|
||||
for p in &mut maven.projects {
|
||||
p.project_root = Some(owner.clone());
|
||||
@@ -80,7 +73,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
|
||||
for g in &mut model.gradle {
|
||||
g.dir = absolute(&scope.root, &g.dir);
|
||||
for p in &mut g.projects {
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
@@ -152,30 +145,6 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attached_primary_keeps_its_cache_and_combines_build_models() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("primary");
|
||||
let other = base.join("other");
|
||||
for path in [&root, &other] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
|
||||
}
|
||||
let cache = base.join("cache");
|
||||
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
|
||||
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
|
||||
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, cache);
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
|
||||
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
|
||||
let model = load_scopes(&root, &scopes);
|
||||
let projects = model.maven.unwrap().projects;
|
||||
assert_eq!(projects.len(), 2);
|
||||
assert_eq!(projects[0].pom, "pom.xml");
|
||||
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_models_use_member_settings_paths_and_caches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::path::PathBuf;
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(start_java_attach_listener(config, cfg!(windows)), cfg!(windows));
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
&& let Ok(project) = host.project(window)
|
||||
@@ -28,7 +28,6 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
|
||||
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
@@ -93,67 +92,11 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let server_port = debug_server_port(&config);
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
if let (Ok(id), Some(port)) = (&result, server_port) {
|
||||
attach_jvm_inspector(host, window, *id, port);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The port of the java-debug server this machine's jdtls started; `debugServer` as `host:port`
|
||||
/// names an adapter on another machine, whose jdtls is not ours to ask.
|
||||
fn debug_server_port(config: &LaunchConfig) -> Option<u16> {
|
||||
if config.adapter != "java-debug" {
|
||||
return None;
|
||||
}
|
||||
let extra: serde_json::Value = serde_json::from_str(config.extra_json.as_deref()?).ok()?;
|
||||
extra.get("debugServer")?.as_u64().and_then(|port| u16::try_from(port).ok()).filter(|port| *port != 0)
|
||||
}
|
||||
|
||||
/// Lets the session ask the jdtls that runs it about its JVM. Without a module (no jdtls, or a
|
||||
/// debug server it did not start) the session keeps the debugger-only memory view.
|
||||
fn attach_jvm_inspector(host: &Host, window: &str, session: DebugSessionId, port: u16) {
|
||||
let Ok(project) = host.project(window) else { return };
|
||||
let Some(module) = project.lsp.java_debug_server_module(port) else { return };
|
||||
let inspector = std::sync::Arc::new(ide_lsp::JdtJvmInspector::new(project.lsp.clone(), module));
|
||||
if let Err(e) = host.debugger.set_jvm_inspector(session, inspector) {
|
||||
tracing::debug!("jvm inspector for session {session:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The JVM memory tab reads the class table over the attach socket, which only exists once the
|
||||
/// JVM has started its attach listener. The flag goes first so the user's arguments still win.
|
||||
/// Not on Windows (`windows`: the JVM runs there), which the memory view does not support.
|
||||
fn start_java_attach_listener(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
|
||||
if windows || config.adapter != "java-debug" || config.request != "launch" {
|
||||
return config;
|
||||
}
|
||||
let Some(serde_json::Value::Object(mut extra)) =
|
||||
config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok())
|
||||
else {
|
||||
return config;
|
||||
};
|
||||
// java-debug also takes an array; rewriting one would drop the user's arguments.
|
||||
let vm_args = match extra.get("vmArgs") {
|
||||
None | Some(serde_json::Value::Null) => "",
|
||||
Some(serde_json::Value::String(args)) => args,
|
||||
Some(_) => return config,
|
||||
};
|
||||
if !ide_jvm_attach::wants_listener_flag(vm_args) {
|
||||
return config;
|
||||
}
|
||||
let vm_args = if vm_args.is_empty() {
|
||||
ide_jvm_attach::START_LISTENER_FLAG.to_string()
|
||||
} else {
|
||||
format!("{} {vm_args}", ide_jvm_attach::START_LISTENER_FLAG)
|
||||
};
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
config
|
||||
}
|
||||
|
||||
/// A JVM launched by java-debug writes what java-debug decodes its output as
|
||||
/// (see [`ide_core::output_encoding::java_debug_vm_args`]).
|
||||
fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
|
||||
@@ -408,14 +351,6 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// A loopback port that is free right now, for a debug agent the launch line starts
|
||||
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
|
||||
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
|
||||
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
@@ -437,51 +372,6 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
|
||||
host.debugger.redefine_classes(session).await
|
||||
}
|
||||
|
||||
/// The debuggee JVM's loaded classes, or why they cannot be read.
|
||||
pub async fn debug_jvm_memory_classes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<JvmMemoryResult> {
|
||||
host.require_trust(window)?;
|
||||
host.debugger.jvm_memory_classes(session).await
|
||||
}
|
||||
|
||||
/// Up to `limit` live instances of the class in the stopped JVM.
|
||||
pub async fn debug_jvm_memory_instances(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
session: DebugSessionId,
|
||||
class_name: String,
|
||||
limit: u32,
|
||||
) -> IdeResult<JvmInstancesResult> {
|
||||
host.require_trust(window)?;
|
||||
host.debugger.jvm_memory_instances(session, class_name, limit).await
|
||||
}
|
||||
|
||||
/// The fields (or array elements) of an instance listed by [`debug_jvm_memory_instances`].
|
||||
pub async fn debug_jvm_memory_fields(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
session: DebugSessionId,
|
||||
object: JvmObjectRef,
|
||||
) -> IdeResult<JvmFieldsResult> {
|
||||
host.require_trust(window)?;
|
||||
host.debugger.jvm_memory_fields(session, object).await
|
||||
}
|
||||
|
||||
/// The objects that hold a reference to `object`; `limit` 0 selects the default.
|
||||
pub async fn debug_jvm_memory_referrers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
session: DebugSessionId,
|
||||
object: JvmObjectRef,
|
||||
limit: u32,
|
||||
) -> IdeResult<JvmReferrersResult> {
|
||||
host.require_trust(window)?;
|
||||
host.debugger.jvm_memory_referrers(session, object, limit).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
@@ -602,7 +492,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
@@ -610,18 +499,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"debug_redefine_classes",
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add("debug_jvm_memory_classes", |c| async move {
|
||||
debug_jvm_memory_classes(&c.host, &c.window, c.arg("session")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_instances", |c| async move {
|
||||
debug_jvm_memory_instances(&c.host, &c.window, c.arg("session")?, c.arg("className")?, c.arg("limit")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_fields", |c| async move {
|
||||
debug_jvm_memory_fields(&c.host, &c.window, c.arg("session")?, c.arg("object")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_referrers", |c| async move {
|
||||
debug_jvm_memory_referrers(&c.host, &c.window, c.arg("session")?, c.arg("object")?, c.arg("limit")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -666,58 +543,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag as added for a JVM on macOS or Linux.
|
||||
fn on_unix(config: LaunchConfig) -> LaunchConfig {
|
||||
start_java_attach_listener(config, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_start_the_attach_listener() {
|
||||
let fixed = on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"})));
|
||||
assert_eq!(vm_args(&fixed), "-XX:+StartAttachListener -Xmx1g");
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({})))),
|
||||
"-XX:+StartAttachListener"
|
||||
);
|
||||
for extra in [
|
||||
serde_json::json!({"vmArgs": "-XX:-StartAttachListener"}),
|
||||
serde_json::json!({"vmArgs": "-XX:+DisableAttachMechanism"}),
|
||||
] {
|
||||
assert_eq!(vm_args(&on_unix(launch("java-debug", "launch", extra.clone()))), extra["vmArgs"]);
|
||||
}
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "attach", serde_json::json!({"vmArgs": "-Xmx1g"})))),
|
||||
"-Xmx1g"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_on_windows_get_no_attach_listener() {
|
||||
let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"}));
|
||||
assert_eq!(vm_args(&start_java_attach_listener(config, true)), "-Xmx1g");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_array_vm_args_are_left_alone() {
|
||||
let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": ["-Xmx1g", "-Dk=v"]}));
|
||||
assert_eq!(on_unix(config.clone()).extra_json, config.extra_json);
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": null})))),
|
||||
"-XX:+StartAttachListener"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_server_port_only_for_local_java_debug() {
|
||||
let port = |adapter: &str, request: &str, extra| debug_server_port(&launch(adapter, request, extra));
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 4711})), Some(4711));
|
||||
assert_eq!(port("java-debug", "attach", serde_json::json!({"debugServer": 4711})), Some(4711));
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": "10.0.0.5:4711"})), None);
|
||||
assert_eq!(port(ide_dap::kotlin::ADAPTER, "launch", serde_json::json!({"debugServer": 4711})), None);
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({})), None);
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 0})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
|
||||
Loaded 100 of 1748 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user