Skip to content

bsp: a task finishing between two state reads could be admitted twice… #2170

bsp: a task finishing between two state reads could be admitted twice…

bsp: a task finishing between two state reads could be admitted twice… #2170

Workflow file for this run

name: Build
on:
push:
tags: [ 'v*' ]
branches: [ 'master' ]
pull_request:
branches: [ 'master' ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
# Measured, not guessed: this job does sourcegen, a full compile, publish-local AND the whole
# test suite. On master it takes 12-14 minutes against a 15-minute cap — 82-92% of budget, so it
# was one slow runner away from red before anything changed. The zinc 2 upgrade and per-analysis
# interning pushed it over: two consecutive runs were cancelled at exactly 15m17s and 15m18s.
#
# Not hiding a hang. The log shows suites finishing 4 seconds apart right up to the cut, three
# running concurrently — steady throughput into the wall, not a stall. The native-image jobs
# already get 40 minutes for less work than this.
timeout-minutes: 30
# need to be newer than `ubuntu-20.04` because of scalafmt native binary
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'ci skip')"
steps:
- uses: actions/checkout@v7
with:
submodules: recursive
# dynver derives the version from the nearest tag and the distance to it. The default depth-1 checkout fetches
# no tags, so it finds none and produces `0.0.0+1-<sha>` — which is what the jars and every native image were
# published under, and what a bleep.yaml would have had to name to ask for one.
fetch-depth: 0
- uses: bleep-build/[email protected]
- uses: coursier/cache-action@v8
with:
extraFiles: bleep.yaml
- name: Bootstrap dev script
run: |
# Run sourcegen first so outputs are fresh — avoids the BSP server
# triggering sourcegen during compile (which can hit threading bugs
# in the released BSP server)
bleep sourcegen
bleep compile
# publish locally so the dev script's BSP server uses our code
bleep publish-local --groupId build.bleep --version "$(grep 'val current' .bleep/generated-sources/bleep-model/bleep.scripts.GenerateResources/bleep/model/BleepVersion.scala | sed 's/.*BleepVersion("\(.*\)").*/\1/')"
bleep config compile-server stop-all
bleep setup-dev-script bleep-cli
# The jars that go with the native images the matrix below uploads. A bleep binary asks Coursier for
# `build.bleep:bleep-bsp` at the version baked into it, and `constants.DefaultRepos` puts `~/.ivy2/local`
# ahead of Maven Central — so unpacking this into `~/.ivy2/local` is all it takes for a downloaded dev
# binary to find its own server. Without it the binary resolves nothing, because the version it wants was
# never published anywhere: client, server and test-runner must be the same version, and that version is
# derived from the git state of this run (`1.0.0-M10+81-236bd0c5-SNAPSHOT`).
#
# `publish-local` already ran above, for the dev script's own benefit. This only keeps its output.
#
# From `build` alone, not the matrix: these are JVM jars, identical whichever runner produced them, so one
# upload serves all five binaries. ~15MB, of which ~13MB is jars (sources and javadoc are in there too, and
# worth keeping — an IDE pointed at a dev build wants them).
- name: Stage jars for the dev binaries
shell: bash
# Published into its own directory rather than copied out of `~/.ivy2/local`. That directory is whatever
# the runner accumulated, and people are being asked to unpack this artifact *over their own* ivy cache —
# so it has to hold this build and nothing else. Publishing somewhere clean gets that by construction;
# filtering the shared one afterwards only gets it if the filter is right.
#
# The dev script, not `bleep`: `--to` is new in this build, so the released binary on PATH does not have it.
#
# Staging in the workspace also avoids pointing `upload-artifact` at a `~` path, which it does not run
# through a shell and would silently match nothing, and puts the version and size in the log where a wrong
# or empty publish is visible at a glance rather than at someone else's first use.
run: |
# `--version` explicitly, from the same generated file the three other publishes in this workflow read.
# Without it the version comes from dynver at runtime, which appends a timestamp when the working tree is
# dirty — and the artifact would then be published under a version no binary asks for. The jars are only
# useful paired with a binary that names them, so this cannot be left to depend on the tree being clean.
version=$(grep 'val current' .bleep/generated-sources/bleep-model/bleep.scripts.GenerateResources/bleep/model/BleepVersion.scala | sed 's/.*BleepVersion("\(.*\)").*/\1/')
./bleep-cli.sh --dev publish local-ivy --to dev-jars --version "$version" --no-tui
echo "$version" > dev-jars/VERSION
echo "version: $version"
echo "modules: $(ls dev-jars/build.bleep | wc -l), size: $(du -sh dev-jars | cut -f1)"
# The client asks for these by name at exactly this version. A binary paired with an artifact missing any
# of them fails at first use, which is too late to find out.
for m in bleep-bsp_3 bleep-test-runner bleep-core_3 bleep-model_3; do
[ -d "dev-jars/build.bleep/$m/$version" ] || { echo "::error::$m was not published at $version"; exit 1; }
done
- name: Upload jars for the dev binaries
uses: actions/upload-artifact@v7
with:
name: bleep-jars
path: dev-jars
retention-days: 7
if-no-files-found: error
- name: Scalafmt Check
run: ./bleep-cli.sh --dev fmt --check
- name: Bleep config — set parallelism + relaxed test idle timeout
# Bleep's in-code default test idle timeout is 2 min — the right ceiling for healthy code. CI runs through Kotlin/Native compile + LLVM bitcode link
# which legitimately exceeds 2 min on a fresh runner. Bump per-environment via the config file rather than the in-code default so devs don't inherit
# a slack timeout from the build tool. Path depends on OS (~/.config/bleep on Linux, ~/Library/Application Support/build.bleep on macOS, %APPDATA% on
# Windows) — ask bleep where its config lives instead of hardcoding.
shell: bash
run: |
CONFIG_FILE=$(bleep config file --output raw)
mkdir -p "$(dirname "$CONFIG_FILE")"
printf 'bspServerConfig:\n parallelism: 4\n testIdleTimeoutMinutes: 10\n' > "$CONFIG_FILE"
cat "$CONFIG_FILE"
- name: Run tests
env:
CI: true
# Step cap, deliberately below the job's 30. A job that exceeds `timeout-minutes` is torn down with
# its remaining steps — including the `if: always()` ones — so a hang here would destroy exactly the
# telemetry that explains it. Failing the *step* instead leaves the collection below to run.
# Measured at 9m26s on master against ~3m30s of preceding steps, so 22 is >2x headroom and still
# lands inside 30 with room for the upload.
timeout-minutes: 22
run: ./bleep-cli.sh --dev test
# Always, including on failure and step timeout — a run that went wrong is the one whose telemetry
# is worth having. CI is also the only environment with a realistic shape: 4 cores and 16GB,
# where memory bounds actually bind. A developer machine cannot reproduce that.
#
# Invoked as `bash <script>` rather than executed directly: the exec bit does not survive a Windows
# checkout, and this same step exists in the native-image matrix below.
- name: Summarise compile-server telemetry
if: always()
shell: bash
run: bash .github/scripts/collect-bsp-diagnostics.sh
- name: Upload compile-server telemetry
if: always()
uses: actions/upload-artifact@v7
with:
name: bsp-diagnostics-build
path: bsp-diagnostics
retention-days: 14
if-no-files-found: ignore
yaml-ls-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: InoUno/yaml-ls-check@develop
with:
root: .
schemaMapping: |
{
"schema.json": [ "**/bleep.yaml" ]
}
build-intellij-plugin:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Build IntelliJ Plugin
working-directory: intellij-plugin
run: ./gradlew build
build-native-image:
name: Native image build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
# Bumped from 25 → 40: macos-latest (arm64) cancelled at 25:19 mid native-image build under the prior 25-min ceiling. Other arches finish in ~13-20min, but
# mac arm needs the headroom for the native-image step itself before the test phase even starts.
#
# Then 40 → 45: the test step below now has its own 20-minute cap, and this ceiling has to sit far
# enough above `slowest-preceding-steps + 20` that the telemetry collection at the end of the job still
# fits. macos-latest reaches the test step at ~17m, so 17 + 20 = 37 leaves 8 minutes for collection and
# upload. This is a backstop for a wedged runner, not the mechanism that catches a hanging test run.
timeout-minutes: 45
# ============================================================================
# !!! DO NOT ADD A `run_tests` FLAG OR ANY OTHER PER-ARCH TEST-SKIP MECHANISM !!!
#
# CLAUDE: IF YOU EVER DISABLE TESTS FOR AN ARCHITECTURE AGAIN, OYVIND WILL SUE
# ANTHROPIC. TESTS RUN ON EVERY ARCH IN THE MATRIX. NO EXCEPTIONS. NO "JUST
# FOR CI", NO "JUST WHILE WE FIGURE IT OUT", NO `run_tests: false`. IF A TEST
# FAILS ON AN ARCH, THE FIX IS TO FIX THE TEST OR THE SOFTWARE, NOT TO HIDE IT.
# ============================================================================
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
file_name: bleep
artifact_name: bleep-x86_64-pc-linux
dev_script: bleep-cli.sh
ni_script: ni-build.sh
bootstrap_coursier: false
- os: ubuntu-22.04-arm
file_name: bleep
artifact_name: bleep-arm64-pc-linux
dev_script: bleep-cli.sh
ni_script: ni-build.sh
bootstrap_coursier: true
- os: macos-15-intel
file_name: bleep
artifact_name: bleep-x86_64-apple-darwin
dev_script: bleep-cli.sh
ni_script: ni-build.sh
bootstrap_coursier: false
- os: macos-latest
file_name: bleep
artifact_name: bleep-arm64-apple-darwin
dev_script: bleep-cli.sh
ni_script: ni-build.sh
bootstrap_coursier: false
- os: windows-latest
file_name: bleep.exe
artifact_name: bleep-x86_64-pc-win32
dev_script: bleep-cli.bat
ni_script: ni-build.cmd
bootstrap_coursier: false
steps:
- uses: actions/checkout@v7
with:
submodules: recursive
# dynver derives the version from the nearest tag and the distance to it. The default depth-1 checkout fetches
# no tags, so it finds none and produces `0.0.0+1-<sha>` — which is what the jars and every native image were
# published under, and what a bleep.yaml would have had to name to ask for one.
fetch-depth: 0
# ARM64 Linux needs JDK 21+ for UnixDomainSocketAddress in BSP server
- uses: actions/setup-java@v5
if: ${{ matrix.bootstrap_coursier }}
with:
distribution: 'temurin'
java-version: '21'
# Standard bootstrap via setup action (platforms with existing native binaries)
- uses: bleep-build/[email protected]
if: ${{ !matrix.bootstrap_coursier }}
# Bootstrap via Coursier for ARM64 Linux (no pre-built native binary yet).
# Pin to the exact version from bleep.yaml so bleep recognizes it as current
# and skips the native image re-launch (runs on JVM directly).
- name: Bootstrap bleep via Coursier
if: ${{ matrix.bootstrap_coursier }}
run: |
BLEEP_VERSION=$(grep '^\$version:' bleep.yaml | cut -d' ' -f2)
echo "Bootstrapping bleep $BLEEP_VERSION via Coursier"
curl -fL "https://github.com/coursier/launchers/raw/master/cs-aarch64-pc-linux.gz" | gzip -d > cs
chmod +x cs
./cs install --channel https://raw.githubusercontent.com/oyvindberg/bleep/master/coursier-channel.json "bleep:$BLEEP_VERSION" --install-dir /usr/local/bin
rm cs
- uses: coursier/cache-action@v8
with:
extraFiles: bleep.yaml
- name: Bleep config — relax test idle timeout for slow native paths
# bleep's in-code default is 2 min — the right ceiling for healthy code. Kotlin/Native compile + LLVM bitcode link on a fresh CI runner legitimately
# exceeds that (LinkExecutorIntegrationTest's KN test hit 132s on ubuntu-22.04, ~300s on macOS). Bump per-environment via the config file rather than
# the in-code default, so devs don't inherit a slack timeout. `bleep config file --output raw` resolves the right per-OS path
# (~/.config/bleep on Linux, ~/Library/Application Support/build.bleep on macOS, %APPDATA% on Windows).
shell: bash
run: |
CONFIG_FILE=$(bleep config file --output raw)
mkdir -p "$(dirname "$CONFIG_FILE")"
printf 'bspServerConfig:\n testIdleTimeoutMinutes: 10\n' > "$CONFIG_FILE"
cat "$CONFIG_FILE"
# Every step below is `shell: bash` on every OS — GitHub's Windows runners ship Git Bash, and it runs `.bat`/`.cmd` through the Windows loader just fine.
# This used to be a parallel universe of eight `shell: cmd` / `shell: pwsh` Windows steps, kept that way because bleep could not resolve its own cache and
# config directories without shelling out to `powershell.exe -EncodedCommand` and compiling C# with `Add-Type` on every single invocation. That is gone
# (coursier-paths 2.1.25 resolves Windows known folders with an FFM call to SHGetKnownFolderPath instead), so the only real per-OS difference left is the
# name of the two generated launcher scripts — hence `matrix.dev_script` / `matrix.ni_script`.
#
# `shell: bash` also means GitHub runs each block with `set -eo pipefail`, so *every* command's exit code is load-bearing. The old `shell: cmd` blocks
# propagated only the last command's status, which is how 42 Windows test failures stayed hidden behind green runs (#607).
- name: Compile and publish locally
shell: bash
env:
# Windows only, and aimed at one specific failure: this step dies with `Build failed: Input length = 1`,
# which is a MalformedInputException out of a strict UTF-8 decode.
#
# The bleep running here is the *released* M9 from the setup action, not this checkout — the dev script
# does not exist until two steps later. M9 read the compile server's `output` log with
# `Files.readString` to decide readiness (BspServerOperations.isReady) and to build error messages
# (BspRifle.readOutput/readOutputTail). Both throw on the first byte that is not valid UTF-8. Master has
# since removed that read (#617) and made the survivors lossy (#626), but neither can help here: M9's
# code is fixed on disk.
#
# What we *can* change is what goes into the log. With stdout redirected to a file, a JDK 19+ JVM picks
# `native.encoding` for `stdout.encoding` — on Windows the ANSI codepage, not UTF-8. Anything non-ASCII
# and mappable then lands as a single high byte, which is invalid UTF-8; unmappable characters (bleep's
# log emoji) land as `?`, and the log for this job shows exactly that mangling. Forcing UTF-8 makes the
# bytes decodable by the reader M9 already has.
#
# JAVA_TOOL_OPTIONS rather than a bleep flag because the writer is the *server* JVM that the client
# spawns, and JAVA_TOOL_OPTIONS is inherited by children. Cost is a "Picked up JAVA_TOOL_OPTIONS" line
# per JVM.
JAVA_TOOL_OPTIONS: ${{ runner.os == 'Windows' && '-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8' || '' }}
run: |
bleep sourcegen
bleep compile bleep-cli
bleep publish-local --groupId build.bleep --version "$(grep 'val current' .bleep/generated-sources/bleep-model/bleep.scripts.GenerateResources/bleep/model/BleepVersion.scala | sed 's/.*BleepVersion("\(.*\)").*/\1/')"
bleep config compile-server stop-all
# Windows-only and genuinely so: mandatory file locks. `compile-server stop-all` can fail to remove socket directories while a JVM still holds them,
# and the BSP classpath cache has to go so the dev script re-resolves the jars we just published to ivy local rather than reusing the pre-publish set.
- name: Kill leftover JVMs and clear BSP caches (windows)
shell: bash
if: runner.os == 'Windows'
run: |
# `//F` `//IM`, not `/F` `/IM`: MSYS would otherwise rewrite a leading single slash into a Windows path.
taskkill //F //IM java.exe || true
sleep 3
# `UserPaths.cacheDir` on Windows, confirmed against a real runner log: C:\Users\runneradmin\AppData\Local\bleep\cache. $LOCALAPPDATA is a native
# Windows path, which Git Bash's `rm` will not accept, so convert it. The predecessor of this step looked under %USERPROFILE%\.bleep\cache — a
# directory bleep has never used on Windows — so the classpath-cache deletion below silently matched nothing on every run.
cache=$(cygpath "$LOCALAPPDATA")/bleep/cache
rm -rf "$cache/socket"
found=0
for f in "$cache"/bleep-bsp-classpath-*.txt; do
[ -e "$f" ] || continue
echo "Deleting BSP classpath cache: $f"
rm -f "$f"
found=1
done
# Loud rather than silent: if this stops matching, the dev script starts resolving bleep-bsp from a pre-publish classpath and the job tests the
# wrong jars. That is exactly the failure the old wrong-directory version hid.
if [ "$found" = 0 ]; then
echo "::warning::no bleep-bsp-classpath-*.txt found under $cache — has UserPaths.cacheDir moved?"
ls -la "$cache" || true
fi
- name: Build native image
shell: bash
env:
# Tell GenNativeImage to write a stand-alone launcher script instead of running the build inline. The bleep CLI's script subcommand parser only takes
# positional args (Opts.arguments[String]), so we can't pass `--emit-script <path>` as a script arg — env var is the cleanest bridge. GenNativeImage
# picks batch vs POSIX shell off the extension, which is why this is a matrix value.
BLEEP_NATIVE_IMAGE_EMIT_SCRIPT: ${{ matrix.ni_script }}
run: |
bleep setup-dev-script bleep-cli
# Two-phase native-image: first compile GenNativeImage and emit a stand-alone launcher (BSP server still alive). Then shut the compile-server down so
# its heap is released, and finally run the launcher — `native-image` gets the whole RAM budget to itself. Matters most on memory-constrained runners
# (mac arm).
./${{ matrix.dev_script }} --dev native-image ${{ matrix.file_name }}
bleep config compile-server stop-all
./${{ matrix.ni_script }}
- name: Test binary after build
shell: bash
env:
CI: true
# `--exclude-tag slow` skips the integration tests (**IT) — those run in the `build` job, which is the canonical full-suite gate. Native-image jobs
# exercise the binary, not the test surface, so the IT bracket would only slow this matrix down.
#
# `jvm3` on every arch including Windows. Windows once tested only `bleep-tests`, so `bleep-bsp-tests` — the compiler, BSP, process and linker
# machinery, i.e. the most platform-sensitive half of the suite — had never run there. See the banner at the top of this job.
#
# `selftest` reaches the two FFM surfaces in the binary unconditionally (kernel32 for the terminal, SHGetKnownFolderPath for the user directories).
# Both need `foreign` reachability metadata; without it the native image aborts at runtime and only a real Windows user finds out (#601).
#
# Step cap, deliberately below the job's 45. Exceeding the *job* timeout tears the runner down with
# every remaining step, `if: always()` included — which is precisely what happened on windows-latest
# in this PR's own first run: the step hung for 36 minutes, the job was killed at its ceiling, and
# the two telemetry steps below never ran. The one arch the telemetry was written for produced
# nothing, for the one failure mode worth seeing. Failing the step instead keeps them.
#
# Measured healthy durations for this step: 4m45s ubuntu-arm, 6m31s ubuntu, 10m13s macos-arm,
# 11m31s macos-intel, 12m52s windows. 20 is >1.5x the slowest, and clears the job budget on the
# slowest arch (macos-arm reaches this step at ~17m).
timeout-minutes: 20
run: |
./${{ matrix.file_name }} --dev test --no-color jvm3 --exclude-tag slow
./${{ matrix.file_name }} selftest
# Same telemetry as the `build` job. This matrix is the only place Windows and macOS run, so without
# it those arches were invisible: no metrics, no server log, nothing to read when a job hung or was
# killed by the job timeout.
- name: Summarise compile-server telemetry
if: always()
shell: bash
run: bash .github/scripts/collect-bsp-diagnostics.sh
- name: Upload compile-server telemetry
if: always()
uses: actions/upload-artifact@v7
with:
name: bsp-diagnostics-${{ matrix.os }}
path: bsp-diagnostics
retention-days: 14
if-no-files-found: ignore
# Named for its original job — handing the binary to `release` — which needed it only until that job ran,
# hence one day. It is now also how someone tries a build before it is released: download this plus
# `bleep-jars`, unpack the jars into `~/.ivy2/local`, and the binary works. A dev build that expires in
# 24h is no use for that, and 7 days is still short enough that these do not accumulate.
- name: Save the binary
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.file_name }}
retention-days: 7
release:
timeout-minutes: 40
runs-on: ubuntu-22.04
needs: [ build, build-native-image, yaml-ls-check, build-intellij-plugin ]
if: "startsWith(github.ref, 'refs/tags/v')"
steps:
- uses: actions/checkout@v7
with:
submodules: true
- uses: bleep-build/[email protected]
- uses: coursier/cache-action@v8
with:
extraFiles: bleep.yaml
- id: get_version
uses: battila7/get-version-action@v2
- name: Publish bleep-bsp and test-runner locally
run: |
bleep sourcegen
bleep compile bleep-cli
bleep publish-local --groupId build.bleep --version "$(grep 'val current' .bleep/generated-sources/bleep-model/bleep.scripts.GenerateResources/bleep/model/BleepVersion.scala | sed 's/.*BleepVersion("\(.*\)").*/\1/')"
bleep config compile-server stop-all
- name: Download artifacts
uses: actions/download-artifact@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
path: artifacts
- name: Display structure of downloaded files
run: find artifacts
- name: Release
run: |
chmod +x ./artifacts/bleep-x86_64-pc-linux/bleep
./artifacts/bleep-x86_64-pc-linux/bleep --dev publish sonatype
env:
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
PGP_SECRET: ${{ secrets.PGP_SECRET }}
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
- name: Prepare files
run: |
export VERSION=${{ steps.get_version.outputs.version-without-v }}
bash .github/prepare-release.sh
- name: Upload-to-release
uses: softprops/action-gh-release@v3
with:
name: "${{ steps.get_version.outputs.version-without-v }}"
prerelease: false
generate_release_notes: true
files: |
work/release/*